import { context, type WithRefOpts } from '../../provider'; import { createAttrs } from '$uix/morfo'; import type { ActiveProps, StateProps } from '$libs/reactive'; import type { Orientation, Direction, SomaMouseEvent, SomaKeyboardEvent } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { toggleGroupMorfo } from '../../../morfo/components/toggle-group'; import { Soma } from '../../core/soma.svelte'; import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte'; import type { ToggleGroupType } from './types'; const attrs = createAttrs(toggleGroupMorfo); // ── Root ───────────────────────────────────────────────────────────────────── interface ToggleGroupOpts extends WithRefOpts, StateProps<{ value: string[] }>, ActiveProps<{ type: ToggleGroupType; deselectable: boolean; disabled: boolean; orientation: Orientation; loop: boolean; dir: Direction; }> {} export class ToggleGroupProvider { readonly opts: ToggleGroupOpts; readonly runtimePart: SomaRuntimePart; readonly soma: Soma; readonly runtime: SomaRuntime; static readonly ctx = context('ToggleGroup'); static get(): ToggleGroupProvider | undefined { return this.ctx.getOr(undefined) as ToggleGroupProvider | undefined; } static require(): ToggleGroupProvider { return this.ctx.get(); } static create(opts: ToggleGroupOpts) { return new ToggleGroupProvider(opts); } private constructor(opts: ToggleGroupOpts) { this.opts = opts; this.soma = Soma.require(); this.runtime = this.soma.runtime(toggleGroupMorfo, { props: { orientation: () => this.opts.orientation.current, disabled: () => this.opts.disabled.current } }); this.runtimePart = this.runtime.part('provider', { id: opts.id, ref: opts.ref, owner: this, context: ToggleGroupProvider.ctx, syncAttrs: true }); } isItemPressed(value: string): boolean { return this.opts.value.current.includes(value); } toggleItem(value: string) { if (this.opts.disabled.current) return; const current = this.opts.value.current; const isPressed = current.includes(value); let next: string[]; if (this.opts.type.current === 'single') { if (isPressed) { // Re-pressing the active item deselects it (→ empty) only when // `deselectable`; otherwise it stays pressed (radio-like — a // selection is required) and this is a no-op. if (!this.opts.deselectable.current) return; next = []; } else { next = [value]; } } else { next = isPressed ? current.filter((v) => v !== value) : [...current, value]; } this.opts.value.current = next; } /** Get all enabled item elements for keyboard navigation, scoped to this root. */ getItems(): HTMLElement[] { const root = this.opts.ref?.current; if (!root) return []; return Array.from( root.querySelectorAll(`[${attrs.item}]:not([data-disabled])`) ).filter((el) => el.closest(`[${attrs.provider}]`) === root); } readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, dir: this.opts.dir.current } as const) ); } // ── Item ───────────────────────────────────────────────────────────────────── interface ToggleGroupItemOpts extends WithRefOpts, ActiveProps<{ value: string; disabled: boolean; }> {} export class ToggleGroupItemProvider { readonly opts: ToggleGroupItemOpts; readonly runtimePart: SomaRuntimePart; static create(opts: ToggleGroupItemOpts) { return new ToggleGroupItemProvider(opts); } readonly provider: ToggleGroupProvider; private constructor(opts: ToggleGroupItemOpts) { this.opts = opts; this.provider = ToggleGroupProvider.require(); this.runtimePart = this.provider.runtime.part('item', { id: opts.id, ref: opts.ref, states: { on: () => this.isPressed }, props: { disabled: () => this.isDisabled, orientation: () => this.provider.opts.orientation.current }, owner: this, syncAttrs: true }); } readonly isPressed = $derived.by(() => this.provider.isItemPressed(this.opts.value.current)); readonly isDisabled = $derived.by( () => this.opts.disabled.current || this.provider.opts.disabled.current ); /** Whether this item should be the roving tabindex target (tabindex=0). * Only ONE item gets tabindex=0: the first pressed item, or (if none pressed) the first enabled item. */ readonly isRovingTarget = $derived.by(() => { const items = this.provider.getItems(); if (items.length === 0) return false; const el = this.opts.ref?.current; if (!el) return false; // Find the first pressed item in DOM order. const firstPressed = items.find((item) => { const value = item.getAttribute('data-value'); return value ? this.provider.isItemPressed(value) : false; }); if (firstPressed) return firstPressed === el; // No pressed items — fallback to first enabled item return items[0] === el; }); readonly onclick = (e: SomaMouseEvent) => { if (this.isDisabled) return; this.provider.toggleItem(this.opts.value.current); // Morfo declares `commit-toggle` on the `item` part. Pass the // clicked button as `fallbackTarget` so the cascade selector // `[data-toggle-group-item][data-event="commit-toggle"]` matches // this exact item (the runtime would otherwise see N registered // items under the same kebab and pick arbitrarily). void this.provider.runtime.trigger('commit-toggle', { fallbackTarget: e.currentTarget }); }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (this.isDisabled) return; const orientation = this.provider.opts.orientation.current; const dir = this.provider.opts.dir.current; const { nextKey, prevKey } = getDirectionalKeys(dir, orientation); const items = this.provider.getItems(); const currentIndex = items.indexOf(e.currentTarget as HTMLElement); const loop = this.provider.opts.loop.current; let targetIndex = -1; if (e.key === nextKey) { e.preventDefault(); targetIndex = loop ? (currentIndex + 1) % items.length : Math.min(currentIndex + 1, items.length - 1); } else if (e.key === prevKey) { e.preventDefault(); targetIndex = loop ? (currentIndex - 1 + items.length) % items.length : Math.max(currentIndex - 1, 0); } else if (e.key === KEYS.HOME) { e.preventDefault(); targetIndex = 0; } else if (e.key === KEYS.END) { e.preventDefault(); targetIndex = items.length - 1; } if (targetIndex >= 0 && items[targetIndex]) { this.provider.soma.dom.focus(items[targetIndex]); } }; readonly props = $derived.by(() => this.runtimePart.assert({ ...this.runtimePart.props, tabindex: this.isRovingTarget ? 0 : -1, 'data-value': this.opts.value.current, disabled: this.isDisabled || undefined, onclick: this.onclick, onkeydown: this.onkeydown } as const) ); }