toggle group: add arrow key navigation

This commit is contained in:
Elijah Duffy
2025-07-10 17:17:46 -07:00
parent e26ed5403a
commit 5b37bda207
4 changed files with 58 additions and 23 deletions

View File

@@ -2,7 +2,6 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { ClassValue, HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements'; import type { ClassValue, HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
import type { IconComponentProps } from 'phosphor-svelte';
import { defaultIconProps, type IconDef } from './util'; import { defaultIconProps, type IconDef } from './util';
interface Props extends Omit<HTMLButtonAttributes, 'class'> { interface Props extends Omit<HTMLButtonAttributes, 'class'> {

View File

@@ -27,16 +27,23 @@
const id = $derived(generateIdentifier('toggle-group', name)); const id = $derived(generateIdentifier('toggle-group', name));
const extended: { detail: Option; ref?: ToggleSelect }[] = $derived.by(() => {
return options.map((opt) => ({
detail: opt
}));
});
let inputElement: HTMLInputElement | undefined = $state<HTMLInputElement>(); let inputElement: HTMLInputElement | undefined = $state<HTMLInputElement>();
let valid: boolean = $state(true); let valid: boolean = $state(true);
const makeSelectedHandler = (name: string) => { const makeSelectedHandler = (name: string) => {
return (toggled: boolean) => { return (toggled: boolean) => {
if (!inputElement) return; if (inputElement) {
if (toggled) selected = [...selected, name]; if (toggled) selected = [...selected, name];
else selected = selected.filter((item) => item !== name); else selected = selected.filter((item) => item !== name);
inputElement.value = JSON.stringify(selected); inputElement.value = JSON.stringify(selected);
validateInput(inputElement); // trigger validation on hidden input validateInput(inputElement); // trigger validation on hidden input
}
}; };
}; };
</script> </script>
@@ -61,12 +68,26 @@
/> />
{/if} {/if}
{#each options as opt} {#each extended as opt, i (getValue(opt.detail))}
<ToggleSelect <ToggleSelect
name={name ? undefined : `toggle_${getValue(opt)}`} bind:this={extended[i].ref}
ontoggle={makeSelectedHandler(getValue(opt))} ontoggle={makeSelectedHandler(getValue(opt.detail))}
onkeydown={(e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
if (i < extended.length - 1) {
extended[i + 1].ref?.focus();
}
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
if (i > 0) {
extended[i - 1].ref?.focus();
}
} else {
return;
}
e.preventDefault();
}}
> >
<span class={[typeof opt === 'string' && 'capitalize']}>{getLabel(opt)}</span> <span class={[typeof opt === 'string' && 'capitalize']}>{getLabel(opt.detail)}</span>
</ToggleSelect> </ToggleSelect>
{/each} {/each}
</div> </div>

View File

@@ -1,23 +1,41 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { ClassValue, MouseEventHandler } from 'svelte/elements'; import type { HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements';
interface Props { interface Props extends Omit<HTMLButtonAttributes, 'type' | 'ontoggle'> {
ref?: HTMLButtonElement | null;
name?: string; name?: string;
selected?: boolean; selected?: boolean;
class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
ontoggle?: (selected: boolean) => void; ontoggle?: (selected: boolean) => void;
} }
let { name, selected = false, class: classValue, children, onclick, ontoggle }: Props = $props(); let {
ref = $bindable(null),
name,
selected = false,
class: classValue,
children,
onclick,
ontoggle,
...others
}: Props = $props();
const handleToggleSelectClick: MouseEventHandler<HTMLButtonElement> = (event) => { const handleToggleSelectClick: MouseEventHandler<HTMLButtonElement> = (event) => {
selected = !selected; // update state selected = !selected; // update state
ontoggle?.(selected); // send event to parent ontoggle?.(selected); // send event to parent
onclick?.(event); // pass onclick handler through onclick?.(event); // pass onclick handler through
}; };
export const toggle = () => {
selected = !selected; // update state
ontoggle?.(selected); // send event to parent
};
export const focus = () => {
ref?.focus();
};
</script> </script>
{#if name} {#if name}
@@ -25,6 +43,8 @@
{/if} {/if}
<button <button
bind:this={ref}
{...others}
class={[ class={[
'rounded-3xl border px-6 py-2.5 font-medium transition-colors', 'rounded-3xl border px-6 py-2.5 font-medium transition-colors',
selected selected

View File

@@ -204,21 +204,16 @@
</div> </div>
<div class="component"> <div class="component">
<p class="title">Toggle Group</p> <p class="title">Toggle</p>
<ToggleSelect name="example-toggle-select" class="mb-4">Toggle Me!</ToggleSelect>
<ToggleGroup <ToggleGroup
name="example-toggle-group" label="Toggle Group"
label="Toggler"
options={['item one', 'item two', { value: 'complex', label: 'Complex item' }]} options={['item one', 'item two', { value: 'complex', label: 'Complex item' }]}
/> />
</div> </div>
<div class="component">
<p class="title">Toggle Select</p>
<ToggleSelect name="example-toggle-select">Toggle Me!</ToggleSelect>
</div>
<div class="component"> <div class="component">
<p class="title">Toolbar</p> <p class="title">Toolbar</p>