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
|
|
|
|
|
} 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)
|
|
|
|
|
);
|
|
|
|
|
}
|