Skip to content

Commit 19d0210

Browse files
refactor: replace GroupOptionList with OptionList in GroupSelect and related components
- Updated GroupSelect.vue to use OptionList for rendering options, improving consistency across components. - Modified MultiSelect, MultiCombobox, and SingleSelect to utilize rows instead of labels for options. - Enhanced OptionList to handle both flat and grouped options, collapsing group headers appropriately. - Adjusted tests for GroupSelect and GroupCombobox to reflect changes in option rendering and interaction. - Removed GroupOptionList component and updated related test utilities for clarity and maintainability.
1 parent 2a84089 commit 19d0210

15 files changed

Lines changed: 409 additions & 225 deletions

docs/packages/ui-inputs.md

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,52 @@ Models an **array of option ids**. Committing an option toggles its membership a
110110
</FormField>
111111
```
112112

113+
### GroupSelect & GroupCombobox
114+
115+
When options arrive **already partitioned** — active sprints above the backlog, tropical fruit above stone fruit — `GroupSelect` and `GroupCombobox` are `SingleSelect` / `Combobox` over **`groups`** instead of `options`. Each group renders a `role="group"` header; a single `v-model` selects across the whole set, and groups stay in **caller order** (there is no `alphabeticalSort` — the partition _is_ the order). `GroupCombobox` filters within groups and drops any group its filter empties. Try typing `a` in the searchable one:
116+
117+
<ClientOnly>
118+
<div class="ui-demo">
119+
<FormField id="demo-group-fruit" label="Fruit" #default="{controlId, describedby, invalid}">
120+
<GroupSelect :id="controlId" v-model="groupFruit" :groups="fruitGroups" label="name" :invalid="invalid" :describedby="describedby" />
121+
</FormField>
122+
<p class="ui-demo__state">Model value: <code>{{ groupFruit === null ? 'null' : JSON.stringify(groupFruit) }}</code></p>
123+
<FormField id="demo-group-fruit-search" label="Fruit (searchable)" #default="{controlId, describedby, invalid}">
124+
<GroupCombobox :id="controlId" v-model="groupFruitSearch" :groups="fruitGroups" label="name" :invalid="invalid" :describedby="describedby" />
125+
</FormField>
126+
<p class="ui-demo__state">Model value: <code>{{ groupFruitSearch === null ? 'null' : JSON.stringify(groupFruitSearch) }}</code></p>
127+
</div>
128+
</ClientOnly>
129+
130+
```vue
131+
<FormField id="fruit" label="Fruit" #default="{controlId, describedby, invalid}">
132+
<GroupSelect :id="controlId" v-model="fruit" :groups="fruitGroups" label="name" :invalid="invalid" :describedby="describedby" />
133+
</FormField>
134+
```
135+
136+
The `groups` prop replaces `options` — each group carries its own `options`, a header `text`, and an optional `header` flag:
137+
138+
```ts
139+
const fruitGroups = [
140+
{
141+
text: 'Tropical',
142+
options: [
143+
{id: 'mango', name: 'Mango'},
144+
{id: 'kiwi', name: 'Kiwi'},
145+
],
146+
},
147+
{
148+
text: 'Stone',
149+
options: [
150+
{id: 'apricot', name: 'Apricot'},
151+
{id: 'peach', name: 'Peach'},
152+
],
153+
},
154+
];
155+
```
156+
157+
Pass `header: false` on a group to render its options **flat** (a leading "ungrouped" run above the named groups); an empty group renders nothing, so a header never outlives its options.
158+
113159
### The checkbox family
114160

115161
`Checkbox`, `Switch`, `CheckboxGroup`, and `RadioGroup` sit on a **native input chassis** — a real `<input type="checkbox">` / `<input type="radio">` restyled through the same `--ui-*` contract, never a div-with-role — so keyboard and assistive-tech behaviour come from the platform. The radio group's arrow-key selection below is the **browser's own** roving focus; the component hand-rolls none of it.
@@ -199,6 +245,8 @@ Models an **array of option ids**. Committing an option toggles its membership a
199245
| `Combobox` | Accessible searchable/filtering single-select; exposes an imperative `focus()` handle |
200246
| `MultiSelect` | Accessible multi-value select — models an array of option ids; toggle-in-place listbox, inline chip bar with per-chip remove |
201247
| `MultiCombobox` | Accessible **searchable** multi-value select — MultiSelect's model + chips with Combobox's filtering input as the trigger |
248+
| `GroupSelect` | Accessible **grouped** single-select — `SingleSelect` over caller-ordered `groups` with `role="group"` headers; models `T['id'] \| null` |
249+
| `GroupCombobox` | Accessible **searchable grouped** single-select — `GroupSelect`'s listbox with `Combobox`'s filtering input; exposes `focus()` |
202250
| `Pressable` | A real `<button>` for a control that carries **no value** — replaces `<span @click>` / `<div @click>`; optional `aria-pressed` toggle mode |
203251
| `Disclosure` | Show/hide a region from a real button (`aria-expanded` + `aria-controls`), optionally wrapped in a real heading — replaces `<h2 @click>` |
204252

@@ -304,6 +352,22 @@ danger-toned by default (`--ui-clear-text`, chaining to `--ui-danger-text`).
304352
/>
305353
```
306354

355+
#### Grouped variants (`GroupSelect` / `GroupCombobox`)
356+
357+
`GroupSelect` and `GroupCombobox` are the grouped single-selects. They share the family's
358+
contract — `label`, `id`, `placeholder`, `disabled`, `required`, `invalid`, `describedby`,
359+
`emptyText`, `optionsLabel`, `mutedOptions`, `clearLabel`, the `#option` slot, and (on
360+
`GroupCombobox`) the imperative `focus()` handle — with **`options` replaced by `groups`** and
361+
no `alphabeticalSort` (the partition is the order):
362+
363+
```ts
364+
groups: {options: T[]; text: string; header?: boolean}[];
365+
```
366+
367+
- Groups render in caller order; a single `T['id'] | null` model selects across the flattened option set.
368+
- A named group renders `text` as a `role="group"` header labelling its options; `header: false` renders the group's options flat (with a boundary so they never fold into the preceding group) — a leading ungrouped run.
369+
- An empty group renders nothing — a header never outlives its options, including when `GroupCombobox`'s filter drains a group.
370+
307371
### The checkbox family
308372

309373
`Checkbox` and `Switch` share `id` (required), `label` (inline label text; the default slot overrides it for rich content), `disabled`, `required`, `invalid`, and `describedby`. Both model a **non-nullable `boolean`**. `Checkbox` adds `indeterminate` (visual prop → the element's DOM property). Native `required` is never set — `aria-required` is the conveyance, as everywhere in the family.
@@ -609,7 +673,7 @@ No file or range atoms; no date _picker_ (`DateInput` wraps the native control);
609673
<script setup lang="ts">
610674
import {computed, ref} from 'vue';
611675

612-
import {Checkbox, CheckboxGroup, Combobox, Disclosure, FormField, MultiCombobox, MultiSelect, Pressable, RadioGroup, SingleSelect, Switch, TextInput} from '../../packages/ui-inputs/src/index';
676+
import {Checkbox, CheckboxGroup, Combobox, Disclosure, FormField, GroupCombobox, GroupSelect, MultiCombobox, MultiSelect, Pressable, RadioGroup, SingleSelect, Switch, TextInput} from '../../packages/ui-inputs/src/index';
613677

614678
import '../../packages/ui-inputs/styles.css';
615679

@@ -653,6 +717,13 @@ const city = ref<string | null>(null);
653717
const toppingIds = ref<string[]>([]);
654718
const tagIds = ref<string[]>([]);
655719

720+
const fruitGroups = [
721+
{text: 'Tropical', options: [{id: 'mango', name: 'Mango'}, {id: 'kiwi', name: 'Kiwi'}, {id: 'papaya', name: 'Papaya'}]},
722+
{text: 'Stone', options: [{id: 'apricot', name: 'Apricot'}, {id: 'peach', name: 'Peach'}, {id: 'plum', name: 'Plum'}]},
723+
];
724+
const groupFruit = ref<string | null>(null);
725+
const groupFruitSearch = ref<string | null>(null);
726+
656727
const accepted = ref(false);
657728
const notifications = ref(true);
658729
const extraIds = ref<string[]>(['sprinkles']);
@@ -698,15 +769,25 @@ const themeToppingIds = ref<string[]>(['caramel', 'sprinkles']);
698769
package's single-class menu selectors (0,1,0), re-adding list markers, indent, and
699770
inter-item margins inside the demos — something no real consumer sees. Restore the
700771
package's own menu layout at winning specificity. Chips are spans; the only <ul>s
701-
in the demos are the listbox menus. */
772+
in the demos are the listbox menus (and the grouped listbox's nested role="group"
773+
sub-lists). */
702774
.vp-doc .ui-demo ul[role='listbox'],
703-
.vp-doc .demo-theme-panel ul[role='listbox'] {
775+
.vp-doc .ui-demo ul[role='group'],
776+
.vp-doc .demo-theme-panel ul[role='listbox'],
777+
.vp-doc .demo-theme-panel ul[role='group'] {
704778
list-style: none;
705779
margin: 0.25rem 0 0;
706780
padding: var(--ui-menu-pad);
707781
}
782+
.vp-doc .ui-demo ul[role='group'],
783+
.vp-doc .demo-theme-panel ul[role='group'] {
784+
margin: 0;
785+
padding: 0;
786+
}
708787
.vp-doc .ui-demo ul[role='listbox'] li + li,
709-
.vp-doc .demo-theme-panel ul[role='listbox'] li + li {
788+
.vp-doc .ui-demo ul[role='group'] li + li,
789+
.vp-doc .demo-theme-panel ul[role='listbox'] li + li,
790+
.vp-doc .demo-theme-panel ul[role='group'] li + li {
710791
margin-top: 0;
711792
}
712793

packages/ui-inputs/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import '@script-development/ui-inputs/style.css';
3535
| `Combobox` | Accessible **searchable/filtering** single-select — a text input that filters the listbox as you type; exposes an imperative `focus()` handle |
3636
| `MultiSelect` | Accessible **multi-value** select — models an array of option ids; toggle-in-place listbox that stays open on commit, inline chip bar with per-chip remove |
3737
| `MultiCombobox` | Accessible **searchable multi-value** select — MultiSelect's array model + chips with Combobox's filter-as-you-type input as the trigger |
38+
| `GroupSelect` | Accessible **grouped** single-select — `SingleSelect` over caller-ordered `groups` with `role="group"` headers; models `T['id'] \| null` |
39+
| `GroupCombobox` | Accessible **searchable grouped** single-select — `GroupSelect`'s grouped listbox with `Combobox`'s filter-as-you-type input; exposes `focus()` |
3840
| `Pressable` | A real `<button>` for an interactive control that carries **no value** — the keyboard-correct replacement for `<span @click>` / `<div @click>`; optional `aria-pressed` toggle mode |
3941
| `Disclosure` | Show/hide a region from a real `<button>` carrying `aria-expanded` + `aria-controls`; optionally wrapped in a real heading — the replacement for `<h2 @click>` |
4042

@@ -190,6 +192,48 @@ Like `Combobox` it exposes an imperative `focus()` handle. `clearLabel` / `empty
190192
deliberately do not transfer — an empty array is the multi "nothing", so there is no `null`
191193
to commit and no committed label to name.
192194

195+
### Grouped selects (`GroupSelect` / `GroupCombobox`)
196+
197+
`GroupSelect` and `GroupCombobox` are the single-selects for options that arrive **already
198+
partitioned** — active sprints above the backlog, tropical fruit above stone fruit. They
199+
mirror `SingleSelect` / `Combobox` closely (model `T['id'] | null`, the `#option` slot,
200+
`mutedOptions`, `clearLabel`, the announced empty state, the top-layer popup, and — on
201+
`GroupCombobox` — the imperative `focus()` handle) with one contract swap: **`options` becomes
202+
`groups`**.
203+
204+
```ts
205+
groups: {options: T[]; text: string; header?: boolean}[];
206+
```
207+
208+
Each group carries its own `options`, a header `text`, and an optional `header` flag. Groups
209+
render in **caller order** — there is no `alphabeticalSort`, because the partition _is_ the
210+
order — and the flat option index runs through them in sequence, so a single `v-model` selects
211+
across the whole set.
212+
213+
- A **named** group renders `text` as a `role="group"` header labelling its options for
214+
assistive tech.
215+
- `header: false` renders a **headerless** group: its options render flat, and a boundary keeps
216+
them from folding into the preceding group's `role="group"`. Use it for a leading "ungrouped"
217+
run above the named groups.
218+
- An **empty** group (no options) renders nothing — no dangling header. On `GroupCombobox` this
219+
also covers a group the filter drains to nothing: a header never outlives its options.
220+
221+
```vue
222+
<FormField id="fruit" label="Fruit" :error="errors.fruit" #default="{controlId, describedby, invalid}">
223+
<GroupSelect
224+
:id="controlId"
225+
v-model="fruit"
226+
:groups="[
227+
{text: 'Tropical', options: tropical},
228+
{text: 'Stone', options: stone},
229+
]"
230+
label="name"
231+
:invalid="invalid"
232+
:describedby="describedby"
233+
/>
234+
</FormField>
235+
```
236+
193237
### Checkbox family
194238

195239
`Checkbox`, `CheckboxGroup`, `Switch`, and `RadioGroup` all sit on a **native input chassis**

packages/ui-inputs/src/components/Combobox.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
<div v-if="open" ref="floating" popover="manual" class="ui-menu-anchor" :style="floatingStyles">
3939
<OptionList
4040
variant="ui-combobox"
41-
:labels="optionLabels"
41+
:rows="rows"
4242
:keys="optionKeys"
4343
:pointer="pointer"
4444
:listbox-id="listboxId"
@@ -77,6 +77,7 @@
7777
<script setup lang="ts" generic="T extends SelectItem">
7878
import {computed, ref, useTemplateRef, watch} from 'vue';
7979
80+
import type {GroupRow} from '../internal/group-rows';
8081
import type {LabelKey, SelectItem} from '../types';
8182
8283
import {useListbox} from '../composables/useListbox';
@@ -175,6 +176,9 @@ const filtered = computed(() => {
175176
// stays the single list every index (pointer, commit, aria) is keyed against.
176177
const optionLabels = computed(() => filtered.value.map(labelOf));
177178
const optionKeys = computed(() => filtered.value.map((option) => String(option.id)));
179+
// A flat control renders one headerless run — an all-option row sequence OptionList lays out
180+
// flat (no group wrappers), the same component the grouped controls feed a header/option mix.
181+
const rows = computed<GroupRow[]>(() => filtered.value.map((_, index) => ({type: 'option', index})));
178182
/** `aria-selected` marks the COMMITTED value — OptionList only asks about rendered indices. */
179183
const isSelected = (index: number): boolean => filtered.value[index].id === model.value;
180184
/** `.is-muted` marks visual de-emphasis only — a muted option stays committable. */

packages/ui-inputs/src/components/GroupCombobox.vue

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@
3636
positions the ANCHOR, not the <ul>: the size() middleware sizes it to the
3737
trigger, so the menu's `min-width: 100%` measures the trigger. -->
3838
<div v-if="open" ref="floating" popover="manual" class="ui-menu-anchor" :style="floatingStyles">
39-
<GroupOptionList
39+
<OptionList
4040
variant="ui-groupcombobox"
4141
:rows="filteredRows"
42+
:keys="optionKeys"
4243
:pointer="pointer"
4344
:listbox-id="listboxId"
4445
:option-id="optionId"
@@ -55,8 +56,8 @@
5556
@clear-hover="highlightClear"
5657
@clear-commit="commitClear"
5758
>
58-
<!-- Re-scope GroupOptionList's index into the typed per-option payload; the
59-
fallback (the plain labelOf text) keeps slotless consumers byte-identical. -->
59+
<!-- Re-scope OptionList's index into the typed per-option payload; the fallback
60+
(the plain labelOf text) keeps slotless consumers byte-identical. -->
6061
<template #option="{index}">
6162
<slot
6263
name="option"
@@ -68,7 +69,7 @@
6869
{{ labelOf(filteredOptions[index]) }}
6970
</slot>
7071
</template>
71-
</GroupOptionList>
72+
</OptionList>
7273
</div>
7374
</div>
7475
</template>
@@ -81,7 +82,7 @@ import type {LabelKey, SelectItem} from '../types';
8182
8283
import {useListbox} from '../composables/useListbox';
8384
import {ensureRefValueExists} from '../internal/reactivity';
84-
import GroupOptionList from './GroupOptionList.vue';
85+
import OptionList from './OptionList.vue';
8586
8687
const {
8788
groups,
@@ -167,6 +168,8 @@ const filteredData = computed(() => {
167168
// The flat list every index (pointer, commit, isSelected) is keyed against — derived from
168169
// filteredData so indices align with filteredRows.
169170
const filteredOptions = computed(() => filteredData.value.flatMap((g) => g.options));
171+
// Stable `v-for` keys for OptionList, indexed by the flat (filtered) option index `rows` navigates.
172+
const optionKeys = computed(() => filteredOptions.value.map((option) => String(option.id)));
170173
171174
// Build the mixed header/option row sequence from the filtered groups. Empty groups are already
172175
// excluded by filteredData, so we never emit a header row for a group with no visible options.
@@ -184,7 +187,7 @@ const filteredRows = computed(() => {
184187
return result;
185188
});
186189
187-
/** `aria-selected` marks the COMMITTED value — GroupOptionList only asks about rendered indices. */
190+
/** `aria-selected` marks the COMMITTED value — OptionList only asks about rendered indices. */
188191
const isSelected = (index: number): boolean => filteredOptions.value[index].id === model.value;
189192
/** `.is-muted` marks visual de-emphasis only — a muted option stays committable. */
190193
const isMuted = (index: number): boolean =>
@@ -196,7 +199,7 @@ const input = useTemplateRef<HTMLInputElement>('input');
196199
// The teleported `.ui-menu-anchor` (null while closed) — floating-ui's floating element.
197200
const floating = useTemplateRef<HTMLElement>('floating');
198201
199-
// Both keyboard (Enter via useListbox) and pointer (GroupOptionList `commit`) funnel through
202+
// Both keyboard (Enter via useListbox) and pointer (OptionList `commit`) funnel through
200203
// this one guard. Read through a local rather than indexing blind: the clamp watcher normally
201204
// keeps `pointer` in range, but a keypress landing between a filter change and the watcher
202205
// flush would otherwise index off the end.

0 commit comments

Comments
 (0)