Compare commits

...

2 Commits

Author SHA1 Message Date
GitHub Action
b770abea08 [automated] Apply ESLint and Oxfmt fixes 2026-03-10 05:40:14 +00:00
CodeRabbit Fixer
1d13bd8c45 fix: refactor: Rebuild MultiSelect and SingleSelect components with Reka UI (remove PrimeVue dependency) (#9700)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 06:36:47 +01:00
2 changed files with 265 additions and 334 deletions

View File

@@ -1,191 +1,173 @@
<template>
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
v-model="selectedItems"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger
:disabled
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
'focus:border-base-foreground',
disabled && 'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
emptyMessage: {
class: 'px-3 pb-4 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="isOpen"
aria-haspopup="listbox"
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
:style="popoverStyle"
:class="
cn(
'flex flex-1 items-center whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
"
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>
{{ slotProps.option.name }}
{{ selectedCount }}
</span>
</div>
</template>
</MultiSelect>
<span
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</span>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
:side-offset="8"
:collision-padding="10"
:style="popoverStyle"
:class="
cn(
'z-3000 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<div
v-if="showSearchBox || showSelectedCount || showClearButton"
class="flex flex-col px-2 pt-2 pb-0"
>
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default"></div>
</div>
<ListboxRoot
v-model="selectedItems"
multiple
selection-behavior="toggle"
by="value"
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
class="flex scrollbar-custom flex-col gap-0 overflow-auto"
>
<ListboxContent>
<ListboxItem
v-for="option in filteredOptions"
:key="option.value"
:value="option"
:class="
cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2 text-sm outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
isSelected(option)
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="isSelected(option)"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>{{ option.name }}</span>
</ListboxItem>
</ListboxContent>
</ListboxRoot>
<p
v-if="filteredOptions.length === 0"
class="px-3 pb-4 text-sm text-muted-foreground"
>
{{ $t('g.noResults') }}
</p>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import {
ListboxContent,
ListboxItem,
ListboxRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
@@ -193,15 +175,27 @@ import type { SelectOption } from './types'
type Option = SelectOption
defineOptions({
inheritAttrs: false
})
interface Props {
const {
label,
options = [],
size = 'lg',
disabled = false,
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
/** Input label shown on the trigger button */
label?: string
/** Available options */
options?: Option[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Disable the select */
disabled?: boolean
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -216,26 +210,14 @@ interface Props {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
}>()
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const isOpen = ref(false)
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
@@ -243,10 +225,11 @@ const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
// Use VueUse's useFuse for better reactivity and performance
function isSelected(option: Option): boolean {
return selectedItems.value.some((item) => item.value === option.value)
}
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
@@ -256,20 +239,17 @@ const fuseOptions: UseFuseOptions<Option> = {
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
const { results } = useFuse(searchQuery, () => options, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return originalOptions.value
return options
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: Option) => result.value === item.value)

View File

@@ -1,21 +1,8 @@
<template>
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
<SelectRoot v-model="selectedItem" :disabled>
<SelectTrigger
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
@@ -25,73 +12,21 @@
'border-[2.5px] border-solid',
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
: 'border-transparent focus:border-node-component-border',
'focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
)
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
>
<div
:class="
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
cn(
'flex flex-1 items-center gap-2 whitespace-nowrap',
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
)
"
>
<i
@@ -99,42 +34,78 @@
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
{{ label }}
</span>
<SelectValue :placeholder="label" />
</div>
</template>
<SelectIcon v-if="!loading" as-child>
<i
class="icon-[lucide--chevron-down] shrink-0 px-3 text-muted-foreground"
/>
</SelectIcon>
</SelectTrigger>
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div
class="flex w-full items-center justify-between gap-3"
:style="optionStyle"
<SelectPortal>
<SelectContent
position="popper"
:class="
cn(
'z-3000 mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
</div>
</template>
</Select>
<SelectViewport
:style="viewportStyle"
class="flex scrollbar-custom flex-col gap-0"
>
<SelectItem
v-for="option in options"
:key="option.value"
:value="option.value"
:class="
cn(
'flex w-full cursor-pointer items-center justify-between gap-3 rounded-sm px-2 py-3 text-sm outline-none select-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
>
<SelectItemText class="truncate">
{{ option.name }}
</SelectItemText>
<SelectItemIndicator>
<i
class="icon-[lucide--check] text-base-foreground"
aria-hidden="true"
/>
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
<script setup lang="ts">
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import {
SelectContent,
SelectIcon,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -142,26 +113,18 @@ import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
defineOptions({
inheritAttrs: false
})
const {
label,
options,
size = 'lg',
invalid = false,
loading = false,
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
@@ -169,6 +132,8 @@ const {
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Disable the select */
disabled?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -181,26 +146,12 @@ const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
const viewportStyle = computed(() => {
const styles: Record<string, string> = {
maxHeight: `min(${listMaxHeight}, 50vh)`
}
if (popoverMinWidth) styles.minWidth = popoverMinWidth
if (popoverMaxWidth) styles.maxWidth = popoverMaxWidth
return styles
})
</script>