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:
co-authored by
autofix-ci[bot]
Neko
parent
15c8b69053
commit
96dbae7a1a
+15
-4
@@ -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>
|
||||
|
||||
+24
-8
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,5 +34,6 @@ profile-switcher:
|
||||
no-profile: プロファイルがありません
|
||||
no-profiles: まだプロファイルがありません
|
||||
save-as-new: 新規プロファイルとして保存
|
||||
new-profile-name: 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: 대기 중
|
||||
|
||||
@@ -34,5 +34,6 @@ profile-switcher:
|
||||
no-profile: Профиль отсутствует
|
||||
no-profiles: Нет профилей
|
||||
save-as-new: Сохранить как Новый Профиль
|
||||
new-profile-name: 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: Đ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') }}
|
||||
|
||||
Reference in New Issue
Block a user