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/grid-list/grid-list-provider.svelte.ts

632 lines
20 KiB

import { context, type WithRefOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef } from '$adom';
import { readableActive, type Active, type ActiveProps, type StateProps } from '$libs/reactive';
import type { OnChangeFn, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { FieldProvider } from '../field/field-provider.svelte';
import { GRID_LIST_LANGS } from './langs';
import type { GridListSelectionMode } from './types';
import { gridListMorfo } from '../../../morfo/components/grid-list';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { TimerHandle } from '$timer';
const attrs = createAttrs(gridListMorfo);
// ── Root provider ──────────────────────────────────────────────────────────
interface GridListOpts
extends
WithRefOpts,
StateProps<{ value: string[] }>,
ActiveProps<{
selectionMode: GridListSelectionMode;
loop: boolean;
typeahead: boolean;
typeaheadTimeout: number;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
name: string | undefined;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onValueChange: OnChangeFn<string[]> | undefined;
}> {}
export class GridListProvider {
readonly opts: GridListOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<GridListProvider>('GridList');
static get(): GridListProvider | undefined {
return this.ctx.getOr(undefined) as GridListProvider | undefined;
}
static require(): GridListProvider {
return this.ctx.get();
}
static create(opts: GridListOpts) {
return new GridListProvider(opts);
}
readonly field = FieldProvider.get();
private typeaheadBuffer = '';
private typeaheadTimer: TimerHandle | null = null;
/** Anchor for Shift+Click range selection. */
private anchor: string | null = null;
private constructor(opts: GridListOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(gridListMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: GridListProvider.ctx
});
$effect(() => {
return () => this.clearTypeaheadTimer();
});
}
// ── Field-aware flags ───────────────────────────────────────────────────
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
);
readonly isReadonly = $derived.by(
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
);
readonly isRequired = $derived.by(
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly isInvalid = $derived.by(
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
);
readonly resolvedDir: Active<Direction> = readableActive(
() => this.soma.prefs.getDir() ?? 'ltr'
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
this.opts.ariaLabelledby.current
? undefined
: this.opts.ariaLabel.current || this.soma.langs.ts(GRID_LIST_LANGS.LABEL) || undefined
);
readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0);
// ── DOM queries ─────────────────────────────────────────────────────────
/** Enabled row elements in DOM order, scoped to this root. */
getRows(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>(`[${attrs.row}]:not([data-disabled])`)
).filter((el) => el.closest(`[${attrs.provider}]`) === root);
}
/** All rows including disabled — used for range-select over contiguous rows. */
private getAllRows(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
return Array.from(root.querySelectorAll<HTMLElement>(`[${attrs.row}]`)).filter(
(el) => el.closest(`[${attrs.provider}]`) === root
);
}
/** Single lifted derivation for the roving-tabindex target — O(1) per row. */
readonly rovingTargetEl = $derived.by(() => {
const rows = this.getRows();
if (rows.length === 0) return undefined;
const selected = new Set(this.opts.value.current);
return rows.find((el) => selected.has(el.getAttribute('data-value') ?? '')) ?? rows[0];
});
// ── Selection ───────────────────────────────────────────────────────────
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
}
select(value: string, mode: 'replace' | 'toggle' | 'range') {
if (this.isDisabled || this.isReadonly) return;
const selectionMode = this.opts.selectionMode.current;
if (selectionMode === 'none') return;
const current = this.opts.value.current;
let next: string[];
if (mode === 'range' && selectionMode === 'multiple' && this.anchor) {
next = this.computeRange(this.anchor, value);
} else if (mode === 'toggle' && selectionMode === 'multiple') {
next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
} else {
// 'replace' or 'toggle' on single mode
next = current.length === 1 && current[0] === value ? current : [value];
}
if (next !== current) {
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
}
if (mode !== 'range') this.anchor = value;
}
private computeRange(from: string, to: string): string[] {
const all = this.getAllRows().map((el) => el.getAttribute('data-value') ?? '');
const i = all.indexOf(from);
const j = all.indexOf(to);
if (i < 0 || j < 0) return [to];
const [lo, hi] = i <= j ? [i, j] : [j, i];
return all.slice(lo, hi + 1);
}
selectAll() {
if (this.isDisabled || this.isReadonly || this.opts.selectionMode.current !== 'multiple')
return;
const next = this.getRows().map((el) => el.getAttribute('data-value') ?? '');
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
}
clear() {
if (this.isDisabled || this.isReadonly) return;
if (this.opts.value.current.length === 0) return;
this.opts.value.current = [];
this.opts.onValueChange.current?.([]);
this.anchor = null;
}
// ── Keyboard navigation ─────────────────────────────────────────────────
private focusAt(index: number) {
const rows = this.getRows();
if (rows.length === 0) return;
const clamped = Math.max(0, Math.min(rows.length - 1, index));
rows[clamped]?.focus();
}
/**
* Interactive descendants of a row, in tab order. Used for cell-level
* 2D navigation (ArrowLeft/Right within a row).
*/
private getFocusableCellsInRow(row: HTMLElement): HTMLElement[] {
const selector =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([data-grid-list-row])';
return Array.from(row.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.closest(`[${attrs.row}]`) === row && !el.hasAttribute('data-grid-list-row')
);
}
/** ArrowRight on a row: move focus to the first focusable cell descendant. */
private focusFirstCellInRow(row: HTMLElement): boolean {
const cells = this.getFocusableCellsInRow(row);
if (cells.length === 0) return false;
cells[0]!.focus();
return true;
}
/** ArrowLeft/Right within a row: cycle between focusable descendants. */
handleCellKeydown(e: SomaKeyboardEvent<HTMLElement>) {
if (this.isDisabled) return;
const dir = this.resolvedDir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
const target = e.target as HTMLElement | null;
if (!target) return;
const row = target.closest<HTMLElement>(`[${attrs.row}]`);
if (!row) return;
if (e.key === nextKey) {
const cells = this.getFocusableCellsInRow(row);
const idx = cells.indexOf(target);
if (idx < 0) return;
if (idx < cells.length - 1) {
e.preventDefault();
e.stopPropagation();
cells[idx + 1]!.focus();
}
return;
}
if (e.key === prevKey) {
const cells = this.getFocusableCellsInRow(row);
const idx = cells.indexOf(target);
if (idx < 0) return;
e.preventDefault();
e.stopPropagation();
if (idx === 0) {
// Return focus to the row itself.
row.focus();
} else {
cells[idx - 1]!.focus();
}
return;
}
// ArrowUp/Down / Home / End bubble up to the row handler so vertical
// navigation still works while focus is inside a cell.
}
handleRowKeydown(e: SomaKeyboardEvent<HTMLElement>) {
if (this.isDisabled) return;
const dir = this.resolvedDir.current;
// GridList is always vertical for navigation (grid pattern).
const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical');
const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal');
const rows = this.getRows();
if (rows.length === 0) return;
const currentIndex = rows.indexOf(e.currentTarget);
const loop = this.opts.loop.current;
// Horizontal — only handled when focus is on the row itself (not a cell).
// From the row, ArrowRight enters the first focusable cell. ArrowLeft
// is a no-op (you're already at the row level).
if (e.target === e.currentTarget && e.key === hNextKey) {
if (this.focusFirstCellInRow(e.currentTarget)) {
e.preventDefault();
return;
}
}
if (e.target === e.currentTarget && e.key === hPrevKey) {
// no-op; consume so the event doesn't scroll the viewport.
return;
}
if (e.key === nextKey) {
e.preventDefault();
const next = loop
? (currentIndex + 1) % rows.length
: Math.min(currentIndex + 1, rows.length - 1);
this.focusAt(next);
return;
}
if (e.key === prevKey) {
e.preventDefault();
const prev = loop
? (currentIndex - 1 + rows.length) % rows.length
: Math.max(currentIndex - 1, 0);
this.focusAt(prev);
return;
}
if (e.key === KEYS.HOME) {
e.preventDefault();
this.focusAt(0);
return;
}
if (e.key === KEYS.END) {
e.preventDefault();
this.focusAt(rows.length - 1);
return;
}
if (e.key === KEYS.PAGE_UP) {
e.preventDefault();
this.focusAt(Math.max(currentIndex - 10, 0));
return;
}
if (e.key === KEYS.PAGE_DOWN) {
e.preventDefault();
this.focusAt(Math.min(currentIndex + 10, rows.length - 1));
return;
}
if (e.key === KEYS.SPACE) {
e.preventDefault();
const value = e.currentTarget.getAttribute('data-value') ?? undefined;
if (value !== undefined) {
const mode =
e.shiftKey && this.opts.selectionMode.current === 'multiple'
? 'range'
: e.ctrlKey || e.metaKey
? 'toggle'
: this.opts.selectionMode.current === 'multiple'
? 'toggle'
: 'replace';
this.select(value, mode);
}
return;
}
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 'a' || e.key === 'A') &&
this.opts.selectionMode.current === 'multiple'
) {
e.preventDefault();
this.selectAll();
return;
}
if (e.key === KEYS.ESCAPE) {
if (this.opts.value.current.length > 0) {
e.preventDefault();
this.clear();
}
return;
}
if (
this.opts.typeahead.current &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
e.key.length === 1 &&
/\S/.test(e.key)
) {
this.handleTypeahead(e.key, currentIndex);
}
}
private handleTypeahead(char: string, fromIndex: number) {
this.typeaheadBuffer += char.toLowerCase();
this.clearTypeaheadTimer();
this.typeaheadTimer = this.soma.uix.timers.schedule(
this.typeaheadTimerKey,
this.opts.typeaheadTimeout.current,
() => {
this.typeaheadTimer = null;
this.typeaheadBuffer = '';
},
{ replace: true, meta: { component: 'grid-list', action: 'typeahead-reset' } }
);
const rows = this.getRows();
if (rows.length === 0) return;
const needle = this.typeaheadBuffer;
const start = needle.length === 1 ? fromIndex + 1 : fromIndex;
for (let i = 0; i < rows.length; i++) {
const idx = (start + i) % rows.length;
const el = rows[idx];
if (!el) continue;
const text = (el.getAttribute('data-text-value') || el.textContent || '')
.trim()
.toLowerCase();
if (text.startsWith(needle)) {
el.focus();
return;
}
}
}
private clearTypeaheadTimer() {
if (this.typeaheadTimer) {
this.typeaheadTimer.cancel();
this.typeaheadTimer = null;
}
}
private get typeaheadTimerKey() {
return `soma:grid-list:${this.opts.id.current}:typeahead`;
}
handleRowClick(value: string, e: SomaMouseEvent<HTMLElement>) {
if (this.isDisabled || this.isReadonly) return;
const mode =
e.shiftKey && this.opts.selectionMode.current === 'multiple'
? 'range'
: (e.ctrlKey || e.metaKey) && this.opts.selectionMode.current === 'multiple'
? 'toggle'
: 'replace';
this.select(value, mode);
e.currentTarget.focus();
}
// ── Root props ──────────────────────────────────────────────────────────
readonly snippetProps = $derived.by(() => ({
value: this.opts.value.current,
isEmpty: this.isEmpty,
selectAll: () => this.selectAll(),
clear: () => this.clear()
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'grid' as const,
'aria-label': this.resolvedAriaLabel.current,
'aria-labelledby': this.opts.ariaLabelledby.current,
'aria-multiselectable': this.opts.selectionMode.current === 'multiple' ? true : undefined,
'aria-disabled': this.isDisabled ? true : undefined,
'aria-readonly': this.isReadonly ? true : undefined,
'aria-invalid': this.isInvalid ? true : undefined,
'aria-required': this.isRequired ? true : undefined,
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid),
'data-required': boolToEmptyStrOrUndef(this.isRequired),
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
'data-selection-mode': this.opts.selectionMode.current
} as const)
);
}
// ── Row ────────────────────────────────────────────────────────────────────
interface GridListRowOpts
extends
WithRefOpts,
ActiveProps<{
value: string;
textValue: string | undefined;
disabled: boolean;
}> {}
export class GridListRowProvider {
readonly opts: GridListRowOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: GridListRowOpts) {
return new GridListRowProvider(opts);
}
readonly provider: GridListProvider;
private constructor(opts: GridListRowOpts) {
this.opts = opts;
this.provider = GridListProvider.require();
this.runtimePart = this.provider.runtime.part('row', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly isSelected = $derived.by(() => this.provider.isSelected(this.opts.value.current));
readonly isDisabled = $derived.by(() => this.opts.disabled.current || this.provider.isDisabled);
readonly isRovingTarget = $derived.by(
() => this.opts.ref.current === this.provider.rovingTargetEl
);
readonly onclick = (e: MouseEvent) => {
if (this.isDisabled) return;
this.provider.handleRowClick(this.opts.value.current, e as SomaMouseEvent<HTMLElement>);
};
readonly onkeydown = (e: KeyboardEvent) => {
this.provider.handleRowKeydown(e as SomaKeyboardEvent<HTMLElement>);
};
readonly snippetProps = $derived.by(() => ({
selected: this.isSelected,
disabled: this.isDisabled
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'row' as const,
tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
'aria-selected':
this.provider.opts.selectionMode.current === 'none' ? undefined : this.isSelected,
'aria-disabled': this.isDisabled ? true : undefined,
'data-value': this.opts.value.current,
'data-text-value': this.opts.textValue.current,
'data-state': this.isSelected ? ('selected' as const) : ('unselected' as const),
'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
onclick: this.onclick,
onkeydown: this.onkeydown
} as const)
);
}
// ── Cell ───────────────────────────────────────────────────────────────────
interface GridListCellOpts extends WithRefOpts {}
export class GridListCellProvider {
readonly opts: GridListCellOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: GridListCellOpts) {
return new GridListCellProvider(opts);
}
readonly provider: GridListProvider;
private constructor(opts: GridListCellOpts) {
this.opts = opts;
this.provider = GridListProvider.require();
this.runtimePart = this.provider.runtime.part('cell', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
/**
* Delegate horizontal arrow navigation to the Provider so the user can
* move between focusable descendants of a row with ArrowLeft/Right, and
* ArrowLeft from the first cell returns focus to the row.
*/
readonly onkeydown = (e: KeyboardEvent) => {
this.provider.handleCellKeydown(e as SomaKeyboardEvent<HTMLElement>);
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'gridcell' as const,
onkeydown: this.onkeydown
} as const)
);
}
// ── SelectionCheckbox ──────────────────────────────────────────────────────
interface GridListSelectionCheckboxOpts
extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined }> {}
export class GridListSelectionCheckboxProvider {
readonly opts: GridListSelectionCheckboxOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: GridListSelectionCheckboxOpts) {
return new GridListSelectionCheckboxProvider(opts);
}
readonly provider: GridListProvider;
/** Resolved from the nearest enclosing Row's value. */
private rowValue: string | null = null;
private constructor(opts: GridListSelectionCheckboxOpts) {
this.opts = opts;
this.provider = GridListProvider.require();
this.runtimePart = this.provider.runtime.part('selection-checkbox', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
/** Read the enclosing row's value lazily (on click). */
private findRowValue(el: HTMLElement | null): string | null {
if (!el) return null;
const row = el.closest<HTMLElement>(`[${attrs.row}]`);
return row?.getAttribute('data-value') ?? null;
}
readonly isChecked = $derived.by(() => {
if (!this.rowValue) return false;
return this.provider.isSelected(this.rowValue);
});
readonly onclick = (e: MouseEvent) => {
e.stopPropagation();
const target = e.currentTarget as HTMLElement;
this.rowValue = this.findRowValue(target);
if (!this.rowValue) return;
this.provider.select(this.rowValue, 'toggle');
};
readonly resolvedAriaLabel = $derived.by(
() =>
this.opts.ariaLabel.current ||
this.provider.soma.langs.ts(GRID_LIST_LANGS.SELECT_ROW) ||
undefined
);
readonly props = $derived.by(() => {
// Read current row association via DOM for aria-state — safe on every render.
const el = this.opts.ref.current;
this.rowValue = this.findRowValue(el);
const checked = this.rowValue ? this.provider.isSelected(this.rowValue) : false;
return this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
role: 'checkbox' as const,
'aria-checked': checked,
'aria-label': this.resolvedAriaLabel,
'aria-disabled': this.provider.isDisabled || this.provider.isReadonly || undefined,
'data-state': checked ? ('checked' as const) : ('unchecked' as const),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled || this.provider.isReadonly),
onclick: this.onclick
} as const);
});
}

Powered by TurnKey Linux.