refactor(ui,stage-ui): FieldSelect, FieldComboSelect split

This commit is contained in:
Neko Ayaka
2026-04-08 03:28:11 +08:00
parent fa5e327117
commit 4179365baa
6 changed files with 424 additions and 3 deletions
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { FieldCombobox } from '@proj-airi/ui'
import { ref } from 'vue'
const basicValue = ref<'option-1' | 'option-2' | 'option-3' | undefined>('option-2')
const customValue = ref<'apple' | 'banana' | 'carrot' | 'spinach' | undefined>('banana')
const emptyValue = ref<'apple' | 'banana' | 'carrot' | 'spinach' | undefined>(undefined)
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 richOptions = [
{
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',
},
{
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',
},
]
</script>
<template>
<Story
title="Field Combobox Select"
group="form"
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant
id="simple"
title="Simple (Plain Value)"
>
<div :class="['max-w-110', 'w-full', 'flex', 'flex-col', 'gap-3']">
<FieldCombobox
v-model="basicValue"
label="Realtime Model"
description="Search and pick from simple options."
:options="basicOptions"
placeholder="Choose a model..."
/>
<p :class="['text-sm', 'text-neutral-600', 'dark:text-neutral-300']">
Selected value: {{ basicValue || 'none' }}
</p>
</div>
</Variant>
<Variant
id="custom-option"
title="Complex (Custom Option Rendering)"
>
<div :class="['max-w-110', 'w-full', 'flex', 'flex-col', 'gap-3']">
<FieldCombobox
v-model="customValue"
label="Ingredient"
description="Custom option content with icon, description, and tag."
:options="richOptions"
placeholder="Search ingredients..."
layout="vertical"
>
<template #option="{ option }">
<div :class="['min-w-0', 'flex', 'w-full', 'items-center', 'justify-between', 'gap-3', 'py-1']">
<div :class="['min-w-0', 'flex', 'items-center', 'gap-2']">
<span
v-if="option.icon"
:class="[
'size-4 shrink-0',
'text-current',
option.icon,
]"
/>
<div :class="['min-w-0', 'flex', 'flex-col']">
<span :class="['truncate']">{{ option.label }}</span>
<span
v-if="option.description"
:class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']"
>
{{ 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']">
{{ option.value }}
</span>
</div>
</template>
</FieldCombobox>
<p :class="['text-sm', 'text-neutral-600', 'dark:text-neutral-300']">
Custom rendered value: {{ customValue || 'none' }}
</p>
</div>
</Variant>
<Variant
id="custom-empty"
title="Custom Empty State"
>
<div :class="['max-w-110', 'w-full', 'flex', 'flex-col', 'gap-3']">
<FieldCombobox
v-model="emptyValue"
label="Ingredient"
description="Provides custom empty content when no match is found."
:options="richOptions"
placeholder="Try typing a non-existing item..."
>
<template #empty>
<div :class="['flex', 'items-center', 'gap-2', 'px-2', 'py-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
<span i-solar:magnifer-zoom-out-linear class="size-4" />
<span>No items match your search.</span>
</div>
</template>
</FieldCombobox>
<p :class="['text-sm', 'text-neutral-600', 'dark:text-neutral-300']">
Selected value: {{ emptyValue || 'none' }}
</p>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { FieldSelect } from '@proj-airi/ui'
import { ref } from 'vue'
const simpleValue = ref<'option-1' | 'option-2' | 'option-3' | undefined>('option-2')
const groupedValue = ref<'apple' | 'banana' | 'carrot' | 'spinach' | undefined>('banana')
const customValue = ref<'apple' | 'banana' | 'carrot' | 'spinach' | undefined>('carrot')
const simpleOptions = [
{ 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',
},
],
},
]
</script>
<template>
<Story
title="Field Select"
group="form"
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant
id="simple"
title="Simple (Plain Value)"
>
<div :class="['max-w-110', 'w-full', 'flex', 'flex-col', 'gap-3']">
<FieldSelect
v-model="simpleValue"
label="Model Preset"
description="Choose a basic preset using plain option rendering."
:options="simpleOptions"
placeholder="Choose a preset..."
/>
<p :class="['text-sm', 'text-neutral-600', 'dark:text-neutral-300']">
Selected value: {{ simpleValue || 'none' }}
</p>
</div>
</Variant>
<Variant
id="grouped"
title="Complex (Grouped Options, Plain Value)"
>
<div :class="['max-w-110', 'w-full', 'flex', 'flex-col', 'gap-3']">
<FieldSelect
v-model="groupedValue"
label="Ingredient"
description="Grouped options with icon and description metadata."
:options="groupedOptions"
placeholder="Pick an ingredient..."
layout="vertical"
/>
<p :class="['text-sm', 'text-neutral-600', 'dark:text-neutral-300']">
Grouped value: {{ groupedValue || 'none' }}
</p>
</div>
</Variant>
<Variant
id="custom-render"
title="Complex (Custom Value And Option Rendering)"
>
<div :class="['max-w-110', 'w-full', 'flex', 'flex-col', 'gap-3']">
<FieldSelect
v-model="customValue"
label="Ingredient"
description="Custom value and option slots for richer presentation."
:options="groupedOptions"
placeholder="Pick an ingredient..."
variant="blurry"
shape="rounded"
>
<template #value="slotProps">
<div :class="['min-w-0', 'flex', 'items-center', 'gap-2', 'px-2', 'py-1']">
<span
:class="[
'size-4 shrink-0',
slotProps.option?.icon ?? 'i-solar:question-circle-linear',
]"
/>
<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']">
<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>
</FieldSelect>
<p :class="['text-sm', 'text-neutral-600', 'dark:text-neutral-300']">
Custom rendered value: {{ customValue || 'none' }}
</p>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts" generic="T extends AcceptableValue">
import type { AcceptableValue } from 'reka-ui'
import { Select } from '../select'
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<{
label: string
description?: string
options?: SelectOptionItem<T>[] | SelectOptionGroupItem<T>[]
placeholder?: string
disabled?: boolean
layout?: 'horizontal' | 'vertical'
selectClass?: string | string[]
by?: string | ((a: T, b: T) => boolean)
contentMinWidth?: string | number
contentWidth?: string | number
shape?: 'rounded' | 'default'
variant?: 'blurry' | 'default'
}>(), {
layout: 'horizontal',
})
const modelValue = defineModel<T>({ required: false })
</script>
<template>
<label :class="['flex', 'flex-col', 'gap-4']">
<div
:class="[
'items-center',
props.layout === 'horizontal' ? 'grid grid-cols-4 gap-2' : 'grid grid-rows-2 gap-2',
]"
>
<div
:class="[
'w-full',
props.layout === 'horizontal' ? 'col-span-2' : 'row-span-2',
]"
>
<div :class="['flex', 'items-center', 'gap-1', 'break-words', 'text-sm', 'font-medium', 'text-left']">
<slot name="label">
{{ props.label }}
</slot>
</div>
<div :class="['break-words', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400', 'text-left']">
<slot name="description">
{{ props.description }}
</slot>
</div>
</div>
<slot>
<Select
v-model="modelValue"
:options="props.options ?? []"
:placeholder="props.placeholder"
:disabled="props.disabled"
:by="props.by"
:content-min-width="props.contentMinWidth"
:content-width="props.contentWidth"
:shape="props.shape"
:variant="props.variant"
:class="[
...(props.selectClass
? (typeof props.selectClass === 'string' ? [props.selectClass] : props.selectClass)
: []),
props.layout === 'horizontal' ? 'col-span-2' : 'row-span-2',
]"
>
<template
v-if="$slots.value"
#value="{ option, value, placeholder: slotPlaceholder }"
>
<slot
name="value"
v-bind="{ option, value, placeholder: slotPlaceholder }"
/>
</template>
<template
v-if="$slots.option"
#option="{ option }"
>
<slot
name="option"
v-bind="{ option }"
/>
</template>
</Select>
</slot>
</div>
</label>
</template>
@@ -1,7 +1,8 @@
export { default as FieldCheckbox } from './field-checkbox.vue'
export { default as FieldCombobox } from './field-combobox.vue'
export { default as FieldCombobox } from './field-combobox-select.vue'
export { default as FieldInput } from './field-input.vue'
export { default as FieldKeyValues } from './field-key-values.vue'
export { default as FieldRange } from './field-range.vue'
export { default as FieldSelect } from './field-select.vue'
export { default as FieldTextArea } from './field-text-area.vue'
export { default as FieldValues } from './field-values.vue'
@@ -112,7 +112,7 @@ function toCssSize(value?: string | number): string | undefined {
<SelectTrigger
:class="[
'group',
'w-full inline-flex items-center justify-between border px-1 leading-none h-fit gap-[5px] outline-none',
'w-full inline-flex items-center justify-between border px-3 leading-none h-9 gap-[5px] outline-none',
props.shape === 'rounded' ? 'rounded-full' : 'rounded-lg',
'text-sm text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-400 dark:data-[placeholder]:text-neutral-500',
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' : '',
@@ -144,7 +144,6 @@ function toCssSize(value?: string | number): string | undefined {
<SelectValue
v-else
v-model="modelValue"
:placeholder="props.placeholder"
/>
</div>
<SelectIcon as-child>