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) <noreply@anthropic.com>
semantuix
parent
8a5b402dd3
commit
0222af7a0b
@ -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. `<Dialog.Trigger>`).
|
||||
- `'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. `<button>` has `role=button`) — this makes the contract polymorphism-safe: if the consumer uses `<div>` via `child`, the role still applies.
|
||||
- **`optional`**: `true` if the part may be absent from a valid composition (Title, Description, Close, Overlay, Indicator, Separator). `false` for required parts (root + core).
|
||||
- **`states`**: only declare if the part carries a `data-state` enum. List the exact values (e.g. `['open', 'closed']`). Required for `stateRef` ARIA values to validate.
|
||||
- **`supportsNesting`**: `true` if this part can nest inside itself (Dialog inside Dialog, Menu inside Menu). Informational — enables eidos to style nested instances with scoped selectors.
|
||||
|
||||
### Step 3 — Declare `data` entries
|
||||
|
||||
For each data-attr the provider emits:
|
||||
|
||||
```ts
|
||||
data: [
|
||||
// Enum-valued: the complete set.
|
||||
{ attr: 'data-state', values: ['open', 'closed'] },
|
||||
|
||||
// Presence-only flag: emitted only when true, absent otherwise.
|
||||
{ attr: 'data-disabled', severity: 'optional' },
|
||||
|
||||
// Conditionally-emitted attr.
|
||||
{
|
||||
attr: 'data-starting-style',
|
||||
severity: 'optional',
|
||||
condition: { when: 'state-equals', state: 'open', value: 'starting' }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Severity rules**:
|
||||
|
||||
- `'required'` (default) — the attr must always be emitted. Missing = error in strict mode.
|
||||
- `'recommended'` — emitted in most cases. Missing = warning.
|
||||
- `'optional'` — emitted only when its condition is met (e.g. `data-disabled` only when disabled). Missing ≠ error.
|
||||
|
||||
Use `'optional'` for all presence-only flags so morfo-check doesn't flag them as missing when they're legitimately absent.
|
||||
|
||||
### Step 4 — Declare `aria` entries
|
||||
|
||||
ARIA values use a tagged union via builders:
|
||||
|
||||
```ts
|
||||
aria: [
|
||||
{ attr: 'aria-haspopup', value: v.literal('dialog') }, // static literal
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') }, // refs states[]
|
||||
{ attr: 'aria-labelledby', value: v.partRef('title') }, // refs another part's kebab
|
||||
{ attr: 'aria-level', value: v.propRef('level') }, // refs a consumer prop
|
||||
{ attr: 'aria-label', value: v.translationRef('common.buttons.close') } // refs a langs.ts idlangref
|
||||
]
|
||||
```
|
||||
|
||||
**Value kinds**:
|
||||
|
||||
- `v.literal(value)` — static string invariant. `aria-haspopup="dialog"`, `aria-modal="true"`.
|
||||
- `v.stateRef(name)` — the attribute value derives from a named state. Validator requires `name` to be in the containing part's `states[]`.
|
||||
- `v.partRef(target)` — the attribute value is the id of another part. Validator requires `target` to be an existing kebab in the morfo.
|
||||
- `v.propRef(prop)` — the attribute value comes from a consumer prop (override, passthrough).
|
||||
- `v.translationRef(key)` — the attribute value resolves to a translation.
|
||||
|
||||
Add `condition` when the ARIA is emitted only in some cases:
|
||||
|
||||
```ts
|
||||
condition: 'always'
|
||||
condition: { when: 'part-present', part: 'title' }
|
||||
condition: { when: 'state-equals', state: 'open', value: 'true' }
|
||||
condition: { when: 'prop-truthy', prop: 'modal' }
|
||||
condition: { when: 'prop-falsy', prop: 'disabled' }
|
||||
```
|
||||
|
||||
### Step 5 — Optional: keyboard and focus
|
||||
|
||||
```ts
|
||||
keyboard: [
|
||||
{ key: 'Escape', action: 'close' },
|
||||
{ key: 'Tab', action: 'focus-next' },
|
||||
{ key: 'Shift+Tab', action: 'focus-prev' }
|
||||
]
|
||||
|
||||
focus: {
|
||||
initial: 'first-focusable', // 'first-focusable' | 'trigger' | { partRef: 'content' }
|
||||
trap: true,
|
||||
return: 'trigger', // 'trigger' | 'previous' | { partRef: '...' }
|
||||
restore: true
|
||||
}
|
||||
```
|
||||
|
||||
Focus is only required for overlay/composite components (Dialog, Drawer, Popover). Plain controls omit it.
|
||||
|
||||
### Step 6 — Wire the provider
|
||||
|
||||
In `src/uix/soma/components/{kebab}/{kebab}-provider.svelte.ts`:
|
||||
|
||||
```ts
|
||||
import { createAttrs, registerContract } from '../../attrs';
|
||||
import { dialogMorfo } from '../../../morfo/components/dialog';
|
||||
|
||||
const attrs = createAttrs(dialogMorfo);
|
||||
registerContract(dialogMorfo);
|
||||
```
|
||||
|
||||
That's it. No more inline `createAttrs({component, parts})`, no more `registerContract({name, version, parts: {...}})`.
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
Three layers catch three classes of drift:
|
||||
|
||||
### 1. Sium schema (build / dev)
|
||||
|
||||
`src/uix/morfo/schema.ts` exports `validateMorfo(morfo)` which:
|
||||
|
||||
- Checks shape: types, enum discriminators, literal unions.
|
||||
- Checks cross-field invariants:
|
||||
- Every part `kebab` is unique in the morfo.
|
||||
- Every `partRef.target` resolves to an existing part.
|
||||
- Every `stateRef.state` exists in the containing part's `states[]`.
|
||||
- Every `state-equals` condition's `state` exists in the containing part.
|
||||
- `focus.initial.partRef` / `focus.return.partRef` resolve.
|
||||
- `scope` is non-empty.
|
||||
- `kebab` matches `/^[a-z][a-z0-9-]*$/`.
|
||||
|
||||
Run a morfo through this to catch authoring errors early. See [`components/dialog.test.ts`](./components/dialog.test.ts) for a reference test.
|
||||
|
||||
### 2. Strict mode in `assertContract` (dev runtime)
|
||||
|
||||
The provider's `assertProps` walks the emitted data-attrs and checks their values against the registered contract. In dev mode, a value not in the declared `values[]` logs a warning:
|
||||
|
||||
```
|
||||
[soma] dialog.content: "data-state" has value "opening" but contract expects one of: open, closed
|
||||
```
|
||||
|
||||
### 3. Smoke + morfo-check (CI)
|
||||
|
||||
Two npm scripts exercise the morfos against the real DOM:
|
||||
|
||||
- `npm run smoke` — Playwright walks all `/test/soma/*` routes. Catches `pageerror`, `console.error`, translation-key-not-found, context-not-found. Not morfo-specific but catches common regressions.
|
||||
|
||||
- `npm run morfo:check` — For each morfo, navigates to `/test/soma/{kebab}` and validates:
|
||||
- Every declared data-attr with `severity: 'required'` is emitted.
|
||||
- Every emitted data-attr value matches `values[]` if declared.
|
||||
- No undeclared `data-{component}-*` attrs are emitted (except `data-_*` private).
|
||||
|
||||
- `npm run morfo:vocabulary` — Flags `data-state` enums that diverge from canonical vocabularies (`open|closed`, `active|inactive`, `checked|unchecked|indeterminate`, etc.). WARN-level; novel vocabularies may be legitimate but should be reviewed.
|
||||
|
||||
Both scripts require `npm run dev` running in another terminal.
|
||||
|
||||
---
|
||||
|
||||
## The data-attr convention
|
||||
|
||||
`createAttrs(morfo)` derives data-attr names from parts:
|
||||
|
||||
| Part kebab | Emitted attr |
|
||||
|------------|--------------|
|
||||
| `'root'` | `data-{component}` — no suffix |
|
||||
| `'trigger'` | `data-{component}-trigger` |
|
||||
| `'item-group'` | `data-{component}-item-group` |
|
||||
|
||||
Never `data-soma-*`, never `data-eidos-*` — always `data-{component}[-{part}]`.
|
||||
|
||||
Private attrs for internal debug / state use the `data-_*` prefix (convention only — no enforcement), which is skipped by strict-mode validation.
|
||||
|
||||
---
|
||||
|
||||
## Handling polymorphism (consumer renders a different element)
|
||||
|
||||
The `child` snippet pattern allows consumers to swap the default element:
|
||||
|
||||
```svelte
|
||||
<Dialog.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<a href="/about" {...props}>About</a>
|
||||
{/snippet}
|
||||
</Dialog.Trigger>
|
||||
```
|
||||
|
||||
Morfo's `defaultElement` is **advisory** — the provider doesn't enforce it. What IS guaranteed is `role`: the provider always emits the explicit role (e.g. `role="button"` on a Trigger even though `<button>` has it implicitly). When the consumer renders as `<a>`, the role stays correct.
|
||||
|
||||
Keyboard handlers should also be element-agnostic: emit `onkeydown` that handles both Enter and Space for "activate" regardless of the underlying element, since `<a>` only handles Enter natively and `<div>` handles neither.
|
||||
|
||||
---
|
||||
|
||||
## Sema alignment
|
||||
|
||||
[Sema](../sema/sema_pre.md) is the future perceptual/semantic layer. It consumes the same DOM surface that morfo declares — no extra hooks needed. The morfo authoring rules that support Sema:
|
||||
|
||||
- **Transition markers**: components with enter/exit transitions declare `data-starting-style` and `data-ending-style` on the transitioning part.
|
||||
|
||||
- **Causal exit states**: components with multiple semantically distinct exit paths (Dialog: saved / cancelled / dismissed / failed; Toast: dismissed / auto-timeout / action) declare `data-last-action` with enumerable `values`. The provider is expected to update `data-last-action` **before** `data-state` changes, so Sema can tint the exit animation per-action. (Tracked by a dedicated MutationObserver timing test — future work.)
|
||||
|
||||
- **Cross-component vocabulary consistency**: the `morfo:vocabulary` script groups components by data-attr semantic (disclosure → `open|closed`, lifecycle → `loading|idle|success|error`) and flags divergent vocabularies for review.
|
||||
|
||||
These don't change the morfo shape — they're authoring conventions that enable Sema without requiring a Sema-aware provider.
|
||||
|
||||
---
|
||||
|
||||
## Files in this package
|
||||
|
||||
```
|
||||
src/uix/morfo/
|
||||
├── README.md ← (this file) developer guide
|
||||
├── DESIGN.md ← design proposal shared with external reviewers
|
||||
├── study.md ← record of the iterative AI peer-review process
|
||||
├── types.ts ← Morfo interface + v.* builders
|
||||
├── schema.ts ← sium-based validator + CANONICAL_VOCABULARIES
|
||||
├── index.ts ← package barrel
|
||||
└── components/
|
||||
├── dialog.ts ← one morfo per component (66 files)
|
||||
├── accordion.ts
|
||||
├── ...
|
||||
└── dialog.test.ts ← reference test pattern
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run check` | TypeScript type-check across the repo (catches shape errors in morfos). |
|
||||
| `npx vitest run src/uix/morfo` | Run morfo unit tests (schema invariants). |
|
||||
| `npm run smoke` | Playwright smoke over all `/test/soma/*` routes (requires dev server). |
|
||||
| `npm run morfo:check` | Validate emitted DOM vs morfo per-component (requires dev server). |
|
||||
| `npm run morfo:vocabulary` | Flag data-state enums that diverge from canonical vocabularies. |
|
||||
|
||||
---
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
**Forgetting `as const` or a type annotation.** Morfo files should declare `satisfies Morfo` or `: Morfo` so TypeScript catches shape errors early:
|
||||
|
||||
```ts
|
||||
// Good
|
||||
export const dialogMorfo: Morfo = { ... };
|
||||
|
||||
// Also good
|
||||
export const dialogMorfo = { ... } satisfies Morfo;
|
||||
```
|
||||
|
||||
**Duplicate kebab in the tree.** `item` in one part and `item` in another = error. Rename one.
|
||||
|
||||
**`stateRef` without declaring `states[]`.** If a part emits `aria-expanded` via `v.stateRef('open')`, the part **must** declare `states: ['open', ...]`. Otherwise the validator throws.
|
||||
|
||||
**`partRef` to a non-existent kebab.** Common after renaming a part. The validator catches this — but the dev-time warning is silent if you skip `validateMorfo`.
|
||||
|
||||
**Missing `severity: 'optional'` on presence flags.** If you declare `{ attr: 'data-disabled' }` without severity, strict mode treats it as required. Add `severity: 'optional'` so morfo-check doesn't flag it missing when the flag is legitimately absent.
|
||||
|
||||
**Provider emits a data-attr not in the morfo.** Strict mode logs a warning at runtime; morfo-check fails in CI. Either add the attr to the morfo or rename the provider's emission to `data-_*` (private).
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [DESIGN.md](./DESIGN.md) — the original proposal (for context on why the shape is what it is).
|
||||
- [study.md](./study.md) — the three-round AI peer-review process that refined the shape.
|
||||
- [types.ts](./types.ts) — the TypeScript interfaces (authoritative reference).
|
||||
- [COMPONENT_GUIDE.md](../soma/COMPONENT_GUIDE.md) — soma component authoring (includes items 37-39 on morfo-specific discipline: translation-namespace grep, DOM-topology audit, smoke validation).
|
||||
- [sema_pre.md](../sema/sema_pre.md) — semantic layer context (morfo provides everything Sema needs).
|
||||
@ -1,51 +1,189 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const commandMorfo: Morfo = {
|
||||
name: 'Command',
|
||||
kebab: 'command',
|
||||
scope: ['soma'],
|
||||
parts: [
|
||||
{ name: 'Provider', kebab: 'root', kind: 'public', defaultElement: 'div', optional: false, data: [], aria: [] },
|
||||
{ name: 'Input', kebab: 'input', kind: 'public', defaultElement: 'input', optional: false, data: [], aria: [] },
|
||||
{ name: 'List', kebab: 'list', kind: 'public', defaultElement: 'div', optional: false, data: [], aria: [] },
|
||||
{ name: 'Viewport', kebab: 'viewport', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'root',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Input',
|
||||
kebab: 'input',
|
||||
kind: 'public',
|
||||
defaultElement: 'input',
|
||||
role: 'combobox',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [
|
||||
{ attr: 'aria-autocomplete', value: v.literal('list') },
|
||||
{ attr: 'aria-expanded', value: v.literal('true') },
|
||||
{
|
||||
attr: 'aria-controls',
|
||||
value: v.partRef('list'),
|
||||
condition: { when: 'part-present', part: 'list' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-activedescendant', value: v.propRef('selectedId'), severity: 'optional' }
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'ArrowDown', action: 'next-item' },
|
||||
{ key: 'ArrowUp', action: 'prev-item' },
|
||||
{ key: 'Home', action: 'first-item' },
|
||||
{ key: 'End', action: 'last-item' },
|
||||
{ key: 'Enter', action: 'select' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'List',
|
||||
kebab: 'list',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'listbox',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.command.list|Suggestions'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Viewport',
|
||||
kebab: 'viewport',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'option',
|
||||
optional: false,
|
||||
data: [
|
||||
{ attr: 'data-selected', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: []
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-selected',
|
||||
value: v.propRef('selected'),
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.propRef('disabled'),
|
||||
severity: 'optional'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'LinkItem',
|
||||
kebab: 'link-item',
|
||||
kind: 'public',
|
||||
defaultElement: 'a',
|
||||
role: 'option',
|
||||
optional: true,
|
||||
data: [
|
||||
{ attr: 'data-selected', severity: 'optional' },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: []
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-selected',
|
||||
value: v.propRef('selected'),
|
||||
severity: 'optional'
|
||||
},
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.propRef('disabled'),
|
||||
severity: 'optional'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Group',
|
||||
kebab: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'group',
|
||||
optional: true,
|
||||
data: [{ attr: 'data-empty', severity: 'optional' }],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('group-heading'),
|
||||
condition: { when: 'part-present', part: 'group-heading' },
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'GroupHeading',
|
||||
kebab: 'group-heading',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
|
||||
},
|
||||
{
|
||||
name: 'GroupItems',
|
||||
kebab: 'group-items',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'presentation',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{ name: 'GroupHeading', kebab: 'group-heading', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{ name: 'GroupItems', kebab: 'group-items', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{ name: 'Empty', kebab: 'empty', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{ name: 'Loading', kebab: 'loading', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{ name: 'Separator', kebab: 'separator', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] }
|
||||
{
|
||||
name: 'Empty',
|
||||
kebab: 'empty',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'status',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-live', value: v.literal('polite') }]
|
||||
},
|
||||
{
|
||||
name: 'Loading',
|
||||
kebab: 'loading',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'progressbar',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [
|
||||
{ attr: 'aria-valuemin', value: v.literal('0') },
|
||||
{ attr: 'aria-valuemax', value: v.literal('100') },
|
||||
{ attr: 'aria-valuenow', value: v.propRef('percent'), severity: 'optional' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Separator',
|
||||
kebab: 'separator',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'separator',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-orientation', value: v.literal('horizontal') }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@ -1,21 +1,63 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const datePickerMorfo: Morfo = {
|
||||
name: 'DatePicker',
|
||||
kebab: 'date-picker',
|
||||
scope: ['soma'],
|
||||
|
||||
focus: {
|
||||
initial: 'first-focusable',
|
||||
trap: true,
|
||||
return: 'trigger',
|
||||
restore: true
|
||||
},
|
||||
|
||||
parts: [
|
||||
{ name: 'Provider', kebab: 'root', kind: 'virtual', defaultElement: 'none', optional: false, data: [], aria: [] },
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'root',
|
||||
kind: 'virtual',
|
||||
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: []
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-haspopup', value: v.literal('dialog') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-controls',
|
||||
value: v.partRef('calendar'),
|
||||
condition: { when: 'part-present', part: 'calendar' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.date-picker.trigger|Choose date'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ name: 'Calendar', kebab: 'calendar', kind: 'public', defaultElement: 'div', optional: false, data: [], aria: [] }
|
||||
{
|
||||
name: 'Calendar',
|
||||
kebab: 'calendar',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'dialog',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@ -1,21 +1,63 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const dateRangePickerMorfo: Morfo = {
|
||||
name: 'DateRangePicker',
|
||||
kebab: 'date-range-picker',
|
||||
scope: ['soma'],
|
||||
|
||||
focus: {
|
||||
initial: 'first-focusable',
|
||||
trap: true,
|
||||
return: 'trigger',
|
||||
restore: true
|
||||
},
|
||||
|
||||
parts: [
|
||||
{ name: 'Provider', kebab: 'root', kind: 'virtual', defaultElement: 'none', optional: false, data: [], aria: [] },
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'root',
|
||||
kind: 'virtual',
|
||||
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: []
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-haspopup', value: v.literal('dialog') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-controls',
|
||||
value: v.partRef('calendar'),
|
||||
condition: { when: 'part-present', part: 'calendar' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.date-range-picker.trigger|Choose date range'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ name: 'Calendar', kebab: 'calendar', kind: 'public', defaultElement: 'div', optional: false, data: [], aria: [] }
|
||||
{
|
||||
name: 'Calendar',
|
||||
kebab: 'calendar',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'dialog',
|
||||
optional: false,
|
||||
data: [],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@ -1,95 +1,255 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const dropdownMenuMorfo: Morfo = {
|
||||
name: 'DropdownMenu',
|
||||
kebab: 'dropdown-menu',
|
||||
scope: ['soma'],
|
||||
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/',
|
||||
|
||||
focus: {
|
||||
initial: 'first-focusable',
|
||||
trap: true,
|
||||
return: 'trigger',
|
||||
restore: true
|
||||
},
|
||||
|
||||
parts: [
|
||||
{ name: 'Provider', kebab: 'root', kind: 'virtual', defaultElement: 'none', optional: false, data: [], aria: [] },
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'root',
|
||||
kind: 'virtual',
|
||||
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: []
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-haspopup', value: v.literal('menu') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-controls',
|
||||
value: v.partRef('content'),
|
||||
condition: { when: 'part-present', part: 'content' },
|
||||
severity: 'recommended'
|
||||
}
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Enter', action: 'open' },
|
||||
{ key: ' ', action: 'open' },
|
||||
{ key: 'ArrowDown', action: 'open-first' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Content',
|
||||
kebab: 'content',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'menu',
|
||||
optional: false,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
|
||||
aria: []
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('trigger'),
|
||||
condition: { when: 'part-present', part: 'trigger' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-orientation', value: v.literal('vertical') }
|
||||
],
|
||||
keyboard: [
|
||||
{ key: 'Escape', action: 'close' },
|
||||
{ key: 'ArrowDown', action: 'next-item' },
|
||||
{ key: 'ArrowUp', action: 'prev-item' },
|
||||
{ key: 'Home', action: 'first-item' },
|
||||
{ key: 'End', action: 'last-item' },
|
||||
{ key: 'Enter', action: 'activate' },
|
||||
{ key: ' ', action: 'activate' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Item',
|
||||
kebab: 'item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'menuitem',
|
||||
optional: false,
|
||||
data: [{ attr: 'data-disabled', severity: 'optional' }],
|
||||
aria: []
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.literal('true'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'CheckboxItem',
|
||||
kebab: 'checkbox-item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'menuitemcheckbox',
|
||||
optional: true,
|
||||
states: ['checked', 'unchecked'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['checked', 'unchecked'] },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-checked', value: v.stateRef('checked') },
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.literal('true'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'RadioGroup',
|
||||
kebab: 'radio-group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'group',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{ name: 'RadioGroup', kebab: 'radio-group', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{
|
||||
name: 'RadioItem',
|
||||
kebab: 'radio-item',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'menuitemradio',
|
||||
optional: true,
|
||||
states: ['checked', 'unchecked'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['checked', 'unchecked'] },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: [
|
||||
{ attr: 'aria-checked', value: v.stateRef('checked') },
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.literal('true'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Sub',
|
||||
kebab: 'sub',
|
||||
kind: 'virtual',
|
||||
defaultElement: 'none',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{ name: 'Sub', kebab: 'sub', kind: 'virtual', defaultElement: 'none', optional: true, data: [], aria: [] },
|
||||
{
|
||||
name: 'SubTrigger',
|
||||
kebab: 'sub-trigger',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'menuitem',
|
||||
optional: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [
|
||||
{ attr: 'data-state', values: ['open', 'closed'] },
|
||||
{ attr: 'data-disabled', severity: 'optional' }
|
||||
],
|
||||
aria: []
|
||||
aria: [
|
||||
{ attr: 'aria-haspopup', value: v.literal('menu') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-controls',
|
||||
value: v.partRef('sub-content'),
|
||||
condition: { when: 'part-present', part: 'sub-content' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{
|
||||
attr: 'aria-disabled',
|
||||
value: v.literal('true'),
|
||||
severity: 'optional',
|
||||
condition: { when: 'prop-truthy', prop: 'disabled' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'SubContent',
|
||||
kebab: 'sub-content',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'menu',
|
||||
optional: true,
|
||||
states: ['open', 'closed'],
|
||||
data: [{ attr: 'data-state', values: ['open', 'closed'] }],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('sub-trigger'),
|
||||
condition: { when: 'part-present', part: 'sub-trigger' },
|
||||
severity: 'recommended'
|
||||
},
|
||||
{ attr: 'aria-orientation', value: v.literal('vertical') }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Group',
|
||||
kebab: 'group',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'group',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [
|
||||
{
|
||||
attr: 'aria-labelledby',
|
||||
value: v.partRef('group-heading'),
|
||||
condition: { when: 'part-present', part: 'group-heading' },
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'GroupHeading',
|
||||
kebab: 'group-heading',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
},
|
||||
{ name: 'Group', kebab: 'group', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{ name: 'GroupHeading', kebab: 'group-heading', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{ name: 'Separator', kebab: 'separator', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] },
|
||||
{ name: 'Arrow', kebab: 'arrow', kind: 'public', defaultElement: 'div', optional: true, data: [], aria: [] }
|
||||
{
|
||||
name: 'Separator',
|
||||
kebab: 'separator',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
role: 'separator',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: [{ attr: 'aria-orientation', value: v.literal('horizontal') }]
|
||||
},
|
||||
{
|
||||
name: 'Arrow',
|
||||
kebab: 'arrow',
|
||||
kind: 'public',
|
||||
defaultElement: 'div',
|
||||
optional: true,
|
||||
data: [],
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@ -1,20 +1,47 @@
|
||||
import type { Morfo } from '../types';
|
||||
import { v } from '../types';
|
||||
|
||||
export const timeRangePickerMorfo: Morfo = {
|
||||
name: 'TimeRangePicker',
|
||||
kebab: 'time-range-picker',
|
||||
scope: ['soma'],
|
||||
|
||||
focus: {
|
||||
initial: 'first-focusable',
|
||||
trap: true,
|
||||
return: 'trigger',
|
||||
restore: true
|
||||
},
|
||||
|
||||
parts: [
|
||||
{ name: 'Provider', kebab: 'root', kind: 'virtual', defaultElement: 'none', optional: false, data: [], aria: [] },
|
||||
{
|
||||
name: 'Provider',
|
||||
kebab: 'root',
|
||||
kind: 'virtual',
|
||||
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: []
|
||||
aria: [
|
||||
{ attr: 'type', value: v.literal('button') },
|
||||
{ attr: 'aria-haspopup', value: v.literal('dialog') },
|
||||
{ attr: 'aria-expanded', value: v.stateRef('open') },
|
||||
{
|
||||
attr: 'aria-label',
|
||||
value: v.translationRef('#?components.time-range-picker.trigger|Choose time range'),
|
||||
severity: 'recommended'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Loading…
Reference in new issue