fix(stage-ui): incorrect size and style of Profile Selector
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user