Modal & Offcanvas

확인/경고/폼 입력/세부 설정 등 컨텍스트 전환 없는 상호작용을 제공하는 오버레이 컴포넌트를 Kosmos DSL로 일관성 있게 구현합니다. 접근성(포커스 트랩), 키보드 내비게이션, 백드롭/스크롤 정책까지 함께 다룹니다.

언제 Modal, 언제 Offcanvas?

상황 권장 컴포넌트 이유
짧은 확인/경고 Modal 중요도 강조/포커스 트랩/키보드 접근 용이
간단한 입력/미니 폼 Modal 짧고 집중도 높은 상호작용
보조 설정/필터 패널 Offcanvas 주 화면과 병행 작업, 넓은 작업 공간
네비게이션/툴박스 Offcanvas 좌/우에서 슬라이드 인, 덜 방해적

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-sm / .modal-lg / .modal-xl
스크롤 본문만 스크롤 .modal-dialog-scrollable
센터 정렬 수직 중앙 .modal-dialog-centered
Static Backdrop 바깥 클릭/ESC 무시 data-bs-backdrop="static" data-bs-keyboard="false"
.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단계 확인)을 사용하고 서버에서도 재검증하세요.
콘텐츠 길이: 긴 폼은 오프캔버스가 더 자연스러울 수 있습니다. 모달은 짧고 집중적인 상호작용에 적합합니다.

다음으로