feat(480 드롭다운): 마우스로 고른 뒤 한 박자 뒤 초점 뺌 — 키보드 고름 그대로 · 공용 진입 · CAD iframe · 종단 새 창 세 자리
- ui_template_native_menu.ts releaseSelectAfterPick — 창 캡처 pointerdown · keydown · change · 아직 그 select 에 초점일 때만 blur - 시험 test_280_6_select_release 새로 · 옛 가짜 import 두 헬퍼(282-1 · 224-1c)에 새 함수 흉내 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { releaseSelectAfterPick } from "@ui/ui_template_native_menu";
|
||||
import type { DesignProfile } from "./design_profile";
|
||||
|
||||
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
|
||||
@@ -101,6 +102,8 @@ function attachPopout(profile: DesignProfile, spec: PopoutSpec): void {
|
||||
true,
|
||||
),
|
||||
);
|
||||
// 새 창 드롭다운도 고른 뒤 초점 뺌 — change 는 메인으로 안 넘김(요구 280-6).
|
||||
releaseSelectAfterPick(next);
|
||||
// 새 창이 초점을 잃으면 길게 누르기를 끊는다(메인 창 blur 듣기).
|
||||
next.addEventListener("blur", () => window.dispatchEvent(new FocusEvent("blur")));
|
||||
// 새 창 크기 기준으로 다시 맞춘다.
|
||||
|
||||
+7
-1
@@ -13,7 +13,11 @@ import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(
|
||||
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-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";
|
||||
import {
|
||||
blockNativeMenu,
|
||||
NATIVE_MENU_ROUTE,
|
||||
releaseSelectAfterPick,
|
||||
} from "@ui/ui_template_native_menu";
|
||||
import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe";
|
||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
|
||||
@@ -58,6 +62,8 @@ function bootstrap(): void {
|
||||
|
||||
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
|
||||
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
|
||||
// 드롭다운을 마우스로 고르면 초점 뺌 — 휠 · ↑↓ 가 값을 또 바꾸지 않게(요구 280-6)
|
||||
releaseSelectAfterPick(window);
|
||||
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
|
||||
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ const js = ts.transpileModule(read("A00_Common", "design", "design_profile_popou
|
||||
const mod = {};
|
||||
new Function("exports", "require", "window", "document", js)(
|
||||
mod,
|
||||
() => ({ showToast() {} }),
|
||||
() => ({ showToast() {}, releaseSelectAfterPick() {} }),
|
||||
main,
|
||||
mainDoc,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* 280-6 드롭다운 초점 빼기(`ui_template/ui_template_native_menu.ts` `releaseSelectAfterPick`) 단위검증 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 select · 가짜 타이머로 판정. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const js = ts.transpileModule(
|
||||
fs.readFileSync(path.join(ROOT, "ui_template", "ui_template_native_menu.ts"), "utf8"),
|
||||
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
// 가짜 창 — 캡처 듣기 · 타이머(손으로 돌림) · 문서 하나
|
||||
function setup() {
|
||||
const listeners = [];
|
||||
const timers = [];
|
||||
const doc = { activeElement: null };
|
||||
const win = {
|
||||
addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }),
|
||||
setTimeout: (fn) => timers.push(fn),
|
||||
};
|
||||
m.releaseSelectAfterPick(win);
|
||||
const el = (tagName) => {
|
||||
const node = { tagName, ownerDocument: doc, blurs: 0 };
|
||||
node.blur = () => {
|
||||
node.blurs += 1;
|
||||
doc.activeElement = null;
|
||||
};
|
||||
return node;
|
||||
};
|
||||
const fire = (type, target) =>
|
||||
listeners.filter((l) => l.type === type).forEach((l) => l.fn({ type, target }));
|
||||
const tick = () => timers.splice(0).forEach((fn) => fn());
|
||||
return { listeners, doc, el, fire, tick };
|
||||
}
|
||||
|
||||
// 차례 하나 — 초점을 select 에 두고 입력들을 쏜 뒤 change · (처리) · 한 틱
|
||||
function run(inputs, tag = "SELECT", moveFocus = false) {
|
||||
const t = setup();
|
||||
const target = t.el(tag);
|
||||
t.doc.activeElement = target;
|
||||
inputs.forEach((type) => t.fire(type, target));
|
||||
t.fire("change", target);
|
||||
const before = target.blurs; // 한 틱 전엔 안 뺌
|
||||
if (moveFocus) t.doc.activeElement = t.el("INPUT");
|
||||
t.tick();
|
||||
return { before, blurs: target.blurs };
|
||||
}
|
||||
|
||||
const src = (rel) => fs.readFileSync(path.join(ROOT, rel), "utf8");
|
||||
const setupOnce = setup();
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mouse: run(["pointerdown"]),
|
||||
keyboard: run(["keydown"]),
|
||||
none: run([]),
|
||||
input: run(["pointerdown"], "INPUT"),
|
||||
moved: run(["pointerdown"], "SELECT", true),
|
||||
mouseThenKey: run(["pointerdown", "keydown"]),
|
||||
keyThenMouse: run(["keydown", "pointerdown"]),
|
||||
listeners: setupOnce.listeners.map((l) => [l.type, l.capture]),
|
||||
wiring: {
|
||||
main: /releaseSelectAfterPick\(window\)/.test(src("A00_Common/main.ts")),
|
||||
cad: /releaseSelectAfterPick\(frame\.contentWindow\)/.test(
|
||||
src("ui_template/cad_host/cad_host.ts"),
|
||||
),
|
||||
popout: /releaseSelectAfterPick\(next\)/.test(
|
||||
src("A00_Common/design/design_profile_popout.ts"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -134,7 +134,7 @@ const { withToolLead } = load(`${leadSrc}\nexports.withToolLead = withToolLead;`
|
||||
|
||||
// ---- 진짜 attachProfilePopout ----
|
||||
const { attachProfilePopout } = load(read("A00_Common", "design", "design_profile_popout.ts"), {
|
||||
require: () => ({ showToast() {} }),
|
||||
require: () => ({ showToast() {}, releaseSelectAfterPick() {} }),
|
||||
document,
|
||||
window,
|
||||
getComputedStyle: () => ({ backgroundColor: "" }),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""280-6 드롭다운 — 마우스로 고르면 한 박자 뒤 초점 뺌 · 키보드로 고르면 그대로 · 부르는 자리 셋.
|
||||
|
||||
Node 헬퍼(`helper_280_6_select_release.cjs`)가 `ui_template/ui_template_native_menu.ts` 를 트랜스파일해
|
||||
가짜 창 · 가짜 select · 가짜 타이머로 돌리고, 여기서 값을 판정함.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_280_6_select_release.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_마우스로_고르면_한_박자_뒤_초점_뺌(r):
|
||||
assert r["mouse"] == {"before": 0, "blurs": 1}
|
||||
assert r["keyThenMouse"]["blurs"] == 1
|
||||
assert r["none"]["blurs"] == 1 # 입력 없이 온 change(스크립트 · 손가락)도 뺌
|
||||
|
||||
|
||||
def test_키보드로_고르면_초점_남음(r):
|
||||
assert r["keyboard"]["blurs"] == 0
|
||||
assert r["mouseThenKey"]["blurs"] == 0 # 마우스로 펼친 뒤 ↑↓ + Enter
|
||||
|
||||
|
||||
def test_select_밖_이미_옮긴_초점은_안_건드림(r):
|
||||
assert r["input"]["blurs"] == 0
|
||||
assert r["moved"]["blurs"] == 0
|
||||
|
||||
|
||||
def test_창_캡처_듣기와_부르는_자리_셋(r):
|
||||
assert sorted(r["listeners"]) == [["change", True], ["keydown", True], ["pointerdown", True]]
|
||||
assert r["wiring"] == {"main": True, "cad": True, "popout": True}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import "./cad_host.css";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { blockNativeMenu } from "@ui/ui_template_native_menu";
|
||||
import { blockNativeMenu, releaseSelectAfterPick } from "@ui/ui_template_native_menu";
|
||||
|
||||
/** CAD 앱 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
|
||||
const CAD_APP_URL = "/b07-cad/index.html";
|
||||
@@ -148,7 +148,9 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
frame.title = options.title;
|
||||
// CAD 안(리본 · 패널)도 브라우저 기본 메뉴를 막음 — 부모 문서의 듣기는 iframe 에 안 닿음(계획서 39-9a)
|
||||
frame.addEventListener("load", () => {
|
||||
if (frame.contentWindow) blockNativeMenu(frame.contentWindow, () => true);
|
||||
if (!frame.contentWindow) return;
|
||||
blockNativeMenu(frame.contentWindow, () => true);
|
||||
releaseSelectAfterPick(frame.contentWindow); // 리본 드롭다운도 고른 뒤 초점 뺌(요구 280-6)
|
||||
});
|
||||
// 포인터가 CAD 위에 오면 키(Ctrl+Z · Ctrl+Y …)가 CAD 로 가게 초점을 넘김 — 부모 입력 칸에서 치는 중이면 안 뺏음
|
||||
frame.addEventListener("mouseenter", () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* =============================================================================
|
||||
* ui_template_native_menu.ts
|
||||
* 브라우저 기본 오른쪽 클릭 메뉴 막기 — 공용 진입(`A00_Common/main.ts`) · CAD iframe(`cad_host.ts`)이 부름.
|
||||
* 브라우저 기본 동작 손봄 — 공용 진입(`A00_Common/main.ts`) · CAD iframe(`cad_host.ts`) · 종단 새 창(`design_profile_popout.ts`)이 부름.
|
||||
*
|
||||
* 창 캡처 단계에서 `preventDefault` 만 함 — 이벤트는 멈추지 않으므로 우리 메뉴 듣기는 그대로 돎.
|
||||
* 오른쪽 클릭 메뉴 막기 — 창 캡처 단계에서 `preventDefault` 만 함 — 이벤트는 멈추지 않으므로 우리 메뉴 듣기는 그대로 돎.
|
||||
* 글 입력 칸도 막음 — 붙여넣기 · 복사는 Ctrl+V · Ctrl+C · 그 칸에 우리 메뉴가 붙어 있으면 그 메뉴가 뜸.
|
||||
* 드롭다운 초점 빼기 — 마우스로 고른 뒤 초점이 남으면 휠 · ↑↓ 가 값을 또 바꿈(요구 280-6).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 막을 주소 — B · M 영역(`b01-…` · `m02-…`) · A 영역은 그대로 */
|
||||
@@ -19,3 +20,23 @@ export function blockNativeMenu(win: Window, isOn: () => boolean): void {
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/** `win` 안 드롭다운을 마우스로 고르면 한 박자 뒤 초점을 뺌 — 키보드로 고른 때(↑↓ 훑기)는 그대로 둠.
|
||||
* 한 박자 뒤 · 아직 그 select 에 초점일 때만 — 페이지 처리(다시 그림 · 다른 칸 초점)가 먼저 끝남.
|
||||
* iframe 은 다른 realm — `instanceof` 대신 `tagName` 으로 가림. */
|
||||
export function releaseSelectAfterPick(win: Window): void {
|
||||
let byKey = false;
|
||||
win.addEventListener("pointerdown", () => (byKey = false), true);
|
||||
win.addEventListener("keydown", () => (byKey = true), true);
|
||||
win.addEventListener(
|
||||
"change",
|
||||
(event) => {
|
||||
const select = event.target as HTMLSelectElement | null;
|
||||
if (byKey || select?.tagName !== "SELECT") return;
|
||||
win.setTimeout(() => {
|
||||
if (select.ownerDocument.activeElement === select) select.blur();
|
||||
}, 0);
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user