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

190 lines
5.9 KiB
Svelte
Raw Normal View History

<!--
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 {
Content as PopoverContent,
Root as PopoverRoot,
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
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 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-[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="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">
<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-paper dark:bg-dark-card shadow-sm border-black/5 dark:border-white/10'
: 'hover:bg-paper/50 dark:hover:bg-dark-card/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-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"
/>
</PopoverContent>
</PopoverRoot>
</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}