Repository: melt-ui/melt-ui
Pull request #534 (2023-09-16 07:45:01)

Feat/add slider ticks
Allow sliders to add ticks using a `tick` builder and a `ticks` state.

This pull request contains:
1) changes to the builder to add the new params
2) tests
3) updated Slider docs with an example on how to use and style ticks


Patch:
diff --git a/.changeset/kind-garlics-invite.md b/.changeset/kind-garlics-invite.md
new file mode 100644
index 00000000..fc556888
--- /dev/null
+++ b/.changeset/kind-garlics-invite.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": minor
+---
+
+Slider: add ticks
diff --git a/src/docs/components/code-block.svelte b/src/docs/components/code-block.svelte
index a030a307..b1907fe7 100644
--- a/src/docs/components/code-block.svelte
+++ b/src/docs/components/code-block.svelte
@@ -35,7 +35,7 @@
 	{/if}
 </button>
 
-<style>
+<style lang="postcss">
 	[data-rehype-pretty-code-fragment] :global(pre) {
 		font-weight: initial !important;
 		@apply border-neutral-700/50 bg-neutral-800/50;
diff --git a/src/docs/content/builders/slider.md b/src/docs/content/builders/slider.md
index 97e1c8e9..f79b13bb 100644
--- a/src/docs/content/builders/slider.md
+++ b/src/docs/content/builders/slider.md
@@ -37,6 +37,14 @@ example:
     <svelte:component this={previews.vertical} />
 </Preview>
 
+### Slider ticks
+
+You can add slider ticks using the `ticks` state and the `tick` element returned by `createSlider`.
+
+<Preview code={snippets.ticks}>
+    <svelte:component this={previews.ticks} />
+</Preview>
+
 ## API Reference
 
 <APIReference {schemas} />
diff --git a/src/docs/data/builders/slider.ts b/src/docs/data/builders/slider.ts
index 83dadbfa..85fc8b25 100644
--- a/src/docs/data/builders/slider.ts
+++ b/src/docs/data/builders/slider.ts
@@ -78,6 +78,10 @@ const builder = builderSchema(BUILDER_NAME, {
 			name: 'thumb',
 			description: 'The builder store used to create the slider thumb element.',
 		},
+		{
+			name: 'tick',
+			description: 'The builder store used to create the slider tick element.',
+		},
 	],
 	states: [
 		{
@@ -85,6 +89,11 @@ const builder = builderSchema(BUILDER_NAME, {
 			type: 'Writable<number[]>',
 			description: 'A writable store that can be used to get the current value of the slider.',
 		},
+		{
+			name: 'ticks',
+			type: 'Writable<number>',
+			description: 'A readable store that can be used to get the current number of ticks.',
+		},
 	],
 	options: OPTION_PROPS,
 });
diff --git a/src/docs/previews/slider/ticks/tailwind/index.svelte b/src/docs/previews/slider/ticks/tailwind/index.svelte
new file mode 100644
index 00000000..7bde58aa
--- /dev/null
+++ b/src/docs/previews/slider/ticks/tailwind/index.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+	import { createSlider, melt } from '$lib/index.js';
+
+	const {
+		elements: { root, range, thumb, tick },
+		states: { ticks },
+	} = createSlider({
+		defaultValue: [5],
+		min: 0,
+		step: 1,
+		max: 10,
+	});
+</script>
+
+<span use:melt={$root} class="relative flex h-[20px] w-[200px] items-center">
+	<span class="block h-[3px] w-full bg-black/40">
+		<span use:melt={$range} class="h-[3px] bg-white" />
+	</span>
+
+	<span
+		use:melt={$thumb()}
+		class="z-10 block h-5 w-5 rounded-full bg-white focus:ring-4 focus:ring-black/40"
+	/>
+
+	{#each { length: $ticks } as _}
+		<span
+			use:melt={$tick()}
+			class="h-[3px] w-[3px] rounded-full bg-white/50 data-[bounded]:bg-magnum-800/75"
+		/>
+	{/each}
+</span>
diff --git a/src/lib/builders/slider/create.ts b/src/lib/builders/slider/create.ts
index ca8cf85c..554e9c0a 100644
--- a/src/lib/builders/slider/create.ts
+++ b/src/lib/builders/slider/create.ts
@@ -256,6 +256,69 @@ export const createSlider = (props?: CreateSliderProps) => {
 		},
 	});
 
+	const ticks = derived([min, max, step], ([$min, $max, $step]) => {
+		const difference = sub($max, $min);
+
+		// min = 0, max = 8, step = 3:
+		// ----------------------------
+		// 0, 3, 6
+		// (8 - 0) / 3 = 2.666... = 3 ceiled
+		let count = Math.ceil(div(difference, $step));
+
+		// min = 0, max = 9, step = 3:
+		// ---------------------------
+		// 0, 3, 6, 9
+		// (9 - 0) / 3 = 3
+		// We need to add 1 because `difference` is a multiple of `step`.
+		if (difference % $step == 0) {
+			count++;
+		}
+
+		return count;
+	});
+
+	const tick = builder(name('tick'), {
+		stores: [ticks, value, min, max, step, orientation],
+		returned: ([$ticks, $value, $min, $max, $step, $orientation]) => {
+			let index = -1;
+			return () => {
+				index++;
+
+				const horizontal = $orientation === 'horizontal';
+				const style: Record<string, string | number | undefined> = {
+					position: 'absolute',
+				};
+
+				// The track is divided into sections of ratio `step / (max - min)`
+				const positionPercentage = mul(index, div($step, sub($max, $min)), 100);
+				style[horizontal ? 'left' : 'bottom'] = `${positionPercentage}%`;
+
+				// Offset each tick by half its size to center it, except for
+				// the first tick as it would be rendered outside the slider.
+				//
+				// As for the last tick, offset it by its full size rather than
+				// half also to prevent it from being rendered outside.
+				if (index === $ticks - 1) {
+					// Left is negative, down is positive.
+					style.translate = horizontal ? '-100% 0' : '0 100%';
+				} else if (index !== 0) {
+					style.translate = horizontal ? '-50% 0' : '0 50%';
+				}
+
+				const tickValue = add($min, mul(index, $step));
+				const bounded =
+					$value.length === 1
+						? tickValue <= $value[0]
+						: $value[0] <= tickValue && tickValue <= $value[$value.length - 1];
+
+				return {
+					'data-bounded': bounded ? true : undefined,
+					style: styleToString(style),
+				};
+			};
+		},
+	});
+
 	effect(
 		[root, min, max, disabled, orientation, step],
 		([$root, $min, $max, $disabled, $orientation, $step]) => {
@@ -367,9 +430,11 @@ export const createSlider = (props?: CreateSliderProps) => {
 			root,
 			thumb,
 			range,
+			tick,
 		},
 		states: {
 			value,
+			ticks
 		},
 		options,
 	};


Test patch:
diff --git a/src/tests/slider/RangeSlider.svelte b/src/tests/slider/RangeSlider.svelte
index 31470d69..09215f1f 100644
--- a/src/tests/slider/RangeSlider.svelte
+++ b/src/tests/slider/RangeSlider.svelte
@@ -4,8 +4,8 @@
 	export let values = [20, 80];
 
 	const {
-		elements: { root, range, thumb },
-		states: { value },
+		elements: { root, range, thumb, tick },
+		states: { value, ticks },
 	} = createSlider({
 		defaultValue: values,
 		max: 100,
@@ -26,5 +26,9 @@
 				class="block h-5 w-5 rounded-full bg-white focus:ring-4 focus:ring-black/40"
 			/>
 		{/each}
+
+		{#each { length: $ticks } as _}
+			<span use:melt={$tick()} data-testid="tick" />
+		{/each}
 	</span>
 </main>
diff --git a/src/tests/slider/Slider.spec.ts b/src/tests/slider/Slider.spec.ts
index 1133af36..9c446374 100644
--- a/src/tests/slider/Slider.spec.ts
+++ b/src/tests/slider/Slider.spec.ts
@@ -437,3 +437,93 @@ describe('Slider (negative min)', () => {
 		expectPercentage({ percentage: 49, thumb, range });
 	});
 });
+
+describe('Slider (value=[5], min=0, max=10, step=1)', () => {
+	const props = {
+		value: [5],
+		min: 0,
+		max: 10,
+		step: 1,
+	};
+
+	test('11 ticks are rendered', () => {
+		const { getAllByTestId } = render(Slider, props);
+
+		expect(getAllByTestId('tick')).toHaveLength(11);
+	});
+
+	test('ticks 0-5 have a data-bounded attribute', () => {
+		const { getAllByTestId } = render(Slider, props);
+
+		const ticks = getAllByTestId('tick');
+		for (let i = 0; i <= 5; ++i) {
+			expect(ticks[i]).toHaveAttribute('data-bounded');
+		}
+	});
+
+	test('ticks 6-10 have no data-bounded attribute', () => {
+		const { getAllByTestId } = render(Slider, props);
+
+		const ticks = getAllByTestId('tick');
+		for (let i = 6; i <= 10; ++i) {
+			expect(ticks[i]).not.toHaveAttribute('data-bounded');
+		}
+	});
+});
+
+describe('Slider (min=0, max=8, step=3)', () => {
+	test('3 ticks are rendered', () => {
+		const { getAllByTestId } = render(Slider, {
+			min: 0,
+			max: 8,
+			step: 3,
+		});
+
+		expect(getAllByTestId('tick')).toHaveLength(3);
+	});
+});
+
+describe('Slider (min=0, max=9, step=3)', () => {
+	test('4 ticks are rendered', () => {
+		const { getAllByTestId } = render(Slider, {
+			min: 0,
+			max: 9,
+			step: 3,
+		});
+
+		expect(getAllByTestId('tick')).toHaveLength(4);
+	});
+});
+
+describe('Slider (value=[3,6], min=0, max=10, step=3)', () => {
+	const props = {
+		value: [3, 6],
+		min: 0,
+		max: 10,
+		step: 3,
+	};
+
+	test('4 ticks are rendered', () => {
+		const { getAllByTestId } = render(Slider, props);
+
+		expect(getAllByTestId('tick')).toHaveLength(4);
+	});
+
+	test('ticks 1,2 have a data-bounded attribute', () => {
+		const { getAllByTestId } = render(Slider, props);
+
+		const ticks = getAllByTestId('tick');
+		for (const i of [1, 2]) {
+			expect(ticks[i]).toHaveAttribute('data-bounded');
+		}
+	});
+
+	test('ticks 0,3 have no data-bounded attribute', () => {
+		const { getAllByTestId } = render(Slider, props);
+
+		const ticks = getAllByTestId('tick');
+		for (const i of [0, 3]) {
+			expect(ticks[i]).not.toHaveAttribute('data-bounded');
+		}
+	});
+});
diff --git a/src/tests/slider/Slider.svelte b/src/tests/slider/Slider.svelte
index 0beaedf9..d67099bc 100644
--- a/src/tests/slider/Slider.svelte
+++ b/src/tests/slider/Slider.svelte
@@ -5,7 +5,8 @@
 	export let min = 0;
 	export let step = 1;
 	const {
-		elements: { root, range, thumb },
+		elements: { root, range, thumb, tick },
+		states: { ticks }
 	} = createSlider({
 		defaultValue: value,
 		max,
@@ -26,4 +27,7 @@
 			class="block h-5 w-5 rounded-full bg-white focus:ring-4 focus:ring-black/40"
 		/>
 	</span>
+	{#each { length: $ticks } as _}
+		<span use:melt={$tick()} data-testid="tick" />
+	{/each}
 </main>