Repository: melt-ui/melt-ui
Pull request #324 (2023-08-01 23:27:28)

fix: various bug slider
This fixes basically every bug in #323 and add the ability to specify min = 0, max = 1, step = .1 (it was not possible before)

I've also added some helpers to better manage floating point add, sub, mul and div (given that numbers can be floating point in the slider).

I'm making this a draft since i want to addresso some other thing before merging

- [x] try to add tests
- [x] address setting the values from the store not adhere to the step (we decided to forgo this because it might be an unpleasant user experience)
- [x] check if we want the sliders to stop when they meet (even if set via store)...i was discussing this with @TGlide 
- [x] verify that everything is correct (it's 1 am and i'm tired, i might have messed something up)

Sorry for the weird git history


Patch:
diff --git a/.changeset/dull-wasps-search.md b/.changeset/dull-wasps-search.md
new file mode 100644
index 00000000..caf66c6e
--- /dev/null
+++ b/.changeset/dull-wasps-search.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+fix various slider bugs
diff --git a/src/docs/previews/dialog/controlled/css.svelte b/src/docs/previews/dialog/controlled/css.svelte
index 8502b248..a5bfcdce 100644
--- a/src/docs/previews/dialog/controlled/css.svelte
+++ b/src/docs/previews/dialog/controlled/css.svelte
@@ -188,7 +188,7 @@
 
 	.description {
 		margin-bottom: 1.25rem;
-		magin-top: 0.5rem;
+		margin-top: 0.5rem;
 
 		line-height: 1.5;
 
diff --git a/src/docs/previews/dialog/main/css.svelte b/src/docs/previews/dialog/main/css.svelte
index 85ad02dd..51e41954 100644
--- a/src/docs/previews/dialog/main/css.svelte
+++ b/src/docs/previews/dialog/main/css.svelte
@@ -159,7 +159,7 @@
 
 	.description {
 		margin-bottom: 1.25rem;
-		magin-top: 0.5rem;
+		margin-top: 0.5rem;
 
 		line-height: 1.5;
 
diff --git a/src/lib/builders/slider/create.ts b/src/lib/builders/slider/create.ts
index c49d15c4..51fb782d 100644
--- a/src/lib/builders/slider/create.ts
+++ b/src/lib/builders/slider/create.ts
@@ -15,6 +15,7 @@ import {
 	styleToString,
 	toWritableStores,
 } from '$lib/internal/helpers';
+import { add, sub, div, mul } from './helpers';
 import type { MeltActionReturn } from '$lib/internal/types';
 import { derived, get, writable } from 'svelte/store';
 import type { SliderEvents } from './events';
@@ -62,9 +63,8 @@ export const createSlider = (props?: CreateSliderProps) => {
 
 	const position = derived([min, max], ([$min, $max]) => {
 		return (val: number) => {
-			const pos = ((val - $min) / ($max - $min)) * 100;
-
-			return Math.abs(pos);
+			const pos = mul(div(sub(val, $min), sub($max, $min)), 100);
+			return pos;
 		};
 	});
 
@@ -92,18 +92,28 @@ export const createSlider = (props?: CreateSliderProps) => {
 		value.update((prev) => {
 			if (!prev) return [val];
 
-			const isFirst = index === 0;
-			const isLast = index === prev.length - 1;
-
-			if (!isLast && val > prev[index + 1]) {
-				prev[index] = prev[index + 1];
-			} else if (!isFirst && val < prev[index - 1]) {
-				prev[index] = prev[index - 1];
-			} else {
-				prev[index] = val;
+			const direction = prev[index] > val ? -1 : +1;
+			function swap() {
+				prev[index] = prev[index + direction];
+				prev[index + direction] = val;
+				const thumbs = getAllThumbs();
+				if (thumbs) {
+					thumbs[index + direction].focus();
+					activeThumb.set({ thumb: thumbs[index + direction], index: index + direction });
+				}
+			}
+			if (direction === -1 && val < prev[index - 1]) {
+				swap();
+				return prev;
+			} else if (direction === 1 && val > prev[index + 1]) {
+				swap();
+				return prev;
 			}
+			const $min = get(min);
+			const $max = get(max);
+			prev[index] = Math.min(Math.max(val, $min), $max);
 
-			return prev.map(Math.abs);
+			return prev;
 		});
 	};
 
@@ -196,7 +206,7 @@ export const createSlider = (props?: CreateSliderProps) => {
 						if (event.metaKey) {
 							updatePosition($min, index);
 						} else if ($value[index] > $min) {
-							const newValue = $value[index] - $step;
+							const newValue = sub($value[index], $step);
 							updatePosition(newValue, index);
 						}
 						break;
@@ -207,7 +217,7 @@ export const createSlider = (props?: CreateSliderProps) => {
 						if (event.metaKey) {
 							updatePosition($max, index);
 						} else if ($value[index] < $max) {
-							const newValue = $value[index] + $step;
+							const newValue = add($value[index], $step);
 							updatePosition(newValue, index);
 						}
 						break;
@@ -216,10 +226,10 @@ export const createSlider = (props?: CreateSliderProps) => {
 						if (event.metaKey) {
 							updatePosition($max, index);
 						} else if ($value[index] > $min && $orientation === 'vertical') {
-							const newValue = $value[index] + $step;
+							const newValue = add($value[index], $step);
 							updatePosition(newValue, index);
 						} else if ($value[index] < $max) {
-							const newValue = $value[index] + $step;
+							const newValue = add($value[index], $step);
 							updatePosition(newValue, index);
 						}
 						break;
@@ -228,10 +238,10 @@ export const createSlider = (props?: CreateSliderProps) => {
 						if (event.metaKey) {
 							updatePosition($min, index);
 						} else if ($value[index] < $max && $orientation === 'vertical') {
-							const newValue = $value[index] - $step;
+							const newValue = sub($value[index], $step);
 							updatePosition(newValue, index);
 						} else if ($value[index] > $min) {
-							const newValue = $value[index] - $step;
+							const newValue = sub($value[index], $step);
 							updatePosition(newValue, index);
 						}
 						break;
@@ -256,8 +266,8 @@ export const createSlider = (props?: CreateSliderProps) => {
 				leftOrBottom: number,
 				rightOrTop: number
 			) => {
-				const percent = (clientXY - leftOrBottom) / (rightOrTop - leftOrBottom);
-				const val = Math.round(percent * ($max - $min) + $min);
+				const percent = div(sub(clientXY, leftOrBottom), sub(rightOrTop, leftOrBottom));
+				const val = add(mul(percent, sub($max, $min)), $min);
 
 				if (val < $min) {
 					updatePosition($min, activeThumbIdx);
@@ -265,7 +275,7 @@ export const createSlider = (props?: CreateSliderProps) => {
 					updatePosition($max, activeThumbIdx);
 				} else {
 					const step = $step;
-					const newValue = Math.round(val / step) * step;
+					const newValue = mul(Math.round(div(val, step)), step);
 
 					updatePosition(newValue, activeThumbIdx);
 				}
diff --git a/src/lib/builders/slider/helpers.ts b/src/lib/builders/slider/helpers.ts
new file mode 100644
index 00000000..b4763b4e
--- /dev/null
+++ b/src/lib/builders/slider/helpers.ts
@@ -0,0 +1,55 @@
+// reinterpretation of https://github.com/guipn/sinful.js/blob/master/sinful.js
+// to fix floating point arithmetic
+
+function multiplier(x: number) {
+	const parts = x.toString().split('.');
+	if (parts.length < 2) {
+		return 1;
+	}
+	return Math.pow(10, parts[1].length);
+}
+
+// Given a variable number of arguments, returns the maximum
+// multiplier that must be used to normalize an operation involving
+// all of them.
+
+function correctionFactor(...args: number[]) {
+	return Math.max(...args.map(multiplier));
+}
+
+export function add(...args: number[]) {
+	const factor = correctionFactor(...args);
+	let sum = 0;
+	for (const number of args) {
+		sum += number * factor;
+	}
+	return sum / factor;
+}
+
+export function sub(...[first, ...args]: number[]) {
+	const factor = correctionFactor(...args);
+	let sum = first * factor;
+
+	for (const number of args) {
+		sum -= number * factor;
+	}
+	return sum / factor;
+}
+
+export function mul(...args: number[]) {
+	let total = 1;
+	for (const number of args) {
+		const factor = correctionFactor(total, number);
+		total = (total * factor * (number * factor)) / (factor * factor);
+	}
+	return total;
+}
+
+export function div(...[first, ...args]: number[]) {
+	let total = first;
+	for (const number of args) {
+		const factor = correctionFactor(total, number);
+		total = (total * factor) / (number * factor);
+	}
+	return total;
+}


Test patch:
diff --git a/src/lib/builders/slider/tests/RangeSlider.svelte b/src/lib/builders/slider/tests/RangeSlider.svelte
new file mode 100644
index 00000000..576fd9f8
--- /dev/null
+++ b/src/lib/builders/slider/tests/RangeSlider.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+	import { createSlider, melt } from '$lib';
+
+	export let values = [20, 80];
+
+	const {
+		elements: { root, range, thumb },
+		states: { value },
+	} = createSlider({
+		defaultValue: values,
+		max: 100,
+	});
+</script>
+
+<main>
+	<span use:melt={$root} class="relative flex h-[20px] w-[200px] items-center">
+		<span class="block h-[3px] w-full bg-black/40">
+			<span data-testid="range" use:melt={$range} class="h-[3px] bg-white" />
+		</span>
+
+		{#each $value as _, i}
+			<span
+				data-testid="thumb-{i}"
+				use:melt={$thumb()}
+				class="block h-5 w-5 rounded-full bg-white focus:ring-4 focus:ring-black/40"
+			/>
+		{/each}
+	</span>
+</main>
diff --git a/src/lib/builders/slider/tests/Slider.spec.ts b/src/lib/builders/slider/tests/Slider.spec.ts
new file mode 100644
index 00000000..999ac1c4
--- /dev/null
+++ b/src/lib/builders/slider/tests/Slider.spec.ts
@@ -0,0 +1,439 @@
+import { kbd } from '$lib/internal/helpers';
+import { act, render } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { axe } from 'jest-axe';
+import Slider from './Slider.svelte';
+import RangeSlider from './RangeSlider.svelte';
+
+const IS_ENOUGH_CLOSE = 0.0001;
+
+/**
+ * Unfortunately sometimes floating point arithmetic still strikes
+ * so we need to check that we are close enough rather than precisely
+ */
+function isCloseEnough(value: number, style: string) {
+	const numStyle = parseFloat(style.replace('%', ''));
+	return Math.abs(numStyle - value) < IS_ENOUGH_CLOSE;
+}
+
+function expectPercentage({
+	percentage,
+	thumb,
+	range,
+}: {
+	percentage: number;
+	thumb: HTMLElement;
+	range: HTMLElement;
+}) {
+	expect(isCloseEnough(percentage, thumb.style.left)).toBeTruthy();
+	expect(isCloseEnough(0, range.style.left)).toBeTruthy();
+	expect(isCloseEnough(100 - percentage, range.style.right)).toBeTruthy();
+}
+
+function expectPercentages({
+	percentages,
+	thumbs,
+	range,
+}: {
+	percentages: number[];
+	thumbs: HTMLElement[];
+	range: HTMLElement;
+}) {
+	let lesserPercentage = Infinity;
+	let higherPercentage = -Infinity;
+	for (let i = 0; i < percentages.length; i++) {
+		const thumb = thumbs[i];
+		const percentage = percentages[i];
+		if (percentage > higherPercentage) {
+			higherPercentage = percentage;
+		}
+		if (percentage < lesserPercentage) {
+			lesserPercentage = percentage;
+		}
+		expect(isCloseEnough(percentage, thumb.style.left)).toBeTruthy();
+	}
+	expect(isCloseEnough(lesserPercentage, range.style.left)).toBeTruthy();
+	expect(isCloseEnough(100 - higherPercentage, range.style.right)).toBeTruthy();
+}
+
+describe('Slider (Default)', () => {
+	test('No accessibility violations', async () => {
+		const { container } = render(Slider);
+
+		expect(await axe(container)).toHaveNoViolations();
+	});
+
+	test('Has a thumb positioned at 30% of the container', async () => {
+		const { getByTestId } = render(Slider);
+
+		const thumb = getByTestId('thumb');
+		expect(thumb).toBeInTheDocument();
+
+		expect(isCloseEnough(30, thumb.style.left)).toBeTruthy();
+	});
+	test('Has a range that covers from 0 to 30%', async () => {
+		const { getByTestId } = render(Slider);
+
+		const range = getByTestId('range');
+		expect(range).toBeInTheDocument();
+
+		expect(isCloseEnough(0, range.style.left)).toBeTruthy();
+		expect(isCloseEnough(70, range.style.right)).toBeTruthy();
+	});
+
+	test.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])('Change by 1% when pressing %s', async (key) => {
+		const { getByTestId } = render(Slider);
+		const user = userEvent.setup();
+
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${key}}`);
+
+		expectPercentage({ percentage: 31, thumb, range });
+	});
+
+	test.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])('Change by 1% when pressing %s', async (key) => {
+		const { getByTestId } = render(Slider);
+		const user = userEvent.setup();
+
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${key}}`);
+
+		expectPercentage({ percentage: 29, thumb, range });
+	});
+
+	test('Goes to minimum when pressing Home', async () => {
+		const { getByTestId } = render(Slider);
+		const user = userEvent.setup();
+
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${kbd.HOME}}`);
+
+		expectPercentage({ percentage: 0, thumb, range });
+	});
+
+	test('Goes to maximum when pressing End', async () => {
+		const { getByTestId } = render(Slider);
+		const user = userEvent.setup();
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${kbd.END}}`);
+
+		expectPercentage({ percentage: 100, thumb, range });
+	});
+});
+
+describe('Slider (Range)', () => {
+	test('No accessibility violations', async () => {
+		const { container } = render(RangeSlider);
+
+		expect(await axe(container)).toHaveNoViolations();
+	});
+
+	test('Has a thumb positioned at 20% of the container and one at 80%', async () => {
+		const { getByTestId } = render(RangeSlider);
+
+		const thumb0 = getByTestId('thumb-0');
+		expect(thumb0).toBeInTheDocument();
+		const thumb1 = getByTestId('thumb-1');
+		expect(thumb1).toBeInTheDocument();
+
+		expect(isCloseEnough(20, thumb0.style.left)).toBeTruthy();
+		expect(isCloseEnough(80, thumb1.style.left)).toBeTruthy();
+	});
+	test('Has a range that covers from 20% to 80%', async () => {
+		const { getByTestId } = render(RangeSlider);
+
+		const range = getByTestId('range');
+		expect(range).toBeInTheDocument();
+
+		expect(isCloseEnough(20, range.style.left)).toBeTruthy();
+		expect(isCloseEnough(20, range.style.right)).toBeTruthy();
+	});
+
+	test.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])(
+		'Change by 1% when pressing %s (pressing on the first thumb)',
+		async (key) => {
+			const { getByTestId } = render(RangeSlider);
+			const user = userEvent.setup();
+
+			const thumb0 = getByTestId('thumb-0');
+			const thumb1 = getByTestId('thumb-1');
+			const range = getByTestId('range');
+
+			await act(() => thumb0.focus());
+			await user.keyboard(`{${key}}`);
+
+			expectPercentages({ percentages: [21, 80], thumbs: [thumb0, thumb1], range });
+		}
+	);
+
+	test.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])(
+		'Change by 1% when pressing %s (pressing on the last thumb)',
+		async (key) => {
+			const { getByTestId } = render(RangeSlider);
+			const user = userEvent.setup();
+
+			const thumb0 = getByTestId('thumb-0');
+			const thumb1 = getByTestId('thumb-1');
+			const range = getByTestId('range');
+
+			await act(() => thumb1.focus());
+			await user.keyboard(`{${key}}`);
+
+			expectPercentages({ percentages: [20, 81], thumbs: [thumb0, thumb1], range });
+		}
+	);
+
+	test.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])(
+		'Change by 1% when pressing %s (pressing on the first thumb)',
+		async (key) => {
+			const { getByTestId } = render(RangeSlider);
+			const user = userEvent.setup();
+
+			const thumb0 = getByTestId('thumb-0');
+			const thumb1 = getByTestId('thumb-1');
+			const range = getByTestId('range');
+
+			await act(() => thumb0.focus());
+			await user.keyboard(`{${key}}`);
+
+			expectPercentages({ percentages: [19, 80], thumbs: [thumb0, thumb1], range });
+		}
+	);
+
+	test.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])(
+		'Change by 1% when pressing %s (pressing on the last thumb)',
+		async (key) => {
+			const { getByTestId } = render(RangeSlider);
+			const user = userEvent.setup();
+
+			const thumb0 = getByTestId('thumb-0');
+			const thumb1 = getByTestId('thumb-1');
+			const range = getByTestId('range');
+
+			await act(() => thumb1.focus());
+			await user.keyboard(`{${key}}`);
+
+			expectPercentages({ percentages: [20, 79], thumbs: [thumb0, thumb1], range });
+		}
+	);
+
+	test.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])(
+		'The handlers swap places when they overlap pressing %s (going up)',
+		async (key) => {
+			const { getByTestId } = render(RangeSlider, {
+				values: [49, 51],
+			});
+			const user = userEvent.setup();
+
+			const thumb0 = getByTestId('thumb-0');
+			const thumb1 = getByTestId('thumb-1');
+			const range = getByTestId('range');
+
+			await act(() => thumb0.focus());
+			await user.keyboard(`{${key}}`);
+			await user.keyboard(`{${key}}`);
+			await user.keyboard(`{${key}}`);
+
+			expectPercentages({ percentages: [51, 52], thumbs: [thumb0, thumb1], range });
+			expect(thumb1).toHaveFocus();
+		}
+	);
+
+	test.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])(
+		'The handlers swap places when they overlap pressing %s (going down)',
+		async (key) => {
+			const { getByTestId } = render(RangeSlider, {
+				values: [49, 51],
+			});
+			const user = userEvent.setup();
+
+			const thumb0 = getByTestId('thumb-0');
+			const thumb1 = getByTestId('thumb-1');
+			const range = getByTestId('range');
+
+			await act(() => thumb1.focus());
+			await user.keyboard(`{${key}}`);
+			await user.keyboard(`{${key}}`);
+			await user.keyboard(`{${key}}`);
+
+			expectPercentages({ percentages: [48, 49], thumbs: [thumb0, thumb1], range });
+			expect(thumb0).toHaveFocus();
+		}
+	);
+
+	test('Thumb 0 goes to minimum when pressing Home', async () => {
+		const { getByTestId } = render(RangeSlider);
+		const user = userEvent.setup();
+
+		const thumb0 = getByTestId('thumb-0');
+		const thumb1 = getByTestId('thumb-1');
+		const range = getByTestId('range');
+
+		await act(() => thumb0.focus());
+		await user.keyboard(`{${kbd.HOME}}`);
+
+		expectPercentages({ percentages: [0, 80], thumbs: [thumb0, thumb1], range });
+	});
+
+	test('Thumb 1 goes to maximum when pressing End', async () => {
+		const { getByTestId } = render(RangeSlider);
+		const user = userEvent.setup();
+
+		const thumb0 = getByTestId('thumb-0');
+		const thumb1 = getByTestId('thumb-1');
+		const range = getByTestId('range');
+
+		await act(() => thumb1.focus());
+		await user.keyboard(`{${kbd.END}}`);
+
+		expectPercentages({ percentages: [20, 100], thumbs: [thumb0, thumb1], range });
+	});
+
+	test('Thumb 1 goes to minimum when pressing Home (thumbs swap places)', async () => {
+		const { getByTestId } = render(RangeSlider);
+		const user = userEvent.setup();
+
+		const thumb0 = getByTestId('thumb-0');
+		const thumb1 = getByTestId('thumb-1');
+		const range = getByTestId('range');
+
+		await act(() => thumb1.focus());
+		await user.keyboard(`{${kbd.HOME}}`);
+
+		expectPercentages({ percentages: [0, 20], thumbs: [thumb0, thumb1], range });
+		expect(thumb0).toHaveFocus();
+	});
+
+	test('Thumb 0 goes to maximum when pressing End (thumbs swap places)', async () => {
+		const { getByTestId } = render(RangeSlider);
+		const user = userEvent.setup();
+
+		const thumb0 = getByTestId('thumb-0');
+		const thumb1 = getByTestId('thumb-1');
+		const range = getByTestId('range');
+
+		await act(() => thumb0.focus());
+		await user.keyboard(`{${kbd.END}}`);
+
+		expectPercentages({ percentages: [80, 100], thumbs: [thumb0, thumb1], range });
+		expect(thumb1).toHaveFocus();
+	});
+});
+
+describe('Slider (Small min, max, step)', () => {
+	test('Has a thumb positioned at 50% of the container', async () => {
+		const { getByTestId } = render(Slider, {
+			value: [0.5],
+			min: 0,
+			max: 1,
+			step: 0.01,
+		});
+
+		const thumb = getByTestId('thumb');
+		expect(thumb).toBeInTheDocument();
+
+		expect(isCloseEnough(50, thumb.style.left)).toBeTruthy();
+	});
+
+	test.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])('Change by 1% when pressing %s', async (key) => {
+		const { getByTestId } = render(Slider, {
+			value: [0.5],
+			min: 0,
+			max: 1,
+			step: 0.01,
+		});
+
+		const user = userEvent.setup();
+
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${key}}`);
+
+		expectPercentage({ percentage: 51, thumb, range });
+	});
+
+	test.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])('Change by 10% when pressing %s', async (key) => {
+		const { getByTestId } = render(Slider, {
+			value: [0.5],
+			min: 0,
+			max: 1,
+			step: 0.01,
+		});
+		const user = userEvent.setup();
+
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${key}}`);
+
+		expectPercentage({ percentage: 49, thumb, range });
+	});
+});
+
+describe('Slider (negative min)', () => {
+	test('Has a thumb positioned at 50% of the container', async () => {
+		const { getByTestId } = render(Slider, {
+			value: [0],
+			min: -50,
+			max: 50,
+			step: 1,
+		});
+
+		const thumb = getByTestId('thumb');
+		expect(thumb).toBeInTheDocument();
+
+		expect(isCloseEnough(50, thumb.style.left)).toBeTruthy();
+	});
+
+	test.each([kbd.ARROW_RIGHT, kbd.ARROW_UP])('Change by 1% when pressing %s', async (key) => {
+		const { getByTestId } = render(Slider, {
+			value: [0],
+			min: -50,
+			max: 50,
+			step: 1,
+		});
+
+		const user = userEvent.setup();
+
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${key}}`);
+
+		expectPercentage({ percentage: 51, thumb, range });
+	});
+
+	test.each([kbd.ARROW_LEFT, kbd.ARROW_DOWN])('Change by 10% when pressing %s', async (key) => {
+		const { getByTestId } = render(Slider, {
+			value: [0],
+			min: -50,
+			max: 50,
+			step: 1,
+		});
+		const user = userEvent.setup();
+
+		const thumb = getByTestId('thumb');
+		const range = getByTestId('range');
+
+		await act(() => thumb.focus());
+		await user.keyboard(`{${key}}`);
+
+		expectPercentage({ percentage: 49, thumb, range });
+	});
+});
diff --git a/src/lib/builders/slider/tests/Slider.svelte b/src/lib/builders/slider/tests/Slider.svelte
new file mode 100644
index 00000000..6ea2a716
--- /dev/null
+++ b/src/lib/builders/slider/tests/Slider.svelte
@@ -0,0 +1,28 @@
+<script lang="ts">
+	import { createSlider, melt } from '$lib';
+	export let value = [30];
+	export let max = 100;
+	export let min = 0;
+	export let step = 1;
+	const {
+		elements: { root, range, thumb },
+	} = createSlider({
+		defaultValue: value,
+		max,
+		min,
+		step,
+	});
+</script>
+
+<main>
+	<span data-testid="slider" use:melt={$root} class="relative flex h-[20px] w-[200px] items-center">
+		<span class="block h-[3px] w-full bg-black/40">
+			<span data-testid="range" use:melt={$range} class="h-[3px] bg-white" />
+		</span>
+		<span
+			data-testid="thumb"
+			use:melt={$thumb()}
+			class="block h-5 w-5 rounded-full bg-white focus:ring-4 focus:ring-black/40"
+		/>
+	</span>
+</main>