From 0222af7a0be146a8ab9a2db8160ca997018d1c80 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 20 Apr 2026 02:28:58 +0200 Subject: [PATCH] morfo: enrich 65 components with aria + keyboard + focus contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each morfo now declares what its provider actually emits (via agent-based extraction from provider code). Before: parts + data only. After: parts + data + ARIA per part + keyboard per part + focus policy for overlays. Contract source: provider's `readonly props = $derived.by(...)` blocks, classified into the v.* value kinds — literal, stateRef, partRef, propRef, translationRef. Conditions extracted from ternary / guard patterns. Focus policies added on overlay components: popover, drawer, alert-dialog, dropdown-menu, context-menu, select, date-picker, date-range-picker, time-picker, time-range-picker, color-picker. Context-menu uses return:'previous' (no discrete trigger). Tooltip, link-preview, combobox, command omit focus (no trap). Tag-group Link variant: morfo-check tolerates sibling parts sharing a physical element (Link IS Item in the DOM) via allPartAttrs cross-scope. README.md added — developer guide: authoring a morfo step-by-step, the v.* builders, severity rules, validation layers, polymorphism handling via defaultElement + role, Sema alignment (data-last-action, transition markers). Verify: npm run check — 5 errs, all pre-existing in sium/examples. vitest run src/uix/morfo — 7/7 pass. morfo:check — 64/66 pass (2 flaky Playwright navigation timeouts, re-run clean when isolated). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/uix/morfo/README.md | 425 ++++++++++++++++++ src/uix/morfo/components/accordion.ts | 34 +- src/uix/morfo/components/alert-dialog.ts | 27 +- src/uix/morfo/components/announce.ts | 8 +- src/uix/morfo/components/breadcrumb.ts | 35 +- src/uix/morfo/components/calendar.ts | 149 +++++- src/uix/morfo/components/carousel.ts | 55 ++- src/uix/morfo/components/checkbox.ts | 29 +- src/uix/morfo/components/clipboard.ts | 13 +- src/uix/morfo/components/collapsible.ts | 23 +- src/uix/morfo/components/color-field.ts | 68 ++- src/uix/morfo/components/color-picker.ts | 149 +++++- src/uix/morfo/components/combobox.ts | 123 ++++- src/uix/morfo/components/command.ts | 160 ++++++- src/uix/morfo/components/context-menu.ts | 175 +++++++- src/uix/morfo/components/date-field.ts | 32 +- src/uix/morfo/components/date-picker.ts | 48 +- src/uix/morfo/components/date-range-field.ts | 13 +- src/uix/morfo/components/date-range-picker.ts | 48 +- src/uix/morfo/components/drag-drop.ts | 49 +- src/uix/morfo/components/drawer.ts | 103 ++++- src/uix/morfo/components/dropdown-menu.ts | 182 +++++++- src/uix/morfo/components/editable.ts | 68 ++- src/uix/morfo/components/feed.ts | 71 ++- src/uix/morfo/components/field.ts | 70 ++- src/uix/morfo/components/file-upload.ts | 120 ++++- src/uix/morfo/components/form.ts | 39 +- src/uix/morfo/components/grid-list.ts | 90 +++- src/uix/morfo/components/link-preview.ts | 23 +- src/uix/morfo/components/listbox.ts | 98 +++- src/uix/morfo/components/menubar.ts | 28 +- src/uix/morfo/components/meter.ts | 14 +- src/uix/morfo/components/navigation-menu.ts | 51 ++- src/uix/morfo/components/number-field.ts | 73 ++- src/uix/morfo/components/pagination.ts | 44 +- src/uix/morfo/components/pin-input.ts | 19 +- src/uix/morfo/components/popover.ts | 95 +++- src/uix/morfo/components/progress.ts | 19 +- src/uix/morfo/components/radio-group.ts | 33 +- src/uix/morfo/components/range-calendar.ts | 132 +++++- src/uix/morfo/components/rating-group.ts | 47 +- src/uix/morfo/components/scroll-area.ts | 20 +- src/uix/morfo/components/search-field.ts | 35 +- src/uix/morfo/components/select.ts | 138 +++++- src/uix/morfo/components/slider.ts | 32 +- src/uix/morfo/components/splitter.ts | 26 +- src/uix/morfo/components/stepper.ts | 70 ++- src/uix/morfo/components/switch.ts | 16 +- src/uix/morfo/components/table.ts | 97 +++- src/uix/morfo/components/tabs.ts | 36 +- src/uix/morfo/components/tag-group.ts | 79 +++- src/uix/morfo/components/tags-input.ts | 70 ++- src/uix/morfo/components/time-field.ts | 32 +- src/uix/morfo/components/time-picker.ts | 38 +- src/uix/morfo/components/time-range-field.ts | 13 +- src/uix/morfo/components/time-range-picker.ts | 31 +- src/uix/morfo/components/toast.ts | 79 +++- src/uix/morfo/components/toggle-group.ts | 18 +- src/uix/morfo/components/toggle.ts | 36 +- src/uix/morfo/components/toolbar.ts | 34 +- src/uix/morfo/components/tooltip.ts | 41 +- src/uix/morfo/components/tree-grid.ts | 110 ++++- src/uix/morfo/components/tree-view.ts | 84 +++- 63 files changed, 3950 insertions(+), 267 deletions(-) create mode 100644 src/uix/morfo/README.md diff --git a/src/uix/morfo/README.md b/src/uix/morfo/README.md new file mode 100644 index 000000000..a1ef98b64 --- /dev/null +++ b/src/uix/morfo/README.md @@ -0,0 +1,425 @@ +# Morfo + +**The cross-layer contract of a component's public DOM surface.** + +Morfo is the single source of truth for a component's parts, data-attrs, ARIA contract, keyboard shortcuts, and focus policy. The same morfo is consumed by soma (to wire the headless provider), by eidos (to generate CSS selectors), by sema (to bind perceptual channels), and by the docs site (to render part tables). + +**One file per component**, at `src/uix/morfo/components/{kebab}.ts`. No prose — that's the component's README. No props — those live in `types.ts` with JSDoc. Just the machine-readable contract. + +--- + +## Why morfo exists + +Without morfo, a component's structural information lives in many places: + +- Part names in `createAttrs({ parts: [...] })` inside the provider. +- Data-attr enums in `registerContract({ parts: {...} })` also in the provider. +- ARIA emission hardcoded in the provider's `$derived.by(...)` props. +- Keyboard handlers scattered across the provider. +- Prose descriptions in the README. +- Selector strings duplicated in eidos CSS, sema `.csem`, docs tables. + +Renaming a part (`content` → `panel`) used to mean touching 6+ locations with zero automatic verification. Cross-layer drift (soma emits `data-dialog-content`, eidos styles `data-dialog-panel`) was silent. + +With morfo, **every location reads from the same declaration**. Parts, data-attrs, and their enum values are authored once. `createAttrs` and `registerContract` consume the morfo directly. A smoke test validates the real DOM against the declaration on every CI run. + +--- + +## What morfo contains + +A `Morfo` is a plain TypeScript constant that describes: + +- **`name`** — PascalCase display name (`"Dialog"`). +- **`kebab`** — kebab-case identifier (`"dialog"`), matches `data-{kebab}` and `createAttrs({component})`. +- **`scope`** — which layers implement this component: `['soma']`, `['soma', 'eidos']`, etc. +- **`apg`** — optional URL to the WAI-ARIA APG pattern when the component implements a formal one. +- **`focus`** — optional focus policy for overlays / composites. +- **`parts`** — the part tree (recursive). Each part declares: + - `name`, `kebab`, `kind` (`public` / `virtual`). + - `defaultElement` (advisory), `role` (always-emitted). + - `optional`, `supportsNesting`. + - `states` — the state names this part can be in. + - `data` — data-attributes emitted, with enum values when applicable. + - `aria` — ARIA attribute contract (attr + value source + condition). + - `keyboard` — keyboard shortcuts relevant when the part has focus. + - `parts` — nested sub-parts (recursive). + +See [`types.ts`](./types.ts) for the full TypeScript shape. + +## What morfo does NOT contain + +| Not in morfo | Lives in | Reason | +|--------------|----------|--------| +| Summary, overview prose | `{component}/README.md` | Editorial, not contract | +| Comparison vs Radix / Base UI / Bits | `{component}/README.md` | Third-party drift shouldn't pollute the contract | +| Usage examples | `{component}/README.md` | Narrative | +| Props (names, types, defaults) | `{component}/types.ts` with JSDoc | Canonical source is TS + JSDoc | +| Translations | `{component}/langs.ts` (idlangref) | Separate registry, consumed by the provider | +| Event handlers, state machines | `{component}-provider.svelte.ts` | Code, not data | +| Visual variants / recipes | `src/uix/eidos/` (future) | Layer-specific, not shared | + +--- + +## Anatomy of a morfo file + +Minimal template: + +```ts +// src/uix/morfo/components/dialog.ts +import type { Morfo } from '../types'; +import { v } from '../types'; // value builders: v.literal, v.stateRef, v.partRef, v.propRef, v.translationRef + +export const dialogMorfo: Morfo = { + name: 'Dialog', + kebab: 'dialog', + scope: ['soma'], + apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/dialog/', + + focus: { + initial: 'first-focusable', + trap: true, + return: 'trigger', + restore: true + }, + + parts: [ + { + name: 'Provider', + kebab: 'root', // special: 'root' emits data-dialog (no suffix) + kind: 'virtual', // context-only, no DOM + defaultElement: 'none', + optional: false, + data: [], + aria: [] + }, + { + name: 'Trigger', + kebab: 'trigger', + kind: 'public', + defaultElement: 'button', + role: 'button', + optional: false, + states: ['open', 'closed'], + data: [{ attr: 'data-state', values: ['open', 'closed'] }], + aria: [ + { attr: 'aria-haspopup', value: v.literal('dialog') }, + { attr: 'aria-expanded', value: v.stateRef('open') }, + { attr: 'aria-controls', value: v.partRef('content') } + ] + }, + { + name: 'Content', + kebab: 'content', + kind: 'public', + defaultElement: 'div', + role: 'dialog', + optional: false, + states: ['open', 'closed'], + data: [ + { attr: 'data-state', values: ['open', 'closed'] }, + { attr: 'data-last-action', + values: ['saved', 'cancelled', 'dismissed', 'failed'], + severity: 'optional' } + ], + aria: [ + { + attr: 'aria-labelledby', + value: v.partRef('title'), + condition: { when: 'part-present', part: 'title' }, + severity: 'recommended' + } + ], + keyboard: [{ key: 'Escape', action: 'close' }] + }, + { + name: 'Title', + kebab: 'title', + kind: 'public', + defaultElement: 'div', + role: 'heading', + optional: true, + data: [], + aria: [{ attr: 'aria-level', value: v.propRef('level'), severity: 'recommended' }] + } + ] +}; +``` + +--- + +## Authoring a new morfo + +### Step 1 — Create the file + +Write `src/uix/morfo/components/{kebab}.ts` exporting a `{camelName}Morfo` const. + +**Naming rules**: +- `name`: PascalCase. `"Dialog"`, `"DateRangePicker"`, `"ColorField"`. +- `kebab`: kebab-case. `"dialog"`, `"date-range-picker"`, `"color-field"`. Must match `data-{kebab}` and `createAttrs({component})` in the provider. +- Part `kebab`s must be **unique across the whole morfo** — no nested path namespacing. If a conflict arises, rename (e.g. `item-trigger` instead of `trigger`). +- The root part uses `kebab: 'root'` — special-cased to emit `data-{component}` with no suffix. + +### Step 2 — Declare parts + +For each part, decide: + +- **`kind`**: + - `'public'` when the consumer composes the part (e.g. ``). + - `'virtual'` for internal coordinators that have no DOM of their own (context-only providers, focus guards). Use with `defaultElement: 'none'`. +- **`defaultElement`**: the HTML element the wrapper renders by default. Advisory — the consumer can override via `child` snippet. See the `MorfoElement` union in [`types.ts`](./types.ts). +- **`role`**: always-emitted ARIA role. Declare this even when the element has an implicit role (e.g. `