예약 명령 인자 입력을 전체화면 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:
2026-09-23 13:39:02 +00:00
co-authored by Claude Opus 5.5
parent 9d0a1a4add
commit a373ed327d
7 changed files with 613 additions and 278 deletions
@@ -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 {
@@ -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<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;
document.body.style.overflow = 'hidden';
await nextTick();
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) => {
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<HTMLElement>(
'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) => {
</div>
</div>
<Teleport to="body" :disabled="!isRecruitmentCommand">
<Teleport to="body" :disabled="!isArgumentOverlay">
<div
v-if="pickerOpen"
ref="pickerElement"
class="command-picker"
:class="{ 'recruitment-picker': isRecruitmentCommand }"
:class="{ 'argument-overlay': isArgumentOverlay, 'recruitment-picker': isRecruitmentCommand }"
data-testid="command-picker"
:style="
isRecruitmentCommand || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
isArgumentOverlay || quickTarget === null || props.compact ? undefined : { top: quickPickerTop }
"
:role="isRecruitmentCommand ? 'dialog' : undefined"
:aria-modal="isRecruitmentCommand ? 'true' : undefined"
:role="isArgumentOverlay ? 'dialog' : undefined"
:aria-modal="isArgumentOverlay ? 'true' : undefined"
:aria-label="
isRecruitmentCommand
? `${selectedCommand?.name ?? ''} ${quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴`} 명령 입력`
: undefined
isArgumentOverlay ? `${selectedCommand?.name ?? ''} ${overlayTurnLabel} 명령 입력` : undefined
"
@keydown.esc.stop.prevent="closePicker"
@keydown="trapRecruitmentFocus"
@keydown="trapOverlayFocus"
>
<header>
<strong
><template v-if="isRecruitmentCommand">{{ selectedCommand?.name }} · </template
>{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력</strong
<header v-if="isArgumentOverlay" class="argument-overlay-bar" data-testid="command-input-top-bar">
<button
data-picker-close
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>
</header>
<CommandSelectForm
@@ -790,8 +861,8 @@ const clickOutsideMenu = (event: Event) => {
@select="selectCommand"
/>
<template v-else>
<div class="selected-command">
<strong>{{ selectedCommand.name }}</strong>
<div v-if="!isArgumentOverlay || selectedCommand.reason" class="selected-command">
<strong v-if="!isArgumentOverlay">{{ selectedCommand.name }}</strong>
<small v-if="selectedCommand.reason"
>현재 상태: {{ selectedCommand.reason }} · 예약 입력은 가능합니다.</small
>
@@ -819,7 +890,11 @@ const clickOutsideMenu = (event: Event) => {
@update:valid="commandArgsValid = $event"
/>
<div class="picker-actions">
<button :disabled="Boolean(pendingReservation)" @click="returnToCommandList">
<button
v-if="!isArgumentOverlay"
:disabled="Boolean(pendingReservation)"
@click="returnToCommandList"
>
명령 다시 선택</button
><button :disabled="!commandArgsValid || Boolean(pendingReservation)" @click="submitCommand">
{{ pendingReservation ? '저장 중' : '입력' }}
@@ -1236,7 +1311,9 @@ small {
max-height: 344px;
}
.command-picker.recruitment-picker {
.command-picker.argument-overlay {
--argument-overlay-header-height: 44px;
--argument-overlay-content-width: 1000px;
position: fixed;
z-index: 1100;
inset: 0;
@@ -1255,48 +1332,120 @@ small {
box-shadow: none;
transform: none;
}
.command-picker.recruitment-picker > header {
/* Ref TopBackBar: 돌아가기 · 명령명 · 검색 켜짐/꺼짐을 1000px 폭의 한 줄에 둔다. */
.command-picker.argument-overlay > .argument-overlay-bar {
position: sticky;
z-index: 30;
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;
min-height: 44px;
padding: 6px 8px;
height: var(--argument-overlay-header-height);
min-height: 0;
padding: 6px max(8px, calc((100% - var(--argument-overlay-content-width)) / 2 + 8px));
border-bottom: 1px solid #777;
background: #302016 var(--sammo-texture-walnut);
}
.command-picker.recruitment-picker > header button {
min-width: 36px;
min-height: 32px;
.command-picker.argument-overlay > .argument-overlay-bar > button {
width: auto;
min-width: 90px;
height: 32px;
padding: 0 10px;
white-space: nowrap;
cursor: pointer;
}
.command-picker.recruitment-picker .selected-command,
.command-picker.recruitment-picker :deep(.recruitment-command-form),
.command-picker.recruitment-picker .picker-actions {
width: min(100%, 1000px);
.command-picker.argument-overlay > .argument-overlay-bar > .overlay-search {
grid-column: 4;
}
.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-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;
}
.command-picker.recruitment-picker .picker-actions {
.command-picker.argument-overlay .picker-actions {
position: sticky;
z-index: 30;
bottom: 0;
box-sizing: border-box;
grid-template-columns: 1fr;
margin-top: 0;
padding: 6px;
border-top: 1px solid #777;
background: #302016 var(--sammo-texture-walnut);
}
@media (min-width: 1025px) {
.argument-expanded:not(.compact) .command-picker {
right: 0;
left: auto;
width: 700px;
/* Ref 1000px 문서 폭을 기본으로 두고, 지도 입력만 원본 지도와 목록 열을 함께 담도록 넓힌다. */
@media (min-width: 1120px) {
.command-picker.argument-overlay:has(.command-argument-form.has-map) {
--argument-overlay-content-width: 1120px;
}
}
/* 좁은 화면은 제목을 첫 줄에 두고 조작 버튼을 둘째 줄에 같은 폭으로 나눈다. */
@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 {
position: fixed;
z-index: 1000;
@@ -1305,13 +1454,6 @@ small {
left: calc(50% - 476px);
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 {
@@ -1352,16 +1494,6 @@ small {
width: 370px;
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 {
position: static;
grid-column: 2;
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, watch, type CSSProperties } from 'vue';
import { commandTargetDescription } from '../../utils/commandTargetDescription';
import { useStorage } from '@vueuse/core';
import {
isCommandTargetSearchField,
useCommandTargetSearchEnabled,
} from '../../composables/useCommandTargetSearchEnabled';
import { buildCommandOptionSearchIndex, matchesCommandTargetSearch } from '../../utils/commandTargetSearch';
import MapViewer from './MapViewer.vue';
import NationColorSelect from './NationColorSelect.vue';
@@ -70,7 +73,7 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
return props.options[field.optionSource];
};
const searchEnabled = useStorage('sam.core.commandTargetSearch', false);
const searchEnabled = useCommandTargetSearchEnabled();
const searchQueries = reactive<Record<string, string>>({});
const searchDrafts = reactive<Record<string, string>>({});
const searchTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -107,9 +110,7 @@ const onSearchKeydown = (key: string, event: KeyboardEvent) => {
(event.target as HTMLInputElement).blur();
}
};
const isSearchable = (field: CommandInputField): boolean =>
field.kind === 'select' && ['generals', 'cities', 'nations'].includes(field.optionSource ?? '');
const hasSearchableFields = computed(() => props.fields.some(isSearchable));
const isSearchable = isCommandTargetSearchField;
// 후보나 정렬이 바뀔 때만 인덱스를 갱신하고 키 입력은 기존 인덱스를 검색한다.
const targetIndexes = computed(
() =>
@@ -461,6 +462,7 @@ watch(
<div
v-if="props.fields.length || showMap || presentation.lines.length"
class="command-argument-form"
:class="{ 'has-map': showMap }"
data-testid="command-argument-form"
>
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
@@ -474,7 +476,7 @@ watch(
:show-current-city-marker="true"
@select-city="selectMapCity"
/>
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<small>지도에서 도시를 클릭하거나 목록에서 대상을 선택하세요.</small>
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
<span class="current-city-status">
<span class="status-key">현재 도시</span>
@@ -490,211 +492,209 @@ watch(
{{ mapTargetSummary }}
</div>
</div>
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
</div>
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
</div>
<button
v-if="hasSearchableFields"
type="button"
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">
<div class="argument-fields">
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
</div>
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
</div>
<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}`"
type="number"
:value="Number(values[field.key] ?? 0)"
:min="effectiveMin(field)"
:max="effectiveMax(field)"
:step="effectiveStep(field)"
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
: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"
/>
<select
v-if="amountPreset"
aria-label="금액 프리셋"
class="amount-preset"
value=""
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value)"
<small
v-if="field.kind === 'text' && textFieldError(field)"
:id="`command-arg-${field.key}-error`"
class="argument-error"
role="alert"
>
<option value="" disabled>프리셋</option>
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
{{ preset.toLocaleString() }}
</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>
{{ textFieldError(field) }}
</small>
<div v-else-if="field.kind === 'number'" class="number-options">
<input
:id="`command-arg-${field.key}`"
type="number"
:value="(values[field.key] as number[] | undefined)?.[index] ?? 0"
:value="Number(values[field.key] ?? 0)"
:min="effectiveMin(field)"
:max="effectiveMax(field)"
:step="effectiveStep(field)"
@input="setTupleValue(field, index, ($event.target as HTMLInputElement).value)"
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
/>
<select
v-if="amountPreset"
:aria-label="`${tupleLabel} 금액 프리셋`"
aria-label="금액 프리셋"
class="amount-preset"
value=""
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value, index)"
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value)"
>
<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' }"
>
<span
v-if="selectedOptionFor(field)?.color"
class="option-color"
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
aria-hidden="true"
</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))"
/>
<span>{{ commandTargetDescription(commandKey, selectedOptionFor(field)) }}</span>
</div>
<div
v-if="searchEnabled && isSearchable(field)"
class="target-search"
:data-testid="`target-search-${field.key}`"
>
<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)"
<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)"
>
지우기
</button>
<small role="status"
>검색 결과 {{ visibleOptionsFor(field).length }}개 / {{ optionsFor(field).length }}개</small
<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
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>
<div
v-if="isSearchable(field)"
class="target-option-list"
:class="{ 'assignment-target-list': commandKey === 'che_발령' && field.optionSource === 'generals' }"
:data-testid="
field.optionSource === 'nations'
? 'nation-target-list'
: field.optionSource === 'cities'
? 'city-target-list'
: 'general-target-list'
"
>
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty">검색 결과가 없습니다.</span>
<button
v-for="option in visibleOptionsFor(field)"
:key="String(option.value)"
type="button"
class="target-option"
<span
v-if="selectedOptionFor(field)?.color"
class="option-color"
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
aria-hidden="true"
/>
<span>{{ commandTargetDescription(commandKey, selectedOptionFor(field)) }}</span>
</div>
<div
v-if="searchEnabled && isSearchable(field)"
class="target-search"
:data-testid="`target-search-${field.key}`"
>
<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)"
>
지우기
</button>
<small role="status"
>검색 결과 {{ visibleOptionsFor(field).length }}개 / {{ optionsFor(field).length }}개</small
>
</div>
<div
v-if="isSearchable(field)"
class="target-option-list"
:class="{
selected: option.value === values[field.key],
unavailable: option.availableNow === false,
'assignment-target-list': commandKey === 'che_발령' && field.optionSource === 'generals',
}"
:aria-pressed="option.value === values[field.key]"
@click="setSelectValue(field, String(option.value))"
:data-testid="
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 }" />
<strong :style="colorOptionStyle(field, option)">{{ option.label }}</strong>
<span class="target-state">{{
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
}}</span>
<small>{{ commandTargetDescription(commandKey, option) }}</small>
</button>
<span v-if="!visibleOptionsFor(field).length" class="target-search-empty"
>검색 결과가 없습니다.</span
>
<button
v-for="option in visibleOptionsFor(field)"
:key="String(option.value)"
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 v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div>
</template>
@@ -704,9 +704,6 @@ small {
font-size: var(--sammo-font-size-small);
}
.target-search-toggle {
margin: 6px 8px;
}
.target-search {
grid-column: 1 / -1;
display: flex;
@@ -739,6 +736,29 @@ small {
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 {
display: block;
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 ?? '');