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: { docs: {
description: { description: {
component: 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 story: { inline: false }, // Render stories in iframe for state isolation
}, },
}, },
argTypes: { argTypes: {
label: {
control: 'text',
description: 'Label for the ComboControl',
},
control: { control: {
control: 'object', control: 'object',
description: 'TypographyControl instance managing the value and bounds', 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>
<script lang="ts"> <script lang="ts">
const defaultControl = createTypographyControl({ value: 77, min: 0, max: 100, step: 1 }); const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
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 });
</script> </script>
<Story <Story
name="Default" name="Horizontal"
args={{ args={{
control: defaultControl, control: horizontalControl,
label: 'Size',
}} }}
> >
{#snippet template(args)} {#snippet template(args)}
<ComboControl control={defaultControl} {...args} /> <ComboControl {...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}
/>
{/snippet} {/snippet}
</Story> </Story>

View File

@@ -1,156 +1,163 @@
<!-- <!--
Component: ComboControl Component: ComboControl
Provides multiple ways to change certain value Typography value control: surface +/ buttons flanking a two-line trigger
- via Increase/Decrease buttons that opens a vertical slider popover.
- via Slider
- via Input
--> -->
<script lang="ts"> <script lang="ts">
import type { TypographyControl } from '$shared/lib'; 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 { import {
Content as PopoverContent, Content as PopoverContent,
Root as PopoverRoot, Root as PopoverRoot,
Trigger as PopoverTrigger, Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover'; } from '$shared/shadcn/ui/popover';
import { Slider } from '$shared/shadcn/ui/slider'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import { Slider } from '$shared/ui';
Content as TooltipContent, import { Button } from '$shared/ui/Button';
Root as TooltipRoot,
Trigger as TooltipTrigger,
} from '$shared/shadcn/ui/tooltip';
import MinusIcon from '@lucide/svelte/icons/minus'; import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements'; import TechText from '../TechText/TechText.svelte';
import IconButton from '../IconButton/IconButton.svelte';
interface Props { 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; control: TypographyControl;
/** label?: string;
* Reduced amount of controls class?: string;
*/
reduced?: boolean; reduced?: boolean;
increaseLabel?: string;
decreaseLabel?: string;
controlLabel?: string;
} }
const { let {
control, control,
decreaseLabel, label,
increaseLabel, class: className,
controlLabel,
reduced = false, reduced = false,
increaseLabel = 'Increase',
decreaseLabel = 'Decrease',
controlLabel,
}: Props = $props(); }: Props = $props();
// Local state for the slider to prevent infinite loops let open = $state(false);
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
let sliderValue = $state(Number(control.value));
// Sync sliderValue when external value changes function toggleOpen() {
$effect(() => { open = !open;
sliderValue = Number(control.value); }
// 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 => { // Display label: prefer explicit prop, fall back to controlLabel
const parsedValue = parseFloat(event.currentTarget.value); const displayLabel = $derived(label ?? controlLabel ?? '');
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;
};
</script> </script>
<TooltipRoot> <!--
<ButtonGroupRoot class="bg-transparent border-none shadow-none"> ── REDUCED MODE ────────────────────────────────────────────────────────────
<TooltipTrigger class="flex items-center"> Inline slider + value. No buttons, no popover.
{#if !reduced} -->
<IconButton {#if reduced}
onclick={control.decrease} <div
disabled={control.isAtMin} class={cn(
aria-label={decreaseLabel} 'flex gap-4 items-end w-full',
rotation="counterclockwise" className,
> )}
{#snippet icon({ className })} >
<MinusIcon class={className} /> <Slider
{/snippet} class="w-full"
</IconButton> bind:value={control.value}
{/if} min={control.min}
<PopoverRoot> 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> <PopoverTrigger>
{#snippet child({ props })} {#snippet child({ props })}
<Button <button
{...props} {...props}
variant="ghost" class={cn(
class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer" 'flex flex-col items-center justify-center w-14 py-1',
size="icon" '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} aria-label={controlLabel}
> >
{control.value} <!-- Label row -->
</Button> {#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} {/snippet}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent class="w-auto p-4">
<div class="flex flex-col items-center gap-3"> <!-- Vertical slider popover -->
<Slider <PopoverContent
min={control.min} 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]"
max={control.max} align="center"
step={control.step} side="top"
value={sliderValue} >
onValueChange={handleSliderChange} <Slider
type="single" class="h-full"
orientation="vertical" bind:value={control.value}
class="h-48" min={control.min}
/> max={control.max}
<Input step={control.step}
value={control.value} orientation="vertical"
onchange={handleInputChange} />
min={control.min}
max={control.max}
class="w-16 text-center"
/>
</div>
</PopoverContent> </PopoverContent>
</PopoverRoot> </PopoverRoot>
</div>
{#if !reduced} <!-- Increase button -->
<IconButton <Button
aria-label={increaseLabel} variant="secondary"
onclick={control.increase} onclick={control.increase}
disabled={control.isAtMax} disabled={control.isAtMax}
rotation="clockwise" aria-label={increaseLabel}
> >
{#snippet icon({ className })} <PlusIcon class="size-3.5 stroke-2" />
<PlusIcon class={className} /> </Button>
{/snippet} </div>
</IconButton> {/if}
{/if}
</TooltipTrigger>
</ButtonGroupRoot>
{#if controlLabel}
<TooltipContent>
{controlLabel}
</TooltipContent>
{/if}
</TooltipRoot>

View File

@@ -1,123 +0,0 @@
<script module>
import { createTypographyControl } from '$shared/lib';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ComboControlV2 from './ComboControlV2.svelte';
const { Story } = defineMeta({
title: 'Shared/ComboControlV2',
component: ComboControlV2,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'ComboControl with input field and slider. Simplified version without increase/decrease buttons.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Orientation of the ComboControl',
defaultValue: 'vertical',
},
label: {
control: 'text',
description: 'Label for the ComboControl',
},
control: {
control: 'object',
description: 'TypographyControl instance managing the value and bounds',
},
},
});
</script>
<script lang="ts">
const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
const verticalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
const floatControl = createTypographyControl({ min: 0, max: 1, step: 0.01, value: 0.5 });
const atMinControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 0 });
const atMaxControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 100 });
const largeRangeControl = createTypographyControl({ min: 0, max: 1000, step: 10, value: 500 });
</script>
<Story
name="Horizontal"
args={{
control: horizontalControl,
orientation: 'horizontal',
label: 'Size',
}}
>
{#snippet template(args)}
<ComboControlV2 control={horizontalControl} orientation="horizontal" label="Size" {...args} />
{/snippet}
</Story>
<Story
name="Vertical"
args={{
control: verticalControl,
orientation: 'vertical',
label: 'Size',
}}
>
{#snippet template(args)}
<ComboControlV2 control={verticalControl} orientation="vertical" class="h-48" label="Size" {...args} />
{/snippet}
</Story>
<Story
name="With Float Values"
args={{
control: floatControl,
orientation: 'vertical',
label: 'Opacity',
}}
>
{#snippet template(args)}
<ComboControlV2 control={floatControl} orientation="vertical" class="h-48" label="Opacity" {...args} />
{/snippet}
</Story>
<Story
name="At Minimum"
args={{
control: atMinControl,
orientation: 'horizontal',
label: 'Size',
}}
>
{#snippet template(args)}
<ComboControlV2 control={atMinControl} orientation="horizontal" label="Size" {...args} />
{/snippet}
</Story>
<Story
name="At Maximum"
args={{
control: atMaxControl,
orientation: 'horizontal',
label: 'Size',
}}
>
{#snippet template(args)}
<ComboControlV2 control={atMaxControl} orientation="horizontal" label="Size" {...args} />
{/snippet}
</Story>
<Story
name="Large Range"
args={{
control: largeRangeControl,
orientation: 'horizontal',
label: 'Scale',
}}
>
{#snippet template(args)}
<ComboControlV2 control={largeRangeControl} orientation="horizontal" label="Scale" {...args} />
{/snippet}
</Story>

View File

@@ -1,231 +0,0 @@
<!--
Component: ComboControl
Provides the same functionality as the original ComboControl but lacks increase/decrease buttons.
-->
<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 {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import {
Content as TooltipContent,
Root as TooltipRoot,
Trigger as TooltipTrigger,
} from '$shared/shadcn/ui/tooltip';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Input } from '$shared/ui';
import { Slider } from '$shared/ui';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import {
type Orientation,
REGEXP_ONLY_DIGITS,
} from 'bits-ui';
import type { ChangeEventHandler } from 'svelte/elements';
import IconButton from '../IconButton/IconButton.svelte';
interface Props {
/**
* Control instance
*/
control: TypographyControl;
/**
* Orientation
*/
orientation?: Orientation;
/**
* Label text
*/
label?: string;
/**
* CSS class
*/
class?: string;
/**
* Show scale flag
*/
showScale?: boolean;
/**
* Flag that change component appearance
* from the one with increase/decrease buttons and popover with input + slider
* to just input + slider
*/
reduced?: boolean;
/**
* Text for increase button aria-label
*/
increaseLabel?: string;
/**
* Text for decrease button aria-label
*/
decreaseLabel?: string;
/**
* Text for control button aria-label
*/
controlLabel?: string;
}
let {
control,
orientation = 'vertical',
label,
class: className,
showScale = true,
reduced = false,
increaseLabel = 'Increase',
decreaseLabel = 'Decrease',
controlLabel,
}: Props = $props();
let inputValue = $state(String(control.value));
$effect(() => {
inputValue = String(control.value);
});
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) {
control.value = parsedValue;
inputValue = String(parsedValue);
}
};
function calculateScale(index: number): number | string {
const calculate = () =>
orientation === 'horizontal'
? control.min + (index * (control.max - control.min)) / 4
: control.max - (index * (control.max - control.min)) / 4;
return Number.isInteger(control.step)
? Math.round(calculate())
: calculate().toFixed(2);
}
</script>
{#snippet ComboControl()}
<div
class={cn(
'flex gap-4 sm:py-4 sm:px-1 rounded-xl transition-all duration-300',
'',
orientation === 'horizontal'
? 'flex-row items-end w-full'
: 'flex-col items-center h-full',
className,
)}
>
<div class={cn('relative', orientation === 'horizontal' ? 'w-full' : 'h-full')}>
{#if showScale}
<div
class={cn(
'absolute flex justify-between',
orientation === 'horizontal'
? 'flex-row w-full -top-8 px-0.5'
: 'flex-col h-full -left-5 py-0.5',
)}
>
{#each Array(5) as _, i}
<div
class={cn(
'flex items-center gap-1.5',
orientation === 'horizontal' ? 'flex-col' : 'flex-row',
)}
>
<span class="font-mono text-[0.375rem] text-text-muted tabular-nums">
{calculateScale(i)}
</span>
<div
class={cn(
'bg-border-muted',
orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1',
)}
>
</div>
</div>
{/each}
</div>
{/if}
<Slider
class={cn(orientation === 'horizontal' ? 'w-full' : 'h-full')}
bind:value={control.value}
min={control.min}
max={control.max}
step={control.step}
{label}
{orientation}
/>
</div>
{#if !reduced}
<Input
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
value={inputValue}
onchange={handleInputChange}
min={control.min}
max={control.max}
step={control.step}
pattern={REGEXP_ONLY_DIGITS}
variant="ghost"
/>
{/if}
</div>
{/snippet}
{#if reduced}
{@render ComboControl()}
{:else}
<TooltipRoot>
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
<TooltipTrigger class="flex items-center">
<IconButton
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
rotation="counterclockwise"
>
{#snippet icon({ className })}
<MinusIcon class={className} />
{/snippet}
</IconButton>
<PopoverRoot>
<PopoverTrigger>
{#snippet child({ props })}
<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"
aria-label={controlLabel}
>
{control.value}
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-auto h-64 sm:px-1 py-0">
{@render ComboControl()}
</PopoverContent>
</PopoverRoot>
<IconButton
aria-label={increaseLabel}
onclick={control.increase}
disabled={control.isAtMax}
rotation="clockwise"
>
{#snippet icon({ className })}
<PlusIcon class={className} />
{/snippet}
</IconButton>
</TooltipTrigger>
</ButtonGroupRoot>
{#if controlLabel}
<TooltipContent>
{controlLabel}
</TooltipContent>
{/if}
</TooltipRoot>
{/if}