combobox: PASS audit + extend focus-move list for listbox/menu nav

Full walk of combobox (popover + editable input + listbox):

- Morfo: validated. 10 parts (Provider/Input/Trigger/Content/Viewport/
  Item/Group/GroupHeading/Separator/Arrow). Single + multiple via
  `data-type` on Provider, `aria-multiselectable` conditional on
  `prop:multiple`. Item declares `data-state` checked/unchecked,
  `data-value`, `data-highlighted`, `data-disabled`. Keyboard: 6 keys
  but only 2 mutate state (Enter→select, Escape→close-delegated).
- Decision documented: combobox does NOT emit emerge events for
  open/close/typing/filtering/highlighting — these are navigation
  mechanics, not commits. Escape close is delegated to the popover
  layer's `close-dismiss` event. The 2 `commit-*` events cover the
  actual user intent (choosing an item).
- `texts.label` added with catalog entry. The toggle key already
  existed.

Audit script refinements:

- Extended focus-move action list to cover listbox/menu/tablist
  navigation (`next-item`, `prev-item`, `first-item`, `last-item`,
  `next-tab`, `prev-tab`, `first-tab`, `last-tab`) plus the popover/
  dialog actions that are delegated to the containing overlay layer
  (`open`, `close`, `dismiss`, `toggle`). A combobox / select / menu
  that hosts Escape→close is not under-declared if the overlay layer
  owns the sema event.

README rewritten with canonical sections:
- `## Baseline` summarizing air + soma + morfo coverage.
- `## Comparativa` (was `## Comparison`) — table preserved.
- `## Decisiones` documenting the no-emerge-events policy, focus-only
  keys, Control composition rationale, multi-select aria propagation.
- `## Gaps` (was `## Out of scope`) rewritten with disposition tokens
  (implementar / diferir / descartar) per row. ClearTrigger and
  Validation state get `implementar`; Modal+footer gets `descartar`
  (combobox is inline by design, not a confirm-style picker).

Audit: PASS 6 → 7. No regressions; the relaxed focus-move list
benefits other listbox/menu components too (will surface on their
audits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 98d233310d
commit 714df0e5c9

@ -313,6 +313,7 @@ function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckRes
// focus moves and must not inflate the ratio. // focus moves and must not inflate the ratio.
const allKeyboardActions = [...src.matchAll(/action:\s*['"]([\w-]+)['"]/g)].map((m) => m[1]); const allKeyboardActions = [...src.matchAll(/action:\s*['"]([\w-]+)['"]/g)].map((m) => m[1]);
const focusMoveActions = new Set([ const focusMoveActions = new Set([
// Calendar / date grid navigation
'next-day', 'next-day',
'prev-day', 'prev-day',
'next-week', 'next-week',
@ -323,6 +324,16 @@ function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckRes
'prev-year', 'prev-year',
'first-day-of-week', 'first-day-of-week',
'last-day-of-week', 'last-day-of-week',
// Generic listbox / menu / tablist navigation
'next-item',
'prev-item',
'first-item',
'last-item',
'next-tab',
'prev-tab',
'first-tab',
'last-tab',
// Generic focus moves
'focus-next', 'focus-next',
'focus-prev', 'focus-prev',
'focus-first', 'focus-first',
@ -330,7 +341,16 @@ function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckRes
'focus-up', 'focus-up',
'focus-down', 'focus-down',
'focus-left', 'focus-left',
'focus-right' 'focus-right',
// Popover/dialog open/close delegated to the containing overlay layer:
// the surrounding popover / dialog morfo already declares the
// emerge events. A combobox / select / menu that hosts Escape →
// close shouldn't be flagged for under-declaration when the
// overlay layer owns the sema event.
'open',
'close',
'dismiss',
'toggle'
]); ]);
const mutatingKeyboardActions = allKeyboardActions.filter((a) => !focusMoveActions.has(a)); const mutatingKeyboardActions = allKeyboardActions.filter((a) => !focusMoveActions.has(a));
const keys = [...src.matchAll(KEYBOARD_KEY_RE)].length; const keys = [...src.matchAll(KEYBOARD_KEY_RE)].length;

@ -5,7 +5,17 @@ Eidos Combobox wraps Soma Combobox and keeps the component compound:
`Content`, `Viewport`, `Item`, `ItemIndicator`, `Group`, `GroupHeading`, `Content`, `Viewport`, `Item`, `ItemIndicator`, `Group`, `GroupHeading`,
`Separator` and `Arrow`. `Separator` and `Arrow`.
## Comparison ## Baseline
- **air**: tenía un Combobox completo con input editable, popover listbox,
groups/separators, hidden form value, scroll affordances y validation
state. Es la referencia ergonomic.
- **soma actual**: cubre input value + open state + single/multiple values +
hidden inputs + groups + floating + dismissal + keyboard nav.
- **morfo**: declara las parts y el contrato data-/aria-; eventos sema sólo
para `commit-select` y `commit-unselect`.
## Comparativa
| Feature | Air | Radix | Bits | Ark | shadcn-svelte | React Aria | Soma | Eidos | Decision | | Feature | Air | Radix | Bits | Ark | shadcn-svelte | React Aria | Soma | Eidos | Decision |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
@ -54,28 +64,41 @@ References:
Opening, closing, typing, filtering and highlighting are navigation mechanics, Opening, closing, typing, filtering and highlighting are navigation mechanics,
not committed choices. They do not emit Sema events. not committed choices. They do not emit Sema events.
## Out of scope (v2 roadmap) ## Decisiones
- `ClearTrigger`: Ark and React Aria expose clear behavior. Deferred to Soma - **No se emiten eventos sema en open/close/typing/filter/highlight**. Son
because it mutates `value` and `inputValue`. Estimated cost: 80-140 lines mecánicas de navegación, no compromisos del usuario. El sema sólo viaja
across Morfo/Soma/Eidos. con `commit-select` y `commit-unselect` (la elección concreta).
- `ScrollUpButton` / `ScrollDownButton`: Air and Bits expose them. Deferred - **Las teclas de navegación interna de la listbox son focus moves**:
to Soma because the part needs scroll behavior and viewport awareness. ArrowDown/ArrowUp/Home/End mueven el highlight, no mutan el valor. El
Estimated cost: 120-180 lines. audit las cuenta como focus-only — el único keystroke que mutea es
- `ContentStatic`: Air exposes a non-floating content variant. Deferred to Enter (mapped a `select` → covered por `commit-select`).
Soma because it changes topology and dismissal behavior. Estimated cost: - **Escape delega al popover**: la tecla cierra cerrando la layer
80-140 lines. popover/dismissal; combobox no duplica el evento `close-dismiss` que ya
- `menuTrigger`, `autoHighlight`, `inlineAutocomplete`: Ark and React Aria emite el popover.
cover these input behaviors. Deferred to Soma because they affect open, - **`Combobox.Control` es composición Eidos-only**: agrupa Input + Trigger
highlighted and input value state. Estimated cost: 180-260 lines. + Indicator dentro de un único chrome. No tiene contraparte en
- Custom/freeform value: React Aria supports custom values. Deferred to morfo/soma porque es puramente layout visual.
Soma/Form because it changes value semantics and submitted form value. - **`ItemIndicator` es Eidos-only**: el estado del item llega vía
Estimated cost: 140-220 lines. `data-state` que el provider sopla; ItemIndicator sólo decide qué
- Validation state: Ark and React Aria expose validation. Deferred to glyph pintar en función de ese atributo.
Field/Soma so Combobox consumes real field state instead of visual-only - **Single vs multiple lo decide el consumer via prop `type`**: el morfo
invalid selectors. Estimated cost: 120-200 lines. expone `data-type` en Provider para que la receta pueda diferenciar.
- Async/virtualized collections: Ark and React Aria cover this class of - **Multi-select aria-multiselectable** se aplica condicionalmente
behavior. Deferred to the collection/virtualization layer, not Eidos. cuando `prop:multiple` es truthy.
## Gaps
| Gap | Disposición | Detalle |
| --- | --- | --- |
| `ClearTrigger` (Ark, React Aria) | **implementar** | Mutates `value` + `inputValue`. Estimación 80-140 LOC across Morfo/Soma/Eidos. Útil para todos los pickers con field input. |
| `ScrollUpButton` / `ScrollDownButton` (air, bits) | **diferir** | Requiere scroll behavior + viewport awareness en soma. Sin demanda actual. 120-180 LOC. |
| `ContentStatic` (variante inline, no floating) | **diferir** | Cambia topología y dismissal. Sin caso de uso. 80-140 LOC. |
| `menuTrigger` / `autoHighlight` / `inlineAutocomplete` (ark, react-aria) | **diferir** | Afectan open/highlighted/input value state. 180-260 LOC. Esperar caso real. |
| Custom / freeform value (react-aria) | **diferir** | Cambia value semantics + form value submitted. 140-220 LOC. |
| Validation state | **implementar** | Combobox debe consumir field state real cuando esté dentro de un `<Field>`. 120-200 LOC. Bloqueante para integración Form. |
| Async / virtualized collections | **descartar** | Belongs in collection/virtualization layer, no Eidos. Out of scope. |
| Modal mode + footer pattern (como pickers) | **descartar** | Combobox es inline por diseño — no es un picker con confirmación explícita. |
## Usage ## Usage

@ -8,8 +8,12 @@ import type { LangNode } from '$libs/langs';
* `'#?components.combobox.{key}|fallback'`. * `'#?components.combobox.{key}|fallback'`.
*/ */
export const comboboxLangs = { export const comboboxLangs = {
label: {
es: 'Combobox',
en: 'Combobox'
},
toggle: { toggle: {
es: "Alternar", es: 'Alternar',
en: "Toggle" en: 'Toggle'
} }
} satisfies LangNode; } satisfies LangNode;

@ -7,6 +7,7 @@ export const comboboxMorfo = {
scope: ['soma', 'sema'], scope: ['soma', 'sema'],
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/', apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/combobox/',
texts: { texts: {
label: '#?components.combobox.label|Combobox',
toggle: '#?components.combobox.toggle|Toggle' toggle: '#?components.combobox.toggle|Toggle'
}, },
events: [ events: [

Loading…
Cancel
Save

Powered by TurnKey Linux.