fix(stage-ui): improve profile switcher popover UI (#1425)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
Liet Blue
2026-03-24 13:17:49 +08:00
committed by GitHub
co-authored by autofix-ci[bot] Neko
parent 15c8b69053
commit 96dbae7a1a
14 changed files with 147 additions and 24 deletions
@@ -1,20 +1,31 @@
<script setup lang="ts">
import type { ProfileSwitcherPopoverProps } from '@proj-airi/stage-ui/components'
import type { PropType } from 'vue'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { ProfileSwitcherPopover } from '@proj-airi/stage-ui/components'
import { electronOpenSettings } from '../../../../shared/eventa'
defineOptions({ inheritAttrs: false })
const props = defineProps({
placement: String as PropType<ProfileSwitcherPopoverProps['placement']>,
})
const open = defineModel<boolean>('open', { default: false })
const openSettings = useElectronEventaInvoke(electronOpenSettings)
function handleNavigation() {
function handleManage() {
openSettings({ route: '/settings/airi-card' })
}
</script>
<template>
<ProfileSwitcherPopover placement="up" @create="handleNavigation" @manage="handleNavigation">
<template #default="{ open, toggle, activeCard }">
<slot :open="open" :toggle="toggle" :active-card="activeCard" />
<ProfileSwitcherPopover v-model:open="open" :placement="props.placement" @manage="handleManage">
<template #default="{ open: popoverOpen, toggle, activeCard }">
<slot :open="popoverOpen" :toggle="toggle" :active-card="activeCard" />
</template>
</ProfileSwitcherPopover>
</template>
@@ -5,7 +5,7 @@ import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/
import { useTheme } from '@proj-airi/ui'
import { refDebounced, useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ControlButtonTooltip from './control-button-tooltip.vue'
@@ -41,21 +41,37 @@ const setAlwaysOnTop = useElectronEventaInvoke(electronWindowSetAlwaysOnTop)
const expanded = ref(false)
const islandRef = ref<HTMLElement>()
// Expose whether hearing dialog is open so parent can disable click-through
const hearingDialogOpen = ref(false)
defineExpose({ hearingDialogOpen })
// Tracks open overlays/dialogs that should prevent auto-collapse (e.g. 'hearing', 'profile-picker')
const blockingOverlays = reactive(new Set<string>())
const isBlocked = computed(() => blockingOverlays.size > 0)
function setOverlay(key: string, active: boolean) {
active ? blockingOverlays.add(key) : blockingOverlays.delete(key)
}
// Expose for parent (e.g. to disable click-through when a dialog is open)
defineExpose({
get hearingDialogOpen() { return blockingOverlays.has('hearing') },
set hearingDialogOpen(v: boolean) { setOverlay('hearing', v) },
})
const { isOutside } = useElectronMouseInElement(islandRef)
const isOutsideAfter2seconds = refDebounced(isOutside, 1500)
watch(isOutsideAfter2seconds, (outside) => {
if (outside && expanded.value && !hearingDialogOpen.value) {
if (outside && expanded.value && !isBlocked.value) {
expanded.value = false
}
})
watch(expanded, (isExpanded) => {
if (!isExpanded) {
blockingOverlays.clear()
}
})
useIntervalFn(() => {
if (expanded.value && isOutside.value && !hearingDialogOpen.value) {
if (expanded.value && isOutside.value && !isBlocked.value) {
expanded.value = false
}
}, 1500)
@@ -130,7 +146,7 @@ function refreshWindow() {
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlsIslandProfilePicker>
<ControlsIslandProfilePicker placement="up" :open="blockingOverlays.has('profile-picker')" @update:open="setOverlay('profile-picker', $event)">
<template #default="{ toggle }">
<ControlButton :button-style="adjustStyleClasses.button" @click="toggle">
<div i-solar:emoji-funny-square-broken :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
@@ -173,7 +189,7 @@ function refreshWindow() {
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlsIslandHearingConfig v-model:show="hearingDialogOpen">
<ControlsIslandHearingConfig :show="blockingOverlays.has('hearing')" @update:show="setOverlay('hearing', $event)">
<div class="relative">
<ControlButton :button-style="adjustStyleClasses.button">
<Transition name="fade" mode="out-in">
+1
View File
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: No Profile
no-profiles: No profiles yet
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
waiting: Waiting
+1
View File
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: No Profile
no-profiles: No profiles yet
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
waiting: Esperando...
+1
View File
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: Aucun profil
no-profiles: Aucuns profils pour l'instant
save-as-new: Enregistrer comme Nouveau Profil
new-profile-name: Nom du profil
manage: Gérer les Profils
waiting: En attente
+1
View File
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: プロファイルがありません
no-profiles: まだプロファイルがありません
save-as-new: 新規プロファイルとして保存
new-profile-name: Profile name
manage: プロファイルを管理
waiting: 待機中
+1
View File
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: No Profile
no-profiles: No profiles yet
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
waiting: 대기 중
+1
View File
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: Профиль отсутствует
no-profiles: Нет профилей
save-as-new: Сохранить как Новый Профиль
new-profile-name: Profile name
manage: Управление профилями
waiting: Ожидание
+1
View File
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: No Profile
no-profiles: No profiles yet
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
waiting: Đang chờ
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: 缺少角色
no-profiles: 还没有角色
save-as-new: 存为新角色
new-profile-name: 角色名称
manage: 管理角色卡
waiting: 等待中
@@ -34,5 +34,6 @@ profile-switcher:
no-profile: No Profile
no-profiles: No profiles yet
save-as-new: Save as New Profile
new-profile-name: Profile name
manage: Manage Profiles
waiting: 等待中
@@ -17,7 +17,7 @@ function handleNavigation() {
<header mb-1 w-full flex items-center justify-between gap-2>
<HeaderLink />
<div flex items-center gap-2>
<ProfileSwitcherPopover @create="handleNavigation" @manage="handleNavigation" />
<ProfileSwitcherPopover @manage="handleNavigation" />
<ActionAbout />
<HeaderAvatar />
</div>
@@ -1,5 +1,6 @@
export { default as Alert } from './alert.vue'
export { default as ErrorContainer } from './error-container.vue'
export { default as ProfileSwitcherPopover } from './profile-switcher-popover.vue'
export type { Props as ProfileSwitcherPopoverProps } from './profile-switcher-popover.vue'
export * from './steppers'
export { default as WIP } from './wip.vue'
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { computed, nextTick, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiriCardStore } from '../../stores/modules/airi-card'
interface Props {
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'
@@ -15,33 +15,73 @@ interface Props {
withDefaults(defineProps<Props>(), { placement: 'down' })
const emit = defineEmits<{
(e: 'create'): void
(e: 'manage'): void
}>()
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
const cardStore = useAiriCardStore()
const { cards, activeCardId, activeCard } = storeToRefs(cardStore)
const open = ref(false)
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 cardsList = computed(() =>
Array.from(cards.value.entries()).map(([id, card]) => ({ id, name: card.name })),
)
const isDuplicateName = computed(() => {
const name = newProfileName.value.trim()
return name !== '' && Array.from(cards.value.values()).some(c => c.name === name)
})
function activateCard(id: string) {
activeCardId.value = id
open.value = false
}
function handleCreate() {
async function showCreateInput() {
creatingNew.value = true
newProfileName.value = activeCard.value?.name ?? ''
await nextTick()
nameInputRef.value?.focus()
nameInputRef.value?.select()
}
function confirmCreate() {
const current = activeCard.value
const name = newProfileName.value.trim()
if (!current || !name)
return
if (isDuplicateName.value)
return
const newId = cardStore.addCard({
...structuredClone(toRaw(current)),
name,
})
activeCardId.value = newId
creatingNew.value = false
newProfileName.value = ''
open.value = false
emit('create')
}
function cancelCreate() {
creatingNew.value = false
newProfileName.value = ''
}
function handleManage() {
@@ -65,7 +105,7 @@ function handleManage() {
type="button"
@click="open = !open"
>
<div class="i-solar:emoji-funny-square-broken size-4 text-neutral-500 dark:text-neutral-400" />
<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>
@@ -88,17 +128,63 @@ function handleManage() {
<div
v-if="open"
:class="[
'absolute right-0 z-50 min-w-48',
'absolute right-0 z-50 w-48',
placement === 'up'
? 'bottom-full mb-2 origin-bottom-right'
: 'top-full mt-2 origin-top-right',
'border border-neutral-200/60 dark:border-neutral-800/60 rounded-xl',
'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',
]"
>
<!-- 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">
<div class="max-h-60 overflow-y-auto p-1 scrollbar-none">
<button
v-for="card in cardsList"
:key="card.id"
@@ -138,7 +224,7 @@ function handleManage() {
'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800',
]"
type="button"
@click="handleCreate"
@click="showCreateInput"
>
<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') }}