Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
be262f1
feat(ui-inputs): add GroupSelect and GroupCombobox atoms (v0.13.0)
Elenyan-Script Aug 28, 2026
0c37abe
test(ui-inputs): reach 100% coverage on GroupSelect and GroupCombobox
Elenyan-Script Aug 28, 2026
67187cb
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Aug 28, 2026
a5a6ca9
fix(ui-inputs): add CSS for GroupSelect/GroupCombobox and fix group h…
Elenyan-Script Aug 28, 2026
f7fc87c
fix(ui-inputs): address review — CSS descendant selector and axe-core…
Elenyan-Script Aug 28, 2026
bcd4c12
style(ui-inputs): oxfmt — expand FRUIT_GROUPS object literal
Elenyan-Script Aug 28, 2026
2fd8c42
fix(ui-inputs): move role="group"+aria-label from <li> to inner <ul> …
Elenyan-Script Aug 28, 2026
a7c4804
fix(ui-inputs): render headerless group after a named group flat
Elenyan-Script Sep 1, 2026
2a84089
refactor(ui-inputs): extract shared ListboxOption; stop GroupOptionLi…
Elenyan-Script Sep 1, 2026
19d0210
refactor: replace GroupOptionList with OptionList in GroupSelect and …
Elenyan-Script Sep 1, 2026
99b69d1
refactor(ui-inputs): extract buildGroupRows into group-rows.ts
Elenyan-Script Sep 1, 2026
32f0f89
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 8, 2026
7e6f90d
chore(ui-inputs): 0.13.0 -> 0.14.0 — main already claimed 0.13.0 for …
Elenyan-Script Sep 8, 2026
8e9b87f
chore(ui-inputs): sync package-lock to 0.14.0
Elenyan-Script Sep 8, 2026
8756956
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 8, 2026
18aa2d1
fix(ui-inputs): honor emptyDisplayValue on GroupSelect and GroupCombobox
Elenyan-Script Sep 8, 2026
0b6f3e6
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 8, 2026
60986d3
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 11, 2026
e8af514
fix(ui-inputs): reset pointer on GroupSelect trigger-click close
Elenyan-Script Sep 11, 2026
16bda20
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 15, 2026
e32954f
docs(ui-inputs): document the group-header theming token surface
Elenyan-Script Sep 15, 2026
db2281d
fix(ui-inputs): align buildGroupRows option count with parent flatMap
Elenyan-Script Sep 15, 2026
0578db2
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 16, 2026
1b28ff9
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 18, 2026
550fc68
chore: update configuration files and tests across multiple packages
Elenyan-Script Sep 18, 2026
c3dbeac
Merge branch 'feat/ui-inputs-group-select' of https://github.com/Elen…
Elenyan-Script Sep 18, 2026
3c8a724
Merge branch 'main' into feat/ui-inputs-group-select
Elenyan-Script Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions docs/packages/ui-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,52 @@ Models an **array of option ids**. Committing an option toggles its membership a
</FormField>
```

### GroupSelect & GroupCombobox

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:

<ClientOnly>
<div class="ui-demo">
<FormField id="demo-group-fruit" label="Fruit" #default="{controlId, describedby, invalid}">
<GroupSelect :id="controlId" v-model="groupFruit" :groups="fruitGroups" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>
<p class="ui-demo__state">Model value: <code>{{ groupFruit === null ? 'null' : JSON.stringify(groupFruit) }}</code></p>
<FormField id="demo-group-fruit-search" label="Fruit (searchable)" #default="{controlId, describedby, invalid}">
<GroupCombobox :id="controlId" v-model="groupFruitSearch" :groups="fruitGroups" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>
<p class="ui-demo__state">Model value: <code>{{ groupFruitSearch === null ? 'null' : JSON.stringify(groupFruitSearch) }}</code></p>
</div>
</ClientOnly>

```vue
<FormField id="fruit" label="Fruit" #default="{controlId, describedby, invalid}">
<GroupSelect :id="controlId" v-model="fruit" :groups="fruitGroups" label="name" :invalid="invalid" :describedby="describedby" />
</FormField>
```

The `groups` prop replaces `options` — each group carries its own `options`, a header `text`, and an optional `header` flag:

```ts
const fruitGroups = [
{
text: 'Tropical',
options: [
{id: 'mango', name: 'Mango'},
{id: 'kiwi', name: 'Kiwi'},
],
},
{
text: 'Stone',
options: [
{id: 'apricot', name: 'Apricot'},
{id: 'peach', name: 'Peach'},
],
},
];
```

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.

### The checkbox family

`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.
Expand Down Expand Up @@ -215,6 +261,8 @@ By default `FormField` stacks the label above the control. Pass `orientation="ho
| `Combobox` | Accessible searchable/filtering single-select; exposes an imperative `focus()` handle |
| `MultiSelect` | Accessible multi-value select — models an array of option ids; toggle-in-place listbox, inline chip bar with per-chip remove |
| `MultiCombobox` | Accessible **searchable** multi-value select — MultiSelect's model + chips with Combobox's filtering input as the trigger |
| `GroupSelect` | Accessible **grouped** single-select — `SingleSelect` over caller-ordered `groups` with `role="group"` headers; models `T['id'] \| null` |
| `GroupCombobox` | Accessible **searchable grouped** single-select — `GroupSelect`'s listbox with `Combobox`'s filtering input; exposes `focus()` |
| `Pressable` | A real `<button>` for a control that carries **no value** — replaces `<span @click>` / `<div @click>`; optional `aria-pressed` toggle mode |
| `Disclosure` | Show/hide a region from a real button (`aria-expanded` + `aria-controls`), optionally wrapped in a real heading — replaces `<h2 @click>` |

Expand Down Expand Up @@ -320,6 +368,22 @@ danger-toned by default (`--ui-clear-text`, chaining to `--ui-danger-text`).
/>
```

#### Grouped variants (`GroupSelect` / `GroupCombobox`)

`GroupSelect` and `GroupCombobox` are the grouped single-selects. They share the family's
contract — `label`, `id`, `placeholder`, `disabled`, `required`, `invalid`, `describedby`,
`emptyText`, `optionsLabel`, `mutedOptions`, `clearLabel`, `emptyDisplayValue`, the `#option`
slot, and (on `GroupCombobox`) the imperative `focus()` handle — with **`options` replaced by
`groups`** and no `alphabeticalSort` (the partition is the order):

```ts
groups: {options: T[]; text: string; header?: boolean}[];
```

- Groups render in caller order; a single `T['id'] | null` model selects across the flattened option set.
- 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.
- An empty group renders nothing — a header never outlives its options, including when `GroupCombobox`'s filter drains a group.

### The checkbox family

`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.
Expand Down Expand Up @@ -380,6 +444,7 @@ The variable surface groups into:
- **Control** (inputs + select triggers) — `--ui-control-bg`, `--ui-control-text`, `--ui-control-text-muted`, `--ui-control-border-width`, `--ui-control-border-color`, `--ui-control-border-open`, `--ui-control-radius`, `--ui-control-pad-x`, `--ui-control-pad-y`, `--ui-control-shadow`, `--ui-control-shadow-hover`, `--ui-control-bg-disabled`, `--ui-focus-ring`, `--ui-control-font-size`, `--ui-control-line-height`, `--ui-control-min-height`
- **Listbox menu** — `--ui-menu-bg`, `--ui-menu-border-width`, `--ui-menu-border-color`, `--ui-menu-radius`, `--ui-menu-pad`, `--ui-menu-shadow`, `--ui-menu-max-height`, `--ui-menu-min-width`, `--ui-menu-max-width`, `--ui-menu-font-size`
- **Option** — `--ui-option-radius`, `--ui-option-pad`, `--ui-option-bg-active`, `--ui-option-min-height`, `--ui-option-text-muted` (`.is-muted`), `--ui-option-bg-selected` / `--ui-option-text-selected` (MultiSelect `[aria-selected="true"]`), `--ui-clear-text` (the clear entry)
- **Group header** (GroupSelect / GroupCombobox) — `--ui-group-header-pad`, `--ui-group-header-color`, `--ui-group-header-size`, `--ui-group-header-weight`, `--ui-group-header-transform`, `--ui-group-header-tracking` — the non-navigable label above each option run; `pad`/`color` default to `--ui-option-pad` / `--ui-control-text-muted` so your token map themes them for free
- **Chip** (MultiSelect) — `--ui-chip-bg`, `--ui-chip-text`, `--ui-chip-radius`, `--ui-chip-pad`, each defaulting to an existing resting token so chips are neutral until you opt in
- **Check** (Checkbox / CheckboxGroup / RadioGroup) — `--ui-check-size`, `--ui-check-border-width` (shorthand-valued, like the control's), `--ui-check-border-color`, `--ui-check-bg`, `--ui-check-bg-checked`, `--ui-check-mark-color`, `--ui-check-radius`, `--ui-check-gap` (control ↔ label), `--ui-check-item-gap` (group rows) — every colour default derives from an existing resting token (`--ui-control-bg`, `--ui-control-border-color`, `--ui-control-border-open`), so your token map themes the family with no new mappings
- **Switch** — `--ui-switch-track-width`, `--ui-switch-track-height`, `--ui-switch-track-radius`, `--ui-switch-track-bg`, `--ui-switch-track-bg-checked`, `--ui-switch-thumb-size`, `--ui-switch-thumb-bg` — the thumb travels track-width − track-height, so geometry stays coherent under any override
Expand Down Expand Up @@ -625,7 +690,7 @@ No file or range atoms; no date _picker_ (`DateInput` wraps the native control);
<script setup lang="ts">
import {computed, ref} from 'vue';

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

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

Expand Down Expand Up @@ -669,6 +734,13 @@ const city = ref<string | null>(null);
const toppingIds = ref<string[]>([]);
const tagIds = ref<string[]>([]);

const fruitGroups = [
{text: 'Tropical', options: [{id: 'mango', name: 'Mango'}, {id: 'kiwi', name: 'Kiwi'}, {id: 'papaya', name: 'Papaya'}]},
{text: 'Stone', options: [{id: 'apricot', name: 'Apricot'}, {id: 'peach', name: 'Peach'}, {id: 'plum', name: 'Plum'}]},
];
const groupFruit = ref<string | null>(null);
const groupFruitSearch = ref<string | null>(null);

const accepted = ref(false);
const notifications = ref(true);
const extraIds = ref<string[]>(['sprinkles']);
Expand Down Expand Up @@ -714,15 +786,25 @@ const themeToppingIds = ref<string[]>(['caramel', 'sprinkles']);
package's single-class menu selectors (0,1,0), re-adding list markers, indent, and
inter-item margins inside the demos — something no real consumer sees. Restore the
package's own menu layout at winning specificity. Chips are spans; the only <ul>s
in the demos are the listbox menus. */
in the demos are the listbox menus (and the grouped listbox's nested role="group"
sub-lists). */
.vp-doc .ui-demo ul[role='listbox'],
.vp-doc .demo-theme-panel ul[role='listbox'] {
.vp-doc .ui-demo ul[role='group'],
.vp-doc .demo-theme-panel ul[role='listbox'],
.vp-doc .demo-theme-panel ul[role='group'] {
list-style: none;
margin: 0.25rem 0 0;
padding: var(--ui-menu-pad);
}
.vp-doc .ui-demo ul[role='group'],
.vp-doc .demo-theme-panel ul[role='group'] {
margin: 0;
padding: 0;
}
.vp-doc .ui-demo ul[role='listbox'] li + li,
.vp-doc .demo-theme-panel ul[role='listbox'] li + li {
.vp-doc .ui-demo ul[role='group'] li + li,
.vp-doc .demo-theme-panel ul[role='listbox'] li + li,
.vp-doc .demo-theme-panel ul[role='group'] li + li {
margin-top: 0;
}

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions packages/ui-inputs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import '@script-development/ui-inputs/style.css';
| `Combobox` | Accessible **searchable/filtering** single-select — a text input that filters the listbox as you type; exposes an imperative `focus()` handle |
| `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 |
| `MultiCombobox` | Accessible **searchable multi-value** select — MultiSelect's array model + chips with Combobox's filter-as-you-type input as the trigger |
| `GroupSelect` | Accessible **grouped** single-select — `SingleSelect` over caller-ordered `groups` with `role="group"` headers; models `T['id'] \| null` |
| `GroupCombobox` | Accessible **searchable grouped** single-select — `GroupSelect`'s grouped listbox with `Combobox`'s filter-as-you-type input; exposes `focus()` |
| `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 |
| `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>` |

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

### Grouped selects (`GroupSelect` / `GroupCombobox`)

`GroupSelect` and `GroupCombobox` are the single-selects for options that arrive **already
partitioned** — active sprints above the backlog, tropical fruit above stone fruit. They
mirror `SingleSelect` / `Combobox` closely (model `T['id'] | null`, the `#option` slot,
`mutedOptions`, `clearLabel`, `emptyDisplayValue`, the announced empty state, the top-layer
popup, and — on `GroupCombobox` — the imperative `focus()` handle) with one contract swap:
**`options` becomes `groups`**.

```ts
groups: {options: T[]; text: string; header?: boolean}[];
```

Each group carries its own `options`, a header `text`, and an optional `header` flag. Groups
render in **caller order** — there is no `alphabeticalSort`, because the partition _is_ the
order — and the flat option index runs through them in sequence, so a single `v-model` selects
across the whole set.

- A **named** group renders `text` as a `role="group"` header labelling its options for
assistive tech.
- `header: false` renders a **headerless** group: its options render flat, and a boundary keeps
them from folding into the preceding group's `role="group"`. Use it for a leading "ungrouped"
run above the named groups.
- An **empty** group (no options) renders nothing — no dangling header. On `GroupCombobox` this
also covers a group the filter drains to nothing: a header never outlives its options.

```vue
<FormField id="fruit" label="Fruit" :error="errors.fruit" #default="{controlId, describedby, invalid}">
<GroupSelect
:id="controlId"
v-model="fruit"
:groups="[
{text: 'Tropical', options: tropical},
{text: 'Stone', options: stone},
]"
label="name"
:invalid="invalid"
:describedby="describedby"
/>
</FormField>
```

### Checkbox family

`Checkbox`, `CheckboxGroup`, `Switch`, and `RadioGroup` all sit on a **native input chassis** —
Expand Down
2 changes: 1 addition & 1 deletion packages/ui-inputs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@script-development/ui-inputs",
"version": "0.13.0",
"version": "0.14.0",
Comment thread
Elenyan-Script marked this conversation as resolved.
"description": "Headless, themeable Vue 3 UI components — form inputs (field, label, error, text/number/date input, textarea, select, checkbox/radio/switch) plus keyboard-correct interactive controls (pressable, disclosure) — styled entirely through --ui-* CSS custom properties.",
"homepage": "https://packages.script.nl/packages/ui-inputs",
"license": "MIT",
Expand Down
6 changes: 5 additions & 1 deletion packages/ui-inputs/src/components/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<div v-if="open" ref="floating" popover="manual" class="ui-menu-anchor" :style="floatingStyles">
<OptionList
variant="ui-combobox"
:labels="optionLabels"
:rows="rows"
:keys="optionKeys"
:pointer="pointer"
:listbox-id="listboxId"
Expand Down Expand Up @@ -77,6 +77,7 @@
<script setup lang="ts" generic="T extends SelectItem">
import {computed, ref, useTemplateRef, watch} from 'vue';

import type {GroupRow} from '../internal/group-rows';
import type {LabelKey, SelectItem} from '../types';

import {useListbox} from '../composables/useListbox';
Expand Down Expand Up @@ -175,6 +176,9 @@ const filtered = computed(() => {
// stays the single list every index (pointer, commit, aria) is keyed against.
const optionLabels = computed(() => filtered.value.map(labelOf));
const optionKeys = computed(() => filtered.value.map((option) => String(option.id)));
// A flat control renders one headerless run — an all-option row sequence OptionList lays out
// flat (no group wrappers), the same component the grouped controls feed a header/option mix.
const rows = computed<GroupRow[]>(() => filtered.value.map((_, index) => ({type: 'option', index})));
/** `aria-selected` marks the COMMITTED value — OptionList only asks about rendered indices. */
const isSelected = (index: number): boolean => filtered.value[index].id === model.value;
/** `.is-muted` marks visual de-emphasis only — a muted option stays committable. */
Expand Down
Loading
Loading