feat(Slider): component redesign with complete storybook coverage
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user