Skip to content

모달 (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. 먼저 — 직접 만들 필요가 있는지

확인 한 번 받거나, 한 줄 알리거나, 한 줄 입력받는 것이라면 마크업이 필요 없습니다.

ts
import { askConfirm, showAlert, showError, showPrompt } from "@hereby/components";

if (!(await askConfirm("이 행을 삭제할까요?"))) return;

이미 모든 앱에 떠 있습니다(컨펌 다이얼로그). 새로 그리지 마세요.

2. 내용이 있는 모달 — 셋 중 하나를 고릅니다

html
<!-- 일반 모달 — 폼·상세 -->
<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.

정당한 예외(컨텍스트 메뉴 · 드롭다운 · 피커 · 자동완성 · 이미지 라이트박스 · 드로어/임베드 딤)는 같은 줄이나 윗줄에 사유를 적어 통과시킵니다:

html
<!-- 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곳 새어 나간 전례가 있습니다. 이중으로 막혀 있습니다: Biome lint/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, --top 70, --system 75(전역 팝업은 항상 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). 좌측 메뉴 220px bg-neutral-50. 중첩 자식 ESC 가드(typeManagerChildOpen·stopImmediatePropagation·500ms 유예).
  • 모바일(<768) 폼 모달기본값 권장, 강제 아님: 풀페이지(.ds-modal--mobile-full) + 폼 컨트롤 ds-input--48/ds-select--48 + CTA ds-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-40rgb(0 0 0 / 0.4)백드롭 dim (40% 고정)
--ds-z-modal50모달 + 백드롭
--ds-z-modal-top60모달 위 모달 (인라인 z-index로 사용)
--ds-z-modal-alert70모달 위 확인/알림/뷰어 — .ds-modal-backdrop--top
--ds-z-modal-system75전역(시스템) 팝업: 권한 요청 등 — .ds-modal-backdrop--system

쌓임 원칙: 팝업은 최신 표시 순서대로 위에 쌓인다. 전역 팝업(권한 요청)은 어떤 모달 스택 위에서든 마지막에 뜨므로 항상 최상단 티어(--system)를 쓴다 — 기본 티어로 두면 뷰어/확인(z-70) 위에서 발생한 403 팝업이 그 아래에 깔려 보이지 않는다.

규칙

1. 표시 — 백드롭은 .ds-modal-backdrop 하나로 끝

html
<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 · 스크롤 잠금)는 기본 백드롭과 같습니다.

왜 따로 있나. 어두운 딤은 "화면을 덮었으니 저기(모달)에 집중하라"는 신호입니다. 반면 **흰 딤은 "같은 화면이 잠깐 멈춤"**으로 읽혀요 — 화면 전환 없이 짧게 잠그고 스피너와 한 줄 안내만 얹는 자리에 씁니다.

html
<!-- 확정 카드는 숨기고, 처리 중에는 같은 백드롭 안에서 흰 딤으로 교체 -->
<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 — 동일 패턴):

ts
closeConfirm: { open: false, target: "" },
closeGuardForms: { showXModal: ["xForm"], ... }  // 모달별 폼 상태 키 등록
attemptClose(stateVar)  // 변경 없으면 바로 닫기, 있으면 확인 팝업
confirmClose()          // "닫기" → 대상 모달 닫기 (함수 이름 target이면 호출)
dismissClose()          // "계속편집" → 취소
  • 새 폼 모달은 closeGuardForms상태변수: [폼 키들]을 등록해야 pristine 가드가 동작합니다 — 미등록 target은 항상 확인을 띄웁니다(안전 기본값).
  • 닫을 때 정리가 필요한 모달은 target에 정리 함수 이름을 넘깁니다 (예: attemptClose('closeDocTemplateForm')).
  • 폼 상태를 JSON 스냅샷으로 비교할 수 없는 모달(행별 인라인 편집, Map 상태 등)은 가드에 등록하지 않고 항상 확인.
html
<!-- 각 앱 셸에 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기본값. 폼 모달 대부분(설정·이름 변경·필터·확인+결과 요약)
780px2열 그리드, 갤러리, 탭이 있는 폼
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 / 600ds-modal__title
모달 디스크립션(타이틀 아래)14px / 400ds-modal__desc
그룹(섹션) 타이틀16px (b3) / 500text-b3 헤딩
필드(인풋) 타이틀13px (b5) / 500text-b5 font-medium
타이틀 ↔ 인풋 간격6pxmb-1.5
콘텐츠 그룹 사이 세로 간격16pxspace-y-4 (모달 바디)
좌우로 놓인 인풋 사이 간격12pxgap-3 (grid/flex)

컨펌 다이얼로그는 바깥 여백만 다릅니다

아래 표는 일반 모달 기준입니다. 컨펌 다이얼로그상하좌우 28 일괄로 좁습니다 — 문장 한두 줄 + 버튼뿐이라 40은 카드가 비어 보입니다.

일반 모달컨펌 다이얼로그
카드 위4428
카드 좌우4028
카드 아래3628
타이틀 ↔ 본문2424 (동일)
본문 ↔ 푸터3232 (동일)
타이틀20 / 60020 / 600 (동일)

여백 차이는 마크업이 아니라 .ds-dialog 가 CSS로 만듭니다(design-system.css). 다이얼로그 마크업에 padding 을 직접 적지 마세요.

  • 모달 안 탭 바는 첫 탭을 헤더 좌측 패딩(40px)에 맞추려면 tabnav-flush-left(탭 nav의 좌측 패딩 0)를 함께 얹습니다 — 예: 병동 설정 모달.

텍스트 색 위계

모달 안 텍스트는 라벨 토큰으로 위계를 만듭니다. raw neutral-* 대신 시맨틱 label-* 토큰을 씁니다(테마 대응).

요소색 토큰
모달 타이틀 · 그룹 타이틀label-normalneutral-900
모달 디스크립션 · 인풋 타이틀label-assistiveneutral-500
기타 보조 설명(예시·힌트·캡션)label-mutedneutral-400
  • ds-modal__title/ds-modal__desc는 CSS가 색을 들고 있어 클래스만 붙이면 됩니다.
  • 인풋 타이틀은 text-label-assistive 를 직접 붙입니다(구 text-neutral-600에서 통일).
  • 보조 설명은 text-label-muted. (label-alternative=neutral-600은 더는 쓰지 않음 — assistive로 통일)
html
<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 패턴을 사용합니다:

html
<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 토글 행)에만 남깁니다.

가장 우측이라고 항상 진한 채움 버튼을 쓰지 않습니다. 푸터의 확정(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는 취소에 둡니다.
html
<!-- 일반 컨펌 — 권장은 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)
푸터 CTAds-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 센터링이 바깥 여백을 만듭니다.

속성의미
widthmin(100vw - 200px, 1280px)좌우 100px 여백, 최대 1280
min-width768px2단 레이아웃 최소 폭
height100vh - 120px상하 60px 여백
min-height300px최소 높이

상하 여백이 더 필요한 폼형 패널(예: 직원 추가)은 .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만 기본 300px720px으로 올립니다. 낮은 뷰포트에서 height(뷰포트−여백)가 720px 밑으로 줄어도 최소 720px은 확보되고, 그 아래로는 본문이 스크롤됩니다. 높이 축만 재정의하므로 -narrow·-loose와 그대로 조합됩니다.

구조

  • 좌측 메뉴 (220px)bg-neutral-50 + 우측 1px 라인. 섹션 버튼은 활성 시 bg-white·neutral-900·600, 비활성은 neutral-600 + hover neutral-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).tsalert( · 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만 직접 등록합니다.

쓰는 법

ts
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 으로 물으면 「취소」가 두 선택지 중 하나를 대신하게 되어, 사용자가 무엇을 고른 건지 흐려집니다.

ts
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 / 3628 / 28 / 28
타이틀 ↔ 본문2424 (동일)
본문 ↔ 푸터3232 (동일)
타이틀20 / 60020 / 600 (동일)
본문14 / 400 · label-assistive동일
html
<!-- 카드에 .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: "교육 삭제" 처럼 문자열을 넘깁니다.
ts
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.tsdsDialog(page) 를 쓰세요.

예외 — 이 표준을 따르지 않는 것들

대상이유
컨텍스트 메뉴 · 드롭다운 · 피커 · 자동완성바깥 클릭 닫힘이 올바른 UX — .ds-modal-backdrop 사용 금지
이미지 미리보기 라이트박스 (메신저)어두운 배경(90%) + 클릭 닫기가 라이트박스 표준 UX
압축 그리드의 마이크로 라벨 (병동 설정 시간매핑 등)11px 미니 인풋 그리드 — 13px 라벨이 입력칸보다 커져 역전

급여(hr-system) 적용 현황

2026-07-29 예외 해제. 시트 탭 모달 25개 기준 — 셸(ds-modal-backdropds-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-* 토큰으로 치환 필요

레거시는 위반이 아니라 마이그레이션 대상입니다. 전수 스윕하지 말고, 그 파일을 작업할 때 함께 교체하세요.

어디서 쓰이나

목표 & 로드맵

항목현재목표
백드롭 표준 (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] 등 임의값)임의값 산재폭 단계 토큰화 검토