diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 0266dab62..02eab493e 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -11,6 +11,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_l import { createButton } from "@ui/ui_template_elements"; import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend"; import { navigateTo, isAuthenticated } from "./router"; +import { readTouchMode, setTouchMode, type TouchMode } from "./device_context"; const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href; @@ -82,21 +83,7 @@ function buildHeader(): HTMLElement { // 네비게이션 링크 const nav = document.createElement("nav"); nav.className = "app-nav"; - const navItems: [keyof typeof ui_locales, string][] = [ - ["App_Nav_Program", ROUTES.A02_PROG_DETAIL], - ["App_Nav_Company", ROUTES.A03_COMP_DETAIL], - ["App_Nav_News", ROUTES.A04_NEWS_HISTORY], - ["App_Nav_Education", ROUTES.A05_EDU_DETAIL], - ["App_Nav_Support", ROUTES.A08_SUPPORT], - ]; - for (const [labelKey, route] of navItems) { - const link = createButton({ - label: L(labelKey), - variant: "pill", - onClick: () => navigateTo(route as never), - }); - nav.append(link); - } + nav.append(...buildNavButtons()); // 우측 액션 (언어/테마/로그인) const actions = document.createElement("div"); @@ -120,10 +107,118 @@ function buildHeader(): HTMLElement { actions.append(authSlot); void fillAuthActions(authSlot); - header.append(brand, nav, actions); + header.append(brand, nav, actions, ...buildDrawer(header)); return header; } +const NAV_ITEMS: [keyof typeof ui_locales, string][] = [ + ["App_Nav_Program", ROUTES.A02_PROG_DETAIL], + ["App_Nav_Company", ROUTES.A03_COMP_DETAIL], + ["App_Nav_News", ROUTES.A04_NEWS_HISTORY], + ["App_Nav_Education", ROUTES.A05_EDU_DETAIL], + ["App_Nav_Support", ROUTES.A08_SUPPORT], +]; + +function buildNavButtons(): HTMLElement[] { + return NAV_ITEMS.map(([labelKey, route]) => + createButton({ + label: L(labelKey), + variant: "pill", + onClick: () => navigateTo(route as never), + }), + ); +} + +/* ----------------------------------------------------------------------------- + * ☰ 서랍(PLAN_기기대응 2-2) — 폭 900 이하(위 메뉴가 숨는 폭) · 터치 모드에서 보임. + * 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치. 데스크탑 마우스엔 안 보임. + * -------------------------------------------------------------------------- */ +function buildDrawer(header: HTMLElement): HTMLElement[] { + const burger = document.createElement("button"); + burger.type = "button"; + burger.className = "app-burger"; + burger.textContent = "☰"; + burger.setAttribute("aria-label", L("App_Menu")); + burger.setAttribute("aria-expanded", "false"); + const drawer = document.createElement("div"); + drawer.className = "app-drawer"; + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("aria-label", L("App_Menu")); + const scrim = document.createElement("div"); + scrim.className = "app-drawer__scrim"; + const setOpen = (open: boolean): void => { + header.classList.toggle("is-drawer-open", open); + burger.setAttribute("aria-expanded", String(open)); + }; + burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); + scrim.addEventListener("click", () => setOpen(false)); + drawer.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); + // 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) + drawer.addEventListener("click", (e) => { + if ((e.target as Element).closest(".app-drawer__nav, .app-drawer__auth")) setOpen(false); + }); + + const navList = document.createElement("nav"); + navList.className = "app-drawer__nav"; + navList.append(...buildNavButtons()); + const prefs = document.createElement("div"); + prefs.className = "app-drawer__row"; + prefs.append( + createButton({ + label: currentLanguageIndex === 0 ? "EN" : "한", + variant: "pill", + onClick: onApp_Lang_Toggle_Click, + }), + createButton({ + label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾", + variant: "pill", + onClick: onApp_Theme_Toggle_Click, + }), + ); + const auth = document.createElement("div"); + auth.className = "app-drawer__row app-drawer__auth"; + void fillAuthActions(auth); + drawer.append(navList, prefs, auth, buildTouchModeSwitch()); + return [burger, scrim, drawer]; +} + +function buildTouchModeSwitch(): HTMLElement { + const box = document.createElement("div"); + box.className = "app-drawer__touch"; + const label = document.createElement("span"); + label.textContent = L("App_TouchMode"); + const group = document.createElement("div"); + group.className = "app-drawer__row"; + group.setAttribute("role", "radiogroup"); + group.setAttribute("aria-label", L("App_TouchMode")); + const modes: [TouchMode, keyof typeof ui_locales][] = [ + ["auto", "App_TouchMode_Auto"], + ["on", "App_TouchMode_On"], + ["off", "App_TouchMode_Off"], + ]; + const buttons = modes.map(([mode, key]) => { + const b = createButton({ + label: L(key), + variant: "pill", + onClick: () => { + setTouchMode(mode); + mark(); + }, + }); + b.setAttribute("role", "radio"); + b.dataset.touchMode = mode; + return b; + }); + const mark = (): void => { + const now = readTouchMode(); + for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now)); + }; + mark(); + group.append(...buttons); + box.append(label, group); + return box; +} + /** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */ async function fillAuthActions(slot: HTMLElement): Promise { try { @@ -259,10 +354,10 @@ const SHELL_CSS = ` gap: var(--spacing-8); } .app-outlet { - min-height: calc(100vh - 64px - 56px); + min-height: calc(100dvh - 64px - 56px); } .app-outlet--without-footer { - min-height: calc(100vh - 64px); + min-height: calc(100dvh - 64px); } .app-footer { height: 56px; @@ -285,7 +380,79 @@ const SHELL_CSS = ` } @media (max-width: 900px) { .app-nav { display: none; } + .app-actions { margin-left: auto; } } +/* ☰ 서랍 — 폭 900 이하 · 터치 모드에서만 보임(데스크탑 마우스엔 없음) */ +.app-burger, +.app-drawer, +.app-drawer__scrim { + display: none; +} +.app-burger { + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text); + font-size: 20px; + cursor: pointer; +} +@media (max-width: 900px) { + .app-burger { display: inline-flex; } +} +html[data-touch="on"] .app-burger { display: inline-flex; } +.app-header.is-drawer-open .app-drawer__scrim { + display: block; + position: fixed; + inset: 64px 0 0 0; + background: rgba(38, 17, 74, 0.35); +} +.app-header.is-drawer-open .app-drawer { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + position: fixed; + top: 64px; + right: 0; + bottom: 0; + width: min(320px, 100vw); + padding: var(--spacing-16); + overflow-y: auto; + background: var(--color-surface-raised); + border-left: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); +} +.app-drawer__nav { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} +.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; } +.app-drawer__row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} +.app-drawer__touch { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-16); + border-top: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} +.app-drawer__touch [aria-checked="true"] { + background: var(--color-mist-violet); + color: var(--color-accent); +} +/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */ +html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); } +html[data-size="compact"] .app-actions { display: none; } +html[data-size="compact"] .app-burger { margin-left: auto; } `; export function injectShellStyles(): void { diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index ab46c241d..aeda37272 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -174,7 +174,7 @@ const SCAFFOLD_CSS = ` font-size: var(--text-body-sm); color: var(--color-text-muted); } -.b-scaffold-wf { height: calc(100vh - 64px - 56px); } +.b-scaffold-wf { height: calc(100dvh - 64px - 56px); } .b-pending { display: flex; diff --git a/A00_Common/device_context.css b/A00_Common/device_context.css new file mode 100644 index 000000000..a636ae88b --- /dev/null +++ b/A00_Common/device_context.css @@ -0,0 +1,78 @@ +/* ============================================================================= + * device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6) + * 모든 규칙은 `` 표시(data-touch · data-size) 안에서만 — 데스크탑 마우스 화면은 안 바뀜. + * 표시는 `device_context.ts` 가 적음. + * ========================================================================== */ + +/* 누를 자리 44px — 손가락(주 입력 coarse) 또는 터치 모드 켬 */ +html[data-touch="on"] + :is( + .ui-btn, + .ui-workflow-overlay__toggle, + .ui-sidebar-row, + .ui-workflow-layout__step, + .ui-ctx-menu__item, + select, + input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"]) + ) { + min-height: 44px; +} + +html[data-touch="on"] .ui-btn { + min-width: 44px; +} + +/* 체크 · 라디오 — 상자를 키우고 글까지 한 줄 전체가 누를 자리 */ +html[data-touch="on"] :is(input[type="checkbox"], input[type="radio"]) { + width: 22px; + height: 22px; +} + +html[data-touch="on"] .ui-checkbox { + min-height: 44px; +} + +/* 좌측 패널 손잡이 — 손가락 폭 */ +html[data-touch="on"] .ui-workflow-overlay__handle--side { + width: 32px; + right: -31px; +} + +/* 팝업(2-4) — 폭 600 미만은 전체 화면 · 겹치면 나중 것(맨 위)만 보이고 × 로 앞 창. + 공용 모달(.ui-modal) · 페이지 팝업은 공용 클래스 `.ui-popup` 만 붙여 맞춤(자리 · 크기 인라인 값도 덮음) */ +html[data-size="compact"] :is(.ui-modal, .ui-popup) { + position: fixed !important; + inset: 0 !important; + width: auto !important; + height: auto !important; + min-width: 0 !important; + max-width: none !important; + max-height: none !important; + margin: 0 !important; + border-radius: 0 !important; + transform: none !important; + overflow: auto; +} + +html[data-size="compact"] .ui-confirm__panel { + min-width: 0; + max-width: calc(100vw - var(--spacing-32)); +} + +/* 공용 표(2-5) — 폭 840 미만은 고정 열이 없는 표도 첫 열을 붙임(머리줄은 늘 붙어 있음 · 가로는 밀기) */ +html:is([data-size="compact"], [data-size="medium"]) + .ui-data-table__table:not(:has(.is-frozen)) + tr + > :first-child { + position: sticky; + left: 0; + z-index: 1; +} + +html:is([data-size="compact"], [data-size="medium"]) + .ui-data-table__table:not(:has(.is-frozen)) + thead + tr + > :first-child { + z-index: 3; +} diff --git a/A00_Common/device_context.ts b/A00_Common/device_context.ts new file mode 100644 index 000000000..d59dfc34f --- /dev/null +++ b/A00_Common/device_context.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * device_context.ts + * 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `` 에 지금 상태를 늘 적음. + * + * - data-size = compact(<600) · medium(<840) · expanded(<1200) · large ← 배치는 이것만 봄 + * - data-pointer = 주 입력 coarse · fine(미디어 `pointer`) + * - data-input = 마지막 누름의 pointerType(mouse · touch · pen) + * - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장) + * - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때) + * 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태). + * 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수. + * 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘. + * ========================================================================== */ + +import "./device_context.css"; + +export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const; +export type SizeClass = "compact" | "medium" | "expanded" | "large"; +export type TouchMode = "auto" | "on" | "off"; + +export interface DeviceContext { + size: SizeClass; + pointer: "coarse" | "fine"; + input: string; + touchMode: TouchMode; + touch: boolean; +} + +const TOUCH_MODE_KEY = "frd_touch_mode"; + +export function sizeClassOf(width: number): SizeClass { + if (width < SIZE_BREAKS.medium) return "compact"; + if (width < SIZE_BREAKS.expanded) return "medium"; + if (width < SIZE_BREAKS.large) return "expanded"; + return "large"; +} + +let modeInMemory: TouchMode | null = null; // 저장이 막힌 브라우저용 + +export function readTouchMode(): TouchMode { + if (modeInMemory) return modeInMemory; + try { + const v = localStorage.getItem(TOUCH_MODE_KEY); + return v === "on" || v === "off" ? v : "auto"; + } catch { + return "auto"; + } +} + +const coarseQuery = window.matchMedia("(pointer: coarse)"); +let current: DeviceContext | null = null; +let lastInput = ""; + +function read(): DeviceContext { + const pointer = coarseQuery.matches ? "coarse" : "fine"; + const touchMode = readTouchMode(); + return { + size: sizeClassOf(window.innerWidth), + pointer, + input: lastInput || (pointer === "coarse" ? "touch" : "mouse"), + touchMode, + touch: touchMode === "on" || (touchMode === "auto" && pointer === "coarse"), + }; +} + +function apply(): void { + const next = read(); + if (current && JSON.stringify(current) === JSON.stringify(next)) return; + const html = document.documentElement; + html.dataset.size = next.size; + html.dataset.pointer = next.pointer; + html.dataset.input = next.input; + html.dataset.touchMode = next.touchMode; + html.dataset.touch = next.touch ? "on" : "off"; + const first = !current; + current = next; + if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next })); +} + +export function deviceContext(): DeviceContext { + return current ?? read(); +} + +/** 터치 모드 스위치(☰ 서랍) — 이 기기 브라우저에만 저장 */ +export function setTouchMode(mode: TouchMode): void { + modeInMemory = mode; + try { + if (mode === "auto") localStorage.removeItem(TOUCH_MODE_KEY); + else localStorage.setItem(TOUCH_MODE_KEY, mode); + } catch { + /* 저장 막힌 브라우저 — 이번 화면만 */ + } + apply(); +} + +function init(): void { + const html = document.documentElement; + for (const [name, px] of Object.entries(SIZE_BREAKS)) + html.style.setProperty(`--bp-${name}`, `${px}px`); + apply(); + coarseQuery.addEventListener("change", apply); + window.addEventListener("resize", apply); + window.visualViewport?.addEventListener("resize", apply); + screen.orientation?.addEventListener("change", apply); + window.addEventListener( + "pointerdown", + (e) => { + if (e.pointerType === lastInput) return; + lastInput = e.pointerType; + apply(); + }, + { capture: true, passive: true }, + ); +} + +init(); diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 571f02c10..2a84f9ce1 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import "./random_uuid_polyfill"; +import "./device_context"; // 맨 앞 — 그리기 전에 기기 표시(기기대응 2-1) import "@ui/ui_template_theme.css"; import { injectBaseStyles } from "@ui/ui_template_elements"; import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu"; diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts index e27d0402a..3441b644e 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe attrs: { type: "button", title: TEXT.close }, }); const dialog = el("div", { - className: "ss-dialog", + className: "ss-dialog ui-popup", attrs: { hidden: "" }, children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body], }); diff --git a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts index e19ee044a..67bc68b39 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts @@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void { onClick: () => go(i + 1 < steps.length ? i + 1 : 0), }); const box = el("div", { - className: "ss-steps__box", + className: "ss-steps__box ui-popup", attrs: { role: "dialog", "aria-label": t("Title") }, }); const shade = el("div", { className: "ss-steps", children: [box] }); diff --git a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts index b34f99b55..92effb8cd 100644 --- a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts @@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void { const caseBox = el("input", { attrs: { type: "checkbox" } }); const list = el("div", { className: "ss-sort__keys" }); const box = el("div", { - className: "ss-sort__box", + className: "ss-sort__box ui-popup", attrs: { role: "dialog", "aria-label": t("Title") }, }); const shade = el("div", { className: "ss-sort", children: [box] }); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index f4f62046f..114a72f0e 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -560,7 +560,7 @@ display: flex; flex-direction: column; width: min(380px, calc(100vw - 32px)); - height: min(680px, calc(100vh - 104px)); + height: min(680px, calc(100dvh - 104px)); border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-surface, #fff); diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index be437104d..6719bdcd3 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -1,5 +1,5 @@ .b03-file { - min-height: calc(100vh - var(--app-header-height, 0px)); + min-height: calc(100dvh - var(--app-header-height, 0px)); background: linear-gradient( 180deg, var(--color-canvas, #ffffff) 0%, diff --git a/B03_FileInput/B03_FileInput_UI_TempPicker.ts b/B03_FileInput/B03_FileInput_UI_TempPicker.ts index 52d84a3c2..fd0dcb436 100644 --- a/B03_FileInput/B03_FileInput_UI_TempPicker.ts +++ b/B03_FileInput/B03_FileInput_UI_TempPicker.ts @@ -74,7 +74,7 @@ export function createTempPicker( const backdrop = document.createElement("div"); backdrop.className = "b03-file__modal-backdrop"; const modal = document.createElement("div"); - modal.className = "b03-file__modal b03-file__temp-modal"; + modal.className = "b03-file__modal b03-file__temp-modal ui-popup"; modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true"); diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 3ac47607d..fefe8d440 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -119,7 +119,7 @@ flex-direction: column; width: 100%; max-width: none; - min-height: calc(100vh - var(--spacing-64)); + min-height: calc(100dvh - var(--spacing-64)); } .b04-surface__workspace > .b04-surface__status { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 51cc3a763..fb0c4208d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -45,7 +45,7 @@ export function createRouteEditChrome(): RouteEditChrome { const overlay = document.createElement("div"); overlay.className = "ui-modal__backdrop b05-routeedit"; overlay.innerHTML = ` -