Skip to content

Commit 99b69d1

Browse files
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) <noreply@anthropic.com>
1 parent 19d0210 commit 99b69d1

3 files changed

Lines changed: 39 additions & 33 deletions

File tree

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

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@
7777
<script setup lang="ts" generic="T extends SelectItem">
7878
import {computed, ref, useTemplateRef, watch} from 'vue';
7979
80-
import type {GroupRow} from '../internal/group-rows';
8180
import type {LabelKey, SelectItem} from '../types';
8281
8382
import {useListbox} from '../composables/useListbox';
83+
import {buildGroupRows} from '../internal/group-rows';
8484
import {ensureRefValueExists} from '../internal/reactivity';
8585
import OptionList from './OptionList.vue';
8686
@@ -171,21 +171,10 @@ const filteredOptions = computed(() => filteredData.value.flatMap((g) => g.optio
171171
// Stable `v-for` keys for OptionList, indexed by the flat (filtered) option index `rows` navigates.
172172
const optionKeys = computed(() => filteredOptions.value.map((option) => String(option.id)));
173173
174-
// Build the mixed header/option row sequence from the filtered groups. Empty groups are already
175-
// excluded by filteredData, so we never emit a header row for a group with no visible options.
176-
const filteredRows = computed(() => {
177-
const result: GroupRow[] = [];
178-
let index = 0;
179-
for (const group of filteredData.value) {
180-
// A named group emits its header; a headerless group emits a boundary so its options
181-
// never fold into the preceding group's role="group".
182-
result.push(group.header !== false ? {type: 'header', text: group.text} : {type: 'boundary'});
183-
for (const _ of group.options) {
184-
result.push({type: 'option', index: index++});
185-
}
186-
}
187-
return result;
188-
});
174+
// The mixed header/boundary/option row sequence over the filtered groups — same single-site
175+
// `buildGroupRows` encoding GroupSelect uses (filteredData has already dropped empty groups,
176+
// so its empty-group guard is a no-op here).
177+
const filteredRows = computed(() => buildGroupRows(filteredData.value));
189178
190179
/** `aria-selected` marks the COMMITTED value — OptionList only asks about rendered indices. */
191180
const isSelected = (index: number): boolean => filteredOptions.value[index].id === model.value;

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

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@
7878
<script setup lang="ts" generic="T extends SelectItem">
7979
import {computed, useTemplateRef} from 'vue';
8080
81-
import type {GroupRow} from '../internal/group-rows';
8281
import type {LabelKey, SelectItem} from '../types';
8382
8483
import {useListbox} from '../composables/useListbox';
84+
import {buildGroupRows} from '../internal/group-rows';
8585
import OptionList from './OptionList.vue';
8686
8787
const {
@@ -145,22 +145,9 @@ const selected = computed(() => flatOptions.value.find((option) => option.id ===
145145
// Stable `v-for` keys for OptionList, indexed by the flat option index `rows` navigates.
146146
const optionKeys = computed(() => flatOptions.value.map((option) => String(option.id)));
147147
148-
// Build the mixed header/option row sequence. Groups with no options get no header row —
149-
// an empty group whose header renders would confuse users and violate the constraint.
150-
const rows = computed(() => {
151-
const result: GroupRow[] = [];
152-
let index = 0;
153-
for (const group of groups) {
154-
if (!group.options.length) continue;
155-
// A named group emits its header; a headerless group emits a boundary so its options
156-
// never fold into the preceding group's role="group".
157-
result.push(group.header !== false ? {type: 'header', text: group.text} : {type: 'boundary'});
158-
for (const _ of group.options) {
159-
result.push({type: 'option', index: index++});
160-
}
161-
}
162-
return result;
163-
});
148+
// The mixed header/boundary/option row sequence — the encoding lives once in `buildGroupRows`
149+
// (shared with GroupCombobox), so the boundary/empty-group rules stay a single-site invariant.
150+
const rows = computed(() => buildGroupRows(groups));
164151
165152
/** `aria-selected` marks the COMMITTED value — OptionList only asks about rendered indices. */
166153
const isSelected = (index: number): boolean => flatOptions.value[index].id === model.value;

packages/ui-inputs/src/internal/group-rows.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,33 @@
55
* group's `role="group"`.
66
*/
77
export type GroupRow = {type: 'header'; text: string} | {type: 'boundary'} | {type: 'option'; index: number};
8+
9+
/**
10+
* Turn caller-ordered groups into the flat header/boundary/option row sequence `OptionList`
11+
* lays out — the single-site encoding of the `GroupRow` invariant, shared by `GroupSelect`
12+
* (raw `groups`) and `GroupCombobox` (its filtered groups) so the boundary rule can never
13+
* drift between the two again.
14+
*
15+
* Only `options.length`, `text`, and `header` are read — never an option's contents — so the
16+
* caller keeps ownership of the option type. Option `index` runs across ALL groups in order,
17+
* matching the flat index every consumer (`pointer`, `isSelected`, the `#option` slot) keys on.
18+
*
19+
* A group with no options emits NOTHING: an empty group whose header rendered would confuse
20+
* users. (`GroupCombobox` pre-filters empty groups, so that guard is a no-op on its path.)
21+
*/
22+
export const buildGroupRows = (
23+
groups: readonly {options: readonly unknown[]; text: string; header?: boolean}[],
24+
): GroupRow[] => {
25+
const rows: GroupRow[] = [];
26+
let index = 0;
27+
for (const group of groups) {
28+
if (!group.options.length) continue;
29+
// A named group emits its header; a headerless group emits a boundary so its options
30+
// never fold into the preceding group's role="group".
31+
rows.push(group.header !== false ? {type: 'header', text: group.text} : {type: 'boundary'});
32+
for (const _ of group.options) {
33+
rows.push({type: 'option', index: index++});
34+
}
35+
}
36+
return rows;
37+
};

0 commit comments

Comments
 (0)