2026-01-21 21:50:30 +03:00
|
|
|
<!--
|
|
|
|
|
Component: ComboControl
|
|
|
|
|
Provides the same functionality as the original ComboControl but lacks increase/decrease buttons.
|
|
|
|
|
-->
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import type { TypographyControl } from '$shared/lib';
|
2026-02-09 09:30:34 +03:00
|
|
|
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';
|
2026-01-21 21:50:30 +03:00
|
|
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
2026-02-08 14:31:15 +03:00
|
|
|
import { Input } from '$shared/ui';
|
|
|
|
|
import { Slider } from '$shared/ui';
|
2026-02-09 09:30:34 +03:00
|
|
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
|
|
|
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
|
|
|
|
import {
|
|
|
|
|
type Orientation,
|
|
|
|
|
REGEXP_ONLY_DIGITS,
|
|
|
|
|
} from 'bits-ui';
|
2026-01-21 21:50:30 +03:00
|
|
|
import type { ChangeEventHandler } from 'svelte/elements';
|
2026-02-09 09:30:34 +03:00
|
|
|
import IconButton from '../IconButton/IconButton.svelte';
|
2026-01-21 21:50:30 +03:00
|
|
|
|
|
|
|
|
interface Props {
|
2026-02-07 18:07:28 +03:00
|
|
|
/**
|
2026-02-08 14:31:15 +03:00
|
|
|
* Control instance
|
2026-02-07 18:07:28 +03:00
|
|
|
*/
|
2026-01-21 21:50:30 +03:00
|
|
|
control: TypographyControl;
|
2026-02-07 18:07:28 +03:00
|
|
|
/**
|
2026-02-08 14:31:15 +03:00
|
|
|
* Orientation
|
2026-02-07 18:07:28 +03:00
|
|
|
*/
|
|
|
|
|
orientation?: Orientation;
|
2026-02-08 14:31:15 +03:00
|
|
|
/**
|
|
|
|
|
* Label text
|
|
|
|
|
*/
|
|
|
|
|
label?: string;
|
|
|
|
|
/**
|
|
|
|
|
* CSS class
|
|
|
|
|
*/
|
|
|
|
|
class?: string;
|
2026-02-09 09:30:34 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-01-21 21:50:30 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let {
|
|
|
|
|
control,
|
2026-02-07 18:07:28 +03:00
|
|
|
orientation = 'vertical',
|
2026-02-08 14:31:15 +03:00
|
|
|
label,
|
|
|
|
|
class: className,
|
2026-02-09 09:30:34 +03:00
|
|
|
showScale = true,
|
|
|
|
|
reduced = false,
|
|
|
|
|
increaseLabel = 'Increase',
|
|
|
|
|
decreaseLabel = 'Decrease',
|
|
|
|
|
controlLabel,
|
2026-01-21 21:50:30 +03:00
|
|
|
}: Props = $props();
|
|
|
|
|
|
2026-02-08 14:31:15 +03:00
|
|
|
let inputValue = $state(String(control.value));
|
2026-01-21 21:50:30 +03:00
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-02-08 14:31:15 +03:00
|
|
|
inputValue = String(control.value);
|
2026-01-21 21:50:30 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
|
|
|
|
const parsedValue = parseFloat(event.currentTarget.value);
|
|
|
|
|
if (!isNaN(parsedValue)) {
|
|
|
|
|
control.value = parsedValue;
|
2026-02-08 14:31:15 +03:00
|
|
|
inputValue = String(parsedValue);
|
2026-01-21 21:50:30 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-09 09:30:34 +03:00
|
|
|
function calculateScale(index: number): number | string {
|
|
|
|
|
const calculate = () =>
|
|
|
|
|
orientation === 'horizontal'
|
2026-02-18 16:55:11 +03:00
|
|
|
? control.min + (index * (control.max - control.min)) / 4
|
|
|
|
|
: control.max - (index * (control.max - control.min)) / 4;
|
2026-02-09 09:30:34 +03:00
|
|
|
return Number.isInteger(control.step)
|
|
|
|
|
? Math.round(calculate())
|
2026-02-18 16:55:11 +03:00
|
|
|
: calculate().toFixed(2);
|
2026-02-09 09:30:34 +03:00
|
|
|
}
|
|
|
|
|
</script>
|
2026-02-08 14:31:15 +03:00
|
|
|
|
2026-02-09 09:30:34 +03:00
|
|
|
{#snippet ComboControl()}
|
|
|
|
|
<div
|
|
|
|
|
class={cn(
|
2026-02-10 18:05:48 +03:00
|
|
|
'flex gap-4 sm:py-4 sm:px-1 rounded-xl transition-all duration-300',
|
2026-02-10 23:19:27 +03:00
|
|
|
'',
|
2026-02-18 16:55:11 +03:00
|
|
|
orientation === 'horizontal'
|
|
|
|
|
? 'flex-row items-end w-full'
|
|
|
|
|
: 'flex-col items-center h-full',
|
2026-02-09 09:30:34 +03:00
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div class={cn('relative', orientation === 'horizontal' ? 'w-full' : 'h-full')}>
|
|
|
|
|
{#if showScale}
|
2026-02-08 14:31:15 +03:00
|
|
|
<div
|
|
|
|
|
class={cn(
|
2026-02-09 09:30:34 +03:00
|
|
|
'absolute flex justify-between',
|
2026-02-18 16:55:11 +03:00
|
|
|
orientation === 'horizontal'
|
|
|
|
|
? 'flex-row w-full -top-8 px-0.5'
|
|
|
|
|
: 'flex-col h-full -left-5 py-0.5',
|
2026-02-08 14:31:15 +03:00
|
|
|
)}
|
|
|
|
|
>
|
2026-02-09 09:30:34 +03:00
|
|
|
{#each Array(5) as _, i}
|
|
|
|
|
<div
|
|
|
|
|
class={cn(
|
|
|
|
|
'flex items-center gap-1.5',
|
|
|
|
|
orientation === 'horizontal' ? 'flex-col' : 'flex-row',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-02-10 23:19:27 +03:00
|
|
|
<span class="font-mono text-[0.375rem] text-text-muted tabular-nums">
|
2026-02-09 09:30:34 +03:00
|
|
|
{calculateScale(i)}
|
|
|
|
|
</span>
|
2026-02-18 16:55:11 +03:00
|
|
|
<div
|
|
|
|
|
class={cn(
|
|
|
|
|
'bg-border-muted',
|
|
|
|
|
orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1',
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-02-09 09:30:34 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
2026-02-08 14:31:15 +03:00
|
|
|
</div>
|
2026-02-09 09:30:34 +03:00
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<Slider
|
|
|
|
|
class={cn(orientation === 'horizontal' ? 'w-full' : 'h-full')}
|
|
|
|
|
bind:value={control.value}
|
|
|
|
|
min={control.min}
|
|
|
|
|
max={control.max}
|
|
|
|
|
step={control.step}
|
2026-02-18 16:55:11 +03:00
|
|
|
{label}
|
2026-02-09 09:30:34 +03:00
|
|
|
{orientation}
|
|
|
|
|
/>
|
2026-02-08 14:31:15 +03:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-15 23:05:58 +03:00
|
|
|
{#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}
|
2026-02-08 14:31:15 +03:00
|
|
|
</div>
|
2026-02-09 09:30:34 +03:00
|
|
|
{/snippet}
|
2026-02-08 14:31:15 +03:00
|
|
|
|
2026-02-09 09:30:34 +03:00
|
|
|
{#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"
|
2026-02-10 23:19:27 +03:00
|
|
|
class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
2026-02-09 09:30:34 +03:00
|
|
|
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}
|