feat(부품): 634-1 여럿 고르기 메뉴 · 한 행 두 영역 · 막힘 글 · 새 아이콘(요구 546)
- createPickMenu multi — menuitemcheckbox · 눌러도 안 닫힘 · values() · 줄 icon · disabled · title · setItem · 단추 ▾ + 켠 수 배지(0 = 없음) · 하나라도 켜면 눌림 · 밀기 · 창 크기 바뀌면 닫힘 · offLabel 없으면 끔 줄 · 눌림 없음 - createMapToolRow — 그룹1 왼쪽(행 반까지) · 그룹2 오른쪽 · 영역마다 min-width 0 + 자기 가로 밀기 · [?] 판은 행 아래 - mapToolTip — 평소 제목만 · 막힘 때만 「제목 — 까닭」 · MAP_TOOLS 글 = 표 호버제목 - 아이콘 — 묶음 넷(streams · signpost · parcelHouse · ban) · 겹친 셋 대신(contours · cutFill · lineament) · 스냅 간격 spacing - 배지 count · accent 덧 · 부품 모음 견본 「고르기 메뉴(하나 · 여럿)」 · 지도 도구줄 한 행 견본 - 시험 568_1 고침 — 예정노선 둘 줄이 한 줄 꼴이 되어 목록에 잡힘 · 새 지킴 634_1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "./ui_template_button";
|
||||
import type { IconName } from "./ui_template_icons";
|
||||
import { createBadge } from "./ui_template_badge";
|
||||
import { icon, type IconName } from "./ui_template_icons";
|
||||
import "./ui_template_context_menu.css";
|
||||
import { showConfirmDialog } from "./ui_template_modal";
|
||||
|
||||
@@ -299,58 +300,125 @@ export function createRowMenu(items: RowMenuItem[]): HTMLElement {
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 부품 「아이콘 고르기 메뉴」(PLAN 626-1 · 요구 530 · 지도 도구줄 단추 하나 + 아래 판)
|
||||
* 꼴 = 아이콘 단추(32 · 고른 것 있으면 눌림) → 아래 판 = 항목 하나만 고름(✓) + 맨 끝 「끔」
|
||||
* 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래
|
||||
* 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 disabled)
|
||||
* 부품 「고르기 메뉴」(PLAN 626-1 · 634-1 · 요구 530 · 546 · 지도 도구줄 단추 하나 + 아래 판)
|
||||
* 꼴 = 하나(기본) — 아이콘 단추(32) → 아래 판 = 항목 하나만 고름(✓) · 누르면 닫힘 · `offLabel` 이면 맨 끝 「끔」 줄 + 고르면 눌림
|
||||
* 여럿(`multi`) — 아이콘 + 켠 수 배지(0 = 없음) + ▾ · 판 = 켬끔 줄(✓) · 눌러도 안 닫힘 · 하나라도 켜면 눌림
|
||||
* 줄 = `icon` · `title`(마우스 글) · `disabled`(흐림) — 바꿈은 `setItem(value, { checked, disabled, title })`
|
||||
* 자리 = 판은 화면 맨 위(body · fixed) — 도구줄 가로 밀기 칸에 잘리지 않게 · 단추 바로 아래 · 밀기 · 창 크기 바뀌면 닫힘
|
||||
* 상태 = 고름 `aria-checked` · 단추 `aria-pressed` · 막힘(단추 · 줄 disabled)
|
||||
* 쓰는 법 = `createPickMenu({ icon: "compass", title, items: [{ value, label }], offLabel: "끔", onPick })`
|
||||
* → { root, set(value | null), value() } · `onPick` 은 누를 때만(set 은 안 부름)
|
||||
* → { root, set(value | null), value(), values(), setItem() } · `onPick` 은 누를 때만(set · setItem 은 안 부름)
|
||||
* 여럿 = `createPickMenu({ multi: true, icon, title, items: [{ value, label, icon }], onPick: (value) => 켬끔 })`
|
||||
* 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu)
|
||||
* ========================================================================== */
|
||||
|
||||
export interface PickMenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
icon?: IconName;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PickMenu {
|
||||
root: HTMLButtonElement;
|
||||
/** 하나 — 고름 바꿈 · 여럿 — 그 하나만 켬(null = 모두 끔). */
|
||||
set: (value: string | null) => void;
|
||||
value: () => string | null;
|
||||
/** 켠(고른) 줄 값들. */
|
||||
values: () => string[];
|
||||
/** 줄 하나 고침 — 켬 · 막힘 · 마우스 글. */
|
||||
setItem: (
|
||||
value: string,
|
||||
patch: { checked?: boolean; disabled?: boolean; title?: string },
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function createPickMenu(opts: {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
items: Array<{ value: string; label: string; title?: string }>;
|
||||
offLabel: string;
|
||||
items: PickMenuItem[];
|
||||
offLabel?: string;
|
||||
multi?: boolean;
|
||||
onPick: (value: string | null) => void;
|
||||
}): PickMenu {
|
||||
let current: string | null = null;
|
||||
const root = createIconButton({ icon: opts.icon, title: opts.title, size: 32, pressed: false });
|
||||
const multi = !!opts.multi;
|
||||
const root = createIconButton({
|
||||
icon: opts.icon,
|
||||
title: opts.title,
|
||||
size: 32,
|
||||
pressed: multi || opts.offLabel ? false : undefined,
|
||||
});
|
||||
root.setAttribute("aria-haspopup", "menu");
|
||||
root.setAttribute("aria-expanded", "false");
|
||||
const badge = createBadge("0", "accent", { kind: "count", size: 18 });
|
||||
if (multi) {
|
||||
root.classList.add("ui-pick-menu--multi");
|
||||
badge.hidden = true;
|
||||
const caret = Object.assign(document.createElement("span"), { textContent: "▾" });
|
||||
caret.className = "ui-pick-menu__caret";
|
||||
root.append(badge, caret);
|
||||
}
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "ui-row-menu__pop ui-pick-menu__pop";
|
||||
pop.setAttribute("role", "menu");
|
||||
pop.hidden = true;
|
||||
const rows = [...opts.items, { value: "", label: opts.offLabel, title: undefined }].map(
|
||||
(item) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "ui-row-menu__item";
|
||||
b.textContent = item.label;
|
||||
if (item.title) b.title = item.title;
|
||||
b.dataset.value = item.value;
|
||||
b.setAttribute("role", "menuitemradio");
|
||||
b.addEventListener("click", () => {
|
||||
setOpen(false);
|
||||
set(item.value || null);
|
||||
opts.onPick(current);
|
||||
});
|
||||
pop.append(b);
|
||||
return b;
|
||||
},
|
||||
);
|
||||
const all: PickMenuItem[] = [
|
||||
...opts.items,
|
||||
...(!multi && opts.offLabel ? [{ value: "", label: opts.offLabel }] : []),
|
||||
];
|
||||
const rows = all.map((item) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "ui-row-menu__item";
|
||||
if (item.icon) b.append(icon(item.icon, 18));
|
||||
b.append(Object.assign(document.createElement("span"), { textContent: item.label }));
|
||||
if (item.title) b.title = item.title;
|
||||
b.disabled = !!item.disabled;
|
||||
b.dataset.value = item.value;
|
||||
b.setAttribute("role", multi ? "menuitemcheckbox" : "menuitemradio");
|
||||
b.addEventListener("click", () => {
|
||||
if (multi) {
|
||||
mark(b, b.getAttribute("aria-checked") !== "true");
|
||||
return opts.onPick(item.value);
|
||||
}
|
||||
setOpen(false);
|
||||
set(item.value || null);
|
||||
opts.onPick(current);
|
||||
});
|
||||
pop.append(b);
|
||||
return b;
|
||||
});
|
||||
const rowOf = (value: string): HTMLButtonElement | undefined =>
|
||||
rows.find((row) => row.dataset.value === value);
|
||||
const values = (): string[] =>
|
||||
rows
|
||||
.filter((row) => row.getAttribute("aria-checked") === "true")
|
||||
.map((row) => row.dataset.value!);
|
||||
let current: string | null = null;
|
||||
/** 단추 눌림 · 배지 — 줄 켬에 맞춤. */
|
||||
function paint(): void {
|
||||
if (!multi) {
|
||||
if (opts.offLabel) root.setAttribute("aria-pressed", String(current !== null));
|
||||
return;
|
||||
}
|
||||
const count = values().length;
|
||||
root.setAttribute("aria-pressed", String(count > 0));
|
||||
badge.textContent = String(count);
|
||||
badge.hidden = count === 0;
|
||||
}
|
||||
function mark(row: HTMLButtonElement, checked: boolean): void {
|
||||
row.setAttribute("aria-checked", String(checked));
|
||||
paint();
|
||||
}
|
||||
const entry: MenuEntry = {
|
||||
close: () => setOpen(false),
|
||||
contains: (target) => target instanceof Node && (root.contains(target) || pop.contains(target)),
|
||||
};
|
||||
/** 밀기(도구줄 · 쪽) · 창 크기 = 판이 단추에서 떨어짐 → 닫음(판 안 밀기는 그대로). */
|
||||
const onShift = (event: Event): void => {
|
||||
if (!(event.target instanceof Node && pop.contains(event.target))) setOpen(false);
|
||||
};
|
||||
function setOpen(open: boolean): void {
|
||||
if (open) {
|
||||
document.body.append(pop);
|
||||
@@ -361,6 +429,11 @@ export function createPickMenu(opts: {
|
||||
left: `${at.left}px`,
|
||||
right: "auto",
|
||||
});
|
||||
window.addEventListener("resize", onShift);
|
||||
document.addEventListener("scroll", onShift, true);
|
||||
} else {
|
||||
window.removeEventListener("resize", onShift);
|
||||
document.removeEventListener("scroll", onShift, true);
|
||||
}
|
||||
pop.hidden = !open;
|
||||
root.setAttribute("aria-expanded", String(open));
|
||||
@@ -370,14 +443,26 @@ export function createPickMenu(opts: {
|
||||
}
|
||||
function set(value: string | null): void {
|
||||
current = value;
|
||||
root.setAttribute("aria-pressed", String(value !== null));
|
||||
for (const row of rows)
|
||||
row.setAttribute("aria-checked", String((row.dataset.value || null) === value));
|
||||
paint();
|
||||
}
|
||||
set(null);
|
||||
root.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(pop.hidden === true);
|
||||
});
|
||||
return { root, set, value: () => current };
|
||||
return {
|
||||
root,
|
||||
set,
|
||||
value: () => current,
|
||||
values,
|
||||
setItem(value, patch) {
|
||||
const row = rowOf(value);
|
||||
if (!row) return;
|
||||
if (patch.disabled !== undefined) row.disabled = patch.disabled;
|
||||
if (patch.title !== undefined) row.title = patch.title;
|
||||
if (patch.checked !== undefined) mark(row, patch.checked);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user