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.
231 lines
7.0 KiB
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)
|
|
);
|
|
}
|