feat(ComboControl): replace ComboControl with redesigned ComboControlV2

This commit is contained in:
Ilia Mashkov
2026-02-25 09:55:46 +03:00
parent 5dbebc2b77
commit 560eda6ac2
4 changed files with 136 additions and 547 deletions

View File

@@ -11,100 +11,36 @@ const { Story } = defineMeta({
docs: {
description: {
component:
'Provides multiple ways to change a numeric value via decrease/increase buttons, slider, and direct input. All three methods are synchronized, giving users flexibility based on precision needs.',
'ComboControl with input field and slider. Simplified version without increase/decrease buttons.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
label: {
control: 'text',
description: 'Label for the ComboControl',
},
control: {
control: 'object',
description: 'TypographyControl instance managing the value and bounds',
},
decreaseLabel: {
control: 'text',
description: 'Accessibility label for the decrease button',
},
increaseLabel: {
control: 'text',
description: 'Accessibility label for the increase button',
},
controlLabel: {
control: 'text',
description: 'Accessibility label for the control button (opens popover)',
},
},
});
</script>
<script lang="ts">
const defaultControl = createTypographyControl({ value: 77, min: 0, max: 100, step: 1 });
const atMinimumControl = createTypographyControl({ value: 0, min: 0, max: 100, step: 1 });
const atMaximumControl = createTypographyControl({ value: 100, min: 0, max: 100, step: 1 });
const withFloatControl = createTypographyControl({ value: 77.5, min: 0, max: 100, step: 0.1 });
const customLabelsControl = createTypographyControl({ value: 50, min: 0, max: 100, step: 1 });
const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
</script>
<Story
name="Default"
name="Horizontal"
args={{
control: defaultControl,
control: horizontalControl,
label: 'Size',
}}
>
{#snippet template(args)}
<ComboControl control={defaultControl} {...args} />
{/snippet}
</Story>
<Story
name="At Minimum"
args={{
control: atMinimumControl,
}}
>
{#snippet template(args)}
<ComboControl control={atMinimumControl} {...args} />
{/snippet}
</Story>
<Story
name="At Maximum"
args={{
control: atMaximumControl,
}}
>
{#snippet template(args)}
<ComboControl control={atMaximumControl} {...args} />
{/snippet}
</Story>
<Story
name="With Float"
args={{
control: withFloatControl,
}}
>
{#snippet template(args)}
<ComboControl control={withFloatControl} {...args} />
{/snippet}
</Story>
<Story
name="Custom Labels"
args={{
control: customLabelsControl,
decreaseLabel: 'Decrease font size',
increaseLabel: 'Increase font size',
controlLabel: 'Open font size controls',
}}
>
{#snippet template(args)}
<ComboControl
control={customLabelsControl}
decreaseLabel="Decrease font size"
increaseLabel="Increase font size"
controlLabel="Open font size controls"
{...args}
/>
<ComboControl {...args} />
{/snippet}
</Story>

View File

@@ -1,156 +1,163 @@
<!--
Component: ComboControl
Provides multiple ways to change certain value
- via Increase/Decrease buttons
- via Slider
- via Input
Typography value control: surface +/ buttons flanking a two-line trigger
that opens a vertical slider popover.
-->
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button';
import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
import { Input } from '$shared/shadcn/ui/input';
import {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { Slider } from '$shared/shadcn/ui/slider';
import {
Content as TooltipContent,
Root as TooltipRoot,
Trigger as TooltipTrigger,
} from '$shared/shadcn/ui/tooltip';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Slider } from '$shared/ui';
import { Button } from '$shared/ui/Button';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements';
import IconButton from '../IconButton/IconButton.svelte';
import TechText from '../TechText/TechText.svelte';
interface Props {
/**
* Text for increase button aria-label
*/
increaseLabel?: string;
/**
* Text for decrease button aria-label
*/
decreaseLabel?: string;
/**
* Text for control button aria-label
*/
controlLabel?: string;
/**
* Control instance
*/
control: TypographyControl;
/**
* Reduced amount of controls
*/
label?: string;
class?: string;
reduced?: boolean;
increaseLabel?: string;
decreaseLabel?: string;
controlLabel?: string;
}
const {
let {
control,
decreaseLabel,
increaseLabel,
controlLabel,
label,
class: className,
reduced = false,
increaseLabel = 'Increase',
decreaseLabel = 'Decrease',
controlLabel,
}: Props = $props();
// Local state for the slider to prevent infinite loops
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
let sliderValue = $state(Number(control.value));
let open = $state(false);
// Sync sliderValue when external value changes
$effect(() => {
sliderValue = Number(control.value);
function toggleOpen() {
open = !open;
}
// Smart value formatting matching the Figma design
const formattedValue = $derived(() => {
const v = control.value;
if (Number.isInteger(v)) return String(v);
return control.step < 0.1 ? v.toFixed(2) : v.toFixed(1);
});
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) {
control.value = parsedValue;
}
};
/**
* Handle slider value change.
* The Slider component passes the value as a number directly.
*/
const handleSliderChange = (newValue: number) => {
control.value = newValue;
};
// Display label: prefer explicit prop, fall back to controlLabel
const displayLabel = $derived(label ?? controlLabel ?? '');
</script>
<TooltipRoot>
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
<TooltipTrigger class="flex items-center">
{#if !reduced}
<IconButton
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
rotation="counterclockwise"
>
{#snippet icon({ className })}
<MinusIcon class={className} />
{/snippet}
</IconButton>
{/if}
<PopoverRoot>
<!--
── REDUCED MODE ────────────────────────────────────────────────────────────
Inline slider + value. No buttons, no popover.
-->
{#if reduced}
<div
class={cn(
'flex gap-4 items-end w-full',
className,
)}
>
<Slider
class="w-full"
bind:value={control.value}
min={control.min}
max={control.max}
step={control.step}
orientation="horizontal"
/>
<span
class="font-mono text-[0.6875rem] text-neutral-500 dark:text-neutral-400 tabular-nums w-10 text-right shrink-0"
>
{formattedValue()}
</span>
</div>
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
{:else}
<div class={cn('flex items-center px-1 relative', className)}>
<!-- Decrease button -->
<Button
variant="secondary"
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
>
<MinusIcon class="size-3.5 stroke-2" />
</Button>
<!-- Trigger -->
<div class="relative mx-1">
<PopoverRoot bind:open>
<PopoverTrigger>
{#snippet child({ props })}
<Button
<button
{...props}
variant="ghost"
class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
size="icon"
class={cn(
'flex flex-col items-center justify-center w-14 py-1',
'select-none rounded-none transition-all duration-150',
'border border-transparent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
open
? 'bg-white dark:bg-[#1e1e1e] shadow-sm border-black/5 dark:border-white/10'
: 'hover:bg-white/50 dark:hover:bg-[#1e1e1e]/50',
)}
aria-label={controlLabel}
>
{control.value}
</Button>
<!-- Label row -->
{#if displayLabel}
<span
class="
text-[0.5625rem] font-primary font-bold tracking-tight uppercase
text-neutral-900 dark:text-neutral-100
mb-0.5 leading-none
"
>
{displayLabel}
</span>
{/if}
<!-- Value row -->
<TechText variant="muted" size="md">
{formattedValue()}
</TechText>
</button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-auto p-4">
<div class="flex flex-col items-center gap-3">
<Slider
min={control.min}
max={control.max}
step={control.step}
value={sliderValue}
onValueChange={handleSliderChange}
type="single"
orientation="vertical"
class="h-48"
/>
<Input
value={control.value}
onchange={handleInputChange}
min={control.min}
max={control.max}
class="w-16 text-center"
/>
</div>
<!-- Vertical slider popover -->
<PopoverContent
class="w-auto py-4 px-3 h-64 flex items-center justify-center rounded-none border border-black/5 dark:border-white/10 shadow-sm bg-white dark:bg-[#1e1e1e]"
align="center"
side="top"
>
<Slider
class="h-full"
bind:value={control.value}
min={control.min}
max={control.max}
step={control.step}
orientation="vertical"
/>
</PopoverContent>
</PopoverRoot>
</div>
{#if !reduced}
<IconButton
aria-label={increaseLabel}
onclick={control.increase}
disabled={control.isAtMax}
rotation="clockwise"
>
{#snippet icon({ className })}
<PlusIcon class={className} />
{/snippet}
</IconButton>
{/if}
</TooltipTrigger>
</ButtonGroupRoot>
{#if controlLabel}
<TooltipContent>
{controlLabel}
</TooltipContent>
{/if}
</TooltipRoot>
<!-- Increase button -->
<Button
variant="secondary"
onclick={control.increase}
disabled={control.isAtMax}
aria-label={increaseLabel}
>
<PlusIcon class="size-3.5 stroke-2" />
</Button>
</div>
{/if}