fix(game-ui): 모바일 국가색 선택기를 추가한다

건국과 국기 변경의 색상 입력을 앱 내부 listbox로 렌더링해 모바일 OS 선택기에서도 항목별 국가색을 직접 확인할 수 있게 한다. 키보드 탐색과 닫기 동작, 대비색, 터치 행 크기와 예약 payload를 Chromium 회귀로 고정한다.
This commit is contained in:
2026-08-27 15:43:01 +00:00
parent edd962922e
commit 7c07742690
3 changed files with 270 additions and 8 deletions
+44 -8
View File
@@ -1003,7 +1003,9 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
});
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => {
test('defaults founding to a Ref-selectable nation trait and opens colored options inside the app', async ({
page,
}) => {
const foundingColors = [
{ value: 0, label: '색상 1', color: '#FF0000' },
{ value: 15, label: '색상 16', color: '#6495ED' },
@@ -1044,7 +1046,7 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
nation: [],
inputOptions: { ...inputOptions, colors: foundingColors },
};
await install(page, false, foundingCommandTable);
const requests = await install(page, false, foundingCommandTable);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
@@ -1060,18 +1062,30 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
await expect(nationType).toBeFocused();
const colorType = picker.getByLabel('국기 색상');
const color16 = colorType.locator('option[value="15"]');
await expect(colorType).toHaveRole('button');
await colorType.click();
const colorList = picker.getByRole('listbox');
const color16 = colorList.getByRole('option', { name: '색상 16' });
await expect(color16).toHaveText('색상 16');
await expect(color16).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(color16).toHaveCSS('color', 'rgb(255, 255, 255)');
await colorType.selectOption('15');
await expect(colorType).toHaveValue('15');
await color16.hover();
await color16.focus();
await expect(color16).toBeFocused();
await color16.press('Escape');
await expect(colorList).toBeHidden();
await expect(colorType).toBeFocused();
await colorType.click();
await color16.click();
await expect(colorType).toContainText('색상 16');
await expect(colorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(colorType).toHaveCSS('color', 'rgb(255, 255, 255)');
await colorType.selectOption('16');
await colorType.press('ArrowDown');
await page.getByRole('option', { name: '색상 17' }).press('Enter');
await expect(colorType).toHaveCSS('background-color', 'rgb(127, 255, 212)');
await expect(colorType).toHaveCSS('color', 'rgb(0, 0, 0)');
await colorType.selectOption('15');
await colorType.press('ArrowUp');
await page.getByRole('option', { name: '색상 16' }).press('Enter');
await picker.screenshot({ path: test.info().outputPath('founding-colored-option-desktop-1200.png') });
await page.setViewportSize({ width: 500, height: 900 });
@@ -1079,8 +1093,27 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
const mobilePicker = page.getByTestId('command-picker');
await mobilePicker.getByRole('button', { name: '국가', exact: true }).click();
await mobilePicker.getByRole('button', { name: '건국', exact: true }).click();
await mobilePicker.getByLabel('국가명').fill('신국');
const mobileColorType = mobilePicker.getByLabel('국기 색상');
await mobileColorType.selectOption('15');
await mobileColorType.click();
const mobileColorList = mobilePicker.getByRole('listbox');
const mobileColor16 = mobileColorList.getByRole('option', { name: '색상 16' });
await expect(mobileColor16).toBeVisible();
await expect(mobileColor16).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(mobileColor16).toHaveCSS('color', 'rgb(255, 255, 255)');
const mobileOptionRect = await mobileColor16.evaluate((element) => element.getBoundingClientRect().toJSON());
expect(mobileOptionRect.height).toBeGreaterThanOrEqual(44);
const mobileListGeometry = await mobileColorList.evaluate((element) => ({
bottom: element.getBoundingClientRect().bottom,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}));
const mobilePickerBottom = await mobilePicker.evaluate((element) => element.getBoundingClientRect().bottom);
expect(mobileListGeometry.bottom).toBeLessThanOrEqual(mobilePickerBottom);
expect(mobileListGeometry.scrollHeight).toBeGreaterThanOrEqual(mobileListGeometry.clientHeight);
await mobilePicker.screenshot({ path: test.info().outputPath('founding-color-options-open-mobile-500.png') });
await mobileColor16.click();
await expect(mobileColorType).toContainText('색상 16');
await expect(mobileColorType).toHaveCSS('background-color', 'rgb(100, 149, 237)');
await expect(mobileColorType).toHaveCSS('color', 'rgb(255, 255, 255)');
const colorSelectRect = await mobileColorType.evaluate((element) => element.getBoundingClientRect().toJSON());
@@ -1088,6 +1121,9 @@ test('defaults founding to a Ref-selectable nation trait and paints color option
expect(colorSelectRect.right).toBeLessThanOrEqual(500);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500);
await mobilePicker.screenshot({ path: test.info().outputPath('founding-colored-option-mobile-500.png') });
await mobilePicker.getByRole('button', { name: '입력', exact: true }).click();
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toContainText('신국');
await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15');
});
test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => {
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, reactive, watch, type CSSProperties } from 'vue';
import MapViewer from './MapViewer.vue';
import NationColorSelect from './NationColorSelect.vue';
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
import { commandCityOptions } from '../command/commandArgumentOptions';
import {
@@ -141,6 +142,11 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
optionsFor(field).find((entry) => entry.value === values[field.key]);
const selectedValueFor = (field: CommandInputField): string | number => {
const value = values[field.key];
return typeof value === 'string' || typeof value === 'number' ? value : '';
};
const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSSProperties | undefined => {
if (field.optionSource === 'colors' && option?.color) {
return {
@@ -478,6 +484,13 @@ watch(
</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}`"
@@ -0,0 +1,213 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { legacyNationTextColor } from '../../utils/legacyNationColor';
import type { CommandOption } from '../command/types';
const props = defineProps<{
id: string;
modelValue: string | number;
options: CommandOption[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: string | number];
}>();
const root = ref<HTMLElement | null>(null);
const trigger = ref<HTMLButtonElement | null>(null);
const open = ref(false);
const selectedOption = computed(() => props.options.find((option) => option.value === props.modelValue));
const selectedIndex = computed(() =>
Math.max(
props.options.findIndex((option) => option.value === props.modelValue),
0
)
);
const optionStyle = (option?: CommandOption) => {
if (!option?.color) return undefined;
return {
backgroundColor: option.color,
color: legacyNationTextColor(option.color),
};
};
const focusOption = async (index: number): Promise<void> => {
await nextTick();
root.value?.querySelector<HTMLButtonElement>(`[data-color-option-index="${index}"]`)?.focus();
};
const openAndFocus = (index: number): void => {
open.value = true;
void focusOption(index);
};
const toggleOpen = (): void => {
open.value = !open.value;
};
const selectOption = (option: CommandOption): void => {
emit('update:modelValue', option.value);
open.value = false;
void nextTick(() => trigger.value?.focus());
};
const handleTriggerKeydown = (event: KeyboardEvent): void => {
if (event.key === 'ArrowDown') {
event.preventDefault();
openAndFocus(selectedIndex.value);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
openAndFocus(open.value ? selectedIndex.value : props.options.length - 1);
} else if (event.key === 'Home') {
event.preventDefault();
openAndFocus(0);
} else if (event.key === 'End') {
event.preventDefault();
openAndFocus(props.options.length - 1);
} else if (event.key === 'Escape' && open.value) {
event.preventDefault();
event.stopPropagation();
open.value = false;
}
};
const handleOptionKeydown = (event: KeyboardEvent, index: number): void => {
let nextIndex: number | null = null;
if (event.key === 'ArrowDown') nextIndex = Math.min(index + 1, props.options.length - 1);
else if (event.key === 'ArrowUp') nextIndex = Math.max(index - 1, 0);
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = props.options.length - 1;
else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
open.value = false;
void nextTick(() => trigger.value?.focus());
return;
}
if (nextIndex === null) return;
event.preventDefault();
void focusOption(nextIndex);
};
const handleDocumentPointerDown = (event: PointerEvent): void => {
if (!root.value?.contains(event.target as Node)) open.value = false;
};
onMounted(() => document.addEventListener('pointerdown', handleDocumentPointerDown));
onBeforeUnmount(() => document.removeEventListener('pointerdown', handleDocumentPointerDown));
</script>
<template>
<div ref="root" class="nation-color-select">
<button
:id="id"
ref="trigger"
type="button"
class="nation-color-select-trigger"
:style="optionStyle(selectedOption)"
aria-haspopup="listbox"
:aria-expanded="open"
:aria-controls="`${id}-options`"
@click="toggleOpen"
@keydown="handleTriggerKeydown"
>
<span>{{ selectedOption?.label ?? '색상 선택' }}</span>
<span class="nation-color-select-arrow" aria-hidden="true"></span>
</button>
<div v-if="open" :id="`${id}-options`" class="nation-color-select-options" role="listbox">
<button
v-for="(option, index) in options"
:key="String(option.value)"
type="button"
class="nation-color-select-option"
role="option"
:aria-selected="option.value === modelValue"
:data-color-option-index="index"
:style="optionStyle(option)"
@click="selectOption(option)"
@keydown="handleOptionKeydown($event, index)"
>
<span>{{ option.label }}</span>
<span v-if="option.value === modelValue" aria-hidden="true"></span>
</button>
</div>
</div>
</template>
<style scoped>
.nation-color-select {
position: relative;
min-width: 0;
margin: 4px 6px 4px 0;
}
.nation-color-select-trigger,
.nation-color-select-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
border: 1px solid rgba(201, 164, 90, 0.45);
padding: 5px 6px;
font: inherit;
text-align: left;
cursor: pointer;
}
.nation-color-select-trigger {
min-height: 28px;
}
.nation-color-select-trigger:hover,
.nation-color-select-trigger[aria-expanded='true'] {
border-color: rgba(244, 216, 153, 0.9);
}
.nation-color-select-trigger:focus-visible,
.nation-color-select-option:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid #fff;
outline-offset: -3px;
}
.nation-color-select-arrow {
margin-left: 8px;
}
.nation-color-select-options {
position: absolute;
z-index: 60;
top: calc(100% + 2px);
right: 0;
left: 0;
max-height: 240px;
overflow-y: auto;
border: 1px solid rgba(201, 164, 90, 0.7);
background: #111;
box-shadow: 0 5px 14px rgb(0 0 0 / 75%);
}
.nation-color-select-option {
min-height: 34px;
border: 0;
border-bottom: 1px solid rgb(0 0 0 / 30%);
}
.nation-color-select-option:hover,
.nation-color-select-option[aria-selected='true'] {
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 78%);
}
@media (max-width: 520px) {
.nation-color-select-options {
position: static;
margin-top: 2px;
}
.nation-color-select-option {
min-height: 44px;
}
}
</style>