feat(ui): now dedicated Select component

This commit is contained in:
Neko Ayaka
2026-03-26 21:09:42 +08:00
parent d1ffd929fb
commit f5dac392d3
6 changed files with 430 additions and 0 deletions
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { Select } from '@proj-airi/ui'
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 disabledValue = ref<'busy' | 'away' | 'offline' | undefined>('away')
const basicOptions = [
{ label: 'Option 1', value: 'option-1' as const },
{ label: 'Option 2', value: 'option-2' as const },
{ label: 'Option 3', value: 'option-3' as const },
]
const groupedOptions = [
{
groupLabel: 'Fruits',
children: [
{
label: 'Apple',
value: 'apple' as const,
icon: 'i-solar:apple-line-duotone',
description: 'Crisp and neutral',
},
{
label: 'Banana',
value: 'banana' as const,
icon: 'i-solar:leaf-line-duotone',
description: 'Soft and familiar',
},
],
},
{
groupLabel: 'Vegetables',
children: [
{
label: 'Carrot',
value: 'carrot' as const,
icon: 'i-solar:cup-star-line-duotone',
description: 'Bright and sweet',
},
{
label: 'Spinach',
value: 'spinach' as const,
icon: 'i-solar:leaf-line-duotone',
description: 'Leafy and dense',
},
],
},
]
const statusOptions = [
{ label: 'Busy', value: 'busy' as const },
{ label: 'Away', value: 'away' as const },
{ label: 'Offline', value: 'offline' as const, disabled: true },
]
</script>
<template>
<Story
title="Select"
group="form"
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant
id="basic"
title="Basic Select"
>
<div :class="['max-w-80', 'w-full', 'flex', 'flex-col', 'gap-3']">
<Select
v-model="basicValue"
:options="basicOptions"
placeholder="Choose an option..."
/>
<p :class="['text-sm', 'text-neutral-600 dark:text-neutral-300']">
Selected: {{ basicValue || 'none' }}
</p>
</div>
</Variant>
<Variant
id="grouped"
title="Grouped Options"
>
<div :class="['max-w-90', 'w-full', 'flex', 'flex-col', 'gap-3']">
<Select
v-model="groupedValue"
:options="groupedOptions"
placeholder="Pick produce"
/>
<p :class="['text-sm', 'text-neutral-600 dark:text-neutral-300']">
Grouped value: {{ groupedValue || 'none' }}
</p>
</div>
</Variant>
<Variant
id="custom-option"
title="Custom Option Rendering"
>
<div :class="['max-w-90', 'w-full', 'flex', 'flex-col', 'gap-3']">
<Select
v-model="customValue"
:options="groupedOptions"
placeholder="Pick produce"
>
<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']">
<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>
<span :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>
</template>
</Select>
<p :class="['text-sm', 'text-neutral-600 dark:text-neutral-300']">
Custom option value: {{ customValue || 'none' }}
</p>
</div>
</Variant>
<Variant
id="disabled"
title="Disabled State"
>
<div :class="['max-w-80', 'w-full', 'flex', 'flex-col', 'gap-3']">
<Select
v-model="disabledValue"
:options="statusOptions"
placeholder="Disabled select"
disabled
/>
<p :class="['text-sm', 'text-neutral-600 dark:text-neutral-300']">
Disabled selection: {{ disabledValue || 'none' }}
</p>
</div>
</Variant>
</Story>
</template>
+1
View File
@@ -58,6 +58,7 @@ import { Button } from '@proj-airi/ui'
* [TransitionVertical](src/components/Animations/TransitionVertical.vue)
* [Form](src/components/Form)
* [Checkbox](src/components/Form/Checkbox)
* [Select](src/components/Form/Select)
* [Field](src/components/Form/Field)
* [Input](src/components/Form/Input)
* [Radio](src/components/Form/Radio)
+1
View File
@@ -5,5 +5,6 @@ export * from './field'
export * from './input'
export * from './radio'
export * from './range'
export * from './select'
export * from './select-tab'
export * from './textarea'
@@ -0,0 +1,2 @@
export { default as SelectOption } from './select-option.vue'
export { default as Select } from './select.vue'
@@ -0,0 +1,89 @@
<script setup lang="ts" generic="T extends AcceptableValue">
import type { AcceptableValue } from 'reka-ui'
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
} from 'reka-ui'
interface SelectOptionItem<T extends AcceptableValue> {
label: string
value: T
description?: string
disabled?: boolean
icon?: string
}
const props = defineProps<{
option: SelectOptionItem<T>
}>()
</script>
<template>
<SelectItem
:value="props.option.value"
: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',
'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',
props.option.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
]"
>
<SelectItemIndicator
:class="[
'col-start-1 row-start-1',
'inline-flex items-center justify-center',
'w-[1.25rem]',
'opacity-30',
'text-current',
]"
>
<div i-solar:alt-arrow-right-outline class="size-4" />
</SelectItemIndicator>
<SelectItemText :class="['sr-only']">
{{ props.option.label }}
</SelectItemText>
<div :class="['col-start-2', 'min-w-0', 'flex', 'items-center', 'gap-2', 'py-1']">
<slot v-bind="{ option: props.option }">
<span
v-if="props.option.icon"
:class="[
'size-4 shrink-0',
'text-current',
props.option.icon,
]"
/>
<div :class="['min-w-0 flex flex-1 flex-col']">
<span
:class="[
'line-clamp-1',
'overflow-hidden',
'text-ellipsis',
'whitespace-nowrap',
]"
>
{{ props.option.label }}
</span>
<span
v-if="props.option.description"
:class="[
'line-clamp-2',
'text-xs',
'text-neutral-500 dark:text-neutral-400',
]"
>
{{ props.option.description }}
</span>
</div>
</slot>
</div>
</SelectItem>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts" generic="T extends AcceptableValue">
import type { AcceptableValue } from 'reka-ui'
import {
SelectArrow,
SelectContent,
SelectGroup,
SelectIcon,
SelectLabel,
SelectPortal,
SelectRoot,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
import { computed } from 'vue'
import SelectOption from './select-option.vue'
interface SelectOptionItem<T extends AcceptableValue> {
label: string
value: T
description?: string
disabled?: boolean
icon?: string
}
interface SelectOptionGroupItem<T extends AcceptableValue> {
groupLabel?: string
children?: SelectOptionItem<T>[]
}
const props = withDefaults(defineProps<{
options: SelectOptionItem<T>[] | SelectOptionGroupItem<T>[]
placeholder?: string
disabled?: boolean
by?: string | ((a: T, b: T) => boolean)
}>(), {
placeholder: 'Select an option',
disabled: false,
by: undefined,
})
const modelValue = defineModel<T>({ required: false })
const normalizedOptions = computed<SelectOptionGroupItem<T>[]>(() => {
if (!props.options.length) {
return []
}
const [firstOption] = props.options
if ('value' in firstOption) {
return [
{
groupLabel: '',
children: props.options as SelectOptionItem<T>[],
},
]
}
return props.options as SelectOptionGroupItem<T>[]
})
</script>
<template>
<SelectRoot
v-model="modelValue"
:by="props.by"
:disabled="props.disabled"
>
<SelectTrigger
:class="[
'group',
'w-full inline-flex items-center justify-between rounded-xl border px-3 leading-none h-9 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',
'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" />
<SelectIcon as-child>
<div
i-solar:alt-arrow-down-linear
:class="[
'h-4 w-4 shrink-0',
'text-neutral-700 dark:text-neutral-200',
'transition-transform duration-200 ease-in-out',
'group-data-[state=open]:rotate-180',
]"
/>
</SelectIcon>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
side="bottom"
align="start"
:side-offset="4"
:avoid-collisions="true"
:class="[
// NOTICE: DialogContent/DialogOverlay use z-[9999], and DrawerContent uses z-[1000].
// SelectContent must render above these layers so that dropdowns inside
// 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]',
'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)' }"
>
<SelectViewport
:class="[
'p-[2px]',
'max-h-50dvh',
'overflow-y-auto',
]"
>
<template
v-for="(group, groupIndex) in normalizedOptions"
:key="group.groupLabel || `group-${groupIndex}`"
>
<SelectGroup :class="['overflow-x-hidden']">
<SelectSeparator
v-if="groupIndex !== 0"
:class="['m-[5px]', 'h-[1px]', 'bg-neutral-200 dark:bg-neutral-800']"
/>
<SelectLabel
v-if="group.groupLabel"
:class="[
'px-[25px] text-xs leading-[25px]',
'text-neutral-500 dark:text-neutral-400',
'transition-colors duration-200 ease-in-out',
]"
>
{{ group.groupLabel }}
</SelectLabel>
<SelectOption
v-for="(option, optionIndex) in group.children || []"
:key="`${group.groupLabel || groupIndex}-${option.label}-${optionIndex}`"
:option="option"
>
<template
v-if="$slots.option"
#default="{ option: slotOption }"
>
<slot
name="option"
v-bind="{ option: slotOption }"
/>
</template>
</SelectOption>
</SelectGroup>
</template>
</SelectViewport>
<SelectArrow
:class="[
'fill-white dark:fill-neutral-900',
'stroke-neutral-200 dark:stroke-neutral-800',
]"
/>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>