perf(ui): improve the props and customization of Select component

This commit is contained in:
Neko Ayaka
2026-03-26 23:32:05 +08:00
parent 1037614a89
commit 7d0e02f57a
5 changed files with 190 additions and 13 deletions
@@ -5,6 +5,7 @@ import { ref } from 'vue'
const basicValue = ref<'option-1' | 'option-2' | 'option-3' | undefined>('option-1')
const groupedValue = ref<'apple' | 'banana' | 'carrot' | 'spinach' | undefined>('banana')
const customValue = ref<'apple' | 'banana' | 'carrot' | 'spinach' | undefined>('carrot')
const emptyCustomValue = ref<'apple' | 'banana' | 'carrot' | 'spinach' | undefined>(undefined)
const disabledValue = ref<'busy' | 'away' | 'offline' | undefined>('away')
const basicOptions = [
@@ -101,7 +102,7 @@ const statusOptions = [
<Variant
id="custom-option"
title="Custom Option Rendering"
title="Custom Option And Value Rendering"
>
<div :class="['max-w-90', 'w-full', 'flex', 'flex-col', 'gap-3']">
<Select
@@ -109,6 +110,36 @@ const statusOptions = [
:options="groupedOptions"
placeholder="Pick produce"
>
<template #value="slotProps">
<div :class="['min-w-0', 'flex', 'items-center', 'gap-2']">
<span
v-if="slotProps.option?.icon"
:class="[
'size-4 shrink-0',
'text-current',
slotProps.option.icon,
]"
/>
<div :class="['min-w-0', 'flex', 'flex-1', 'items-center', 'justify-between', 'gap-2']">
<span
:class="[
'truncate',
slotProps.option
? 'text-neutral-700 dark:text-neutral-200'
: 'text-neutral-400 dark:text-neutral-500',
]"
>
{{ slotProps.option?.label ?? slotProps.placeholder }}
</span>
<span
v-if="slotProps.option"
:class="['rounded-full', 'bg-primary-400/12 dark:bg-primary-400/18', 'px-2', 'py-0.5', 'text-xs', 'text-primary-700 dark:text-primary-200']"
>
{{ slotProps.option.value }}
</span>
</div>
</div>
</template>
<template #option="slotProps">
<div :class="['min-w-0', 'flex', 'flex-1', 'items-center', 'justify-between', 'gap-3', 'py-1']">
<div :class="['min-w-0', 'flex', 'items-center', 'gap-2']">
@@ -137,7 +168,7 @@ const statusOptions = [
</template>
</Select>
<p :class="['text-sm', 'text-neutral-600 dark:text-neutral-300']">
Custom option value: {{ customValue || 'none' }}
Custom rendered value: {{ customValue || 'none' }}
</p>
</div>
</Variant>
@@ -158,5 +189,64 @@ const statusOptions = [
</p>
</div>
</Variant>
<Variant
id="custom-empty-value"
title="Custom Value Placeholder"
>
<div :class="['max-w-90', 'w-full', 'flex', 'flex-col', 'gap-3']">
<Select
v-model="emptyCustomValue"
:options="groupedOptions"
placeholder="Pick produce"
>
<template #value="slotProps">
<div
:class="[
'min-w-0',
'flex',
'items-center',
'gap-2',
slotProps.option ? 'text-neutral-700 dark:text-neutral-200' : 'text-neutral-400 dark:text-neutral-500',
]"
>
<span
:class="[
'size-4 shrink-0',
slotProps.option?.icon ?? 'i-solar:question-circle-linear',
]"
/>
<span :class="['truncate']">
{{ slotProps.option?.label ?? `Nothing selected, ${slotProps.placeholder}` }}
</span>
</div>
</template>
<template #option="slotProps">
<div :class="['min-w-0', 'flex', 'flex-1', 'items-center', 'gap-2.5', 'py-1']">
<span
v-if="slotProps.option.icon"
:class="[
'size-4 shrink-0',
'text-current',
slotProps.option.icon,
]"
/>
<div :class="['min-w-0', 'flex', 'flex-col']">
<span :class="['truncate']">{{ slotProps.option.label }}</span>
<span
v-if="slotProps.option.description"
:class="['text-xs', 'text-neutral-500 dark:text-neutral-400']"
>
{{ slotProps.option.description }}
</span>
</div>
</div>
</template>
</Select>
<p :class="['text-sm', 'text-neutral-600 dark:text-neutral-300']">
Empty custom value: {{ emptyCustomValue || 'none' }}
</p>
</div>
</Variant>
</Story>
</template>
@@ -9,6 +9,8 @@ const props = defineProps<{
disabled?: boolean
title?: string
layout?: 'horizontal' | 'vertical'
contentMinWidth?: string | number
contentWidth?: string | number
}>()
const show = ref(false)
@@ -27,5 +29,11 @@ provide('hide', handleHide)
</script>
<template>
<Combobox v-model="modelValue" :default-value="modelValue" :options="[{ groupLabel: '', children: props.options }]" />
<Combobox
v-model="modelValue"
:default-value="modelValue"
:options="[{ groupLabel: '', children: props.options }]"
:content-min-width="props.contentMinWidth"
:content-width="props.contentWidth"
/>
</template>
@@ -20,6 +20,8 @@ import {
const props = defineProps<{
options: { groupLabel: string, children?: { label: string, value: T }[] }[]
placeholder?: string
contentMinWidth?: string | number
contentWidth?: string | number
}>()
const modelValue = defineModel<T>({ required: false })
@@ -28,6 +30,14 @@ function toDisplayValue(value: T): string {
const option = props.options.flatMap(group => group.children).find(option => option?.value === value)
return option ? option.label : props.placeholder || ''
}
function toCssSize(value?: string | number): string | undefined {
if (value == null) {
return undefined
}
return typeof value === 'number' ? `${value}px` : value
}
</script>
<template>
@@ -76,12 +86,15 @@ function toDisplayValue(value: T): string {
// Dialog/Drawer are not hidden behind the overlay or dismissed unexpectedly.
// Read more at: https://github.com/moeru-ai/airi/issues/1136
'z-[10010]',
'w-full min-w-[160px] overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
'w-full overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
'bg-white dark:bg-neutral-900',
'border-neutral-200 dark:border-neutral-800 border-solid border-2 focus:border-neutral-300 dark:focus:border-neutral-600',
]"
:style="{ width: 'var(--reka-combobox-trigger-width)' }"
:style="{
width: toCssSize(props.contentWidth) ?? 'var(--reka-combobox-trigger-width)',
minWidth: toCssSize(props.contentMinWidth) ?? '160px',
}"
>
<ComboboxViewport :class="['p-[2px]', 'max-h-50dvh', 'overflow-y-auto']">
<ComboboxEmpty
@@ -26,7 +26,7 @@ const props = defineProps<{
:disabled="props.option.disabled"
:text-value="props.option.label"
:class="[
'leading-normal rounded-lg grid grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-2 min-h-8 pr-2 pl-2 relative select-none data-[disabled]:pointer-events-none data-[highlighted]:outline-none',
'leading-normal rounded-lg grid grid-cols-[1rem_minmax(0,1fr)] items-center gap-2 min-h-8 px-2 relative select-none data-[disabled]:pointer-events-none data-[highlighted]:outline-none',
'data-[highlighted]:bg-neutral-100 dark:data-[highlighted]:bg-neutral-800',
'text-sm text-neutral-700 dark:text-neutral-200 data-[disabled]:text-neutral-400 dark:data-[disabled]:text-neutral-600',
'transition-colors duration-200 ease-in-out',
@@ -36,10 +36,16 @@ const props = withDefaults(defineProps<{
placeholder?: string
disabled?: boolean
by?: string | ((a: T, b: T) => boolean)
contentMinWidth?: string | number
contentWidth?: string | number
variant?: 'blurry' | 'default'
}>(), {
placeholder: 'Select an option',
disabled: false,
by: undefined,
contentMinWidth: 160,
contentWidth: undefined,
variant: 'default',
})
const modelValue = defineModel<T>({ required: false })
@@ -61,6 +67,38 @@ const normalizedOptions = computed<SelectOptionGroupItem<T>[]>(() => {
return props.options as SelectOptionGroupItem<T>[]
})
const flattenedOptions = computed<SelectOptionItem<T>[]>(() =>
normalizedOptions.value.flatMap(group => group.children ?? []),
)
const selectedOption = computed<SelectOptionItem<T> | undefined>(() =>
flattenedOptions.value.find(option => isSelectedOption(option.value, modelValue.value)),
)
function isSelectedOption(a: T, b: T | undefined): boolean {
if (b == null) {
return false
}
if (typeof props.by === 'function') {
return props.by(a, b)
}
if (typeof props.by === 'string') {
return (a as Record<string, unknown> | null)?.[props.by] === (b as Record<string, unknown> | null)?.[props.by]
}
return a === b
}
function toCssSize(value?: string | number): string | undefined {
if (value == null) {
return undefined
}
return typeof value === 'number' ? `${value}px` : value
}
</script>
<template>
@@ -72,17 +110,42 @@ const normalizedOptions = computed<SelectOptionGroupItem<T>[]>(() => {
<SelectTrigger
:class="[
'group',
'w-full inline-flex items-center justify-between rounded-xl border px-3 leading-none h-9 gap-[5px] outline-none',
'w-full inline-flex items-center justify-between rounded-xl border px-3 leading-none h-fit gap-[5px] outline-none',
'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-400 dark:data-[placeholder]:text-neutral-500',
'bg-white dark:bg-neutral-900 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700',
'border-neutral-200 dark:border-neutral-800 border-solid border-2 focus:border-primary-300 dark:focus:border-primary-400/50',
props.variant === 'default' ? 'bg-white dark:bg-neutral-900 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700' : '',
props.variant === 'blurry' ? 'bg-neutral-50/70 dark:bg-neutral-800/70 disabled:bg-neutral-100 hover:bg-neutral-50 dark:disabled:bg-neutral-900 dark:hover:bg-neutral-700' : '',
props.variant === 'blurry' ? 'backdrop-blur-md' : '',
'border-2 border-solid focus:border-primary-300 dark:focus:border-primary-400/50',
props.variant === 'default' ? 'border-neutral-200 dark:border-neutral-800' : '',
props.variant === 'blurry' ? 'border-neutral-100/60 dark:border-neutral-800/30' : '',
'shadow-sm focus:shadow-[0_0_0_2px] focus:shadow-black/10 dark:focus:shadow-black/30',
'transition-colors duration-200 ease-in-out',
props.disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
]"
>
<SelectValue :placeholder="props.placeholder" />
<div :class="['min-w-0 flex-1 text-left']">
<slot
v-if="$slots.value"
name="value"
v-bind="{ option: selectedOption, value: modelValue, placeholder: props.placeholder }"
>
<span
:class="[
'block truncate',
selectedOption
? 'text-neutral-700 dark:text-neutral-200'
: 'text-neutral-400 dark:text-neutral-500',
]"
>
{{ selectedOption?.label ?? props.placeholder }}
</span>
</slot>
<SelectValue
v-else
v-model="modelValue"
:placeholder="props.placeholder"
/>
</div>
<SelectIcon as-child>
<div
i-solar:alt-arrow-down-linear
@@ -109,12 +172,15 @@ const normalizedOptions = computed<SelectOptionGroupItem<T>[]>(() => {
// Dialog/Drawer are not hidden behind the overlay or dismissed unexpectedly.
// Read more at: https://github.com/moeru-ai/airi/issues/1136
'z-[10010]',
'min-w-[160px] overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
'overflow-hidden rounded-xl shadow-sm border will-change-[opacity,transform]',
'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
'bg-white dark:bg-neutral-900',
'border-neutral-200 dark:border-neutral-800 border-solid border-2',
]"
:style="{ width: 'var(--reka-select-trigger-width)' }"
:style="{
width: toCssSize(props.contentWidth) ?? 'var(--reka-select-trigger-width)',
minWidth: toCssSize(props.contentMinWidth),
}"
>
<SelectViewport
:class="[