mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
feat(ui): now dedicated Select component
This commit is contained in:
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user