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:
Younsang Na
2026-09-09 14:20:51 +08:00
committed by GitHub
co-authored by leafyy
parent 7bcd8e5274
commit 5e78b64e35
10 changed files with 1123 additions and 320 deletions
@@ -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;
@@ -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()
@@ -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
}
@@ -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)
}
})
@@ -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 }">
@@ -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>' },
@@ -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>
@@ -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>