Files
frontend-svelte/src/shared/ui/ComboControl/ComboControl.svelte
T
2026-04-23 14:59:32 +03:00

186 lines
5.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Component: ComboControl
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 { cn } from '$shared/lib';
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 { Popover } from 'bits-ui';
import TechText from '../TechText/TechText.svelte';
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'
*/
increaseLabel?: string;
/**
* Decrease button label
* @default 'Decrease'
*/
decreaseLabel?: string;
/**
* Control aria label
*/
controlLabel?: string;
}
let {
control,
label,
class: className,
reduced = false,
increaseLabel = 'Increase',
decreaseLabel = 'Decrease',
controlLabel,
}: Props = $props();
let open = $state(false);
// 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);
});
// Display label: prefer explicit prop, fall back to controlLabel
const displayLabel = $derived(label ?? controlLabel ?? '');
</script>
<!--
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-xs text-secondary 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="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={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-paper dark:bg-dark-card shadow-sm border-subtle'
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
)}
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
>
<!-- 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}