diff --git a/app/game-frontend/e2e/chiefCommandMapLive.spec.ts b/app/game-frontend/e2e/chiefCommandMapLive.spec.ts index 01bb0a44..e2ab52ba 100644 --- a/app/game-frontend/e2e/chiefCommandMapLive.spec.ts +++ b/app/game-frontend/e2e/chiefCommandMapLive.spec.ts @@ -245,10 +245,7 @@ test('shows and operates a city target map through live PostgreSQL, Game API, an expect(geometry.display).not.toBe('none'); await page.screenshot({ path: testInfo.outputPath('chief-command-city-map-live.png'), fullPage: true }); - await page - .getByRole('button', { name: '명령 입력 닫기', exact: true }) - .click({ timeout: 2_000 }) - .catch(() => undefined); + await page.getByRole('button', { name: '명령 취소', exact: true }).click(); await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); const nationPicker = page.getByTestId('command-picker'); await nationPicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); @@ -264,10 +261,7 @@ test('shows and operates a city target map through live PostgreSQL, Game API, an ); await page.screenshot({ path: testInfo.outputPath('chief-command-nation-map-live.png'), fullPage: true }); - await page - .getByRole('button', { name: '명령 입력 닫기', exact: true }) - .click({ timeout: 2_000 }) - .catch(() => undefined); + await page.getByRole('button', { name: '명령 취소', exact: true }).click(); await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click(); const assignmentPicker = page.getByTestId('command-picker'); await assignmentPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index d1f2f345..191faeb4 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -2124,7 +2124,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk await picker.getByRole('button', { name: '징병', exact: true }).click(); await expect(picker).toHaveAttribute('role', 'dialog'); await expect(picker).toHaveAttribute('aria-modal', 'true'); - await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused(); + await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused(); const form = picker.getByTestId('recruitment-command-form'); await expect(form).toContainText('현재 기술력 : 1등급'); await expect(form).toContainText('공격'); @@ -2196,7 +2196,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk picker = page.getByTestId('command-picker'); await picker.getByRole('button', { name: '내정', exact: true }).click(); await picker.getByRole('button', { name: '징병', exact: true }).click(); - await expect(picker.getByRole('button', { name: '명령 입력 닫기', exact: true })).toBeFocused(); + await expect(picker.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused(); const referenceWidthGeometry = await picker.evaluate((element) => { const formElement = element.querySelector('[data-testid="recruitment-command-form"]')!; const row = formElement.querySelector('.crew-row')!; @@ -2277,6 +2277,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk return { scrollTop: element.scrollTop, headerTop: header.getBoundingClientRect().top, + headerBottom: header.getBoundingClientRect().bottom, listFrontTop: listFront.getBoundingClientRect().top, actionsBottom: actions.getBoundingClientRect().bottom, viewportHeight: window.innerHeight, @@ -2284,7 +2285,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk }); expect(stickyGeometry.scrollTop).toBeGreaterThan(0); expect(stickyGeometry.headerTop).toBe(0); - expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(44); + expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(stickyGeometry.headerBottom); expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight); await page.setViewportSize({ width: 390, height: 844 }); @@ -3299,7 +3300,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await drag(0, 2); await expect(editor.locator('.index-column > button.selected')).toHaveCount(3); await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click(); - const picker = editor.getByTestId('command-picker'); + const picker = page.getByTestId('command-picker'); const blockedFire = picker.getByRole('button', { name: '화계', exact: true }); await expect(blockedFire).toBeEnabled(); await blockedFire.click(); @@ -3717,7 +3718,7 @@ for (const width of [1200, 390]) { let picker = page.getByTestId('command-picker'); await picker.getByRole('button', { name: /화계/ }).click(); let form = picker.getByTestId('command-argument-form'); - const toggle = form.getByRole('button', { name: '검색 꺼짐', exact: true }); + const toggle = picker.getByRole('button', { name: '검색 꺼짐', exact: true }); await expect(form.locator('input[type=search]')).toHaveCount(0); await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3); await form.getByTestId('city-target-list').getByRole('button', { name: /허창/ }).click(); @@ -3743,7 +3744,7 @@ for (const width of [1200, 390]) { await input.press('Enter'); await expect(input).not.toBeFocused(); await expect(picker).toBeVisible(); - await form.getByRole('button', { name: '검색 켜짐', exact: true }).click(); + await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click(); await expect(input).toHaveCount(0); await expect(results.locator('button')).toHaveCount(3); await expect(form.locator('#command-arg-destCityId')).toHaveValue('2'); @@ -3758,7 +3759,7 @@ for (const width of [1200, 390]) { form = picker.getByTestId('command-argument-form'); await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(3); await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3); - await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); + await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); const generalSearch = form.locator('#command-search-destGeneralId'); const citySearch = form.locator('#command-search-destCityId'); for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) { @@ -3801,7 +3802,7 @@ for (const width of [1200, 390]) { await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); await picker.getByRole('button', { name: /선전포고/ }).click(); form = picker.getByTestId('command-argument-form'); - await expect(form.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute( + await expect(picker.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute( 'aria-pressed', 'true' ); @@ -3821,7 +3822,7 @@ for (const width of [1200, 390]) { await input.press('Escape'); await expect(input).toHaveValue(''); await expect(picker).toBeVisible(); - await form.getByRole('button', { name: '검색 켜짐', exact: true }).click(); + await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click(); await expect(input).toHaveCount(0); await expect(results.locator('button')).toHaveCount(3); }); @@ -3856,7 +3857,7 @@ test('gift search indexes nullable names without matching the displayed no-troop const results = form.getByTestId('general-target-list'); await expect(results.locator('button')).toHaveCount(3); await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음'); - await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); + await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); const search = form.locator('input[type=search]'); await search.fill('ㅇㅇ'); await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']); @@ -3896,7 +3897,7 @@ test('search reflects an active Korean IME composition after debounce without co const picker = page.getByTestId('command-picker'); await picker.getByRole('button', { name: /증여/ }).click(); const form = picker.getByTestId('command-argument-form'); - await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); + await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); const input = form.locator('input[type=search]'); const results = form.getByTestId('general-target-list'); await input.focus(); @@ -3928,11 +3929,180 @@ test('search reflects an active Korean IME composition after debounce without co await form.getByRole('button', { name: '지우기', exact: true }).click(); await expect(results.locator('button')).toHaveCount(3); await input.fill('ㄱㅇ'); - await form.getByRole('button', { name: '검색 켜짐', exact: true }).click(); + await picker.getByRole('button', { name: '검색 켜짐', exact: true }).click(); await page.waitForTimeout(250); // 예약된 디바운스가 OFF 이후 재적용되지 않는지 확인한다. await expect(results.locator('button')).toHaveCount(3); - await form.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); + await picker.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); await expect(input).toHaveValue(''); await expect(results.locator('button')).toHaveCount(3); await cdp.detach(); }); + +// Ref v_processing처럼 인자 입력은 화면 전체를 차지하되, Core는 URL을 바꾸지 않고 +// Back·명령 취소·명령 다시 선택·Esc로 같은 문서의 overlay만 닫는다. +for (const viewport of [ + { width: 1200, height: 900 }, + { width: 500, height: 900 }, + { width: 390, height: 844 }, +] as const) { + test(`opens every argument input as a full-screen overlay with a top control bar at ${viewport.width}px`, async ({ + page, + }, testInfo) => { + const requests = await install(page); + await page.setViewportSize(viewport); + await page.goto(gamePath('/')); + const mainUrl = page.url(); + const initialHistoryLength = await page.evaluate(() => history.length); + const editor = page.locator('[data-command-scope="general"]:visible'); + + const openFireAttack = async (turn: number) => { + await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click(); + const listPicker = page.getByTestId('command-picker'); + await expect(listPicker).not.toHaveAttribute('role', 'dialog'); + await listPicker.getByRole('button', { name: /화계/ }).click(); + const overlay = page.getByRole('dialog', { name: `화계 ${turn}턴 명령 입력` }); + await expect(overlay).toBeVisible(); + return overlay; + }; + const measure = (overlay: ReturnType) => + overlay.evaluate((element) => { + const bar = element.querySelector('[data-testid="command-input-top-bar"]')!; + const map = element.querySelector('[data-testid="command-argument-map"]'); + const fields = element.querySelector('.argument-fields'); + const rect = (target: Element | null) => target?.getBoundingClientRect().toJSON() ?? null; + return { + parentIsBody: element.parentElement === document.body, + overlay: rect(element), + bar: rect(bar), + buttons: [...bar.querySelectorAll('button')].map((button) => ({ + text: button.textContent?.trim(), + ...button.getBoundingClientRect().toJSON(), + })), + title: bar.querySelector('h2')?.textContent?.replace(/\s+/g, ' ').trim(), + map: rect(map), + fields: rect(fields), + bodyOverflow: getComputedStyle(document.body).overflow, + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + + let overlay = await openFireAttack(1); + await expect(overlay).toHaveAttribute('aria-modal', 'true'); + await expect(overlay.getByRole('button', { name: '명령 취소', exact: true })).toBeFocused(); + await page.evaluate(() => document.fonts.ready); + const geometry = await measure(overlay); + await writeFile( + testInfo.outputPath(`argument-overlay-${viewport.width}.json`), + JSON.stringify(geometry, null, 2) + ); + await page.screenshot({ path: testInfo.outputPath(`argument-overlay-${viewport.width}.png`) }); + expect(geometry.parentIsBody).toBe(true); + expect(geometry.overlay).toMatchObject({ x: 0, y: 0, width: viewport.width, height: viewport.height }); + expect(geometry.bar?.y).toBe(0); + expect(geometry.buttons.map((button) => button.text)).toEqual(['명령 취소', '명령 다시 선택', '검색 꺼짐']); + expect(geometry.title).toBe('화계1턴'); + expect(geometry.bodyOverflow).toBe('hidden'); + expect(geometry.scrollWidth).toBe(geometry.clientWidth); + for (const button of geometry.buttons) { + expect(button.x).toBeGreaterThanOrEqual(0); + expect(button.right).toBeLessThanOrEqual(viewport.width); + } + if (viewport.width >= 1120) { + // 원본 700px 지도 오른쪽에 대상 목록 열을 두고 1120px 폭(1px 테두리 포함) 안에서 중앙 정렬한다. + expect(geometry.map).toMatchObject({ x: (viewport.width - 1120) / 2 + 1, width: 700 }); + expect(geometry.fields?.x).toBe(geometry.map!.right); + expect(geometry.fields?.y).toBe(geometry.map!.y); + expect(geometry.fields?.right).toBe((viewport.width + 1120) / 2 - 1); + } else { + expect(geometry.fields!.y).toBeGreaterThanOrEqual(geometry.map!.bottom); + expect(geometry.map!.width).toBeLessThanOrEqual(viewport.width); + } + + // 검색 토글은 상단 바에 있고 인자 폼의 검색 입력을 제어한다. + await overlay.getByRole('button', { name: '검색 꺼짐', exact: true }).click(); + await expect(overlay.getByRole('button', { name: '검색 켜짐', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(overlay.locator('input[type=search]')).toHaveCount(1); + await overlay.getByRole('button', { name: '검색 켜짐', exact: true }).click(); + await expect(overlay.locator('input[type=search]')).toHaveCount(0); + + // 명령 취소: overlay와 명령 목록을 함께 닫고 Back entry를 소비한다. + await overlay.getByRole('button', { name: '명령 취소', exact: true }).click(); + await expect(page.getByTestId('command-picker')).toHaveCount(0); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden'); + await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1); + expect(page.url()).toBe(mainUrl); + + // 브라우저 Back: 페이지를 떠나지 않고 overlay만 닫는다. + await openFireAttack(2); + await page.goBack(); + await expect(page.getByTestId('command-picker')).toHaveCount(0); + expect(page.url()).toBe(mainUrl); + await expect(editor).toBeVisible(); + + // Esc + await openFireAttack(3); + await page.keyboard.press('Escape'); + await expect(page.getByTestId('command-picker')).toHaveCount(0); + expect(page.url()).toBe(mainUrl); + + // 명령 다시 선택: 명령 목록 popup으로 돌아가고 이후 Back은 남은 history를 소비하지 않는다. + overlay = await openFireAttack(4); + await overlay.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); + const listPicker = page.getByTestId('command-picker'); + await expect(listPicker).toBeVisible(); + await expect(listPicker).not.toHaveAttribute('role', 'dialog'); + await expect(listPicker.getByRole('button', { name: /화계/ })).toBeVisible(); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden'); + + // 입력 완료 뒤에도 같은 URL과 원래 history 길이를 유지하고 payload·turnList는 그대로다. + await listPicker.getByRole('button', { name: /화계/ }).click(); + overlay = page.getByRole('dialog', { name: '화계 4턴 명령 입력' }); + await overlay.getByTestId('command-argument-form').locator('select').selectOption('2'); + await overlay.getByRole('button', { name: '입력', exact: true }).click(); + await expect(page.getByTestId('command-picker')).toHaveCount(0); + await expect(editor.locator('.action-column > div').nth(3)).toHaveText('【허창】에 화계실행'); + await expect + .poll(() => JSON.stringify(requests)) + .toContain('"turnList":[3],"action":"che_화계","args":{"destCityId":2}'); + await expect.poll(() => page.evaluate(() => history.length)).toBeLessThanOrEqual(initialHistoryLength + 1); + expect(page.url()).toBe(mainUrl); + await page.goBack(); + await expect(page).not.toHaveURL(mainUrl); + }); +} + +test('opens chief argument input as the same full-screen overlay with selected turns in the title', async ({ + page, +}, testInfo) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto(gamePath('/chief-center')); + const mainUrl = page.url(); + const editor = page.locator('[data-command-scope="nation"]'); + await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const listPicker = page.getByTestId('command-picker'); + await listPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); + await listPicker.getByRole('button', { name: /발령/ }).click(); + const overlay = page.getByRole('dialog', { name: '발령 1턴 명령 입력' }); + await expect(overlay).toBeVisible(); + await expect(overlay.getByTestId('command-input-top-bar').locator('h2')).toHaveText('발령1턴'); + const geometry = await overlay.evaluate((element) => ({ + parentIsBody: element.parentElement === document.body, + overlay: element.getBoundingClientRect().toJSON(), + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + })); + expect(geometry.parentIsBody).toBe(true); + expect(geometry.overlay).toMatchObject({ x: 0, y: 0, width: 1200, height: 900 }); + expect(geometry.scrollWidth).toBe(geometry.clientWidth); + await expect(overlay.getByTestId('command-argument-map')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('chief-argument-overlay-1200.png') }); + await page.goBack(); + await expect(page.getByTestId('command-picker')).toHaveCount(0); + expect(page.url()).toBe(mainUrl); +}); diff --git a/app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts b/app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts index bf55c573..c3a6e2c0 100644 --- a/app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts +++ b/app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts @@ -70,7 +70,8 @@ test('reserves every requested general and chief command through Chromium and re fill?: (form: Locator) => Promise ) => { await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click(); - const picker = editor.getByTestId('command-picker'); + // 명령 목록은 편집기 안 popup이고, 인자 입력은 body 위 전체화면 overlay로 옮겨진다. + const picker = editor.page().getByTestId('command-picker'); await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${category}$`) }).click(); const commandButton = picker.getByRole('button', { name: command }).first(); await expect(commandButton).toBeEnabled(); diff --git a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue index b76ed8c2..b4196720 100644 --- a/app/game-frontend/src/components/command/RecruitmentCommandForm.vue +++ b/app/game-frontend/src/components/command/RecruitmentCommandForm.vue @@ -452,7 +452,7 @@ small { .recruitment-list-front { position: sticky; z-index: 5; - top: 44px; + top: var(--argument-overlay-header-height, 44px); background: #1d1d1d; } .recruitment-status { diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index e4d0cee1..4edb4b7c 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -4,7 +4,10 @@ const { accelerated, label: clockLabel, toggle: toggleClock, mode: clockDisplayM import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import CommandArgumentForm from '../main/CommandArgumentForm.vue'; import CommandSelectForm from '../main/CommandSelectForm.vue'; -import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from './commandArgumentPresentation'; +import { + isCommandTargetSearchField, + useCommandTargetSearchEnabled, +} from '../../composables/useCommandTargetSearchEnabled'; import DragSelect from './DragSelect.vue'; import RecruitmentCommandForm from './RecruitmentCommandForm.vue'; import { @@ -136,16 +139,21 @@ const quickPickerTop = ref('38px'); const isRecruitmentCommand = computed( () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' ); -const isArgumentPickerExpanded = computed(() => { - const command = selectedCommand.value; - if (!command) return false; - const presentation = commandArgumentPresentation(command.key); - return Boolean( - (command.reqArg && presentation.lines.length) || - resolveCommandArgumentMapTarget(command.key, command.inputFields) - ); +// Ref는 인자가 필요한 명령을 고르면 v_processing.php로 화면 전체를 옮긴다. Core는 URL을 +// 유지하되 같은 범위의 인자 입력을 body 위 전체화면 overlay로 연다. 명령 목록은 기존 popup이다. +const isArgumentOverlay = computed(() => Boolean(selectedCommand.value?.reqArg)); +const isArgumentOverlayOpen = computed(() => pickerOpen.value && isArgumentOverlay.value); +const targetSearchEnabled = useCommandTargetSearchEnabled(); +const hasSearchableFields = computed( + () => selectedCommand.value?.inputFields.some(isCommandTargetSearchField) ?? false +); +const MAX_TITLE_TURNS = 8; +const overlayTurnLabel = computed(() => { + if (quickTarget.value !== null) return `${quickTarget.value + 1}턴`; + const turns = selectedIndices().map((index) => index + 1); + const shown = turns.slice(0, MAX_TITLE_TURNS).join(', '); + return turns.length > MAX_TITLE_TURNS ? `${shown} 외 ${turns.length - MAX_TITLE_TURNS}개 턴` : `${shown}턴`; }); -const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); const commandBrief = (entry: { action: string; args: unknown; label?: string }): string => formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) || entry.label || @@ -210,21 +218,59 @@ const restoreBodyScroll = () => { previousBodyOverflow = null; }; -watch(isRecruitmentOverlayOpen, async (open) => { - if (!open) { - restoreBodyScroll(); +// 브라우저·모바일 Back은 페이지를 떠나지 않고 overlay만 닫는다. 같은 URL의 history entry를 +// 하나 넣고, 버튼·Esc·입력 완료로 닫을 때는 그 entry를 직접 소비해 Back 횟수를 늘리지 않는다. +const OVERLAY_HISTORY_KEY = 'samCommandInputOverlay'; +let overlayHistoryPushed = false; +let pendingOverlayHistoryBacks = 0; +const onOverlayPopState = () => { + if (pendingOverlayHistoryBacks > 0) { + pendingOverlayHistoryBacks -= 1; return; } + if (!overlayHistoryPushed) return; + overlayHistoryPushed = false; + closePicker(); +}; +const pushOverlayHistory = () => { + if (overlayHistoryPushed) return; + const state: unknown = window.history.state; + const base = state && typeof state === 'object' ? (state as Record) : {}; + // vue-router의 position/current 값을 보존해야 popstate를 같은 위치의 이동으로 해석한다. + window.history.pushState({ ...base, [OVERLAY_HISTORY_KEY]: true }, '', window.location.href); + overlayHistoryPushed = true; +}; +const consumeOverlayHistory = () => { + if (!overlayHistoryPushed) return; + overlayHistoryPushed = false; + pendingOverlayHistoryBacks += 1; + window.history.back(); +}; + +onMounted(() => window.addEventListener('popstate', onOverlayPopState)); + +watch(isArgumentOverlayOpen, async (open) => { + if (!open) { + restoreBodyScroll(); + consumeOverlayHistory(); + return; + } + pushOverlayHistory(); if (previousBodyOverflow === null) previousBodyOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; await nextTick(); pickerElement.value?.querySelector('[data-picker-close]')?.focus(); }); -onBeforeUnmount(restoreBodyScroll); +onBeforeUnmount(() => { + restoreBodyScroll(); + window.removeEventListener('popstate', onOverlayPopState); + // route 이동 중 unmount되면 history를 되돌리지 않는다. 남은 entry는 같은 URL이라 무해하다. + overlayHistoryPushed = false; +}); -const trapRecruitmentFocus = (event: KeyboardEvent) => { - if (!isRecruitmentOverlayOpen.value || event.key !== 'Tab' || !pickerElement.value) return; +const trapOverlayFocus = (event: KeyboardEvent) => { + if (!isArgumentOverlayOpen.value || event.key !== 'Tab' || !pickerElement.value) return; const focusable = [ ...pickerElement.value.querySelectorAll( 'button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]' @@ -369,7 +415,6 @@ const clickOutsideMenu = (event: Event) => { mobile: props.mobile, 'edit-mode': editMode, 'picker-open': pickerOpen, - 'argument-expanded': isArgumentPickerExpanded, }" :data-command-scope="props.scope" > @@ -753,30 +798,56 @@ const clickOutsideMenu = (event: Event) => { - +
-
- {{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력 + + +

+ {{ selectedCommand?.name }}{{ overlayTurnLabel }} +

+ +
+
+ {{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력
{ @select="selectCommand" />