From be262f1b12e2eb095f61a59acab59bedaefb2039 Mon Sep 17 00:00:00 2001 From: Elena Kreleger Date: Fri, 28 Aug 2026 13:27:56 +0200 Subject: [PATCH 01/17] feat(ui-inputs): add GroupSelect and GroupCombobox atoms (v0.13.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new atoms for grouped-option listboxes, mirroring the existing SingleSelect/Combobox and MultiSelect/MultiCombobox split (ADR-0043): - GroupRow union type (header | option) so group headers interleave with navigable options in a single row sequence without occupying index space - GroupOptionList.vue — internal listbox popup; headers carry role=presentation so they are valid listbox children but are skipped by the keyboard path - GroupSelect.vue — button trigger, non-searchable; flatOptions bridges all groups for index-keyed pointer, commit, and aria-selected - GroupCombobox.vue — input trigger with per-group filtering; WR-0576 browse-to-change equality rule, select-all-on-open, dismiss revert, and async edit-form re-sync watch; defineExpose focus handle (WR-0448) - 46 tests (22 GroupSelect + 24 GroupCombobox), 100% coverage Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 4 +- packages/ui-inputs/package.json | 2 +- .../src/components/GroupCombobox.vue | 290 ++++++++++++++++ .../src/components/GroupOptionList.vue | 94 +++++ .../ui-inputs/src/components/GroupSelect.vue | 222 ++++++++++++ packages/ui-inputs/src/index.ts | 2 + packages/ui-inputs/src/internal/group-rows.ts | 2 + .../ui-inputs/tests/GroupCombobox.spec.ts | 320 ++++++++++++++++++ packages/ui-inputs/tests/GroupSelect.spec.ts | 295 ++++++++++++++++ packages/ui-inputs/tests/find-menu.ts | 4 + 10 files changed, 1232 insertions(+), 3 deletions(-) create mode 100644 packages/ui-inputs/src/components/GroupCombobox.vue create mode 100644 packages/ui-inputs/src/components/GroupOptionList.vue create mode 100644 packages/ui-inputs/src/components/GroupSelect.vue create mode 100644 packages/ui-inputs/src/internal/group-rows.ts create mode 100644 packages/ui-inputs/tests/GroupCombobox.spec.ts create mode 100644 packages/ui-inputs/tests/GroupSelect.spec.ts diff --git a/package-lock.json b/package-lock.json index 6803aad1..d422d9cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10371,7 +10371,7 @@ }, "packages/dialog": { "name": "@script-development/fs-dialog", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "vue-component-type-helpers": "^3.3.11" @@ -10536,7 +10536,7 @@ }, "packages/ui-inputs": { "name": "@script-development/ui-inputs", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { "@floating-ui/vue": "^2.0.1" diff --git a/packages/ui-inputs/package.json b/packages/ui-inputs/package.json index 48c5d4ea..7264d30a 100644 --- a/packages/ui-inputs/package.json +++ b/packages/ui-inputs/package.json @@ -1,6 +1,6 @@ { "name": "@script-development/ui-inputs", - "version": "0.12.0", + "version": "0.13.0", "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", diff --git a/packages/ui-inputs/src/components/GroupCombobox.vue b/packages/ui-inputs/src/components/GroupCombobox.vue new file mode 100644 index 00000000..f48853ff --- /dev/null +++ b/packages/ui-inputs/src/components/GroupCombobox.vue @@ -0,0 +1,290 @@ + + + diff --git a/packages/ui-inputs/src/components/GroupOptionList.vue b/packages/ui-inputs/src/components/GroupOptionList.vue new file mode 100644 index 00000000..a4b233d4 --- /dev/null +++ b/packages/ui-inputs/src/components/GroupOptionList.vue @@ -0,0 +1,94 @@ + + + diff --git a/packages/ui-inputs/src/components/GroupSelect.vue b/packages/ui-inputs/src/components/GroupSelect.vue new file mode 100644 index 00000000..85054d34 --- /dev/null +++ b/packages/ui-inputs/src/components/GroupSelect.vue @@ -0,0 +1,222 @@ + + + diff --git a/packages/ui-inputs/src/index.ts b/packages/ui-inputs/src/index.ts index b341b132..21f61687 100644 --- a/packages/ui-inputs/src/index.ts +++ b/packages/ui-inputs/src/index.ts @@ -13,6 +13,8 @@ export {default as SingleSelect} from './components/SingleSelect.vue'; export {default as Combobox} from './components/Combobox.vue'; export {default as MultiSelect} from './components/MultiSelect.vue'; export {default as MultiCombobox} from './components/MultiCombobox.vue'; +export {default as GroupSelect} from './components/GroupSelect.vue'; +export {default as GroupCombobox} from './components/GroupCombobox.vue'; // Interactive controls that are NOT form inputs: they carry no value and belong to no field. // Both exist to make a keyboard-reachable control the path of least resistance — the class of diff --git a/packages/ui-inputs/src/internal/group-rows.ts b/packages/ui-inputs/src/internal/group-rows.ts new file mode 100644 index 00000000..ae75fdbd --- /dev/null +++ b/packages/ui-inputs/src/internal/group-rows.ts @@ -0,0 +1,2 @@ +/** A row in a grouped listbox — either a non-navigable group header or a navigable option. */ +export type GroupRow = {type: 'header'; text: string} | {type: 'option'; index: number}; diff --git a/packages/ui-inputs/tests/GroupCombobox.spec.ts b/packages/ui-inputs/tests/GroupCombobox.spec.ts new file mode 100644 index 00000000..0eac63ad --- /dev/null +++ b/packages/ui-inputs/tests/GroupCombobox.spec.ts @@ -0,0 +1,320 @@ +// @vitest-environment happy-dom +import {mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {h} from 'vue'; + +import GroupCombobox from '../src/components/GroupCombobox.vue'; +import {groupMenu} from './find-menu'; + +type Fruit = {id: number; name: string}; + +const GROUPS = [ + { + options: [ + {id: 1, name: 'Mango'}, + {id: 2, name: 'Kiwi'}, + ], + text: 'Tropical', + }, + { + options: [ + {id: 3, name: 'Apricot'}, + {id: 4, name: 'Lime'}, + ], + text: 'Stone', + }, +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC + VTU mount inference +const mountGroupCombobox = (props: Record, slots?: Record) => + mount(GroupCombobox as any, { + props: {groups: GROUPS, label: 'name', id: 'fruit', modelValue: null, ...props}, + slots, + attachTo: document.body, + }); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('GroupCombobox', () => { + it('renders an input with combobox role and no menu until opened', () => { + const wrapper = mountGroupCombobox({}); + const input = wrapper.find('input'); + + expect(input.attributes('role')).toBe('combobox'); + expect(input.attributes('aria-autocomplete')).toBe('list'); + expect(input.attributes('aria-expanded')).toBe('false'); + expect(input.attributes('aria-controls')).toBeUndefined(); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('opens on click and renders group headers + options', async () => { + const wrapper = mountGroupCombobox({}); + await wrapper.find('input').trigger('click'); + + expect(wrapper.find('input').attributes('aria-expanded')).toBe('true'); + const m = groupMenu(wrapper); + expect(m.exists()).toBe(true); + + const headers = m.findAll('.ui-groupcombobox__group-header'); + expect(headers.map((h) => h.text())).toEqual(['Tropical', 'Stone']); + + const options = m.findAll('.ui-groupcombobox__option'); + expect(options.map((o) => o.text())).toEqual(['Mango', 'Kiwi', 'Apricot', 'Lime']); + }); + + it('filter narrows within groups; empty group is hidden; empty query shows all', async () => { + const wrapper = mountGroupCombobox({}); + const input = wrapper.find('input'); + + await input.setValue('a'); // Mango (has 'a'), Apricot (has 'a') — Kiwi and Lime do not + expect( + groupMenu(wrapper) + .findAll('.ui-groupcombobox__option') + .map((o) => o.text()), + ).toEqual(['Mango', 'Apricot']); + // Tropical still has Mango, Stone still has Apricot → both headers present + expect( + groupMenu(wrapper) + .findAll('.ui-groupcombobox__group-header') + .map((h) => h.text()), + ).toEqual(['Tropical', 'Stone']); + + await input.setValue('ki'); // only Kiwi + expect( + groupMenu(wrapper) + .findAll('.ui-groupcombobox__option') + .map((o) => o.text()), + ).toEqual(['Kiwi']); + // Stone has no match → its header is hidden + expect( + groupMenu(wrapper) + .findAll('.ui-groupcombobox__group-header') + .map((h) => h.text()), + ).toEqual(['Tropical']); + + await input.setValue(''); + expect(groupMenu(wrapper).findAll('.ui-groupcombobox__option')).toHaveLength(4); + }); + + it('commits on click, emits update:modelValue, closes, and snaps input to label', async () => { + const wrapper = mountGroupCombobox({}); + await wrapper.find('input').trigger('click'); + + const options = groupMenu(wrapper).findAll('.ui-groupcombobox__option'); + await options[2].trigger('click'); // Apricot (index 2) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([3]); + expect(groupMenu(wrapper).exists()).toBe(false); + expect(wrapper.find('input').element.value).toBe('Apricot'); + }); + + it('shows the committed value label in the input on mount', () => { + const wrapper = mountGroupCombobox({modelValue: 2}); + expect(wrapper.find('input').element.value).toBe('Kiwi'); + }); + + it('browse-to-change (WR-0576): opening with committed label shows the FULL list', async () => { + const wrapper = mountGroupCombobox({modelValue: 1}); // Mango + const input = wrapper.find('input'); + expect(input.element.value).toBe('Mango'); + + await input.trigger('click'); + // Query equals committed label → filter must not engage. + expect(groupMenu(wrapper).findAll('.ui-groupcombobox__option')).toHaveLength(4); + }); + + it('filter engages on divergence from the committed label', async () => { + const wrapper = mountGroupCombobox({modelValue: 1}); // Mango + await wrapper.find('input').trigger('click'); + await wrapper.find('input').setValue('Ki'); // diverged + + expect( + groupMenu(wrapper) + .findAll('.ui-groupcombobox__option') + .map((o) => o.text()), + ).toEqual(['Kiwi']); + }); + + it('Escape reverts a half-typed query to the committed label', async () => { + const wrapper = mountGroupCombobox({modelValue: 3}); // Apricot + const input = wrapper.find('input'); + const root = wrapper.find('.ui-groupcombobox'); + + await input.trigger('click'); + await input.setValue('zzz'); + expect(input.element.value).toBe('zzz'); + + await root.trigger('keydown', {key: 'Escape'}); + expect(groupMenu(wrapper).exists()).toBe(false); + expect(input.element.value).toBe('Apricot'); + }); + + it('click-outside reverts a half-typed query', async () => { + const wrapper = mountGroupCombobox({}); + const input = wrapper.find('input'); + + await input.setValue('ap'); + expect(input.element.value).toBe('ap'); + expect(groupMenu(wrapper).exists()).toBe(true); + + document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})); + await wrapper.vm.$nextTick(); + expect(groupMenu(wrapper).exists()).toBe(false); + expect(input.element.value).toBe(''); + }); + + it('select-all-on-open when input holds the committed label (WR-0576)', async () => { + const wrapper = mountGroupCombobox({modelValue: 1}); // Mango + const input = wrapper.find('input'); + + await input.trigger('click'); + expect(input.element.selectionStart).toBe(0); + expect(input.element.selectionEnd).toBe('Mango'.length); + }); + + it('does not select when typing is what opened the popup (WR-0576)', async () => { + const wrapper = mountGroupCombobox({modelValue: 1}); // Mango + const input = wrapper.find('input'); + const select = vi.spyOn(input.element, 'select'); + + await input.setValue('Ma'); // typing opens; query diverged before open watcher runs + expect(select).not.toHaveBeenCalled(); + }); + + it('ArrowDown/Enter keyboard commit', async () => { + const wrapper = mountGroupCombobox({}); + const root = wrapper.find('.ui-groupcombobox'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open, pointer -1 + await root.trigger('keydown', {key: 'ArrowDown'}); // → 0 (Mango) + await root.trigger('keydown', {key: 'ArrowDown'}); // → 1 (Kiwi) + await root.trigger('keydown', {key: 'Enter'}); + + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([2]); // Kiwi + expect(groupMenu(wrapper).exists()).toBe(false); + expect(wrapper.find('input').element.value).toBe('Kiwi'); + }); + + it('clearLabel: ArrowDown lands on clear entry; Enter commits null; input snaps to ""', async () => { + const wrapper = mountGroupCombobox({clearLabel: 'None', modelValue: 1}); + const root = wrapper.find('.ui-groupcombobox'); + const input = wrapper.find('input'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open + await root.trigger('keydown', {key: 'ArrowDown'}); // → clear entry + expect(input.attributes('aria-activedescendant')).toBe('fruit-clear'); + await root.trigger('keydown', {key: 'Enter'}); + + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]); + expect(groupMenu(wrapper).exists()).toBe(false); + expect(input.element.value).toBe(''); + }); + + it('aria-activedescendant tracks pointer through filtered options, not headers', async () => { + const wrapper = mountGroupCombobox({}); + const root = wrapper.find('.ui-groupcombobox'); + const input = wrapper.find('input'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open + expect(input.attributes('aria-activedescendant')).toBeUndefined(); + + await root.trigger('keydown', {key: 'ArrowDown'}); // → 0 (Mango) + expect(input.attributes('aria-activedescendant')).toBe('fruit-opt-0'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // → 1 (Kiwi) + expect(input.attributes('aria-activedescendant')).toBe('fruit-opt-1'); + + // Header rows carry no id. + const headerIds = groupMenu(wrapper) + .findAll('.ui-groupcombobox__group-header') + .map((h) => h.attributes('id')); + expect(headerIds.every((id) => id === undefined)).toBe(true); + }); + + it('aria-selected marks the committed option across the full filtered list', async () => { + const wrapper = mountGroupCombobox({modelValue: 3}); // Apricot + await wrapper.find('input').trigger('click'); + + const options = groupMenu(wrapper).findAll('.ui-groupcombobox__option'); + // Mango, Kiwi, Apricot, Lime — Apricot (index 2) is selected + expect(options.map((o) => o.attributes('aria-selected'))).toEqual(['false', 'false', 'true', 'false']); + }); + + it('does not open when disabled', async () => { + const wrapper = mountGroupCombobox({disabled: true}); + await wrapper.find('input').trigger('click'); + expect(groupMenu(wrapper).exists()).toBe(false); + + await wrapper.find('.ui-groupcombobox').trigger('keydown', {key: 'ArrowDown'}); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('propagates describedby to the input', () => { + const wrapper = mountGroupCombobox({describedby: 'fruit-error'}); + expect(wrapper.find('input').attributes('aria-describedby')).toBe('fruit-error'); + }); + + it('re-syncs input to committed label when model changes from outside while idle', async () => { + const wrapper = mountGroupCombobox({}); + expect(wrapper.find('input').element.value).toBe(''); + + await wrapper.setProps({modelValue: 4}); // Lime + expect(wrapper.find('input').element.value).toBe('Lime'); + }); + + it("shows label once a pre-set model's option arrives asynchronously (edit-form pattern)", async () => { + const wrapper = mountGroupCombobox({modelValue: 1, groups: []}); + expect(wrapper.find('input').element.value).toBe(''); + + await wrapper.setProps({groups: GROUPS}); + expect(wrapper.find('input').element.value).toBe('Mango'); + }); + + it('does not yank the typed query when model changes from outside while open', async () => { + const wrapper = mountGroupCombobox({}); + await wrapper.find('input').trigger('click'); + await wrapper.setProps({modelValue: 1}); // external change while open + expect(wrapper.find('input').element.value).toBe(''); + }); + + it('removes its document listener on unmount — no throw on post-unmount click', async () => { + const wrapper = mountGroupCombobox({}); + await wrapper.find('input').trigger('click'); + wrapper.unmount(); + document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + + it('shows emptyText when all groups are filtered out', async () => { + const wrapper = mountGroupCombobox({emptyText: 'No match'}); + await wrapper.find('input').setValue('zzz'); + expect(groupMenu(wrapper).find('.ui-groupcombobox__empty').text()).toBe('No match'); + expect(groupMenu(wrapper).findAll('.ui-groupcombobox__option')).toHaveLength(0); + }); + + it('exposes an imperative focus() handle that moves DOM focus to the input', () => { + const wrapper = mountGroupCombobox({}); + const inputEl = wrapper.find('input').element; + expect(document.activeElement).not.toBe(inputEl); + + (wrapper.vm as unknown as {focus: () => void}).focus(); + expect(document.activeElement).toBe(inputEl); + }); + + it('renders per-option custom content through the #option scoped slot', async () => { + const wrapper = mountGroupCombobox( + {modelValue: 1}, + { + option: (props: {option: Fruit; index: number; selected: boolean}) => + h('b', {class: 'swatch'}, `${props.option.name}#${props.index}${props.selected ? '*' : ''}`), + }, + ); + await wrapper.find('input').trigger('click'); + await wrapper.find('input').setValue('ki'); // only Kiwi (index 0 after filter) + + const swatches = groupMenu(wrapper) + .findAll('.swatch') + .map((el) => el.text()); + expect(swatches).toEqual(['Kiwi#0']); + }); +}); diff --git a/packages/ui-inputs/tests/GroupSelect.spec.ts b/packages/ui-inputs/tests/GroupSelect.spec.ts new file mode 100644 index 00000000..5aa423c8 --- /dev/null +++ b/packages/ui-inputs/tests/GroupSelect.spec.ts @@ -0,0 +1,295 @@ +// @vitest-environment happy-dom +import {mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it} from 'vitest'; +import {h} from 'vue'; + +import GroupSelect from '../src/components/GroupSelect.vue'; +import {groupMenu} from './find-menu'; + +type Fruit = {id: number; name: string}; + +const GROUPS = [ + { + options: [ + {id: 1, name: 'Mango'}, + {id: 2, name: 'Kiwi'}, + ], + text: 'Tropical', + }, + { + options: [ + {id: 3, name: 'Apricot'}, + {id: 4, name: 'Lime'}, + ], + text: 'Stone', + }, +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC + VTU mount inference +const mountGroupSelect = (props: Record, slots?: Record) => + mount(GroupSelect as any, { + props: {groups: GROUPS, label: 'name', id: 'fruit', modelValue: null, ...props}, + slots, + attachTo: document.body, + }); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('GroupSelect', () => { + it('renders a combobox button with placeholder and closed aria state', () => { + const wrapper = mountGroupSelect({}); + const button = wrapper.find('button'); + + expect(button.attributes('role')).toBe('combobox'); + expect(button.attributes('aria-expanded')).toBe('false'); + expect(button.attributes('aria-haspopup')).toBe('listbox'); + expect(button.find('.ui-groupselect__placeholder').text()).toBe('Select…'); + expect(button.attributes('aria-controls')).toBeUndefined(); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('opens on click and renders group headers with role="presentation" and options with role="option"', async () => { + const wrapper = mountGroupSelect({}); + await wrapper.find('button').trigger('click'); + + expect(wrapper.find('button').attributes('aria-expanded')).toBe('true'); + const m = groupMenu(wrapper); + expect(m.exists()).toBe(true); + + const headers = m.findAll('.ui-groupselect__group-header'); + expect(headers.map((h) => h.attributes('role'))).toEqual(['presentation', 'presentation']); + + const options = m.findAll('.ui-groupselect__option'); + expect(options.every((o) => o.attributes('role') === 'option')).toBe(true); + }); + + it('intersperse group headers between their option rows', async () => { + const wrapper = mountGroupSelect({}); + await wrapper.find('button').trigger('click'); + + const m = groupMenu(wrapper); + const items = m.findAll('li'); + // Tropical header, Mango, Kiwi, Stone header, Apricot, Lime + expect(items[0].classes()).toContain('ui-groupselect__group-header'); + expect(items[1].classes()).toContain('ui-groupselect__option'); + expect(items[2].classes()).toContain('ui-groupselect__option'); + expect(items[3].classes()).toContain('ui-groupselect__group-header'); + expect(items[4].classes()).toContain('ui-groupselect__option'); + expect(items[5].classes()).toContain('ui-groupselect__option'); + }); + + it('commits the clicked option, emits update:modelValue, and closes', async () => { + const wrapper = mountGroupSelect({}); + await wrapper.find('button').trigger('click'); + + const options = groupMenu(wrapper).findAll('.ui-groupselect__option'); + await options[2].trigger('click'); // Apricot (index 2) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([3]); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('shows the committed value label in the trigger, not the placeholder', () => { + const wrapper = mountGroupSelect({modelValue: 2}); + expect(wrapper.find('.ui-groupselect__value').text()).toBe('Kiwi'); + expect(wrapper.find('.ui-groupselect__placeholder').exists()).toBe(false); + }); + + it('commits null on clear entry click', async () => { + const wrapper = mountGroupSelect({clearLabel: 'None', modelValue: 1}); + await wrapper.find('button').trigger('click'); + + await groupMenu(wrapper).find('.ui-groupselect__clear').trigger('click'); + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('commits the highlighted option on Enter and closes; Escape closes without commit', async () => { + const wrapper = mountGroupSelect({}); + const root = wrapper.find('.ui-groupselect'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open + await root.trigger('keydown', {key: 'ArrowDown'}); // → index 0 (Mango) + await root.trigger('keydown', {key: 'Enter'}); + + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([1]); // Mango + expect(groupMenu(wrapper).exists()).toBe(false); + + // Reopen and Escape + await root.trigger('keydown', {key: 'ArrowDown'}); + expect(groupMenu(wrapper).exists()).toBe(true); + await root.trigger('keydown', {key: 'Escape'}); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('Home/End jump to first and last option', async () => { + const wrapper = mountGroupSelect({}); + const root = wrapper.find('.ui-groupselect'); + const button = wrapper.find('button'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open + await root.trigger('keydown', {key: 'End'}); + expect(button.attributes('aria-activedescendant')).toBe('fruit-opt-3'); // Lime (last) + await root.trigger('keydown', {key: 'Home'}); + expect(button.attributes('aria-activedescendant')).toBe('fruit-opt-0'); // Mango (first) + }); + + it('aria-activedescendant tracks the pointer across options and skips headers', async () => { + const wrapper = mountGroupSelect({}); + const root = wrapper.find('.ui-groupselect'); + const button = wrapper.find('button'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open, pointer -1 + expect(button.attributes('aria-activedescendant')).toBeUndefined(); + + await root.trigger('keydown', {key: 'ArrowDown'}); // → 0 (Mango) + expect(button.attributes('aria-activedescendant')).toBe('fruit-opt-0'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // → 1 (Kiwi) + expect(button.attributes('aria-activedescendant')).toBe('fruit-opt-1'); + + // Headers have no id and are role="presentation" — the pointer only lands on options. + const headerIds = groupMenu(wrapper) + .findAll('.ui-groupselect__group-header') + .map((h) => h.attributes('id')); + expect(headerIds.every((id) => id === undefined)).toBe(true); + }); + + it('aria-selected marks the committed option, not the hovered one', async () => { + const wrapper = mountGroupSelect({modelValue: 1}); // Mango + await wrapper.find('button').trigger('click'); + + const options = groupMenu(wrapper).findAll('.ui-groupselect__option'); + expect(options.map((o) => o.attributes('aria-selected'))).toEqual(['true', 'false', 'false', 'false']); + + await options[2].trigger('mouseover'); // hover Apricot + expect(options.map((o) => o.attributes('aria-selected'))).toEqual(['true', 'false', 'false', 'false']); + }); + + it('does not open when disabled, by click or by keyboard', async () => { + const wrapper = mountGroupSelect({disabled: true}); + await wrapper.find('button').trigger('click'); + expect(groupMenu(wrapper).exists()).toBe(false); + + await wrapper.find('.ui-groupselect').trigger('keydown', {key: 'ArrowDown'}); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('propagates required, invalid, and describedby to the trigger', () => { + const wrapper = mountGroupSelect({required: true, invalid: true, describedby: 'fruit-error'}); + const button = wrapper.find('button'); + + expect(button.attributes('aria-required')).toBe('true'); + expect(button.attributes('aria-invalid')).toBe('true'); + expect(button.attributes('aria-describedby')).toBe('fruit-error'); + }); + + it('shows emptyText when all groups have no options', async () => { + const wrapper = mountGroupSelect({groups: [], emptyText: 'Nothing here'}); + await wrapper.find('button').trigger('click'); + + expect(groupMenu(wrapper).find('.ui-groupselect__empty').text()).toBe('Nothing here'); + expect(groupMenu(wrapper).findAll('.ui-groupselect__option')).toHaveLength(0); + }); + + it('omits the header row for a group with header=false but still renders its options', async () => { + const wrapper = mountGroupSelect({ + groups: [ + {options: [{id: 1, name: 'Mango'}], text: 'Tropical', header: false}, + {options: [{id: 2, name: 'Apricot'}], text: 'Stone'}, + ], + }); + await wrapper.find('button').trigger('click'); + + const headers = groupMenu(wrapper).findAll('.ui-groupselect__group-header'); + expect(headers.map((h) => h.text())).toEqual(['Stone']); // only the second header + expect(groupMenu(wrapper).findAll('.ui-groupselect__option')).toHaveLength(2); + }); + + it('renders mutedOptions with .is-muted and keeps them committable', async () => { + const wrapper = mountGroupSelect({mutedOptions: [2]}); // Kiwi + await wrapper.find('button').trigger('click'); + + const options = groupMenu(wrapper).findAll('.ui-groupselect__option'); + expect(options.map((o) => o.classes().includes('is-muted'))).toEqual([false, true, false, false]); + + await options[1].trigger('click'); // Kiwi commits anyway + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([2]); + }); + + it('#option slot receives correct typed props', async () => { + const wrapper = mountGroupSelect( + {modelValue: 1}, + { + option: (props: {option: Fruit; index: number; selected: boolean; active: boolean}) => + h('b', {class: 'swatch'}, `${props.option.name}#${props.index}${props.selected ? '*' : ''}`), + }, + ); + await wrapper.find('button').trigger('click'); + + const swatches = groupMenu(wrapper) + .findAll('.swatch') + .map((el) => el.text()); + expect(swatches).toEqual(['Mango#0*', 'Kiwi#1', 'Apricot#2', 'Lime#3']); + }); + + it('clear entry renders outside option index, aria-selected true when model null', async () => { + const wrapper = mountGroupSelect({clearLabel: 'None'}); + await wrapper.find('button').trigger('click'); + + const clear = groupMenu(wrapper).find('.ui-groupselect__clear'); + expect(clear.text()).toBe('None'); + expect(clear.attributes('id')).toBe('fruit-clear'); + expect(clear.attributes('aria-selected')).toBe('true'); // model is null + + // Options keep their own index space independent of the clear entry. + expect( + groupMenu(wrapper) + .findAll('.ui-groupselect__option') + .map((o) => o.attributes('id')), + ).toEqual(['fruit-opt-0', 'fruit-opt-1', 'fruit-opt-2', 'fruit-opt-3']); + }); + + it('ArrowDown lands on clear entry first, then options; Enter commits null', async () => { + const wrapper = mountGroupSelect({clearLabel: 'None', modelValue: 1}); + const root = wrapper.find('.ui-groupselect'); + const button = wrapper.find('button'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open + await root.trigger('keydown', {key: 'ArrowDown'}); // → clear entry + expect(button.attributes('aria-activedescendant')).toBe('fruit-clear'); + await root.trigger('keydown', {key: 'Enter'}); + + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('uses optionsLabel as the listbox aria-label', async () => { + const wrapper = mountGroupSelect({optionsLabel: 'Fruits'}); + await wrapper.find('button').trigger('click'); + expect(groupMenu(wrapper).attributes('aria-label')).toBe('Fruits'); + }); + + it('closes on a click outside', async () => { + const wrapper = mountGroupSelect({}); + await wrapper.find('button').trigger('click'); + expect(groupMenu(wrapper).exists()).toBe(true); + + document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})); + await wrapper.vm.$nextTick(); + expect(groupMenu(wrapper).exists()).toBe(false); + }); + + it('removes its document listener on unmount — no throw on post-unmount click', async () => { + const wrapper = mountGroupSelect({}); + await wrapper.find('button').trigger('click'); + wrapper.unmount(); + document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})); + }); + + it('resolves the display string via a getter label function', () => { + const wrapper = mountGroupSelect({label: (o: Fruit) => `${o.name}!`, modelValue: 1}); + expect(wrapper.find('.ui-groupselect__value').text()).toBe('Mango!'); + }); +}); diff --git a/packages/ui-inputs/tests/find-menu.ts b/packages/ui-inputs/tests/find-menu.ts index d1a055fb..e047336e 100644 --- a/packages/ui-inputs/tests/find-menu.ts +++ b/packages/ui-inputs/tests/find-menu.ts @@ -1,5 +1,6 @@ import type {VueWrapper} from '@vue/test-utils'; +import GroupOptionList from '../src/components/GroupOptionList.vue'; import OptionList from '../src/components/OptionList.vue'; /** @@ -7,3 +8,6 @@ import OptionList from '../src/components/OptionList.vue'; * misses it. OptionList is still in the vnode tree; this is the VTU handle onto its `
    `. */ export const menu = (wrapper: VueWrapper) => wrapper.findComponent(OptionList); + +/** GroupOptionList equivalent of `menu` — for GroupSelect and GroupCombobox. */ +export const groupMenu = (wrapper: VueWrapper) => wrapper.findComponent(GroupOptionList); From 0c37abeb9a31d90ddea6ae4f1d6b2fe72e42e66e Mon Sep 17 00:00:00 2001 From: Elena Kreleger Date: Fri, 28 Aug 2026 13:51:53 +0200 Subject: [PATCH 02/17] test(ui-inputs): reach 100% coverage on GroupSelect and GroupCombobox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v8 ignore the unreachable race-guard `return false` in both commit() functions (defensive guard against a concurrent filter change between keydown and the watcher flush — cannot be triggered in tests) - GroupSelect: add empty-group skip test (line 151 continue branch) and clear-entry mouseover test (GroupOptionList clearHover emit) - GroupCombobox: add option mouseover test (@hover handler), getter label test (labelOf function branch), header=false test, and mutedOptions test Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/GroupCombobox.vue | 1 + .../ui-inputs/src/components/GroupSelect.vue | 1 + .../ui-inputs/tests/GroupCombobox.spec.ts | 41 +++++++++++++++++++ packages/ui-inputs/tests/GroupSelect.spec.ts | 25 +++++++++++ 4 files changed, 68 insertions(+) diff --git a/packages/ui-inputs/src/components/GroupCombobox.vue b/packages/ui-inputs/src/components/GroupCombobox.vue index f48853ff..9862f9ea 100644 --- a/packages/ui-inputs/src/components/GroupCombobox.vue +++ b/packages/ui-inputs/src/components/GroupCombobox.vue @@ -200,6 +200,7 @@ const floating = useTemplateRef('floating'); // flush would otherwise index off the end. const commit = (index: number): boolean => { const highlighted = filteredOptions.value[index]; + // v8 ignore next if (!highlighted) return false; model.value = highlighted.id; query.value = labelOf(highlighted); diff --git a/packages/ui-inputs/src/components/GroupSelect.vue b/packages/ui-inputs/src/components/GroupSelect.vue index 85054d34..f48ec9ff 100644 --- a/packages/ui-inputs/src/components/GroupSelect.vue +++ b/packages/ui-inputs/src/components/GroupSelect.vue @@ -174,6 +174,7 @@ const floating = useTemplateRef('floating'); // flush would otherwise index off the end. const commit = (index: number): boolean => { const highlighted = flatOptions.value[index]; + // v8 ignore next if (!highlighted) return false; model.value = highlighted.id; close(); diff --git a/packages/ui-inputs/tests/GroupCombobox.spec.ts b/packages/ui-inputs/tests/GroupCombobox.spec.ts index 0eac63ad..ba8e9bf8 100644 --- a/packages/ui-inputs/tests/GroupCombobox.spec.ts +++ b/packages/ui-inputs/tests/GroupCombobox.spec.ts @@ -292,6 +292,14 @@ describe('GroupCombobox', () => { expect(groupMenu(wrapper).findAll('.ui-groupcombobox__option')).toHaveLength(0); }); + it('hovering an option moves the pointer to it (aria-activedescendant updates)', async () => { + const wrapper = mountGroupCombobox({}); + await wrapper.find('input').trigger('click'); + + await groupMenu(wrapper).findAll('.ui-groupcombobox__option')[1].trigger('mouseover'); // Kiwi (index 1) + expect(wrapper.find('input').attributes('aria-activedescendant')).toBe('fruit-opt-1'); + }); + it('exposes an imperative focus() handle that moves DOM focus to the input', () => { const wrapper = mountGroupCombobox({}); const inputEl = wrapper.find('input').element; @@ -301,6 +309,39 @@ describe('GroupCombobox', () => { expect(document.activeElement).toBe(inputEl); }); + it('resolves the display string via a getter label function', () => { + const wrapper = mountGroupCombobox({label: (o: Fruit) => `${o.name}!`, modelValue: 1}); + expect(wrapper.find('input').element.value).toBe('Mango!'); + }); + + it('omits the header row for a group with header=false but still renders its options', async () => { + const wrapper = mountGroupCombobox({ + groups: [ + {options: [{id: 1, name: 'Mango'}], text: 'Tropical', header: false}, + {options: [{id: 2, name: 'Apricot'}], text: 'Stone'}, + ], + }); + await wrapper.find('input').trigger('click'); + + expect( + groupMenu(wrapper) + .findAll('.ui-groupcombobox__group-header') + .map((h) => h.text()), + ).toEqual(['Stone']); + expect(groupMenu(wrapper).findAll('.ui-groupcombobox__option')).toHaveLength(2); + }); + + it('renders mutedOptions with .is-muted and keeps them committable', async () => { + const wrapper = mountGroupCombobox({mutedOptions: [2]}); // Kiwi + await wrapper.find('input').trigger('click'); + + const options = groupMenu(wrapper).findAll('.ui-groupcombobox__option'); + expect(options.map((o) => o.classes().includes('is-muted'))).toEqual([false, true, false, false]); + + await options[1].trigger('click'); // Kiwi commits anyway + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([2]); + }); + it('renders per-option custom content through the #option scoped slot', async () => { const wrapper = mountGroupCombobox( {modelValue: 1}, diff --git a/packages/ui-inputs/tests/GroupSelect.spec.ts b/packages/ui-inputs/tests/GroupSelect.spec.ts index 5aa423c8..98ef9cec 100644 --- a/packages/ui-inputs/tests/GroupSelect.spec.ts +++ b/packages/ui-inputs/tests/GroupSelect.spec.ts @@ -288,6 +288,31 @@ describe('GroupSelect', () => { document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})); }); + it('skips a group with no options — no header row, no option rows for it', async () => { + const wrapper = mountGroupSelect({ + groups: [ + {options: [], text: 'Empty'}, + {options: [{id: 1, name: 'Mango'}], text: 'Tropical'}, + ], + }); + await wrapper.find('button').trigger('click'); + + expect( + groupMenu(wrapper) + .findAll('.ui-groupselect__group-header') + .map((h) => h.text()), + ).toEqual(['Tropical']); + expect(groupMenu(wrapper).findAll('.ui-groupselect__option')).toHaveLength(1); + }); + + it('hovering the clear entry highlights it (aria-activedescendant points at clear id)', async () => { + const wrapper = mountGroupSelect({clearLabel: 'None', modelValue: 1}); + await wrapper.find('button').trigger('click'); + + await groupMenu(wrapper).find('.ui-groupselect__clear').trigger('mouseover'); + expect(wrapper.find('button').attributes('aria-activedescendant')).toBe('fruit-clear'); + }); + it('resolves the display string via a getter label function', () => { const wrapper = mountGroupSelect({label: (o: Fruit) => `${o.name}!`, modelValue: 1}); expect(wrapper.find('.ui-groupselect__value').text()).toBe('Mango!'); From a5a6ca938b205b3173b9359147a890c6d0a38a11 Mon Sep 17 00:00:00 2001 From: Elena Kreleger Date: Fri, 28 Aug 2026 15:03:49 +0200 Subject: [PATCH 03/17] fix(ui-inputs): add CSS for GroupSelect/GroupCombobox and fix group header ARIA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues raised in PR review: 1. GroupSelect/GroupCombobox shipped with zero CSS rules — every existing shared selector block enumerated only the four legacy families. Extended menu, option, muted/active option, clear, clear-active, empty, and reduced-motion blocks with the two new prefixes. Added GroupSelect trigger/placeholder/chevron rules. Added group-container and group-header rules keyed on new --ui-group-header-* vars. 2. Group headers used role="presentation", making group names invisible to AT. Changed GroupOptionList to the APG listbox grouping pattern: named groups render as
  • with the visual header as an aria-hidden="true" span inside, so screen readers announce the group name without double-reading it. Options with header:false still render flat (no group wrapper). Updated two GroupSelect tests that asserted the old role="presentation" structure. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/GroupOptionList.vue | 71 ++++++++++---- packages/ui-inputs/styles.css | 92 +++++++++++++++++-- packages/ui-inputs/tests/GroupSelect.spec.ts | 14 ++- 3 files changed, 148 insertions(+), 29 deletions(-) diff --git a/packages/ui-inputs/src/components/GroupOptionList.vue b/packages/ui-inputs/src/components/GroupOptionList.vue index a4b233d4..597848bd 100644 --- a/packages/ui-inputs/src/components/GroupOptionList.vue +++ b/packages/ui-inputs/src/components/GroupOptionList.vue @@ -18,23 +18,41 @@ listbox child that satisfies role="listbox"'s required owned role="option", while aria-disabled marks it non-selectable. -->
  • {{ emptyText }}
  • - @@ -89,6 +107,25 @@ const { const emit = defineEmits<{hover: [index: number]; commit: [index: number]; clearHover: []; clearCommit: []}>(); -// Reactive: rows changes when the filter changes in GroupCombobox, so this must recompute. const hasOptions = computed(() => rows.some((row) => row.type === 'option')); + +// Converts the flat GroupRow[] into runs keyed by header. Named runs use role="group"; runs +// without a header (from `header:false` groups) render options flat in the listbox. +const groupedRuns = computed(() => { + const runs: {header: string | null; indices: number[]}[] = []; + let current: {header: string | null; indices: number[]} | null = null; + for (const row of rows) { + if (row.type === 'header') { + current = {header: row.text, indices: []}; + runs.push(current); + } else { + if (!current) { + current = {header: null, indices: []}; + runs.push(current); + } + current.indices.push(row.index); + } + } + return runs; +}); diff --git a/packages/ui-inputs/styles.css b/packages/ui-inputs/styles.css index 9f408b22..2c7498ff 100644 --- a/packages/ui-inputs/styles.css +++ b/packages/ui-inputs/styles.css @@ -96,6 +96,16 @@ destructive-ish "commit null"); renders only when a consumer passes the prop. */ --ui-clear-text: var(--ui-danger-text); + /* group header (GroupSelect / GroupCombobox) — the non-navigable label above each + option run. Defaults derive from existing tokens so a territory's --ui-* map themes + them for free; override individually to break from the muted-label treatment. */ + --ui-group-header-pad: var(--ui-option-pad); + --ui-group-header-color: var(--ui-control-text-muted); + --ui-group-header-size: 0.75em; + --ui-group-header-weight: 600; + --ui-group-header-transform: uppercase; + --ui-group-header-tracking: 0.05em; + /* chip (MultiSelect) — every default derives from an existing resting token, so the chip contract is neutral until a territory opts in. */ --ui-chip-bg: var(--ui-option-bg-active); @@ -256,7 +266,9 @@ .ui-select, .ui-combobox, .ui-multiselect, -.ui-multicombobox { +.ui-multicombobox, +.ui-groupselect, +.ui-groupcombobox { position: relative; } .ui-select__trigger { @@ -280,6 +292,28 @@ transform: rotate(180deg); } +/* GroupSelect trigger — button-triggered, same layout as SingleSelect. */ +.ui-groupselect__trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + text-align: left; + cursor: pointer; +} +.ui-groupselect__placeholder { + color: var(--ui-control-text-muted); +} +.ui-groupselect__chevron { + width: 1.1em; + height: 1.1em; + flex: none; + transition: transform 0.12s ease; +} +.ui-groupselect__trigger.is-open .ui-groupselect__chevron { + transform: rotate(180deg); +} + /* MultiSelect / MultiCombobox — the control chrome sits on the flex box; the chips and the (chrome-less, flex-filling) trigger — a button on MultiSelect, a text input on MultiCombobox — live inside it, so clicking the empty part of the chip bar is clicking @@ -437,7 +471,9 @@ .ui-select__menu, .ui-combobox__menu, .ui-multiselect__menu, -.ui-multicombobox__menu { +.ui-multicombobox__menu, +.ui-groupselect__menu, +.ui-groupcombobox__menu { list-style: none; margin: 0.25rem 0 0; padding: var(--ui-menu-pad); @@ -457,7 +493,9 @@ .ui-select__option, .ui-combobox__option, .ui-multiselect__option, -.ui-multicombobox__option { +.ui-multicombobox__option, +.ui-groupselect__option, +.ui-groupcombobox__option { padding: var(--ui-option-pad); border-radius: var(--ui-option-radius); cursor: pointer; @@ -478,19 +516,25 @@ .ui-select__option.is-muted, .ui-combobox__option.is-muted, .ui-multiselect__option.is-muted, -.ui-multicombobox__option.is-muted { +.ui-multicombobox__option.is-muted, +.ui-groupselect__option.is-muted, +.ui-groupcombobox__option.is-muted { color: var(--ui-option-text-muted); } .ui-select__option.is-active, .ui-combobox__option.is-active, .ui-multiselect__option.is-active, -.ui-multicombobox__option.is-active { +.ui-multicombobox__option.is-active, +.ui-groupselect__option.is-active, +.ui-groupcombobox__option.is-active { background: var(--ui-option-bg-active); } /* The committing clear entry — an option-shaped row above the list (outside the index space), danger-toned by default; shares the option's active-highlight treatment. */ .ui-select__clear, -.ui-combobox__clear { +.ui-combobox__clear, +.ui-groupselect__clear, +.ui-groupcombobox__clear { padding: var(--ui-option-pad); border-radius: var(--ui-option-radius); cursor: pointer; @@ -499,17 +543,48 @@ color: var(--ui-clear-text); } .ui-select__clear.is-active, -.ui-combobox__clear.is-active { +.ui-combobox__clear.is-active, +.ui-groupselect__clear.is-active, +.ui-groupcombobox__clear.is-active { background: var(--ui-option-bg-active); } .ui-select__empty, .ui-combobox__empty, .ui-multiselect__empty, -.ui-multicombobox__empty { +.ui-multicombobox__empty, +.ui-groupselect__empty, +.ui-groupcombobox__empty { padding: var(--ui-option-pad); color: var(--ui-control-text-muted); } +/* GroupSelect / GroupCombobox — group container (
  • ) and inner presentation + list reset their default list chrome so options sit flush with the rest of the menu. */ +.ui-groupselect__group, +.ui-groupcombobox__group { + list-style: none; + padding: 0; + margin: 0; +} +.ui-groupselect__group ul, +.ui-groupcombobox__group ul { + list-style: none; + padding: 0; + margin: 0; +} +/* The visual group label — hidden from AT via aria-hidden="true"; the group name is + announced through role="group" + aria-label on the parent
  • . */ +.ui-groupselect__group-header, +.ui-groupcombobox__group-header { + display: block; + padding: var(--ui-group-header-pad); + color: var(--ui-group-header-color); + font-size: var(--ui-group-header-size); + font-weight: var(--ui-group-header-weight); + text-transform: var(--ui-group-header-transform); + letter-spacing: var(--ui-group-header-tracking); +} + /* Checkbox / Switch / radio rows — the
  • ; the visual header span is aria-hidden to avoid double-reading. + const groups = m.findAll('.ui-groupselect__group'); + expect(groups.map((g) => g.attributes('role'))).toEqual(['group', 'group']); + expect(groups.map((g) => g.attributes('aria-label'))).toEqual(['Tropical', 'Stone']); + const headers = m.findAll('.ui-groupselect__group-header'); - expect(headers.map((h) => h.attributes('role'))).toEqual(['presentation', 'presentation']); + expect(headers.map((h) => h.attributes('aria-hidden'))).toEqual(['true', 'true']); const options = m.findAll('.ui-groupselect__option'); expect(options.every((o) => o.attributes('role') === 'option')).toBe(true); @@ -71,11 +77,11 @@ describe('GroupSelect', () => { const m = groupMenu(wrapper); const items = m.findAll('li'); - // Tropical header, Mango, Kiwi, Stone header, Apricot, Lime - expect(items[0].classes()).toContain('ui-groupselect__group-header'); + // Tropical group-li, Mango, Kiwi, Stone group-li, Apricot, Lime + expect(items[0].classes()).toContain('ui-groupselect__group'); expect(items[1].classes()).toContain('ui-groupselect__option'); expect(items[2].classes()).toContain('ui-groupselect__option'); - expect(items[3].classes()).toContain('ui-groupselect__group-header'); + expect(items[3].classes()).toContain('ui-groupselect__group'); expect(items[4].classes()).toContain('ui-groupselect__option'); expect(items[5].classes()).toContain('ui-groupselect__option'); }); From f7fc87c9c144ba843890ffad543d1fd0b90fb903 Mon Sep 17 00:00:00 2001 From: Elena Kreleger Date: Fri, 28 Aug 2026 15:18:06 +0200 Subject: [PATCH 04/17] =?UTF-8?q?fix(ui-inputs):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20CSS=20descendant=20selector=20and=20axe-core=20cove?= =?UTF-8?q?rage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten .ui-group{select,combobox}__group ul to a direct-child selector (> ul) so consumer
      elements inside the #option slot are not silently stripped of list-style/padding/margin. Add GroupSelect and GroupCombobox to axe.browser.spec.ts: three real-browser axe-core cases covering the grouped listbox DOM shape (role="group" + role="option" interleaved inside role="listbox"), the committing clear entry, and the filtered combobox path. Co-Authored-By: Claude Sonnet 4.6 --- packages/ui-inputs/styles.css | 4 +- .../tests/browser/axe.browser.spec.ts | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/ui-inputs/styles.css b/packages/ui-inputs/styles.css index 2c7498ff..966622a2 100644 --- a/packages/ui-inputs/styles.css +++ b/packages/ui-inputs/styles.css @@ -566,8 +566,8 @@ padding: 0; margin: 0; } -.ui-groupselect__group ul, -.ui-groupcombobox__group ul { +.ui-groupselect__group > ul, +.ui-groupcombobox__group > ul { list-style: none; padding: 0; margin: 0; diff --git a/packages/ui-inputs/tests/browser/axe.browser.spec.ts b/packages/ui-inputs/tests/browser/axe.browser.spec.ts index 38e6c55f..e7d29e3e 100644 --- a/packages/ui-inputs/tests/browser/axe.browser.spec.ts +++ b/packages/ui-inputs/tests/browser/axe.browser.spec.ts @@ -19,6 +19,8 @@ import CheckboxGroup from '../../src/components/CheckboxGroup.vue'; import Combobox from '../../src/components/Combobox.vue'; import Disclosure from '../../src/components/Disclosure.vue'; import FormField from '../../src/components/FormField.vue'; +import GroupCombobox from '../../src/components/GroupCombobox.vue'; +import GroupSelect from '../../src/components/GroupSelect.vue'; import MultiCombobox from '../../src/components/MultiCombobox.vue'; import MultiSelect from '../../src/components/MultiSelect.vue'; import Pressable from '../../src/components/Pressable.vue'; @@ -36,6 +38,11 @@ const FRUITS: Fruit[] = [ {id: 3, name: 'Mango'}, ]; +const FRUIT_GROUPS: {options: Fruit[]; text: string}[] = [ + {text: 'Tropical', options: [{id: 1, name: 'Watermelon'}, {id: 2, name: 'Mango'}]}, + {text: 'Stone', options: [{id: 3, name: 'Apricot'}]}, +]; + type FieldSlot = {controlId: string; errorId: string; required: boolean; invalid: boolean; describedby?: string}; /** Compose a control inside FormField — the documented consuming shape (label + error wiring). */ @@ -70,6 +77,16 @@ const selectProps = (slot: FieldSlot) => ({ describedby: slot.describedby, }); +// Shared grouped-select prop bag. +const groupSelectProps = (slot: FieldSlot) => ({ + groups: FRUIT_GROUPS, + label: 'name', + id: slot.controlId, + required: slot.required, + invalid: slot.invalid, + describedby: slot.describedby, +}); + describe('axe-core audits — zero violations, closed and open', () => { it('FormField + TextInput, resting', async () => { const screen = renderInField((slot) => @@ -183,6 +200,56 @@ describe('axe-core audits — zero violations, closed and open', () => { await expectNoViolations(screen.container); }); + // WR-0587 group DOM shape: role="group" rows interleaved with role="option" rows inside + // role="listbox" (+ the
        wrapper). audits aria-required-children + // and aria-required-owned which happy-dom specs cannot catch. + it('FormField + GroupSelect, closed and open (grouped listbox — role="group" + role="option" shape)', async () => { + const model = ref(null); + const screen = renderInField((slot) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC in a render-fn host + h(GroupSelect as any, {...groupSelectProps(slot), modelValue: model.value, 'onUpdate:modelValue': noop}), + ); + await expectNoViolations(screen.container); + + await userEvent.click(document.getElementById('fruit') as HTMLElement); + expect(document.querySelector('.ui-groupselect__menu')).not.toBeNull(); + await userEvent.keyboard('{ArrowDown}'); + await expectNoViolations(screen.container); + }); + + // The clear entry is a role="option"
      • rendered ABOVE the groups in the listbox — a DOM + // shape no other audited component uses. Audits that it is a valid listbox child. + it('FormField + GroupSelect, open with a clear entry (committing clear role="option" above groups)', async () => { + const model = ref(2); + const screen = renderInField((slot) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC in a render-fn host + h(GroupSelect as any, { + ...groupSelectProps(slot), + modelValue: model.value, + 'onUpdate:modelValue': noop, + clearLabel: 'Clear selection', + }), + ); + + await userEvent.click(document.getElementById('fruit') as HTMLElement); + expect(document.querySelector('.ui-groupselect__clear')).not.toBeNull(); + await expectNoViolations(screen.container); + }); + + it('FormField + GroupCombobox, closed and open with a typed filter (filtered grouped listbox)', async () => { + const model = ref(null); + const screen = renderInField((slot) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC in a render-fn host + h(GroupCombobox as any, {...groupSelectProps(slot), modelValue: model.value, 'onUpdate:modelValue': noop}), + ); + await expectNoViolations(screen.container); + + await userEvent.click(document.getElementById('fruit') as HTMLElement); + await userEvent.keyboard('ma{ArrowDown}'); + expect(document.querySelector('.ui-groupcombobox__menu')).not.toBeNull(); + await expectNoViolations(screen.container); + }); + // HAND-WRITTEN value assertion — axe cannot check this (WCAG 4.1.2 *Value* is semantic: // there is no axe rule for "N items selected but nothing says so"). A green axe run above // must never be read as covering it; this pins the fix so a revert goes red HERE. From bcd4c12f8471cfb71b467087503bec248cd5a61c Mon Sep 17 00:00:00 2001 From: Elena Kreleger Date: Fri, 28 Aug 2026 15:19:15 +0200 Subject: [PATCH 05/17] =?UTF-8?q?style(ui-inputs):=20oxfmt=20=E2=80=94=20e?= =?UTF-8?q?xpand=20FRUIT=5FGROUPS=20object=20literal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- packages/ui-inputs/tests/browser/axe.browser.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui-inputs/tests/browser/axe.browser.spec.ts b/packages/ui-inputs/tests/browser/axe.browser.spec.ts index e7d29e3e..7558faaa 100644 --- a/packages/ui-inputs/tests/browser/axe.browser.spec.ts +++ b/packages/ui-inputs/tests/browser/axe.browser.spec.ts @@ -39,7 +39,13 @@ const FRUITS: Fruit[] = [ ]; const FRUIT_GROUPS: {options: Fruit[]; text: string}[] = [ - {text: 'Tropical', options: [{id: 1, name: 'Watermelon'}, {id: 2, name: 'Mango'}]}, + { + text: 'Tropical', + options: [ + {id: 1, name: 'Watermelon'}, + {id: 2, name: 'Mango'}, + ], + }, {text: 'Stone', options: [{id: 3, name: 'Apricot'}]}, ]; From 2fd8c4234af859a1cf6b3f905851b4a03eaa240a Mon Sep 17 00:00:00 2001 From: Elena Kreleger Date: Fri, 28 Aug 2026 15:27:20 +0200 Subject: [PATCH 06/17] fix(ui-inputs): move role="group"+aria-label from
      • to inner
          in GroupOptionList html-aria disallows role="group" on
        • (axe aria-allowed-role). The APG grouped listbox pattern is preserved by giving the wrapper
        • role="presentation" and moving role="group" + aria-label to the inner
            , which explicitly allows role="group". AT sees through the presentation hole to the named group as before. Update GroupSelect.spec.ts to assert the corrected structure. Co-Authored-By: Claude Sonnet 4.6 --- .../ui-inputs/src/components/GroupOptionList.vue | 10 ++++++---- packages/ui-inputs/tests/GroupSelect.spec.ts | 15 +++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/ui-inputs/src/components/GroupOptionList.vue b/packages/ui-inputs/src/components/GroupOptionList.vue index 597848bd..941d928c 100644 --- a/packages/ui-inputs/src/components/GroupOptionList.vue +++ b/packages/ui-inputs/src/components/GroupOptionList.vue @@ -18,13 +18,15 @@ listbox child that satisfies role="listbox"'s required owned role="option", while aria-disabled marks it non-selectable. -->
          • {{ emptyText }}
          • -
          @@ -64,6 +68,18 @@ import {computed} from 'vue'; import type {GroupRow} from '../internal/group-rows'; +import ListboxOption from './ListboxOption.vue'; + +/** + * The grouped listbox popup for GroupSelect/GroupCombobox — INTERNAL, not barrel-exported. + * It owns ONLY the group layout (the `role="group"` + `aria-label` wrappers and the headerless + * runs); the option row itself is the shared `ListboxOption`, the same component `OptionList` + * renders, so the `role="option"` / `aria-selected` / `is-active`+`is-muted` / hover+commit + * markup is no longer forked between the flat and grouped bodies. `groupedRuns` turns the flat + * `GroupRow[]` into named/headerless runs; a `boundary` row closes the open run so a + * `header:false` group after a named one renders flat instead of being absorbed. + */ + const { rows, pointer, diff --git a/packages/ui-inputs/src/components/ListboxOption.vue b/packages/ui-inputs/src/components/ListboxOption.vue new file mode 100644 index 00000000..e2657462 --- /dev/null +++ b/packages/ui-inputs/src/components/ListboxOption.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/ui-inputs/src/components/OptionList.vue b/packages/ui-inputs/src/components/OptionList.vue index b77dcb9b..4c88f61a 100644 --- a/packages/ui-inputs/src/components/OptionList.vue +++ b/packages/ui-inputs/src/components/OptionList.vue @@ -31,27 +31,31 @@ the listbox its required option, while aria-disabled marks it non-selectable (the pointer never lands on it — useListbox has no navigable index when empty). -->
        • {{ emptyText }}
        • -
        • + inside ListboxOption, outside the slot, so custom content never re-creates it. --> + - -
        • +
        diff --git a/packages/ui-inputs/src/components/GroupSelect.vue b/packages/ui-inputs/src/components/GroupSelect.vue index 0609ea78..0d74d160 100644 --- a/packages/ui-inputs/src/components/GroupSelect.vue +++ b/packages/ui-inputs/src/components/GroupSelect.vue @@ -37,9 +37,10 @@ positions the ANCHOR, not the
          : the size() middleware sizes it to the trigger, so the menu's `min-width: 100%` measures the trigger. -->
          - - + - +
          @@ -81,7 +82,7 @@ import type {GroupRow} from '../internal/group-rows'; import type {LabelKey, SelectItem} from '../types'; import {useListbox} from '../composables/useListbox'; -import GroupOptionList from './GroupOptionList.vue'; +import OptionList from './OptionList.vue'; const { groups, @@ -141,6 +142,8 @@ const labelOf = (option: T): string => // commit/isSelected/isMuted operate on. Groups are caller-ordered; no alphabeticalSort. const flatOptions = computed(() => groups.flatMap((g) => g.options)); const selected = computed(() => flatOptions.value.find((option) => option.id === model.value)); +// Stable `v-for` keys for OptionList, indexed by the flat option index `rows` navigates. +const optionKeys = computed(() => flatOptions.value.map((option) => String(option.id))); // Build the mixed header/option row sequence. Groups with no options get no header row — // an empty group whose header renders would confuse users and violate the constraint. @@ -159,7 +162,7 @@ const rows = computed(() => { return result; }); -/** `aria-selected` marks the COMMITTED value — GroupOptionList only asks about rendered indices. */ +/** `aria-selected` marks the COMMITTED value — OptionList only asks about rendered indices. */ const isSelected = (index: number): boolean => flatOptions.value[index].id === model.value; /** `.is-muted` marks visual de-emphasis only — a muted option stays committable. */ const isMuted = (index: number): boolean => @@ -170,7 +173,7 @@ const reference = useTemplateRef('reference'); // The teleported `.ui-menu-anchor` (null while closed) — floating-ui's floating element. const floating = useTemplateRef('floating'); -// Both keyboard (Enter via useListbox) and pointer (GroupOptionList `commit`) funnel through +// Both keyboard (Enter via useListbox) and pointer (OptionList `commit`) funnel through // this one guard. Read through a local rather than indexing blind: the clamp watcher normally // keeps `pointer` in range, but a keypress landing between an `groups` change and the watcher // flush would otherwise index off the end. diff --git a/packages/ui-inputs/src/components/ListboxOption.vue b/packages/ui-inputs/src/components/ListboxOption.vue index e2657462..1ad8f2ad 100644 --- a/packages/ui-inputs/src/components/ListboxOption.vue +++ b/packages/ui-inputs/src/components/ListboxOption.vue @@ -14,10 +14,10 @@ diff --git a/packages/ui-inputs/src/components/SingleSelect.vue b/packages/ui-inputs/src/components/SingleSelect.vue index 72b70575..330c3852 100644 --- a/packages/ui-inputs/src/components/SingleSelect.vue +++ b/packages/ui-inputs/src/components/SingleSelect.vue @@ -46,7 +46,7 @@
          import {computed, useTemplateRef} from 'vue'; +import type {GroupRow} from '../internal/group-rows'; import type {LabelKey, SelectItem} from '../types'; import {useListbox} from '../composables/useListbox'; @@ -158,6 +159,9 @@ const sorted = computed(() => // stays the single list every index (pointer, commit, aria) is keyed against. const optionLabels = computed(() => sorted.value.map(labelOf)); const optionKeys = computed(() => sorted.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(() => sorted.value.map((_, index) => ({type: 'option', index}))); /** `aria-selected` marks the COMMITTED value — OptionList only asks about rendered indices. */ const isSelected = (index: number): boolean => sorted.value[index].id === model.value; /** `.is-muted` marks visual de-emphasis only — a muted option stays committable. */ diff --git a/packages/ui-inputs/tests/GroupCombobox.spec.ts b/packages/ui-inputs/tests/GroupCombobox.spec.ts index 83cd5591..a516b7bd 100644 --- a/packages/ui-inputs/tests/GroupCombobox.spec.ts +++ b/packages/ui-inputs/tests/GroupCombobox.spec.ts @@ -250,9 +250,19 @@ describe('GroupCombobox', () => { expect(groupMenu(wrapper).exists()).toBe(false); }); - it('propagates describedby to the input', () => { - const wrapper = mountGroupCombobox({describedby: 'fruit-error'}); - expect(wrapper.find('input').attributes('aria-describedby')).toBe('fruit-error'); + it('propagates required, invalid, and describedby to the input', () => { + const wrapper = mountGroupCombobox({required: true, invalid: true, describedby: 'fruit-error'}); + const input = wrapper.find('input'); + + expect(input.attributes('aria-required')).toBe('true'); + expect(input.attributes('aria-invalid')).toBe('true'); + expect(input.attributes('aria-describedby')).toBe('fruit-error'); + }); + + it('omits aria-required/aria-invalid when the flags are false (the `|| undefined` branch)', () => { + const input = mountGroupCombobox({}).find('input'); + expect(input.attributes('aria-required')).toBeUndefined(); + expect(input.attributes('aria-invalid')).toBeUndefined(); }); it('re-syncs input to committed label when model changes from outside while idle', async () => { diff --git a/packages/ui-inputs/tests/GroupSelect.spec.ts b/packages/ui-inputs/tests/GroupSelect.spec.ts index 99d4178d..c7c7235d 100644 --- a/packages/ui-inputs/tests/GroupSelect.spec.ts +++ b/packages/ui-inputs/tests/GroupSelect.spec.ts @@ -233,7 +233,7 @@ describe('GroupSelect', () => { await wrapper.find('button').trigger('click'); // The header=false group renders its options flat (no group wrapper), through - // GroupOptionList's v-else headerless-run branch — hover then click land on that branch. + // OptionList's v-else headerless-run branch — hover then click land on that branch. const options = groupMenu(wrapper).findAll('.ui-groupselect__option'); await options[1].trigger('mouseover'); // hover Kiwi (index 1) expect(wrapper.find('button').attributes('aria-activedescendant')).toBe('fruit-opt-1'); diff --git a/packages/ui-inputs/tests/browser/interaction.browser.spec.ts b/packages/ui-inputs/tests/browser/interaction.browser.spec.ts index 16cbca82..cdde7450 100644 --- a/packages/ui-inputs/tests/browser/interaction.browser.spec.ts +++ b/packages/ui-inputs/tests/browser/interaction.browser.spec.ts @@ -14,6 +14,8 @@ import {defineComponent, h, ref} from 'vue'; import Checkbox from '../../src/components/Checkbox.vue'; import Combobox from '../../src/components/Combobox.vue'; import Disclosure from '../../src/components/Disclosure.vue'; +import GroupCombobox from '../../src/components/GroupCombobox.vue'; +import GroupSelect from '../../src/components/GroupSelect.vue'; import MultiCombobox from '../../src/components/MultiCombobox.vue'; import MultiSelect from '../../src/components/MultiSelect.vue'; import Pressable from '../../src/components/Pressable.vue'; @@ -32,6 +34,19 @@ const FRUITS: Fruit[] = [ ]; // Sorted render order: Apricot(2), Mango(3), Watermelon(1). +// Grouped controls are CALLER-ordered (no alphabetical sort), so the flat option index runs +// through the groups in declaration order: Watermelon(0), Mango(1), Apricot(2). +const FRUIT_GROUPS: {options: Fruit[]; text: string}[] = [ + { + text: 'Tropical', + options: [ + {id: 1, name: 'Watermelon'}, + {id: 3, name: 'Mango'}, + ], + }, + {text: 'Stone', options: [{id: 2, name: 'Apricot'}]}, +]; + const cleanupTargets: Element[] = []; afterEach(() => { for (const el of cleanupTargets.splice(0)) el.remove(); @@ -66,6 +81,32 @@ const menu = () => document.querySelector('.ui-select__menu, .ui-combobox__menu, .ui-multiselect__menu, .ui-multicombobox__menu'); const optionAt = (index: number): HTMLElement => document.querySelectorAll('[role="option"]')[index]; +/** The grouped-control equivalent of `renderControlled` — feeds `groups`, not `options`. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC in a render-fn host +const renderControlledGroups = (component: any, initial: V, props: Record = {}) => { + const model = ref(initial); + render( + defineComponent( + () => () => + h(component, { + groups: FRUIT_GROUPS, + label: 'name', + id: 'fruit', + ...props, + modelValue: model.value, + 'onUpdate:modelValue': (value: V) => { + model.value = value; + }, + }), + ), + ); + return model; +}; + +const groupMenu = () => document.querySelector('.ui-groupselect__menu, .ui-groupcombobox__menu'); +const groupHeaders = (variant: 'groupselect' | 'groupcombobox'): string[] => + [...document.querySelectorAll(`.ui-${variant}__group-header`)].map((h) => h.textContent?.trim() ?? ''); + describe('SingleSelect — real keyboard walk', () => { it('Tab focuses, Enter opens, ArrowDown navigates, Enter commits, menu closes', async () => { const model = renderControlled(SingleSelect, null, {}); @@ -307,6 +348,68 @@ describe('MultiCombobox — input-as-trigger, real focus choreography', () => { }); }); +describe('GroupSelect — real keyboard walk across grouped options', () => { + it('Enter opens, group headers render, ArrowDown skips headers, Enter commits the flat index', async () => { + const model = renderControlledGroups(GroupSelect, null); + const trigger = document.getElementById('fruit') as HTMLButtonElement; + + await userEvent.tab(); + expect(document.activeElement).toBe(trigger); + expect(groupMenu()).toBeNull(); + + await userEvent.keyboard('{Enter}'); + expect(groupMenu()).not.toBeNull(); + // Both group headers render, in caller order, above their options. + expect(groupHeaders('groupselect')).toEqual(['Tropical', 'Stone']); + + // The keyboard walk rides the FLAT index and never lands on a header: Watermelon(0), + // Mango(1) — the second option, which lives under the same first group. + await userEvent.keyboard('{ArrowDown}{ArrowDown}'); + expect(trigger.getAttribute('aria-activedescendant')).toBe('fruit-opt-1'); + + await userEvent.keyboard('{Enter}'); + expect(model.value).toBe(3); // Mango + expect(groupMenu()).toBeNull(); + expect(trigger.textContent).toContain('Mango'); + }); + + it('a real click commits an option from the SECOND group and closes', async () => { + const model = renderControlledGroups(GroupSelect, null); + + await userEvent.click(document.getElementById('fruit') as HTMLElement); + expect(groupMenu()).not.toBeNull(); + + await userEvent.click(optionAt(2)); // Apricot — the lone Stone-group option + expect(model.value).toBe(2); + expect(groupMenu()).toBeNull(); + }); +}); + +describe('GroupCombobox — real typing filters within groups and commits', () => { + it('typing narrows within groups, empties a group header, ArrowDown highlights, Enter commits', async () => { + const model = renderControlledGroups(GroupCombobox, null); + const input = document.getElementById('fruit') as HTMLInputElement; + + await userEvent.click(input); + expect(groupMenu()).not.toBeNull(); + expect(document.querySelectorAll('[role="option"]')).toHaveLength(3); + expect(groupHeaders('groupcombobox')).toEqual(['Tropical', 'Stone']); + + await userEvent.keyboard('ma'); // only Mango contains 'ma' + expect([...document.querySelectorAll('[role="option"]')].map((o) => o.textContent?.trim())).toEqual(['Mango']); + // Stone drained to nothing — its header is gone, never left dangling above no options. + expect(groupHeaders('groupcombobox')).toEqual(['Tropical']); + + await userEvent.keyboard('{ArrowDown}'); + expect(input.getAttribute('aria-activedescendant')).toBe('fruit-opt-0'); + + await userEvent.keyboard('{Enter}'); + expect(model.value).toBe(3); // Mango + expect(input.value).toBe('Mango'); + expect(groupMenu()).toBeNull(); + }); +}); + describe('RadioGroup — NATIVE roving focus and arrow-key selection', () => { it('Tab enters the group, real arrow keys move focus AND selection, the model follows change', async () => { // The component hand-rolls no keyboard code — this walk proves the browser provides diff --git a/packages/ui-inputs/tests/find-menu.ts b/packages/ui-inputs/tests/find-menu.ts index e047336e..3f6a3f4e 100644 --- a/packages/ui-inputs/tests/find-menu.ts +++ b/packages/ui-inputs/tests/find-menu.ts @@ -1,13 +1,13 @@ import type {VueWrapper} from '@vue/test-utils'; -import GroupOptionList from '../src/components/GroupOptionList.vue'; import OptionList from '../src/components/OptionList.vue'; /** * The listbox is teleported out of the control (KD-1136), so `wrapper.find('.ui-*__menu')` * misses it. OptionList is still in the vnode tree; this is the VTU handle onto its `
            `. + * Every select control — flat and grouped alike — renders the one `OptionList`. */ export const menu = (wrapper: VueWrapper) => wrapper.findComponent(OptionList); -/** GroupOptionList equivalent of `menu` — for GroupSelect and GroupCombobox. */ -export const groupMenu = (wrapper: VueWrapper) => wrapper.findComponent(GroupOptionList); +/** Grouped alias of `menu`, kept for readability at GroupSelect/GroupCombobox call sites. */ +export const groupMenu = menu; From 99b69d150d67edb92c5998ec382856a7a1c3a979 Mon Sep 17 00:00:00 2001 From: Elena Date: Tue, 1 Sep 2026 12:08:11 +0200 Subject: [PATCH 10/17] refactor(ui-inputs): extract buildGroupRows into group-rows.ts GroupSelect and GroupCombobox held the identical GroupRow-derivation loop (header/boundary + option-index pushing), so the header:false boundary fix had to land in both copies. Move the encoding to a single buildGroupRows() helper in src/internal/group-rows.ts and call it from both -- the GroupRow invariant is now single-site. The empty-group skip guard is a no-op on the GroupCombobox path (filteredData already drops empty groups). Behaviour and DOM unchanged; 100% coverage + browser suites held. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/GroupCombobox.vue | 21 ++++--------- .../ui-inputs/src/components/GroupSelect.vue | 21 +++---------- packages/ui-inputs/src/internal/group-rows.ts | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/packages/ui-inputs/src/components/GroupCombobox.vue b/packages/ui-inputs/src/components/GroupCombobox.vue index 6effcdc8..4865740d 100644 --- a/packages/ui-inputs/src/components/GroupCombobox.vue +++ b/packages/ui-inputs/src/components/GroupCombobox.vue @@ -77,10 +77,10 @@ diff --git a/packages/ui-inputs/tests/GroupSelect.spec.ts b/packages/ui-inputs/tests/GroupSelect.spec.ts index dd6743a1..5d01e243 100644 --- a/packages/ui-inputs/tests/GroupSelect.spec.ts +++ b/packages/ui-inputs/tests/GroupSelect.spec.ts @@ -174,6 +174,22 @@ describe('GroupSelect', () => { expect(headerIds.every((id) => id === undefined)).toBe(true); }); + it('closing via trigger click resets pointer so reopening has no stale aria-activedescendant', async () => { + const wrapper = mountGroupSelect({}); + const root = wrapper.find('.ui-groupselect'); + const button = wrapper.find('button'); + + await root.trigger('keydown', {key: 'ArrowDown'}); // open + await root.trigger('keydown', {key: 'ArrowDown'}); // → index 0 (Mango) + expect(button.attributes('aria-activedescendant')).toBe('fruit-opt-0'); + + await button.trigger('click'); // close via toggle + expect(groupMenu(wrapper).exists()).toBe(false); + + await button.trigger('click'); // reopen via toggle + expect(button.attributes('aria-activedescendant')).toBeUndefined(); + }); + it('aria-selected marks the committed option, not the hovered one', async () => { const wrapper = mountGroupSelect({modelValue: 1}); // Mango await wrapper.find('button').trigger('click'); From e32954f824b9b09dd11fdd3fdb624fa43234d12e Mon Sep 17 00:00:00 2001 From: Elena Date: Tue, 15 Sep 2026 10:53:23 +0200 Subject: [PATCH 15/17] docs(ui-inputs): document the group-header theming token surface Adds the six --ui-group-header-* custom properties (GroupSelect / GroupCombobox) to the theming inventory in docs/packages/ui-inputs.md, alongside the Option category. The tokens already ship and are wired in styles.css; the inventory just omitted the new group-header category so consumers could not discover the overrides through the documented contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/packages/ui-inputs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/packages/ui-inputs.md b/docs/packages/ui-inputs.md index 0e5d3c5e..fd103ce1 100644 --- a/docs/packages/ui-inputs.md +++ b/docs/packages/ui-inputs.md @@ -444,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 From db2281da3f485e5638816211ce69b38a2415f707 Mon Sep 17 00:00:00 2001 From: Elena Date: Tue, 15 Sep 2026 11:03:44 +0200 Subject: [PATCH 16/17] fix(ui-inputs): align buildGroupRows option count with parent flatMap buildGroupRows counted options with `for...of`, which visits array holes (yielding undefined), while both grouped parents derive `flatOptions` via `groups.flatMap((g) => g.options)`, which skips holes. A sparse but type-valid T[] therefore emitted option rows whose indices did not exist in flatOptions, so OptionList dereferenced flatOptions[index].id and the popup threw during render. A sparse groups array hit the same divergence one level up. Switch both loops to forEach (hole-skipping, exactly like flatMap's flatten) and defer a group's header/boundary row until it has >=1 real option row, so the row count is definitionally identical to the flattened option list on either vector. Adds a sparse-array render test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ui-inputs/src/internal/group-rows.ts | 32 ++++++++++++------- packages/ui-inputs/tests/GroupSelect.spec.ts | 14 ++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/ui-inputs/src/internal/group-rows.ts b/packages/ui-inputs/src/internal/group-rows.ts index c8aea14c..36ef7cdb 100644 --- a/packages/ui-inputs/src/internal/group-rows.ts +++ b/packages/ui-inputs/src/internal/group-rows.ts @@ -12,26 +12,36 @@ export type GroupRow = {type: 'header'; text: string} | {type: 'boundary'} | {ty * (raw `groups`) and `GroupCombobox` (its filtered groups) so the boundary rule can never * drift between the two again. * - * Only `options.length`, `text`, and `header` are read — never an option's contents — so the - * caller keeps ownership of the option type. Option `index` runs across ALL groups in order, - * matching the flat index every consumer (`pointer`, `isSelected`, the `#option` slot) keys on. + * Only an option's PRESENCE, plus `text` and `header`, are read — never an option's contents — + * so the caller keeps ownership of the option type. Option `index` runs across ALL groups in + * order, matching the flat index every consumer (`pointer`, `isSelected`, the `#option` slot) + * keys on. * - * A group with no options emits NOTHING: an empty group whose header rendered would confuse - * users. (`GroupCombobox` pre-filters empty groups, so that guard is a no-op on its path.) + * Both loops use `forEach`, which skips array HOLES exactly as the parents' `groups.flatMap((g) + * => g.options)` does — so the option-row count is definitionally identical to the flattened + * option list, and a sparse `T[]` can never emit a row index that `flatOptions` lacks (which + * would throw when a consumer dereferences `flatOptions[index]`). `for...of` would visit holes + * as `undefined` and reintroduce that divergence. + * + * A group with no options — none, or all holes — emits NOTHING: an empty group whose header + * rendered would confuse users. (`GroupCombobox` pre-filters empty groups, so that guard is a + * no-op on its path.) */ export const buildGroupRows = ( groups: readonly {options: readonly unknown[]; text: string; header?: boolean}[], ): GroupRow[] => { const rows: GroupRow[] = []; let index = 0; - for (const group of groups) { - if (!group.options.length) continue; + groups.forEach((group) => { + const optionRows: GroupRow[] = []; + group.options.forEach(() => { + optionRows.push({type: 'option', index: index++}); + }); + if (!optionRows.length) return; // A named group emits its header; a headerless group emits a boundary so its options // never fold into the preceding group's role="group". rows.push(group.header !== false ? {type: 'header', text: group.text} : {type: 'boundary'}); - for (const _ of group.options) { - rows.push({type: 'option', index: index++}); - } - } + rows.push(...optionRows); + }); return rows; }; diff --git a/packages/ui-inputs/tests/GroupSelect.spec.ts b/packages/ui-inputs/tests/GroupSelect.spec.ts index 5d01e243..3b211f9b 100644 --- a/packages/ui-inputs/tests/GroupSelect.spec.ts +++ b/packages/ui-inputs/tests/GroupSelect.spec.ts @@ -390,6 +390,20 @@ describe('GroupSelect', () => { expect(groupMenu(wrapper).findAll('.ui-groupselect__option')).toHaveLength(1); }); + it('renders a sparse options array without throwing — holes emit no phantom rows', async () => { + // A sparse `T[]` (holes, not undefined elements) once diverged the two derivations: + // `buildGroupRows` counted holes via `for...of` while the parent's `groups.flatMap` skips + // them, so a row index pointed past `flatOptions` and `flatOptions[index].id` threw on open. + // Both now use `forEach` (hole-skipping), so a hole contributes no row on either side. + const sparse: Fruit[] = [{id: 1, name: 'Mango'}]; + sparse[2] = {id: 3, name: 'Papaya'}; // index 1 is a hole + const wrapper = mountGroupSelect({groups: [{options: sparse, text: 'Tropical'}]}); + await wrapper.find('button').trigger('click'); + + // Two real options, no row for the hole, and no render throw. + expect(groupMenu(wrapper).findAll('.ui-groupselect__option')).toHaveLength(2); + }); + it('hovering the clear entry highlights it (aria-activedescendant points at clear id)', async () => { const wrapper = mountGroupSelect({clearLabel: 'None', modelValue: 1}); await wrapper.find('button').trigger('click'); From 550fc681a98bbc9c2abf382b1aa3f96114ad2b29 Mon Sep 17 00:00:00 2001 From: Elena Kreleger Date: Fri, 18 Sep 2026 11:16:22 +0200 Subject: [PATCH 17/17] chore: update configuration files and tests across multiple packages --- packages/ui-inputs/tests/browser/axe.browser.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui-inputs/tests/browser/axe.browser.spec.ts b/packages/ui-inputs/tests/browser/axe.browser.spec.ts index 7558faaa..03c19d73 100644 --- a/packages/ui-inputs/tests/browser/axe.browser.spec.ts +++ b/packages/ui-inputs/tests/browser/axe.browser.spec.ts @@ -56,8 +56,14 @@ const renderInField = (make: (slot: FieldSlot) => VNode, fieldProps: Record () => - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC in a render-fn host - h(FormField as any, {label: 'Fruit', id: 'fruit', ...fieldProps}, {default: make}), + // Wrap in
            +

            so axe page-level rules (landmark-one-main, + // page-has-heading-one, region) find valid document structure and do not + // fire against the test harness page rather than the component under audit. + h('main', [ + h('h1', 'Test form'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic SFC in a render-fn host + h(FormField as any, {label: 'Fruit', id: 'fruit', ...fieldProps}, {default: make}), + ]), ), );