mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-03 05:29:05 +00:00
94 lines
2.6 KiB
Svelte
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>
|