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

681 lines
21 KiB

morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
import { Provider, context, type WithRefOpts, type ProviderOpts } from '../../provider';
remove backward-compat re-export shims Per architectural feedback: when relocating code to its correct layer, do the migration completely — update every consumer directly and delete the original. No re-export barrels at the old paths. Removed shims: - `src/uix/soma/attrs/` — was re-exporting `createAttrs` / `registerContract` / `assertContract` / `getContract` from `$uix/morfo` and the bool/data-attr formatters from `$libs/dom`. All 66 importing files updated to import from the canonical locations directly. - `src/uix/soma/reactive/` — `reactive.svelte.ts` was a thin re-export of `$libs/reactive`. The 79 consumers (every soma provider + layers + types) now import primitives directly from `$libs/reactive`. The `opts.ts` Props↔Opts bridge moved to `src/uix/soma/provider/opts.ts` (its natural home — provider plumbing, not reactive primitives). - `attachRef` / `RefAttachment` re-export from `src/uix/soma/provider/provider.svelte.ts` and from `src/uix/soma/index.ts`. Both removed. Consumers (`floating.svelte.ts`, `date-field-provider.svelte.ts`) now import directly from `$libs/reactive`. Also: - Moved `ActiveProps<T>` / `StateProps<T>` types to `src/libs/reactive/types.ts` — they're trivial generic helpers over `Active<T>` / `State<T>` and shouldn't have lived in soma. - Stripped soma's root barrel (`src/uix/soma/index.ts`) of every re-export of libs/morfo concerns. It now only re-exports actually-soma surfaces (Provider base, mergeProps, css helpers, id, types, Soma class). Result: - `grep -r '\$soma' src/uix/morfo` → 0 matches - `grep -r '\$uix' src/libs` → 0 matches - `src/uix/soma/attrs/` and `src/uix/soma/reactive/` no longer exist - Every import statement now reflects the real architectural layer Verified: vitest 19/20 files / 214 tests (1 pre-existing air dead- branch fail using `bun:test`), smoke 68/68, morfo-check 66/66.
5 months ago
import {
createAttrs,
registerContract
} from '$uix/morfo';
import {
boolToEmptyStrOrUndef
sema/morfo/eidos: typed selector builder + dialog wrapper + emerge color sema architecture - sema-map: 5-channel registry (motion / sound / color / presence / haptic) via SemaChannelSignatures declaration merging; engine stamps data-event-* tokens; flat CSS-style cascade replaces the eventLabel-overrides middle layer. - sema-map: emerge family gains base.color so intent deltas can shift hue / saturation / intensity. Without a base, the resolver was skipping the channel and Dialog open with intent='threat' rendered as neutral blue in the Sema tab visualisation. - types: SEMA_FAMILY_POLICY const drives compile-time + runtime intent requirements per family. Object shape so future per-family policy fields fit alongside. Emerge events MAY now declare intent (canon update — a Dialog confirming threat carries it in its very appearance). - chans: rename vibra→haptic, add HapticChannel V1 (Vibration API); SoundChannel eager-init on first user gesture (autoplay race fix). morfo selector discipline - morfo/selectors.ts (new): semaSelector(morfo, partKebab, matchers?) — type-checked against morfo.parts and morfo.events. Sema cascade rules MUST use it; hand-written strings are an architecture violation that breaks silently when morfo renames a part. - dialog morfo: open carries intent via fromProp; close-cancel / close-dismiss / close-dismiss-outside drop intent (cancellation has no evaluative load); close-save stays hardcoded fulfill (commit fulfils the user's decision regardless of dialog context). eidos dialog migration (4th pilot) - eidos/components/dialog/: full subdirectory wrapper — flat <Dialog> + compound Provider/Trigger/Overlay/Content/Title/Description/Close/Header/ Footer; size + position responsive props; sheet auto-form on narrow viewports; closePosition for the auto-X. - sema/components/dialog.ts: cascade rules use semaSelector(dialogMorfo, 'content', matchers?). Intent block adds character (haptic kind / pattern) but never overrides pitch / gain / contour — those are intent.deltas signature ownership and overriding flattens per-intent perceptual difference. demo controls + dialog page - src/lib/_demo (DemoSwitch / DemoEnum / DemoText / DemoRange) — unified controls reused across all component demos. - web/routes/dialog: live preview always rendered above tabs; per-event Sema tab with independent intent probe; signature visualisation + Play buttons with fallback target chain. documentation - CLAUDE.md: intent.deltas signature ownership rule; eidos drift defense doctrine (types over lint); 2026-05-09 hand-off entry. - morfo/README: Typed selector builder section. - eidos/README: linter section reframed as opt-in safety net for plain CSS recipes; the architectural mechanism is compile-time typing. - sema/README: open channel registry + flat cascade docs + override semantics + intent policy const + selector builder discipline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
} from '$adom';
morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
import {
readableActive,
state,
type Active,
type ActiveProps,
type StateProps
remove backward-compat re-export shims Per architectural feedback: when relocating code to its correct layer, do the migration completely — update every consumer directly and delete the original. No re-export barrels at the old paths. Removed shims: - `src/uix/soma/attrs/` — was re-exporting `createAttrs` / `registerContract` / `assertContract` / `getContract` from `$uix/morfo` and the bool/data-attr formatters from `$libs/dom`. All 66 importing files updated to import from the canonical locations directly. - `src/uix/soma/reactive/` — `reactive.svelte.ts` was a thin re-export of `$libs/reactive`. The 79 consumers (every soma provider + layers + types) now import primitives directly from `$libs/reactive`. The `opts.ts` Props↔Opts bridge moved to `src/uix/soma/provider/opts.ts` (its natural home — provider plumbing, not reactive primitives). - `attachRef` / `RefAttachment` re-export from `src/uix/soma/provider/provider.svelte.ts` and from `src/uix/soma/index.ts`. Both removed. Consumers (`floating.svelte.ts`, `date-field-provider.svelte.ts`) now import directly from `$libs/reactive`. Also: - Moved `ActiveProps<T>` / `StateProps<T>` types to `src/libs/reactive/types.ts` — they're trivial generic helpers over `Active<T>` / `State<T>` and shouldn't have lived in soma. - Stripped soma's root barrel (`src/uix/soma/index.ts`) of every re-export of libs/morfo concerns. It now only re-exports actually-soma surfaces (Provider base, mergeProps, css helpers, id, types, Soma class). Result: - `grep -r '\$soma' src/uix/morfo` → 0 matches - `grep -r '\$uix' src/libs` → 0 matches - `src/uix/soma/attrs/` and `src/uix/soma/reactive/` no longer exist - Every import statement now reflects the real architectural layer Verified: vitest 19/20 files / 214 tests (1 pre-existing air dead- branch fail using `bun:test`), smoke 68/68, morfo-check 66/66.
5 months ago
} from '$libs/reactive';
morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
import type {
OnChangeFn,
Direction,
SomaKeyboardEvent,
SomaMouseEvent
} from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { TREE_GRID_LANGS } from './langs';
import type { TreeGridSelectionMode } from './types';
morfo: port 65 components + sium.lazy + morfo-check passes 66/66 All 65 soma components now consume their morfo as the cross-layer contract. createAttrs(morfo) and registerContract(morfo) overloads removed — the Morfo form is now mandatory. Each component provider has a 3-line morfo import + wire-up block instead of 20+ lines of inline declarations. Additions: sium/core/lazy.ts — lazy() combinator for recursive schemas (unblocks morfo's eventual lazy part-tree validation; tests included). sium/examples/ — booking, color, login, settings, signup, survey, tree form demos. Route demos at src/routes/test/sium/{booking,color,login,settings, signup,survey,tree}. Tooling: morfo-check.ts — now tolerates sibling parts sharing a physical element (Link variant of Item, etc.). Queries via allPartAttrs cross-scope. morfo-vocabulary-check.ts — 28 WARN (legitimate per-component vocabularies or subsets like switch missing "indeterminate"). All reviewed, none are bugs. Fixes caught by morfo-check: table: data-sorted / data-pinned marked severity:'optional' (only emitted when sorting/pinning active, not always). tree-grid: data-expanded on Row severity:'optional' (branches only). menubar: data-menubar-value declared on Trigger. splitter: data-splitter-panel-id declared on Panel. Verify: npm run check — 0 morfo errors (4 pre-existing in sium/examples, unrelated). npx vitest run src/uix/morfo — 7/7 tests pass. npm run smoke — 65/65 routes pass. npm run morfo:check — 66/66 morfos validate against real DOM. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
import { treeGridMorfo } from '../../../morfo/components/tree-grid';
morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
morfo: port 65 components + sium.lazy + morfo-check passes 66/66 All 65 soma components now consume their morfo as the cross-layer contract. createAttrs(morfo) and registerContract(morfo) overloads removed — the Morfo form is now mandatory. Each component provider has a 3-line morfo import + wire-up block instead of 20+ lines of inline declarations. Additions: sium/core/lazy.ts — lazy() combinator for recursive schemas (unblocks morfo's eventual lazy part-tree validation; tests included). sium/examples/ — booking, color, login, settings, signup, survey, tree form demos. Route demos at src/routes/test/sium/{booking,color,login,settings, signup,survey,tree}. Tooling: morfo-check.ts — now tolerates sibling parts sharing a physical element (Link variant of Item, etc.). Queries via allPartAttrs cross-scope. morfo-vocabulary-check.ts — 28 WARN (legitimate per-component vocabularies or subsets like switch missing "indeterminate"). All reviewed, none are bugs. Fixes caught by morfo-check: table: data-sorted / data-pinned marked severity:'optional' (only emitted when sorting/pinning active, not always). tree-grid: data-expanded on Row severity:'optional' (branches only). menubar: data-menubar-value declared on Trigger. splitter: data-splitter-panel-id declared on Panel. Verify: npm run check — 0 morfo errors (4 pre-existing in sium/examples, unrelated). npx vitest run src/uix/morfo — 7/7 tests pass. npm run smoke — 65/65 routes pass. npm run morfo:check — 66/66 morfos validate against real DOM. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
const attrs = createAttrs(treeGridMorfo);
registerContract(treeGridMorfo);
morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
// ── Root provider ──────────────────────────────────────────────────────────
interface TreeGridOpts
extends WithRefOpts,
StateProps<{
expanded: string[];
value: string[];
}>,
ActiveProps<{
selectionMode: TreeGridSelectionMode;
loop: boolean;
typeahead: boolean;
typeaheadTimeout: number;
disabled: boolean;
readonly: boolean;
ariaLabel: string | undefined;
ariaLabelledby: string | undefined;
onValueChange: OnChangeFn<string[]> | undefined;
onExpandedChange: OnChangeFn<string[]> | undefined;
}> {}
export class TreeGridProvider extends Provider<TreeGridOpts> {
static readonly ctx = context<TreeGridProvider>('TreeGrid');
static get(): TreeGridProvider | undefined {
return this.ctx.getOr(undefined) as TreeGridProvider | undefined;
}
static require(): TreeGridProvider {
return this.ctx.get();
}
static create(opts: TreeGridOpts) {
return new TreeGridProvider(opts);
}
readonly soma = Soma.get();
private typeaheadBuffer = '';
private typeaheadTimer: ReturnType<typeof setTimeout> | null = null;
private anchor: string | null = null;
private constructor(opts: TreeGridOpts) {
super(opts, 'TreeGrid', 'provider', attrs.provider, TreeGridProvider.ctx);
morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
$effect(() => {
return () => {
if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
};
});
}
readonly resolvedDir: Active<Direction> = readableActive(
() => this.soma?.presentation.getDir() ?? 'ltr'
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() =>
this.opts.ariaLabelledby.current
? undefined
: this.opts.ariaLabel.current ||
this.soma?.langs.ts(TREE_GRID_LANGS.LABEL) ||
undefined
);
readonly isEmpty = $derived.by(() => this.opts.value.current.length === 0);
// ── DOM queries ─────────────────────────────────────────────────────────
/** All visible, enabled rows (not hidden inside a collapsed parent). */
getVisibleRows(): HTMLElement[] {
const root = this.opts.ref.current;
if (!root) return [];
const all = Array.from(
root.querySelectorAll<HTMLElement>(`[${attrs.row}]:not([data-disabled])`)
).filter((el) => el.closest(`[${attrs.provider}]`) === root);
morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
// Exclude rows inside collapsed `data-expanded="false"` ancestor row-children.
return all.filter((el) => {
let cursor: HTMLElement | null = el.parentElement;
while (cursor && cursor !== root) {
if (cursor.matches(`[${attrs['row-children']}]`)) {
const parentRow = cursor.parentElement?.closest<HTMLElement>(
`[${attrs.row}]`
);
if (parentRow && parentRow.getAttribute('data-expanded') === 'false') {
return false;
}
}
cursor = cursor.parentElement;
}
return true;
});
}
/** All rows (including those hidden inside collapsed ancestors) for range selection. */
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
morfo: cross-layer contract + 14 new WAI-ARIA components + Dialog wired Morfo (src/uix/morfo/): machine-readable contract of a component's public DOM surface, shared between soma/sema/eidos. Shape v5 consolidated across three independent AI reviews (Gemini, Grok, ChatGPT) plus Sema alignment. Types, sium-based validator with cross-field invariants, dialog.ts as the first canonical morfo with data-last-action for Sema-causal exits, 7 passing unit tests. createAttrs(morfo) and registerContract(morfo) now consume a Morfo — legacy inline signature removed. Dialog provider refactored to derive parts + data contract from dialogMorfo. The other 65 soma components need porting (svelte-check lists them) — tracked work. Tooling: scripts/smoke-check.mjs — Playwright smoke over 65 soma demo routes scripts/morfo-check.ts — validates emitted DOM vs morfo scripts/morfo-vocabulary-check.ts — canonical vocabulary consistency npm run smoke / morfo:check / morfo:vocabulary WAI-ARIA APG components added (100% APG coverage now): Announce, Avatar, Clipboard, DragDrop, Feed, GridList, Meter, Progress, SearchField, TagGroup, TreeGrid + Table.RowDetail/Trigger (detail panel pattern; hierarchical Table rows deprecated — use TreeGrid instead). air: Link, Banner. Sema prep: src/uix/sema/sema_pre.md documents the semantic layer's DOM requirements. Morfo already provides everything Sema needs (data-last-action patterns, transition markers, enumerable state values, cross-component consistency). Sema implementation deferred. COMPONENT_GUIDE updated: items 37-39 (translation namespace grep, DOM topology vs .require() audit, npm run smoke is part of done) + A34 rule with incident log. WAI-ARIA pattern listed as soma membership criterion. Pagination translation bug fixed (soma.pagination.page -> idlangref PAGINATION_LANGS.PAGE). Table demo namespace fix (soma.table.* -> components.table.*). Avatar demo now offline-safe (SVG data URL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
);
}
readonly rovingTargetEl = $derived.by(() => {
const rows = this.getVisibleRows();
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]
);
});
// ── Expand / collapse ──────────────────────────────────────────────────
isExpanded(value: string): boolean {
return this.opts.expanded.current.includes(value);
}
toggleExpand(value: string) {
if (this.opts.disabled.current) return;
const current = this.opts.expanded.current;
const next = current.includes(value)
? current.filter((v) => v !== value)
: [...current, value];
this.opts.expanded.current = next;
this.opts.onExpandedChange.current?.(next);
}
expand(value: string) {
if (this.opts.disabled.current) return;
if (this.opts.expanded.current.includes(value)) return;
const next = [...this.opts.expanded.current, value];
this.opts.expanded.current = next;
this.opts.onExpandedChange.current?.(next);
}
collapse(value: string) {
if (this.opts.disabled.current) return;
if (!this.opts.expanded.current.includes(value)) return;
const next = this.opts.expanded.current.filter((v) => v !== value);
this.opts.expanded.current = next;
this.opts.onExpandedChange.current?.(next);
}
// ── Selection ───────────────────────────────────────────────────────────
isSelected(value: string): boolean {
return this.opts.value.current.includes(value);
}
select(value: string, mode: 'replace' | 'toggle' | 'range') {
if (this.opts.disabled.current || this.opts.readonly.current) 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 {
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[] {
// Range uses visible rows so Shift+Click doesn't select hidden descendants.
const all = this.getVisibleRows().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.opts.selectionMode.current !== 'multiple') return;
const next = this.getVisibleRows().map(
(el) => el.getAttribute('data-value') ?? ''
);
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
}
clear() {
if (this.opts.value.current.length === 0) return;
this.opts.value.current = [];
this.opts.onValueChange.current?.([]);
this.anchor = null;
}
// ── Keyboard ────────────────────────────────────────────────────────────
private focusAt(index: number) {
const rows = this.getVisibleRows();
if (rows.length === 0) return;
const clamped = Math.max(0, Math.min(rows.length - 1, index));
rows[clamped]?.focus();
}
/** Find the parent row of the given row element (traverse up past row-children). */
private parentRow(row: HTMLElement): HTMLElement | undefined {
let cursor: HTMLElement | null = row.parentElement;
while (cursor && cursor !== this.opts.ref.current) {
if (cursor.matches(`[${attrs['row-children']}]`)) {
const parent = cursor.parentElement?.closest<HTMLElement>(`[${attrs.row}]`);
if (parent) return parent;
}
cursor = cursor.parentElement;
}
return undefined;
}
handleRowKeydown(e: SomaKeyboardEvent<HTMLElement>) {
if (this.opts.disabled.current) return;
const row = e.currentTarget;
const value = row.getAttribute('data-value') ?? '';
const hasChildren = row.getAttribute('data-has-children') === 'true';
const expandedAttr = row.getAttribute('data-expanded');
const dir = this.resolvedDir.current;
const { nextKey: hNextKey, prevKey: hPrevKey } = getDirectionalKeys(dir, 'horizontal');
const rows = this.getVisibleRows();
if (rows.length === 0) return;
const currentIndex = rows.indexOf(row);
const loop = this.opts.loop.current;
// ArrowDown / ArrowUp
if (e.key === KEYS.ARROW_DOWN) {
e.preventDefault();
const next = loop
? (currentIndex + 1) % rows.length
: Math.min(currentIndex + 1, rows.length - 1);
this.focusAt(next);
return;
}
if (e.key === KEYS.ARROW_UP) {
e.preventDefault();
const prev = loop
? (currentIndex - 1 + rows.length) % rows.length
: Math.max(currentIndex - 1, 0);
this.focusAt(prev);
return;
}
// ArrowRight / ArrowLeft (RTL-aware via h*Key). APG:
// - ArrowRight on collapsed branch → expand
// - ArrowRight on expanded branch → focus first child
// - ArrowRight on leaf → no-op (or move into first cell)
// - ArrowLeft on expanded branch → collapse
// - ArrowLeft on collapsed branch or leaf → focus parent row
if (e.key === hNextKey) {
if (hasChildren && expandedAttr === 'false') {
e.preventDefault();
this.expand(value);
return;
}
if (hasChildren && expandedAttr === 'true') {
e.preventDefault();
// Focus first child visible row (the next in visible-order beneath this one
// whose parent row == this row).
const next = rows[currentIndex + 1];
if (next && this.parentRow(next) === row) next.focus();
return;
}
// Leaf: fall through so the browser can move into any focusable cell.
return;
}
if (e.key === hPrevKey) {
if (hasChildren && expandedAttr === 'true') {
e.preventDefault();
this.collapse(value);
return;
}
// Collapsed branch or leaf → focus parent row.
const parent = this.parentRow(row);
if (parent) {
e.preventDefault();
parent.focus();
}
return;
}
if (e.key === KEYS.HOME) {
e.preventDefault();
if (e.ctrlKey) this.focusAt(0);
else 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 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.key === KEYS.ENTER && hasChildren) {
e.preventDefault();
this.toggleExpand(value);
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();
if (this.typeaheadTimer) clearTimeout(this.typeaheadTimer);
this.typeaheadTimer = setTimeout(() => {
this.typeaheadBuffer = '';
}, this.opts.typeaheadTimeout.current);
const rows = this.getVisibleRows();
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;
}
}
}
handleRowClick(value: string, e: SomaMouseEvent<HTMLElement>) {
if (this.opts.disabled.current || this.opts.readonly.current) 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();
}
readonly snippetProps = $derived.by(() => ({
expanded: this.opts.expanded.current,
selected: this.opts.value.current,
toggleExpand: (v: string) => this.toggleExpand(v),
selectAll: () => this.selectAll(),
clear: () => this.clear()
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'treegrid' 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.opts.disabled.current ? true : undefined,
'aria-readonly': this.opts.readonly.current ? true : undefined,
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-empty': boolToEmptyStrOrUndef(this.isEmpty),
'data-selection-mode': this.opts.selectionMode.current
} as const)
);
}
// ── Header ─────────────────────────────────────────────────────────────────
interface TreeGridHeaderOpts extends WithRefOpts {}
export class TreeGridHeaderProvider extends Provider<TreeGridHeaderOpts> {
static create(opts: TreeGridHeaderOpts) {
return new TreeGridHeaderProvider(opts);
}
private constructor(opts: TreeGridHeaderOpts) {
super(opts, 'TreeGrid', 'header', attrs.header);
TreeGridProvider.require();
}
readonly props = $derived.by(() =>
this.assertProps({ ...this.baseProps, role: 'row' as const } as const)
);
}
// ── ColumnHeader ───────────────────────────────────────────────────────────
interface TreeGridColumnHeaderOpts extends WithRefOpts {}
export class TreeGridColumnHeaderProvider extends Provider<TreeGridColumnHeaderOpts> {
static create(opts: TreeGridColumnHeaderOpts) {
return new TreeGridColumnHeaderProvider(opts);
}
private constructor(opts: TreeGridColumnHeaderOpts) {
super(opts, 'TreeGrid', 'column-header', attrs['column-header']);
TreeGridProvider.require();
}
readonly props = $derived.by(() =>
this.assertProps({ ...this.baseProps, role: 'columnheader' as const } as const)
);
}
// ── Row ────────────────────────────────────────────────────────────────────
interface TreeGridRowOpts
extends WithRefOpts,
ActiveProps<{
value: string;
hasChildren: boolean;
disabled: boolean;
textValue: string | undefined;
}> {}
export class TreeGridRowProvider extends Provider<TreeGridRowOpts> {
static readonly ctx = context<TreeGridRowProvider>('TreeGridRow');
static get(): TreeGridRowProvider | undefined {
return this.ctx.getOr(undefined) as TreeGridRowProvider | undefined;
}
static require(): TreeGridRowProvider {
return this.ctx.get();
}
static create(opts: TreeGridRowOpts) {
return new TreeGridRowProvider(opts);
}
readonly provider: TreeGridProvider;
readonly parentRow: TreeGridRowProvider | undefined;
readonly level: number;
private constructor(opts: TreeGridRowOpts) {
super(opts, 'TreeGrid', 'row', attrs.row, TreeGridRowProvider.ctx);
this.provider = TreeGridProvider.require();
this.parentRow = TreeGridRowProvider.get();
this.level = (this.parentRow?.level ?? 0) + 1;
}
readonly isSelected = $derived.by(() =>
this.provider.isSelected(this.opts.value.current)
);
readonly isExpanded = $derived.by(() =>
this.provider.isExpanded(this.opts.value.current)
);
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || this.provider.opts.disabled.current
);
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(() => ({
expanded: this.isExpanded,
selected: this.isSelected,
disabled: this.isDisabled,
level: this.level,
hasChildren: this.opts.hasChildren.current
}));
readonly props = $derived.by(() => {
const hasChildren = this.opts.hasChildren.current;
return this.assertProps({
...this.baseProps,
role: 'row' as const,
tabindex: this.isDisabled ? -1 : this.isRovingTarget ? 0 : -1,
'aria-level': this.level,
'aria-expanded': hasChildren ? this.isExpanded : undefined,
'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-expanded': hasChildren ? String(this.isExpanded) : undefined,
'data-has-children': hasChildren ? 'true' : 'false',
'data-highlighted': boolToEmptyStrOrUndef(this.isRovingTarget),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-level': this.level,
onclick: this.onclick,
onkeydown: this.onkeydown
} as const);
});
}
// ── RowChildren ────────────────────────────────────────────────────────────
interface TreeGridRowChildrenOpts extends WithRefOpts {}
export class TreeGridRowChildrenProvider extends Provider<TreeGridRowChildrenOpts> {
static create(opts: TreeGridRowChildrenOpts) {
return new TreeGridRowChildrenProvider(opts);
}
readonly row: TreeGridRowProvider;
private constructor(opts: TreeGridRowChildrenOpts) {
super(opts, 'TreeGrid', 'row-children', attrs['row-children']);
this.row = TreeGridRowProvider.require();
}
readonly isExpanded = $derived.by(() => this.row.isExpanded);
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'group' as const
} as const)
);
}
// ── Cell ───────────────────────────────────────────────────────────────────
interface TreeGridCellOpts extends WithRefOpts {}
export class TreeGridCellProvider extends Provider<TreeGridCellOpts> {
static create(opts: TreeGridCellOpts) {
return new TreeGridCellProvider(opts);
}
private constructor(opts: TreeGridCellOpts) {
super(opts, 'TreeGrid', 'cell', attrs.cell);
TreeGridProvider.require();
}
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'gridcell' as const
} as const)
);
}
// ── ExpandTrigger ──────────────────────────────────────────────────────────
interface TreeGridExpandTriggerOpts
extends ProviderOpts,
ActiveProps<{ ariaLabel: string | undefined }> {}
export class TreeGridExpandTriggerProvider extends Provider<TreeGridExpandTriggerOpts> {
static create(opts: TreeGridExpandTriggerOpts) {
return new TreeGridExpandTriggerProvider(opts);
}
readonly provider: TreeGridProvider;
readonly row: TreeGridRowProvider;
private constructor(opts: TreeGridExpandTriggerOpts) {
super(opts, 'TreeGrid', 'expand-trigger', attrs['expand-trigger']);
this.provider = TreeGridProvider.require();
this.row = TreeGridRowProvider.require();
}
readonly resolvedAriaLabel = $derived.by(() => {
if (this.opts.ariaLabel.current) return this.opts.ariaLabel.current;
const key = this.row.isExpanded ? TREE_GRID_LANGS.COLLAPSE : TREE_GRID_LANGS.EXPAND;
return this.provider.soma?.langs.ts(key);
});
readonly onclick = (e: MouseEvent) => {
e.stopPropagation();
this.provider.toggleExpand(this.row.opts.value.current);
};
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
type: 'button' as const,
tabindex: -1,
'aria-label': this.resolvedAriaLabel,
'aria-hidden': true as const,
'data-state': this.row.isExpanded ? ('open' as const) : ('closed' as const),
onclick: this.onclick
} as const)
);
}

Powered by TurnKey Linux.