Files
dockhand/lib/components/ui/toggle-pill/toggle-group.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

94 lines
2.6 KiB
Svelte

<script lang="ts">
/**
* ToggleGroup - A pill-style toggle for switching between multiple values (2+).
* Extends ToggleSwitch to support any number of options.
* Perfect for settings like scanner selection: "None / Trivy / Grype / Both"
*
* Keyboard navigation:
* - Tab: Focus the selected option (or first if none selected)
* - Arrow Left/Right: Navigate between options
* - Space/Enter: Select the focused option
*/
import type { Component } from 'svelte';
interface Option {
value: string;
label?: string;
icon?: Component;
}
interface Props {
value: string;
options: Option[];
disabled?: boolean;
onchange?: (value: string) => void;
}
let {
value = $bindable(),
options,
disabled = false,
onchange
}: Props = $props();
let buttons = $state<HTMLButtonElement[]>([]);
function select(optionValue: string) {
if (disabled || value === optionValue) return;
value = optionValue;
onchange?.(value);
}
function handleKeydown(e: KeyboardEvent, index: number) {
if (disabled) return;
let nextIndex = index;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
nextIndex = (index + 1) % options.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
nextIndex = (index - 1 + options.length) % options.length;
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
select(options[index].value);
return;
} else {
return;
}
// Move focus and select the new option
buttons[nextIndex]?.focus();
select(options[nextIndex].value);
}
</script>
<div
class="inline-flex items-center rounded-md bg-muted p-0.5 text-xs font-medium {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
role="radiogroup"
>
{#each options as option, i}
{@const isSelected = value === option.value}
{@const displayLabel = option.label ?? option.value}
<button
bind:this={buttons[i]}
type="button"
role="radio"
aria-checked={isSelected}
tabindex={isSelected ? 0 : -1}
class="px-2 py-1 rounded transition-colors flex items-center gap-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 {isSelected
? 'bg-slate-300 text-slate-900 shadow-sm dark:bg-amber-900/50 dark:text-amber-100'
: 'text-muted-foreground hover:text-foreground'} {disabled ? 'cursor-not-allowed' : ''}"
onclick={() => select(option.value)}
onkeydown={(e) => handleKeydown(e, i)}
{disabled}
>
{#if option.icon}
<svelte:component this={option.icon} class="w-3 h-3" />
{/if}
{displayLabel}
</button>
{/each}
</div>