feat(ComboControl): replace ComboControl with redesigned ComboControlV2
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
{/snippet}
|
|
||||||
</IconButton>
|
|
||||||
{/if}
|
|
||||||
<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 p-4">
|
|
||||||
<div class="flex flex-col items-center gap-3">
|
|
||||||
<Slider
|
<Slider
|
||||||
|
class="w-full"
|
||||||
|
bind:value={control.value}
|
||||||
min={control.min}
|
min={control.min}
|
||||||
max={control.max}
|
max={control.max}
|
||||||
step={control.step}
|
step={control.step}
|
||||||
value={sliderValue}
|
orientation="horizontal"
|
||||||
onValueChange={handleSliderChange}
|
|
||||||
type="single"
|
|
||||||
orientation="vertical"
|
|
||||||
class="h-48"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<span
|
||||||
value={control.value}
|
class="font-mono text-[0.6875rem] text-neutral-500 dark:text-neutral-400 tabular-nums w-10 text-right shrink-0"
|
||||||
onchange={handleInputChange}
|
>
|
||||||
|
{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
|
||||||
|
{...props}
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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}
|
min={control.min}
|
||||||
max={control.max}
|
max={control.max}
|
||||||
class="w-16 text-center"
|
step={control.step}
|
||||||
|
orientation="vertical"
|
||||||
/>
|
/>
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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}
|
|
||||||
Reference in New Issue
Block a user