feat(stage-layouts): add mobile view adjustment mode (#2475)
This commit is contained in:
@@ -7,10 +7,13 @@ import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/M
|
||||
import ChatArea from '@proj-airi/stage-layouts/components/Widgets/ChatArea'
|
||||
|
||||
import { PiniaColada } from '@pinia/colada'
|
||||
import { useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { browserAuthorizationHandler, registerAuthorizationHandler } from '@proj-airi/stage-ui/libs/auth'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
@@ -35,6 +38,8 @@ function createTestI18n() {
|
||||
}
|
||||
|
||||
async function renderArea(component: Component = InteractiveArea) {
|
||||
useL2dViewControl().viewControlsEnabled.value = false
|
||||
useThreeViewControl().viewControlsEnabled.value = false
|
||||
const sessionB: ChatSessionMeta = {
|
||||
sessionId: 'session-b',
|
||||
userId: 'local',
|
||||
@@ -73,6 +78,7 @@ async function renderArea(component: Component = InteractiveArea) {
|
||||
chat: useChatStore(pinia),
|
||||
chatSession: useChatSessionStore(pinia),
|
||||
chatStream: useChatStreamStore(pinia),
|
||||
stageModel: useSettingsStageModel(pinia),
|
||||
screen,
|
||||
}
|
||||
}
|
||||
@@ -173,6 +179,171 @@ describe('interactive area synchronized state', () => {
|
||||
await expect.element(screen.getByRole('dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
it('uses the full mobile width for chat history after removing the action rail', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The removed right action rail left a fixed 3.5rem reservation on the
|
||||
// chat history. Long messages and the scrollbar still stopped before the
|
||||
// right edge even though the controls no longer occupied that space.
|
||||
await page.viewport(390, 844)
|
||||
const { screen } = await renderArea(MobileInteractiveArea)
|
||||
const history = screen.container.querySelector<HTMLElement>('.chat-history')
|
||||
|
||||
expect(history).not.toBeNull()
|
||||
expect(history!.getBoundingClientRect().width).toBe(390)
|
||||
})
|
||||
|
||||
it('opens view controls on the stage and closes them from the top right', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const viewControl = useL2dViewControl()
|
||||
viewControl.viewControlsEnabled.value = false
|
||||
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
|
||||
await expect.element(screen.getByRole('dialog', { name: 'stage.mobile-tools.title' })).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).not.toBeVisible()
|
||||
await expect.element(screen.getByTestId('conversation-selector-button')).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'X', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Y', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Scale', exact: true })).toBeVisible()
|
||||
|
||||
const close = screen.getByTestId('view-controls-close-button').element()
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The drawer suppressed focus restoration before emitting the mode change,
|
||||
// but the Stage did not move focus into the newly rendered controls.
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).toHaveFocus()
|
||||
expect(close.getBoundingClientRect().right).toBe(378)
|
||||
expect(close.getBoundingClientRect().top).toBe(12)
|
||||
const toolbar = screen.getByTestId('view-controls-toolbar').element()
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Fixed horizontal padding ignored display cutouts in landscape viewports.
|
||||
// The toolbar now uses the same left and right safe-area minimum as the header.
|
||||
expect(toolbar.classList).toContain('pl-[max(0.75rem,env(safe-area-inset-left))]')
|
||||
expect(toolbar.classList).toContain('pr-[max(0.75rem,env(safe-area-inset-right))]')
|
||||
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.close-view', exact: true }).click()
|
||||
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible()
|
||||
await expect.element(screen.getByTestId('conversation-selector-button')).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'X', exact: true })).not.toBeInTheDocument()
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Closing view mode removed its focused header without moving focus to the
|
||||
// newly mounted normal header, so keyboard users fell back to the document body.
|
||||
await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus()
|
||||
expect(viewControl.viewControlsEnabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a docked input bubble mounted while view controls are open', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Entering view mode removed the composer subtree. Its dock animation stores
|
||||
// opacity and position on the mounted elements, while the docked state survives.
|
||||
// Recreating the subtree therefore lost the visual state when view mode closed.
|
||||
await page.viewport(390, 844)
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
const bubble = screen.getByTestId('mobile-input-bubble').element()
|
||||
const input = screen.getByRole('textbox').element()
|
||||
const icon = bubble.querySelector<HTMLElement>('[aria-hidden="true"]')!
|
||||
const bounds = bubble.getBoundingClientRect()
|
||||
const pointer = {
|
||||
bubbles: true,
|
||||
clientX: bounds.left + bounds.width / 2,
|
||||
clientY: bounds.top + bounds.height / 2,
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
}
|
||||
vi.spyOn(bubble, 'setPointerCapture').mockImplementation(() => {})
|
||||
|
||||
bubble.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, button: 0, buttons: 1 }))
|
||||
await new Promise(resolve => setTimeout(resolve, 550))
|
||||
bubble.dispatchEvent(new PointerEvent('pointermove', { ...pointer, buttons: 1, clientY: pointer.clientY - 80 }))
|
||||
bubble.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientY: pointer.clientY - 80 }))
|
||||
await expect.poll(() => getComputedStyle(input).opacity).toBe('0')
|
||||
expect(getComputedStyle(icon).opacity).toBe('1')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
|
||||
expect(bubble.isConnected).toBe(true)
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).not.toBeVisible()
|
||||
|
||||
await screen.getByTestId('view-controls-close-button').click()
|
||||
|
||||
expect(screen.getByTestId('mobile-input-bubble').element()).toBe(bubble)
|
||||
expect(getComputedStyle(input).opacity).toBe('0')
|
||||
expect(getComputedStyle(icon).opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('closes view controls with Escape and restores focus', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Moving view controls out of the dismissible drawer removed its Escape
|
||||
// behavior, leaving keyboard users in the focused Stage mode.
|
||||
await page.viewport(390, 844)
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).toHaveFocus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).not.toBeInTheDocument()
|
||||
await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('exits view controls when the active renderer changes', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Mobile view mode combined both renderer flags. After a renderer switch,
|
||||
// the previous flag kept the chat hidden while the new renderer had no controls.
|
||||
// The active renderer now owns the visible mode, and transitions clear stale flags.
|
||||
await page.viewport(390, 844)
|
||||
const live2dViewControl = useL2dViewControl()
|
||||
const threeViewControl = useThreeViewControl()
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('live2d')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).toBeVisible()
|
||||
|
||||
stageModel.setStageModelRenderer('vrm')
|
||||
|
||||
await expect.element(screen.getByTestId('mobile-message-composer')).toBeVisible()
|
||||
await expect.element(screen.getByTestId('view-controls-close-button')).not.toBeInTheDocument()
|
||||
expect(live2dViewControl.viewControlsEnabled.value).toBe(false)
|
||||
expect(threeViewControl.viewControlsEnabled.value).toBe(false)
|
||||
await expect.element(screen.getByTestId('mobile-settings-button')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows all five mobile view controls for VRM models', async () => {
|
||||
await page.viewport(390, 844)
|
||||
const { screen, stageModel } = await renderArea(MobileInteractiveArea)
|
||||
stageModel.setStageModelRenderer('vrm')
|
||||
|
||||
await screen.getByTestId('mobile-settings-button').click()
|
||||
await screen.getByRole('button', { name: 'stage.mobile-tools.view', exact: true }).click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'X', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Y', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Z', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'Dis', exact: true })).toBeVisible()
|
||||
await expect.element(screen.getByRole('button', { name: 'FOV', exact: true })).toBeVisible()
|
||||
|
||||
await screen.getByTestId('view-controls-close-button').click()
|
||||
})
|
||||
|
||||
it('keeps the empty mobile input compact and aligns the one-line send action', async () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user