mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-17 02:47:35 +00:00
Compare commits
2 Commits
remove-cac
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b770abea08 | ||
|
|
1d13bd8c45 |
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user