refactor(ui,stage-ui): FieldSelect, FieldComboSelect split
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user