Skip to content

Commit af760a2

Browse files
committed
refactor: re-organize component, file, types, props
1 parent d594c77 commit af760a2

File tree

10 files changed

+241
-202
lines changed

10 files changed

+241
-202
lines changed

__tests__/ProportionSlider.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ describe("ProportionSlider", () => {
1616
onChange={() => {}}
1717
proportions={[
1818
{
19-
name: "A",
19+
label: "A",
2020
},
2121
{
22-
name: "B",
22+
label: "B",
2323
},
2424
]}
2525
/>
@@ -32,12 +32,12 @@ describe("ProportionSlider", () => {
3232
<ProportionSlider
3333
value={[50, 50]}
3434
onChange={() => {}}
35-
proportions={[{ name: "Left Side" }, { name: "Right Side" }]}
35+
proportions={[{ label: "Left Side" }, { label: "Right Side" }]}
3636
/>
3737
);
3838

39-
expect(screen.getByText("Left Side")).toBeDefined();
40-
expect(screen.getByText("Right Side")).toBeDefined();
39+
expect(screen.getAllByText("Left Side")).toHaveLength(2);
40+
expect(screen.getAllByText("Right Side")).toHaveLength(2);
4141
});
4242

4343
it("calls onChange when slider knob is moved", async () => {
@@ -48,7 +48,7 @@ describe("ProportionSlider", () => {
4848
<ProportionSlider
4949
value={[50, 50]}
5050
onChange={mockOnChange}
51-
proportions={[{ name: "A" }, { name: "B" }]}
51+
proportions={[{ label: "A" }, { label: "B" }]}
5252
/>
5353
);
5454

@@ -74,7 +74,7 @@ describe("ProportionSlider", () => {
7474
<ProportionSlider
7575
value={[50, 50]}
7676
onChange={mockOnChange}
77-
proportions={[{ name: "A" }, { name: "B" }]}
77+
proportions={[{ label: "A" }, { label: "B" }]}
7878
/>
7979
);
8080

dev/App.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react";
22
import "./App.css";
3-
import { ProportionSlider } from "../src/components/ProportionSlider";
3+
import { ProportionSlider } from "../src";
44

55
function App() {
66
const [proportions, setProportions] = React.useState<[number, number]>([
@@ -21,26 +21,23 @@ function App() {
2121
value={proportions}
2222
proportions={[
2323
{
24-
name: "Skill",
24+
label: "Skill",
2525
backgroundColor: "#31332E",
2626
},
2727
{
28-
name: "3.7 Sonnet",
28+
label: "3.7 Sonnet",
2929
backgroundColor: "#5f625C",
3030
},
3131
]}
3232
onChange={(change) => {
3333
setProportions(change);
3434
}}
35-
sliderOptions={{
35+
knobOptions={{
3636
width: 5,
3737
gap: 5,
3838
backgroundColor: "#EC1308",
3939
}}
40-
options={{
41-
height: 50,
42-
displayValueType: "percentage",
43-
}}
40+
height={50}
4441
/>
4542
</div>
4643
);

src/ProportionSlider.tsx

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { useCallback, useRef, CSSProperties } from "react";
2+
import { Proportion, SliderKnob } from "./components";
3+
import { ProportionDetail, SliderKnobOptions } from "./components/types";
4+
5+
export type ProportionSliderProps = {
6+
/**
7+
* Current values of the two proportions [left, right]
8+
* These should be positive numbers
9+
*/
10+
value: [number, number];
11+
12+
/**
13+
* Details for the two proportions [left, right]
14+
*/
15+
proportions: [ProportionDetail, ProportionDetail];
16+
17+
/**
18+
* Callback when values change
19+
* @param values The new values [left, right]
20+
*/
21+
onChange?: (values: [number, number]) => void;
22+
23+
/**
24+
* Appearance of the slider knob
25+
*/
26+
knobOptions?: SliderKnobOptions;
27+
28+
/** Height of the slider in pixels */
29+
height?: number;
30+
/** Whether the slider is disabled */
31+
disabled?: boolean;
32+
/** Custom class name for the slider container */
33+
className?: string;
34+
/** Custom styles for the slider container */
35+
style?: CSSProperties;
36+
37+
/**
38+
* Accessibility options
39+
*/
40+
ariaLabel?: string;
41+
};
42+
43+
export const ProportionSlider = ({
44+
value,
45+
proportions,
46+
onChange,
47+
knobOptions,
48+
disabled,
49+
height,
50+
ariaLabel,
51+
className,
52+
style,
53+
}: ProportionSliderProps) => {
54+
const mergedKnobOptions = getMergeKnobOptions(knobOptions);
55+
56+
const refWidth = useRef<number | null>(null);
57+
const total = value[0] + value[1];
58+
59+
const refStartX = useRef<number | null>(null);
60+
const refValue1Start = useRef<number | null>(null);
61+
const refValue2Start = useRef<number | null>(null);
62+
const sliderWidth = mergedKnobOptions.width + mergedKnobOptions.gap * 2;
63+
const onDragStart = useCallback(
64+
(px: number): void => {
65+
refStartX.current = px;
66+
refValue1Start.current = value[0];
67+
refValue2Start.current = value[1];
68+
},
69+
[value]
70+
);
71+
const onDragEnd = useCallback(() => {
72+
refStartX.current = null;
73+
refValue1Start.current = null;
74+
refValue2Start.current = null;
75+
}, []);
76+
const onDrag = useCallback(
77+
(px: number): void => {
78+
if (refStartX.current === null) return;
79+
const diffPx = px - refStartX.current;
80+
const totalWidthPx = refWidth.current! - sliderWidth;
81+
const total = refValue1Start.current! + refValue2Start.current!;
82+
let newValue1 = refValue1Start.current! + (diffPx / totalWidthPx) * total;
83+
newValue1 = Math.max(0, Math.min(total, newValue1));
84+
onChange?.([newValue1, total - newValue1]);
85+
},
86+
[onChange, sliderWidth]
87+
);
88+
return (
89+
<div
90+
ref={(el) => {
91+
if (el) {
92+
refWidth.current = el.getBoundingClientRect().width;
93+
}
94+
}}
95+
role="slider"
96+
aria-label={ariaLabel}
97+
aria-valuenow={Math.round((value[0] / total) * 100)}
98+
style={{
99+
display: "flex",
100+
flexDirection: "row",
101+
height,
102+
opacity: disabled ? 0.6 : 1,
103+
cursor: disabled ? "not-allowed" : "pointer",
104+
...style,
105+
}}
106+
className={className}
107+
>
108+
<Proportion
109+
detail={proportions[0]}
110+
valueLabel={`${Math.round((value[0] * 100) / total)}%`}
111+
width={`calc(${(value[0] * 100) / total}% - ${sliderWidth / 2}px)`}
112+
anchor="left"
113+
/>
114+
<SliderKnob
115+
onDragStart={disabled ? undefined : onDragStart}
116+
onDrag={disabled ? undefined : onDrag}
117+
onDragEnd={disabled ? undefined : onDragEnd}
118+
{...mergedKnobOptions}
119+
/>
120+
<Proportion
121+
detail={proportions[1]}
122+
valueLabel={`${Math.round((value[1] * 100) / total)}%`}
123+
width={`calc(${(value[1] * 100) / total}% - ${sliderWidth / 2}px)`}
124+
anchor="right"
125+
/>
126+
</div>
127+
);
128+
};
129+
130+
const DefaultKnobOptions: Required<SliderKnobOptions> = {
131+
width: 5,
132+
gap: 2,
133+
backgroundColor: "red",
134+
className: "slider-knob",
135+
style: {},
136+
};
137+
138+
function getMergeKnobOptions(
139+
knobOptions: SliderKnobOptions | undefined = {}
140+
): Required<SliderKnobOptions> {
141+
return {
142+
width: knobOptions.width ?? DefaultKnobOptions.width,
143+
gap: knobOptions.gap ?? DefaultKnobOptions.gap,
144+
backgroundColor:
145+
knobOptions.backgroundColor ?? DefaultKnobOptions.backgroundColor,
146+
className: knobOptions.className ?? DefaultKnobOptions.className,
147+
style: knobOptions.style ?? DefaultKnobOptions.style,
148+
};
149+
}

src/components/DynamicChildPositioner.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,20 @@ import React, {
99
type DynamicChildPositionerProps = PropsWithChildren<{
1010
rightNode: React.ReactNode;
1111
leftNode: React.ReactNode;
12-
options: {
13-
primary: "left" | "right";
14-
};
12+
primaryNode: "left" | "right";
1513
backgroundColor?: string;
1614
width: number | string;
15+
ariaLabel?: string;
1716
}>;
1817

1918
export const DynamicChildPositioner = ({
2019
rightNode,
2120
leftNode,
22-
options: { primary },
21+
primaryNode,
2322
width,
2423
backgroundColor = "gray",
2524
children,
25+
ariaLabel,
2626
}: DynamicChildPositionerProps) => {
2727
const ref = useRef<HTMLDivElement | null>(null);
2828
const refRight = useRef<HTMLDivElement | null>(null);
@@ -33,23 +33,25 @@ export const DynamicChildPositioner = ({
3333

3434
const rightStyle = useMemo(() => {
3535
const rightNoSpace =
36-
fitStatus === "none" || (fitStatus === "primary" && primary === "left");
37-
if (primary === "right") {
36+
fitStatus === "none" ||
37+
(fitStatus === "primary" && primaryNode === "left");
38+
if (primaryNode === "right") {
3839
return rightNoSpace ? STYLES["BOTTOM_RIGHT"] : STYLES["RIGHT"];
3940
} else {
4041
return rightNoSpace ? STYLES["TOP_LEFT"] : STYLES["RIGHT"];
4142
}
42-
}, [fitStatus, primary]);
43+
}, [fitStatus, primaryNode]);
4344

4445
const leftStyle = useMemo(() => {
4546
const leftNoSpace =
46-
fitStatus === "none" || (fitStatus === "primary" && primary === "right");
47-
if (primary === "left") {
47+
fitStatus === "none" ||
48+
(fitStatus === "primary" && primaryNode === "right");
49+
if (primaryNode === "left") {
4850
return leftNoSpace ? STYLES["BOTTOM_LEFT"] : STYLES["LEFT"];
4951
} else {
5052
return leftNoSpace ? STYLES["TOP_RIGHT"] : STYLES["LEFT"];
5153
}
52-
}, [fitStatus, primary]);
54+
}, [fitStatus, primaryNode]);
5355

5456
useEffect(() => {
5557
const interval = setInterval(() => {
@@ -64,7 +66,7 @@ export const DynamicChildPositioner = ({
6466
const leftWidth = textLeft.getBoundingClientRect().width;
6567

6668
const { primaryWidth, secondaryWidth } =
67-
primary === "left"
69+
primaryNode === "left"
6870
? { primaryWidth: leftWidth, secondaryWidth: rightWidth }
6971
: { primaryWidth: rightWidth, secondaryWidth: leftWidth };
7072

@@ -81,11 +83,12 @@ export const DynamicChildPositioner = ({
8183
setFitStatus(fitStatus);
8284
}, 1000 / 30);
8385
return () => clearInterval(interval);
84-
}, [ref, refRight, refLeft, primary]);
86+
}, [ref, refRight, refLeft, primaryNode]);
8587

8688
return (
8789
<div
8890
ref={ref}
91+
aria-label={ariaLabel}
8992
style={{
9093
position: "relative",
9194
width,

0 commit comments

Comments
 (0)