Files
frontend-svelte/src/shared/ui/ComboControl/ComboControl.svelte
T

186 lines
5.7 KiB
Svelte
Raw Normal View History

<!--
Component: ComboControl
Typography value control: surface +/ buttons flanking a two-line trigger
that opens a vertical slider popover.
-->
2026-01-05 09:03:31 +03:00
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Slider } from '$shared/ui';
import { Button } from '$shared/ui/Button';
2026-01-05 09:03:31 +03:00
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import { Popover } from 'bits-ui';
import clsx from 'clsx';
import TechText from '../TechText/TechText.svelte';
2026-01-05 09:03:31 +03:00
interface Props {
/**
* Typography control
*/
control: TypographyControl;
/**
* Control label
*/
label?: string;
/**
* CSS classes
*/
class?: string;
/**
* Reduced layout
* @default false
*/
reduced?: boolean;
/**
* Increase button label
* @default 'Increase'
*/
2026-01-05 09:03:31 +03:00
increaseLabel?: string;
/**
* Decrease button label
* @default 'Decrease'
*/
2026-01-05 09:03:31 +03:00
decreaseLabel?: string;
/**
* Control aria label
*/
2026-01-05 09:03:31 +03:00
controlLabel?: string;
}
let {
control,
label,
class: className,
reduced = false,
increaseLabel = 'Increase',
decreaseLabel = 'Decrease',
controlLabel,
}: Props = $props();
2026-01-05 09:03:31 +03:00
let open = $state(false);
2026-01-05 09:03:31 +03:00
// 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);
});
2026-01-05 09:03:31 +03:00
// Display label: prefer explicit prop, fall back to controlLabel
const displayLabel = $derived(label ?? controlLabel ?? '');
2026-01-05 09:03:31 +03:00
</script>
<!--
REDUCED MODE
Inline slider + value. No buttons, no popover.
-->
{#if reduced}
<div
class={clsx(
'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-xs text-secondary tabular-nums w-10 text-right shrink-0">
{formattedValue()}
</span>
</div>
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
{:else}
<div class={clsx('flex items-center px-1 relative', className)}>
<!-- Decrease button -->
<Button
variant="icon"
size="sm"
onclick={control.decrease}
disabled={control.isAtMin}
aria-label={decreaseLabel}
>
{#snippet icon()}
<MinusIcon class="size-3.5 stroke-2" />
{/snippet}
</Button>
<!-- Trigger -->
<div class="relative mx-1">
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button
{...props}
class={clsx(
'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-paper dark:bg-dark-card shadow-sm border-subtle'
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
)}
aria-label={controlLabel}
>
<!-- Label row -->
{#if displayLabel}
<span
class="
text-3xs 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}
</Popover.Trigger>
<!-- Vertical slider popover -->
<Popover.Content
class="w-auto py-4 px-3 h-64 flex items-center justify-center rounded-none border border-subtle shadow-sm bg-paper dark:bg-dark-card"
align="center"
side="top"
>
<Slider
class="h-full"
bind:value={control.value}
min={control.min}
max={control.max}
step={control.step}
orientation="vertical"
/>
</Popover.Content>
</Popover.Root>
</div>
<!-- Increase button -->
<Button
variant="icon"
size="sm"
onclick={control.increase}
disabled={control.isAtMax}
aria-label={increaseLabel}
>
{#snippet icon()}
<PlusIcon class="size-3.5 stroke-2" />
{/snippet}
</Button>
</div>
{/if}