fix(stage-tamagotchi): prevent controls island overflow in small windows with scroll (#2474)
## Description <!-- Please insert your description here and especially provide info about the "what" this PR is solving --> ### As-is Depending on the size, the Controls Island area gets cut off, making the buttons in the cropped area inaccessible. ### To-be The Controls Island is managed via scrolling, allowing interaction with buttons regardless of the size. If the main controls also exceed the available space, the entire Controls Island becomes scrollable. Horizontal scrolling is available for narrow windows. #### narrow & smaill https://github.com/user-attachments/assets/1a44ff4f-8fe2-4a05-96a8-59ebf7dafcff #### somewhat generous size https://github.com/user-attachments/assets/c73dbee4-8c8f-4937-ae1a-eda00178274c ## Linked Issues <!-- Optional, if you have any --> close #2400 ## Additional Context <!-- e.g. is there anything you'd like reviewers to focus on? --> Although the size is somewhat less than ideal, the issue has been resolved, and this serves as a universal solution. --------- Co-authored-by: leafyy <wuqizq@outlook.com>
This commit is contained in:
+8
-1
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentProps } from 'reka-ui'
|
||||
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
|
||||
import { TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useControlsIslandPlacement } from './use-controls-island-placement'
|
||||
@@ -31,9 +31,11 @@ const resolvedSide = computed<NonNullable<TooltipContentProps['side']>>(() => {
|
||||
<TooltipTrigger>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<Transition name="fade">
|
||||
<TooltipContent
|
||||
:class="[
|
||||
'controls-island-tooltip',
|
||||
'border-1 border-solid border-neutral-200/60 dark:border-neutral-800/10',
|
||||
'bg-neutral-50/80 dark:bg-neutral-800/70',
|
||||
'w-fit flex items-center self-end justify-center px-1.5 py-1',
|
||||
@@ -46,11 +48,16 @@ const resolvedSide = computed<NonNullable<TooltipContentProps['side']>>(() => {
|
||||
<slot name="tooltip" />
|
||||
</TooltipContent>
|
||||
</Transition>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global([data-reka-popper-content-wrapper=""]:has(.controls-island-tooltip)) {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
+41
-8
@@ -5,6 +5,11 @@ import { createApp, h, nextTick, ref } from 'vue'
|
||||
|
||||
import ControlsIslandAuthButton from './controls-island-auth-button.vue'
|
||||
|
||||
import { electronAuthStartLogin } from '../../../../shared/eventa'
|
||||
|
||||
const subscriptions = vi.hoisted(() => ({ on: vi.fn(() => vi.fn()) }))
|
||||
const invokes = vi.hoisted(() => ({ startLogin: vi.fn(), openSettings: vi.fn() }))
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: ref(true),
|
||||
user: ref<{ name: string, image?: string }>({
|
||||
@@ -21,13 +26,9 @@ vi.mock('@proj-airi/stage-ui/stores/auth', () => ({
|
||||
|
||||
vi.mock('@proj-airi/electron-vueuse', () => ({
|
||||
useElectronEventaContext: () => ref({
|
||||
on: vi.fn(),
|
||||
on: subscriptions.on,
|
||||
}),
|
||||
useElectronEventaInvoke: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store: object) => store,
|
||||
useElectronEventaInvoke: (event: unknown) => event === electronAuthStartLogin ? invokes.startLogin : invokes.openSettings,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -45,20 +46,52 @@ describe('controlsIslandAuthButton', () => {
|
||||
host.remove()
|
||||
}
|
||||
mountedApps.length = 0
|
||||
authState.isAuthenticated.value = true
|
||||
authState.needsLogin.value = false
|
||||
invokes.startLogin.mockReset()
|
||||
invokes.openSettings.mockReset()
|
||||
authState.user.value.image = 'https://example.com/broken-avatar.png'
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
function mountComponent(active = ref(true)) {
|
||||
const host = document.createElement('div')
|
||||
document.body.appendChild(host)
|
||||
const app = createApp({
|
||||
render: () => h(ControlsIslandAuthButton),
|
||||
render: () => h(ControlsIslandAuthButton, { active: active.value }),
|
||||
})
|
||||
app.mount(host)
|
||||
mountedApps.push({ app, host })
|
||||
return host
|
||||
}
|
||||
|
||||
it('starts a deferred login when the hidden menu becomes active', async () => {
|
||||
authState.isAuthenticated.value = false
|
||||
const active = ref(false)
|
||||
mountComponent(active)
|
||||
|
||||
authState.needsLogin.value = true
|
||||
await nextTick()
|
||||
expect(invokes.startLogin).not.toHaveBeenCalled()
|
||||
expect(authState.needsLogin.value).toBe(true)
|
||||
|
||||
active.value = true
|
||||
await nextTick()
|
||||
expect(invokes.startLogin).toHaveBeenCalledOnce()
|
||||
expect(authState.needsLogin.value).toBe(false)
|
||||
})
|
||||
|
||||
it('disposes both auth subscriptions when the menu unmounts', () => {
|
||||
subscriptions.on.mockClear()
|
||||
mountComponent()
|
||||
expect(subscriptions.on).toHaveBeenCalledTimes(2)
|
||||
const stops = subscriptions.on.mock.results.map(result => result.value)
|
||||
mountedApps[0]!.app.unmount()
|
||||
for (const stop of stops)
|
||||
expect(stop).toHaveBeenCalledOnce()
|
||||
mountedApps[0]!.host.remove()
|
||||
mountedApps.length = 0
|
||||
})
|
||||
|
||||
it('renders the shared account fallback when no avatar is available', () => {
|
||||
authState.user.value.image = undefined
|
||||
const host = mountComponent()
|
||||
|
||||
+13
-8
@@ -3,7 +3,7 @@ import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/el
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { Avatar } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../../../../shared/eventa'
|
||||
|
||||
const props = defineProps<{
|
||||
active: boolean
|
||||
buttonStyle?: string
|
||||
iconClass?: string
|
||||
}>()
|
||||
@@ -45,18 +46,22 @@ function doSigningIn() {
|
||||
startSigningIn()
|
||||
}
|
||||
|
||||
// Clear loading state on callback or error from main process.
|
||||
// No cleanup needed — this component lives for the window's lifetime.
|
||||
context.value.on(electronAuthCallback, () => {
|
||||
// Each listener belongs to this mounted menu, including its hidden measurement state.
|
||||
const stopCallback = context.value.on(electronAuthCallback, () => {
|
||||
signingIn.value = false
|
||||
})
|
||||
context.value.on(electronAuthCallbackError, () => {
|
||||
const stopError = context.value.on(electronAuthCallbackError, () => {
|
||||
signingIn.value = false
|
||||
})
|
||||
onScopeDispose(() => {
|
||||
stopCallback()
|
||||
stopError()
|
||||
})
|
||||
|
||||
// React to needsLogin from other components (e.g. onboarding)
|
||||
watch(needsLogin, (val) => {
|
||||
if (val && !isAuthenticated.value) {
|
||||
// Hidden measurement must not initiate login requests. Keep the request until
|
||||
// the menu becomes visible, then preserve the original transition behavior.
|
||||
watch([needsLogin, () => props.active], ([requested, active]) => {
|
||||
if (active && requested && !isAuthenticated.value) {
|
||||
doSigningIn()
|
||||
needsLogin.value = false
|
||||
}
|
||||
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
import type { AiriCard } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
|
||||
import type { ControlsIslandDock } from './use-controls-island-placement'
|
||||
|
||||
import en from '@proj-airi/i18n/locales/en'
|
||||
|
||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision/store'
|
||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { createPinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
import { page } from 'vitest/browser'
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ControlsIsland from './index.vue'
|
||||
|
||||
import { electronOpenSettings } from '../../../../shared/eventa'
|
||||
import { controlsIslandPlacementKey } from './use-controls-island-placement'
|
||||
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import 'virtual:uno.css'
|
||||
|
||||
const isOutside = ref(false)
|
||||
const openSettings = vi.fn().mockResolvedValue(undefined)
|
||||
const authState = vi.hoisted(() => ({
|
||||
credits: { value: 0 },
|
||||
isAuthenticated: { value: false },
|
||||
needsLogin: { value: false },
|
||||
user: { value: null as { createdAt: Date, email: string, emailVerified: boolean, id: string, name: string, updatedAt: Date } | null },
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/electron-vueuse', () => ({
|
||||
useElectronEventaContext: () => ref({ on: vi.fn(() => vi.fn()), emit: vi.fn() }),
|
||||
useElectronEventaInvoke: (event: unknown) => event === electronOpenSettings ? openSettings : vi.fn().mockResolvedValue(false),
|
||||
useElectronMouseInElement: () => ({ isOutside }),
|
||||
}))
|
||||
|
||||
vi.mock('@moeru/eventa', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@moeru/eventa')>(),
|
||||
defineInvoke: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/auth', async () => {
|
||||
const { ref } = await import('vue')
|
||||
authState.credits = ref(0)
|
||||
authState.isAuthenticated = ref(false)
|
||||
authState.needsLogin = ref(false)
|
||||
authState.user = ref(null)
|
||||
|
||||
return { useAuthStore: () => authState }
|
||||
})
|
||||
|
||||
function scrollOwners(island: HTMLElement) {
|
||||
return Array.from(island.querySelectorAll<HTMLElement>('[data-reka-scroll-area-viewport]'))
|
||||
.filter(element => getComputedStyle(element).overflowY === 'scroll' && element.scrollHeight > element.clientHeight)
|
||||
}
|
||||
|
||||
const docks: ControlsIslandDock[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']
|
||||
const sizes = ['small', 'large', 'auto'] as const
|
||||
|
||||
function mountControlsIsland(dock: ControlsIslandDock, size: typeof sizes[number] = 'auto', dockRef = ref(dock), initializeProfile = false) {
|
||||
const pinia = createPinia()
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
|
||||
const component = initializeProfile
|
||||
? defineComponent({
|
||||
setup() {
|
||||
// Seed the profile store without invoking the stage's asynchronous
|
||||
// runtime initialization. The profile form only needs an active card.
|
||||
const cards = useAiriCardStore()
|
||||
// Create the stores that card duplication reads while Vue still has
|
||||
// a component setup context. The action itself can then reuse them.
|
||||
useArtistryStore()
|
||||
useConsciousnessStore()
|
||||
useSpeechStore()
|
||||
useSettingsStageModel()
|
||||
useVisionStore()
|
||||
const defaultCard = {
|
||||
name: 'ReLU',
|
||||
version: '1.0.0',
|
||||
extensions: {
|
||||
airi: {
|
||||
modules: {
|
||||
consciousness: { provider: '', model: '' },
|
||||
vision: { provider: '', model: '' },
|
||||
speech: { provider: '', model: '', voice_id: '' },
|
||||
},
|
||||
agents: {},
|
||||
},
|
||||
},
|
||||
} satisfies AiriCard
|
||||
cards.cards.set('default', defaultCard)
|
||||
return () => h(ControlsIsland)
|
||||
},
|
||||
})
|
||||
: ControlsIsland
|
||||
const screen = render(component, {
|
||||
global: {
|
||||
provide: {
|
||||
[controlsIslandPlacementKey as symbol]: {
|
||||
dock: dockRef,
|
||||
isTop: computed(() => dockRef.value.startsWith('top')),
|
||||
isLeft: computed(() => dockRef.value.endsWith('left')),
|
||||
motionPhase: ref('idle'),
|
||||
},
|
||||
},
|
||||
plugins: [pinia, i18n],
|
||||
directives: { 'track-button': {} },
|
||||
},
|
||||
})
|
||||
useSettings(pinia).controlsIslandIconSize = size
|
||||
|
||||
return { cards: useAiriCardStore(pinia), auth: authState, dock: dockRef, i18n, screen, settings: useSettings(pinia) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isOutside.value = false
|
||||
openSettings.mockClear()
|
||||
authState.credits.value = 0
|
||||
authState.isAuthenticated.value = false
|
||||
authState.needsLogin.value = false
|
||||
authState.user.value = null
|
||||
})
|
||||
|
||||
describe('controls Island overflow', () => {
|
||||
for (const dock of docks) {
|
||||
for (const size of sizes) {
|
||||
// ROOT CAUSE:
|
||||
// The expanded panel had no viewport limit or scroll owner. Its first rows
|
||||
// left the window when the panel and main controls exceeded its height.
|
||||
// The menu now owns scrolling until the main controls fill the viewport.
|
||||
// https://github.com/moeru-ai/airi/issues/2400
|
||||
it(`Issue #2400 keeps ${dock} ${size} controls reachable across measured boundaries`, async () => {
|
||||
await page.viewport(450, 600)
|
||||
const { i18n, screen } = mountControlsIsland(dock, size)
|
||||
await nextTick()
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const main = screen.getByTestId('main-controls').element() as HTMLElement
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
await expect.poll(() => Number.parseFloat(getComputedStyle(main.querySelector('div.size-3, div.size-5')!).width)).toBe(size === 'small' ? 12 : 20)
|
||||
const mainHeight = main.getBoundingClientRect().height
|
||||
const mainBefore = main.getBoundingClientRect()
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
const menu = screen.getByTestId('controls-menu').element() as HTMLElement
|
||||
await expect.poll(() => island.getBoundingClientRect().height).toBeGreaterThan(mainHeight)
|
||||
expect(main.getBoundingClientRect().top).toBe(mainBefore.top)
|
||||
expect(main.getBoundingClientRect().right).toBe(mainBefore.right)
|
||||
await expect.poll(() => scrollOwners(island)).toHaveLength(0)
|
||||
const naturalHeight = island.getBoundingClientRect().height
|
||||
const naturalWidth = island.getBoundingClientRect().width
|
||||
const menuHeight = menu.querySelector<HTMLElement>('.w-max')!.offsetHeight
|
||||
const isTop = dock.startsWith('top')
|
||||
const isLeft = dock.endsWith('left')
|
||||
|
||||
for (const height of [naturalHeight + 17, naturalHeight + 16, naturalHeight + 15, mainHeight + 17, mainHeight + 16, mainHeight + 15, 600]) {
|
||||
await page.viewport(450, Math.ceil(height))
|
||||
const sideways = height < naturalHeight + 16
|
||||
await expect.poll(() => island.dataset.direction).toBe(sideways ? (isLeft ? 'right' : 'left') : (isTop ? 'down' : 'up'))
|
||||
await expect.poll(() => island.getBoundingClientRect().height).toBeLessThanOrEqual(Math.ceil(height) - 16)
|
||||
expect(island.getBoundingClientRect().top).toBeGreaterThanOrEqual(8)
|
||||
expect(island.getBoundingClientRect().bottom).toBeLessThanOrEqual(Math.ceil(height) - 8)
|
||||
expect(main.getBoundingClientRect().height).toBe(mainHeight)
|
||||
const expectedOwnerCount = Math.max(mainHeight, menuHeight) > Math.ceil(height) - 16 ? 1 : 0
|
||||
await expect.poll(() => scrollOwners(island).length).toBe(expectedOwnerCount)
|
||||
if (expectedOwnerCount) {
|
||||
const owner = scrollOwners(island)[0]!
|
||||
expect(menu.contains(owner)).toBe(height >= mainHeight + 16)
|
||||
owner.scrollTop = owner.scrollHeight
|
||||
expect(owner.scrollTop).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
|
||||
for (const width of [naturalWidth + 17, naturalWidth + 16, naturalWidth + 15, 40, 450]) {
|
||||
await page.viewport(Math.ceil(width), 600)
|
||||
await expect.poll(() => island.getBoundingClientRect().width).toBeLessThanOrEqual(Math.ceil(width) - 16)
|
||||
expect(island.getBoundingClientRect().left).toBeGreaterThanOrEqual(8)
|
||||
if (width < naturalWidth + 16) {
|
||||
const owner = Array.from(island.querySelectorAll<HTMLElement>('[data-reka-scroll-area-viewport]'))
|
||||
.find(viewport => viewport.scrollWidth > viewport.clientWidth)!
|
||||
owner.scrollLeft = owner.scrollWidth
|
||||
expect(owner.scrollLeft).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
|
||||
await page.viewport(450, Math.ceil(mainHeight + 60))
|
||||
const settings = screen.getByLabelText(label('open-settings'), { exact: true })
|
||||
const settingsElement = settings.element() as HTMLElement
|
||||
settingsElement.focus()
|
||||
await expect.poll(() => settingsElement.getBoundingClientRect().top).toBeGreaterThanOrEqual(8)
|
||||
await settings.click()
|
||||
expect(openSettings).toHaveBeenCalledWith({ route: '/settings' })
|
||||
|
||||
await screen.getByLabelText(label('collapse'), { exact: true }).click()
|
||||
await expect.poll(() => menu.closest('[aria-hidden]')?.getAttribute('aria-hidden')).toBe('true')
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
const reopenedViewport = screen.getByTestId('controls-menu').element().querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
expect(reopenedViewport.scrollTop).toBe(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const dock of ['top-right', 'bottom-right'] as const) {
|
||||
it(`Issue #2400 aligns ${dock} controls to the visible right edge`, async () => {
|
||||
await page.viewport(450, 600)
|
||||
const { i18n, screen } = mountControlsIsland(dock)
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const naturalWidth = island.getBoundingClientRect().width
|
||||
await page.viewport(Math.max(40, Math.floor(naturalWidth / 2)), 600)
|
||||
|
||||
const viewport = island.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
await expect.poll(() => screen.getByTestId('main-controls').element().getBoundingClientRect().right).toBeLessThanOrEqual(window.innerWidth - 8)
|
||||
expect(viewport.scrollLeft).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
// ROOT CAUSE:
|
||||
// Icon size changes alter the Island geometry after a right-docked layout has
|
||||
// been aligned. The old implementation did not realign after that change.
|
||||
//
|
||||
// Before the patch, the right dock kept a stale horizontal scroll position.
|
||||
//
|
||||
// We fixed this by observing the Island geometry and aligning after updates.
|
||||
it('issue #2400 realigns the right dock after an icon size change', async () => {
|
||||
await page.viewport(450, 600)
|
||||
const { i18n, screen, settings } = mountControlsIsland('bottom-right', 'small')
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const viewport = island.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
await page.viewport(40, 600)
|
||||
await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0)
|
||||
const previousScrollWidth = viewport.scrollWidth
|
||||
|
||||
settings.controlsIslandIconSize = 'large'
|
||||
await expect.poll(() => viewport.scrollWidth).toBeGreaterThan(previousScrollWidth)
|
||||
await expect.poll(() => viewport.scrollLeft).toBe(viewport.scrollWidth - viewport.clientWidth)
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
// Tooltip content is portaled outside the Island and can render below the
|
||||
// stage when it uses the default stacking order.
|
||||
//
|
||||
// Before the patch, a tooltip over a control could be hidden by the stage.
|
||||
//
|
||||
// We fixed this by keeping the control tooltip portal above the stage layer.
|
||||
it('issue #2400 raises portaled control tooltips above the stage', async () => {
|
||||
await page.viewport(450, 600)
|
||||
const { i18n, screen } = mountControlsIsland('bottom-right')
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
await screen.getByLabelText(label('open-settings'), { exact: true }).hover()
|
||||
|
||||
const tooltipWrapper = '[data-reka-popper-content-wrapper]'
|
||||
await expect.poll(() => document.querySelector(tooltipWrapper)).not.toBeNull()
|
||||
expect(getComputedStyle(document.querySelector(tooltipWrapper)!).zIndex).toBe('1000')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
// Authentication content can grow after the right-docked Island has been
|
||||
// aligned, which changes the horizontal overflow range.
|
||||
//
|
||||
// Before the patch, the right edge moved out of view after the user signed in.
|
||||
//
|
||||
// We fixed this by observing content geometry and realigning the dock edge.
|
||||
it('issue #2400 realigns the right dock after authentication content grows', async () => {
|
||||
await page.viewport(450, 600)
|
||||
const { auth, i18n, screen } = mountControlsIsland('bottom-right')
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const viewport = island.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
await page.viewport(40, 600)
|
||||
await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0)
|
||||
const previousScrollWidth = viewport.scrollWidth
|
||||
|
||||
auth.credits.value = 999999999
|
||||
auth.isAuthenticated.value = true
|
||||
auth.user.value = {
|
||||
createdAt: new Date('2020-01-01'),
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
id: 'user',
|
||||
name: 'A very long authenticated user name that changes the island width',
|
||||
updatedAt: new Date('2020-01-01'),
|
||||
}
|
||||
|
||||
await expect.poll(() => viewport.scrollWidth).toBeGreaterThan(previousScrollWidth)
|
||||
await expect.poll(() => viewport.scrollLeft).toBe(viewport.scrollWidth - viewport.clientWidth)
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
// Dock changes reverse the horizontal edge that must remain visible, but the
|
||||
// previous scroll offset belongs to the old dock.
|
||||
//
|
||||
// Before the patch, moving from right to left kept the old right-edge offset.
|
||||
//
|
||||
// We fixed this by aligning both axes whenever the dock changes.
|
||||
it('issue #2400 resets horizontal scroll after moving from a right dock to a left dock', async () => {
|
||||
await page.viewport(450, 600)
|
||||
const { dock, i18n, screen } = mountControlsIsland('bottom-right')
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const viewport = island.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
await page.viewport(40, 600)
|
||||
await expect.poll(() => viewport.scrollLeft).toBeGreaterThan(0)
|
||||
|
||||
dock.value = 'bottom-left'
|
||||
await expect.poll(() => viewport.scrollLeft).toBe(0)
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
// A bottom-docked Island must use the lower scroll edge when its content is
|
||||
// taller than the window, or the main controls can remain below the viewport.
|
||||
//
|
||||
// Before the patch, the bottom dock could open with its main controls clipped.
|
||||
//
|
||||
// We fixed this by aligning the outer viewport to the dock edge after layout changes.
|
||||
it('issue #2400 aligns bottom docks to the visible vertical scroll end', async () => {
|
||||
await page.viewport(450, 200)
|
||||
const { i18n, screen } = mountControlsIsland('bottom-right')
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const viewport = island.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
await expect.poll(() => viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight)
|
||||
// Focused collapse remains reachable even when docking would clip it.
|
||||
const collapse = screen.getByLabelText(label('collapse'), { exact: true }).element() as HTMLElement
|
||||
expect(collapse.getBoundingClientRect().top).toBeGreaterThanOrEqual(8)
|
||||
collapse.blur()
|
||||
await page.viewport(450, 190)
|
||||
await expect.poll(() => viewport.scrollTop).toBe(viewport.scrollHeight - viewport.clientHeight)
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
// A scrollbar drag can move the pointer outside the Island while the user is
|
||||
// still interacting with it.
|
||||
//
|
||||
// Before the patch, the outside timer collapsed the menu during a scrollbar drag.
|
||||
//
|
||||
// We fixed this by treating pressed scrollbar interaction as a blocked state.
|
||||
// The interaction path is independent from the size and dock matrix.
|
||||
it('issue #2400 keeps the expanded menu open during a scrollbar drag', async () => {
|
||||
await page.viewport(450, 300)
|
||||
const { i18n, screen } = mountControlsIsland('bottom-right')
|
||||
const label = (key: string) => i18n.global.t(`tamagotchi.stage.controls-island.${key}`)
|
||||
await screen.getByLabelText(label('expand'), { exact: true }).click()
|
||||
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const settings = screen.getByLabelText(label('open-settings'), { exact: true }).element() as HTMLElement
|
||||
settings.focus()
|
||||
expect(island.contains(document.activeElement)).toBe(true)
|
||||
expect(settings.getBoundingClientRect().bottom).toBeGreaterThan(8)
|
||||
|
||||
island.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
isOutside.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 1700))
|
||||
expect(screen.getByTestId('controls-menu').element()).toBeInTheDocument()
|
||||
window.dispatchEvent(new MouseEvent('mouseup'))
|
||||
await expect.poll(() => screen.getByTestId('controls-menu').element().closest('[aria-hidden]')?.getAttribute('aria-hidden'), { timeout: 3500 }).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2474
|
||||
it('measures the collapsed menu and opens inward when height is insufficient (PR #2474)', async () => {
|
||||
// ROOT CAUSE:
|
||||
// The menu only mounted after opening and always used the vertical axis.
|
||||
// Natural content measurement now determines both placement and the arrow.
|
||||
await page.viewport(600, 300)
|
||||
const { i18n, screen } = mountControlsIsland('bottom-right')
|
||||
const island = screen.getByTestId('controls-island').element()
|
||||
await expect.poll(() => island.getAttribute('data-direction')).toBe('left')
|
||||
await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click()
|
||||
const main = screen.getByTestId('main-controls').element()
|
||||
const menu = screen.getByTestId('controls-menu').element()
|
||||
await expect.poll(() => menu.getBoundingClientRect().right).toBeLessThanOrEqual(main.getBoundingClientRect().left - 12)
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2474
|
||||
it('keeps the profile creation form open for pointer interaction (PR #2474)', async () => {
|
||||
// ROOT CAUSE:
|
||||
// Closing the selector canceled creation, and the body portal counted as an
|
||||
// outside click. The selector and form must share one interaction lifecycle.
|
||||
await page.viewport(600, 300)
|
||||
const { cards, i18n, screen } = mountControlsIsland('bottom-right', 'auto', ref('bottom-right'), true)
|
||||
await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click()
|
||||
await screen.getByRole('combobox').click()
|
||||
await page.getByRole('option', { name: i18n.global.t('stage.profile-switcher.save-as-new') }).click()
|
||||
const input = page.getByPlaceholder(i18n.global.t('stage.profile-switcher.new-profile-name'))
|
||||
await input.click()
|
||||
await input.fill('New profile')
|
||||
await expect.element(input).toHaveValue('New profile')
|
||||
isOutside.value = true
|
||||
await new Promise(resolve => setTimeout(resolve, 1700))
|
||||
expect(screen.getByTestId('controls-menu').element().closest('[inert]')).toBeNull()
|
||||
for (const [width, height] of [[160, 200], [100, 80], [600, 600]] as const) {
|
||||
await page.viewport(width, height)
|
||||
const form = page.getByTestId('profile-create-form').element() as HTMLElement
|
||||
await expect.poll(() => form.getBoundingClientRect().right).toBeLessThanOrEqual(width - 8)
|
||||
await expect.poll(() => form.getBoundingClientRect().bottom).toBeLessThanOrEqual(height - 8)
|
||||
expect(form.getBoundingClientRect().left).toBeGreaterThanOrEqual(8)
|
||||
expect(form.getBoundingClientRect().top).toBeGreaterThanOrEqual(8)
|
||||
}
|
||||
await page.getByRole('button', { name: i18n.global.t('stage.profile-switcher.save-as-new'), exact: true }).click()
|
||||
await expect.poll(() => cards.activeCard?.name).toBe('New profile')
|
||||
await expect.element(input).not.toBeInTheDocument()
|
||||
isOutside.value = false
|
||||
|
||||
// ROOT CAUSE:
|
||||
// Reopening the selector while creation was active changed the selected
|
||||
// card but left the old form state alive. A later save could clone the new
|
||||
// card with the name entered for the previous card.
|
||||
//
|
||||
// We fixed this by canceling creation when a non-create option is selected.
|
||||
// The close-to-create transition remains allowed.
|
||||
await screen.getByRole('combobox').click()
|
||||
await page.getByRole('option', { name: i18n.global.t('stage.profile-switcher.save-as-new') }).click()
|
||||
await page.getByPlaceholder(i18n.global.t('stage.profile-switcher.new-profile-name')).fill('Stale profile name')
|
||||
await screen.getByRole('combobox').click()
|
||||
await page.getByRole('option', { name: 'ReLU' }).click()
|
||||
await expect.element(page.getByTestId('profile-create-form')).not.toBeInTheDocument()
|
||||
await expect.poll(() => cards.activeCard?.name).toBe('ReLU')
|
||||
|
||||
await screen.getByRole('combobox').click()
|
||||
await page.getByRole('option', { name: i18n.global.t('stage.profile-switcher.save-as-new') }).click()
|
||||
const form = page.getByTestId('profile-create-form').element() as HTMLElement
|
||||
form.querySelectorAll<HTMLButtonElement>('button')[1]!.click()
|
||||
await expect.element(input).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
for (const dock of docks) {
|
||||
// https://github.com/moeru-ai/airi/pull/2474
|
||||
it(`PR #2474 keeps one inert measured menu and rotates the ${dock} arrow before opening`, async () => {
|
||||
await page.viewport(600, 600)
|
||||
const { i18n, screen, settings } = mountControlsIsland(dock, 'small')
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const main = screen.getByTestId('main-controls').element() as HTMLElement
|
||||
const menu = screen.getByTestId('controls-menu').element() as HTMLElement
|
||||
const toggle = main.querySelector<HTMLButtonElement>('[aria-controls]')!
|
||||
const icon = toggle.querySelector<HTMLElement>('[i-solar\\:alt-arrow-up-line-duotone]')!
|
||||
const isTop = dock.startsWith('top')
|
||||
const isLeft = dock.endsWith('left')
|
||||
await expect.poll(() => island.offsetHeight === main.offsetHeight).toBe(true)
|
||||
expect(toggle.getAttribute('aria-controls')).toBe(menu.id)
|
||||
expect(menu.closest('[inert]')).not.toBeNull()
|
||||
const hiddenButton = menu.querySelector<HTMLButtonElement>('button')!
|
||||
hiddenButton.focus()
|
||||
expect(document.activeElement).not.toBe(hiddenButton)
|
||||
await expect.poll(() => icon.style.transform).toBe(`rotate(${isTop ? 180 : 0}deg)`)
|
||||
await page.viewport(600, 120)
|
||||
await expect.poll(() => island.dataset.direction).toBe(isLeft ? 'right' : 'left')
|
||||
expect(icon.style.transform).toBe(`rotate(${isLeft ? 90 : 270}deg)`)
|
||||
settings.controlsIslandIconSize = 'large'
|
||||
await expect.poll(() => main.querySelector('.size-5')).not.toBeNull()
|
||||
await page.viewport(600, 300)
|
||||
await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click()
|
||||
expect(screen.getByTestId('controls-menu').element()).toBe(menu)
|
||||
expect(menu.closest('[inert]')).toBeNull()
|
||||
expect(icon.style.transform).toBe(`rotate(${isLeft ? 270 : 90}deg)`)
|
||||
await page.viewport(600, 600)
|
||||
await expect.poll(() => island.dataset.direction).toBe(isTop ? 'down' : 'up')
|
||||
expect(screen.getByTestId('controls-menu').element()).toBe(menu)
|
||||
const settingsButton = screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.open-settings'), { exact: true }).element() as HTMLElement
|
||||
settingsButton.focus()
|
||||
isOutside.value = true
|
||||
await expect.poll(() => toggle.getAttribute('aria-expanded'), { timeout: 3500 }).toBe('false')
|
||||
expect(document.activeElement).toBe(toggle)
|
||||
await expect.poll(() => island.offsetHeight === main.offsetHeight).toBe(true)
|
||||
expect(menu.closest('[inert]')).not.toBeNull()
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2474
|
||||
it('assigns sideways overflow to the necessary menu axes without nested scrolling (PR #2474)', async () => {
|
||||
await page.viewport(600, 600)
|
||||
const { i18n, screen } = mountControlsIsland('top-left', 'small')
|
||||
await screen.getByLabelText(i18n.global.t('tamagotchi.stage.controls-island.expand'), { exact: true }).click()
|
||||
const island = screen.getByTestId('controls-island').element() as HTMLElement
|
||||
const main = screen.getByTestId('main-controls').element() as HTMLElement
|
||||
const menu = screen.getByTestId('controls-menu').element() as HTMLElement
|
||||
const content = menu.querySelector<HTMLElement>('.w-max')!
|
||||
const viewport = menu.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
const outer = island.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')!
|
||||
await expect.poll(() => scrollOwners(island)).toHaveLength(0)
|
||||
// Extra auth-row spacing exercises content growth with native layout intact.
|
||||
const login = menu.querySelector<HTMLButtonElement>('button')!
|
||||
login.style.paddingBlock = '3rem'
|
||||
await expect.poll(() => content.offsetHeight).toBeGreaterThan(main.offsetHeight + 20)
|
||||
const menuHeight = content.offsetHeight
|
||||
const narrowWidth = main.offsetWidth + 12 + content.offsetWidth - 20 + 16
|
||||
for (const [width, height, horizontal, vertical] of [
|
||||
[600, menuHeight + 16, false, false],
|
||||
[narrowWidth, menuHeight + 16, true, false],
|
||||
[600, menuHeight + 6, false, true],
|
||||
[narrowWidth, menuHeight + 6, true, true],
|
||||
] as const) {
|
||||
await page.viewport(width, height)
|
||||
await expect.poll(() => island.dataset.direction).toBe('right')
|
||||
await expect.poll(() => viewport.scrollWidth > viewport.clientWidth).toBe(horizontal)
|
||||
await expect.poll(() => viewport.scrollHeight > viewport.clientHeight).toBe(vertical)
|
||||
await expect.poll(() => outer.scrollWidth === outer.clientWidth).toBe(true)
|
||||
await expect.poll(() => outer.scrollHeight === outer.clientHeight).toBe(true)
|
||||
viewport.scrollTo(viewport.scrollWidth, viewport.scrollHeight)
|
||||
await nextTick()
|
||||
if (horizontal)
|
||||
expect(viewport.scrollLeft).toBeGreaterThan(0)
|
||||
if (vertical)
|
||||
expect(viewport.scrollTop).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
+5
@@ -8,6 +8,9 @@ import { useControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps<{ active: boolean }>()
|
||||
const emit = defineEmits<{ interactionChange: [active: boolean] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const openSettings = useElectronEventaInvoke(electronOpenSettings)
|
||||
@@ -23,8 +26,10 @@ function handleManage() {
|
||||
<template>
|
||||
<ProfileSwitcherPopover
|
||||
v-model:open="open"
|
||||
:active="props.active"
|
||||
:content-side="contentSide"
|
||||
:content-align="contentAlign"
|
||||
@interaction-change="emit('interactionChange', $event)"
|
||||
@manage="handleManage"
|
||||
>
|
||||
<template #default="{ open: popoverOpen, toggle, activeCard }">
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ vi.mock('pinia', () => ({
|
||||
|
||||
vi.mock('reka-ui', () => ({
|
||||
TooltipContent: { template: '<div><slot /></div>', inheritAttrs: false },
|
||||
TooltipPortal: { template: '<div><slot /></div>' },
|
||||
TooltipProvider: { template: '<div><slot /></div>' },
|
||||
TooltipRoot: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
|
||||
+119
-32
@@ -3,10 +3,10 @@ import { defineInvoke } from '@moeru/eventa'
|
||||
import { useElectronEventaContext, useElectronEventaInvoke, useElectronMouseInElement } from '@proj-airi/electron-vueuse'
|
||||
import { IS_DEV } from '@proj-airi/stage-shared'
|
||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { refDebounced, useIntervalFn } from '@vueuse/core'
|
||||
import { ScrollableArea, useTheme } from '@proj-airi/ui'
|
||||
import { refDebounced, useIntervalFn, useMousePressed } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, reactive, ref, useId, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import StatusIsland from '../status-island/index.vue'
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
electronStartDraggingWindow,
|
||||
electronWindowSetAlwaysOnTop,
|
||||
} from '../../../../shared/eventa'
|
||||
import { useControlsIslandLayout } from './use-controls-island-layout'
|
||||
import { useControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
interface Emits {
|
||||
@@ -39,7 +40,8 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
const { isDark, toggleDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const { dock, isLeft, isTop, motionPhase } = useControlsIslandPlacement()
|
||||
const placement = useControlsIslandPlacement()
|
||||
const { dock, isLeft, isTop, motionPhase } = placement
|
||||
|
||||
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||
const settingsStore = useSettings()
|
||||
@@ -54,11 +56,36 @@ const setAlwaysOnTop = useElectronEventaInvoke(electronWindowSetAlwaysOnTop)
|
||||
const centerMainWindow = useElectronEventaInvoke(electronCenterMainWindow)
|
||||
|
||||
const expanded = ref(false)
|
||||
// Closing disables interaction immediately. Keep layout until the exit
|
||||
// animation ends, then isolate the same menu for natural-size measurement.
|
||||
const panelPresent = ref(false)
|
||||
const islandElement = useTemplateRef<HTMLElement>('island')
|
||||
const islandScrollArea = useTemplateRef<InstanceType<typeof ScrollableArea>>('islandScrollArea')
|
||||
const islandViewport = computed(() => islandScrollArea.value?.viewport)
|
||||
const islandContent = useTemplateRef<HTMLElement>('islandContent')
|
||||
const mainControlsElement = useTemplateRef<HTMLElement>('mainControls')
|
||||
const menuContent = useTemplateRef<HTMLElement>('menuContent')
|
||||
const menuScrollArea = useTemplateRef<InstanceType<typeof ScrollableArea>>('menuScrollArea')
|
||||
const menuViewport = computed(() => menuScrollArea.value?.viewport)
|
||||
const availableSpaceElement = useTemplateRef<HTMLElement>('availableSpace')
|
||||
const gapElement = useTemplateRef<HTMLElement>('gap')
|
||||
const menuId = useId()
|
||||
const profileOpen = ref(false)
|
||||
const { direction, scrollWholeIsland, panelStyle, layoutClasses: islandLayoutClasses, arrowRotation, motionOffset } = useControlsIslandLayout({
|
||||
main: mainControlsElement,
|
||||
menu: menuContent,
|
||||
available: availableSpaceElement,
|
||||
gap: gapElement,
|
||||
viewport: islandViewport,
|
||||
menuViewport,
|
||||
content: islandContent,
|
||||
}, expanded, placement)
|
||||
|
||||
// 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)
|
||||
// A scrollbar drag can leave the visible boundary before the user releases it.
|
||||
const { pressed } = useMousePressed({ target: islandElement })
|
||||
const isBlocked = computed(() => blockingOverlays.size > 0 || pressed.value)
|
||||
|
||||
function setOverlay(key: string, active: boolean) {
|
||||
if (active) {
|
||||
@@ -72,6 +99,7 @@ function setOverlay(key: string, active: boolean) {
|
||||
// The stage page observes this element for cursor hit testing.
|
||||
defineExpose({
|
||||
get element() { return islandElement.value },
|
||||
get overlayActive() { return blockingOverlays.size > 0 || pressed.value },
|
||||
get hearingDialogOpen() { return blockingOverlays.has('hearing') },
|
||||
set hearingDialogOpen(v: boolean) { setOverlay('hearing', v) },
|
||||
})
|
||||
@@ -86,7 +114,12 @@ watch(isOutsideAfter2seconds, (outside) => {
|
||||
})
|
||||
|
||||
watch(expanded, (isExpanded) => {
|
||||
if (isExpanded)
|
||||
panelPresent.value = true
|
||||
if (!isExpanded) {
|
||||
if (menuContent.value?.contains(document.activeElement) || blockingOverlays.size > 0)
|
||||
mainControlsElement.value?.querySelector<HTMLButtonElement>('[aria-controls]')?.focus()
|
||||
profileOpen.value = false
|
||||
blockingOverlays.clear()
|
||||
}
|
||||
})
|
||||
@@ -159,25 +192,20 @@ const islandMotionClasses = computed(() => {
|
||||
isHidden && !isTop.value ? 'translate-y-2' : '',
|
||||
]
|
||||
})
|
||||
const islandLayoutClasses = computed(() => [
|
||||
isTop.value ? 'flex-col-reverse' : 'flex-col',
|
||||
isLeft.value ? 'items-start' : 'items-end',
|
||||
])
|
||||
const mainControlsLayoutClasses = computed(() => [
|
||||
'flex gap-1',
|
||||
isTop.value ? 'flex-col-reverse' : 'flex-col',
|
||||
])
|
||||
const panelPositionClasses = computed(() => {
|
||||
if (dock.value === 'top-left')
|
||||
return ['mt-2', 'origin-top-left']
|
||||
return ['origin-top-left']
|
||||
if (dock.value === 'top-right')
|
||||
return ['mt-2', 'origin-top-right']
|
||||
return ['origin-top-right']
|
||||
if (dock.value === 'bottom-left')
|
||||
return ['mb-2', 'origin-bottom-left']
|
||||
return ['origin-bottom-left']
|
||||
|
||||
return ['mb-2', 'origin-bottom-right']
|
||||
return ['origin-bottom-right']
|
||||
})
|
||||
const panelHiddenTransformClass = computed(() => isTop.value ? '-translate-y-8' : 'translate-y-8')
|
||||
|
||||
/**
|
||||
* This is a know issue (or expected behavior maybe) to Electron.
|
||||
@@ -202,34 +230,56 @@ function resetMainWindowPosition() {
|
||||
<template>
|
||||
<div
|
||||
ref="island"
|
||||
data-testid="controls-island"
|
||||
:data-direction="direction"
|
||||
:data-scroll-owner="scrollWholeIsland ? 'island' : 'menu'"
|
||||
:class="[
|
||||
'fixed',
|
||||
'fixed max-h-[calc(100dvh-1rem)] max-w-[calc(100dvw-1rem)]',
|
||||
islandPositionClasses,
|
||||
islandMotionClasses,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex gap-1',
|
||||
islandLayoutClasses,
|
||||
]"
|
||||
<!-- Probes track viewport/rem sizes without adding scrollable overflow. -->
|
||||
<div aria-hidden="true" :class="['pointer-events-none invisible fixed size-0 overflow-hidden [contain:strict]']">
|
||||
<div ref="availableSpace" :class="['h-[calc(100dvh-1rem)] w-[calc(100dvw-1rem)]']" />
|
||||
<div ref="gap" :class="['size-3']" />
|
||||
</div>
|
||||
<ScrollableArea
|
||||
ref="islandScrollArea"
|
||||
orientation="both"
|
||||
:class="['max-h-[inherit] max-w-[inherit]']"
|
||||
viewport-class="overscroll-contain"
|
||||
>
|
||||
<div ref="islandContent" :class="['relative w-max flex', panelPresent ? 'gap-3' : '', islandLayoutClasses]">
|
||||
<!-- iOS Style Drawer Panel -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-500 cubic-bezier(0.32, 0.72, 0, 1)"
|
||||
leave-active-class="transition-all duration-400 cubic-bezier(0.32, 0.72, 0, 1)"
|
||||
:enter-from-class="`opacity-0 ${panelHiddenTransformClass} scale-90 blur-sm`"
|
||||
:leave-to-class="`opacity-0 ${panelHiddenTransformClass} scale-90 blur-sm`"
|
||||
<div
|
||||
:inert="!expanded"
|
||||
:aria-hidden="!expanded"
|
||||
:class="panelPresent ? 'contents' : 'pointer-events-none invisible absolute size-0 overflow-hidden [contain:strict]'"
|
||||
>
|
||||
<ScrollableArea
|
||||
:id="menuId"
|
||||
ref="menuScrollArea"
|
||||
data-testid="controls-menu"
|
||||
orientation="both"
|
||||
:style="panelStyle"
|
||||
:class="['w-max shrink-0', panelPositionClasses]"
|
||||
viewport-class="overscroll-contain"
|
||||
>
|
||||
<div
|
||||
ref="menuContent"
|
||||
:class="['w-max', expanded ? 'controls-menu-enter' : panelPresent ? 'controls-menu-leave' : 'opacity-0']"
|
||||
:style="{ '--menu-offset': motionOffset }"
|
||||
@animationend.self="panelPresent = expanded"
|
||||
>
|
||||
<div
|
||||
v-if="expanded"
|
||||
:class="[
|
||||
'flex flex-col gap-1 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-800',
|
||||
'w-max flex flex-col gap-1 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-800',
|
||||
'bg-neutral-100/80 shadow-2xl shadow-black/20 backdrop-blur-xl dark:bg-neutral-900/80',
|
||||
panelPositionClasses,
|
||||
]"
|
||||
>
|
||||
<ControlsIslandAuthButton
|
||||
:active="expanded"
|
||||
:button-style="adjustStyleClasses.button"
|
||||
:icon-class="adjustStyleClasses.icon"
|
||||
/>
|
||||
@@ -250,7 +300,7 @@ function resetMainWindowPosition() {
|
||||
</ControlButtonTooltip>
|
||||
|
||||
<ControlButtonTooltip disable-hoverable-content>
|
||||
<ControlsIslandProfilePicker :open="blockingOverlays.has('profile-picker')" @update:open="setOverlay('profile-picker', $event)">
|
||||
<ControlsIslandProfilePicker v-model:open="profileOpen" :active="expanded" @interaction-change="setOverlay('profile-picker', $event)">
|
||||
<template #default="{ toggle }">
|
||||
<ControlButton
|
||||
v-track-button="{ name: 'controls_island_action', action: 'toggle_profile_picker' }"
|
||||
@@ -352,10 +402,12 @@ function resetMainWindowPosition() {
|
||||
</ControlButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
|
||||
<!-- Main Controls -->
|
||||
<div :class="mainControlsLayoutClasses">
|
||||
<div ref="mainControls" data-testid="main-controls" :class="['shrink-0', mainControlsLayoutClasses]">
|
||||
<ControlButtonTooltip side="inward">
|
||||
<ControlButton
|
||||
v-track-button="{
|
||||
@@ -363,11 +415,14 @@ function resetMainWindowPosition() {
|
||||
action: expanded ? 'collapse_controls' : 'expand_controls',
|
||||
}"
|
||||
:button-style="adjustStyleClasses.button"
|
||||
:aria-expanded="expanded"
|
||||
:aria-controls="menuId"
|
||||
:aria-label="expanded ? t('tamagotchi.stage.controls-island.collapse') : t('tamagotchi.stage.controls-island.expand')"
|
||||
@click="toggleControls"
|
||||
>
|
||||
<div
|
||||
:class="[adjustStyleClasses.icon, isTop !== expanded ? 'rotate-180' : 'rotate-0']"
|
||||
:class="adjustStyleClasses.icon"
|
||||
:style="{ transform: `rotate(${arrowRotation}deg)` }"
|
||||
i-solar:alt-arrow-up-line-duotone scale-110 transition-all duration-300
|
||||
text="neutral-800 dark:neutral-300"
|
||||
/>
|
||||
@@ -428,5 +483,37 @@ function resetMainWindowPosition() {
|
||||
</ControlButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.controls-menu-enter {
|
||||
animation: controls-menu-enter 400ms cubic-bezier(0.32, 0.72, 0, 1) both;
|
||||
}
|
||||
|
||||
.controls-menu-leave {
|
||||
animation: controls-menu-leave 300ms ease-in both;
|
||||
}
|
||||
|
||||
@keyframes controls-menu-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(var(--menu-offset)) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes controls-menu-leave {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(var(--menu-offset)) scale(0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { ControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
import { useElementSize, useRafFn, useResizeObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
interface LayoutElements {
|
||||
main: Readonly<Ref<HTMLElement | null>>
|
||||
menu: Readonly<Ref<HTMLElement | null>>
|
||||
available: Readonly<Ref<HTMLElement | null>>
|
||||
gap: Readonly<Ref<HTMLElement | null>>
|
||||
viewport: Readonly<Ref<HTMLElement | undefined>>
|
||||
menuViewport: Readonly<Ref<HTMLElement | undefined>>
|
||||
content: Readonly<Ref<HTMLElement | null>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns renderer-local geometry and scroll alignment for one mounted Island.
|
||||
* Measures unconstrained border boxes, so clipping and animation cannot change
|
||||
* the direction decision. VueUse observers stop with the component scope.
|
||||
*/
|
||||
export function useControlsIslandLayout(elements: LayoutElements, expanded: Ref<boolean>, placement: ControlsIslandPlacement) {
|
||||
const { isLeft, isTop, dock } = placement
|
||||
const main = useElementSize(elements.main, undefined, { box: 'border-box' })
|
||||
const menu = useElementSize(elements.menu, undefined, { box: 'border-box' })
|
||||
const available = useElementSize(elements.available)
|
||||
const gap = useElementSize(elements.gap)
|
||||
const sideways = computed(() => menu.height.value > 0 && available.height.value > 0
|
||||
&& main.height.value + gap.width.value + menu.height.value > available.height.value)
|
||||
const direction = computed(() => sideways.value
|
||||
? (isLeft.value ? 'right' : 'left')
|
||||
: (isTop.value ? 'down' : 'up'))
|
||||
const scrollWholeIsland = computed(() => main.height.value > available.height.value
|
||||
|| main.width.value > available.width.value
|
||||
|| (sideways.value && available.width.value - main.width.value - gap.width.value <= 0))
|
||||
const panelStyle = computed(() => ({
|
||||
maxWidth: scrollWholeIsland.value ? 'none' : `${Math.max(0, available.width.value - (sideways.value ? main.width.value + gap.width.value : 0))}px`,
|
||||
maxHeight: scrollWholeIsland.value ? 'none' : `${Math.max(0, available.height.value - (sideways.value ? 0 : main.height.value + gap.width.value))}px`,
|
||||
}))
|
||||
const layoutClasses = computed(() => sideways.value
|
||||
? [isLeft.value ? 'flex-row-reverse' : 'flex-row', isTop.value ? 'items-start' : 'items-end']
|
||||
: [isTop.value ? 'flex-col-reverse' : 'flex-col', isLeft.value ? 'items-start' : 'items-end'])
|
||||
const arrowRotation = computed(() => (({ up: 0, right: 90, down: 180, left: 270 }[direction.value]) + (expanded.value ? 180 : 0)) % 360)
|
||||
const motionOffset = computed(() => ({ up: '0, 2rem', down: '0, -2rem', left: '2rem, 0', right: '-2rem, 0' }[direction.value]))
|
||||
|
||||
function alignScrollPosition() {
|
||||
const viewport = elements.viewport.value
|
||||
if (!viewport)
|
||||
return
|
||||
|
||||
viewport.scrollTop = isTop.value ? 0 : viewport.scrollHeight - viewport.clientHeight
|
||||
viewport.scrollLeft = isLeft.value ? 0 : viewport.scrollWidth - viewport.clientWidth
|
||||
|
||||
// Focus visibility takes precedence over docking after a layout change.
|
||||
const focused = document.activeElement
|
||||
if (focused instanceof HTMLElement && viewport.contains(focused))
|
||||
focused.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// Defer scroll alignment to the next animation frame after an observer runs.
|
||||
// scrollIntoView can change scrollbar geometry during ResizeObserver delivery.
|
||||
// Chromium then logs "ResizeObserver loop completed with undelivered notifications"
|
||||
// during rapid Controls Island layout changes.
|
||||
// Source/context: https://github.com/moeru-ai/airi/pull/2474#discussion_r3954626137
|
||||
// Removal condition: Remove this scheduling when Chromium no longer logs the
|
||||
// warning and the Controls Island browser tests pass without deferred alignment.
|
||||
const { pause, resume } = useRafFn(() => {
|
||||
pause()
|
||||
alignScrollPosition()
|
||||
}, { immediate: false })
|
||||
|
||||
// Observe actual geometry, never scroll offsets. User scrolling must persist.
|
||||
useResizeObserver(elements.viewport, resume)
|
||||
useResizeObserver(elements.content, resume)
|
||||
watch([dock, expanded, direction, main.width, main.height, menu.width, menu.height, available.width, available.height], async () => {
|
||||
await nextTick()
|
||||
resume()
|
||||
}, { flush: 'post' })
|
||||
watch(expanded, async (open) => {
|
||||
if (!open)
|
||||
return
|
||||
await nextTick()
|
||||
elements.menuViewport.value?.scrollTo(0, 0)
|
||||
}, { flush: 'post' })
|
||||
|
||||
return { direction, scrollWholeIsland, panelStyle, layoutClasses, arrowRotation, motionOffset }
|
||||
}
|
||||
@@ -140,7 +140,7 @@ const isAroundWindowBorderFor250Ms = refDebounced(isAroundWindowBorder, 250)
|
||||
|
||||
const setIgnoreMouseEvents = useElectronEventaInvoke(electron.window.setIgnoreMouseEvents)
|
||||
|
||||
const hearingDialogOpen = computed(() => controlsIslandRef.value?.hearingDialogOpen ?? false)
|
||||
const controlsOverlayActive = computed(() => controlsIslandRef.value?.overlayActive ?? false)
|
||||
|
||||
const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
|
||||
const hasModel = !!stageModelSelectedUrl.value
|
||||
@@ -247,7 +247,7 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
|
||||
* Upstream:
|
||||
* - {@link isOutsideFor250Ms} and {@link isAroundWindowBorderFor250Ms}
|
||||
* - {@link isOutsideWindow}, {@link isTransparent}, and {@link isTransparentForMouseEvents}
|
||||
* - {@link hearingDialogOpen}, {@link fadeOnHoverEnabled}, and {@link stagePaused}
|
||||
* - {@link controlsOverlayActive}, {@link fadeOnHoverEnabled}, and {@link stagePaused}
|
||||
*
|
||||
* Downstream:
|
||||
* - {@link resolveFadeOnHoverInteraction}
|
||||
@@ -261,8 +261,8 @@ function handleFadeOnHoverInteractionChange() {
|
||||
return
|
||||
}
|
||||
|
||||
if (hearingDialogOpen.value) {
|
||||
// Hearing dialog/drawer is open; keep window interactive
|
||||
if (controlsOverlayActive.value) {
|
||||
// Portaled controls must receive clicks even outside the Island's bounds.
|
||||
isIgnoringMouseEvents.value = false
|
||||
shouldFadeOnCursorWithin.value = false
|
||||
setIgnoreMouseEvents([false, { forward: true }])
|
||||
@@ -293,7 +293,7 @@ function handleFadeOnHoverInteractionChange() {
|
||||
}
|
||||
|
||||
watch(
|
||||
[isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, isTransparentForMouseEvents, hearingDialogOpen, fadeOnHoverEnabled, stagePaused],
|
||||
[isOutsideFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, isTransparentForMouseEvents, controlsOverlayActive, fadeOnHoverEnabled, stagePaused],
|
||||
handleFadeOnHoverInteractionChange,
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
import type { SelectContentProps } from 'reka-ui'
|
||||
|
||||
import { Select } from '@proj-airi/ui'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { onClickOutside, useElementBounding, useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, toRaw, watch } from 'vue'
|
||||
import { computed, nextTick, onScopeDispose, ref, toRaw, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAiriCardStore } from '../../stores/modules/airi-card'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
active: true,
|
||||
contentAlign: 'start',
|
||||
contentSide: 'bottom',
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'manage'): void
|
||||
/** Includes the selector and its portaled create form for host interaction protection. */
|
||||
(e: 'interactionChange', active: boolean): void
|
||||
}>()
|
||||
const CREATE_PROFILE_ACTION = '__create-profile__'
|
||||
const MANAGE_PROFILE_ACTION = '__manage-profile__'
|
||||
@@ -23,9 +26,18 @@ type ProfileSelectValue = string | typeof CREATE_PROFILE_ACTION | typeof MANAGE_
|
||||
|
||||
/** Placement preferences for the profile selector and its create form. */
|
||||
export interface Props {
|
||||
/** Horizontal alignment before collision handling. */
|
||||
/**
|
||||
* Stops all overlays when the host menu closes.
|
||||
* @default true
|
||||
*/
|
||||
active?: boolean
|
||||
/** Horizontal alignment before collision handling.
|
||||
* @default 'start'
|
||||
*/
|
||||
contentAlign?: Extract<SelectContentProps['align'], 'start' | 'end'>
|
||||
/** Vertical side before collision handling. */
|
||||
/** Vertical side before collision handling.
|
||||
* @default 'bottom'
|
||||
*/
|
||||
contentSide?: Extract<SelectContentProps['side'], 'top' | 'bottom'>
|
||||
}
|
||||
|
||||
@@ -39,6 +51,31 @@ const creatingNew = ref(false)
|
||||
const newProfileName = ref('')
|
||||
const nameInputRef = ref<HTMLInputElement>()
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const { x: containerX, y: containerY, width: containerWidth, height: containerHeight } = useElementBounding(containerRef)
|
||||
const createFormRef = ref<HTMLElement>()
|
||||
const createContentRef = ref<HTMLElement>()
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize()
|
||||
const { width: formWidth } = useElementSize(createFormRef, undefined, { box: 'border-box' })
|
||||
const { height: formHeight } = useElementSize(createContentRef, undefined, { box: 'border-box' })
|
||||
const createFormStyle = computed(() => {
|
||||
// The portal cannot use the Island's scroll area. Fit it to the viewport,
|
||||
// flip toward the larger vertical space, then clamp if neither side fits.
|
||||
const margin = 8
|
||||
const height = Math.min(formHeight.value, Math.max(0, windowHeight.value - margin * 2))
|
||||
const above = containerY.value - margin * 2
|
||||
const below = windowHeight.value - containerY.value - containerHeight.value - margin * 2
|
||||
let topSide = props.contentSide === 'top'
|
||||
if ((topSide ? above : below) < height)
|
||||
topSide = above > below
|
||||
const anchorLeft = props.contentAlign === 'start' ? containerX.value : containerX.value + containerWidth.value - formWidth.value
|
||||
const anchorTop = topSide ? containerY.value - margin - height : containerY.value + containerHeight.value + margin
|
||||
return {
|
||||
left: `${Math.max(margin, Math.min(anchorLeft, windowWidth.value - formWidth.value - margin))}px`,
|
||||
top: `${Math.max(margin, Math.min(anchorTop, windowHeight.value - height - margin))}px`,
|
||||
width: 'min(14rem, calc(100dvw - 1rem))',
|
||||
maxHeight: 'calc(100dvh - 1rem)',
|
||||
}
|
||||
})
|
||||
|
||||
const cardsList = computed(() =>
|
||||
Array.from(cards.value.entries()).map(([id, card]) => ({ id, name: card.name })),
|
||||
@@ -78,11 +115,16 @@ const selectOptions = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
// Selector close and form open are one transition. Only the host becoming
|
||||
// inactive cancels both surfaces; Select closes itself after selection.
|
||||
watch(() => props.active, (active) => {
|
||||
if (!active) {
|
||||
open.value = false
|
||||
cancelCreate()
|
||||
}
|
||||
})
|
||||
watch(() => open.value || creatingNew.value, active => emit('interactionChange', active), { immediate: true })
|
||||
onScopeDispose(() => emit('interactionChange', false))
|
||||
|
||||
watch(activeCardId, (value) => {
|
||||
selectedProfile.value = value
|
||||
@@ -93,13 +135,17 @@ watch(selectedProfile, (value, previousValue) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Restoring the active card after choosing "Save as new" is part of the
|
||||
// close-to-create transition, not a user selection that cancels creation.
|
||||
if (previousValue === CREATE_PROFILE_ACTION && value === activeCardId.value)
|
||||
return
|
||||
|
||||
handleSelection(value)
|
||||
})
|
||||
|
||||
onClickOutside(containerRef, () => {
|
||||
open.value = false
|
||||
cancelCreate()
|
||||
})
|
||||
}, { ignore: [createFormRef] })
|
||||
|
||||
async function handleSelection(value: ProfileSelectValue) {
|
||||
if (value === CREATE_PROFILE_ACTION) {
|
||||
@@ -110,11 +156,13 @@ async function handleSelection(value: ProfileSelectValue) {
|
||||
|
||||
if (value === MANAGE_PROFILE_ACTION) {
|
||||
selectedProfile.value = activeCardId.value
|
||||
cancelCreate()
|
||||
handleManage()
|
||||
return
|
||||
}
|
||||
|
||||
await cardStore.activateCard(value)
|
||||
cancelCreate()
|
||||
}
|
||||
|
||||
async function showCreateInput() {
|
||||
@@ -288,12 +336,14 @@ function toggleOpen() {
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="creatingNew"
|
||||
ref="createFormRef"
|
||||
data-testid="profile-create-form"
|
||||
:style="createFormStyle"
|
||||
:class="[
|
||||
'absolute z-[10011] w-56 rounded-xl border-2 p-2 shadow-sm backdrop-blur-xl',
|
||||
props.contentSide === 'top' ? 'bottom-full mb-2' : 'top-full mt-2',
|
||||
props.contentAlign === 'start' ? 'left-0' : 'right-0',
|
||||
'fixed z-[10011] overflow-auto rounded-xl shadow-sm backdrop-blur-xl',
|
||||
props.contentSide === 'top' && props.contentAlign === 'start' ? 'origin-bottom-left' : '',
|
||||
props.contentSide === 'top' && props.contentAlign === 'end' ? 'origin-bottom-right' : '',
|
||||
props.contentSide === 'bottom' && props.contentAlign === 'start' ? 'origin-top-left' : '',
|
||||
@@ -301,14 +351,14 @@ function toggleOpen() {
|
||||
'border-neutral-200 bg-white/95 dark:border-neutral-800 dark:bg-neutral-900/95',
|
||||
]"
|
||||
>
|
||||
<div :class="['flex items-center gap-2']">
|
||||
<div ref="createContentRef" :class="['flex flex-wrap items-center justify-end gap-2 border-2 rounded-xl p-2']">
|
||||
<input
|
||||
ref="nameInputRef"
|
||||
v-model="newProfileName"
|
||||
type="text"
|
||||
:placeholder="t('stage.profile-switcher.new-profile-name')"
|
||||
:class="[
|
||||
'min-w-0 flex-1 rounded-lg border-2 px-2 py-1 text-sm outline-none transition-colors',
|
||||
'min-w-0 w-full 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
|
||||
@@ -326,6 +376,8 @@ function toggleOpen() {
|
||||
(newProfileName.trim() && !isDuplicateName) ? '' : 'pointer-events-none opacity-30',
|
||||
]"
|
||||
type="button"
|
||||
:aria-label="t('stage.profile-switcher.save-as-new')"
|
||||
:disabled="!newProfileName.trim() || isDuplicateName"
|
||||
@click="confirmCreate"
|
||||
>
|
||||
<div class="i-solar:check-circle-bold size-4.5" />
|
||||
@@ -343,6 +395,7 @@ function toggleOpen() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user