Skip to content

Commit ff97afd

Browse files
committed
feat: support toggling multi-select groups in combobox
1 parent 63212a7 commit ff97afd

File tree

7 files changed

+519
-12
lines changed

7 files changed

+519
-12
lines changed

src/components/Combobox.vue

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Combobox, ComboboxButton, ComboboxInput } from '@headlessui/vue'
44
55
import type { Option } from './Option.vue'
66
import { OptionGroup, areOptionsGrouped } from './OptionGroup.vue'
7+
import { GROUP_VALUE_PREFIX } from './SelectableOptionGroup.vue'
78
import ATag from './Tag.vue'
89
import AColorCircle from './ColorCircle.vue'
910
import OptionsPanel, { Direction } from './internal/OptionsPanel.vue'
@@ -124,6 +125,15 @@ export default defineComponent({
124125
collapsedTagsLabel: {
125126
type: Function as PropType<(count: number) => string>,
126127
default: (count: number) => `+${count} more`
128+
},
129+
/**
130+
* When true and the combobox is in multi-select mode with grouped options,
131+
* displays a CheckboxSelectAll in each group header to select/clear all
132+
* options in that group.
133+
*/
134+
selectableGroups: {
135+
type: Boolean,
136+
default: false
127137
}
128138
},
129139
emits: ['update:modelValue', 'update:query'],
@@ -146,11 +156,41 @@ export default defineComponent({
146156
}
147157
}
148158
159+
// toggle all options in a group (select all if not all selected, otherwise clear)
160+
const handleGroupToggle = (options: Option[]) => {
161+
if (!isMultiSelect(props.modelValue)) return
162+
163+
const values = options.filter(o => !o.disabled).map(o => o.value)
164+
const currentSet = new Set(props.modelValue)
165+
const allSelected = values.every(v => currentSet.has(v))
166+
167+
const newSelection = allSelected
168+
? props.modelValue.filter(v => !values.includes(v))
169+
: [...new Set([...props.modelValue, ...values])]
170+
171+
emit('update:modelValue', newSelection)
172+
clearQuery()
173+
}
174+
149175
const model = computed({
150176
get: () => {
151177
return props.modelValue
152178
},
153179
set: value => {
180+
// handle keyboard selection of group headers (which have __group__ prefix)
181+
if (isMultiSelect(value) && areOptionsGrouped(props.options)) {
182+
const groupValue = value.find(v => v.startsWith(GROUP_VALUE_PREFIX))
183+
if (groupValue) {
184+
// extract group title and toggle its options
185+
const title = groupValue.slice(GROUP_VALUE_PREFIX.length)
186+
const group = props.options.find(g => g.title === title)
187+
if (group) {
188+
handleGroupToggle(group.options)
189+
}
190+
return
191+
}
192+
}
193+
154194
emit('update:modelValue', value)
155195
clearQuery()
156196
}
@@ -245,6 +285,13 @@ export default defineComponent({
245285
return Math.max(0, model.value.length - props.maxTags)
246286
})
247287
288+
const showSelectableGroups = computed(
289+
() =>
290+
props.selectableGroups &&
291+
isMultiSelect(props.modelValue) &&
292+
areOptionsGrouped(props.options)
293+
)
294+
248295
const handleDelete = () => {
249296
if (isMultiSelect(model.value) && !query.value) {
250297
model.value = model.value.slice(0, -1)
@@ -279,7 +326,8 @@ export default defineComponent({
279326
handleBlur,
280327
container,
281328
visibleValues,
282-
hiddenCount
329+
hiddenCount,
330+
showSelectableGroups
283331
}
284332
}
285333
})
@@ -367,7 +415,9 @@ export default defineComponent({
367415
:multi="isMultiSelect"
368416
:inline="inline"
369417
:escape-overflow="escapeOverflow"
370-
:width="optionsPanelWidth">
418+
:width="optionsPanelWidth"
419+
:selectable-groups="showSelectableGroups"
420+
:selected-values="isMultiSelect ? (model as string[]) : []">
371421
<template #default>
372422
<!-- @slot `#default` slot. Takes `a-option` or `a-option-group` components without any wrappers. Use this slot to render options with extra styles or markup. Default value: `options` prop rendered as `a-option`s or `a-option-group`s -->
373423
<slot :filtered-options="filteredOptions"></slot>
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<script lang="ts">
2+
import { defineComponent, PropType, computed } from 'vue'
3+
import { ComboboxOption } from '@headlessui/vue'
4+
import ASeparator from './Separator.vue'
5+
import { Option } from './Option.vue'
6+
import AOption from './Option.vue'
7+
import ACheckbox from './Checkbox.vue'
8+
9+
export const GROUP_VALUE_PREFIX = '__group__'
10+
11+
export default defineComponent({
12+
name: 'ASelectableOptionGroup',
13+
components: { ASeparator, AOption, ACheckbox, ComboboxOption },
14+
props: {
15+
/**
16+
* the option group title
17+
*/
18+
title: {
19+
type: String,
20+
required: true
21+
},
22+
/**
23+
* list of options in this group
24+
*/
25+
options: {
26+
type: Array as PropType<Option[]>,
27+
required: true
28+
},
29+
/**
30+
* current selection values (for computing checkbox state)
31+
*/
32+
modelValue: {
33+
type: Array as PropType<string[]>,
34+
default: () => []
35+
},
36+
/**
37+
* parent component type. Only 'combobox' is supported because this component
38+
* uses Headless UI's ComboboxOption for keyboard navigation.
39+
* This prop exists for API consistency with AOptionGroup.
40+
*/
41+
component: {
42+
type: String as PropType<'combobox'>,
43+
default: 'combobox',
44+
validator: (value: string) => value === 'combobox'
45+
}
46+
},
47+
setup(props) {
48+
const titleId = computed(() => `${props.title}-option-group`)
49+
50+
// used by Headless UI for keyboard navigation AND click selection
51+
const groupValue = computed(() => `${GROUP_VALUE_PREFIX}${props.title}`)
52+
53+
const enabledOptions = computed(() => props.options.filter(o => !o.disabled))
54+
55+
const selectedCount = computed(() => {
56+
const selectedSet = new Set(props.modelValue)
57+
return props.options.filter(o => selectedSet.has(o.value)).length
58+
})
59+
60+
const areAllSelected = computed(
61+
() => selectedCount.value === enabledOptions.value.length && enabledOptions.value.length > 0
62+
)
63+
64+
const isPartiallySelected = computed(
65+
() => selectedCount.value > 0 && selectedCount.value < enabledOptions.value.length
66+
)
67+
68+
return {
69+
titleId,
70+
groupValue,
71+
areAllSelected,
72+
isPartiallySelected
73+
}
74+
}
75+
})
76+
</script>
77+
78+
<template>
79+
<div role="group" :aria-labelledby="titleId" class="a-selectable-option-group">
80+
<!-- used to focus the group header via keyboard -->
81+
<ComboboxOption v-slot="{ active }" as="template" :value="groupValue">
82+
<div
83+
:id="titleId"
84+
class="a-selectable-option-group__header flex min-h-8 items-center capitalize text-warsaw body-xs-600 px-2 cursor-pointer"
85+
:class="{ 'bg-athens a-selectable-option-group__header--active': active }">
86+
<!--
87+
@slot Named `#group-title` slot. Use to render custom styles or extra markup as the option group title.
88+
Renders a checkbox with the title prop by default.
89+
-->
90+
<slot name="group-title">
91+
<ACheckbox readonly :model-value="areAllSelected" :mixed="isPartiallySelected">
92+
<span class="capitalize text-warsaw body-xs-600">{{ title }}</span>
93+
</ACheckbox>
94+
</slot>
95+
</div>
96+
</ComboboxOption>
97+
98+
<div class="a-selectable-option-group__options [&_[role='option']]:pl-4">
99+
<!--
100+
@slot `#default` slot. Takes `a-option` components without any wrappers.
101+
Use this slot to render combobox options with extra styles or markup.
102+
103+
Default value: `options` prop rendered as `a-option`s with `multi` enabled
104+
-->
105+
<slot>
106+
<AOption
107+
v-for="option in options"
108+
:key="option.value"
109+
component="combobox"
110+
multi
111+
:option="option" />
112+
</slot>
113+
</div>
114+
</div>
115+
<a-separator class="my-2 last-of-type:hidden" />
116+
</template>
117+
118+
<style>
119+
.a-selectable-option-group__header:hover {
120+
@apply bg-athens;
121+
}
122+
123+
/* when header is hovered or focused via keyboard, highlight child options */
124+
.a-selectable-option-group:has(.a-selectable-option-group__header:hover)
125+
.a-selectable-option-group__options
126+
[role='option'],
127+
.a-selectable-option-group:has(.a-selectable-option-group__header--active)
128+
.a-selectable-option-group__options
129+
[role='option'] {
130+
@apply bg-athens/50;
131+
}
132+
</style>

0 commit comments

Comments
 (0)