Files
Aislo/ui_template/ui_template_button.ts
eomsangdonandClaude Opus 5.5 eb64bf64ac feat(804-8): M02 단가산출 수식 탭 = M01 과 같은 단추 · 저장 한 번 · 머리 단추 줄바꿈 막음(요구 689)
- 공용 줄 쌓기 화면: 페이지가 저장을 맡는 꼴(`page`)을 M02 단가산출 화면에도 — 탭 위 [고침 버림] · [수식 저장] 걷음 · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지는 그대로 · 「줄 쌓기에서 못 고치는 칸」 안내 줄 걷음(금액 줄 기호 = 율 줄이 앞 줄 금액을 부르는 속 이름 · 화면에서 고칠 값 아님 · 보존 그대로)
- M02 머리 단추 = [초기화] · [삭제] · [되돌림] · [저장] — 단가산출 상세는 [새로고침] 숨김 · [되돌림] · [저장] = 고침 있을 때만(기본정보 + 수식) · [저장] 한 번 = 수식 먼저 저장(새 판 · 수식 칸 받음) → 단가산출 문서 저장 · [되돌림] · 버리고 떠남 = 수식 캐시도 비움
- 보기 고르기(수식 · 단가산출 상세) = M01 처럼 머리 줄 · [항목 추가] · [율 줄 추가] 걷음(줄 더하기 = 수식 탭 [+] 단가산출 · 일위대가 복사) · 일위대가 줄 모달 안 상세는 그대로
- 머리 단추 줄 맞춤(부품 상자 `fitButtonRow`): 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 아이콘만(글 = 풍선 도움말) · M01 단추 다섯 · M02 단추 넷 아이콘 · 단가산출 상세 긴 이름은 제목 안에서 줄바꿈
- 사본 할일 막힘 알림 글 = [저장] 또는 [되돌림] 먼저
- 시험: test_804_7 에 M02 단추 · 저장 한 번 · 줄 맞춤 지킴 더함 · test_804_4 권한 시험에 수식 저장 판 = 페이지 저장 판 이어 씀 확인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:56:31 +09:00

134 lines
6.2 KiB
TypeScript

/* =============================================================================
* ui_template_button.ts
* 부품 「단추」 · 「아이콘 단추」 · 크기 여덟 단(552-2 · 부품 모음 `#/b01-account?view=ui-kit`)
*
* 꼴 = 단추: 주(filled) · 보조(ghost) · 고스트 글(pill) · 위험(danger) · 위험 고스트(danger-ghost) · 유리(glass · 그림 위) · 글 링크(link)
* 아이콘 단추: 고스트(ghost · 기본) · 선(outline) · 채움(filled) · 유리(glass · 그림 위) · 켜짐 = aria-pressed
* 크기 = `data-size="18|20|22|24|28|32|36|42"`(높이 px) — 단추 · 아이콘 단추 · 입력 · 고르기 · 분할 · 탭 같은 값
* 옛 `size: "sm"` = 18 · 크기를 안 주면 data-size 없이 옛 꼴(높이 32 · 아이콘 붙으면 34) 그대로
* 실측 높이 = 단(꼴 · 페이지 글 크기 · 왼쪽 패널 양식과 상관없이) · `hidden` 이면 늘 숨음
* 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` — CSS 는 `[data-state]` 로도 받아 부품 모음이 굳혀 보임
* 쓰기 = `createButton({ label: "저장", size: 24, onClick })` · `createIconButton({ icon: "settings", title: "설정", size: 24 })`
* 글자 기호 단추(▲ ▼ ✕ ?)는 `glyph: "▲"` — 시험이 글자로 잡음
* 대신 = 페이지마다 만든 16 ~ 26 짜리 작은 단추 · `b01-shell__icon-btn` · `b01-pg-menu__btn` · `b05-profile__zoom-btn` …(목록 4장)
* 클래스 = `ui-btn ui-btn--<꼴>`(+ 옛 `ui-btn--sm`) · `ui-btn__icon` · `ui-btn__label` · `ui-icon-btn ui-icon-btn--<꼴>` — 시험 · 가이드 촬영이 잡음
* ========================================================================== */
import { el } from "./ui_template_elements_base";
import { icon, type IconName } from "./ui_template_icons";
import "./ui_template_button.css";
/** 크기 여덟 단(높이 px · 요구 377 · 378) */
export const UI_SIZES = [18, 20, 22, 24, 28, 32, 36, 42] as const;
export type UiSize = (typeof UI_SIZES)[number];
/** 단마다 아이콘 크기(`ui_template_button.css` 의 `--ui-size-icon` 과 같은 값) */
const ICON_PX: Record<UiSize, number> = {
18: 12,
20: 14,
22: 14,
24: 14,
28: 16,
32: 16,
36: 18,
42: 20,
};
/** 요소에 크기 단을 붙임 */
export function setUiSize<T extends HTMLElement>(node: T, size: UiSize): T {
node.dataset.size = String(size);
return node;
}
export type ButtonVariant =
"filled" | "ghost" | "pill" | "danger" | "danger-ghost" | "glass" | "link";
/** 크기 — 단(18 ~ 42) · 옛 "sm" = 18 · 옛 "md" = 단 없이 옛 꼴 */
export type ButtonSize = "md" | "sm" | UiSize;
export interface ButtonOptions {
/** 표시 텍스트 (i18n 결과 문자열을 전달) */
label: string;
variant?: ButtonVariant;
size?: ButtonSize;
/** 갈래 색(보조 꼴에만) — "option" 옵션값 · "link" 요소 연결 · `data-tone` */
tone?: "option" | "link";
/** 이벤트 핸들러 (frontend.md §4: on[페이지]_[기능]_[액션] 명명) */
onClick?: (ev: MouseEvent) => void;
disabled?: boolean;
type?: "button" | "submit" | "reset";
/** 앞쪽 아이콘 요소 (선택) — 이름이면 단 크기 아이콘 */
iconStart?: HTMLElement | IconName;
}
export function createButton(opts: ButtonOptions): HTMLButtonElement {
const variant = opts.variant ?? "filled";
const size: UiSize | null =
opts.size === "sm" ? 18 : opts.size === "md" || opts.size === undefined ? null : opts.size;
const btn = el("button", {
className: `ui-btn ui-btn--${variant}${opts.size === "sm" ? " ui-btn--sm" : ""}`,
attrs: { type: opts.type ?? "button" },
});
if (size) setUiSize(btn, size);
if (opts.tone) btn.dataset.tone = opts.tone;
if (opts.iconStart) {
const mark =
typeof opts.iconStart === "string"
? icon(opts.iconStart, size ? ICON_PX[size] : 16)
: opts.iconStart;
mark.classList.add("ui-btn__icon");
btn.append(mark);
}
btn.append(el("span", { className: "ui-btn__label", text: opts.label }));
if (opts.disabled) btn.disabled = true;
if (opts.onClick) btn.addEventListener("click", opts.onClick);
return btn;
}
export type IconButtonVariant = "ghost" | "outline" | "filled" | "glass";
export interface IconButtonOptions {
/** 아이콘 이름 — 없으면 glyph */
icon?: IconName;
/** 글자 기호(▲ ▼ ✕ ? …) — 시험이 글자로 잡는 단추 */
glyph?: string;
/** 마우스 글 · 보조기기 이름(필수) */
title: string;
variant?: IconButtonVariant;
/** 기본 32 */
size?: UiSize;
/** 켜고 끄는 단추면 지금 값(aria-pressed) */
pressed?: boolean;
disabled?: boolean;
onClick?: (ev: MouseEvent) => void;
}
export function createIconButton(opts: IconButtonOptions): HTMLButtonElement {
const size = opts.size ?? 32;
const btn = el("button", {
className: `ui-icon-btn ui-icon-btn--${opts.variant ?? "ghost"}`,
attrs: { type: "button", title: opts.title, "aria-label": opts.title },
});
setUiSize(btn, size);
btn.append(opts.icon ? icon(opts.icon, ICON_PX[size]) : (opts.glyph ?? ""));
if (opts.pressed !== undefined) btn.setAttribute("aria-pressed", String(opts.pressed));
if (opts.disabled) btn.disabled = true;
if (opts.onClick) btn.addEventListener("click", opts.onClick);
return btn;
}
/** 단추 줄 맞춤(804-8) — 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 글을 숨김(글은 `title`) · 넓어지면 다시 글.
* `row` = 한 줄 flex(감김 허용) · 아이콘 없는 단추 · 다른 요소는 그대로 */
export function fitButtonRow(row: HTMLElement): void {
const fit = (): void => {
row.classList.remove("ui-btn-row--tight");
for (const b of row.querySelectorAll<HTMLElement>(".ui-btn:has(> .ui-btn__icon)"))
b.title ||= b.textContent?.trim() ?? "";
// 단추 높이보다 높음 = 단추가 떨어졌거나 제목이 여러 줄 — 글을 숨겨 자리를 냄
const tall = Math.max(0, ...[...row.querySelectorAll("button")].map((x) => x.offsetHeight));
if (row.clientHeight > tall + 1) row.classList.add("ui-btn-row--tight");
};
const watch = new ResizeObserver(fit);
watch.observe(row);
for (const x of row.children) watch.observe(x); // 단추 숨김 · 보임도 다시 잼
}