perf(ui): improve the props and customization of Select component
This commit is contained in:
@@ -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="[
|
||||
|
||||
Reference in New Issue
Block a user