feat(ComboControl): replace ComboControl with redesigned ComboControlV2
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user