Skip to content

Commit 3473ba9

Browse files
authored
Merge pull request #2 from fabric-ds/feat/slider
Implementation of Slider logic
2 parents be10d6b + a836c27 commit 3473ba9

File tree

5 files changed

+206
-2
lines changed

5 files changed

+206
-2
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@fabric-ds/core",
33
"description": "Shared business logic for JS implementations of Fabric Design System",
4-
"version": "0.0.7",
4+
"version": "0.0.10",
55
"type": "module",
66
"exports": {
77
"./*": "./dist/*/index.js"
@@ -10,6 +10,9 @@
1010
"*": {
1111
"breadcrumbs": [
1212
"./dist/breadcrumbs/index.d.ts"
13+
],
14+
"slider": [
15+
"./dist/slider/index.d.ts"
1316
]
1417
}
1518
},

src/slider/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./utils/helpers.js";
2+
export * from "./utils/handlers.js";

src/slider/utils/handlers.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {
2+
validKeyCodes,
3+
validKeys,
4+
eventOptions,
5+
clamp,
6+
roundDecimals,
7+
} from "./helpers.js";
8+
9+
type ClickEvent = TouchEvent | MouseEvent;
10+
export type Dimensions = {
11+
left: number;
12+
width: number;
13+
};
14+
15+
interface SliderProps {
16+
min: number; // Default 0
17+
max: number; // Default 100
18+
step: number;
19+
disabled?: boolean;
20+
label?: string;
21+
labelledBy?: string;
22+
}
23+
24+
interface SliderState {
25+
dimensions: Dimensions;
26+
sliderPressed: boolean;
27+
position: number;
28+
val: number;
29+
step: number;
30+
thumbEl: HTMLDivElement | null;
31+
}
32+
33+
export function createHandlers({
34+
props,
35+
sliderState,
36+
}: {
37+
props: SliderProps;
38+
sliderState: SliderState;
39+
}) {
40+
const clampedChange = (n: number) =>
41+
clamp(n, { max: props.max, min: props.min });
42+
43+
function getCoordinates(e: ClickEvent) {
44+
const { left: offsetLeft, width: trackWidth } = sliderState.dimensions;
45+
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
46+
let left =
47+
Math.min(Math.max((clientX - offsetLeft - 16) / trackWidth, 0), 1) || 0;
48+
const value = props.min + left * (props.max - props.min);
49+
return { value };
50+
}
51+
52+
const getThumbPosition = () =>
53+
((sliderState.position - props.min) / (props.max - props.min)) * 100;
54+
55+
const getThumbTransform = () =>
56+
(getThumbPosition() / 100) * sliderState.dimensions.width;
57+
58+
const getShiftedChange = (n: number) => {
59+
const r = 1.0 / sliderState.step;
60+
return Math.floor(n * r) / r;
61+
};
62+
63+
function handleKeyDown(e: KeyboardEvent) {
64+
const key = e.key;
65+
if (!validKeyCodes.includes(key)) return;
66+
e.preventDefault();
67+
if (
68+
[validKeys.left, validKeys.right, validKeys.up, validKeys.down].includes(
69+
key
70+
)
71+
) {
72+
const direction = [validKeys.right, validKeys.up].includes(key) ? 1 : -1;
73+
sliderState.position = clampedChange(
74+
sliderState.val + direction * sliderState.step
75+
);
76+
} else if (key === validKeys.home) {
77+
sliderState.position = props.min;
78+
} else if (key === validKeys.end) {
79+
sliderState.position = props.max;
80+
} else {
81+
const direction = key === validKeys.pageup ? 1 : -1;
82+
const minStepMultiplier = 2;
83+
const maxStepMultiplier = 50;
84+
sliderState.position = clampedChange(
85+
sliderState.val +
86+
direction *
87+
sliderState.step *
88+
Math.max(
89+
minStepMultiplier,
90+
Math.min(
91+
maxStepMultiplier,
92+
Math.ceil((props.max - props.min) / 10 / sliderState.step)
93+
)
94+
)
95+
);
96+
}
97+
}
98+
99+
function handleFocus(e: FocusEvent | unknown): void {}
100+
function handleBlur(e: FocusEvent | unknown): void {}
101+
102+
function handleMouseDown(e: KeyboardEvent) {
103+
sliderState.sliderPressed = true;
104+
if ("touches" in e) {
105+
window.addEventListener("touchmove", handleMouseChange, eventOptions);
106+
window.addEventListener("touchend", handleMouseUp, { once: true });
107+
} else {
108+
window.addEventListener("mousemove", handleMouseChange, eventOptions);
109+
window.addEventListener("mouseup", handleMouseUp, { once: true });
110+
}
111+
e.stopPropagation();
112+
e.preventDefault();
113+
}
114+
115+
// we don't return this function, it's called via mouseDown's addEventListener
116+
function handleMouseUp() {
117+
sliderState.sliderPressed = false;
118+
window.removeEventListener("touchmove", handleMouseChange);
119+
window.removeEventListener("mousemove", handleMouseChange);
120+
}
121+
122+
function handleClick(e: ClickEvent | unknown) {
123+
handleMouseChange(e);
124+
}
125+
126+
function handleMouseChange(e: ClickEvent | unknown) {
127+
const { value } = getCoordinates(e as ClickEvent);
128+
const n = roundDecimals(value);
129+
sliderState.thumbEl?.focus();
130+
if (sliderState.position === n) return;
131+
sliderState.position = n;
132+
}
133+
134+
return {
135+
handleKeyDown,
136+
handleFocus,
137+
handleBlur,
138+
handleMouseDown,
139+
handleClick,
140+
getThumbPosition,
141+
getThumbTransform,
142+
getShiftedChange,
143+
};
144+
}

src/slider/utils/helpers.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Dimensions } from "./handlers.js";
2+
3+
type UpdateDimensions = ({ left, width }: Dimensions) => void;
4+
5+
export const useDimensions = () => {
6+
let observer: ResizeObserver;
7+
8+
// we use boundingClient because other observer attributes don't calculate X offset in a useful way
9+
const useOnResize =
10+
(updateDimensions: UpdateDimensions) =>
11+
(entries: ResizeObserverEntry[]) => {
12+
const { left, width: w } = entries[0].target.getBoundingClientRect();
13+
updateDimensions({ left, width: w - 24 }); // so the thumb can't run off the track to the right
14+
};
15+
16+
const mountedHook = (
17+
sliderLineEl: HTMLDivElement,
18+
updateDimensions: UpdateDimensions
19+
) => {
20+
updateDimensions(sliderLineEl.getBoundingClientRect());
21+
observer = new ResizeObserver(useOnResize(updateDimensions));
22+
observer.observe(sliderLineEl);
23+
};
24+
25+
const unmountedHook = () => {
26+
observer.disconnect();
27+
};
28+
return { mountedHook, unmountedHook };
29+
};
30+
31+
export const validKeys = Object.freeze({
32+
up: "ArrowUp",
33+
down: "ArrowDown",
34+
left: "ArrowLeft",
35+
right: "ArrowRight",
36+
end: "End",
37+
home: "Home",
38+
pageup: "PageUp",
39+
pagedown: "PageDown",
40+
});
41+
42+
export const validKeyCodes = Object.values(validKeys);
43+
44+
export const eventOptions = { passive: true };
45+
46+
export function roundDecimals(n: number, decimals = 2) {
47+
const rounding = decimals ? Math.pow(10, decimals) : 1;
48+
return Math.round(n * rounding) / rounding;
49+
}
50+
51+
const isNumber = (e: string) => Number.isFinite(parseFloat(e));
52+
export const clamp = (
53+
v: string | number,
54+
{ min, max }: { min: number; max: number }
55+
) => (isNumber(v as string) ? Math.min(Math.max(Number(v), min), max) : min);

0 commit comments

Comments
 (0)