From 714df0e5c9a88d9568f0c62fd6b6fcd34f451720 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 20 May 2026 23:41:45 +0200 Subject: [PATCH] combobox: PASS audit + extend focus-move list for listbox/menu nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/component-audit.ts | 22 ++++++- src/uix/eidos/components/combobox/README.md | 69 ++++++++++++++------- src/uix/langs/components/combobox.ts | 8 ++- src/uix/morfo/components/combobox.ts | 1 + 4 files changed, 74 insertions(+), 26 deletions(-) diff --git a/scripts/component-audit.ts b/scripts/component-audit.ts index dbd343d5b..72853666a 100644 --- a/scripts/component-audit.ts +++ b/scripts/component-audit.ts @@ -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; diff --git a/src/uix/eidos/components/combobox/README.md b/src/uix/eidos/components/combobox/README.md index 2117ca3f5..fe3eebc9c 100644 --- a/src/uix/eidos/components/combobox/README.md +++ b/src/uix/eidos/components/combobox/README.md @@ -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 ``. 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 diff --git a/src/uix/langs/components/combobox.ts b/src/uix/langs/components/combobox.ts index 6684c7afe..3ba9a319f 100644 --- a/src/uix/langs/components/combobox.ts +++ b/src/uix/langs/components/combobox.ts @@ -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; diff --git a/src/uix/morfo/components/combobox.ts b/src/uix/morfo/components/combobox.ts index cf8363da8..beecdeb63 100644 --- a/src/uix/morfo/components/combobox.ts +++ b/src/uix/morfo/components/combobox.ts @@ -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: [