feat(stage-ui): new Combobox component

This commit is contained in:
Neko Ayaka
2025-07-24 03:10:02 +08:00
parent 9c46d9410f
commit fff4598c5e
11 changed files with 209 additions and 140 deletions
@@ -1,15 +1,24 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { FieldCheckbox, FieldSelect, Option, Select } from '@proj-airi/ui'
import { FieldCheckbox, FieldSelect } from '@proj-airi/ui'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
const settings = useSettings()
const { t, messages } = useI18n()
const { allowVisibleOnAllWorkspaces } = storeToRefs(settings)
const dark = useDark()
const languages = computed(() => {
return Object.keys(messages.value).map(lang => ({
label: t(`settings.language.${lang}`),
value: lang,
}))
})
</script>
<template>
@@ -39,6 +48,7 @@ const dark = useDark()
<!-- Language Setting -->
<FieldSelect
v-model="settings.language"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
@@ -47,27 +57,8 @@ const dark = useDark()
transition="all ease-in-out duration-250"
:label="t('settings.language.title')"
:description="t('settings.language.description')"
>
<Select
v-model="settings.language"
transition="all ease-in-out duration-250"
cursor-pointer bg-transparent outline-none
>
<template #default="{ value }">
<div>
{{ value ? $t(`settings.language.${value}`) : t('settings.language.english') }}
</div>
</template>
<template #options="{ hide }">
<Option value="en" :active="locale === 'en'" @click="hide()">
{{ $t('settings.language.english') }}
</Option>
<Option value="zh-Hans" :active="locale === 'zh-Hans'" @click="hide()">
{{ $t('settings.language.chinese') }}
</Option>
</template>
</Select>
</FieldSelect>
:options="languages"
/>
<div
v-motion
@@ -55,6 +55,12 @@ onMounted(async () => {
if (!region.value) {
region.value = 'eastasia' // Default region
}
if (!providers.value[providerId]?.region) {
if (!providers.value[providerId])
providers.value[providerId] = { region: region.value }
else
providers.value[providerId].region = region.value
}
await speechStore.loadVoicesForProvider(providerId)
})
@@ -1,13 +1,21 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { FieldCheckbox, FieldSelect, Option, Select } from '@proj-airi/ui'
import { FieldCheckbox, FieldSelect } from '@proj-airi/ui'
import { useDark } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const settings = useSettings()
const { t, locale } = useI18n()
const { t, messages } = useI18n()
const dark = useDark()
const languages = computed(() => {
return Object.keys(messages.value).map(lang => ({
label: t(`settings.language.options.${lang}`),
value: lang,
}))
})
</script>
<template>
@@ -26,6 +34,7 @@ const dark = useDark()
<!-- Language Setting -->
<FieldSelect
v-model="settings.language"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
@@ -34,27 +43,8 @@ const dark = useDark()
transition="all ease-in-out duration-250"
:label="t('settings.language.title')"
:description="t('settings.language.description')"
>
<Select
v-model="settings.language"
transition="all ease-in-out duration-250"
cursor-pointer bg-transparent outline-none
>
<template #default="{ value }">
<div>
{{ value ? $t(`settings.language.${value}`) : t('settings.language.english') }}
</div>
</template>
<template #options="{ hide }">
<Option value="en" :active="locale === 'en'" @click="hide()">
{{ $t('settings.language.english') }}
</Option>
<Option value="zh-Hans" :active="locale === 'zh-Hans'" @click="hide()">
{{ $t('settings.language.chinese') }}
</Option>
</template>
</Select>
</FieldSelect>
:options="languages"
/>
<div
v-motion
@@ -55,6 +55,12 @@ onMounted(async () => {
if (!region.value) {
region.value = 'eastasia' // Default region
}
if (!providers.value[providerId]?.region) {
if (!providers.value[providerId])
providers.value[providerId] = { region: region.value }
else
providers.value[providerId].region = region.value
}
await speechStore.loadVoicesForProvider(providerId)
})
@@ -253,6 +253,12 @@ watch(dark, updateDropShadowFilter, { immediate: true })
watch([model, themeColorsHue], updateDropShadowFilter)
watch(offset, setScaleAndPosition)
watch(() => props.scale, setScaleAndPosition)
watch(modelFile, () => {
if (modelFile.value) {
loadingModel.value = true
loadModel()
}
}, { immediate: true })
// TODO: This is hacky!
function updateDropShadowFilterLoop() {
@@ -0,0 +1,128 @@
<script setup lang="ts">
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxLabel,
ComboboxRoot,
ComboboxSeparator,
ComboboxTrigger,
ComboboxViewport,
} from 'reka-ui'
const props = defineProps<{
options: { groupLabel: string, children?: { label: string, value: any }[] }[]
placeholder?: string
}>()
function toDisplayValue(value: any): string {
const option = props.options.flatMap(group => group.children).find(option => option?.value === value)
return option ? option.label : props.placeholder || ''
}
</script>
<template>
<ComboboxRoot
class="relative w-full"
>
<ComboboxAnchor
:class="[
'min-w-[160px] w-full inline-flex items-center justify-between rounded-lg border px-[15px] leading-none h-[35px] gap-[5px] outline-none',
'text-xs text-neutral-700 dark:text-neutral-200 data-[placeholder]:text-neutral-200',
'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-300 dark:border-neutral-800 border-solid border-2 focus:border-neutral-400 dark:focus:border-neutral-600',
'shadow-sm focus:shadow-[0_0_0_2px] focus:shadow-black',
'transition-colors duration-200 ease-in-out',
]"
>
<ComboboxInput
:class="[
'!bg-transparent outline-none h-full selection:bg-grass5 placeholder-stone-400',
'text-neutral-700 dark:text-neutral-200',
'transition-colors duration-200 ease-in-out',
]"
:placeholder="props.placeholder"
:display-value="(val) => toDisplayValue(val)"
/>
<ComboboxTrigger>
<div
i-solar:alt-arrow-down-linear
:class="[
'h-4 w-4',
'text-neutral-700 dark:text-neutral-200',
'transition-colors duration-200 ease-in-out',
]"
/>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxContent
:avoid-collisions="true"
:class="[
'absolute z-10 w-full mt-1 min-w-[160px] overflow-hidden rounded-lg shadow-sm border will-change-[opacity,transform] max-h-50dvh',
'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-300 dark:border-neutral-800 border-solid border-2 focus:border-neutral-400 dark:focus:border-neutral-600',
]"
>
<ComboboxViewport class="p-[2px]">
<ComboboxEmpty
:class="[
'font-medium py-2',
'text-xs text-center text-neutral-700 dark:text-neutral-200',
'transition-colors duration-200 ease-in-out',
]"
/>
<template
v-for="(group, index) in options"
:key="group.name"
>
<ComboboxGroup>
<ComboboxSeparator
v-if="index !== 0"
class="m-[5px] h-[1px] bg-neutral-400"
/>
<ComboboxLabel
:class="[
'px-[25px] text-xs leading-[25px]',
'text-neutral-500 dark:text-neutral-400',
'transition-colors duration-200 ease-in-out',
]"
>
{{ group.groupLabel }}
</ComboboxLabel>
<ComboboxItem
v-for="option in group.children"
:key="option.label"
:text-value="option.label"
:value="option.value"
:class="[
'leading-none rounded-[5px] flex items-center h-[25px] pr-[35px] pl-[25px] relative select-none data-[disabled]:pointer-events-none data-[highlighted]:outline-none',
'data-[highlighted]:bg-neutral-100 dark:data-[highlighted]:bg-neutral-800',
'text-xs text-neutral-700 dark:text-neutral-200 data-[disabled]:text-neutral-400 dark:data-[disabled]:text-neutral-600 data-[highlighted]:text-grass1',
'transition-colors duration-200 ease-in-out',
'cursor-pointer',
]"
>
<ComboboxItemIndicator
class="absolute left-0 w-[25px] inline-flex items-center justify-center opacity-30"
>
<div i-solar:check-circle-line-duotone />
</ComboboxItemIndicator>
<span>
{{ option.label }}
</span>
</ComboboxItem>
</ComboboxGroup>
</template>
</ComboboxViewport>
</ComboboxContent>
</ComboboxRoot>
</template>
@@ -0,0 +1 @@
export { default as Combobox } from './Combobox.vue'
@@ -1,14 +1,17 @@
<script setup lang="ts">
import { Select } from '@proj-airi/ui'
const props = defineProps<{
const props = withDefaults(defineProps<{
label: string
description?: string
options?: { label: string, value: string | number }[]
placeholder?: string
disabled?: boolean
layout?: 'horizontal' | 'vertical'
}>()
selectClass?: string | string[]
}>(), {
layout: 'horizontal',
})
const modelValue = defineModel<string>({ required: false })
</script>
@@ -39,6 +42,7 @@ const modelValue = defineModel<string>({ required: false })
:placeholder="props.placeholder"
:disabled="props.disabled"
:title="label"
:class="props.selectClass"
>
<template #default="{ value }">
{{ props.options?.find(option => option.value === value)?.label || props.placeholder }}
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { Dropdown as VDropdown } from 'floating-vue'
import { provide, ref } from 'vue'
import UIOption from './Option.vue'
import Combobox from '../Combobox/Combobox.vue'
const props = defineProps<{
options?: { label: string, value: string | number }[]
@@ -28,94 +27,5 @@ provide('hide', handleHide)
</script>
<template>
<VDropdown
auto-size
auto-boundary-max-size
w-full
>
<div
min-w="[160px]" p="2.5" w-full
class="focus:ring-2 focus:ring-black/10"
border="neutral-300 dark:neutral-800 solid 2 focus:neutral-400 dark:focus:neutral-600"
text="xs sm:sm dark:neutral-200 disabled:neutral-400 dark:disabled:neutral-600 neutral-700"
bg="white dark:neutral-900 disabled:neutral-100 hover:neutral-50 dark:disabled:neutral-900 dark:hover:neutral-800 "
cursor="disabled:not-allowed pointer"
flex items-center gap-2 rounded-lg shadow-sm outline-none transition-colors duration-200 ease-in-out
:class="[
props.disabled ? 'pointer-events-none' : '',
]"
>
<div class="flex-1 truncate">
<slot :value="modelValue">
{{ props.options?.find(item => item.value === modelValue)?.label || modelValue }}
</slot>
</div>
<div i-solar:alt-arrow-down-linear class="h-3.5 w-3.5 text-neutral-500 dark:text-neutral-400" />
</div>
<template #popper="{ hide }">
<div class="min-w-[160px] flex flex-col gap-0.5 border border-neutral-200 rounded-lg bg-white p-1 shadow-lg dark:border-neutral-800 dark:bg-neutral-900 dark:bg-neutral-900">
<slot name="options" :hide="hide">
<template v-if="props.options && props.options.length">
<UIOption
v-for="option of props.options"
:key="option.value"
:value="option.value"
:label="option.label"
:active="modelValue === option.value"
@click="selectOption(option.value); hide()"
/>
</template>
<p v-else class="my-3 text-center text-neutral-500 dark:text-neutral-400">
No data
</p>
</slot>
</div>
</template>
</VDropdown>
<Combobox v-model="modelValue" :options="[{ groupLabel: '', children: props.options }]" />
</template>
<style>
.resize-observer[data-v-b329ee4c] {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
border: none;
background-color: transparent;
pointer-events: none;
display: block;
overflow: hidden;
opacity: 0;
}
.resize-observer[data-v-b329ee4c] object {
display: block;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.v-popper__popper {
z-index: 10000;
top: 0;
left: 0;
outline: none;
}
.v-popper__arrow-container {
display: none;
}
.v-popper__inner {
border: none !important;
}
</style>
+1
View File
@@ -1,4 +1,5 @@
export * from './Checkbox'
export * from './Combobox'
export * from './Field'
export * from './Input'
export * from './Radio'
+26
View File
@@ -189,6 +189,32 @@ export function sharedUnoConfig() {
}
}],
],
theme: {
/**
* https://github.com/unocss/unocss/blob/1031312057a3bea1082b7d938eb2ad640f57613a/packages-presets/preset-wind4/src/theme/animate.ts
* https://unocss.dev/presets/wind4#transformdirectives
*/
animation: {
keyframes: {
slideUpAndFade: '{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}',
slideRightAndFade: '{from{opacity:0;transform:translateX(-2px)}to{opacity:1;transform:translateX(0)}}',
slideDownAndFade: '{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}',
slideLeftAndFade: '{from{opacity:0;transform:translateX(2px)}to{opacity:1;transform:translateX(0)}}',
},
durations: {
slideUpAndFade: '400ms',
slideRightAndFade: '400ms',
slideDownAndFade: '400ms',
slideLeftAndFade: '400ms',
},
timingFns: {
slideUpAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideRightAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideDownAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideLeftAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
},
},
},
})
}