fix(stage-ui): incorrect size and style of Profile Selector

This commit is contained in:
Neko Ayaka
2026-03-26 23:32:05 +08:00
parent 7d0e02f57a
commit aa8834db69
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Select } from '@proj-airi/ui'
import { onClickOutside } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, toRaw, watch } from 'vue'
@@ -6,17 +7,18 @@ import { useI18n } from 'vue-i18n'
import { useAiriCardStore } from '../../stores/modules/airi-card'
export interface Props {
// 'down': opens below the trigger (for elements near the top, e.g. header)
// 'up': opens above the trigger (for elements near the bottom, e.g. floating island)
placement?: 'down' | 'up'
}
withDefaults(defineProps<Props>(), { placement: 'down' })
const emit = defineEmits<{
(e: 'manage'): void
}>()
const CREATE_PROFILE_ACTION = '__create-profile__'
const MANAGE_PROFILE_ACTION = '__manage-profile__'
type ProfileSelectValue = string | typeof CREATE_PROFILE_ACTION | typeof MANAGE_PROFILE_ACTION
export interface Props {
placement?: 'down' | 'up'
}
const open = defineModel<boolean>('open', { default: false })
@@ -27,32 +29,87 @@ const { cards, activeCardId, activeCard } = storeToRefs(cardStore)
const creatingNew = ref(false)
const newProfileName = ref('')
const nameInputRef = ref<HTMLInputElement>()
const popoverRef = ref<HTMLElement>()
onClickOutside(popoverRef, () => {
open.value = false
})
watch(open, (isOpen) => {
if (!isOpen)
cancelCreate()
})
const containerRef = ref<HTMLElement>()
const cardsList = computed(() =>
Array.from(cards.value.entries()).map(([id, card]) => ({ id, name: card.name })),
)
const selectedProfile = ref<ProfileSelectValue | undefined>(activeCardId.value)
const isDuplicateName = computed(() => {
const name = newProfileName.value.trim()
return name !== '' && Array.from(cards.value.values()).some(c => c.name === name)
return name !== '' && Array.from(cards.value.values()).some(card => card.name === name)
})
function activateCard(id: string) {
activeCardId.value = id
const selectOptions = computed(() => [
{
groupLabel: cardsList.value.length ? undefined : t('stage.profile-switcher.no-profiles'),
children: cardsList.value.map(card => ({
label: card.name,
value: card.id,
icon: card.id === activeCardId.value
? 'i-solar:check-circle-bold-duotone'
: 'i-solar:emoji-funny-square-broken',
})),
},
{
groupLabel: '',
children: [
{
label: t('stage.profile-switcher.save-as-new'),
value: CREATE_PROFILE_ACTION,
icon: 'i-solar:add-circle-bold-duotone',
},
{
label: t('stage.profile-switcher.manage'),
value: MANAGE_PROFILE_ACTION,
icon: 'i-solar:settings-minimalistic-bold-duotone',
},
],
},
])
watch(open, (isOpen) => {
if (!isOpen) {
cancelCreate()
}
})
watch(activeCardId, (value) => {
selectedProfile.value = value
}, { immediate: true })
watch(selectedProfile, (value, previousValue) => {
if (!value || value === previousValue) {
return
}
handleSelection(value)
})
onClickOutside(containerRef, () => {
open.value = false
cancelCreate()
})
function handleSelection(value: ProfileSelectValue) {
if (value === CREATE_PROFILE_ACTION) {
selectedProfile.value = activeCardId.value
void showCreateInput()
return
}
if (value === MANAGE_PROFILE_ACTION) {
selectedProfile.value = activeCardId.value
handleManage()
return
}
activeCardId.value = value
}
async function showCreateInput() {
open.value = false
creatingNew.value = true
newProfileName.value = activeCard.value?.name ?? ''
await nextTick()
@@ -63,20 +120,17 @@ async function showCreateInput() {
function confirmCreate() {
const current = activeCard.value
const name = newProfileName.value.trim()
if (!current || !name)
return
if (isDuplicateName.value)
if (!current || !name || isDuplicateName.value) {
return
}
const newId = cardStore.addCard({
...structuredClone(toRaw(current)),
name,
})
activeCardId.value = newId
creatingNew.value = false
newProfileName.value = ''
open.value = false
cancelCreate()
}
function cancelCreate() {
@@ -86,163 +140,199 @@ function cancelCreate() {
function handleManage() {
open.value = false
cancelCreate()
emit('manage')
}
function toggleOpen() {
open.value = !open.value
}
</script>
<template>
<div ref="popoverRef" class="relative">
<!-- Trigger button -->
<slot :open="open" :toggle="() => open = !open" :active-card="activeCard">
<button
:class="[
'flex items-center gap-1.5 rounded-xl px-2 py-1.5 text-sm transition-all',
'border border-neutral-200/60 dark:border-neutral-800/60',
'bg-neutral-50/70 dark:bg-neutral-800/70 backdrop-blur-md',
'hover:bg-neutral-100 dark:hover:bg-neutral-700',
open ? 'ring-2 ring-primary-500/20' : '',
]"
type="button"
@click="open = !open"
<div ref="containerRef" :class="['relative inline-flex flex-col items-end gap-2']">
<div
v-if="$slots.default"
:class="['profile-switcher-select-overlay', 'relative inline-flex']"
>
<slot :open="open" :toggle="toggleOpen" :active-card="activeCard" />
<Select
v-model="selectedProfile"
v-model:open="open"
:options="selectOptions"
:placeholder="t('stage.profile-switcher.no-profile')"
:content-min-width="224"
variant="blurry"
>
<div class="i-solar:emoji-funny-square-broken size-5 text-neutral-500 dark:text-neutral-400" />
<span class="max-w-28 truncate text-neutral-700 dark:text-neutral-200">
{{ activeCard?.name ?? t('stage.profile-switcher.no-profile') }}
</span>
<div
class="i-solar:alt-arrow-down-linear size-3 text-neutral-400 transition-transform duration-200"
:class="open === (placement === 'down') ? 'rotate-180' : ''"
/>
</button>
</slot>
<template #value="{ option, placeholder }">
<div :class="['min-w-0', 'flex', 'items-center', 'gap-2', 'p-1']">
<span
:class="[
'block truncate',
'text-lg',
'select-none',
option
? 'text-neutral-700 dark:text-neutral-200'
: 'text-neutral-400 dark:text-neutral-500',
]"
>
{{ option?.label ?? placeholder }}
</span>
</div>
</template>
<template #option="{ option }">
<div :class="['min-w-0', 'flex', 'flex-1', 'items-center', 'gap-2.5', 'py-1']">
<span
:class="[
'truncate',
'text-sm',
'select-none',
option.value === activeCardId
? 'text-primary-700 dark:text-primary-300'
: '',
]"
>
{{ option.label }}
</span>
</div>
</template>
</Select>
</div>
<Select
v-else
v-model="selectedProfile"
v-model:open="open"
:options="selectOptions"
:placeholder="t('stage.profile-switcher.no-profile')"
:content-min-width="224"
variant="blurry"
>
<template #value="{ option, placeholder }">
<div :class="['min-w-0', 'flex', 'items-center', 'gap-2', 'p-1']">
<div
:class="[
'size-4 shrink-0',
option?.value === activeCardId
? 'i-solar:check-circle-bold-duotone text-primary-500'
: option?.icon ?? 'i-solar:emoji-funny-square-broken text-neutral-400',
]"
/>
<span
:class="[
'block truncate',
'text-lg',
'select-none',
option
? 'text-neutral-700 dark:text-neutral-200'
: 'text-neutral-400 dark:text-neutral-500',
]"
>
{{ option?.label ?? placeholder }}
</span>
</div>
</template>
<template #option="{ option }">
<div :class="['min-w-0', 'flex', 'flex-1', 'items-center', 'gap-2.5', 'py-1']">
<div
v-if="option.icon"
:class="[
'size-5 shrink-0',
option.value === activeCardId
? 'text-primary-500'
: 'text-neutral-400',
option.icon,
]"
/>
<span
:class="[
'truncate',
'text-sm',
'select-none',
option.value === activeCardId
? 'text-primary-700 dark:text-primary-300'
: '',
]"
>
{{ option.label }}
</span>
</div>
</template>
</Select>
<!-- Popover panel -->
<Transition
enter-active-class="transition duration-150 ease-out"
:enter-from-class="placement === 'up' ? 'translate-y-1 opacity-0 scale-95' : '-translate-y-1 opacity-0 scale-95'"
enter-to-class="translate-y-0 opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="translate-y-0 opacity-100 scale-100"
:leave-to-class="placement === 'up' ? 'translate-y-1 opacity-0 scale-95' : '-translate-y-1 opacity-0 scale-95'"
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-[opacity,transform] duration-100 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="open"
v-if="creatingNew"
:class="[
'absolute right-0 z-50 w-48',
placement === 'up'
? 'bottom-full mb-2 origin-bottom-right'
: 'top-full mt-2 origin-top-right',
'overflow-hidden border border-neutral-200/60 dark:border-neutral-800/60 rounded-xl',
'bg-white/90 dark:bg-neutral-900/90 backdrop-blur-xl shadow-xl',
'divide-y divide-neutral-100 dark:divide-neutral-800',
'absolute right-0 z-[10011] w-56 rounded-xl border-2 p-2 shadow-sm backdrop-blur-xl',
placement === 'up' ? 'bottom-full mb-2 origin-bottom-right' : 'top-full mt-2 origin-top-right',
'border-neutral-200 bg-white/95 dark:border-neutral-800 dark:bg-neutral-900/95',
]"
>
<!-- New profile name input -->
<Transition
enter-active-class="transition-[grid-template-rows,opacity] duration-200 ease-out"
enter-from-class="grid-rows-[0fr] opacity-0"
enter-to-class="grid-rows-[1fr] opacity-100"
leave-active-class="transition-[grid-template-rows,opacity] duration-150 ease-in"
leave-from-class="grid-rows-[1fr] opacity-100"
leave-to-class="grid-rows-[0fr] opacity-0"
>
<div v-if="creatingNew" class="grid">
<div class="overflow-hidden">
<div class="p-2">
<div :class="['flex items-center gap-1.5 rounded-lg', isDuplicateName ? 'border border-red-400 dark:border-red-600' : 'border border-primary-300 dark:border-primary-700', 'bg-white dark:bg-neutral-800']">
<input
ref="nameInputRef"
v-model="newProfileName"
:class="[
'min-w-0 flex-1 bg-transparent px-2.5 py-1.5 text-sm outline-none',
'text-neutral-800 dark:text-neutral-100',
'placeholder:text-neutral-400 dark:placeholder:text-neutral-500',
]"
type="text"
:placeholder="t('stage.profile-switcher.new-profile-name')"
@keydown.enter="confirmCreate"
@keydown.escape="cancelCreate"
>
<button
:class="['shrink-0 p-1.5 transition', 'text-primary-500 hover:text-primary-600 dark:hover:text-primary-400', (newProfileName.trim() && !isDuplicateName) ? '' : 'opacity-30 pointer-events-none']"
type="button"
@click="confirmCreate"
>
<div class="i-solar:check-circle-bold size-4.5" />
</button>
<button
:class="['shrink-0 p-1.5 pr-2 transition', 'text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300']"
type="button"
@click="cancelCreate"
>
<div class="i-solar:close-circle-bold size-4.5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- Profile list -->
<div class="max-h-60 overflow-y-auto p-1 scrollbar-none">
<button
v-for="card in cardsList"
:key="card.id"
<div :class="['flex items-center gap-2']">
<input
ref="nameInputRef"
v-model="newProfileName"
type="text"
:placeholder="t('stage.profile-switcher.new-profile-name')"
:class="[
'group w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition',
card.id === activeCardId
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
: 'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800',
'min-w-0 flex-1 rounded-lg border-2 px-2 py-1 text-sm outline-none transition-colors',
'bg-neutral-50 text-neutral-800 placeholder:text-neutral-400',
'dark:bg-neutral-950 dark:text-neutral-100 dark:placeholder:text-neutral-500',
isDuplicateName
? 'border-red-400 dark:border-red-600'
: 'border-neutral-100 focus:border-primary-300 dark:border-neutral-900 dark:focus:border-primary-400/50',
]"
type="button"
@click="activateCard(card.id)"
@keydown.enter="confirmCreate"
@keydown.escape="cancelCreate"
>
<div
:class="[
'size-4 shrink-0',
card.id === activeCardId
? 'i-solar:check-circle-bold-duotone text-primary-500'
: 'i-solar:emoji-funny-square-broken text-neutral-400',
]"
/>
<span class="truncate">{{ card.name }}</span>
</button>
<div
v-if="cardsList.length === 0"
class="px-3 py-2 text-sm text-neutral-400 dark:text-neutral-500"
>
{{ t('stage.profile-switcher.no-profiles') }}
</div>
</div>
<!-- Actions -->
<div class="p-1">
<button
:class="[
'group w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition',
'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800',
'shrink-0 p-1.5 transition',
'text-primary-500 hover:text-primary-600 dark:hover:text-primary-400',
(newProfileName.trim() && !isDuplicateName) ? '' : 'pointer-events-none opacity-30',
]"
type="button"
@click="showCreateInput"
@click="confirmCreate"
>
<div class="i-solar:add-circle-bold-duotone size-4 text-neutral-400 transition group-hover:text-primary-500" />
{{ t('stage.profile-switcher.save-as-new') }}
<div class="i-solar:check-circle-bold size-4.5" />
</button>
<button
:class="[
'group w-full flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition',
'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800',
'shrink-0 p-1.5 transition',
'text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300',
]"
type="button"
@click="handleManage"
@click="cancelCreate"
>
<div class="i-solar:settings-minimalistic-bold-duotone size-4 text-neutral-400 transition group-hover:text-primary-500" />
{{ t('stage.profile-switcher.manage') }}
<div class="i-solar:close-circle-bold size-4.5" />
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.profile-switcher-select-overlay {
min-width: 0;
}
.profile-switcher-select-overlay > :deep(button[role='combobox']) {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
opacity: 0;
}
</style>