Skip to content

Commit 536b318

Browse files
authored
Release: 0.9.3 Mobile Support, Optional Parameters, Refactor
Release: 0.9.3 Mobile Support, Optional Parameters
2 parents 4f64cb6 + 29c1340 commit 536b318

17 files changed

+377
-259
lines changed
Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,43 @@
11
import React from "react";
2-
import { describe, it, expect, vi, beforeEach } from "vitest";
3-
import { render, screen, fireEvent } from "@testing-library/react";
2+
import {
3+
describe,
4+
it,
5+
expect,
6+
vi,
7+
beforeEach,
8+
afterAll,
9+
beforeAll,
10+
} from "vitest";
11+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
412
import { ProportionSlider } from "../src";
513

614
describe("ProportionSlider", () => {
15+
const boundingClientRect = {
16+
width: 100,
17+
height: 100,
18+
top: 0,
19+
right: 100,
20+
bottom: 100,
21+
left: 0,
22+
x: 0,
23+
y: 0,
24+
toJSON: () => {},
25+
};
26+
27+
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
28+
29+
beforeAll(() => {
30+
Element.prototype.getBoundingClientRect = () => boundingClientRect;
31+
});
32+
33+
afterAll(() => {
34+
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
35+
});
36+
737
beforeEach(() => {
838
// Reset mocks between tests
939
vi.clearAllMocks();
40+
cleanup();
1041
});
1142

1243
it("renders without crashing", () => {
@@ -16,10 +47,10 @@ describe("ProportionSlider", () => {
1647
onChange={() => {}}
1748
proportions={[
1849
{
19-
name: "A",
50+
label: "A",
2051
},
2152
{
22-
name: "B",
53+
label: "B",
2354
},
2455
]}
2556
/>
@@ -32,12 +63,12 @@ describe("ProportionSlider", () => {
3263
<ProportionSlider
3364
value={[50, 50]}
3465
onChange={() => {}}
35-
proportions={[{ name: "Left Side" }, { name: "Right Side" }]}
66+
proportions={[{ label: "Left Side" }, { label: "Right Side" }]}
3667
/>
3768
);
3869

39-
expect(screen.getByText("Left Side")).toBeDefined();
40-
expect(screen.getByText("Right Side")).toBeDefined();
70+
expect(screen.getAllByText("Left Side")).toHaveLength(2);
71+
expect(screen.getAllByText("Right Side")).toHaveLength(2);
4172
});
4273

4374
it("calls onChange when slider knob is moved", async () => {
@@ -48,21 +79,48 @@ describe("ProportionSlider", () => {
4879
<ProportionSlider
4980
value={[50, 50]}
5081
onChange={mockOnChange}
51-
proportions={[{ name: "A" }, { name: "B" }]}
82+
proportions={[{ label: "A" }, { label: "B" }]}
5283
/>
5384
);
5485

5586
const slider = screen.getByRole("slider");
56-
const knob = slider.querySelector("div[role='button']");
57-
expect(knob).toBeDefined();
87+
expect(slider).toBeDefined();
88+
89+
expect(slider.getBoundingClientRect().width).toBe(100); // mocked
5890

5991
// Simulate drag start
60-
fireEvent.mouseDown(knob as HTMLElement);
92+
fireEvent.mouseDown(slider as HTMLElement, { clientX: 50 });
6193
// Simulate drag movement
62-
fireEvent.mouseMove(window, { clientX: 100 });
94+
fireEvent.mouseMove(window, { clientX: 45 });
6395
// Simulate drag end
6496
fireEvent.mouseUp(window);
6597

6698
expect(mockOnChange).toHaveBeenCalled();
6799
});
100+
101+
it("works on mobile", async () => {
102+
const mockOnChange = vi.fn();
103+
// userEvent.setup();
104+
105+
render(
106+
<ProportionSlider
107+
value={[50, 50]}
108+
onChange={mockOnChange}
109+
proportions={[{ label: "A" }, { label: "B" }]}
110+
/>
111+
);
112+
113+
const slider = screen.getByRole("slider");
114+
expect(slider).toBeDefined();
115+
116+
expect(slider.getBoundingClientRect().width).toBe(100); // mocked
117+
118+
// Simulate drag start
119+
fireEvent.touchStart(slider as HTMLElement, { touches: [{ clientX: 50 }] });
120+
// Simulate drag movement
121+
fireEvent.touchMove(window, { touches: [{ clientX: 45 }] });
122+
// Simulate drag end
123+
fireEvent.touchEnd(window);
124+
expect(mockOnChange).toHaveBeenCalled();
125+
});
68126
});

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
);

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "react-proportion-slider",
33
"private": false,
4-
"version": "0.9.2",
4+
"version": "0.9.3",
55
"type": "module",
66
"files": [
77
"dist"
@@ -21,7 +21,8 @@
2121
"build": "tsc -b && vite build",
2222
"lint": "eslint .",
2323
"preview": "vite preview",
24-
"test": "vitest"
24+
"test": "vitest",
25+
"test:watch": "vitest --watch"
2526
},
2627
"dependencies": {
2728
"react": ">=16.8.0",

src/ProportionSlider.tsx

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { useCallback, useRef, CSSProperties, useEffect } from "react";
2+
import { DynamicChildPositioner, SliderKnob } from "./components";
3+
import { ProportionDetail, SliderKnobOptions } from "./components/types";
4+
import { EventType, getClientX, clamp } from "./utilities";
5+
6+
export type ProportionSliderProps = {
7+
/**
8+
* Current values of the two proportions [left, right]
9+
* These should be positive numbers
10+
*/
11+
value: [number, number];
12+
13+
/**
14+
* Details for the two proportions [left, right]
15+
*/
16+
proportions: [ProportionDetail, ProportionDetail];
17+
18+
/**
19+
* Callback when values change
20+
* @param values The new values [left, right]
21+
*/
22+
onChange?: (values: [number, number]) => void;
23+
24+
/**
25+
* Appearance of the slider knob
26+
*/
27+
knobOptions?: SliderKnobOptions;
28+
29+
/** Height of the slider in pixels */
30+
height?: number;
31+
/** Width of the slider in pixels */
32+
width?: number;
33+
/** Whether the slider is disabled */
34+
disabled?: boolean;
35+
/** Custom class name for the slider container */
36+
className?: string;
37+
/** Custom styles for the slider container */
38+
style?: CSSProperties;
39+
40+
/**
41+
* Accessibility options
42+
*/
43+
ariaLabel?: string;
44+
};
45+
46+
export const ProportionSlider = ({
47+
value,
48+
proportions,
49+
onChange,
50+
knobOptions,
51+
disabled,
52+
height,
53+
ariaLabel,
54+
className,
55+
style,
56+
width,
57+
}: ProportionSliderProps) => {
58+
const mergedKnobOptions = getMergeKnobOptions(knobOptions);
59+
60+
const ref = useRef<HTMLDivElement | null>(null);
61+
const total = value[0] + value[1];
62+
const sliderWidth = mergedKnobOptions.width + mergedKnobOptions.gap * 2;
63+
const refTotal = useRef<number>(total);
64+
const refSliderWidth = useRef<number>(sliderWidth);
65+
refTotal.current = total;
66+
refSliderWidth.current = sliderWidth;
67+
const refIsDragging = useRef<boolean>(false);
68+
69+
const onChangeValueFromEvent = useCallback(
70+
(e: EventType, { width, left }: { width: number; left: number }) => {
71+
const x = getClientX(e);
72+
const knobWidth = refSliderWidth.current;
73+
const factor = (x - left - knobWidth / 2) / (width - knobWidth);
74+
const total = refTotal.current;
75+
const value1 = clamp(total * factor, 0, total);
76+
onChange?.([value1, total - value1]);
77+
},
78+
[onChange]
79+
);
80+
81+
const onDown = useCallback(
82+
(e: EventType) => {
83+
const target = e.target as HTMLElement;
84+
const rect = ref.current?.getBoundingClientRect();
85+
if (
86+
(ref.current && !ref.current.contains(target)) ||
87+
!rect ||
88+
rect.width === 0
89+
) {
90+
console.log("returning because of rect width", rect?.width);
91+
return;
92+
}
93+
e.preventDefault();
94+
refIsDragging.current = true;
95+
onChangeValueFromEvent(e, rect);
96+
return true;
97+
},
98+
[onChangeValueFromEvent]
99+
);
100+
101+
const onMove = useCallback(
102+
(e: EventType) => {
103+
const rect = ref.current?.getBoundingClientRect();
104+
if (!refIsDragging.current || !rect || rect.width === 0) {
105+
return false;
106+
}
107+
e.preventDefault();
108+
onChangeValueFromEvent(e, rect);
109+
return true;
110+
},
111+
[onChangeValueFromEvent]
112+
);
113+
114+
const onUp = useCallback((e: EventType) => {
115+
if (!refIsDragging.current) return false;
116+
refIsDragging.current = false;
117+
e.preventDefault();
118+
return true;
119+
}, []);
120+
121+
useEffect(() => {
122+
window.addEventListener("mousedown", onDown);
123+
window.addEventListener("mousemove", onMove);
124+
window.addEventListener("mouseup", onUp);
125+
window.addEventListener("touchstart", onDown);
126+
window.addEventListener("touchmove", onMove);
127+
window.addEventListener("touchend", onUp);
128+
return () => {
129+
window.removeEventListener("mousedown", onDown);
130+
window.removeEventListener("mousemove", onMove);
131+
window.removeEventListener("mouseup", onUp);
132+
window.removeEventListener("touchstart", onDown);
133+
window.removeEventListener("touchmove", onMove);
134+
window.removeEventListener("touchend", onUp);
135+
};
136+
}, [onDown, onMove, onUp]);
137+
return (
138+
<div
139+
ref={ref}
140+
role="slider"
141+
aria-label={ariaLabel}
142+
aria-valuenow={Math.round((value[0] / total) * 100)}
143+
style={{
144+
display: "flex",
145+
flexDirection: "row",
146+
height,
147+
width,
148+
opacity: disabled ? 0.6 : 1,
149+
cursor: disabled ? "not-allowed" : "pointer",
150+
...style,
151+
}}
152+
className={className}
153+
>
154+
<DynamicChildPositioner
155+
detail={proportions[0]}
156+
valueLabel={`${Math.round((value[0] * 100) / total)}%`}
157+
width={`calc(${(value[0] * 100) / total}% - ${sliderWidth / 2}px)`}
158+
primaryNode="left"
159+
/>
160+
<SliderKnob disabled={disabled} {...mergedKnobOptions} />
161+
<DynamicChildPositioner
162+
detail={proportions[1]}
163+
valueLabel={`${Math.round((value[1] * 100) / total)}%`}
164+
width={`calc(${(value[1] * 100) / total}% - ${sliderWidth / 2}px)`}
165+
primaryNode="right"
166+
/>
167+
</div>
168+
);
169+
};
170+
171+
const DefaultKnobOptions: Required<SliderKnobOptions> = {
172+
width: 5,
173+
gap: 2,
174+
backgroundColor: "red",
175+
className: "slider-knob",
176+
style: {},
177+
};
178+
179+
function getMergeKnobOptions(
180+
knobOptions: SliderKnobOptions | undefined = {}
181+
): Required<SliderKnobOptions> {
182+
return {
183+
width: knobOptions.width ?? DefaultKnobOptions.width,
184+
gap: knobOptions.gap ?? DefaultKnobOptions.gap,
185+
backgroundColor:
186+
knobOptions.backgroundColor ?? DefaultKnobOptions.backgroundColor,
187+
className: knobOptions.className ?? DefaultKnobOptions.className,
188+
style: knobOptions.style ?? DefaultKnobOptions.style,
189+
};
190+
}

0 commit comments

Comments
 (0)