Modal & Offcanvas
확인/경고/폼 입력/세부 설정 등 컨텍스트 전환 없는 상호작용을 제공하는 오버레이 컴포넌트를 Kosmos DSL로 일관성 있게 구현합니다. 접근성(포커스 트랩), 키보드 내비게이션, 백드롭/스크롤 정책까지 함께 다룹니다.
언제 Modal, 언제 Offcanvas?
| 상황 | 권장 컴포넌트 | 이유 |
|---|---|---|
| 짧은 확인/경고 | Modal | 중요도 강조/포커스 트랩/키보드 접근 용이 |
| 간단한 입력/미니 폼 | Modal | 짧고 집중도 높은 상호작용 |
| 보조 설정/필터 패널 | Offcanvas | 주 화면과 병행 작업, 넓은 작업 공간 |
| 네비게이션/툴박스 | Offcanvas | 좌/우에서 슬라이드 인, 덜 방해적 |
코드 심화: Modal 컴포넌트
Bootstrap 구조를 따르는 표준 Modal 컴포넌트입니다. id만 맞춰주면 어디서든 트리거 가능.
public class CompModal implements HtmlComponent {
private final String id; // 유일 ID
private final String title; // 헤더 타이틀 (null 가능)
private final HtmlComponent body; // 본문
private final HtmlComponent footer; // 푸터(액션 버튼)
private final String size; // null | sm | lg | xl
private final boolean scrollable; // .modal-dialog-scrollable
private final boolean centered; // .modal-dialog-centered
private final boolean staticBackdrop; // data-bs-backdrop="static" + keyboard=false
public CompModal(String id, String title, HtmlComponent body, HtmlComponent footer,
String size, boolean scrollable, boolean centered, boolean staticBackdrop) {
this.id = id; this.title = title; this.body = body; this.footer = footer;
this.size = size; this.scrollable = scrollable; this.centered = centered; this.staticBackdrop = staticBackdrop;
}
@Override public String render(RenderContext ctx) {
var dialogCls = new StringBuilder("modal-dialog");
if (scrollable) dialogCls.append(" modal-dialog-scrollable");
if (centered) dialogCls.append(" modal-dialog-centered");
if (size != null) dialogCls.append(" modal-").append(size); // sm|lg|xl
var modal = El.div().css("modal fade").attr("id", id).attr("tabindex","-1")
.attr("aria-labelledby", id + "-label").attr("aria-hidden","true");
if (staticBackdrop) {
modal.attr("data-bs-backdrop","static").attr("data-bs-keyboard","false");
}
var dialog = El.div().css(dialogCls.toString());
var content = El.div().css("modal-content");
var header = El.div().css("modal-header");
if (title != null) {
header.children(
El.h3().css("modal-title h5").attr("id", id + "-label").text(title),
El.button().css("btn-close").attr("type","button").attr("data-bs-dismiss","modal").attr("aria-label","Close")
);
} else {
header.child(
El.button().css("btn-close ms-auto").attr("type","button").attr("data-bs-dismiss","modal").attr("aria-label","Close")
);
}
var bodyEl = El.div().css("modal-body").child(body);
var footerEl = El.div().css("modal-footer").child(footer);
content.children(header, bodyEl, footerEl);
dialog.child(content);
modal.child(dialog);
return modal.render(ctx);
}
}
트리거 & 사용
// 트리거 버튼
El.button().css("btn btn-danger")
.attr("type","button")
.attr("data-bs-toggle","modal")
.attr("data-bs-target","#del-modal")
.child(El.i().css("bi bi-trash me-1").attr("aria-hidden","true"))
.text("삭제");
// 모달 본체
new CompModal(
"del-modal",
"정말 삭제하시겠습니까?",
El.p().text("이 작업은 되돌릴 수 없습니다."),
El.div().children(
El.button().css("btn btn-secondary").attr("type","button").attr("data-bs-dismiss","modal").text("취소"),
El.a().css("btn btn-danger").href("/items/42/delete").text("삭제")
),
"lg", false, true, true
);
Modal 옵션 매핑
| 옵션 | 설명 | 값/클래스 |
|---|---|---|
| 크기 | 대/소 크기 | .modal-sm / .modal-lg / .modal-xl |
| 스크롤 | 본문만 스크롤 | .modal-dialog-scrollable |
| 센터 정렬 | 수직 중앙 | .modal-dialog-centered |
| Static Backdrop | 바깥 클릭/ESC 무시 | data-bs-backdrop="static" data-bs-keyboard="false" |
Modal 해부도
.modal-header (title, btn-close) .modal-body .modal-footer (actions)
코드 심화: Offcanvas 컴포넌트
public class CompOffcanvas implements HtmlComponent {
public enum Placement { START, END, TOP, BOTTOM } // 좌/우/상/하
private final String id;
private final Placement place;
private final String title;
private final HtmlComponent body;
private final boolean backdrop; // 백드롭 표시
private final boolean scroll; // 본문 스크롤 허용
public CompOffcanvas(String id, Placement place, String title, HtmlComponent body, boolean backdrop, boolean scroll) {
this.id = id; this.place = place; this.title = title; this.body = body; this.backdrop = backdrop; this.scroll = scroll;
}
@Override public String render(RenderContext ctx) {
String side = switch (place) { case START -> "offcanvas-start"; case END -> "offcanvas-end";
case TOP -> "offcanvas-top"; case BOTTOM -> "offcanvas-bottom"; };
var root = El.div().css("offcanvas " + side).attr("tabindex","-1").attr("id", id)
.attr("aria-labelledby", id + "-label");
if (!backdrop) root.attr("data-bs-backdrop","false");
if (scroll) root.attr("data-bs-scroll","true");
return root.children(
El.div().css("offcanvas-header").children(
El.h3().css("offcanvas-title h5").attr("id", id + "-label").text(title),
El.button().css("btn-close").attr("type","button").attr("data-bs-dismiss","offcanvas").attr("aria-label","Close")
),
El.div().css("offcanvas-body").child(body)
).render(ctx);
}
}
트리거 & 사용
// 트리거
El.button().css("btn btn-outline-secondary")
.attr("type","button")
.attr("data-bs-toggle","offcanvas")
.attr("data-bs-target","#filter-panel")
.child(El.i().css("bi bi-sliders me-1").attr("aria-hidden","true"))
.text("필터");
// 본체
new CompOffcanvas(
"filter-panel",
CompOffcanvas.Placement.END,
"검색 필터",
El.form().children(
// 필터 폼 구성
El.div().css("mb-3").children(El.label().text("상태"), El.select().css("form-select").children(
El.option().attr("value","").text("전체"),
El.option().attr("value","open").text("열림"),
El.option().attr("value","closed").text("닫힘")
)),
El.button().css("btn btn-primary").attr("type","submit").text("적용")
),
true, // backdrop
true // scroll
);
Modal vs Offcanvas 비교
| 항목 | Modal | Offcanvas |
|---|---|---|
| 주 목적 | 확인/경고/짧은 폼 | 보조 패널, 설정/필터 |
| 포커스 트랩 | 기본 제공 | 기본 제공 |
| 스크롤 정책 | 기본적으로 본문 스크롤 | data-bs-scroll로 페이지 스크롤 유지 가능 |
| 위치 | 중앙 | 좌/우/상/하 슬라이드 |
| 방해 정도 | 높음 | 중간 (주 화면과 병행) |
접근성 & UX 체크리스트
- 레이블:
aria-labelledby가 실제 타이틀 요소와 연결되어야 합니다. - 포커스: 열릴 때 첫 포커스, 닫힐 때 트리거로 포커스 복귀가 되는지 확인하세요.
- 키보드:
ESC동작(또는 staticBackdrop일 경우 비활성) 검토. - 모바일: 세로 공간이 좁으므로 스크롤 가능한 본문/헤더 고정이 유용합니다.
선택 스크립트: 폼 전송 후 모달 닫기
// Bootstrap JS가 로드되어 있다고 가정
document.addEventListener("submit", (e) => {
const form = e.target.closest(".modal form");
if (!form) return;
e.preventDefault();
// TODO: fetch(form.action, {method: form.method, body: new FormData(form)})
const modalEl = form.closest(".modal");
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.hide();
});
주의사항
중첩 금지: 모달 안에 또 다른 모달을 띄우지 마세요. UX 혼란과 포커스 문제를 야기합니다.
위험 작업: 삭제/영구 작업은 항상 확인 모달(또는 2단계 확인)을 사용하고 서버에서도 재검증하세요.
콘텐츠 길이: 긴 폼은 오프캔버스가 더 자연스러울 수 있습니다. 모달은 짧고 집중적인 상호작용에 적합합니다.
다음으로
- Form Validation — 검증 메시지와 모달/오프캔버스 통합
- Page Template & Slots — 액션 영역/오버레이 배치 가이드