feat(morfo): 28 morfos declaran su política de foco — la especie, por escrito

9 trap (dialog, drawer, alert-dialog, context-menu, dropdown-menu,
menu-dial, onion-menu, float-panel, popover) y 19 roving (toolbar, menubar,
tabs, listbox, radio-group, stepper, tag-group, tags-input, toggle-group,
carousel, drag-drop, tree-view, tree-grid, grid-list, chronos, calendar,
range-calendar, month-grid, year-grid).

Actas dentro de cada declaración; las que corrigen mentiras:
- popover: trap FALSE — declaraba true desde hacía meses con cero lectores;
  la verdad embarcada manda.
- radio-group: orientation 'both' por APG — su propio keyboard[] declaraba
  las 4 flechas; lo embarcado (2 flechas) era la deriva. El provider pasa
  both=true al ejecutor.
- calendarios ×4 (calendar, range-calendar, month-grid, year-grid):
  grid + executor.custom firmado — roving-tabindex REAL medido en sus
  providers (focused ? 0 : -1), 2D que el primitivo 1D no expresa.
- grids/árboles (tree-grid, grid-list, chronos, carousel, drag-drop,
  tree-view): custom firmado con su razón.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 1 month ago
parent f491bc6be0
commit 2c0fca4319

@ -18,6 +18,7 @@ export const alertDialogMorfo = {
},
focus: {
kind: 'trap',
initial: 'first-focusable',
trap: true,
return: 'trigger',

@ -69,6 +69,19 @@ export const calendarMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['day'],
// The APG grid pattern: exactly one tabbable day (CalendarDayProvider,
// `focused ? 0 : -1`), arrows move the stop on two axes with month
// hops at the edges — 2D, which the shared 1D primitive cannot express.
orientation: 'grid',
executor: {
custom:
'calendar 2D day traversal (arrows = day/week, month-boundary hops) — exceeds the 1D shared primitive'
}
},
parts: [
{
name: 'Provider',

@ -5,7 +5,7 @@ export const carouselMorfo = {
name: 'Carousel',
kebab: 'carousel',
scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/carousel/',
texts: {
label: '#?components.carousel.label|Carousel',
@ -29,6 +29,16 @@ export const carouselMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['item'],
orientation: 'horizontal',
executor: {
custom:
'Slide membership is viewport-virtualised: the roving stop follows the scroller and snap state, not a static candidate query — the shared primitive cannot see it (eje focus-first F2).'
}
},
parts: [
{
name: 'Provider',
@ -160,10 +170,7 @@ export const carouselMorfo = {
role: 'tab',
optional: true,
states: ['active', 'inactive'],
data: [
{ attr: 'data-state', values: ['active', 'inactive'] },
{ attr: 'data-index' }
],
data: [{ attr: 'data-state', values: ['active', 'inactive'] }, { attr: 'data-index' }],
aria: [
{ attr: 'type', value: v.literal('button') },
{ attr: 'aria-selected', value: v.stateRef('active') },

@ -147,6 +147,16 @@ export const chronosMorfo = {
}
}
],
focus: {
kind: 'roving',
parts: ['day-cell'],
orientation: 'grid',
executor: {
custom:
'Calendar 2D traversal across month/time projections (day cells, time slots): the shared roving primitive is 1D (eje focus-first F2).'
}
},
parts: [
// ── Root ──────────────────────────────────────────────────────────────
{

@ -18,6 +18,7 @@ export const contextMenuMorfo = {
},
focus: {
kind: 'trap',
initial: 'first-focusable',
// Non-modal menu: focus enters for arrow-key nav but is NOT trapped —
// Tab / outside-click exit and close. A hard trap is dialog behaviour.

@ -106,6 +106,7 @@ export const dialogMorfo = {
],
focus: {
kind: 'trap',
initial: 'first-focusable',
trap: true,
return: 'trigger',

@ -63,6 +63,16 @@ export const dragDropMorfo = {
}
}
],
focus: {
kind: 'roving',
parts: ['draggable'],
orientation: 'both',
executor: {
custom:
'Keyboard drag model (pick up / move / drop with live target announcement) exceeds arrow roving — the arrows move a PAYLOAD, not just the stop (eje focus-first F2).'
}
},
parts: [
{
name: 'Provider',

@ -121,6 +121,7 @@ export const drawerMorfo = {
],
focus: {
kind: 'trap',
initial: 'first-focusable',
trap: true,
return: 'trigger',

@ -20,6 +20,7 @@ export const dropdownMenuMorfo = {
},
focus: {
kind: 'trap',
initial: 'first-focusable',
// Non-modal menu: focus enters for arrow-key nav, but it is NOT trapped —
// Tab / outside-click exit and close (the dismissal owns that). A hard trap

@ -101,6 +101,7 @@ export const floatPanelMorfo = {
],
focus: {
kind: 'trap',
initial: 'first-focusable',
// Non-modal by default — the provider resolves the effective trap from
// the `modal` prop (modal panels trap, free-floating panels don't).
@ -254,19 +255,63 @@ export const floatPanelMorfo = {
// runtime skips an entry whose condition is falsy → no preventDefault).
// One `kb-move` handler reads the event (key + Shift) and applies.
keyboard: [
{ key: 'Enter', action: 'kb-move-toggle', condition: { when: 'prop-truthy', prop: 'draggable' } },
{ key: ' ', action: 'kb-move-toggle', condition: { when: 'prop-truthy', prop: 'draggable' } },
{ key: 'Escape', action: 'kb-move-cancel', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{
key: 'Enter',
action: 'kb-move-toggle',
condition: { when: 'prop-truthy', prop: 'draggable' }
},
{
key: ' ',
action: 'kb-move-toggle',
condition: { when: 'prop-truthy', prop: 'draggable' }
},
{
key: 'Escape',
action: 'kb-move-cancel',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{ key: 'ArrowUp', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'ArrowDown', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'ArrowLeft', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'ArrowRight', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'Shift+ArrowUp', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'Shift+ArrowDown', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'Shift+ArrowLeft', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'Shift+ArrowRight', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{
key: 'ArrowDown',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{
key: 'ArrowLeft',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{
key: 'ArrowRight',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{
key: 'Shift+ArrowUp',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{
key: 'Shift+ArrowDown',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{
key: 'Shift+ArrowLeft',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{
key: 'Shift+ArrowRight',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{ key: 'PageUp', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'PageDown', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{
key: 'PageDown',
action: 'kb-move',
condition: { when: 'prop-truthy', prop: 'moveMode' }
},
{ key: 'Home', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } },
{ key: 'End', action: 'kb-move', condition: { when: 'prop-truthy', prop: 'moveMode' } }
]
@ -388,18 +433,66 @@ export const floatPanelMorfo = {
{ attr: 'aria-keyshortcuts', value: v.literal('Enter Space') }
],
keyboard: [
{ key: 'Enter', action: 'kb-resize-toggle', condition: { when: 'prop-truthy', prop: 'resizable' } },
{ key: ' ', action: 'kb-resize-toggle', condition: { when: 'prop-truthy', prop: 'resizable' } },
{ key: 'Escape', action: 'kb-resize-cancel', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'ArrowUp', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'ArrowDown', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'ArrowLeft', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'ArrowRight', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'Shift+ArrowUp', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'Shift+ArrowDown', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'Shift+ArrowLeft', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'Shift+ArrowRight', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{ key: 'Home', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } },
{
key: 'Enter',
action: 'kb-resize-toggle',
condition: { when: 'prop-truthy', prop: 'resizable' }
},
{
key: ' ',
action: 'kb-resize-toggle',
condition: { when: 'prop-truthy', prop: 'resizable' }
},
{
key: 'Escape',
action: 'kb-resize-cancel',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'ArrowUp',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'ArrowDown',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'ArrowLeft',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'ArrowRight',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'Shift+ArrowUp',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'Shift+ArrowDown',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'Shift+ArrowLeft',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'Shift+ArrowRight',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{
key: 'Home',
action: 'kb-resize',
condition: { when: 'prop-truthy', prop: 'resizeMode' }
},
{ key: 'End', action: 'kb-resize', condition: { when: 'prop-truthy', prop: 'resizeMode' } }
]
}

@ -5,7 +5,7 @@ export const gridListMorfo = {
name: 'GridList',
kebab: 'grid-list',
scope: ['soma', 'sema', 'eidos'],
expression: "family-default",
expression: 'family-default',
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/grid/',
texts: {
label: '#?components.grid-list.label|Grid list',
@ -34,6 +34,16 @@ export const gridListMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['cell'],
orientation: 'grid',
executor: {
custom:
'2D row/cell traversal (grid pattern): the shared roving primitive is 1D (eje focus-first F2).'
}
},
parts: [
{
name: 'Provider',

@ -36,6 +36,14 @@ export const listboxMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
// Typeahead is ADDITIVE provider behaviour on top of the arrow roving —
// it does not change the movement model, so no custom executor.
parts: ['item'],
orientation: 'vertical'
},
parts: [
{
name: 'Provider',

@ -27,6 +27,7 @@ export const menuDialMorfo = {
},
focus: {
kind: 'trap',
initial: 'first-focusable',
// Non-modal: arrows move along the fan, but Tab / outside-click exit and
// close. The trigger gets focus back on close.

@ -26,6 +26,16 @@ export const menubarMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
// The bar's own surface: roving across top-level triggers. The open
// panel's focus behaviour belongs to the child menu composition, not to
// this declaration.
parts: ['trigger'],
orientation: 'horizontal',
loop: true
},
parts: [
{
name: 'Provider',

@ -45,6 +45,17 @@ export const monthGridMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['cell'],
// One tabbable month cell (provider: `focused ? 0 : -1`, documented
// there as "roving tabindex"), arrows move the stop on the month grid.
orientation: 'grid',
executor: {
custom: '2D month-grid traversal (rows × columns of months) — exceeds the 1D shared primitive'
}
},
parts: [
{
name: 'Provider',

@ -27,6 +27,7 @@ export const onionMenuMorfo = {
},
focus: {
kind: 'trap',
initial: 'first-focusable',
// Non-modal: arrow keys navigate the ring, but Tab / outside-click exit
// and close (dismissal owns that). The trigger gets focus back on close.

@ -79,8 +79,14 @@ export const popoverMorfo = {
],
focus: {
kind: 'trap',
// FALSE is the shipped truth (eje focus-first, 2026-08-26): Popover is
// non-modal by default (`modal = false` in the wrapper) and the provider
// resolves `trapFocus ?? modal`. This declaration said `true` for months
// with zero readers — the drift that motivated the census, which now
// compares this default against the wrapper's.
trap: false,
initial: 'first-focusable',
trap: true,
return: 'trigger',
restore: true
},

@ -27,6 +27,18 @@ export const radioGroupMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['item'],
// 'both' per the APG radio pattern (all four arrows move, whatever the
// visual orientation) — adjudicated 2026-08-26 when the F4b migration
// surfaced a three-way contradiction: this morfo's own keyboard[] table
// already declared the four arrows, `focus` said 'vertical', and the
// shipped provider moved with two. The APG (the pattern this morfo's
// `apg:` URL commits to) arbitrates: keyboard[] was right.
orientation: 'both'
},
parts: [
{
name: 'Provider',
@ -129,7 +141,9 @@ export const radioGroupMorfo = {
defaultElement: 'span',
optional: true,
states: ['checked', 'unchecked'],
data: [{ attr: 'data-state', values: ['checked', 'unchecked'], value: v.stateRef('checked') }],
data: [
{ attr: 'data-state', values: ['checked', 'unchecked'], value: v.stateRef('checked') }
],
aria: [{ attr: 'aria-hidden', value: v.literal('true') }]
},
{

@ -76,6 +76,19 @@ export const rangeCalendarMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['day'],
// Same 2D day-grid stop as Calendar (one tabbable day, arrows move it
// across both axes with month hops); range selection rides on top and
// does not change the focus mechanics.
orientation: 'grid',
executor: {
custom:
'calendar 2D day traversal shared with Calendar (range preview does not alter the stop) — exceeds the 1D shared primitive'
}
},
parts: [
{
name: 'Provider',

@ -33,6 +33,14 @@ export const stepperMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
// Linear gating (non-selectable future steps) is candidate FILTERING,
// not a different movement model.
parts: ['trigger'],
orientation: 'horizontal'
},
parts: [
{
name: 'Provider',
@ -108,9 +116,7 @@ export const stepperMorfo = {
role: 'tab',
optional: true,
states: ['complete', 'current', 'incomplete'],
data: [
{ attr: 'data-state', values: ['complete', 'current', 'incomplete'] }
],
data: [{ attr: 'data-state', values: ['complete', 'current', 'incomplete'] }],
aria: [
{ attr: 'type', value: v.literal('button') },
{

@ -23,6 +23,13 @@ export const tabsMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['trigger'],
orientation: 'horizontal',
loop: true
},
parts: [
{
name: 'Provider',

@ -54,6 +54,14 @@ export const tagGroupMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
// Link is a declared variant of Item (the element carries both attrs),
// so the item selector covers both.
parts: ['item'],
orientation: 'horizontal'
},
parts: [
{
name: 'Provider',

@ -63,6 +63,12 @@ export const tagsInputMorfo = {
}
}
],
focus: {
kind: 'roving',
parts: ['item'],
orientation: 'horizontal'
},
parts: [
{
// FIRMA 2026-08-25 — the provider carries NO role. It used to be a

@ -69,6 +69,12 @@ export const toggleGroupMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['item'],
orientation: 'horizontal'
},
parts: [
{
name: 'Provider',

@ -26,6 +26,15 @@ export const toolbarMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
// The composite roves across every interactive child class — a singular
// part would misdeclare this family (eje focus-first, 2026-08-26).
parts: ['button', 'link', 'group-item'],
orientation: 'horizontal',
loop: true
},
parts: [
{
name: 'Provider',

@ -51,6 +51,16 @@ export const treeGridMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['cell'],
orientation: 'grid',
executor: {
custom:
'2D row/cell traversal (treegrid pattern): the shared roving primitive is 1D (eje focus-first F2).'
}
},
parts: [
{
name: 'Provider',

@ -63,6 +63,16 @@ export const treeViewMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['item'],
orientation: 'vertical',
executor: {
custom:
'Flattened-tree projection with typeahead and expand/collapse/* keys: the movement set exceeds next/prev over a static candidate list (eje focus-first F2).'
}
},
parts: [
{
name: 'Provider',

@ -44,6 +44,17 @@ export const yearGridMorfo = {
}
],
direction: {},
focus: {
kind: 'roving',
parts: ['cell'],
// One tabbable year cell (same roving-tabindex shape as MonthGrid),
// arrows move the stop on the year grid.
orientation: 'grid',
executor: {
custom: '2D year-grid traversal (rows × columns of years) — exceeds the 1D shared primitive'
}
},
parts: [
{
name: 'Provider',

Loading…
Cancel
Save

Powered by TurnKey Linux.