You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/toggle-group/toggle-group-provider.svelt...

231 lines
7.0 KiB

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<ToggleGroupProvider>('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<HTMLElement>(`[${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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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)
);
}

Powered by TurnKey Linux.