예약 명령 인자 입력을 전체화면 overlay로 전환
Ref v_processing처럼 인자가 필요한 장수·사령턴 명령은 작은 popup 대신 body 위 전체화면 dialog에서 입력한다. 명령 목록 단계는 기존 popup을 유지한다. - 상단 막대: 명령 취소 · 명령 다시 선택 · 명령명과 대상 턴 · 검색 켜짐/꺼짐 - 검색 토글 상태를 useCommandTargetSearchEnabled()로 폼과 공유 - URL은 유지하고 같은 URL의 history entry로 Back이 overlay만 닫게 함 - 1120px 이상에서는 원본 700px 지도와 대상 목록을 두 열로 배치 - 징병·모병 sticky 기준을 공용 header 높이 변수로 연결 - e2e를 전체화면 계약에 맞게 갱신하고 overlay·Back·geometry 테스트 추가 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -245,10 +245,7 @@ test('shows and operates a city target map through live PostgreSQL, Game API, an
|
|||||||
expect(geometry.display).not.toBe('none');
|
expect(geometry.display).not.toBe('none');
|
||||||
await page.screenshot({ path: testInfo.outputPath('chief-command-city-map-live.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('chief-command-city-map-live.png'), fullPage: true });
|
||||||
|
|
||||||
await page
|
await page.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||||||
.getByRole('button', { name: '명령 입력 닫기', exact: true })
|
|
||||||
.click({ timeout: 2_000 })
|
|
||||||
.catch(() => undefined);
|
|
||||||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||||
const nationPicker = page.getByTestId('command-picker');
|
const nationPicker = page.getByTestId('command-picker');
|
||||||
await nationPicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
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.screenshot({ path: testInfo.outputPath('chief-command-nation-map-live.png'), fullPage: true });
|
||||||
|
|
||||||
await page
|
await page.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||||||
.getByRole('button', { name: '명령 입력 닫기', exact: true })
|
|
||||||
.click({ timeout: 2_000 })
|
|
||||||
.catch(() => undefined);
|
|
||||||
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||||||
const assignmentPicker = page.getByTestId('command-picker');
|
const assignmentPicker = page.getByTestId('command-picker');
|
||||||
await assignmentPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
await assignmentPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||||
|
|||||||
@@ -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 picker.getByRole('button', { name: '징병', exact: true }).click();
|
||||||
await expect(picker).toHaveAttribute('role', 'dialog');
|
await expect(picker).toHaveAttribute('role', 'dialog');
|
||||||
await expect(picker).toHaveAttribute('aria-modal', 'true');
|
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');
|
const form = picker.getByTestId('recruitment-command-form');
|
||||||
await expect(form).toContainText('현재 기술력 : 1등급');
|
await expect(form).toContainText('현재 기술력 : 1등급');
|
||||||
await expect(form).toContainText('공격');
|
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');
|
picker = page.getByTestId('command-picker');
|
||||||
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
||||||
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 referenceWidthGeometry = await picker.evaluate((element) => {
|
||||||
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
|
const formElement = element.querySelector<HTMLElement>('[data-testid="recruitment-command-form"]')!;
|
||||||
const row = formElement.querySelector<HTMLElement>('.crew-row')!;
|
const row = formElement.querySelector<HTMLElement>('.crew-row')!;
|
||||||
@@ -2277,6 +2277,7 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk
|
|||||||
return {
|
return {
|
||||||
scrollTop: element.scrollTop,
|
scrollTop: element.scrollTop,
|
||||||
headerTop: header.getBoundingClientRect().top,
|
headerTop: header.getBoundingClientRect().top,
|
||||||
|
headerBottom: header.getBoundingClientRect().bottom,
|
||||||
listFrontTop: listFront.getBoundingClientRect().top,
|
listFrontTop: listFront.getBoundingClientRect().top,
|
||||||
actionsBottom: actions.getBoundingClientRect().bottom,
|
actionsBottom: actions.getBoundingClientRect().bottom,
|
||||||
viewportHeight: window.innerHeight,
|
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.scrollTop).toBeGreaterThan(0);
|
||||||
expect(stickyGeometry.headerTop).toBe(0);
|
expect(stickyGeometry.headerTop).toBe(0);
|
||||||
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(44);
|
expect(stickyGeometry.listFrontTop).toBeGreaterThanOrEqual(stickyGeometry.headerBottom);
|
||||||
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
|
expect(stickyGeometry.actionsBottom).toBe(stickyGeometry.viewportHeight);
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
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 drag(0, 2);
|
||||||
await expect(editor.locator('.index-column > button.selected')).toHaveCount(3);
|
await expect(editor.locator('.index-column > button.selected')).toHaveCount(3);
|
||||||
await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click();
|
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 });
|
const blockedFire = picker.getByRole('button', { name: '화계', exact: true });
|
||||||
await expect(blockedFire).toBeEnabled();
|
await expect(blockedFire).toBeEnabled();
|
||||||
await blockedFire.click();
|
await blockedFire.click();
|
||||||
@@ -3717,7 +3718,7 @@ for (const width of [1200, 390]) {
|
|||||||
let picker = page.getByTestId('command-picker');
|
let picker = page.getByTestId('command-picker');
|
||||||
await picker.getByRole('button', { name: /화계/ }).click();
|
await picker.getByRole('button', { name: /화계/ }).click();
|
||||||
let form = picker.getByTestId('command-argument-form');
|
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.locator('input[type=search]')).toHaveCount(0);
|
||||||
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
|
await expect(form.getByTestId('city-target-list').locator('button')).toHaveCount(3);
|
||||||
await form.getByTestId('city-target-list').getByRole('button', { name: /허창/ }).click();
|
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 input.press('Enter');
|
||||||
await expect(input).not.toBeFocused();
|
await expect(input).not.toBeFocused();
|
||||||
await expect(picker).toBeVisible();
|
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(input).toHaveCount(0);
|
||||||
await expect(results.locator('button')).toHaveCount(3);
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
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');
|
form = picker.getByTestId('command-argument-form');
|
||||||
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(3);
|
await expect(form.getByTestId('general-target-list').locator('button')).toHaveCount(3);
|
||||||
await expect(form.getByTestId('city-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 generalSearch = form.locator('#command-search-destGeneralId');
|
||||||
const citySearch = form.locator('#command-search-destCityId');
|
const citySearch = form.locator('#command-search-destCityId');
|
||||||
for (const query of ['ㅇㅇ', '없음', '통솔', '1,200']) {
|
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: /^(?:국가:)?외교$/, exact: true }).click();
|
||||||
await picker.getByRole('button', { name: /선전포고/ }).click();
|
await picker.getByRole('button', { name: /선전포고/ }).click();
|
||||||
form = picker.getByTestId('command-argument-form');
|
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',
|
'aria-pressed',
|
||||||
'true'
|
'true'
|
||||||
);
|
);
|
||||||
@@ -3821,7 +3822,7 @@ for (const width of [1200, 390]) {
|
|||||||
await input.press('Escape');
|
await input.press('Escape');
|
||||||
await expect(input).toHaveValue('');
|
await expect(input).toHaveValue('');
|
||||||
await expect(picker).toBeVisible();
|
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(input).toHaveCount(0);
|
||||||
await expect(results.locator('button')).toHaveCount(3);
|
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');
|
const results = form.getByTestId('general-target-list');
|
||||||
await expect(results.locator('button')).toHaveCount(3);
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
await expect(results.getByRole('button', { name: /관우/ })).toContainText('탑승 부대 없음');
|
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]');
|
const search = form.locator('input[type=search]');
|
||||||
await search.fill('ㅇㅇ');
|
await search.fill('ㅇㅇ');
|
||||||
await expect(results.locator('button strong')).toHaveText(['원우 (피곤 · 업)', '조조 (피곤 · 업)']);
|
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');
|
const picker = page.getByTestId('command-picker');
|
||||||
await picker.getByRole('button', { name: /증여/ }).click();
|
await picker.getByRole('button', { name: /증여/ }).click();
|
||||||
const form = picker.getByTestId('command-argument-form');
|
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 input = form.locator('input[type=search]');
|
||||||
const results = form.getByTestId('general-target-list');
|
const results = form.getByTestId('general-target-list');
|
||||||
await input.focus();
|
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 form.getByRole('button', { name: '지우기', exact: true }).click();
|
||||||
await expect(results.locator('button')).toHaveCount(3);
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
await input.fill('ㄱㅇ');
|
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 page.waitForTimeout(250); // 예약된 디바운스가 OFF 이후 재적용되지 않는지 확인한다.
|
||||||
await expect(results.locator('button')).toHaveCount(3);
|
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(input).toHaveValue('');
|
||||||
await expect(results.locator('button')).toHaveCount(3);
|
await expect(results.locator('button')).toHaveCount(3);
|
||||||
await cdp.detach();
|
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<Page['getByRole']>) =>
|
||||||
|
overlay.evaluate((element) => {
|
||||||
|
const bar = element.querySelector<HTMLElement>('[data-testid="command-input-top-bar"]')!;
|
||||||
|
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"]');
|
||||||
|
const fields = element.querySelector<HTMLElement>('.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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ test('reserves every requested general and chief command through Chromium and re
|
|||||||
fill?: (form: Locator) => Promise<void>
|
fill?: (form: Locator) => Promise<void>
|
||||||
) => {
|
) => {
|
||||||
await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click();
|
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();
|
await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${category}$`) }).click();
|
||||||
const commandButton = picker.getByRole('button', { name: command }).first();
|
const commandButton = picker.getByRole('button', { name: command }).first();
|
||||||
await expect(commandButton).toBeEnabled();
|
await expect(commandButton).toBeEnabled();
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ small {
|
|||||||
.recruitment-list-front {
|
.recruitment-list-front {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
top: 44px;
|
top: var(--argument-overlay-header-height, 44px);
|
||||||
background: #1d1d1d;
|
background: #1d1d1d;
|
||||||
}
|
}
|
||||||
.recruitment-status {
|
.recruitment-status {
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ const { accelerated, label: clockLabel, toggle: toggleClock, mode: clockDisplayM
|
|||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
||||||
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
||||||
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from './commandArgumentPresentation';
|
import {
|
||||||
|
isCommandTargetSearchField,
|
||||||
|
useCommandTargetSearchEnabled,
|
||||||
|
} from '../../composables/useCommandTargetSearchEnabled';
|
||||||
import DragSelect from './DragSelect.vue';
|
import DragSelect from './DragSelect.vue';
|
||||||
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
|
import RecruitmentCommandForm from './RecruitmentCommandForm.vue';
|
||||||
import {
|
import {
|
||||||
@@ -136,16 +139,21 @@ const quickPickerTop = ref('38px');
|
|||||||
const isRecruitmentCommand = computed(
|
const isRecruitmentCommand = computed(
|
||||||
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
|
() => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병'
|
||||||
);
|
);
|
||||||
const isArgumentPickerExpanded = computed(() => {
|
// Ref는 인자가 필요한 명령을 고르면 v_processing.php로 화면 전체를 옮긴다. Core는 URL을
|
||||||
const command = selectedCommand.value;
|
// 유지하되 같은 범위의 인자 입력을 body 위 전체화면 overlay로 연다. 명령 목록은 기존 popup이다.
|
||||||
if (!command) return false;
|
const isArgumentOverlay = computed(() => Boolean(selectedCommand.value?.reqArg));
|
||||||
const presentation = commandArgumentPresentation(command.key);
|
const isArgumentOverlayOpen = computed(() => pickerOpen.value && isArgumentOverlay.value);
|
||||||
return Boolean(
|
const targetSearchEnabled = useCommandTargetSearchEnabled();
|
||||||
(command.reqArg && presentation.lines.length) ||
|
const hasSearchableFields = computed(
|
||||||
resolveCommandArgumentMapTarget(command.key, command.inputFields)
|
() => 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 =>
|
const commandBrief = (entry: { action: string; args: unknown; label?: string }): string =>
|
||||||
formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) ||
|
formatReservedCommandBrief(props.scope, entry.action, entry.args, props.commandTable) ||
|
||||||
entry.label ||
|
entry.label ||
|
||||||
@@ -210,21 +218,59 @@ const restoreBodyScroll = () => {
|
|||||||
previousBodyOverflow = null;
|
previousBodyOverflow = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(isRecruitmentOverlayOpen, async (open) => {
|
// 브라우저·모바일 Back은 페이지를 떠나지 않고 overlay만 닫는다. 같은 URL의 history entry를
|
||||||
if (!open) {
|
// 하나 넣고, 버튼·Esc·입력 완료로 닫을 때는 그 entry를 직접 소비해 Back 횟수를 늘리지 않는다.
|
||||||
restoreBodyScroll();
|
const OVERLAY_HISTORY_KEY = 'samCommandInputOverlay';
|
||||||
|
let overlayHistoryPushed = false;
|
||||||
|
let pendingOverlayHistoryBacks = 0;
|
||||||
|
const onOverlayPopState = () => {
|
||||||
|
if (pendingOverlayHistoryBacks > 0) {
|
||||||
|
pendingOverlayHistoryBacks -= 1;
|
||||||
return;
|
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<string, unknown>) : {};
|
||||||
|
// 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;
|
if (previousBodyOverflow === null) previousBodyOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
await nextTick();
|
await nextTick();
|
||||||
pickerElement.value?.querySelector<HTMLElement>('[data-picker-close]')?.focus();
|
pickerElement.value?.querySelector<HTMLElement>('[data-picker-close]')?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(restoreBodyScroll);
|
onBeforeUnmount(() => {
|
||||||
|
restoreBodyScroll();
|
||||||
|
window.removeEventListener('popstate', onOverlayPopState);
|
||||||
|
// route 이동 중 unmount되면 history를 되돌리지 않는다. 남은 entry는 같은 URL이라 무해하다.
|
||||||
|
overlayHistoryPushed = false;
|
||||||
|
});
|
||||||
|
|
||||||
const trapRecruitmentFocus = (event: KeyboardEvent) => {
|
const trapOverlayFocus = (event: KeyboardEvent) => {
|
||||||
if (!isRecruitmentOverlayOpen.value || event.key !== 'Tab' || !pickerElement.value) return;
|
if (!isArgumentOverlayOpen.value || event.key !== 'Tab' || !pickerElement.value) return;
|
||||||
const focusable = [
|
const focusable = [
|
||||||
...pickerElement.value.querySelectorAll<HTMLElement>(
|
...pickerElement.value.querySelectorAll<HTMLElement>(
|
||||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]'
|
'button:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex="0"]'
|
||||||
@@ -369,7 +415,6 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
mobile: props.mobile,
|
mobile: props.mobile,
|
||||||
'edit-mode': editMode,
|
'edit-mode': editMode,
|
||||||
'picker-open': pickerOpen,
|
'picker-open': pickerOpen,
|
||||||
'argument-expanded': isArgumentPickerExpanded,
|
|
||||||
}"
|
}"
|
||||||
:data-command-scope="props.scope"
|
:data-command-scope="props.scope"
|
||||||
>
|
>
|
||||||
@@ -753,30 +798,56 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body" :disabled="!isRecruitmentCommand">
|
<Teleport to="body" :disabled="!isArgumentOverlay">
|
||||||
<div
|
<div
|
||||||
v-if="pickerOpen"
|
v-if="pickerOpen"
|
||||||
ref="pickerElement"
|
ref="pickerElement"
|
||||||
class="command-picker"
|
class="command-picker"
|
||||||
:class="{ 'recruitment-picker': isRecruitmentCommand }"
|
:class="{ 'argument-overlay': isArgumentOverlay, 'recruitment-picker': isRecruitmentCommand }"
|
||||||
data-testid="command-picker"
|
data-testid="command-picker"
|
||||||
:style="
|
:style="
|
||||||
isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
|
isArgumentOverlay || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
|
||||||
"
|
"
|
||||||
:role="isRecruitmentCommand ? 'dialog' : undefined"
|
:role="isArgumentOverlay ? 'dialog' : undefined"
|
||||||
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
|
:aria-modal="isArgumentOverlay ? 'true' : undefined"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
isRecruitmentCommand
|
isArgumentOverlay ? `${selectedCommand?.name ?? ''} ${overlayTurnLabel} 명령 입력` : undefined
|
||||||
? `${selectedCommand?.name ?? ''} ${quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴`} 명령 입력`
|
|
||||||
: undefined
|
|
||||||
"
|
"
|
||||||
@keydown.esc.stop.prevent="closePicker"
|
@keydown.esc.stop.prevent="closePicker"
|
||||||
@keydown="trapRecruitmentFocus"
|
@keydown="trapOverlayFocus"
|
||||||
>
|
>
|
||||||
<header>
|
<header v-if="isArgumentOverlay" class="argument-overlay-bar" data-testid="command-input-top-bar">
|
||||||
<strong
|
<button
|
||||||
><template v-if="isRecruitmentCommand">{{ selectedCommand?.name }} · </template
|
data-picker-close
|
||||||
>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력</strong
|
type="button"
|
||||||
|
class="legacy-button legacy-button--navigation overlay-cancel"
|
||||||
|
@click="closePicker"
|
||||||
|
>
|
||||||
|
명령 취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="legacy-button legacy-button--navigation overlay-reselect"
|
||||||
|
:disabled="Boolean(pendingReservation)"
|
||||||
|
@click="returnToCommandList"
|
||||||
|
>
|
||||||
|
명령 다시 선택
|
||||||
|
</button>
|
||||||
|
<h2 class="overlay-title">
|
||||||
|
{{ selectedCommand?.name }}<small>{{ overlayTurnLabel }}</small>
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
v-if="hasSearchableFields"
|
||||||
|
type="button"
|
||||||
|
class="legacy-button legacy-button--secondary overlay-search"
|
||||||
|
:aria-pressed="targetSearchEnabled"
|
||||||
|
@click="targetSearchEnabled = !targetSearchEnabled"
|
||||||
|
>
|
||||||
|
{{ targetSearchEnabled ? '검색 켜짐' : '검색 꺼짐' }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<header v-else>
|
||||||
|
<strong>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력</strong
|
||||||
><button data-picker-close type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
|
><button data-picker-close type="button" aria-label="명령 입력 닫기" @click="closePicker">×</button>
|
||||||
</header>
|
</header>
|
||||||
<CommandSelectForm
|
<CommandSelectForm
|
||||||
@@ -790,8 +861,8 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
@select="selectCommand"
|
@select="selectCommand"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="selected-command">
|
<div v-if="!isArgumentOverlay || selectedCommand.reason" class="selected-command">
|
||||||
<strong>{{ selectedCommand.name }}</strong>
|
<strong v-if="!isArgumentOverlay">{{ selectedCommand.name }}</strong>
|
||||||
<small v-if="selectedCommand.reason"
|
<small v-if="selectedCommand.reason"
|
||||||
>현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.</small
|
>현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.</small
|
||||||
>
|
>
|
||||||
@@ -819,7 +890,11 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
@update:valid="commandArgsValid = $event"
|
@update:valid="commandArgsValid = $event"
|
||||||
/>
|
/>
|
||||||
<div class="picker-actions">
|
<div class="picker-actions">
|
||||||
<button :disabled="Boolean(pendingReservation)" @click="returnToCommandList">
|
<button
|
||||||
|
v-if="!isArgumentOverlay"
|
||||||
|
:disabled="Boolean(pendingReservation)"
|
||||||
|
@click="returnToCommandList"
|
||||||
|
>
|
||||||
명령 다시 선택</button
|
명령 다시 선택</button
|
||||||
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
|
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
|
||||||
{{ pendingReservation ? '저장 중' : '입력' }}
|
{{ pendingReservation ? '저장 중' : '입력' }}
|
||||||
@@ -1236,7 +1311,9 @@ small {
|
|||||||
max-height: 344px;
|
max-height: 344px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-picker.recruitment-picker {
|
.command-picker.argument-overlay {
|
||||||
|
--argument-overlay-header-height: 44px;
|
||||||
|
--argument-overlay-content-width: 1000px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1255,48 +1332,120 @@ small {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
.command-picker.recruitment-picker > header {
|
/* Ref TopBackBar: 돌아가기 · 명령명 · 검색 켜짐/꺼짐을 1000px 폭의 한 줄에 둔다. */
|
||||||
|
.command-picker.argument-overlay > .argument-overlay-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content max-content minmax(0, 1fr) max-content;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 44px;
|
height: var(--argument-overlay-header-height);
|
||||||
padding: 6px 8px;
|
min-height: 0;
|
||||||
|
padding: 6px max(8px, calc((100% - var(--argument-overlay-content-width)) / 2 + 8px));
|
||||||
border-bottom: 1px solid #777;
|
border-bottom: 1px solid #777;
|
||||||
background: #302016 var(--sammo-texture-walnut);
|
background: #302016 var(--sammo-texture-walnut);
|
||||||
}
|
}
|
||||||
.command-picker.recruitment-picker > header button {
|
.command-picker.argument-overlay > .argument-overlay-bar > button {
|
||||||
min-width: 36px;
|
width: auto;
|
||||||
min-height: 32px;
|
min-width: 90px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.command-picker.recruitment-picker .selected-command,
|
.command-picker.argument-overlay > .argument-overlay-bar > .overlay-search {
|
||||||
.command-picker.recruitment-picker :deep(.recruitment-command-form),
|
grid-column: 4;
|
||||||
.command-picker.recruitment-picker .picker-actions {
|
}
|
||||||
width: min(100%, 1000px);
|
.argument-overlay-bar > .overlay-title {
|
||||||
|
grid-column: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: normal;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.overlay-title small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #ffe0a0;
|
||||||
|
font-size: var(--sammo-font-size-small);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.command-picker.argument-overlay .selected-command,
|
||||||
|
.command-picker.argument-overlay :deep(.recruitment-command-form),
|
||||||
|
.command-picker.argument-overlay :deep(.command-argument-form),
|
||||||
|
.command-picker.argument-overlay .picker-actions {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: min(100%, var(--argument-overlay-content-width));
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.command-picker.recruitment-picker :deep(.recruitment-command-form) {
|
.command-picker.argument-overlay :deep(.recruitment-command-form),
|
||||||
|
.command-picker.argument-overlay :deep(.command-argument-form) {
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
}
|
}
|
||||||
.command-picker.recruitment-picker .picker-actions {
|
.command-picker.argument-overlay .picker-actions {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
box-sizing: border-box;
|
grid-template-columns: 1fr;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-top: 1px solid #777;
|
border-top: 1px solid #777;
|
||||||
background: #302016 var(--sammo-texture-walnut);
|
background: #302016 var(--sammo-texture-walnut);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1025px) {
|
/* Ref 1000px 문서 폭을 기본으로 두고, 지도 입력만 원본 지도와 목록 열을 함께 담도록 넓힌다. */
|
||||||
.argument-expanded:not(.compact) .command-picker {
|
@media (min-width: 1120px) {
|
||||||
right: 0;
|
.command-picker.argument-overlay:has(.command-argument-form.has-map) {
|
||||||
left: auto;
|
--argument-overlay-content-width: 1120px;
|
||||||
width: 700px;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 좁은 화면은 제목을 첫 줄에 두고 조작 버튼을 둘째 줄에 같은 폭으로 나눈다. */
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.command-picker.argument-overlay {
|
||||||
|
--argument-overlay-header-height: 76px;
|
||||||
|
}
|
||||||
|
.command-picker.argument-overlay > .argument-overlay-bar {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
grid-template-rows: 28px 32px;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
.command-picker.argument-overlay > .argument-overlay-bar > button {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.command-picker.argument-overlay > .argument-overlay-bar > .overlay-cancel {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
.command-picker.argument-overlay > .argument-overlay-bar > .overlay-reselect {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
.command-picker.argument-overlay > .argument-overlay-bar > .overlay-search {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
.argument-overlay-bar > .overlay-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1025px) {
|
||||||
.compact:not(.mobile) .command-picker {
|
.compact:not(.mobile) .command-picker {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -1305,13 +1454,6 @@ small {
|
|||||||
left: calc(50% - 476px);
|
left: calc(50% - 476px);
|
||||||
width: 238px;
|
width: 238px;
|
||||||
}
|
}
|
||||||
.compact.argument-expanded:not(.mobile) .command-picker {
|
|
||||||
left: calc(50% - 350px);
|
|
||||||
width: 700px;
|
|
||||||
height: auto;
|
|
||||||
max-height: calc(100vh - 104px);
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile.compact .editor-layout {
|
.mobile.compact .editor-layout {
|
||||||
@@ -1352,16 +1494,6 @@ small {
|
|||||||
width: 370px;
|
width: 370px;
|
||||||
height: 327px;
|
height: 327px;
|
||||||
}
|
}
|
||||||
.mobile.compact.argument-expanded .command-picker {
|
|
||||||
position: relative;
|
|
||||||
top: auto;
|
|
||||||
left: auto;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
margin-top: -330px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
.mobile.compact .advanced-actions {
|
.mobile.compact .advanced-actions {
|
||||||
position: static;
|
position: static;
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, reactive, watch, type CSSProperties } from 'vue';
|
import { computed, onBeforeUnmount, reactive, watch, type CSSProperties } from 'vue';
|
||||||
import { commandTargetDescription } from '../../utils/commandTargetDescription';
|
import { commandTargetDescription } from '../../utils/commandTargetDescription';
|
||||||
import { useStorage } from '@vueuse/core';
|
import {
|
||||||
|
isCommandTargetSearchField,
|
||||||
|
useCommandTargetSearchEnabled,
|
||||||
|
} from '../../composables/useCommandTargetSearchEnabled';
|
||||||
import { buildCommandOptionSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
|
import { buildCommandOptionSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
|
||||||
import MapViewer from './MapViewer.vue';
|
import MapViewer from './MapViewer.vue';
|
||||||
import NationColorSelect from './NationColorSelect.vue';
|
import NationColorSelect from './NationColorSelect.vue';
|
||||||
@@ -70,7 +73,7 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
|
|||||||
return props.options[field.optionSource];
|
return props.options[field.optionSource];
|
||||||
};
|
};
|
||||||
|
|
||||||
const searchEnabled = useStorage('sam.core.commandTargetSearch', false);
|
const searchEnabled = useCommandTargetSearchEnabled();
|
||||||
const searchQueries = reactive<Record<string, string>>({});
|
const searchQueries = reactive<Record<string, string>>({});
|
||||||
const searchDrafts = reactive<Record<string, string>>({});
|
const searchDrafts = reactive<Record<string, string>>({});
|
||||||
const searchTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const searchTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
@@ -107,9 +110,7 @@ const onSearchKeydown = (key: string, event: KeyboardEvent) => {
|
|||||||
(event.target as HTMLInputElement).blur();
|
(event.target as HTMLInputElement).blur();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const isSearchable = (field: CommandInputField): boolean =>
|
const isSearchable = isCommandTargetSearchField;
|
||||||
field.kind === 'select' && ['generals', 'cities', 'nations'].includes(field.optionSource ?? '');
|
|
||||||
const hasSearchableFields = computed(() => props.fields.some(isSearchable));
|
|
||||||
// 후보나 정렬이 바뀔 때만 인덱스를 갱신하고 키 입력은 기존 인덱스를 검색한다.
|
// 후보나 정렬이 바뀔 때만 인덱스를 갱신하고 키 입력은 기존 인덱스를 검색한다.
|
||||||
const targetIndexes = computed(
|
const targetIndexes = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -461,6 +462,7 @@ watch(
|
|||||||
<div
|
<div
|
||||||
v-if="props.fields.length || showMap || presentation.lines.length"
|
v-if="props.fields.length || showMap || presentation.lines.length"
|
||||||
class="command-argument-form"
|
class="command-argument-form"
|
||||||
|
:class="{ 'has-map': showMap }"
|
||||||
data-testid="command-argument-form"
|
data-testid="command-argument-form"
|
||||||
>
|
>
|
||||||
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
|
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
|
||||||
@@ -474,7 +476,7 @@ watch(
|
|||||||
:show-current-city-marker="true"
|
:show-current-city-marker="true"
|
||||||
@select-city="selectMapCity"
|
@select-city="selectMapCity"
|
||||||
/>
|
/>
|
||||||
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
<small>지도에서 도시를 클릭하거나 목록에서 대상을 선택하세요.</small>
|
||||||
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
||||||
<span class="current-city-status">
|
<span class="current-city-status">
|
||||||
<span class="status-key">현재 도시</span>
|
<span class="status-key">현재 도시</span>
|
||||||
@@ -490,211 +492,209 @@ watch(
|
|||||||
{{ mapTargetSummary }}
|
{{ mapTargetSummary }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
|
<div class="argument-fields">
|
||||||
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
|
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
|
||||||
</div>
|
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
|
||||||
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
|
</div>
|
||||||
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
|
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
|
||||||
</div>
|
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
|
||||||
<button
|
</div>
|
||||||
v-if="hasSearchableFields"
|
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
|
||||||
type="button"
|
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
||||||
class="legacy-button legacy-button--secondary target-search-toggle"
|
|
||||||
:aria-pressed="searchEnabled"
|
|
||||||
@click="searchEnabled = !searchEnabled"
|
|
||||||
>
|
|
||||||
{{ searchEnabled ? '검색 켜짐' : '검색 꺼짐' }}
|
|
||||||
</button>
|
|
||||||
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
|
|
||||||
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
|
||||||
<input
|
|
||||||
v-if="field.kind === 'text'"
|
|
||||||
:id="`command-arg-${field.key}`"
|
|
||||||
:value="String(values[field.key] ?? '')"
|
|
||||||
:minlength="field.min"
|
|
||||||
:maxlength="field.max"
|
|
||||||
:aria-invalid="Boolean(textFieldError(field))"
|
|
||||||
:aria-describedby="textFieldError(field) ? `command-arg-${field.key}-error` : undefined"
|
|
||||||
@input="values[field.key] = ($event.target as HTMLInputElement).value"
|
|
||||||
/>
|
|
||||||
<small
|
|
||||||
v-if="field.kind === 'text' && textFieldError(field)"
|
|
||||||
:id="`command-arg-${field.key}-error`"
|
|
||||||
class="argument-error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ textFieldError(field) }}
|
|
||||||
</small>
|
|
||||||
<div v-else-if="field.kind === 'number'" class="number-options">
|
|
||||||
<input
|
<input
|
||||||
|
v-if="field.kind === 'text'"
|
||||||
:id="`command-arg-${field.key}`"
|
:id="`command-arg-${field.key}`"
|
||||||
type="number"
|
:value="String(values[field.key] ?? '')"
|
||||||
:value="Number(values[field.key] ?? 0)"
|
:minlength="field.min"
|
||||||
:min="effectiveMin(field)"
|
:maxlength="field.max"
|
||||||
:max="effectiveMax(field)"
|
:aria-invalid="Boolean(textFieldError(field))"
|
||||||
:step="effectiveStep(field)"
|
:aria-describedby="textFieldError(field) ? `command-arg-${field.key}-error` : undefined"
|
||||||
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
|
@input="values[field.key] = ($event.target as HTMLInputElement).value"
|
||||||
/>
|
/>
|
||||||
<select
|
<small
|
||||||
v-if="amountPreset"
|
v-if="field.kind === 'text' && textFieldError(field)"
|
||||||
aria-label="금액 프리셋"
|
:id="`command-arg-${field.key}-error`"
|
||||||
class="amount-preset"
|
class="argument-error"
|
||||||
value=""
|
role="alert"
|
||||||
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
>
|
||||||
<option value="" disabled>프리셋</option>
|
{{ textFieldError(field) }}
|
||||||
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
|
</small>
|
||||||
{{ preset.toLocaleString() }}
|
<div v-else-if="field.kind === 'number'" class="number-options">
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<NationColorSelect
|
|
||||||
v-else-if="field.kind === 'select' && field.optionSource === 'colors'"
|
|
||||||
:id="`command-arg-${field.key}`"
|
|
||||||
:model-value="selectedValueFor(field)"
|
|
||||||
:options="optionsFor(field)"
|
|
||||||
@update:model-value="setSelectValue(field, String($event))"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
v-else-if="field.kind === 'select'"
|
|
||||||
:id="`command-arg-${field.key}`"
|
|
||||||
:value="String(values[field.key] ?? '')"
|
|
||||||
:style="colorOptionStyle(field, selectedOptionFor(field))"
|
|
||||||
@change="setSelectValue(field, ($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="option in optionsFor(field)"
|
|
||||||
:key="String(option.value)"
|
|
||||||
:value="String(option.value)"
|
|
||||||
:style="colorOptionStyle(field, option)"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<div v-else-if="field.kind === 'boolean'" class="boolean-options">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ selected: values[field.key] === true }"
|
|
||||||
@click="values[field.key] = true"
|
|
||||||
>
|
|
||||||
{{ field.key === 'buyRice' ? '쌀 구매' : field.key === 'isGold' ? '금' : '예' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ selected: values[field.key] === false }"
|
|
||||||
@click="values[field.key] = false"
|
|
||||||
>
|
|
||||||
{{ field.key === 'buyRice' ? '쌀 판매' : field.key === 'isGold' ? '쌀' : '아니오' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="field.kind === 'numberTuple'" class="tuple-options">
|
|
||||||
<label v-for="(tupleLabel, index) in field.tupleLabels ?? ['1', '2']" :key="tupleLabel">
|
|
||||||
<span>{{ tupleLabel }}</span>
|
|
||||||
<input
|
<input
|
||||||
|
:id="`command-arg-${field.key}`"
|
||||||
type="number"
|
type="number"
|
||||||
:value="(values[field.key] as number[] | undefined)?.[index] ?? 0"
|
:value="Number(values[field.key] ?? 0)"
|
||||||
:min="effectiveMin(field)"
|
:min="effectiveMin(field)"
|
||||||
:max="effectiveMax(field)"
|
:max="effectiveMax(field)"
|
||||||
:step="effectiveStep(field)"
|
:step="effectiveStep(field)"
|
||||||
@input="setTupleValue(field, index, ($event.target as HTMLInputElement).value)"
|
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
v-if="amountPreset"
|
v-if="amountPreset"
|
||||||
:aria-label="`${tupleLabel} 금액 프리셋`"
|
aria-label="금액 프리셋"
|
||||||
class="amount-preset"
|
class="amount-preset"
|
||||||
value=""
|
value=""
|
||||||
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value, index)"
|
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value)"
|
||||||
>
|
>
|
||||||
<option value="" disabled>프리셋</option>
|
<option value="" disabled>프리셋</option>
|
||||||
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
|
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
|
||||||
{{ preset.toLocaleString() }}
|
{{ preset.toLocaleString() }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<NationColorSelect
|
||||||
<div
|
v-else-if="field.kind === 'select' && field.optionSource === 'colors'"
|
||||||
v-if="
|
:id="`command-arg-${field.key}`"
|
||||||
field.kind === 'select' &&
|
:model-value="selectedValueFor(field)"
|
||||||
(commandTargetDescription(commandKey, selectedOptionFor(field)) || selectedOptionFor(field)?.color)
|
:options="optionsFor(field)"
|
||||||
"
|
@update:model-value="setSelectValue(field, String($event))"
|
||||||
class="option-detail"
|
|
||||||
:class="{ 'assignment-detail': commandKey === 'che_발령' && field.optionSource === 'generals' }"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-if="selectedOptionFor(field)?.color"
|
|
||||||
class="option-color"
|
|
||||||
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
/>
|
||||||
<span>{{ commandTargetDescription(commandKey, selectedOptionFor(field)) }}</span>
|
<select
|
||||||
</div>
|
v-else-if="field.kind === 'select'"
|
||||||
<div
|
:id="`command-arg-${field.key}`"
|
||||||
v-if="searchEnabled && isSearchable(field)"
|
:value="String(values[field.key] ?? '')"
|
||||||
class="target-search"
|
:style="colorOptionStyle(field, selectedOptionFor(field))"
|
||||||
:data-testid="`target-search-${field.key}`"
|
@change="setSelectValue(field, ($event.target as HTMLSelectElement).value)"
|
||||||
>
|
|
||||||
<label :for="`command-search-${field.key}`">{{ field.label }} 검색</label>
|
|
||||||
<input
|
|
||||||
:id="`command-search-${field.key}`"
|
|
||||||
:value="searchDrafts[field.key] ?? ''"
|
|
||||||
type="search"
|
|
||||||
inputmode="search"
|
|
||||||
enterkeyhint="done"
|
|
||||||
placeholder="이름 또는 초성"
|
|
||||||
autocomplete="off"
|
|
||||||
:spellcheck="false"
|
|
||||||
@input="updateSearchDraft(field.key, $event)"
|
|
||||||
@compositionend="updateSearchDraft(field.key, $event)"
|
|
||||||
@blur="flushSearch(field.key)"
|
|
||||||
@keydown.enter="onSearchKeydown(field.key, $event)"
|
|
||||||
@keydown.esc.stop="onSearchKeydown(field.key, $event)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="legacy-button legacy-button--secondary"
|
|
||||||
@click="clearFieldSearch(field.key)"
|
|
||||||
>
|
>
|
||||||
지우기
|
<option
|
||||||
</button>
|
v-for="option in optionsFor(field)"
|
||||||
<small role="status"
|
:key="String(option.value)"
|
||||||
>검색 결과 {{ visibleOptionsFor(field).length }}개 / {{ optionsFor(field).length }}개</small
|
:value="String(option.value)"
|
||||||
|
:style="colorOptionStyle(field, option)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div v-else-if="field.kind === 'boolean'" class="boolean-options">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ selected: values[field.key] === true }"
|
||||||
|
@click="values[field.key] = true"
|
||||||
|
>
|
||||||
|
{{ field.key === 'buyRice' ? '쌀 구매' : field.key === 'isGold' ? '금' : '예' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ selected: values[field.key] === false }"
|
||||||
|
@click="values[field.key] = false"
|
||||||
|
>
|
||||||
|
{{ field.key === 'buyRice' ? '쌀 판매' : field.key === 'isGold' ? '쌀' : '아니오' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="field.kind === 'numberTuple'" class="tuple-options">
|
||||||
|
<label v-for="(tupleLabel, index) in field.tupleLabels ?? ['1', '2']" :key="tupleLabel">
|
||||||
|
<span>{{ tupleLabel }}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="(values[field.key] as number[] | undefined)?.[index] ?? 0"
|
||||||
|
:min="effectiveMin(field)"
|
||||||
|
:max="effectiveMax(field)"
|
||||||
|
:step="effectiveStep(field)"
|
||||||
|
@input="setTupleValue(field, index, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-if="amountPreset"
|
||||||
|
:aria-label="`${tupleLabel} 금액 프리셋`"
|
||||||
|
class="amount-preset"
|
||||||
|
value=""
|
||||||
|
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value, index)"
|
||||||
|
>
|
||||||
|
<option value="" disabled>프리셋</option>
|
||||||
|
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
|
||||||
|
{{ preset.toLocaleString() }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
field.kind === 'select' &&
|
||||||
|
(commandTargetDescription(commandKey, selectedOptionFor(field)) ||
|
||||||
|
selectedOptionFor(field)?.color)
|
||||||
|
"
|
||||||
|
class="option-detail"
|
||||||
|
:class="{ 'assignment-detail': commandKey === 'che_발령' && field.optionSource === 'generals' }"
|
||||||
>
|
>
|
||||||
</div>
|
<span
|
||||||
<div
|
v-if="selectedOptionFor(field)?.color"
|
||||||
v-if="isSearchable(field)"
|
class="option-color"
|
||||||
class="target-option-list"
|
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
|
||||||
:class="{ 'assignment-target-list': commandKey === 'che_발령' && field.optionSource === 'generals' }"
|
aria-hidden="true"
|
||||||
:data-testid="
|
/>
|
||||||
field.optionSource === 'nations'
|
<span>{{ commandTargetDescription(commandKey, selectedOptionFor(field)) }}</span>
|
||||||
? 'nation-target-list'
|
</div>
|
||||||
: field.optionSource === 'cities'
|
<div
|
||||||
? 'city-target-list'
|
v-if="searchEnabled && isSearchable(field)"
|
||||||
: 'general-target-list'
|
class="target-search"
|
||||||
"
|
:data-testid="`target-search-${field.key}`"
|
||||||
>
|
>
|
||||||
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty">검색 결과가 없습니다.</span>
|
<label :for="`command-search-${field.key}`">{{ field.label }} 검색</label>
|
||||||
<button
|
<input
|
||||||
v-for="option in visibleOptionsFor(field)"
|
:id="`command-search-${field.key}`"
|
||||||
:key="String(option.value)"
|
:value="searchDrafts[field.key] ?? ''"
|
||||||
type="button"
|
type="search"
|
||||||
class="target-option"
|
inputmode="search"
|
||||||
|
enterkeyhint="done"
|
||||||
|
placeholder="이름 또는 초성"
|
||||||
|
autocomplete="off"
|
||||||
|
:spellcheck="false"
|
||||||
|
@input="updateSearchDraft(field.key, $event)"
|
||||||
|
@compositionend="updateSearchDraft(field.key, $event)"
|
||||||
|
@blur="flushSearch(field.key)"
|
||||||
|
@keydown.enter="onSearchKeydown(field.key, $event)"
|
||||||
|
@keydown.esc.stop="onSearchKeydown(field.key, $event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="legacy-button legacy-button--secondary"
|
||||||
|
@click="clearFieldSearch(field.key)"
|
||||||
|
>
|
||||||
|
지우기
|
||||||
|
</button>
|
||||||
|
<small role="status"
|
||||||
|
>검색 결과 {{ visibleOptionsFor(field).length }}개 / {{ optionsFor(field).length }}개</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isSearchable(field)"
|
||||||
|
class="target-option-list"
|
||||||
:class="{
|
:class="{
|
||||||
selected: option.value === values[field.key],
|
'assignment-target-list': commandKey === 'che_발령' && field.optionSource === 'generals',
|
||||||
unavailable: option.availableNow === false,
|
|
||||||
}"
|
}"
|
||||||
:aria-pressed="option.value === values[field.key]"
|
:data-testid="
|
||||||
@click="setSelectValue(field, String(option.value))"
|
field.optionSource === 'nations'
|
||||||
|
? 'nation-target-list'
|
||||||
|
: field.optionSource === 'cities'
|
||||||
|
? 'city-target-list'
|
||||||
|
: 'general-target-list'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
|
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty"
|
||||||
<strong :style="colorOptionStyle(field, option)">{{ option.label }}</strong>
|
>검색 결과가 없습니다.</span
|
||||||
<span class="target-state">{{
|
>
|
||||||
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
|
<button
|
||||||
}}</span>
|
v-for="option in visibleOptionsFor(field)"
|
||||||
<small>{{ commandTargetDescription(commandKey, option) }}</small>
|
:key="String(option.value)"
|
||||||
</button>
|
type="button"
|
||||||
|
class="target-option"
|
||||||
|
:class="{
|
||||||
|
selected: option.value === values[field.key],
|
||||||
|
unavailable: option.availableNow === false,
|
||||||
|
}"
|
||||||
|
:aria-pressed="option.value === values[field.key]"
|
||||||
|
@click="setSelectValue(field, String(option.value))"
|
||||||
|
>
|
||||||
|
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
|
||||||
|
<strong :style="colorOptionStyle(field, option)">{{ option.label }}</strong>
|
||||||
|
<span class="target-state">{{
|
||||||
|
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
|
||||||
|
}}</span>
|
||||||
|
<small>{{ commandTargetDescription(commandKey, option) }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -704,9 +704,6 @@ small {
|
|||||||
font-size: var(--sammo-font-size-small);
|
font-size: var(--sammo-font-size-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
.target-search-toggle {
|
|
||||||
margin: 6px 8px;
|
|
||||||
}
|
|
||||||
.target-search {
|
.target-search {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -739,6 +736,29 @@ small {
|
|||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 전체화면 입력의 넓은 폭에서는 Ref 처리 화면처럼 지도를 계속 보며 목록을 고르도록
|
||||||
|
* 원본 크기(700px) 지도와 입력 열을 나란히 둔다. 지도는 긴 대상 목록을 scroll해도
|
||||||
|
* 상단 바 아래에 남는다. 더 좁은 폭은 기존처럼 지도 아래에 입력을 둔다.
|
||||||
|
*/
|
||||||
|
@media (min-width: 1120px) {
|
||||||
|
.command-argument-form.has-map {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 700px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-argument-form.has-map > .command-map {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--argument-overlay-header-height, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-argument-form.has-map > .argument-fields {
|
||||||
|
min-width: 0;
|
||||||
|
border-left: 1px solid rgba(201, 164, 90, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.command-map small {
|
.command-map small {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { effectScope, type Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { CommandInputField } from '../components/command/types';
|
||||||
|
|
||||||
|
// Ref TopBackBar의 `검색 켜짐/꺼짐`은 처리 화면 상단에 있고 목록 선택기가 그 값을 받는다.
|
||||||
|
// Core도 상단 바와 인자 폼이 같은 ref를 공유하도록 모듈 단위로 한 번만 만든다.
|
||||||
|
// 처음 호출한 component가 unmount되어도 저장 watch가 멈추지 않게 분리된 scope에 둔다.
|
||||||
|
let searchEnabled: Ref<boolean> | undefined;
|
||||||
|
|
||||||
|
export const useCommandTargetSearchEnabled = (): Ref<boolean> => {
|
||||||
|
searchEnabled ??= effectScope(true).run(() => useStorage('sam.core.commandTargetSearch', false));
|
||||||
|
if (!searchEnabled) throw new Error('command target search state was not created');
|
||||||
|
return searchEnabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCommandTargetSearchField = (field: CommandInputField): boolean =>
|
||||||
|
field.kind === 'select' && ['generals', 'cities', 'nations'].includes(field.optionSource ?? '');
|
||||||
Reference in New Issue
Block a user