Skip to content

Commit 202f7a0

Browse files
dante01yoonclaude
andcommitted
refactor: rewrite MultiSelect with ComboboxRoot instead of Popover+Listbox
Replace the Popover+Listbox workaround with Reka UI's ComboboxRoot: - Built-in multi-selection, keyboard nav, and ARIA roles - ComboboxInput for integrated search (replaces nested SearchInput) - CSS-driven checkbox styling via group-data-[state] (no isSelected()) - Trigger width matching via --reka-combobox-trigger-width CSS var - Removes useElementSize and manual isOpen/isSelected state management Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 15e0aa7 commit 202f7a0

File tree

1 file changed

+73
-84
lines changed

1 file changed

+73
-84
lines changed

src/components/input/MultiSelect.vue

Lines changed: 73 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
<template>
2-
<PopoverRoot v-model:open="isOpen">
3-
<PopoverTrigger as-child>
4-
<button
5-
ref="triggerRef"
2+
<ComboboxRoot
3+
v-model="selectedItems"
4+
multiple
5+
by="value"
6+
:disabled
7+
ignore-filter
8+
:reset-search-term-on-select="false"
9+
>
10+
<ComboboxAnchor as-child>
11+
<ComboboxTrigger
612
v-bind="$attrs"
7-
type="button"
8-
:disabled
913
:aria-label="label || t('g.multiSelectDropdown')"
10-
role="combobox"
11-
:aria-expanded="isOpen"
12-
aria-haspopup="listbox"
13-
:tabindex="0"
1414
:class="
1515
cn(
1616
'relative inline-flex cursor-pointer items-center select-none',
@@ -49,14 +49,14 @@
4949
>
5050
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
5151
</div>
52-
</button>
53-
</PopoverTrigger>
52+
</ComboboxTrigger>
53+
</ComboboxAnchor>
5454

55-
<PopoverPortal>
56-
<PopoverContent
55+
<ComboboxPortal>
56+
<ComboboxContent
57+
position="popper"
5758
:side-offset="8"
5859
align="start"
59-
:style="{ minWidth: contentMinWidth }"
6060
:class="
6161
cn(
6262
'z-3000 overflow-hidden',
@@ -70,19 +70,29 @@
7070
'data-[side=bottom]:slide-in-from-top-2'
7171
)
7272
"
73-
@open-auto-focus.prevent
7473
>
7574
<div
7675
v-if="showSearchBox || showSelectedCount || showClearButton"
7776
class="flex flex-col px-2 pt-2 pb-0"
7877
>
79-
<SearchInput
78+
<div
8079
v-if="showSearchBox"
81-
v-model="searchQuery"
82-
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
83-
:placeholder="searchPlaceholder"
84-
size="sm"
85-
/>
80+
:class="
81+
cn(
82+
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
83+
showSelectedCount || showClearButton ? 'mb-2' : ''
84+
)
85+
"
86+
>
87+
<i
88+
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
89+
/>
90+
<ComboboxInput
91+
v-model="searchQuery"
92+
:placeholder="searchPlaceholder"
93+
class="w-full border-none bg-transparent text-sm outline-none"
94+
/>
95+
</div>
8696
<div
8797
v-if="showSelectedCount || showClearButton"
8898
class="mt-2 flex items-center justify-between"
@@ -109,79 +119,68 @@
109119
<div class="my-4 h-px bg-border-default" />
110120
</div>
111121

112-
<ListboxRoot
113-
v-model="selectedItems"
114-
multiple
115-
by="value"
122+
<ComboboxViewport
116123
:class="
117124
cn(
118-
'flex flex-col gap-0 p-0 text-sm outline-none',
119-
'scrollbar-custom overflow-y-auto'
125+
'flex flex-col gap-0 p-0 text-sm',
126+
'scrollbar-custom overflow-y-auto',
127+
'min-w-(--reka-combobox-trigger-width)'
120128
)
121129
"
122130
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
123131
>
124-
<ListboxContent>
125-
<ListboxItem
126-
v-for="opt in filteredOptions"
127-
:key="opt.value"
128-
:value="opt"
129-
:class="
130-
cn(
131-
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
132-
'hover:bg-secondary-background-hover',
133-
'focus:bg-secondary-background-selected focus:hover:bg-secondary-background-selected'
134-
)
135-
"
136-
:style="popoverStyle"
132+
<ComboboxItem
133+
v-for="opt in filteredOptions"
134+
:key="opt.value"
135+
:value="opt"
136+
:class="
137+
cn(
138+
'group flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
139+
'hover:bg-secondary-background-hover',
140+
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
141+
)
142+
"
143+
:style="popoverStyle"
144+
>
145+
<div
146+
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background"
137147
>
138-
<div
139-
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
140-
:class="
141-
isSelected(opt)
142-
? 'bg-primary-background'
143-
: 'bg-secondary-background'
144-
"
145-
>
148+
<ComboboxItemIndicator>
146149
<i
147-
v-if="isSelected(opt)"
148150
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
149151
/>
150-
</div>
151-
<span>{{ opt.name }}</span>
152-
</ListboxItem>
153-
<div
154-
v-if="filteredOptions.length === 0"
155-
class="px-3 pb-4 text-sm text-muted-foreground"
156-
>
157-
{{ $t('g.noResultsFound') }}
152+
</ComboboxItemIndicator>
158153
</div>
159-
</ListboxContent>
160-
</ListboxRoot>
161-
</PopoverContent>
162-
</PopoverPortal>
163-
</PopoverRoot>
154+
<span>{{ opt.name }}</span>
155+
</ComboboxItem>
156+
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
157+
{{ $t('g.noResultsFound') }}
158+
</ComboboxEmpty>
159+
</ComboboxViewport>
160+
</ComboboxContent>
161+
</ComboboxPortal>
162+
</ComboboxRoot>
164163
</template>
165164

166165
<script setup lang="ts">
167166
import { useFuse } from '@vueuse/integrations/useFuse'
168167
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
169-
import { useElementSize } from '@vueuse/core'
170168
import {
171-
ListboxContent,
172-
ListboxItem,
173-
ListboxRoot,
174-
PopoverContent,
175-
PopoverPortal,
176-
PopoverRoot,
177-
PopoverTrigger
169+
ComboboxAnchor,
170+
ComboboxContent,
171+
ComboboxEmpty,
172+
ComboboxInput,
173+
ComboboxItem,
174+
ComboboxItemIndicator,
175+
ComboboxPortal,
176+
ComboboxRoot,
177+
ComboboxTrigger,
178+
ComboboxViewport
178179
} from 'reka-ui'
179-
import type { ComponentPublicInstance } from 'vue'
180-
import { computed, ref } from 'vue'
180+
import { computed } from 'vue'
181181
import { useI18n } from 'vue-i18n'
182182
183183
import Button from '@/components/ui/button/Button.vue'
184-
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
185184
import { usePopoverSizing } from '@/composables/usePopoverSizing'
186185
import { cn } from '@/utils/tailwindUtil'
187186
@@ -234,23 +233,13 @@ const selectedItems = defineModel<SelectOption[]>({
234233
const searchQuery = defineModel<string>('searchQuery', { default: '' })
235234
236235
const { t } = useI18n()
237-
const isOpen = ref(false)
238-
const triggerRef = ref<ComponentPublicInstance | null>(null)
239-
const { width: triggerWidth } = useElementSize(triggerRef)
240-
const contentMinWidth = computed(() =>
241-
triggerWidth.value > 0 ? `${triggerWidth.value}px` : undefined
242-
)
243236
const selectedCount = computed(() => selectedItems.value.length)
244237
245238
const popoverStyle = usePopoverSizing({
246239
minWidth: popoverMinWidth,
247240
maxWidth: popoverMaxWidth
248241
})
249242
250-
function isSelected(opt: SelectOption): boolean {
251-
return selectedItems.value.some((item) => item.value === opt.value)
252-
}
253-
254243
const fuseOptions: UseFuseOptions<SelectOption> = {
255244
fuseOptions: {
256245
keys: ['name', 'value'],

0 commit comments

Comments
 (0)