모달 (Modal)
1차 표준 (v1) — 진행 중, 확정 아님
- 이 문서는 1차 정리본입니다. 딤(40%)·간격·타이포 수치와 닫기 동작은 실사용 피드백에 따라 조정될 수 있으며, 바뀌면 이 문서가 함께 갱신됩니다.
- 적용 현황: team-manager · admin(native/탭 인라인/scheduler) · general-affairs · management-dashboard · 공유 위자드/설정 모달 적용 완료.
- 급여(hr-system)도 적용 대상입니다 (2026-07-29 제외 해제). 시트 모달은 대부분 이미 표준을 따르고 있고, 남은 항목은 적용 현황에서 관리합니다.
모든 다이얼로그형 모달이 따르는 공통 패턴입니다. 핵심은 세 가지 — ① 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 로 흡수하는 것이 목표이고, 새 코드에서는 쓰지 마세요. 기존 것은 건드리는 파일 안에서만 옮기고 일괄 스윕은 하지 않습니다.
규칙 (강제) · 코딩 에이전트용 — 사람은 접힌 채로 두어도 됩니다
⚠️ 이 문서는 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 해제됨.)
라이브 예시
실제 design-system.css가 그대로 적용된 데모입니다. 3종 모두 여기서 만져 볼 수 있습니다 — 아래 순서대로 컨펌 다이얼로그 · 일반 모달 · 패널 모달.
컨펌 다이얼로그 — 확인 · 알림 · 입력
네이티브 alert/confirm/prompt 자리에 뜨는 가장 작은 모달입니다. 종류를 바꿔 보고, 타이틀을 꺼 보고, 여백 28 가이드를 켜서 일반 모달(40)과의 차이를 확인해 보세요. 확인/취소를 누르면 호출부가 받는 반환값이 아래에 표시됩니다. (규격은 컨펌 다이얼로그 참고)
일반 모달 — 폼 + 저장확인
직접 해보세요: dim을 클릭 → 닫히지 않습니다 · 아무것도 안 만지고 ✕ → 바로 닫힘(pristine 가드) · 이름을 입력한 뒤 ✕/취소 → 저장확인 팝업.
패널 모달 — 좌측 메뉴 + 우측 현황
같은 백드롭·닫기·스크롤 잠금 표준을 따르되, 카드 안이 「좌측 메뉴 + 우측 현황」 2단인 큰 팝업 변형(.ds-modal--panel)입니다. 좌측 메뉴로 섹션을 전환해 보세요. (규격·구조는 아래 패널 모달 참고)
단일 출처
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 |
쌓임 원칙: 팝업은 최신 표시 순서대로 위에 쌓인다. 전역 팝업(권한 요청)은 어떤 모달 스택 위에서든 마지막에 뜨므로 항상 최상단 티어(--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에 붙입니다.
이름 함정
.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 |
| 콘텐츠 그룹 사이 세로 간격 | 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전파를 막습니다.
모바일 (풀페이지 변형)
이 섹션은 권장 기본값입니다 (강제 규칙 아님)
아래 수치는 직원 앱(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로 높이를 줄이는 별도 처리를 씁니다. 웹 모달에 그대로 옮기지 마세요.) - 짧은 확인 팝업(저장확인 · 삭제확인)은 풀페이지로 만들지 않는 편이 낫습니다 — 카드 그대로. 풀페이지는 입력이 있는 폼에 어울립니다.
⚠️ 미해결 — 입력칸 탭 시 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--mobile-full은 아직 앱 로컬
현재 정의는 apps/management-dashboard/src/styles/main.css의 @media (max-width: 767px) 블록에만 있습니다(직원 앱은 .modal-sheet--full이라는 자체 이름). 두 번째 앱이 필요해지는 시점에 design-system.css(SoT)로 올리고 이 문단을 지우세요 — 규칙만 문서에 있고 클래스는 한 앱에만 있는 상태를 오래 두지 않습니다.
첫 적용: 경영 대시보드 청구 금액 추가(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).
패널 모달 (Panel modal)
여러 페이지의 탭으로 흩어져 있던 유형·기준 등록/관리를 한 팝업에 모으는 큰 팝업 변형(.ds-modal--panel)입니다. 좌측 메뉴로 섹션을 고르면 우측 현황이 바뀝니다 — 라이브 예시는 페이지 상단 라이브 예시 → 패널 모달에서 직접 조작할 수 있습니다.
신규 (2026-07) — 근태 관리에서 첫 적용
.ds-modal--panel 은 위 백드롭·닫기·스크롤 잠금 표준을 그대로 따르는 큰 팝업 변형입니다. 다른 점은 카드 안이 「좌측 메뉴 + 우측 콘텐츠」 2단이라는 것뿐 — 백드롭/딤/닫기 동작은 §1~§3과 동일합니다.
좌측은 섹션을 고르는 메뉴(LNB형), 우측은 선택한 섹션의 현황 표. 등록/수정 폼은 이 팝업 위(z-60) 에 중첩으로 뜹니다. admin 근태의 「유형·기준 통합 관리」가 첫 적용 사례입니다.
단일 출처
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 관점의 라이브 미리보기·비교는 → LnbSidebar → 패널 모달 내비게이션
컨펌 다이얼로그 (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)".
키보드 — 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은 그 조정입니다.
타이틀 — 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. showAlert는 일부러 await 할 수 없습니다. 대부분catch에서 부르는데 거기서 await 하면 뒤따르는finally정리가 사용자 클릭까지 멈춰 섭니다.- e2e에서
page.on("dialog", …)는 아무것도 잡지 못합니다 — 조용한 no-op 이 되어 "얼럿이 안 떴다" 검증을 항상 통과시킵니다.helpers/ds-dialog.ts의dsDialog(page)를 쓰세요.
예외 — 이 표준을 따르지 않는 것들
| 대상 | 이유 |
|---|---|
| 컨텍스트 메뉴 · 드롭다운 · 피커 · 자동완성 | 바깥 클릭 닫힘이 올바른 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으로 정정 |
column-kind-editor.html의 raw gray-* 색 88건 | ⬜ 남음 — neutral-* 토큰으로 치환 필요 |
레거시는 위반이 아니라 마이그레이션 대상입니다. 전수 스윕하지 말고, 그 파일을 작업할 때 함께 교체하세요.
어디서 쓰이나
- 공통 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/ — 직원 추가/수정 위자드, 내 정보, 수당/공제 설정
목표 & 로드맵
| 항목 | 현재 | 목표 |
|---|---|---|
| 백드롭 표준 (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] 등 임의값) | 임의값 산재 | 폭 단계 토큰화 검토 |