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.
const allKeyboardActions = [...src.matchAll(/action:\s*['"]([\w-]+)['"]/g)].map((m) => m[1]);
const focusMoveActions = new Set([
// Calendar / date grid navigation
'next-day',
'prev-day',
'next-week',
@ -323,6 +324,16 @@ function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckRes
'prev-year',
'first-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-prev',
'focus-first',
@ -330,7 +341,16 @@ function checkMorfo(kebab: string, src: string, info: ComponentReport): CheckRes
'focus-up',
'focus-down',
'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 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`,
`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 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
@ -54,28 +64,41 @@ References:
Opening, closing, typing, filtering and highlighting are navigation mechanics,
not committed choices. They do not emit Sema events.
## Out of scope (v2 roadmap)
- `ClearTrigger`: Ark and React Aria expose clear behavior. Deferred to Soma
because it mutates `value` and `inputValue`. Estimated cost: 80-140 lines
across Morfo/Soma/Eidos.
- `ScrollUpButton` / `ScrollDownButton`: Air and Bits expose them. Deferred
to Soma because the part needs scroll behavior and viewport awareness.
Estimated cost: 120-180 lines.
- `ContentStatic`: Air exposes a non-floating content variant. Deferred to
Soma because it changes topology and dismissal behavior. Estimated cost:
80-140 lines.
- `menuTrigger`, `autoHighlight`, `inlineAutocomplete`: Ark and React Aria
cover these input behaviors. Deferred to Soma because they affect open,
highlighted and input value state. Estimated cost: 180-260 lines.
- Custom/freeform value: React Aria supports custom values. Deferred to
Soma/Form because it changes value semantics and submitted form value.
Estimated cost: 140-220 lines.
- Validation state: Ark and React Aria expose validation. Deferred to
Field/Soma so Combobox consumes real field state instead of visual-only
invalid selectors. Estimated cost: 120-200 lines.
- Async/virtualized collections: Ark and React Aria cover this class of
behavior. Deferred to the collection/virtualization layer, not Eidos.
## Decisiones
- **No se emiten eventos sema en open/close/typing/filter/highlight**. Son
mecánicas de navegación, no compromisos del usuario. El sema sólo viaja
con `commit-select` y `commit-unselect` (la elección concreta).
- **Las teclas de navegación interna de la listbox son focus moves**:
ArrowDown/ArrowUp/Home/End mueven el highlight, no mutan el valor. El
audit las cuenta como focus-only — el único keystroke que mutea es
Enter (mapped a `select` → covered por `commit-select`).
- **Escape delega al popover**: la tecla cierra cerrando la layer
popover/dismissal; combobox no duplica el evento `close-dismiss` que ya
emite el popover.
- **`Combobox.Control` es composición Eidos-only**: agrupa Input + Trigger
+ Indicator dentro de un único chrome. No tiene contraparte en
morfo/soma porque es puramente layout visual.
- **`ItemIndicator` es Eidos-only**: el estado del item llega vía
`data-state` que el provider sopla; ItemIndicator sólo decide qué
glyph pintar en función de ese atributo.
- **Single vs multiple lo decide el consumer via prop `type`**: el morfo
expone `data-type` en Provider para que la receta pueda diferenciar.
- **Multi-select aria-multiselectable** se aplica condicionalmente
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

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

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

Loading…
Cancel
Save

Powered by TurnKey Linux.