feat(Slider): component redesign with complete storybook coverage

This commit is contained in:
Ilia Mashkov
2026-02-24 17:57:40 +03:00
parent 10437a2bf3
commit 2ee49b7cbd
2 changed files with 212 additions and 128 deletions

View File

@@ -9,7 +9,8 @@ const { Story } = defineMeta({
parameters: { parameters: {
docs: { docs: {
description: { description: {
component: 'Styled bits-ui slider component for selecting a value within a range.', component:
'Styled bits-ui slider component with red accent (#ff3b30). Thumb is a 45° rotated square with hover/active scale animations.',
}, },
story: { inline: false }, // Render stories in iframe for state isolation story: { inline: false }, // Render stories in iframe for state isolation
}, },
@@ -31,32 +32,110 @@ const { Story } = defineMeta({
control: 'number', control: 'number',
description: 'Step size for value increments', description: 'Step size for value increments',
}, },
label: {
control: 'text',
description: 'Optional label displayed inline on the track',
},
}, },
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
let value = $state(50); let value = $state(50);
let valueLow = $state(25);
let valueHigh = $state(75);
</script> </script>
<Story name="Horizontal" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}> <Story name="Horizontal" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
{#snippet template(args)} {#snippet template(args)}
<Slider bind:value {...args} /> <div class="p-8">
<Slider {...args} />
<p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p>
<p class="mt-2 text-xs text-muted-foreground">
Hover over thumb to see scale effect, click and drag to interact
</p>
</div>
{/snippet} {/snippet}
</Story> </Story>
<Story name="Vertical" args={{ orientation: 'vertical', min: 0, max: 100, step: 1, value }}> <Story name="Vertical" args={{ orientation: 'vertical', min: 0, max: 100, step: 1, value }}>
{#snippet template(args)} {#snippet template(args)}
<Slider bind:value {...args} /> <div class="p-8 flex items-center gap-8 h-72">
<Slider {...args} />
<div>
<p class="text-sm text-muted-foreground">Value: {args.value}</p>
<p class="mt-2 text-xs text-muted-foreground">Vertical orientation with same red accent</p>
</div>
</div>
{/snippet} {/snippet}
</Story> </Story>
<Story name="With Label" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value, label: 'SIZE' }}> <Story name="With Label" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
{#snippet template(args)} {#snippet template(args)}
<Slider bind:value {...args} /> <div class="p-8">
<Slider {...args} />
<p class="mt-4 text-sm text-muted-foreground">Slider with inline label</p>
</div>
{/snippet}
</Story>
<Story
name="Dark Mode"
args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}
parameters={{
backgrounds: {
default: 'dark',
},
}}
>
{#snippet template(args)}
<div class="p-8 bg-background">
<Slider {...args} />
<p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p>
<p class="mt-2 text-xs text-muted-foreground">Dark mode: track uses neutral-800</p>
</div>
{/snippet}
</Story>
<Story name="Interactive States" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value: 50 }}>
{#snippet template(args)}
<div class="p-8 space-y-8">
<div>
<p class="text-sm font-medium mb-2">Thumb: 45° rotated square</p>
<Slider {...args} value={50} />
</div>
<div>
<p class="text-sm font-medium mb-2">Hover State (scale-125)</p>
<Slider {...args} value={50} />
</div>
<div>
<p class="text-sm font-medium mb-2">Different Values</p>
<div class="space-y-4">
<Slider {...args} value={10} />
<Slider {...args} value={50} />
<Slider {...args} value={90} />
</div>
</div>
<div>
<p class="text-sm font-medium mb-2">Focus State (ring-2 ring-[#ff3b30]/20)</p>
<p class="text-xs text-muted-foreground">Tab to the thumb to see focus ring</p>
<Slider {...args} value={50} class="mt-2" />
</div>
</div>
{/snippet}
</Story>
<Story name="Step Sizes" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
{#snippet template(args)}
<div class="p-8 space-y-6">
<div>
<p class="text-sm font-medium mb-2">Step: 1 (default)</p>
<Slider {...args} value={50} step={1} />
</div>
<div>
<p class="text-sm font-medium mb-2">Step: 10</p>
<Slider {...args} value={50} step={10} />
</div>
<div>
<p class="text-sm font-medium mb-2">Step: 25</p>
<Slider {...args} value={50} step={25} />
</div>
</div>
{/snippet} {/snippet}
</Story> </Story>

View File

@@ -1,137 +1,142 @@
<!-- <!--
Component: Slider Component: Slider
Styled bits-ui slider component with single value. Single-value slider using bits-ui Slider primitive.
Swiss design: 1px track, diamond thumb (rotate-45), brand accent.
--> -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { Slider } from 'bits-ui';
import {
Slider,
type SliderRootProps,
} from 'bits-ui';
type Props = interface Props {
& Omit< value?: number;
SliderRootProps, min?: number;
'type' | 'onValueChange' | 'onValueCommit' max?: number;
> step?: number;
& { disabled?: boolean;
/** orientation?: 'horizontal' | 'vertical';
* Slider value, numeric. /**
*/ * Format the displayed value label.
value: number; * @default (v) => v
/** */
* Optional label displayed inline on the track before the filled range. format?: (v: number) => string | number;
*/ onValueChange?: (v: number) => void;
label?: string; class?: string;
/** }
* A callback function called when the value changes.
* @param newValue - number
*/
onValueChange?: (newValue: number) => void;
/**
* A callback function called when the user stops dragging the thumb and the value is committed.
* @param newValue - number
*/
onValueCommit?: (newValue: number) => void;
};
let { let {
value = $bindable(), value = $bindable(0),
min = 0,
max = 100,
step = 1,
disabled = false,
orientation = 'horizontal', orientation = 'horizontal',
format = (v: number) => v,
onValueChange,
class: className, class: className,
label,
...rest
}: Props = $props(); }: Props = $props();
const isVertical = $derived(orientation === 'vertical');
const labelClasses = `font-['Space_Mono'] text-[0.625rem] tabular-nums shrink-0
text-neutral-500 dark:text-neutral-400
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
transition-colors`;
const thumbClasses = `block w-2.5 h-2.5 bg-brand
rotate-45 shadow-sm
hover:scale-125
focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-brand/20
data-active:scale-90
transition-transform duration-150
disabled:pointer-events-none disabled:opacity-50
cursor-grab active:cursor-grabbing`;
</script> </script>
<Slider.Root {#if isVertical}
bind:value <div class="inline-flex flex-col items-center gap-3 group h-full {className ?? ''}">
class={cn( <span class="{labelClasses} text-center">
'relative flex h-full w-6 touch-none select-none items-center justify-center', {format(value)}
orientation === 'horizontal' ? 'w-48 h-6' : 'w-6 h-48', </span>
className,
)}
type="single"
{orientation}
{...rest}
>
{#snippet children(props)}
{#if label && orientation === 'horizontal'}
<span class="absolute top-0 left-0 -translate-y-1/2 text-[0.5rem] uppercase text-gray-400">
{label}
</span>
{/if}
<span
{...props}
class={cn(
'relative bg-background-muted rounded-full',
orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px',
)}
>
<Slider.Range
class={cn(
'absolute bg-foreground rounded-full',
orientation === 'horizontal' ? 'h-full' : 'w-full',
)}
/>
<Slider.Thumb <Slider.Root
index={0} type="single"
class={cn( orientation="vertical"
'group/thumb relative block', bind:value
'size-2', {min}
orientation === 'horizontal' ? '-top-1' : '-left-1', {max}
'rounded-full', {step}
'bg-foreground', {disabled}
// Glow shadow onValueChange={(v => onValueChange?.(v))}
'shadow-[0_0_6px_rgba(0,0,0,0.4)]', class="
// Smooth transitions only for size/position relative flex flex-col items-center select-none touch-none
'duration-200 ease-out', w-5 h-full grow cursor-row-resize
orientation === 'horizontal' disabled:opacity-50 disabled:cursor-not-allowed
? 'transition-[height,top,left,box-shadow]' "
: 'transition-[width,top,left,box-shadow]', >
// Hover: bigger glow {#snippet children({ thumbItems })}
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]', <span
orientation === 'horizontal'
? 'hover:size-3 hover:-top-[5.5px]'
: 'hover:size-3 hover:-left-[5.5px]',
// Active: smaller glow
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
orientation === 'horizontal'
? 'active:h-2.5 active:-top-[4.5px]'
: 'active:w-2.5 active:-left-[4.5px]',
'focus:outline-none',
'cursor-grab active:cursor-grabbing',
)}
>
<div
class=" class="
absolute inset-0 rounded-full bg-neutral-200 dark:bg-neutral-800
bg-background-20 relative grow w-px overflow-visible
opacity-0 group-hover/thumb:opacity-100 group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
transition-opacity duration-200 transition-colors
" "
> >
</div> <Slider.Range class="absolute bg-brand w-full" />
<span
class={cn(
'absolute',
orientation === 'horizontal'
? '-top-8 left-1/2 -translate-x-1/2'
: 'left-5 top-1/2 -translate-y-1/2',
'px-1.5 py-0.5 rounded-md',
'bg-foreground/90 backdrop-blur-sm',
'font-mono text-[0.625rem] font-medium text-background',
'opacity-0 group-hover/thumb:opacity-100',
'transition-all duration-300',
'pointer-events-none',
'shadow-sm',
)}
>
{value}
</span> </span>
</Slider.Thumb>
{#each thumbItems as thumb (thumb)}
<Slider.Thumb
index={thumb.index}
class={thumbClasses}
aria-label="Value"
/>
{/each}
{/snippet}
</Slider.Root>
</div>
{:else}
<div class="flex items-center gap-4 group w-full {className ?? ''}">
<Slider.Root
type="single"
orientation="horizontal"
bind:value
{min}
{max}
{step}
{disabled}
onValueChange={(v => onValueChange?.(v))}
class="
relative flex items-center select-none touch-none
w-full h-5 cursor-col-resize
disabled:opacity-50 disabled:cursor-not-allowed
"
>
{#snippet children({ thumbItems })}
<span
class="
bg-neutral-200 dark:bg-neutral-800
relative grow h-px overflow-visible
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
transition-colors
"
>
<Slider.Range class="absolute bg-brand h-full" />
</span>
{#each thumbItems as thumb (thumb)}
<Slider.Thumb
index={thumb.index}
class={thumbClasses}
aria-label="Value"
/>
{/each}
{/snippet}
</Slider.Root>
<!-- Label: right of slider -->
<span class="{labelClasses} w-12 text-right">
{format(value)}
</span> </span>
{/snippet} </div>
</Slider.Root> {/if}