diff --git a/docs/audit/theming/README.md b/docs/audit/theming/README.md index 7aa7647e6..566f0eef5 100644 --- a/docs/audit/theming/README.md +++ b/docs/audit/theming/README.md @@ -10,9 +10,9 @@ - **Medido**: 2026-08-20 · **162 recetas** con CSS + **8 componentes sin receta** = 170 fichas, el árbol entero de `eidos/components/` - **La pregunta**: ¿cuánto de la apariencia de cada componente puede cambiar un tema **sin tocar el sistema ni la receta**? -- **Alcance global**: **42%** — 2026 de 4857 knobs pasan por un token público del componente -- **Reparto**: público 2026 · privado 724 · global 1518 · literal 589 · sistema transversal 335 _(fuera del ratio)_ -- **Sin token público propio**: 52 · **alcance < 20 %**: 46 · **alcance 100 %**: 7 · **con eje `size`**: 74 +- **Alcance global**: **42%** — 2049 de 4856 knobs pasan por un token público del componente +- **Reparto**: público 2049 · privado 724 · global 1496 · literal 587 · sistema transversal 335 _(fuera del ratio)_ +- **Sin token público propio**: 51 · **alcance < 20 %**: 45 · **alcance 100 %**: 7 · **con eje `size`**: 74 ## Cómo se lee @@ -82,21 +82,21 @@ theming §6.7 lo pone delante (`hover-bg`). Las fichas proponen la forma | 10 | [time-picker](./time-picker.md) | 47 | 18% | 59 | 16 | | 11 | [color-picker](./color-picker.md) | 43 | 46% | 82 | 54 | | 12 | [field-langs](./field-langs.md) | 39 | 0% | 41 | 0 | -| 13 | [listbox](./listbox.md) | 35 | 0% | 38 | 0 | -| 14 | [field](./field.md) | 33 | 51% | 73 | 60 | -| 15 | [feed](./feed.md) | 32 | 0% | 33 | 0 | -| 16 | [carousel](./carousel.md) | 31 | 0% | 36 | 0 | -| 17 | [date-range-picker](./date-range-picker.md) | 30 | 40% | 53 | 22 | -| 18 | [natural-time-picker](./natural-time-picker.md) | 30 | 61% | 77 | 62 | -| 19 | [popover](./popover.md) | 28 | 42% | 51 | 36 | -| 20 | [radio-cards](./radio-cards.md) | 28 | 60% | 72 | 39 | +| 13 | [field](./field.md) | 33 | 51% | 73 | 60 | +| 14 | [feed](./feed.md) | 32 | 0% | 33 | 0 | +| 15 | [carousel](./carousel.md) | 31 | 0% | 36 | 0 | +| 16 | [date-range-picker](./date-range-picker.md) | 30 | 40% | 53 | 22 | +| 17 | [natural-time-picker](./natural-time-picker.md) | 30 | 61% | 77 | 62 | +| 18 | [popover](./popover.md) | 28 | 42% | 51 | 36 | +| 19 | [radio-cards](./radio-cards.md) | 28 | 60% | 72 | 39 | +| 20 | [drawer](./drawer.md) | 27 | 55% | 62 | 44 | -## Sin ningún token público propio (52) +## Sin ningún token público propio (51) Ni una clave pública en `lib/recipes/base.ts` — alguno tiene privados forward (`_palette-*`), que no son contrato: un tema no puede nombrarlos. -[`alert-dialog`](./alert-dialog.md) · [`announce`](./announce.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`card-group`](./card-group.md) · [`carousel`](./carousel.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`code-block`](./code-block.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`feed`](./feed.md) · [`field-langs`](./field-langs.md) · [`grid-list`](./grid-list.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`kbd`](./kbd.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`link-preview`](./link-preview.md) · [`listbox`](./listbox.md) · [`mark`](./mark.md) · [`mask-field`](./mask-field.md) · [`menu-dial`](./menu-dial.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`picker-shell`](./picker-shell.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`spinner`](./spinner.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-circular`](./text-circular.md) · [`text-focus`](./text-focus.md) · [`text-gradient`](./text-gradient.md) · [`text-scramble`](./text-scramble.md) · [`textarea`](./textarea.md) · [`tree-view`](./tree-view.md) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md) +[`alert-dialog`](./alert-dialog.md) · [`announce`](./announce.md) · [`aspect-ratio`](./aspect-ratio.md) · [`auto-grid`](./auto-grid.md) · [`avatar`](./avatar.md) · [`card-group`](./card-group.md) · [`carousel`](./carousel.md) · [`cascade`](./cascade.md) · [`clipboard`](./clipboard.md) · [`code`](./code.md) · [`code-block`](./code-block.md) · [`css-field`](./css-field.md) · [`date-picker`](./date-picker.md) · [`display`](./display.md) · [`feed`](./feed.md) · [`field-langs`](./field-langs.md) · [`grid-list`](./grid-list.md) · [`heading`](./heading.md) · [`highlight`](./highlight.md) · [`kbd`](./kbd.md) · [`knob`](./knob.md) · [`label`](./label.md) · [`link`](./link.md) · [`link-preview`](./link-preview.md) · [`mark`](./mark.md) · [`mask-field`](./mask-field.md) · [`menu-dial`](./menu-dial.md) · [`mockup`](./mockup.md) · [`month-grid`](./month-grid.md) · [`motion`](./motion.md) · [`number-field`](./number-field.md) · [`palabras`](./palabras.md) · [`picker-shell`](./picker-shell.md) · [`range-calendar`](./range-calendar.md) · [`scroll-frames`](./scroll-frames.md) · [`skeleton`](./skeleton.md) · [`skin-media-player`](./skin-media-player.md) · [`spinner`](./spinner.md) · [`stack`](./stack.md) · [`text`](./text.md) · [`text-blur`](./text-blur.md) · [`text-circular`](./text-circular.md) · [`text-focus`](./text-focus.md) · [`text-gradient`](./text-gradient.md) · [`text-scramble`](./text-scramble.md) · [`textarea`](./textarea.md) · [`tree-view`](./tree-view.md) · [`virtual-grid`](./virtual-grid.md) · [`virtual-list`](./virtual-list.md) · [`wrap`](./wrap.md) · [`year-grid`](./year-grid.md) ## Tabla completa (162 recetas, por alcance ascendente) @@ -108,7 +108,6 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component | [month-grid](./month-grid.md) | 0% | 61 | 0 | 13 | 40 | 5 | 3 | 0 | y | | [year-grid](./year-grid.md) | 0% | 61 | 0 | 13 | 40 | 5 | 3 | 0 | y | | [field-langs](./field-langs.md) | 0% | 41 | 0 | 0 | 31 | 8 | 2 | 0 | – | -| [listbox](./listbox.md) | 0% | 38 | 0 | 9 | 23 | 3 | 3 | 0 | – | | [carousel](./carousel.md) | 0% | 36 | 0 | 14 | 12 | 5 | 5 | 0 | y | | [feed](./feed.md) | 0% | 33 | 0 | 13 | 16 | 3 | 1 | 0 | y | | [grid-list](./grid-list.md) | 0% | 32 | 0 | 8 | 17 | 2 | 5 | 0 | y | @@ -215,6 +214,7 @@ La columna «contrato» cuenta las claves **públicas** del bloque del component | [image-adjustments](./image-adjustments.md) | 67% | 18 | 10 | 0 | 4 | 1 | 3 | 12 | – | | [text-gradient](./text-gradient.md) | 67% | 9 | 6 | 0 | 0 | 3 | 0 | 0 | – | | [separator](./separator.md) | 67% | 6 | 4 | 0 | 0 | 2 | 0 | 3 | – | +| [listbox](./listbox.md) | 68% | 37 | 23 | 9 | 1 | 1 | 3 | 21 | – | | [select](./select.md) | 69% | 72 | 45 | 14 | 3 | 3 | 7 | 101 | y | | [dialog](./dialog.md) | 70% | 33 | 23 | 5 | 4 | 1 | 0 | 37 | y | | [image-picker](./image-picker.md) | 71% | 20 | 12 | 0 | 1 | 4 | 3 | 13 | – | diff --git a/docs/audit/theming/listbox.md b/docs/audit/theming/listbox.md index 91a72cffc..6a9a940f2 100644 --- a/docs/audit/theming/listbox.md +++ b/docs/audit/theming/listbox.md @@ -5,62 +5,38 @@ > Vista de conjunto: [README](./README.md) · método y protocolo: > [`PLAN-theming.md`](../../process/PLAN-theming.md) §1, §2, §7. -- **Medido**: 2026-08-20 · **Alcance**: **0%** — 0 de 35 knobs por token público -- **Knobs de apariencia**: 38 — público 0 · privado 9 · global 23 · literal 3 · sistema 3 _(fuera del ratio)_ -- **Contrato hoy** (`lib/recipes/base.ts`): 0 pública(s) · 2 privada(s) forward — `_palette-solid`, `_palette-element` +- **Medido**: 2026-08-20 · **Alcance**: **68%** — 23 de 34 knobs por token público +- **Knobs de apariencia**: 37 — público 23 · privado 9 · global 1 · literal 1 · sistema 3 _(fuera del ratio)_ +- **Contrato hoy** (`lib/recipes/base.ts`): 21 pública(s) — `gap`, `padding`, `radius`, `border-width`, `border`, `bg`, `fg`, `font-family`, `line-height`, `max-block-size`, `invalid-border`, `item-radius`, `item-fg`, `disabled-item-fg`, `item-indicator-size`, `group-gap`, `group-label-padding-block`, `group-label-fg`, `group-label-font-size`, `group-label-font-weight`, `group-label-tracking` · 2 privada(s) forward — `_palette-solid`, `_palette-element` - **Eje `size`**: no · **ficheros**: `listbox.css` ## 1. Knobs fuera de alcance -### 1.1 Directo a primitivo global (23) +### 1.1 Directo a primitivo global (1) | # | fichero:línea | selector | propiedad | valor | | ---: | --- | --- | --- | --- | -| 1 | `listbox.css:36` | `[data-listbox]` | `gap` | `var(--space-1)` | -| 2 | `listbox.css:37` | `[data-listbox]` | `padding` | `var(--space-1)` | -| 3 | `listbox.css:38` | `[data-listbox]` | `border` | `var(--border-width) solid var(--color-border-default)` | -| 4 | `listbox.css:39` | `[data-listbox]` | `border-radius` | `var(--radius-md)` | -| 5 | `listbox.css:40` | `[data-listbox]` | `background` | `var(--color-surface-default)` | -| 6 | `listbox.css:41` | `[data-listbox]` | `color` | `var(--color-content-primary)` | -| 7 | `listbox.css:42` | `[data-listbox]` | `font-family` | `var(--style-label-font-family)` | -| 8 | `listbox.css:44` | `[data-listbox]` | `line-height` | `var(--leading-ui)` | -| 9 | `listbox.css:74` | `[data-listbox][data-variant='surface']` | `background` | `var(--color-surface-default)` | -| 10 | `listbox.css:75` | `[data-listbox][data-variant='surface']` | `border-color` | `var(--color-border-default)` | -| 11 | `listbox.css:80` | `[data-listbox][data-variant='outline']` | `border-color` | `var(--color-border-default)` | -| 12 | `listbox.css:98` | `[data-listbox][data-invalid]` | `border-color` | `var(--color-threat-element)` | -| 13 | `listbox.css:115` | `[data-listbox-item]` | `border-radius` | `var(--radius-sm)` | -| 14 | `listbox.css:116` | `[data-listbox-item]` | `color` | `var(--color-content-primary)` | -| 15 | `listbox.css:125` | `[data-listbox-item][data-highlighted]:not([data-disabled])` | `background` | `var(--color-surface-overlay)` | -| 16 | `listbox.css:130` | `[data-listbox-item][data-selected]:not([data-disabled])` | `color` | `var(--color-content-primary)` | -| 17 | `listbox.css:138` | `[data-listbox-item][data-disabled]` | `color` | `var(--color-content-muted)` | -| 18 | `listbox.css:168` | `[data-listbox-group]` | `gap` | `var(--space-1)` | -| 19 | `listbox.css:172` | `[data-listbox-group-label]` | `padding-block` | `var(--space-1)` | -| 20 | `listbox.css:174` | `[data-listbox-group-label]` | `color` | `var(--color-content-muted)` | -| 21 | `listbox.css:175` | `[data-listbox-group-label]` | `font-size` | `var(--font-size-xs)` | -| 22 | `listbox.css:176` | `[data-listbox-group-label]` | `font-weight` | `var(--font-weight-medium)` | -| 23 | `listbox.css:178` | `[data-listbox-group-label]` | `letter-spacing` | `var(--tracking-caps)` | +| 1 | `listbox.css:131` | `[data-listbox-item][data-highlighted]:not([data-disabled])` | `background` | `var(--color-surface-overlay)` | ### 1.2 A través de un privado (9) | # | fichero:línea | selector | propiedad | valor | | ---: | --- | --- | --- | --- | -| 1 | `listbox.css:43` | `[data-listbox]` | `font-size` | `var(--_listbox-item-font-size)` | -| 2 | `listbox.css:111` | `[data-listbox-item]` | `gap` | `var(--_listbox-item-gap)` | -| 3 | `listbox.css:112` | `[data-listbox-item]` | `min-block-size` | `var(--_listbox-item-height)` | -| 4 | `listbox.css:113` | `[data-listbox-item]` | `padding-block` | `var(--_listbox-item-padding-block)` | -| 5 | `listbox.css:114` | `[data-listbox-item]` | `padding-inline` | `var(--_listbox-item-padding-inline)` | -| 6 | `listbox.css:129` | `[data-listbox-item][data-selected]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` | -| 7 | `listbox.css:134` | `[data-listbox-item][data-selected][data-highlighted]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` | -| 8 | `listbox.css:155` | `[data-listbox-item-indicator]` | `color` | `var(--_listbox-palette-solid)` | -| 9 | `listbox.css:173` | `[data-listbox-group-label]` | `padding-inline` | `var(--_listbox-item-padding-inline)` | - -### 1.3 Literales (3) +| 1 | `listbox.css:49` | `[data-listbox]` | `font-size` | `var(--_listbox-item-font-size)` | +| 2 | `listbox.css:117` | `[data-listbox-item]` | `gap` | `var(--_listbox-item-gap)` | +| 3 | `listbox.css:118` | `[data-listbox-item]` | `min-block-size` | `var(--_listbox-item-height)` | +| 4 | `listbox.css:119` | `[data-listbox-item]` | `padding-block` | `var(--_listbox-item-padding-block)` | +| 5 | `listbox.css:120` | `[data-listbox-item]` | `padding-inline` | `var(--_listbox-item-padding-inline)` | +| 6 | `listbox.css:139` | `[data-listbox-item][data-selected]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` | +| 7 | `listbox.css:143` | `[data-listbox-item][data-selected][data-highlighted]:not([data-disabled])` | `background` | `var(--_listbox-palette-element)` | +| 8 | `listbox.css:164` | `[data-listbox-item-indicator]` | `color` | `var(--_listbox-palette-solid)` | +| 9 | `listbox.css:182` | `[data-listbox-group-label]` | `padding-inline` | `var(--_listbox-item-padding-inline)` | + +### 1.3 Literales (1) | # | fichero:línea | selector | propiedad | valor | | ---: | --- | --- | --- | --- | -| 1 | `listbox.css:59` | `[data-listbox][data-block]` | `inline-size` | `100%` | -| 2 | `listbox.css:152` | `[data-listbox-item-indicator]` | `inline-size` | `0.875rem` | -| 3 | `listbox.css:153` | `[data-listbox-item-indicator]` | `block-size` | `0.875rem` | +| 1 | `listbox.css:65` | `[data-listbox][data-block]` | `inline-size` | `100%` | ## 2. Sistema transversal (3) — informativo, fuera del ratio @@ -68,9 +44,9 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2). | # | fichero:línea | selector | propiedad | valor | | ---: | --- | --- | --- | --- | -| 1 | `listbox.css:93` | `[data-listbox][data-disabled]` | `opacity` | `var(--opacity-disabled)` | -| 2 | `listbox.css:103` | `[data-listbox]:focus-visible, [data-listbox][data-focused]` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` | -| 3 | `listbox.css:140` | `[data-listbox-item][data-disabled]` | `opacity` | `var(--opacity-disabled)` | +| 1 | `listbox.css:99` | `[data-listbox][data-disabled]` | `opacity` | `var(--opacity-disabled)` | +| 2 | `listbox.css:109` | `[data-listbox]:focus-visible, [data-listbox][data-focused]` | `outline` | `var(--focus-ring-width) solid var(--focus-ring-color)` | +| 3 | `listbox.css:149` | `[data-listbox-item][data-disabled]` | `opacity` | `var(--opacity-disabled)` | ## 3. Privados de la receta — ¿de dónde sale su valor? @@ -81,7 +57,7 @@ Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2). | `--_listbox-item-padding-block` | 1 | `var(--list-item-padding-block)` | global | no | | `--_listbox-item-gap` | 1 | `var(--list-item-gap)` | global | no | | `--_listbox-item-font-size` | 1 | `var(--list-font-size)` | global | no | -| `--_listbox-max-height` | 1 | `min(60vh, 22rem)` | literal | no | +| `--_listbox-max-height` | 1 | `var(--listbox-max-block-size)` | public | **sí** | Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inline del wrapper): `--_listbox-palette-element`, `--_listbox-palette-solid`. @@ -90,7 +66,7 @@ Consumidos y **no declarados en el CSS** (vienen de `base.ts` o de un estilo inl - **Consume la capa compartida `list-surface`.** Un eje que la capa posee se consume como `var(--_x, var(--x))`; el consumidor **no acuña** `--listbox-{eje}` para él — sería un vocabulario paralelo (README de `eidos/components`, «Capas compartidas» regla 2). - **Consume la capa compartida `menu-indicator`.** Un eje que la capa posee se consume como `var(--_x, var(--x))`; el consumidor **no acuña** `--listbox-{eje}` para él — sería un vocabulario paralelo (README de `eidos/components`, «Capas compartidas» regla 2). -### 4.1 Tokens a declarar en `lib/recipes/base.ts` (29) +### 4.1 Tokens a declarar en `lib/recipes/base.ts` (7) Valor **verbatim** del CSS de hoy: el default no se mueve, sólo cambia quién puede moverlo. Nombres derivados de recipe-contract §1 (ejes lógicos, talla @@ -100,40 +76,16 @@ no distingue lo que debería — se marca `⚠`. | token (`--listbox-…`) | scope TSC | valor propuesto | usos | | --- | --- | --- | ---: | -| `gap` | `root` | `var(--space-1)` | 1 | -| `radius` | `root` | `var(--radius-md)` | 1 | -| `bg` | `root` | `var(--color-surface-default)` | 1 | -| `fg` | `root` | `var(--color-content-primary)` | 1 | -| `font-family` | `root` | `var(--style-label-font-family)` | 1 | | `font-size` | `root` | `var(--list-font-size)` | 1 | -| `line-height` | `root` | `var(--leading-ui)` | 1 | -| `surface-bg` | `root` | `var(--color-surface-default)` | 1 | -| `surface-border` | `root` | `var(--color-border-default)` | 1 | -| `outline-border` | `root` | `var(--color-border-default)` | 1 | -| `invalid-border` | `root` | `var(--color-threat-element)` | 1 | | `item-gap` | `root` | `var(--list-item-gap)` | 1 | | `item-height` | `root` | `var(--list-item-height)` | 1 | | `item-padding-block` | `root` | `var(--list-item-padding-block)` | 1 | | `item-padding-inline` | `root` | `var(--list-item-padding-inline)` | 1 | -| `item-radius` | `root` | `var(--radius-sm)` | 1 | -| `item-fg` | `root` | `var(--color-content-primary)` | 1 | | `item-bg` | `root` | `var(--color-surface-overlay)` | 1 | -| `selected-item-fg` | `root` | `var(--color-content-primary)` | 1 | -| `disabled-item-fg` | `root` | `var(--color-content-muted)` | 1 | -| `item-indicator-width` | `root` | `0.875rem` | 1 | -| `item-indicator-height` | `root` | `0.875rem` | 1 | -| `group-gap` | `root` | `var(--space-1)` | 1 | -| `group-label-padding-block` | `root` | `var(--space-1)` | 1 | | `group-label-padding-inline` | `root` | `var(--list-item-padding-inline)` | 1 | -| `group-label-fg` | `root` | `var(--color-content-muted)` | 1 | -| `group-label-font-size` | `root` | `var(--font-size-xs)` | 1 | -| `group-label-font-weight` | `root` | `var(--font-weight-medium)` | 1 | -| `group-label-letter-spacing` | `root` | `var(--tracking-caps)` | 1 | -### 4.2 Sin nombre mecánico (6) +### 4.2 Sin nombre mecánico (4) -- **⚠ decisión: `padding` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `padding`. -- **⚠ decisión: `border` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)** — 1: `border`. - **⚠ decisión: `100%` es un valor identidad o geometría de layout, no un knob de tema — el perímetro de «knob» es D-TH.2, sin firmar** — 1: `inline-size`. - **⚠ decisión: el privado que alimenta este knob no se declara en el CSS (viene de `base.ts` o de un estilo inline) — hay que resolverlo antes de nombrarlo** — 3: `background`, `color`. diff --git a/docs/next-features.md b/docs/next-features.md index ba1342f9c..cf2d817ae 100644 --- a/docs/next-features.md +++ b/docs/next-features.md @@ -478,6 +478,19 @@ existiera. Se anotan porque el eje entero se apoya en esas mediciones. mientras el resto del catálogo lleva la mezcla suave del sistema (`--focus-ring-color`, 52 %). Quedó sin tocar porque alinearlo mueve píxel — y de hecho quedó sin token, porque la regla entera está muerta bajo popover. +- **El censo PENALIZA consumir una capa compartida correctamente.** `listbox` + adopta los cinco ejes de ritmo que `list-surface` posee — que es exactamente + lo que la doctrina de capas manda — y el censo los cuenta como `private`, así + que su alcance queda en 68 % en vez de ~86 %. Es el mismo defecto de medición + que D-TH.2-b arregló para los primitivos tipográficos (`--style-*` pasó a + contar como `system`): la capa ES la superficie de tema de ese eje. Afecta a + los consumidores de `list-surface` (select · combobox · command · listbox · + los menús) y de `viewport-placement` (affix · fab · menu-dial). Extenderlo es + una decisión, no una corrección al paso. +- **`listbox` pinta el `highlighted` DOS veces**: la receta pone un + `--color-surface-overlay` plano y encima el arquetipo `item` añade su velo del + 8 % — medido. El plano es el duplicado que §38 deprecó; se dejó sin token para + no bendecirlo, pero retirarlo mueve píxel. - **La demo del bloque F2-A ya enseña los tokens** (`TokensPanel`), pero los ~155 componentes sin contrato siguen sin superficie donde verlos hasta que se tokenicen. diff --git a/src/uix/eidos/components/listbox/README.md b/src/uix/eidos/components/listbox/README.md index bcf4dd23f..908438fcc 100644 --- a/src/uix/eidos/components/listbox/README.md +++ b/src/uix/eidos/components/listbox/README.md @@ -40,22 +40,22 @@ dentro de Select). - Props headless: `value: string[]`/`onValueChange`, **`selectionMode`** (norma N1 — unión discriminada single/multiple), `loop`, `typeahead` + `typeaheadTimeout`, `orientation`, `dir`, `disabled/readonly/required/ - invalid`, `name` (form bridge), `aria-label(ledby)`. +invalid`, `name` (form bridge), `aria-label(ledby)`. - Props eidos: `size` (`xs`–`xl`, responsive), `variant` (`ControlVariant` del chrome contenedor), `color` (acento de selección — sistema completo: rol / intent / 33 escalas / valor CSS, THM-2). ## Comparativa -| Capacidad | UIX | React Aria | Ark UI | Radix/Bits | -| --- | --- | --- | --- | --- | -| Listbox standalone (sin overlay) | ✓ | ✓ (ListBox) | ✓ | No (solo en Select) | -| `selectionMode` single/multiple | ✓ (N1) | ✓ | ✓ | — | -| Typeahead con timeout | ✓ | ✓ | ✓ | — | -| Grupos con label | ✓ | ✓ (Section) | ✓ | — | -| Orientación + RTL | ✓ | ✓ | ✓ | — | -| Form bridge (`name`) | ✓ | Vía Form | ✓ | — | -| Eventos perceptivos select/unselect | ✓ (pack) | No | No | — | +| Capacidad | UIX | React Aria | Ark UI | Radix/Bits | +| ----------------------------------- | -------- | ----------- | ------ | ------------------- | +| Listbox standalone (sin overlay) | ✓ | ✓ (ListBox) | ✓ | No (solo en Select) | +| `selectionMode` single/multiple | ✓ (N1) | ✓ | ✓ | — | +| Typeahead con timeout | ✓ | ✓ | ✓ | — | +| Grupos con label | ✓ | ✓ (Section) | ✓ | — | +| Orientación + RTL | ✓ | ✓ | ✓ | — | +| Form bridge (`name`) | ✓ | Vía Form | ✓ | — | +| Eventos perceptivos select/unselect | ✓ (pack) | No | No | — | Referencias: [APG Listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) · [React Aria ListBox](https://react-spectrum.adobe.com/react-aria/ListBox.html) · @@ -79,14 +79,54 @@ Referencias: [APG Listbox](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) · - **`value` siempre `string[]`** (también en single): un solo tipo de dato para las dos cardinalidades; `selectionMode` gobierna el gesto, no el tipo. +## Talla y tema + +Contrato en `lib/recipes/base.ts > listbox` (21 claves). Alcance del censo: +**68 %**, y ese techo es DELIBERADO — no deuda. + +### Lo que este componente NO puede acuñar + +El ritmo de fila —altura, padding inline y block, gap y tamaño de letra— lo +posee `lib/list-surface.css`, por talla, para TODAS las superficies de lista +(select, combobox, command y los menús). La regla de las capas compartidas es +explícita: **un consumidor no acuña `--{componente}-{eje}` para un eje que la +capa ya posee** — sería un vocabulario paralelo, y además ganaría a la capa +para todas las demás superficies. + +Así que los cinco privados `--_listbox-item-*` **adoptan** la capa y se quedan +privados. Un tema mueve ese ritmo por `--list-item-*` / `--list-font-size`, que +es donde vive. El censo los cuenta como privados y baja el alcance: la métrica +penaliza hacer lo correcto, y eso está registrado en `next-features.md` §13. + +**Sin eje `size` propio**: nada estampa `data-size` para estos ejes; la talla +llega por el contexto de la capa. + +### Lo que sí es suyo + +La superficie que sostiene las filas (fondo, borde, radio, padding, gap, +tipografía, alto máximo), la esquina y la tinta de la fila, el tamaño del +glifo de selección, y el grupo con su etiqueta. + +### Dos cosas medidas que NO se tocaron + +- **El `highlighted` pinta DOS veces.** La receta pone + `background: var(--color-surface-overlay)` y encima el arquetipo `item` añade + su velo — medido: `bg` opaco + `background-image` con el gradiente del 8 %. + El plano es el duplicado que §38 deprecó, así que **no se le acuñó token**: + bendecir con un nombre público algo condenado a morir es el error que este + eje ya evitó en el codemod. Retirarlo mueve píxel ⇒ decisión aparte. +- **La tinta de la fila seleccionada era código muerto** y se retiró: el + arquetipo la fija a (0,4,0) contra los (0,3,0) de la receta. Mismo hallazgo + que en `table`, verificado igual y con 0 diffs al quitarlo. + ## Gaps -| Gap | Disposición | Detalle | -| --- | --- | --- | -| `data-readonly` declarado sin estilo (R-1.3) | **implementar** | Censo de familia de selección: tratamiento muted + cursor default cuando aterrice el pase readonly. | -| Selección por arrastre (drag-select de rango, estilo file manager) | **diferir** | Sin caso real; multiple + Shift/Ctrl cubre el uso actual. | -| Virtualización de listas largas | **descartar** | Pertenece a `VirtualList`/`s-text-virtual-list` — componer, no absorber. | -| Tests del wrapper eidos (soma con suite ✓) | **diferir** | Pasada SYS-2. | +| Gap | Disposición | Detalle | +| ------------------------------------------------------------------ | --------------- | --------------------------------------------------------------------------------------------------- | +| `data-readonly` declarado sin estilo (R-1.3) | **implementar** | Censo de familia de selección: tratamiento muted + cursor default cuando aterrice el pase readonly. | +| Selección por arrastre (drag-select de rango, estilo file manager) | **diferir** | Sin caso real; multiple + Shift/Ctrl cubre el uso actual. | +| Virtualización de listas largas | **descartar** | Pertenece a `VirtualList`/`s-text-virtual-list` — componer, no absorber. | +| Tests del wrapper eidos (soma con suite ✓) | **diferir** | Pasada SYS-2. | ## Referencias diff --git a/src/uix/eidos/components/listbox/listbox.css b/src/uix/eidos/components/listbox/listbox.css index 07a4cc0cd..bfe07a722 100644 --- a/src/uix/eidos/components/listbox/listbox.css +++ b/src/uix/eidos/components/listbox/listbox.css @@ -24,24 +24,30 @@ * block sets `--list-*`; the private knobs below just adopt them. This aligns * md to the list rhythm (font sm→md, px space-3→space-2) — shared with the * menu surfaces. */ + /* These five ADOPT the shared layer and are deliberately NOT public tokens + * of this component: `lib/list-surface.css` owns the row rhythm, per size, + * for every list surface (select, combobox, command, the menus). Minting + * `--listbox-item-height` here would be the parallel vocabulary the layer's + * own rules forbid, and it would outrank the layer for all of them. A theme + * moves this rhythm through `--list-*`. */ --_listbox-item-height: var(--list-item-height); --_listbox-item-padding-inline: var(--list-item-padding-inline); --_listbox-item-padding-block: var(--list-item-padding-block); --_listbox-item-gap: var(--list-item-gap); --_listbox-item-font-size: var(--list-font-size); - --_listbox-max-height: min(60vh, 22rem); + --_listbox-max-height: var(--listbox-max-block-size); display: flex; flex-direction: column; - gap: var(--space-1); - padding: var(--space-1); - border: var(--border-width) solid var(--color-border-default); - border-radius: var(--radius-md); - background: var(--color-surface-default); - color: var(--color-content-primary); - font-family: var(--style-label-font-family); + gap: var(--listbox-gap); + padding: var(--listbox-padding); + border: var(--listbox-border-width) solid var(--listbox-border); + border-radius: var(--listbox-radius); + background: var(--listbox-bg); + color: var(--listbox-fg); + font-family: var(--listbox-font-family); font-size: var(--_listbox-item-font-size); - line-height: var(--leading-ui); + line-height: var(--listbox-line-height); outline: none; /* * Vertical scroll for tall lists. Default cap is min(60vh, 22rem) — @@ -71,13 +77,13 @@ /* ── Variant — surface (default) ─ */ [data-listbox][data-variant='surface'] { - background: var(--color-surface-default); - border-color: var(--color-border-default); + background: var(--listbox-bg); + border-color: var(--listbox-border); } [data-listbox][data-variant='outline'] { background: transparent; - border-color: var(--color-border-default); + border-color: var(--listbox-border); } [data-listbox][data-variant='ghost'] { @@ -95,7 +101,7 @@ } [data-listbox][data-invalid] { - border-color: var(--color-threat-element); + border-color: var(--listbox-invalid-border); } [data-listbox]:focus-visible, @@ -112,8 +118,8 @@ min-block-size: var(--_listbox-item-height); padding-block: var(--_listbox-item-padding-block); padding-inline: var(--_listbox-item-padding-inline); - border-radius: var(--radius-sm); - color: var(--color-content-primary); + border-radius: var(--listbox-item-radius); + color: var(--listbox-item-fg); cursor: pointer; user-select: none; transition: @@ -125,9 +131,12 @@ background: var(--color-surface-overlay); } +/* No `color` here: the item's `archetype: 'item'` rule in `archetypes.css` sets + the selected ink at (0,4,0) and beats this (0,3,0), so the declaration was + dead — the same shape `table` shed on 2026-08-20. The selected row's ink + belongs to the archetype. */ [data-listbox-item][data-selected]:not([data-disabled]) { background: var(--_listbox-palette-element); - color: var(--color-content-primary); } [data-listbox-item][data-selected][data-highlighted]:not([data-disabled]) { @@ -135,7 +144,7 @@ } [data-listbox-item][data-disabled] { - color: var(--color-content-muted); + color: var(--listbox-disabled-item-fg); cursor: default; opacity: var(--opacity-disabled); } @@ -149,8 +158,8 @@ */ [data-listbox-item-indicator] { display: none; - inline-size: 0.875rem; - block-size: 0.875rem; + inline-size: var(--listbox-item-indicator-size); + block-size: var(--listbox-item-indicator-size); flex: 0 0 auto; color: var(--_listbox-palette-solid); } @@ -165,15 +174,15 @@ [data-listbox-group] { display: flex; flex-direction: column; - gap: var(--space-1); + gap: var(--listbox-group-gap); } [data-listbox-group-label] { - padding-block: var(--space-1); + padding-block: var(--listbox-group-label-padding-block); padding-inline: var(--_listbox-item-padding-inline); - color: var(--color-content-muted); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-medium); + color: var(--listbox-group-label-fg); + font-size: var(--listbox-group-label-font-size); + font-weight: var(--listbox-group-label-font-weight); text-transform: uppercase; - letter-spacing: var(--tracking-caps); + letter-spacing: var(--listbox-group-label-tracking); } diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index a52b440e5..91cad258e 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -1948,6 +1948,27 @@ --gradient-picker-selected-preset-border: var(--color-primary-border); --gradient-picker-selected-preset-outline-offset: 2px; --gradient-picker-actions-gap: var(--space-2); + --listbox-gap: var(--space-1); + --listbox-padding: var(--space-1); + --listbox-radius: var(--radius-md); + --listbox-border-width: var(--border-width); + --listbox-border: var(--color-border-default); + --listbox-bg: var(--color-surface-default); + --listbox-fg: var(--color-content-primary); + --listbox-font-family: var(--style-label-font-family); + --listbox-line-height: var(--leading-ui); + --listbox-max-block-size: min(60vh, 22rem); + --listbox-invalid-border: var(--color-threat-element); + --listbox-item-radius: var(--radius-sm); + --listbox-item-fg: var(--color-content-primary); + --listbox-disabled-item-fg: var(--color-content-muted); + --listbox-item-indicator-size: 0.875rem; + --listbox-group-gap: var(--space-1); + --listbox-group-label-padding-block: var(--space-1); + --listbox-group-label-fg: var(--color-content-muted); + --listbox-group-label-font-size: var(--size-xs-font-size); + --listbox-group-label-font-weight: var(--font-weight-medium); + --listbox-group-label-tracking: var(--tracking-caps); --proof-of-human-min-height-sm: calc(var(--size-sm-control-height) * 3); --proof-of-human-min-height-md: calc(var(--size-md-control-height) * 3); --proof-of-human-min-height-lg: calc(var(--size-lg-control-height) * 3); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index c54da18db..635dcdd98 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -2599,7 +2599,47 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({ '_palette-solid': { value: 'var(--color-primary-solid)', scope: 'host' }, '_palette-element': { value: 'var(--color-primary-element)', scope: 'host' } }, + // Listbox. Consumes TWO shared layers, and the rule of those layers decides + // most of this entry: a consumer does NOT mint `--{c}-{axis}` for an axis the + // layer already owns. So the row rhythm (height, inline/block padding, gap + // and font-size) is NOT here — `lib/list-surface.css` owns it through + // `--list-item-*` / `--list-font-size`, per size, and the recipe adopts it + // through its private slots. Minting `--listbox-item-height` would be the + // parallel vocabulary that doctrine forbids, and it would silently outrank + // the layer for every surface that shares the rhythm (select, combobox, + // command and the menus). listbox: { + // The surface the rows sit on - this IS the listbox's own. + gap: 'var(--space-1)', + padding: 'var(--space-1)', + radius: 'var(--radius-md)', + 'border-width': 'var(--border-width)', + border: 'var(--color-border-default)', + bg: 'var(--color-surface-default)', + fg: 'var(--color-content-primary)', + 'font-family': 'var(--style-label-font-family)', + 'line-height': 'var(--leading-ui)', + // Generous but bounded; the consumer overrides it inline for a taller list. + 'max-block-size': 'min(60vh, 22rem)', + 'invalid-border': 'var(--color-threat-element)', + + // Rows. The rhythm comes from the layer; what is left is the row's own + // corner and ink. + 'item-radius': 'var(--radius-sm)', + 'item-fg': 'var(--color-content-primary)', + 'disabled-item-fg': 'var(--color-content-muted)', + + // The check glyph. Its COLOUR is the palette forward, not a knob. + 'item-indicator-size': '0.875rem', + + // Groups. + 'group-gap': 'var(--space-1)', + 'group-label-padding-block': 'var(--space-1)', + 'group-label-fg': 'var(--color-content-muted)', + 'group-label-font-size': 'var(--size-xs-font-size)', + 'group-label-font-weight': 'var(--font-weight-medium)', + 'group-label-tracking': 'var(--tracking-caps)', + '_palette-solid': { value: 'var(--color-primary-solid)', scope: 'host' }, '_palette-element': { value: 'var(--color-primary-element)', scope: 'host' } }, diff --git a/web/routes/uix/components/listbox/+page.svelte b/web/routes/uix/components/listbox/+page.svelte index 0e073f9a8..d506643f1 100644 --- a/web/routes/uix/components/listbox/+page.svelte +++ b/web/routes/uix/components/listbox/+page.svelte @@ -9,10 +9,11 @@ import { compileMorfo } from '$uix/morfo'; import { listboxMorfo } from '@/uix/morfo/components/listbox'; import { getActiveUix } from '$active-uix'; + import TokensPanel from '../../lib/TokensPanel.svelte'; const uix = getActiveUix(); - type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y'; + type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'tokens' | 'a11y'; let tab = $state('live'); // ── Live state ──────────────────────────────────────────────────────── @@ -133,15 +134,13 @@
Forms · Listbox

Listbox

- Embedded selection list (role="listbox") with single - or multiple selection, roving-tabindex keyboard nav, alphanumeric - typeahead, ARIA-grouped sections, and form participation. Soma - OR-merges disabled / readonly / + Embedded selection list (role="listbox") with single or multiple selection, + roving-tabindex keyboard nav, alphanumeric typeahead, ARIA-grouped sections, and form + participation. Soma OR-merges disabled / readonly / required / invalid with - Field.Provider when nested. For atomic options - only — use GridList when rows contain interactive - children, or Select / Combobox when - the surface is a closed combobox-style picker. + Field.Provider when nested. For atomic options only — use GridList + when rows contain interactive children, or Select / Combobox when the + surface is a closed combobox-style picker.

@@ -158,13 +157,18 @@ listbox ↗ - selected {value.join(', ') || '—'} + selected + {value.join(', ') || '—'}
-
+
focus the listbox + arrow / type to highlight {:else} {#each trace.slice(0, 3) as entry} - {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} + {entry.event} · {entry.family}{entry.intent + ? ' · ' + entry.intent + : ''} {fmtTime(entry.at)} {/each} {/if} @@ -249,7 +257,12 @@ sema {events.length} - + +
@@ -269,7 +282,11 @@ selectionMode {#each ['single', 'multiple'] as m (m)} - + {/each} @@ -277,7 +294,11 @@ orientation {#each ['vertical', 'horizontal'] as o (o)} - + {/each} @@ -316,10 +337,16 @@
@@ -327,7 +354,11 @@ size {#each ['xs', 'sm', 'md', 'lg', 'xl'] as s (s)} - + {/each} @@ -335,7 +366,11 @@ color {#each ['primary', 'secondary', 'neutral', 'affirm', 'fulfill', 'risk', 'threat', 'loss'] as c (c)} - + {/each} @@ -378,17 +413,62 @@ - - - - - - - - - - - + + + + + + + + + + +
PropTypeDefaultDescription
value bindablestring[][]Current selection. Always string[]; single mode keeps at most one entry.
selectionMode'single' | 'multiple''single'Selection behavior.
loopbooleantrueWrap arrow nav at edges.
typeaheadbooleantrueAlphanumeric jump-to-item.
orientation'vertical' | 'horizontal''vertical'Arrow nav axis.
disabled · readonly · required · invalidbooleanfalseOR-merged with Field.Provider when nested.
namestring—Form name → hidden inputs per selected value.
variant eidosControlVariant: 'surface' | 'outline' | 'ghost''surface'Container chrome.
size eidos'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Item height + font-size.
color eidosColorRole'primary'Selected-item highlight + focus ring accent.
block eidosbooleanfalseFill container's inline-size.
value bindablestring[][]Current selection. Always string[]; single mode keeps at most one entry.
selectionMode'single' | 'multiple''single'Selection behavior.
loopbooleantrueWrap arrow nav at edges.
typeaheadbooleantrueAlphanumeric jump-to-item.
orientation'vertical' | 'horizontal''vertical'Arrow nav axis.
disabled · readonly · required · invalidbooleanfalseOR-merged with Field.Provider when nested.
namestring—Form name → hidden inputs per selected value.
variant eidosControlVariant: 'surface' | 'outline' | 'ghost''surface'Container chrome.
size eidos'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Item height + font-size.
color eidosColorRole'primary'Selected-item highlight + focus ring accent.
block eidosbooleanfalseFill container's inline-size.
@@ -398,18 +478,25 @@ - - + +
PropTypeDefaultDescription
valuestringrequiredUnique identifier in the listbox.
disabledbooleanfalseSkip the item from keyboard nav + click selection.
valuestringrequiredUnique identifier in the listbox.
disabledbooleanfalseSkip the item from keyboard nav + click selection.
ItemIndicator · Group · GroupLabel

- ItemIndicator renders a ✓ glyph inside selected items - (same recipe as menu-indicator partial). Group wraps a - logical section; GroupLabel is the muted heading - (ARIA-linked via aria-labelledby). + ItemIndicator renders a ✓ glyph inside selected items (same recipe as + menu-indicator partial). Group wraps a logical section; GroupLabel + is the muted heading (ARIA-linked via aria-labelledby).

{/if} @@ -428,9 +515,21 @@ name"{listboxMorfo.name}" kebab"{listboxMorfo.kebab}" - scope[{listboxMorfo.scope.map((s) => `"${s}"`).join(', ')}] - apg{listboxMorfo.apg} - parts.length{listboxMorfo.parts.length} + scope[{listboxMorfo.scope.map((s) => `"${s}"`).join(', ')}] + apg{listboxMorfo.apg} + parts.length{listboxMorfo.parts.length} events.length{events.length} @@ -439,7 +538,13 @@
Parts
- + {#each partsList as part (part.kebab)} @@ -459,12 +564,23 @@
Events declaration
PartMarkerElementRoleArchetypeStatesOptional
PartMarkerElementRoleArchetypeStatesOptional
- + {#each events as action (action.name)} {@const sem = action.semantic} {@const intentDecl = 'intent' in sem ? sem.intent : undefined} - {@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` : '—'} + {@const intentStr = + typeof intentDecl === 'string' + ? intentDecl + : intentDecl + ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'}` + : '—'} @@ -487,14 +603,19 @@ · events

- Two events: commit-select (intent affirm) - when an item enters the selection; commit-unselect + Two events: commit-select (intent affirm) when an item enters the + selection; commit-unselect (intent neutral) when it leaves.

namefamilyverbsequenceintenttarget
namefamilyverbsequenceintenttarget
{action.name} {sem.family}
- + {#each events as action (action.name)} {@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined} @@ -509,7 +630,8 @@ + }}>▶ play {/each} @@ -531,37 +653,80 @@ {#if tab === 'recipe'}

Eidos recipe

-

Selectors at src/uix/eidos/components/listbox/listbox.css.

+

+ Selectors at src/uix/eidos/components/listbox/listbox.css. +

NameFamilyVerbSequenceIntentPlay
NameFamilyVerbSequenceIntentPlay
- - - - - - - - - + + + + + + + + +
SelectorSourceWhat it paints
[data-listbox]eidosContainer — flex column, gap, border, padding.
[data-listbox][data-size]eidosItem height + font-size scale.
[data-listbox][data-variant='outline' | 'ghost']eidosContainer chrome (border + bg).
[data-listbox][data-color]eidosAccent palette for selected highlight + focus ring.
[data-listbox][data-invalid]morfoThreat-coloured border override.
[data-listbox-item]morfoOption row — flex, padding, hover/highlight states.
[data-listbox-item][data-selected]morfoSelected-state band (accent-soft fill).
[data-listbox-item-indicator]morfoHidden by default; revealed inside selected items.
[data-listbox-group-label]morfoMuted uppercase section heading.
[data-listbox]eidosContainer — flex column, gap, border, padding.
[data-listbox][data-size]eidosItem height + font-size scale.
[data-listbox][data-variant='outline' | 'ghost']eidosContainer chrome (border + bg).
[data-listbox][data-color]eidosAccent palette for selected highlight + focus ring.
[data-listbox][data-invalid]morfoThreat-coloured border override.
[data-listbox-item]morfoOption row — flex, padding, hover/highlight states.
[data-listbox-item][data-selected]morfoSelected-state band (accent-soft fill).
[data-listbox-item-indicator]morfoHidden by default; revealed inside selected items.
[data-listbox-group-label]morfoMuted uppercase section heading.
{/if} + {#if tab === 'tokens'} + + {/if} + {#if tab === 'a11y'}

Accessibility

- Follows the APG listbox pattern. - Container is role="listbox"; each Item is - role="option" with aria-selected; - Group is role="group" with + Follows the APG listbox pattern. Container is role="listbox"; each Item is + role="option" with aria-selected; Group is + role="group" + with aria-labelledby pointing to GroupLabel. - aria-multiselectable is stamped automatically - in multiple mode. + aria-multiselectable is stamped automatically in multiple mode.

Keyboard
@@ -569,12 +734,34 @@ - - - - - - + + + + + +
KeyAction
ArrowDown / ArrowUpMove highlight ±1 (vertical orientation).
ArrowRight / ArrowLeftSame but for horizontal orientation; RTL inverts.
Home / EndFirst / last item.
Enter / SpaceToggle selection of highlighted item.
Ctrl/Cmd+ASelect all (multiple mode only).
Letter / digitTypeahead — focus next item starting with that character.
ArrowDown / ArrowUpMove highlight ±1 (vertical orientation).
ArrowRight / ArrowLeftSame but for horizontal orientation; RTL inverts.
Home / EndFirst / last item.
Enter / SpaceToggle selection of highlighted item.
Ctrl/Cmd+ASelect all (multiple mode only).
Letter / digitTypeahead — focus next item starting with that character.
@@ -585,14 +772,37 @@ PartAttributeValue listboxrole"listbox" - listboxaria-multiselectable"true" (multiple mode) - listboxaria-orientation"horizontal" | "vertical" - listboxaria-disabled / aria-readonly / aria-required / aria-invalidflags + listboxaria-multiselectable"true" (multiple mode) + listboxaria-orientation"horizontal" | "vertical" + listboxaria-disabled / aria-readonly / aria-required / aria-invalidflags itemrole"option" - itemaria-selected"true" | "false" - itemaria-disabled"true" (disabled) + itemaria-selected"true" | "false" + itemaria-disabled"true" (disabled) grouprole"group" - grouparia-labelledby{``} + grouparia-labelledby{``}