모달 (Modal)
모달 3종의 규격과 공통 동작을 확인하고, 어느 것을 쓸지 고르는 기준을 찾아보세요.
패턴 · Web · 1차 표준(v1) · 문서 포맷 시범 적용
실제 design-system.css가 그대로 적용된 데모입니다. 3종 모두 아래 탭에서 만져 볼 수 있습니다 — 각 탭 맨 위가 그 종류의 라이브 예시입니다.
규칙 (강제) · 코딩 에이전트용
⚠️ 이 문서는 v1(진행 중, 확정 아님) — 딤 40%·간격·타이포 수치·닫기 동작은 조정될 수 있으니 수치를 하드코딩하지 말고 이 문서를 정본으로 참조. 급여(hr-system) 포함 전 앱 적용 대상(2026-07-29 제외 해제) — 단 레거시는 위반이 아니라 마이그레이션 대상이니, 이미 작업 중인 파일 안에서 교체하세요.
- 🚫 네이티브 시스템 다이얼로그 금지 (예외 없음):
alert()/confirm()/prompt()를 새로 쓰지 마세요. 2026-07-29에 전 앱 244곳을 걷어냈고 현재 제품에 0곳입니다. 대신askConfirm/showAlert/showError/showPrompt(@hereby/components), HTML은$store.dialog.ask(...)→ 확인·알림·입력 다이얼로그.window.alert·(window as any).confirm·window["prompt"]같은 우회 표기도 전부 금지 — 캐스트형이 실제로 9곳 새어 나간 전례가 있습니다. 이중으로 막혀 있습니다: Biomelint/suspicious/noAlert(error) +pnpm check:dialogs(pre-commit blocking, 캐스트형·.html담당). - 🚫 신규 기능의 모달은 무조건 이 3종을 따른다(→ 새 모달을 만들 때). 백드롭 직접 조합(
fixed inset-0 bg-…)·bg-black/*·카드에 직접 준 패딩·새.ds-mini-modal·제목에 얹은 색은 커밋이 막힌다(pnpm check:modals, pre-commit blocking, 추가된 줄만 검사). 정당한 예외는ds-modal-exempt: <사유>주석으로만 통과. - 모달은 3종뿐(→ 모달은 3종입니다): 컨펌 다이얼로그(
.ds-dialog) · 일반 모달(.ds-modal) · 패널 모달(.ds-modal--panel). 네 번째 종류를 만들지 말 것. 셋은 백드롭·닫기·스크롤잠금·카드 내부 구조가 전부 같고 바깥 여백과 크기만 다르다. - 컨펌 다이얼로그 여백은 상하좌우 28 일괄(일반 모달 44/40/36과 다름). 마크업에
padding을 적지 말고 카드에.ds-dialog만 얹을 것 — 수치는design-system.css의.ds-dialog .ds-modal__*가 갖는다. 안쪽 간격(타이틀↔본문 24 · 본문↔푸터 32)과 타이포는 일반 모달과 동일. - 컨펌 다이얼로그 타이틀은 on/off, 기본값은 「확인」(종류 불문 — alert/confirm/prompt 모두). 끄려면
title: ""또는title: false(헤더가 통째로 빠지고.ds-dialog--no-title이 본문 위 여백을 대신 진다). 종류별로 다른 기본 제목(「안내」/「오류」/「입력」)을 되살리지 말 것 — 실패·파괴 신호는 제목이 아니라 푸터 버튼이 진다(tone: "danger"는 확인 버튼 색만 바꾼다). - 핵심 3원칙: dim 항상 보임 · 바깥 클릭으로 안 닫힘 · 뒤 화면 스크롤 잠금.
- 백드롭: 반드시
.ds-modal-backdrop하나로(fixed inset-0 + dim 40% + flex 중앙 + z토큰 + 스크롤 잠금). ❌fixed inset-0 bg-...직접 조합, ❌bg-black/40(투명 버그), ❌bg-[var(--ds-dim)](45%) 금지. 모달 위 모달--top(z70), 전역 팝업--system(z75), 임베드(ga-embedded/tm-embedded)는padding-bottom:10vh. - 이름 함정:
.ds-modal(접미사 없음)은 카드, 백드롭은.ds-modal-backdrop. 카드 셸.ds-modal bg-white w-full mx-4 max-h-[90vh] flex flex-col relative+ 인라인max-width. - 화이트 딤
--white는 처리 중(로딩) 전용(→ 화이트 딤): 딤 색만--ds-glass-70으로 바뀌고 나머지는 기본 백드롭과 같다. 사용자 확인을 받는 다이얼로그에는 쓰지 않는다(그건 어두운 딤) — 흰 딤 위에 선택 버튼을 얹지 말 것. 스피너 + 한 줄 안내까지가 이 딤의 내용물이다. - 카드 폭은 5단 스케일에서만 고른다 —
320 / 580 / 780 / 980 / 1180px. 중간값(460·640·900 …)을 새로 만들지 마세요. 내용이 넘치면 다음 단으로 올리고, 그래도 안 되면 패널 모달입니다. 미니 팝업(.ds-mini-modal)은 320 고정(w-80). 상세: 카드 폭 스케일. - 미니 팝업(
.ds-mini-modal) 내부도 큰 모달과 같은 규격: 제목은ds-modal__title(20/600, 14px 자체 값 금지) · 제목↔본문 24px(제목+본문을flex flex-col gap-6로 묶어라 — 카드의gap:32px는 그 묶음과 버튼 줄 사이에만 걸린다) · 제목 바로 아래가 설명 문장이면ds-modal__desc(14/400, 8px) · 폭 320(w-80). X(ds-modal__close)는 입력 폼형에만 달고(사본 만들기·이름 바꾸기), 확인/알림형은 취소·확인 버튼으로 충분해 달지 않는다. 카드에position:relative가 이미 있어 X는 클래스만 얹으면 제자리에 붙는다. - 닫기: 변경 없는 폼·뷰어·확인은 X/취소/Esc → 바로 닫힘, 변경 있는 폼은 저장확인 경유(
attemptClose). 새 폼 모달은closeGuardForms에 등록 필수(미등록=항상 확인, 안전 기본). - 스크롤 잠금: 백드롭 내장(
overscroll-behavior:none+ 오버플로 0). ❌contain+강제 1px 오버플로로 "고치기" 금지(회귀). - 타이포·간격(일반 모달 기준 — 컨펌 다이얼로그는 위 항목의 28): 카드 마진 44/40/36 · 타이틀 20/600(
ds-modal__title) · 디스크립션 14/400 · 그룹 타이틀 16(text-b3)/500 · 필드 타이틀 13(text-b5)/500 · 타이틀↔인풋 6px(mb-1.5) · 그룹 세로 16px(space-y-4) · 좌우 인풋 12px(gap-3). 모달 안 탭 바는tabnav-flush-left. - 텍스트 색: raw
neutral-*대신label-*토큰 — 타이틀·그룹 타이틀label-normal, 디스크립션·인풋 타이틀label-assistive, 보조label-muted. - 상태 선택: pill·스위치 대신 불리언
select(ds-select ds-select--32 w-full+<option value="true/false">).ds-switch는 기능 on/off에만. - 푸터 버튼(weight 3티어): 위치 아닌 중요도로 — 기본 컨펌
ds-btn--dark> 크리티컬(삭제)ds-btn--lightor(남발 금지) > 물러남(취소·닫기)ds-btn--white-bk. 위험 반전: 저장확인 계속편집=dark/닫기=white-bk, 삭제확인 삭제=lightor·기본 포커스는 취소. 예외: 워크플로 승인=green·반려=red 의미색 유지. - z 토큰: dim
--ds-dim-40, 모달--ds-z-modal(50), 모달위모달 60,--top70,--system75(전역 팝업은 항상 system). - 피커(모달 안): 다이얼로그형(시간)=
--top으로 위에(+@keydown.escape.stop으로 모달 escape 차단), 팝오버형(월.ds-menu)=바디 앵커 절대배치 비잘림. Esc/바깥클릭은 피커만 닫음. - 패널 모달
.ds-modal--panel:width:min(100vw-200px,1280px)·min-width:768px·height:100vh-120px·min-height:300px(변형-loose/-narrow/-tall). 좌측 메뉴 220pxbg-neutral-50. 중첩 자식 ESC 가드(typeManagerChildOpen·stopImmediatePropagation·500ms 유예). - 모바일(<768) 폼 모달 — 기본값 권장, 강제 아님: 풀페이지(
.ds-modal--mobile-full) + 폼 컨트롤ds-input--48/ds-select--48+ CTAds-btn--lg풀폭이 출발점입니다(사이즈는 픽셀이 아니라 사이즈 클래스로 지정 — 인풋은 숫자 표기, 버튼은 영문 표기). 화면이 요구하면 스케일 안에서 다른 단계를 골라도 됩니다(→ 모바일). 사이즈를 바꿀 때 DS 클래스엔md:가 안 붙으므로 앱main.css의@media (max-width:767px)에서 덮으세요(유틸리티 컨트롤은 모바일 퍼스트h-12 md:h-10). ⚠️ 입력칸 탭 시 iOS 자동 확대는 아직 규칙 없음(현재 14px, 확대됨) — 새로 정하지 말고 그 섹션을 읽으세요. - 상세(읽기전용) 모달: 본문·사용자 입력은
x-text만, 리치 텍스트 아니면x-html금지(XSS). ESC는 팝업 열린 동안만 등록/해제. - 예외(표준 적용 금지): 컨텍스트 메뉴·드롭다운·피커·자동완성(바깥클릭 닫힘), 이미지 라이트박스(90% dim). (급여/hr-system 예외는 2026-07-29 해제됨.)
패널 폼 공유 규칙. 각 패널만의 규칙은 그 컴포넌트 문서에 있습니다.
셸
- 카드는
.ds-modal ds-modal--panel— 모달 3종 중 패널 모달입니다. 새 종류를 만들지 마세요. - 좌측 메뉴는 폭 220px 고정
<aside>이고, 항목은<button>+ds-r-8입니다.
⚠️ 단계 게이트를 만들지 마세요 카테고리는 자유 이동입니다. 「다음 →」으로 넘기는 단계형이 아니에요. 미완성을 알려야 하면 빨간 점 방식을 쓰고, 이동 자체를 막지 마세요.
⚠️ 입력한 걸 조용히 날리지 마세요 X · 취소 · ESC 는 전부 attemptClosePanel() 한 곳을 지나갑니다(닫기 가드). 어느 하나를 바로 showX = false 로 연결하지 마세요.
⚠️ ESC 는 위에서부터 하나씩만ESC 층의 순서를 바꾸지 마세요. 맨 앞에서 권한 팝업에 양보하고, 레이어를 하나 닫고, 막 닫은 뒤 500ms 는 패널을 닫지 않습니다.
⚠️ 주소 검색 코드를 또 복사하지 마세요 지금 세 컴포넌트에 같은 로더가 각각 복사돼 있습니다(아래). 네 번째를 만들지 말고 공용 유틸로 빼는 것을 먼저 논의하세요.
- 다크 모드(2026-10-13) — 모달 카드 면은 CSS 가
--color-bg-elevated(라이트 #fff · 다크 neutral-700)로 칠한다. 마크업의bg-white는 무해하지만 다크에서는 카드 위 흰 면으로 남으므로 새 모달에 쓰지 않고, 건드리는 파일에서는 지운다. 레거시.ds-mini-modal도 같은 토큰. 딤 · 흰 딤(--white)은 유리 토큰(--ds-glass-70)이라 다크에서 어두운 딤이 된다. 확인: Playground 「미리보기 테마 → 다크」.
모든 다이얼로그형 모달이 따르는 공통 패턴입니다. 핵심은 세 가지 — ① dim은 항상 보인다 ② 바깥 클릭으로 닫히지 않는다 ③ 모달 뒤 화면은 스크롤되지 않는다.
모달은 3종입니다
이름을 먼저 맞춥니다. 아래 셋 말고 네 번째 종류를 만들지 마세요.
| 종류 | 무엇 | 클래스 | 바깥 여백 |
|---|---|---|---|
| 컨펌 다이얼로그 (confirmation dialog) | 확인 한 번 받거나 한 줄 알리거나 한 줄 입력받는 가장 작은 것. 네이티브 alert/confirm/prompt 자리 | .ds-modal + .ds-dialog | 상하좌우 28 일괄 |
| 일반 모달 (modal) | 폼·상세 보기 등 내용이 있는 기본 모달 | .ds-modal | 상 44 / 좌우 40 / 하 36 |
| 패널 모달 (panel modal) | 좌측 메뉴 + 우측 콘텐츠 2단의 큰 팝업 | .ds-modal + .ds-modal--panel | (뷰포트를 채움 → 패널 모달) |
셋 다 같은 백드롭·같은 닫기 동작·같은 스크롤 잠금(§1~§3)을 쓰고, 카드 안 구조도 __header / __title / __body / __footer 로 같습니다. 다른 건 바깥 여백과 카드 크기뿐입니다 — 타이포·안쪽 간격을 따로 만들지 마세요.
- 컨펌 다이얼로그 → 규격 · 라이브 예시
- 일반 모달 → 내부 타이포·간격 · 라이브 예시 · 상세(읽기전용) 모달 · 모바일 풀페이지 변형 · 풀레이어(모바일 전용 앱)
- 패널 모달 → 규격 · 라이브 예시
새 모달을 만들 때
신규 기능의 모달은 무조건 이 3종을 따릅니다
백드롭을 직접 조합하거나(fixed inset-0 bg-...), 카드에 패딩을 직접 주거나, .ds-mini-modal 을 새로 쓰는 것은 커밋이 막힙니다(pnpm check:modals, pre-commit blocking). 확인/알림/입력은 마크업을 짜지 말고 함수를 부르세요.
1. 먼저 — 직접 만들 필요가 있는지
확인 한 번 받거나, 한 줄 알리거나, 한 줄 입력받는 것이라면 마크업이 필요 없습니다.
import { askConfirm, showAlert, showError, showPrompt } from "@hereby/components";
if (!(await askConfirm("이 행을 삭제할까요?"))) return;이미 모든 앱에 떠 있습니다(컨펌 다이얼로그). 새로 그리지 마세요.
2. 내용이 있는 모달 — 셋 중 하나를 고릅니다
<!-- 일반 모달 — 폼·상세 -->
<div x-show="open" x-cloak class="ds-modal-backdrop">
<div class="ds-modal bg-white w-full mx-4 flex flex-col relative" style="max-width: 480px; max-height: 90vh">
<button type="button" @click="attemptClose()" class="ds-modal__close" title="닫기">…</button>
<div class="ds-modal__header"><h3 class="ds-modal__title">병동 추가</h3></div>
<div class="ds-modal__body space-y-4">…</div>
<div class="ds-modal__footer">
<button class="ds-btn ds-btn--solid ds-btn--white-bk ds-btn--md">취소</button>
<button class="ds-btn ds-btn--solid ds-btn--dark ds-btn--md">저장</button>
</div>
</div>
</div>
<!-- 패널 모달 — 좌측 메뉴 + 우측 콘텐츠 -->
<div class="ds-modal ds-modal--panel bg-white flex relative overflow-hidden">…</div>카드 셸·구조·푸터 버튼은 위 그대로 두고 본문만 채우세요. 여백·타이포는 클래스가 갖습니다.
3. 새 폼 모달이면 저장확인에 등록
변경이 있는 폼은 attemptClose() 를 거쳐야 합니다. closeGuardForms 에 등록하지 않으면 항상 확인창이 뜹니다(안전 기본값) — §2 닫기 참고.
4. 무엇이 막히나
| 걸리는 것 | 대신 |
|---|---|
fixed inset-0 + 배경 직접 조합 | .ds-modal-backdrop |
bg-black/40 · bg-[var(--ds-dim…)] | .ds-modal-backdrop(딤 내장) |
.ds-mini-modal 새로 사용 | .ds-modal ds-dialog |
모달 카드에 p-6 · style="padding…" | 종류 클래스(28 / 44·40·36 / 패널) |
.ds-modal__title 에 색 유틸 | 색 얹지 않음 — 차이는 푸터 버튼 |
alert() · confirm() · prompt() | askConfirm / showAlert / showError / showPrompt |
추가된 줄만 검사합니다 — 레거시(손수 만든 백드롭 25곳 · bg-black/* 24곳 · .ds-mini-modal 17곳)는 막지 않습니다. 목적은 과거 청산이 아니라 새 위반이 더 들어오지 않게 하는 것입니다. 현황은 pnpm check:modals:audit.
정당한 예외(컨텍스트 메뉴 · 드롭다운 · 피커 · 자동완성 · 이미지 라이트박스 · 드로어/임베드 딤)는 같은 줄이나 윗줄에 사유를 적어 통과시킵니다:
<!-- ds-modal-exempt: LNB 드로어 딤 — 모달이 아니라 바깥클릭으로 닫히는 오버레이 -->
<div class="fixed inset-0" style="background: var(--ds-dim-40)"></div>사유 없는 예외는 두지 마세요. 이 문자열이 곧 리뷰 대상입니다.
.ds-mini-modal 은 이 3종에 들어가지 않는 레거시입니다
저장확인(close-confirm)·패널 폼 확인 팝업이 아직 .ds-mini-modal(패딩 24 · gap 32 · 타이틀 14)을 씁니다. 역할은 컨펌 다이얼로그와 같지만 규격이 다릅니다 — .ds-dialog 로 흡수하는 것이 목표이고, 새 코드에서는 쓰지 마세요. 기존 것은 건드리는 파일 안에서만 옮기고 일괄 스윕은 하지 않습니다.
단일 출처
packages/static/styles/design-system.css ← .ds-modal-backdrop / .ds-mini-modal / 토큰| 토큰 | 값 | 용도 |
|---|---|---|
--ds-dim-40 | rgb(0 0 0 / 0.4) | 백드롭 dim (40% 고정) |
--ds-z-modal | 50 | 모달 + 백드롭 |
--ds-z-modal-top | 60 | 모달 위 모달 (인라인 z-index로 사용) |
--ds-z-modal-alert | 70 | 모달 위 확인/알림/뷰어 — .ds-modal-backdrop--top |
--ds-z-modal-system | 75 | 전역(시스템) 팝업: 권한 요청 등 — .ds-modal-backdrop--system |
--ds-z-impersonation-banner | 76 | 권한 미리보기 배너(.ds-impersonation-banner) — 한 줄 띠(높이 --ds-impersonation-banner-h 28px). 대상 직원에게 권한이 없어 권한 요청 팝업(75)이 덮는 순간에도 관리자가 「종료」를 눌러야 하므로 모든 팝업 위. 보이는 동안 body.ds-impersonating 이 화면을 그만큼 내린다(--ds-impersonation-offset) — 앱의 100vh 셸은 이 값을 빼서 잡고, 뷰포트 sticky 헤더는 .ds-impersonation-offset-top 을 단다 |
쌓임 원칙: 팝업은 최신 표시 순서대로 위에 쌓인다. 전역 팝업(권한 요청)은 어떤 모달 스택 위에서든 마지막에 뜨므로 항상 최상단 티어(--system)를 쓴다 — 기본 티어로 두면 뷰어/확인(z-70) 위에서 발생한 403 팝업이 그 아래에 깔려 보이지 않는다.
규칙
1. 표시 — 백드롭은 .ds-modal-backdrop 하나로 끝
<div x-show="showXModal" x-cloak class="ds-modal-backdrop">
<div class="ds-modal bg-white w-full mx-4 max-h-[90vh] flex flex-col relative" style="max-width: 480px;">
<!-- 모달 내용 -->
</div>
</div>.ds-modal-backdrop가 전부 담당합니다: position: fixed; inset: 0 + dim 40% + 가운데 정렬(flex) + z-index 토큰 + 스크롤 잠금. 직접 fixed inset-0 bg-...를 조합하지 마세요.
- 모달 위에 또 모달(저장확인, 미리보기 등)을 띄울 때는
ds-modal-backdrop ds-modal-backdrop--top(z-70). - 전역 팝업(권한 요청 등 — 어떤 모달 위에서든 뜰 수 있는 것)은
ds-modal-backdrop ds-modal-backdrop--system(z-75). - 처리 중(로딩) 화면 잠금은
ds-modal-backdrop ds-modal-backdrop--white— 아래 화이트 딤. - ❌
bg-black/40— opacity 토큰 매핑 이슈로 투명하게 컴파일되는 알려진 버그가 있습니다. - ❌
bg-[var(--ds-dim)](45%) — 40%로 통일되어 폐기. - 임베드 앱(admin 콘텐츠 iframe 안에서 도는 총무·팀매니저)은
body.ga-embedded/body.tm-embedded마커에.ds-modal-backdrop { padding-bottom: 10vh }를 더해 모달 세로 중심을 살짝 올립니다 — iframe 안에선 부모 헤더 때문에 정중앙이 아래로 치우쳐 보이기 때문. 마커는 앱이 iframe 임베드로 부팅될 때main.ts가 body에 붙입니다. - 임베드 채움(
data-embed-fill, 2026-10-21) — admin 이 부모 여백을 알려 주면setupEmbedModalCentering()(@hereby/components)이 모달을 화면 전체의 가운데에 세우고, 그러려고 카드 높이를 「iframe 높이 − 헤더 차이 − 8」로 줄입니다. 읽을 내용이 긴 미리보기처럼 높이가 가운데보다 중요한 카드는data-embed-fill을 달아 iframe 세로를 위아래 24px 씩 남기고 채웁니다(가로만 가운데). iframe 은 화면보다 낮아 늘 화면90vh상한 안입니다. 내용 길이와 상관없이 최대로 두려면 카드에h-[90vh] max-h-[90vh]를 함께 둡니다(standalone 은 90vh, 임베드는 채움 높이로 덮음). 첫 적용: 문서 결재 미리보기(apps/team-manager/src/views/modals/doc-preview.html).
이름 함정
.ds-modal(접미사 없음)은 admin/management-dashboard main.css의 모달 카드 컴포넌트입니다. 백드롭은 반드시 .ds-modal-backdrop. 혼동하면 카드 전체가 깨집니다.
화이트 딤 — 처리 중(로딩) 전용
.ds-modal-backdrop--white는 딤 색만 바꾸는 변형입니다(var(--ds-glass-70)). 나머지(fixed inset-0 · 가운데 정렬 · z · 스크롤 잠금)는 기본 백드롭과 같습니다.
왜 따로 있나. 어두운 딤은 "화면을 덮었으니 저기(모달)에 집중하라"는 신호입니다. 반면 **흰 딤은 "같은 화면이 잠깐 멈춤"**으로 읽혀요 — 화면 전환 없이 짧게 잠그고 스피너와 한 줄 안내만 얹는 자리에 씁니다.
<!-- 확정 카드는 숨기고, 처리 중에는 같은 백드롭 안에서 흰 딤으로 교체 -->
<div x-show="processing" x-cloak
class="ds-modal-backdrop ds-modal-backdrop--white flex-col gap-3" aria-live="polite">
<span class="ds-spinner"></span>
<span class="text-b5 font-medium text-neutral-700">시트를 설정 완료 합니다.</span>
</div>- ✅ 쓰는 자리: 되돌릴 수 없는 전이를 확정한 직후의 짧은 처리(예: 시트 [설정 완료]), 화면 이동 없이 몇 초 잠글 때.
- ❌ 쓰지 않는 자리: 사용자 확인을 받는 다이얼로그(그건 기본 어두운 딤). 흰 딤 위에 버튼을 얹지 마세요 — 멈춤 신호가 선택 화면으로 바뀝니다.
- 스피너는 공용
.ds-spinner를 그대로 씁니다. 인라인style로 링을 흉내 내면--ds-spinner-*변수와prefers-reduced-motion대응을 못 받아요(2026-08-13 이 자리에서 실제로 정리한 사례). 흰 딤 위에서는 받침(.ds-spinner-badge)을 씌우지 않습니다 — 흰 바탕에 흰 원이 겹칩니다. - 선택자는 더블 클래스(
.ds-modal-backdrop.ds-modal-backdrop--white)라 앱main.css의 단일 클래스 복사본보다 항상 셉니다.!important불필요. - 첫 도입: 급여 시트 설정 완료(UP-008). 실제 마크업 —
apps/hr-system/src/views/tabs/sheets/modals/setup-complete.html.
2. 닫기 — X · 취소 · Esc만, 바깥 클릭은 닫지 않음
| 모달 유형 | 닫기 동작 |
|---|---|
| 폼(입력) 모달 — 변경 없음 | X / 취소 / Esc → 바로 닫힘 (pristine-close guard — 잃을 입력이 없으므로) |
| 폼(입력) 모달 — 변경 있음 | X / 취소 / Esc → 저장확인 팝업 경유 (attemptClose) |
| 뷰어·확인 모달 | X / 확인 버튼 / Esc → 직접 닫기 |
공용 저장확인은 호스트 컴포넌트에 구현돼 있습니다 (admin app.ts, team-manager app.ts — 동일 패턴):
closeConfirm: { open: false, target: "" },
closeGuardForms: { showXModal: ["xForm"], ... } // 모달별 폼 상태 키 등록
attemptClose(stateVar) // 변경 없으면 바로 닫기, 있으면 확인 팝업
confirmClose() // "닫기" → 대상 모달 닫기 (함수 이름 target이면 호출)
dismissClose() // "계속편집" → 취소- 새 폼 모달은
closeGuardForms에상태변수: [폼 키들]을 등록해야 pristine 가드가 동작합니다 — 미등록 target은 항상 확인을 띄웁니다(안전 기본값). - 닫을 때 정리가 필요한 모달은 target에 정리 함수 이름을 넘깁니다 (예:
attemptClose('closeDocTemplateForm')). - 폼 상태를 JSON 스냅샷으로 비교할 수 없는 모달(행별 인라인 편집, Map 상태 등)은 가드에 등록하지 않고 항상 확인.
<!-- 각 앱 셸에 1회 include -->
<!-- include "src/views/modals/close-confirm.html" -->3. 스크롤 잠금 — 백드롭에 내장, 별도 작업 불필요
dim 위에서 휠/터치를 굴려도, 모달 카드 끝에서 스크롤이 넘쳐도 뒤 화면은 절대 움직이지 않습니다. 모달 내부의 overflow-y-auto 영역은 정상 스크롤됩니다.
되돌리기 금지
구현은 overscroll-behavior: none + 오버플로 0입니다. contain + 강제 1px 오버플로 조합으로 "고치지" 마세요 — 카드가 1px 밀리고 macOS 트랙패드 러버밴드로 dim 전체가 흔들립니다(측정으로 검증된 회귀).
4. 카드 폭 스케일
카드 폭은 5단이고, 인라인 max-width로 지정합니다. 그 사이 값을 새로 만들지 마세요 — 폭이 제각각이면 팝업이 연속으로 뜰 때 카드가 미세하게 들썩여 보입니다.
| 폭 | 쓰는 곳 |
|---|---|
320px (.ds-mini-modal = w-80) | 미니 팝업 — 확인/알림/한 줄 입력. 버튼 2개까지 |
| 580px | 기본값. 폼 모달 대부분(설정·이름 변경·필터·확인+결과 요약) |
| 780px | 2열 그리드, 갤러리, 탭이 있는 폼 |
| 980px | 표가 들어가는 모달 |
| 1180px | 표 + 좌측 목록 등 2단 구성 |
- 어느 단을 고를지 애매하면 내용을 기준으로 한 단 넓게가 아니라, 한 단 좁게 두고 내용을 줄이는 쪽을 먼저 검토하세요. 모달은 한 가지 결정을 받는 자리입니다.
- 1180px으로도 부족하면 폭을 더 늘리지 말고 패널 모달(
.ds-modal--panel)로 바꿉니다. - 세로는 폭과 무관하게
max-h-[90vh]+ 본문만 스크롤(ds-modal__body flex-1 min-h-0 overflow-y-auto).
5. 내부 타이포 · 간격
| 항목 | 표준 | 클래스 |
|---|---|---|
| 카드 마진(상/좌우/하) | 44 / 40 / 36px — 일반 모달 기준 | ds-modal__header(44·40·24) + __body(0·40) + __footer(32·40·36) |
| 모달 타이틀 | 20px / 600 | ds-modal__title |
| 모달 디스크립션(타이틀 아래) | 14px / 400 | ds-modal__desc |
| 그룹(섹션) 타이틀 | 16px (b3) / 500 | text-b3 헤딩 |
| 필드(인풋) 타이틀 | 13px (b5) / 500 | text-b5 font-medium |
| 타이틀 ↔ 인풋 간격 | 6px | mb-1.5 |
| 컨펌 다이얼로그 본문 ↔ 입력칸(prompt) | 6px — 위와 같은 값 | mt-1.5를 래퍼 div에 (아래) |
| 콘텐츠 그룹 사이 세로 간격 | 16px | space-y-4 (모달 바디) |
| 좌우로 놓인 인풋 사이 간격 | 12px | gap-3 (grid/flex) |
컨펌 다이얼로그는 바깥 여백만 다릅니다
아래 표는 일반 모달 기준입니다. 컨펌 다이얼로그는 상하좌우 28 일괄로 좁습니다 — 문장 한두 줄 + 버튼뿐이라 40은 카드가 비어 보입니다.
| 일반 모달 | 컨펌 다이얼로그 | |
|---|---|---|
| 카드 위 | 44 | 28 |
| 카드 좌우 | 40 | 28 |
| 카드 아래 | 36 | 28 |
| 타이틀 ↔ 본문 | 24 | 24 (동일) |
| 본문 ↔ 푸터 | 32 | 32 (동일) |
| 타이틀 | 20 / 600 | 20 / 600 (동일) |
여백 차이는 마크업이 아니라 .ds-dialog 가 CSS로 만듭니다(design-system.css). 다이얼로그 마크업에 padding 을 직접 적지 마세요.
- 모달 안 탭 바는 첫 탭을 헤더 좌측 패딩(40px)에 맞추려면
tabnav-flush-left(탭 nav의 좌측 패딩 0)를 함께 얹습니다 — 예: 병동 설정 모달.
텍스트 색 위계
모달 안 텍스트는 라벨 토큰으로 위계를 만듭니다. raw neutral-* 대신 시맨틱 label-* 토큰을 씁니다(테마 대응).
| 요소 | 색 토큰 | 값 |
|---|---|---|
| 모달 타이틀 · 그룹 타이틀 | label-normal | neutral-900 |
| 모달 디스크립션 · 인풋 타이틀 | label-assistive | neutral-500 |
| 기타 보조 설명(예시·힌트·캡션) | label-muted | neutral-400 |
ds-modal__title/ds-modal__desc는 CSS가 색을 들고 있어 클래스만 붙이면 됩니다.- 인풋 타이틀은
text-label-assistive를 직접 붙입니다(구text-neutral-600에서 통일). - 보조 설명은
text-label-muted. (label-alternative=neutral-600은 더는 쓰지 않음 — assistive로 통일)
<div class="ds-modal__body flex-1 min-h-0 overflow-y-auto space-y-4">
<div>
<label class="block text-b5 font-medium text-label-assistive mb-1.5">병동명 <span class="text-red-400">*</span></label>
<input type="text" x-model="form.name" class="w-full ds-input--40" />
<p class="text-label-muted" style="font-size: var(--text-b6)">예: 2층 병동</p>
</div>
<!-- 좌우로 놓이는 인풋은 gap-3(12px) -->
<div class="grid grid-cols-2 gap-3"><!-- 인풋 A / 인풋 B --></div>
</div>6. 상태(활성/비활성) 선택 — 드롭다운으로 통일
pill 토글·스위치 대신 불리언 select 패턴을 사용합니다:
<select :value="form.isActive ? 'true' : 'false'"
@change="form.isActive = $event.target.value === 'true'"
class="ds-select ds-select--32 w-full">
<option value="true">활성</option>
<option value="false">비활성</option>
</select>스위치(ds-switch)는 상태 선택이 아닌 다른 용도(기능 on/off 토글 행)에만 남깁니다.
7. 푸터 버튼 — 강조는 위치가 아니라 맥락·중요도로
가장 우측이라고 항상 진한 채움 버튼을 쓰지 않습니다. 푸터의 확정(affirmative) 버튼은 2축으로 정합니다 — ① 위계(어느 버튼을 강조하나) × ② weight 티어(그 강조를 얼마나 무겁게). 버튼 자체는 통합 버튼 체계의 ds-btn 조합이고, 여기선 그 조합을 고르는 규칙만 정합니다.
white-bk는 흰 모달 위에선 텍스트만 보이고 hover·focus 시 옅게 떠오릅니다 — 의도된 "물러남" 표현입니다.
weight 3티어 — dark > lightor > white-bk
확정 버튼은 무게가 다른 세 채움 중 하나입니다. 색이 아니라 행동의 중요도로 고릅니다.
| 티어 | 조합 | 모습 | 언제 |
|---|---|---|---|
| 1 · 기본 (강조) | solid · dark · md | 검정 채움 + 흰 글자 | 대부분의 컨펌 — 저장 · 등록 · 수정 · 확인 등 권장 액션 |
| 2 · 크리티컬 (드물게) | solid · lightor · md | 연주황 틴트 + 주황 글자 | 신중을 요하는 확정 — 삭제 · 영구 제거 · 되돌릴 수 없는 중요 결정 |
| 3 · 그 외 (약화) | solid · white-bk · md | 흰 바탕 + 검정 글자(텍스트만) | 물러나는 동작 — 취소 · 닫기 · dismiss |
축 1 — 위계 (어느 버튼을 강조하나)
- 푸터에서 가장 무거운 버튼 = 그 맥락의 권장 액션입니다. "우측이라서"가 아니라 "권장 액션이라서" 무겁게 칠합니다.
- 보통 권장 액션은
dark, 물러나는 동작(취소·닫기)은white-bk. lightor는 신중을 요하는 확정에만 — 남발하면 "주의" 신호가 무뎌집니다.
dark가 기본, lightor는 드물게, white-bk는 물러나기
- dark = 대부분의 권장 컨펌(차분하지만 또렷).
- lightor = "정말 할까요?" 한 번 멈칫하게 하고 싶은 크리티컬 확정(삭제·비가역).
- white-bk = 취소·닫기처럼 눈에서 비켜야 하는 동작.
축 1·2 결합 — 위험 반전 (destructive)
데이터를 잃는 확인(변경 폐기·삭제 직전)에서는 안전한 선택을 권장 액션으로 둡니다.
- 저장확인("저장하지 않고 나가시겠어요?")은 계속편집 =
dark(권장), 닫기 =white-bk(물러남). 가장 무거운 버튼이 안전한 쪽이라 반사적으로 데이터를 잃지 않습니다. - 크리티컬 파괴(삭제)는 **삭제 버튼을
lightor**로 두되, 자동 포커스·기본 Enter는 취소에 둡니다.
<!-- 일반 컨펌 — 권장은 dark, 취소는 white-bk -->
<div class="ds-modal__footer flex justify-end gap-2">
<button class="ds-btn ds-btn--solid ds-btn--white-bk ds-btn--md">취소</button>
<button class="ds-btn ds-btn--solid ds-btn--dark ds-btn--md">저장</button>
</div>
<!-- 크리티컬 — 삭제는 lightor, 취소가 안전 기본값 -->
<div class="ds-modal__footer flex justify-end gap-2">
<button class="ds-btn ds-btn--solid ds-btn--white-bk ds-btn--md" autofocus>취소</button>
<button class="ds-btn ds-btn--solid ds-btn--lightor ds-btn--md">삭제</button>
</div>
<!-- 변경 폐기(저장확인) — 안전한 계속편집을 dark로, 닫기는 white-bk -->
<div class="flex justify-end gap-2">
<button class="ds-btn ds-btn--solid ds-btn--white-bk ds-btn--md">닫기</button>
<button class="ds-btn ds-btn--solid ds-btn--dark ds-btn--md">계속편집</button>
</div>얼럿(미니 확인 팝업)도 같은 축
작은 확인 팝업(.ds-mini-modal — 저장확인 · 삭제확인 등)도 동일합니다. 확정 버튼을 행동 중요도로 dark/lightor/white-bk 중에서 고르세요.
현황 — 이 규칙이 새 기본값, 앱은 점진 정렬
지금 앱들은 "가장 우측 = orange/dark 채움" 관성이 남아 있고, 일부 워크플로(승인 green · 반려 red)는 의미색을 씁니다. 이 weight 3티어가 모달·얼럿 푸터의 새 기본값이며, 모달 안의 affirmative는 이 모델로 정렬해 갑니다(앱 마이그레이션은 버튼 체계와 함께). 의미색 red/green은 모달 밖 인라인·툴바·워크플로 컨텍스트에 남습니다.
예외 — 모달 내 워크플로 승인/반려: 문서 결재(doc-preview)처럼 모달 안에서 일어나는 **승인=green(ds-btn--green) · 반려=red(ds-btn--red, 연한 시작은 --lightred)**는 의미가 강해 의미색을 유지합니다(weight 3티어 미적용). 단 **같은 푸터의 일반 닫기·취소는 white-bk**로 정렬합니다 — 즉 승인/반려 워크플로 색만 예외, 나머지는 3티어 그대로.
8. 모달 안의 피커 (날짜 · 월 · 시간)
날짜·월·시간 피커는 공용 폼 컨트롤입니다(모달·페이지 공용) — 컴포넌트 규격·라이브 데모는 Form controls → 달력(날짜·월) · 시간 입력에 등록돼 있습니다. 모달 안에서 쓸 때만 아래가 더해집니다:
- 레이어 — 다이얼로그형(시간)은
.ds-modal-backdrop--top(z60)으로 모달 위에 올립니다(저장확인 팝업과 같은 레이어). 팝오버형(월.ds-menu)은 모달 바디 안에 앵커링하되overflow-y-auto에 잘리지 않게 트리거 기준 절대배치 + 6px. - 닫기 충돌 — 피커가 떠 있을 때 Esc/바깥클릭은 피커만 닫아야 합니다(모달까지 닫히면 입력 손실). 시간 다이얼로그는
@keydown.escape.stop으로 모달의escape.window전파를 막습니다.
컨펌 다이얼로그 — 확인 · 알림 · 입력
네이티브 alert/confirm/prompt 자리에 뜨는 가장 작은 모달입니다. 종류를 바꿔 보고, 타이틀을 꺼 보고, 여백 28 가이드를 켜서 일반 모달(40)과의 차이를 확인해 보세요. 확인/취소를 누르면 호출부가 받는 반환값이 아래에 표시됩니다. (규격은 컨펌 다이얼로그 참고)
컨펌 다이얼로그 (Confirmation dialog)
브라우저가 창 상단에 그리는 alert() / confirm() / prompt() 를 앱 안으로 들여온 공용 모달입니다.
네이티브 시스템 다이얼로그는 금지입니다 — 예외 없음
2026-07-29에 전 앱에서 244곳(TS 231 · HTML 13)을 걷어냈고, 현재 제품 코드와 빌드 산출물 모두 0곳입니다. 새로 만들지 마세요.
- ❌
alert(...)·confirm(...)·prompt(...) - ❌ 우회 표기도 전부:
window.alert(...)·(window as any).confirm(...)·globalThis.prompt(...)·window["alert"](...) - ✅
askConfirm/showAlert/showError/showPrompt(@hereby/components) — HTML은$store.dialog.ask(...)
왜 — 스타일을 줄 수 없어 DS와 따로 놀고, 모바일(Capacitor)에서는 OS 얼럿으로 뜹니다. 결정적으로 사용자가 「이 페이지가 추가 대화상자를 표시하지 않도록 차단」을 한 번 체크하면 브라우저가 조용히 무력화해서 — 이후 모든 confirm() 이 false, 모든 alert() 가 no-op — 동작이 아무 반응 없이 실패한 것처럼 보입니다.
어떻게 막혀 있나 (둘 다 통과해야 커밋됩니다)
| 장치 | 담당 | 놓치는 것 |
|---|---|---|
Biome lint/suspicious/noAlert (error) | .ts 의 alert( · window.alert( · globalThis.prompt( | 캐스트형, .html |
pnpm check:dialogs (pre-commit blocking) | 캐스트형 (window as any).confirm( · 인덱스형 window["alert"]( · .html 전부 | — |
캐스트형은 Biome이 전역 참조로 보지 않아 통과시킵니다 — 실제로 이 구멍으로 9곳이 빌드 산출물까지 흘러간 적이 있어 스크립트를 따로 둡니다. 예외는 다이얼로그 자체 구현(dialog.ts · ds-dialog.html) 뿐이며, 스크립트의 ALLOW 목록에 명시돼 있습니다.
라이브 예시는 이 탭 맨 위에서 직접 조작할 수 있습니다.
단일 출처
packages/components/src/dialog.ts ← 스토어 + askConfirm/askChoice/showAlert/showError/showPrompt
packages/components/html/ds-dialog.html ← 마크업 (앱 index.html 에서 include)앱 main.ts 는 아무것도 하지 않습니다 — createAuthComponentsPlugin() 이 Alpine.store("dialog", …) 를 대신 등록합니다(앱이 빠뜨릴 수 없게). 플러그인을 쓰지 않는 랜딩·system-admin만 직접 등록합니다.
쓰는 법
import { askConfirm, showAlert, showError, showPrompt } from "@hereby/components";
if (!(await askConfirm("이 행을 삭제할까요?"))) return; // confirm()
showAlert("저장했습니다."); // alert() — await 불가(의도)
showError(`삭제 실패: ${message}`); // alert() + danger 톤
const name = await showPrompt({ message: "새 이름", value: old, requireValue: true });HTML 안 Alpine 식에서는 @click="(await $store.dialog.ask('삭제할까요?')) && remove(id)".
표 · 안내 · 아래 한 줄 — 본문 아래 덧붙임
확인 창 본문 아래에 세 가지를 더 붙일 수 있습니다. 순서는 본문 → 대상(details) → 표(table) → 안내(notice) → 아래 한 줄(footnote) 로 고정이고, 넘기지 않은 것은 자리째 빠집니다.
await askConfirm({
title: "다시 실행할까요?",
message: "적용되면 되돌릴 수 없어요.",
details: ["홍길동 · 계약 변경"],
table: {
heading: "지금 적용하면 이렇게 바뀌어요", // 표 위 14px semibold
columns: ["항목", "지금", "적용 후"],
rows: [["기본급", "3,000,000", "3,200,000"]],
columnStyles: [null, "before", "after"], // before=흐리게+취소선 · after=굵게
more: "외 3개 항목", // 표 아래 작은 글자(선택)
emptyText: "(비움)", // 이 글자 칸은 취소선·굵게 없이 흐리게(선택)
},
confirmLabel: "다시 실행",
cancelLabel: "닫기",
});
// 표 대신 결과 한 줄 — 아이콘 없는 notice
showAlert({ title: "미리보기", message: "", notice: { tone: "danger", text: "지금 적용하면 실패해요: 계약이 없어요." },
footnote: "미리보기라 실제로는 아무것도 바뀌지 않았어요.", confirmLabel: "닫기" });- 표는 항목마다 값을 줄 맞춰 비교할 때만 씁니다(예: 적용하면 바뀌는 것의 전 · 후). 대상 이름 나열은
details, 문장은message입니다. - 표는
<table>이 아니라 격자로 그립니다 — adminmain.css가 표 칸을 가운데 정렬 · 높이 36 으로 강제(!important)해 다이얼로그 안 표가 깨지기 때문입니다. 모양은 일반 모달의 비교 격자(머리글 회색 줄 + 줄 구분선)와 같습니다. - 줄이 없으면(
rows: []) 제목만 보입니다(「바뀌는 값이 없어요」). - 빈 값 칸은 강조하지 않습니다 —
emptyText와 같은 칸은 before/after 열이어도 취소선·굵게 없이 흐리게 그립니다. 없던 값에 취소선을 그으면 바뀌는 값처럼 읽힙니다. notice는 결과 한 줄입니다 — 본문이 이미 맥락을 말하므로 같은 경고를 반복하지 마세요. 아이콘은 넣지 않습니다.- 셋 다 상태 객체를 재사용하므로 다이얼로그를 열 때마다 비워집니다 — 다음 확인 창에 앞의 표가 남지 않습니다.
키보드 — Enter는 확인, Esc는 취소
다이얼로그가 열리면 확인 버튼이 포커스를 가집니다(prompt만 입력칸이 가짐). 그래서 Enter가 곧 확인 — 네이티브 confirm()의 관례 — 이고, Tab으로 취소/대안 버튼에 옮겨 Enter·Space로 누를 수 있습니다. 포커스 이동은 ds-dialog.html 확인 버튼의 x-effect가 집니다.
포커스를 옮기지 않으면 다이얼로그를 연 쪽 입력창(수식바 등)에 포커스가 남아, Enter가 다이얼로그 대신 그 입력창에서 돌아 "Enter를 쳐도 안 끝나는" 상태가 됩니다(2026-08 실제 리포트). 다이얼로그류 컴포넌트를 새로 만들면 같은 규칙을 따르세요.
선택형 — "할까/말까"가 아니라 "어느 쪽이냐"
물음이 적용 범위일 때가 있습니다. 예를 들어 급여 시트에서 셀 한 칸에 넣은 수식은 그 칸에만 둘 수도, 열 전체에 적용할 수도 있습니다. 이때 askConfirm 으로 물으면 「취소」가 두 선택지 중 하나를 대신하게 되어, 사용자가 무엇을 고른 건지 흐려집니다.
import { askChoice } from "@hereby/components";
const scope = await askChoice({
message: "수식을 변경했습니다 — 전체 열에 적용하겠습니까?",
altLabel: "이 셀만", // 가운데 버튼 → "alt"
confirmLabel: "전체 열에 적용" // 오른쪽 버튼 → "confirm"
});
if (scope === "cancel") return; // Esc — 아무것도 하지 않는다- 네 번째 모달 종류가 아닙니다. 카드·제목·여백·타이포는 컨펌 다이얼로그와 완전히 같고, 푸터 버튼만 갈립니다.
- 취소 버튼이 없습니다. 선택지 두 개만 세우고, 되돌아 나가는 길은 Esc 가 집니다(
"cancel"). 셋째 버튼을 두면 무엇이 기본인지 흐려집니다. - 호스트가 없으면
"cancel"입니다.askConfirm은 같은 상황에서 통과(true)시키지만, 선택형은 통과시킬 기본값이 없습니다 — 임의로 고르면 사용자가 고르지 않은 쪽이 실행됩니다. - 정말 "할까/말까"면 쓰지 마세요. 선택지가 하나뿐인 물음은
askConfirm이 맞습니다.
여백 — 상하좌우 28 일괄
카드 안 구조와 타이포는 일반 모달과 같은 클래스를 그대로 씁니다. 다른 건 바깥 여백뿐이고, 그 차이는 마크업이 아니라 .ds-dialog 가 CSS로 만듭니다.
| 일반 모달 | 컨펌 다이얼로그 | |
|---|---|---|
| 카드 위 / 좌우 / 아래 | 44 / 40 / 36 | 28 / 28 / 28 |
| 타이틀 ↔ 본문 | 24 | 24 (동일) |
| 본문 ↔ 푸터 | 32 | 32 (동일) |
| 타이틀 | 20 / 600 | 20 / 600 (동일) |
| 본문 | 14 / 400 · label-assistive | 동일 |
<!-- 카드에 .ds-dialog 만 얹으면 끝. padding 을 직접 적지 마세요. -->
<div class="ds-modal ds-dialog bg-white w-full mx-4 flex flex-col relative" style="max-width: 480px">문장 한두 줄 + 버튼뿐인 카드에 40을 주면 내용 대비 여백이 과해 카드가 비어 보입니다. 40 → 28은 그 조정입니다.
한 줄 입력(prompt) — 본문과 입력칸 사이 6px
showPrompt 의 본문은 그 입력칸이 무엇인지 말하는 줄입니다(message: "시트 이름"). 그래서 폼 컨트롤의 라벨 ↔ 입력칸과 같은 6px으로 붙입니다 — 모달의 내부 타이포·간격과 한 값입니다.
입력칸에 직접 mt-* 을 주면 안 먹습니다 — 간격은 래퍼가 집니다
design-system.css 의 필드 기본 규칙이 margin: 0 을 선언하는데, :not() 이 겹겹이라 특이도가 유틸 클래스보다 훨씬 높습니다.
input:not(.cell-input):not(.ds-input-plain):not([type="checkbox"])… { margin: 0; }실제로 이것 때문에 간격이 0px 이 된 적이 있습니다(2026-08). !important 로 되살리지 말고 래퍼 div 가 간격을 지게 두세요.
<!-- ✅ 래퍼가 간격을 진다 -->
<div x-show="$store.dialog.kind === 'prompt'" class="mt-1.5">
<input type="text" class="ds-input ds-input--40 w-full" />
</div>
<!-- ❌ 입력칸에 직접 — 조용히 0px 이 된다 -->
<input type="text" class="ds-input ds-input--40 w-full mt-1.5" />type="checkbox"·type="radio" 는 위 :not() 목록에 들어 있어 영향을 받지 않습니다 — 그 둘에 준 mt-* 은 정상 동작합니다.
타이틀 — on/off, 기본값 「확인」
- 기본값은 종류 불문 「확인」 입니다. alert · confirm · prompt 모두 같습니다.
- 끄려면
title: ""또는title: false— 헤더가 통째로 빠지고, 카드 위 여백은.ds-dialog--no-title이 본문padding-top으로 대신 집니다. (빈 헤더를 남기면 그만큼 위가 떠 보입니다.) - 바꾸려면
title: "교육 삭제"처럼 문자열을 넘깁니다.
askConfirm("삭제할까요?"); // 타이틀 「확인」
askConfirm({ message: "삭제할까요?", title: "교육 삭제" }); // 타이틀 교체
askConfirm({ message: "삭제할까요?", title: false }); // 타이틀 없음종류별로 다른 기본 제목을 되살리지 마세요
예전에는 alert가 「안내」/「오류」, prompt가 「입력」이었습니다. 같은 컴포넌트인데 화면마다 제목이 갈려 서로 다른 것처럼 보였습니다. 실패·파괴 신호는 제목이 아니라 푸터 버튼 이 집니다 — tone: "danger" 는 확인 버튼을 lightor 로 바꿀 뿐, 제목과 카드는 그대로입니다.
규칙 (강제)
- 구조·타이포는 일반 모달과 같은 클래스 —
.ds-modal__header·__title(20/600) ·__body·__footer. 다이얼로그 전용 타이포를 새로 두지 마세요. - 바깥 여백만 다릅니다 — 카드에
.ds-dialog를 얹어 상하좌우 28(여백). 마크업에padding을 직접 적지 마세요. - 타이틀은 on/off, 기본값 「확인」(타이틀). 끄면
.ds-dialog--no-title이 본문 위 여백을 집니다. 종류별 기본 제목을 되살리지 마세요. - 본문은 디스크립션 등급(
text-b4/label-assistive). 제목은 항상 같은 모양입니다 — 색을 바꾸지 마세요. - 레이어는
--top(z70) — 폼 모달 위에서 뜬 오류 안내가 뒤에 깔리면 안 됩니다. - X 버튼 없음. 취소·확인(과 Esc)이 유일한 퇴장입니다. Esc는
stopImmediatePropagation으로 아래 모달의escape.window전파를 막습니다. - 선택형(
askChoice)은 취소 버튼을 두지 않습니다 — 선택지 둘 + Esc("cancel"). 셋째 버튼을 만들거나, 두 선택지 중 하나를 「취소」 자리에 앉히지 마세요(선택형). - 컨펌 팝업을 특징별로 나누지 마세요. 파괴적(삭제)이든 아니든 카드·제목·여백·타이포는 완전히 같습니다 — 차이는 푸터 버튼이 집니다(기본
dark, 파괴적 확인만lightor).tone: "danger"는 확인 버튼 색만 바꿉니다. - 본문은
x-text조각으로만 렌더합니다(emphasis부분 굵게 포함).x-html금지 — XSS. - 비교 표가 필요하면
table옵션을 씁니다(표 · 안내 · 아래 한 줄). 표를 그리려고 페이지 안에 다이얼로그를 따로 만들지 마세요. showAlert는 일부러 await 할 수 없습니다. 대부분catch에서 부르는데 거기서 await 하면 뒤따르는finally정리가 사용자 클릭까지 멈춰 섭니다.- e2e에서
page.on("dialog", …)는 아무것도 잡지 못합니다 — 조용한 no-op 이 되어 "얼럿이 안 떴다" 검증을 항상 통과시킵니다.helpers/ds-dialog.ts의dsDialog(page)를 쓰세요.
일반 모달 — 폼 + 저장확인
직접 해보세요: dim을 클릭 → 닫히지 않습니다 · 아무것도 안 만지고 ✕ → 바로 닫힘(pristine 가드) · 이름을 입력한 뒤 ✕/취소 → 저장확인 팝업.
모바일 (풀페이지 변형)
이 섹션은 권장 기본값입니다 (강제 규칙 아님)
아래 수치는 직원 앱(works)의 풀페이지 모달 + 경영 대시보드 청구 모달이라는 좁은 표본에서 나왔습니다. 모바일 폼 모달의 쓰임새는 앞으로 더 넓어질 여지가 크므로, 이걸 "모든 모바일 모달이 반드시 따라야 하는 법"으로 읽지 마세요.
- 참고할 출발점 — 새 모바일 모달을 만들 때 아무 근거 없이 눈대중으로 정하지 말고 여기서 시작하세요. 그러면 대부분 맞습니다.
- 벗어나도 됩니다 — 화면이 다른 걸 요구하면 다른 값을 쓰고, 그 근거를 남기세요. 새 사례가 쌓이면 이 표를 갱신합니다.
- 미해결 항목이 하나 있습니다 — 입력칸 탭 시 iOS 자동 확대. 아직 규칙을 정하지 않았습니다.
입력이 있는 폼 모달은 모바일(md 미만, <768)에서 화면을 꽉 채우는 것을 기본으로 합니다 — .ds-modal--mobile-full. 좁은 화면에서 가운데 뜬 카드형은 키보드가 올라오면 푸터(저장)가 가려지고 남은 공간도 비좁습니다. 화면을 채우고 본문만 스크롤시키면 CTA가 항상 아래에 남습니다. 데스크톱(md 이상)은 카드 모달 그대로 — 같은 마크업이 두 모습을 갖습니다.
값의 출처는 직원 앱(works)의 .modal-sheet--full 입니다. 직원 앱은 모바일 전용이라 @media가 없고 아래 값들이 곧 기본값 — 다른 앱은 그 값을 모바일 구간에서만 재현합니다.
사이즈는 픽셀이 아니라 사이즈 클래스로 고릅니다. 아래 표도 클래스로 읽으세요 — "48px 인풋"이 아니라 ds-input--48, "32px 버튼"이 아니라 ds-btn--md. 괄호 안 픽셀은 그 클래스가 지금 어떤 값인지 보여주는 참고치일 뿐입니다.
두 계열의 표기법이 다릅니다. 폼 컨트롤은 숫자(
ds-input--24/28/32/40/48/52, 숫자가 곧 높이 px), 버튼은 영문(ds-btn--xs/sm/md/ml/lg/xl). 높이가 같아도(둘 다 48) 클래스 이름은 다릅니다.
| 모바일 (<768) | 데스크톱 (≥768) | |
|---|---|---|
| 카드 | 풀스크린 · border-radius: 0 · 상단 safe-area-inset-top | 카드 + 인라인 max-width |
| 폼 컨트롤 (input · select · 피커 트리거) | ds-input--48 / ds-select--48 (48) | ds-input--40 / ds-select--40 (40) |
| 푸터 CTA | ds-btn--lg (48) + 풀폭 | ds-btn--md (32) |
| 입력칸 글자 (input · select) | 사이즈 클래스의 기본(14) — ⚠️ iOS 자동 확대 미해결 | 사이즈 클래스의 기본(14) |
| 그 밖의 글자 (라벨 · 설명) | text-b4 이상 | 필드 라벨 text-b5 |
| 닫기 X | 직원 앱 풀페이지 모달과 동일한 큰 타깃 | DS 기본 |
| 여백 | 20px 그리드 (헤더 20/20/16, 본문 0 20 20) | 44/40/36 (§4) |
| 푸터 | 하단 고정 · border-top · env(safe-area-inset-bottom) | 카드 안 우측 정렬 |
표의 값은 출발점이지 상한/하한이 아닙니다. 배경은 이렇습니다.
ds-input--48을 고른 이유는 손가락 타깃입니다.ds-input--40은 데스크톱 포인터 기준이라 모바일에서 오탭이 납니다. 더 큰 타깃이 필요하면 스케일 안에서 한 단계 올리세요(ds-input--52) — 스케일 밖의 임의 픽셀로 가지는 말고요.- CTA 배치는 화면이 정합니다 — 두 버튼이 가로로 나란히 들어가면
flex: 1로 반반(직원 앱 기본), 좁아서 각각 반쪽이 되면 세로로 쌓고 각각 풀폭. 어느 쪽이든 하단 고정은 지키는 편이 좋습니다. - 키보드가 떠도 모달 높이는 줄이지 않습니다(2026-07 결정). 키보드에 맞춰 모달을 축소하면 입력 중인 필드까지 좁아지므로, 필드가 온전히 보이는 쪽을 택했습니다. CTA는 키보드 뒤에 남고, 저장은 키보드를 내리거나 키보드의 완료/Return으로 합니다 — 웹의 기본 동작입니다. (직원 앱은 네이티브 셸이라
body.keyboard-open+--kb-height로 높이를 줄이는 별도 처리를 씁니다. 웹 모달에 그대로 옮기지 마세요.) - 짧은 확인 팝업(저장확인 · 삭제확인)은 풀페이지로 만들지 않는 편이 낫습니다 — 카드 그대로. 풀페이지는 입력이 있는 폼에 어울립니다.
공용 모달을 풀레이어로 — .ds-modal--full
직원 앱(모바일 앱)의 풀레이어 팝업 규격은 한곳에
구조 · 제목 줄 · 닫기(X) · 여백 · 하단 버튼 · 열고 닫힐 때의 움직임은 페이지 인터랙션 — 모바일 앱 이 정본입니다. 이 절은 공용 모달을 풀레이어로 켜는 방법과 모달 종류 안에서의 위치만 다룹니다.
위 표는 폭으로 갈리는 변형(모바일에서만 풀페이지)을 말합니다. 그런데 모바일 전용 앱은 사정이 다릅니다. 직원 앱(works)은 자기 팝업 10종(휴가 신청·증명서·보고서…)이 전부 풀레이어(.modal-sheet--full)라, 거기 얹히는 공용 모달(예: 불편·오류 신고)만 가운데 뜬 카드로 나오면 같은 동작이 한 앱 안에서 두 모습으로 읽힙니다. 게다가 카드 여백(44/40/36)은 390px 폭에서 본문을 절반 가까이 깎습니다.
그래서 공용 템플릿에는 호스트가 켜는 풀레이어 변형을 씁니다.
<!-- 앱 index.html — 모바일 전용 앱만 켠다 -->
<div x-data="csReport({ fullLayer: true })" data-cs-report-host><!-- 공용 템플릿 — 셸만 갈아끼우고 구조(__header/__body/__footer)는 그대로 -->
<div class="ds-modal bg-white w-full flex flex-col relative"
:class="fullLayer ? 'ds-modal--full' : 'mx-4'" :style="shellStyle">- 네 번째 종류가 아닙니다.
.ds-modal--full은 일반 모달의 표시 변형입니다(--panel이 같은 축의 반대쪽인 것과 같은 관계) — 백드롭·닫기·스크롤 잠금·__header/__title/__body/__footer는 그대로고, 바깥 여백과 카드 크기만 바뀝니다. - 여백 · 닫기(X) 위치 · 제목 규격은 직원 앱 풀레이어 팝업과 같습니다(좌우 12 · X 는 제목 줄 오른쪽 · 제목
text-b3semibold 한 줄 고정 크기, 색만 DSlabel-normal) → 페이지 인터랙션 · 제목 줄. - 폭으로 켜지지 않습니다. 미디어 쿼리로 갈면 태블릿 폭에서만 카드로 돌아가는데, 직원 앱은 그 폭에서도 모바일 레이아웃입니다. 폭으로 갈리는 쪽은
.ds-modal--mobile-full입니다(아래 경고 참고). - 안쪽 사이즈는 마크업이 고릅니다 — DS 클래스에
md:가 안 붙으므로(→ 구현) 공용 템플릿이fullLayer로 직접 고릅니다: 입력ds-input--48, 푸터 CTAds-btn--lg(+.ds-modal--full .ds-modal__footer .ds-btn { flex: 1 }로 반반 · 둘까지), 라벨text-b4. 글자 배율 보정 선택자에 같이 넣어야 하는 것도 직원 앱 팝업과 같습니다 → 페이지 인터랙션 · 본문 · 여백.
첫 적용: 직원 앱의 불편·오류 신고(cs-report.html).
⚠️ 미해결 — 입력칸 탭 시 iOS 자동 확대
아직 규칙이 없습니다. 현재 모바일 모달의 입력칸 글자는 사이즈 클래스 기본값(14px)이고, 아이폰에서 입력칸을 탭하면 화면이 자동으로 확대됩니다 — 알고 두는 상태입니다. 여기 적는 건 결론이 아니라 다음 사람이 처음부터 다시 조사하지 않도록 남기는 메모입니다.
- 현상 — iOS Safari는 16px 미만인 입력창에 포커스가 가면 화면을 자동 확대하고(그 뒤로 레이아웃이 밀려 키보드 위 여백도 과하게 벌어집니다), 확대는 사용자가 직접 되돌려야 합니다.
- 해법 후보 ①: 입력 요소 글자를 16px로 — 웹 표준 해법입니다. 다만 사이즈 스케일(14)을 벗어나는 값이라, 도입하려면 "모바일 입력칸은 16px"이라는 전사 규칙을 확정하는 셈이 됩니다.
- 해법 후보 ②: viewport에
user-scalable=no— 직원 앱(works)이 실제로 쓰는 방식입니다(index.html). 단 핀치 확대까지 죽어 접근성이 깎이고, 브라우저(모바일 사파리)에서는 이 메타가 무시될 가능성이 있어 웹 화면에는 통하지 않을 수 있습니다. 네이티브 셸(Capacitor/WKWebView)에서만 확실히 동작합니다. - 왜 아직 안 정했나 — ①을 넣으면 입력칸이 14px인 직원 앱과 어긋나고, 웹/네이티브에서 동작이 갈리는 문제라 모달 한 개를 고치면서 결정할 사안이 아닙니다. 앱 전반(모바일 웹으로도 접속되는지 포함)을 함께 보고 정해야 합니다.
- 정할 때 먼저 할 일 — 실기기(아이폰 사파리)에서 14px 입력칸을 탭해 확대가 실제로 뜨는지, 그리고
user-scalable=no를 건 페이지에서도 뜨는지 확인하세요. 두 결과가 선택지를 가릅니다.
구현 — 사이즈 클래스에는 md:를 못 붙인다
ds-input--40 · ds-btn--md는 Tailwind 유틸리티가 아니라 DS 클래스라 md: 프리픽스가 통하지 않습니다(md:ds-input--48 같은 건 생성되지 않음). 그래서 두 갈래로 씁니다:
- DS 클래스로 만든 컨트롤 — 마크업은 데스크톱 기준(
ds-input--40,ds-btn--md)으로 두고, 앱main.css의@media (max-width: 767px)블록에서.ds-modal--mobile-full스코프로 덮습니다. 덮을 때도 목표는 사이즈 클래스(ds-input--48,ds-btn--lg)이고, CSS엔 그 클래스가 정의한 값을 그대로 옮겨 적으세요 — 스케일에 없는 임의 픽셀을 새로 만들면 스케일이 흩어집니다. - 유틸리티로 만든 컨트롤(예:
<div role="button">월 피커 트리거) — 모바일 퍼스트로h-12 md:h-10,text-b4 md:text-b5.
이름이 셋입니다 — 어느 걸 쓰는지 먼저 정하세요
| 클래스 | 어디 | 언제 켜지나 |
|---|---|---|
.ds-modal--full | design-system.css (SoT) | 호스트가 켠다(fullLayer: true) — 폭과 무관. 모바일 전용 앱의 공용 모달용 → 풀레이어 |
.ds-modal--mobile-full | apps/management-dashboard/src/styles/main.css 의 @media (max-width: 767px) — 아직 앱 로컬 | 뷰포트 <768 |
.modal-sheet--full | apps/employee-app/src/styles/main.css — 앱 자체 이름 | 항상(모바일 전용 앱) |
.ds-modal--mobile-full 은 두 번째 앱이 필요해지는 시점에 design-system.css 로 올리세요(값은 이미 .ds-modal--full 과 같으니 미디어 쿼리 안에서 그 선택자에 붙이면 됩니다) — 규칙만 문서에 있고 클래스는 한 앱에만 있는 상태를 오래 두지 않습니다. .modal-sheet--full 은 직원 앱 뷰 10종이 쓰고 있어 당장 개명하지 않습니다.
첫 적용: 경영 대시보드 청구 금액 추가(dashboard.html) · 직원 앱 결재/신고 모달 10종(modals/).
상세(읽기전용) 모달
목록 행을 눌러 내용을 읽는 모달(예: 공지 상세)입니다. 입력이 없으므로 §2의 "뷰어·확인 모달" 규칙(X·Esc·확인 → 바로 닫기, 저장확인 없음)을 따르고, 백드롭·스크롤 잠금·내부 타이포는 §1·§3·§4 그대로입니다. 여기에 더해지는 규칙:
- 셸 —
ds-modal-backdrop+ds-modal(읽기용으로 넓게, 예:max-width: 1000px; min-height: 600px; max-h-[90vh]) +ds-modal__header/__title+ds-modal__body flex-1 min-h-0 overflow-y-auto+ds-modal__footer+ds-modal__close. - 메타는 라벨로 — 상태·분류·우선순위 등은
ds-label ds-label--sm ds-label--square(의미색), 날짜는tabular-nums. 본문·사용자 입력은x-text만 사용(리치 텍스트가 아니면x-html금지 — XSS 차단). - 푸터 이전/다음 이동 — 목록을 모달 안에서 넘기는 이전·다음은
ds-btn ds-btn--icon ds-btn--md(각각title싱글톤 툴팁), 닫기는ds-btn ds-btn--solid ds-btn--white-bk ds-btn--md(§6 물러남 티어). - ESC 생명주기 — 뷰어 모달의
@keydown.escape.window는 팝업이 열려 있을 때만 등록/해제해 누수·이중 닫힘이 없어야 합니다.
첫 적용: 총무 공지 상세(notice-detail.html).
패널 모달 — 좌측 메뉴 + 우측 현황
같은 백드롭·닫기·스크롤 잠금 표준을 따르되, 카드 안이 「좌측 메뉴 + 우측 현황」 2단인 큰 팝업 변형(.ds-modal--panel)입니다. 좌측 메뉴로 섹션을 전환해 보세요. (규격·구조는 아래 패널 모달 참고)
패널 모달 (Panel modal)
여러 페이지의 탭으로 흩어져 있던 유형·기준 등록/관리를 한 팝업에 모으는 큰 팝업 변형(.ds-modal--panel)입니다. 좌측 메뉴로 섹션을 고르면 우측 현황이 바뀝니다 — 라이브 예시는 이 탭 맨 위에서 직접 조작할 수 있습니다.
신규 (2026-07) — 근태 관리에서 첫 적용
.ds-modal--panel 은 위 백드롭·닫기·스크롤 잠금 표준을 그대로 따르는 큰 팝업 변형입니다. 다른 점은 카드 안이 「좌측 메뉴 + 우측 콘텐츠」 2단이라는 것뿐 — 백드롭/딤/닫기 동작은 §1~§3과 동일합니다.
좌측은 섹션을 고르는 메뉴(LNB형), 우측은 선택한 섹션의 현황 표. 등록/수정 폼은 이 팝업 위(z-60) 에 중첩으로 뜹니다. admin 근태의 「유형·기준 통합 관리」가 첫 적용 사례입니다.
이 패널 안에 「긴 폼」을 담는 경우는 규칙이 따로 있습니다 → 패널 폼
같은 .ds-modal--panel 이지만 우측이 현황 표가 아니라 입력 폼인 쓰임입니다(직원 추가 · 직원 수정). 그때는 이 페이지에 없는 계약이 더 붙습니다 — 필수 누락 표시 · 닫기 가드(입력한 걸 날리지 않기) · 패널 위 레이어의 ESC 순서 · 주소 검색 · 주민등록번호. 그 규칙은 패널 폼에 모여 있습니다.
이 절이 정하는 것은 껍데기(백드롭 · 닫기 · 스크롤 잠금 · 카드 크기)까지이고, 그 안에서 폼을 굴리는 규칙은 아래 패널 폼입니다.
단일 출처
packages/static/styles/design-system.css ← .ds-modal--panel / .type-manager__nav-icon
apps/admin/src/views/modals/type-manager.html ← 좌측 메뉴 + 우측 섹션 마크업사이즈 (.ds-modal--panel)
일반 폼 모달의 max-width(임의값) 대신 뷰포트를 채우는 고정 패널입니다. 백드롭(.ds-modal-backdrop)의 flex 센터링이 바깥 여백을 만듭니다.
| 속성 | 값 | 의미 |
|---|---|---|
width | min(100vw - 200px, 1280px) | 좌우 100px 여백, 최대 1280 |
min-width | 768px | 2단 레이아웃 최소 폭 |
height | 100vh - 120px | 상하 60px 여백 |
min-height | 300px | 최소 높이 |
상하 여백이 더 필요한 폼형 패널(예: 직원 추가)은 .ds-modal--panel-loose 를 함께 얹습니다 — height: 100vh - 360px(상하 180px 여백)만 재정의되고 폭·최소 규격은 기본 패널과 동일합니다.
좌우 여백이 더 필요한 폼형 패널(예: 직원 추가/수정)은 .ds-modal--panel-narrow 를 함께 얹습니다 — width: min(100vw - 400px, 1280px)(좌우 200px 여백, 기본의 2배)만 재정의되고 상하·최소/최대 규격은 기본 패널과 동일합니다. 뷰포트 1680px 이상에서는 최대 폭 1280 캡이 먼저 걸려 기본 패널과 같은 폭이 됩니다. loose와 narrow는 서로 다른 축을 재정의하므로 함께 얹을 수 있습니다.
입력 필드가 많아 세로 공간이 넉넉해야 하는 폼형 패널(예: 직원 추가/수정)은 .ds-modal--panel-tall 을 함께 얹습니다 — min-height만 기본 300px → 720px으로 올립니다. 낮은 뷰포트에서 height(뷰포트−여백)가 720px 밑으로 줄어도 최소 720px은 확보되고, 그 아래로는 본문이 스크롤됩니다. 높이 축만 재정의하므로 -narrow·-loose와 그대로 조합됩니다.
구조
- 좌측 메뉴 (220px) —
bg-neutral-50+ 우측 1px 라인. 섹션 버튼은 활성 시bg-white·neutral-900·600, 비활성은neutral-600+ hoverneutral-100. 항목 아이콘은 각 섹션의 출처 페이지 헤더에서 쓰던 정체성 아이콘을 16px(.type-manager__nav-icon)로 재사용 — LNB 규격과 동일한 원칙입니다(LnbSidebar → 패널 모달 내비게이션). - 우측 콘텐츠 — 헤더(
.ds-modal__title) + 상단 액션(필터 select + 추가 버튼) + 현황 표(Table 1). 헤더 아래 12px, 카드 마진은 §4 그대로. - 닫기 X —
.ds-modal__close(우상단 24×24). 좌측 메뉴/우측 헤더와 겹치지 않도록 액션 버튼은 콘텐츠 안쪽으로 내립니다.
중첩 자식과 ESC 가드
패널 위에 도메인 폼·삭제확인·저장확인이 중첩(z-60) 으로 뜨므로, ESC는 가장 최근에 열린 자식만 닫아야 합니다(컨테이너까지 닫히면 입력 손실).
- 컨테이너는
typeManagerChildOpen(자식 오버레이가 하나라도 열림) 이 참이면 ESC로 닫지 않습니다. - 각 자식은
stopImmediatePropagation으로 컨테이너의escape.window전파를 막아, include 순서와 무관하게 "직전에 열린 팝업만" 닫습니다. - 자식이 막 닫힌 직후(
tmChildClosedAt+ 500ms) 는 컨테이너 닫힘을 유예 — ESC 연타/키 리피트로 컨테이너까지 닫히는 것을 방지합니다.
관련 — 이 패널의 좌측 메뉴는 LNB 패턴의 인-모달 변형입니다. LNB 관점의 Playground·비교는 → LnbSidebar → 패널 모달 내비게이션
패널 폼 (패널형)
좌측 카테고리 메뉴 + 우측 긴 폼으로 된 패널 모달의 공유 계약입니다. 직원 추가 · 직원 수정 두 패널이 이 셸을 씁니다.
이 페이지가 정본입니다
셸 · 좌측 메뉴 · 닫기 가드 · ESC 층 · 주소 검색 · 주민번호처럼 두 패널이 함께 지키는 것은 여기에만 적습니다. 각 컴포넌트 문서에는 그 패널만의 것(카테고리 구성 · 저장 방식 · 필수 누락 표시 유무)이 있습니다.
두 패널
기본정보
이 자리에 해당 카테고리의 입력 폼이 옵니다. 지금 카테고리에 필수값이 비어 있습니다.
같은 셸이지만 계약이 셋 다릅니다. 새로 만들 때 어느 쪽을 따라갈지 먼저 정하세요.
| 직원 추가 | 직원 수정 | |
|---|---|---|
| 카테고리 | 4개 — basic hr cert contract | 5개 — basic hr certification contract account |
| 필수 누락 빨간 점 | 있음 | 없음 |
| 저장 | 폼 전체를 생성 payload 로 (빈 값은 키를 뺌) | 바뀐 것만 (주소 diff · 계약 upsert) |
셸 구조
| 영역 | 마크업 | 비고 |
|---|---|---|
| 카드 | .ds-modal ds-modal--panel … | 패널 모달 |
| 좌측 메뉴 | <aside style="width: 220px"> + <nav> | 카테고리 목록 |
| 우측 본문 | <section class="flex-1 min-w-0"> | 제목(.ds-modal__title) + 스크롤 영역 |
카테고리 항목은 이 모양입니다 — 두 패널이 글자 하나까지 같습니다.
<button type="button" @click="…GoCat(catId)"
class="w-full flex items-center gap-2 px-2 py-2 ds-r-8 text-b5 transition-colors"
:class="…Cat === catId ? 'bg-white text-neutral-900 font-semibold' : 'text-neutral-600 hover:bg-neutral-100'">
<span class="truncate" x-text="…CatLabel(catId)"></span>
</button>자유 이동
「다음 → 다음」으로 넘기는 위저드가 아닙니다. 아무 카테고리로나 바로 갈 수 있고, 이동을 막지 않습니다.
필수 누락 표시
미완성을 알려야 하는 패널은 메뉴 항목 오른쪽에 빨간 점을 답니다.
<span class="ml-auto inline-block w-1.5 h-1.5 rounded-full bg-red-600 shrink-0"
x-show="!…CatValid(catId)"></span>점을 없애면 저장이 왜 막히는지 알 방법이 사라집니다
저장 검사는 보고 있지 않은 카테고리까지 봅니다. 그래서 지금 화면은 멀쩡한데 저장이 안 되는 상황이 정상적으로 생기고, 점이 유일한 단서입니다.
지금 이 표시를 쓰는 것은 직원 추가 하나입니다. 직원 수정은 이미 저장된 값에서 출발하므로 「필수 누락」이 성립하지 않아 쓰지 않습니다.
닫기 가드
X · 취소 · ESC 가 전부 같은 길(attemptClosePanel())로 갑니다.
closeBaseline 은 열 때 찍은 폼 스냅샷(JSON.stringify)입니다. 그래서 입력했다 지워서 원래대로 돌아오면 확인 없이 바로 닫힙니다.
ESC 층
패널 위에 레이어가 넷 뜰 수 있고, ESC 는 위에서부터 하나씩만 닫습니다.
알림(notice)은 패널이 닫혀 있어도 먼저 닫힙니다
알림 마크업이 모달 루트 바깥(형제) 에 있기 때문입니다 — 모달이 열리기 전에 참조 데이터 로드가 실패해도 보여 줘야 해서 그렇게 뒀습니다.
topLayerClosedAt 유예(500ms)는 ESC 연타·키리피트로 패널까지 통째로 닫히는 것을 막습니다. 권한 팝업의 양보 유예와 같은 값입니다.
주소 검색
우편번호 검색은 Daum 우편번호 서비스(공개 CDN, 키 불필요)를 최초 검색 시 lazy 로드합니다. 로드에 실패하면 프로미스를 버려 다음 시도에 다시 받습니다.
도로명(R)을 고르면 도로명 주소가, 지번(J)을 고르면 지번 주소가 들어가고 지번이 비어 있으면 도로명으로 떨어집니다. X 버튼은 주소 · 우편번호 · 상세주소를 함께 지웁니다.
저장할 때는 street · postalCode · detail 구조로 담고, 주소를 골랐으면 상세주소가 필수입니다(안 고르면 주소 자체가 선택 항목).
같은 로더가 세 곳에 복사돼 있습니다
AddEmployeePanel · EditEmployeePanel · MyInfoForm 셋 다 자기 안에 같은 _loadDaumPostcode() · daumPostcodePromise · CDN 주소를 갖고 있습니다. packages/components/src/utils/ 에 공용 유틸 자리가 이미 있는데(rrn.ts · fetch-all-pages.ts 등) 주소 검색만 빠져 있습니다. 네 번째를 복사하지 마세요.
주민등록번호
두 패널이 packages/components/src/utils/rrn.ts 를 함께 씁니다.
- 생년월일 → 주민번호 앞자리(
YYMMDD) 자동 채움. 한 방향입니다 — 주민번호를 고쳐도 생년월일은 안 따라옵니다. - 생년월일을 지우면 뒷자리와 음력 플래그까지 함께 지워집니다.
- 뒷자리는 숫자만 남기고 7자리에서 자릅니다.
- 13자리가 완성됐을 때만 체크섬을 봅니다. 부분 입력은 오류가 아닙니다.
- 저장 payload 에는 유효한 13자리일 때만 넣습니다.
알림 팝업
패널 안에서 alert() 대신 쓰는 자체 팝업입니다 — notice: { show, tone, message }.
새 패널에는 DS 다이얼로그를 쓰세요
이 자체 구현은 DS 다이얼로그(showAlert / showError)가 생기기 전에 만든 것입니다. 코드 주석도 tone 이 「현재 시각 스타일은 동일」이라고 적고 있습니다 — 새로 만들 때 따라 하지 마세요.
마크업은 모달 루트 바깥(형제) 에 둡니다. 모달이 열리기 전에 참조 데이터 로드가 실패해도 보여 줘야 하기 때문이고, 그래서 ESC 층에서도 맨 위에 있습니다.
1차 표준 (v1) — 진행 중, 확정 아님
- 이 문서는 1차 정리본입니다. 딤(40%)·간격·타이포 수치와 닫기 동작은 실사용 피드백에 따라 조정될 수 있으며, 바뀌면 이 문서가 함께 갱신됩니다.
- 적용 현황: team-manager · admin(native/탭 인라인/scheduler) · general-affairs · management-dashboard · 공유 위자드/설정 모달 적용 완료.
- 급여(hr-system)도 적용 대상입니다 (2026-07-29 제외 해제). 시트 모달은 대부분 이미 표준을 따르고 있고, 남은 항목은 적용 현황에서 관리합니다.
예외 — 이 표준을 따르지 않는 것들
| 대상 | 이유 |
|---|---|
| 컨텍스트 메뉴 · 드롭다운 · 피커 · 자동완성 | 바깥 클릭 닫힘이 올바른 UX — .ds-modal-backdrop 사용 금지 |
| 이미지 미리보기 라이트박스 (메신저) | 어두운 배경(90%) + 클릭 닫기가 라이트박스 표준 UX |
| 압축 그리드의 마이크로 라벨 (병동 설정 시간매핑 등) | 11px 미니 인풋 그리드 — 13px 라벨이 입력칸보다 커져 역전 |
급여(hr-system) 적용 현황
2026-07-29 예외 해제. 시트 탭 모달 25개 기준 — 셸(ds-modal-backdrop → ds-modal → __header/__title/__body/__footer)과 푸터 버튼은 20개가 이미 표준이고, 레거시 버튼(icon-btn·ds-btn--secondary)은 0건입니다.
| 남은 항목 | 상태 |
|---|---|
미니 팝업(.ds-mini-modal) 3종 — 확인/알림·한 줄 입력·저장확인 | ✅ 정리됨 (제목 ds-modal__title, 폭 320, 닫기 X) |
| 카드 폭 — 규격 밖 460px | ✅ 580으로 정정 |
| 미사용 옛 열 설정 모달 | ✅ 2026-09-22 미연결 템플릿 삭제로 정리 |
레거시는 위반이 아니라 마이그레이션 대상입니다. 전수 스윕하지 말고, 그 파일을 작업할 때 함께 교체하세요.
어디서 쓰이나
- 공통 CSS: packages/static/styles/design-system.css —
.ds-modal-backdrop/.ds-mini-modal - team-manager: src/views/modals/ — 표준 1호 적용 (폼 9 저장확인 / 뷰어 직접)
- admin: src/views/modals/ + 탭 인라인 모달(wards·leaves·settings·documents·parameters)
- general-affairs: src/views/modals/notice-detail.html — 공지 상세(읽기전용) 모달 + 임베드 백드롭 오프셋(
ga-embedded) - 패널 모달(
.ds-modal--panel): apps/admin/src/views/modals/type-manager.html — 근태 유형·기준 통합 관리(좌측 메뉴 + 우측 현황) - 공유 컴포넌트: packages/components/html/ — 직원 추가/수정 패널, 내 정보, 수당/공제 설정
검토 필요 사항
두 패널의 코드가 각자입니다 — 겹치는 양을 세어 봤습니다.
개수 추가 전용 함수 30 이름이 같은 함수 29 수정 전용 함수 38 이름이 같은 29개 중 14개는 본문까지 글자 하나 안 다릅니다 — 순수 복사본입니다(
_loadDaumPostcode·validateRrn·formatRrn·formatPhone·showNotice·closeNotice·closePreview·closeAddrSearch·dismissClosePanel·positionFloatingMenu·salaryGradeLabel·shiftRefGroups·toggleTeamPickerCollapsed·_t).나머지 15개는 「이름은 같은데 조금씩 다른」 것들이고 이쪽이 더 위험합니다 — 한쪽만 고쳐도 아무도 모릅니다. 실제로 카테고리 id 가
cert↔certification로 갈라졌고, 주소 흐름은 주석에 **「추가에서 수정으로 이식」**이라고 적혀 있습니다(= 다음에도 손으로 옮겨야 한다는 뜻).분리 자체는 되돌릴 필요가 없습니다 — 저장 방식이 진짜로 다릅니다(생성 payload ↔ diff, 계정·비밀번호 별도 경로, 사번 확정 절차). 갈라야 할 것은 **「무엇을 저장하나」**이고, 공유해야 할 것은 **「어떻게 생겼나」**입니다. 지금은 둘 다 복사돼 있는 것이 문제입니다.
왜 이렇게 됐나 — 처음부터 두 파일이었습니다. 하나가 자라 갈라진 것이 아니라 2026-05-04 같은 커밋(
51930358)에서 한꺼번에 만들어졌습니다(TS 433 + 567줄, HTML 502 + 593줄). 「만들기 화면 하나 · 고치기 화면 하나」는 흔한 출발이지만, 공유해야 할 부분까지 함께 복제됐습니다.카테고리 id 가 어긋나 있습니다 — 추가는
cert, 수정은certification. 한쪽 코드를 복사해 오면 조용히 안 맞습니다.주소 검색 로더가 세 벌입니다 (위).
utils/에 자리가 있는데 안 빠져 있습니다.외부 CDN 의존이 있습니다 —
t1.daumcdn.net. 망 분리 환경이나 CDN 장애 때 주소 입력만 못 하게 되는데, 그때 사용자에게 무엇을 보여 줄지 정해져 있지 않습니다.
목표 & 로드맵
| 항목 | 현재 | 목표 |
|---|---|---|
| 백드롭 표준 (dim 40 · 바깥클릭 제거 · 스크롤 잠금) | ✅ tm · admin(native/탭/scheduler) · ga(4005) · md(4006) · 공유 | 신규 모달 준수 |
저장확인 (attemptClose + pristine 가드) | ✅ 전 앱 폼 모달 + 위자드 | admin 탭 인라인 폼 4개(wards/leaves/settings/parameters) 배선 |
| 필드 타이틀 13px/6px · 그룹 16px | ✅ 전 모달 (280곳+) | 신규 모달 작성 시 준수 |
| 상태 선택 드롭다운 | ✅ 5곳 변환 완료 | 신규 폼 준수 |
| 푸터 버튼 강조 (맥락·중요도 weight 3티어) | 📝 규칙 정의 (이 문서 §6) · 앱은 orange/dark-우측 관성·일부 green/red 잔존 | 전 모달·얼럿 affirmative를 dark/lightor/white-bk로 정렬 |
| 저장확인 로직 공통화 | 동일 구현 6벌(admin/scheduler/tm/위자드×2/ga/md) | @hereby/components 헬퍼 추출 |
모달 카드 폭 표준화 (w-[520px] 등 임의값) | 임의값 산재 | 폭 단계 토큰화 검토 |
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-09-09 | 흡수한 「패널 폼」 절 본문의 「위저드」 표기 9곳을 「패널」로 정리했습니다(백로그 해소). ⚠️ 자유 이동의 「넘기는 위저드가 아닙니다」는 단계형 폼을 뜻하는 일반명사라 유지합니다 |
| 2026-09-08 | 페이지 신규 작성. 직원 추가·직원 수정 문서에 나뉘어 있던 공유 규칙(셸 · 좌측 메뉴 · 닫기 가드 · ESC 층 · 주소 검색 · 주민번호 · 알림 팝업)을 문구 그대로 이 페이지로 옮기고, 두 문서는 여기를 참조하도록 바꿨습니다 |