Rails Modal Manager
Rails 애플리케이션을 위한 고급 모달 매니저입니다.
@reshacs/react-modal-manager에서 포팅되었습니다.
Version: 1.0.12
목차
설치
1. Gemfile에 추가
gem 'rails_modal_manager', path: 'path/to/rails-modal-manager'
2. Bundle 설치
bundle install
3. JavaScript 설정
app/javascript/application.js에 추가:
import { Application } from "@hotwired/stimulus"
import { registerRMMControllers, initHistoryStack, initGlobalClickHandler } from "rails_modal_manager"
const application = Application.start()
// RMM 컨트롤러 등록
registerRMMControllers(application)
// 히스토리 스택 초기화 (브라우저 뒤로가기 지원)
initHistoryStack()
// 글로벌 클릭 핸들러 초기화 (data-rmm-modal-id 버튼 지원)
initGlobalClickHandler()
4. CSS 설정
app/assets/stylesheets/application.css에 추가:
@import "rails_modal_manager";
5. Importmap 설정 (importmap-rails 사용 시)
config/importmap.rb에 추가:
pin "rails_modal_manager", to: "rails_modal_manager/index.js"
pin "rails_modal_manager/modal_store", to: "rails_modal_manager/modal_store.js"
pin "rails_modal_manager/history_stack_manager", to: "rails_modal_manager/history_stack_manager.js"
pin "rails_modal_manager/controllers/rmm_modal_controller", to: "rails_modal_manager/controllers/rmm_modal_controller.js"
pin "rails_modal_manager/controllers/rmm_overlay_controller", to: "rails_modal_manager/controllers/rmm_overlay_controller.js"
pin "rails_modal_manager/controllers/rmm_header_controller", to: "rails_modal_manager/controllers/rmm_header_controller.js"
pin "rails_modal_manager/controllers/rmm_sidebar_controller", to: "rails_modal_manager/controllers/rmm_sidebar_controller.js"
pin "rails_modal_manager/controllers/rmm_submenu_controller", to: "rails_modal_manager/controllers/rmm_submenu_controller.js"
pin "rails_modal_manager/controllers/rmm_taskbar_controller", to: "rails_modal_manager/controllers/rmm_taskbar_controller.js"
pin "rails_modal_manager/controllers/rmm_resize_handle_controller", to: "rails_modal_manager/controllers/rmm_resize_handle_controller.js"
기본 사용법
정적 모달
HTML에 미리 정의된 모달:
<!-- Overlay -->
<div class="rmm-overlay" id="my-modal-overlay"
data-controller="rmm-overlay"
data-rmm-overlay-modal-id-value="my-modal"
data-rmm-overlay-close-on-click-value="true"
data-action="click->rmm-overlay#handleClick">
</div>
<!-- Modal -->
<div id="my-modal"
class="rmm-modal rmm-size-md rmm-position-center"
data-controller="rmm-modal"
data-rmm-modal-modal-id-value="my-modal"
data-rmm-modal-title-value="모달 제목"
data-rmm-modal-size-value="md"
data-rmm-modal-position-value="center"
data-rmm-modal-draggable-value="true"
data-rmm-modal-closable-value="true"
data-rmm-modal-close-on-esc-value="true"
data-action="click->rmm-modal#handleClick"
role="dialog"
aria-modal="true">
<!-- Header -->
<div class="rmm-header rmm-draggable"
data-controller="rmm-header"
data-rmm-header-modal-id-value="my-modal"
data-rmm-header-draggable-value="true"
data-action="mousedown->rmm-header#handleMouseDown">
<div class="rmm-header-left">
<span class="rmm-drag-handle" title="드래그하여 이동">
<svg viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5"></circle>
<circle cx="15" cy="5" r="1.5"></circle>
<circle cx="9" cy="10" r="1.5"></circle>
<circle cx="15" cy="10" r="1.5"></circle>
<circle cx="9" cy="15" r="1.5"></circle>
<circle cx="15" cy="15" r="1.5"></circle>
</svg>
</span>
<h2 class="rmm-header-title">모달 제목</h2>
</div>
<div class="rmm-header-controls">
<button type="button" class="rmm-header-btn rmm-close-btn"
data-action="click->rmm-header#close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<!-- Body -->
<div class="rmm-body">
<div class="rmm-content-wrapper">
<div class="rmm-content">
<p>모달 내용</p>
</div>
</div>
</div>
</div>
모달 열기/닫기
// 모달 열기
const modalElement = document.getElementById('my-modal')
const controller = application.getControllerForElementAndIdentifier(modalElement, 'rmm-modal')
controller.open()
// 또는 JavaScript API 사용
import { openModal, closeModal } from "rails_modal_manager"
openModal('my-modal')
closeModal('my-modal')
모달 옵션
모달 컨트롤러 (rmm-modal)
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
modal-id |
String | - | 모달 고유 ID (필수) |
title |
String | "Modal" | 모달 제목 |
size |
String | "md" | 크기 (fit, xss, xs, sm, md, lg, xl, full) |
position |
String | "center" | 위치 (center, top, bottom, top-left, top-right, bottom-left, bottom-right) |
height |
String | "auto" | 높이 (auto, 300px, 50vh 등) |
draggable |
Boolean | false | 드래그 가능 여부 |
resizable |
Boolean | false | 리사이즈 가능 여부 |
minimizable |
Boolean | false | 최소화 가능 여부 |
closable |
Boolean | true | 닫기 가능 여부 |
close-on-esc |
Boolean | true | ESC 키로 닫기 |
close-on-overlay |
Boolean | true | 오버레이 클릭으로 닫기 |
hide-overlay |
Boolean | false | 오버레이 숨김 |
enable-history-stack |
Boolean | true | 브라우저 히스토리 연동 |
confirm-close |
Boolean | false | 닫기 시 확인 다이얼로그 |
confirm-close-message |
String | "정말 닫으시겠습니까?" | 확인 메시지 |
mobile-default-maximized |
Boolean | false | 모바일에서 기본 최대화 |
parent-modal-id |
String | - | 부모 모달 ID (종속 관계) |
min-width |
Number | 200 | 최소 너비 (px) |
min-height |
Number | 150 | 최소 높이 (px) |
max-width |
Number | - | 최대 너비 (px) |
max-height |
Number | - | 최대 높이 (px) |
크기 (Size)
| 값 | 너비 | 최소 너비 | 설명 |
|---|---|---|---|
| fit | auto | 150px | 콘텐츠에 맞춰 자동 조절 |
| xss | 20% | 200px | 매우 작은 크기 |
| xs | 30% | 280px | 작은 크기 |
| sm | 40% | 360px | 중소 크기 |
| md | 50% | 480px | 중간 크기 (기본값) |
| lg | 60% | 600px | 큰 크기 |
| xl | 70% | 720px | 매우 큰 크기 |
| full | 100% | 100% | 전체 화면 |
컴포넌트
Header
모달 상단 영역으로 제목, 드래그 핸들, 컨트롤 버튼을 포함합니다.
<div class="rmm-header rmm-draggable"
data-controller="rmm-header"
data-rmm-header-modal-id-value="my-modal"
data-rmm-header-draggable-value="true"
data-rmm-header-default-size-value="md"
data-action="mousedown->rmm-header#handleMouseDown touchstart->rmm-header#handleTouchStart">
<div class="rmm-header-left">
<!-- 사이드바 토글 버튼 (선택) -->
<button type="button" class="rmm-header-btn rmm-sidebar-toggle"
onclick="...">
<svg>...</svg>
</button>
<!-- 드래그 핸들 -->
<span class="rmm-drag-handle" title="드래그하여 이동">
<svg>...</svg>
</span>
<!-- 제목 -->
<h2 class="rmm-header-title">모달 제목</h2>
</div>
<div class="rmm-header-controls">
<!-- 최소화 버튼 -->
<button type="button" class="rmm-header-btn"
data-action="click->rmm-header#minimize" title="최소화">
<svg>...</svg>
</button>
<!-- 기본 크기 버튼 -->
<button type="button" class="rmm-header-btn"
data-action="click->rmm-header#restoreDefaultSize" title="기본 크기">
<svg>...</svg>
</button>
<!-- 최대화/복원 버튼 -->
<button type="button" class="rmm-header-btn rmm-maximize-btn"
data-action="click->rmm-header#maximize" title="최대화">
<svg class="rmm-icon-maximize">...</svg>
<svg class="rmm-icon-restore" style="display: none;">...</svg>
</button>
<!-- 닫기 버튼 -->
<button type="button" class="rmm-header-btn rmm-close-btn"
data-action="click->rmm-header#close">
<svg>...</svg>
</button>
</div>
</div>
버튼 순서 권장: [최소화] [크기조절] [커스텀 버튼들] [닫기]
Header 커스텀 버튼
헤더에 커스텀 버튼을 추가할 수 있습니다:
header_buttons: [
{ id: "delete-btn", icon: "trash", title: "삭제", variant: "danger", onclick: "deleteItem()" },
{ id: "edit-btn", icon: "edit", title: "수정", onclick: "editItem()" }
]
Header Button Options:
id- 버튼 ID (선택)icon- 미리 정의된 아이콘: trash, edit, settings, info, warning, download, refresh, plusicon_svg- 커스텀 SVG 아이콘 (icon 옵션보다 우선)title- 툴팁 텍스트variant- 스타일: default, danger, warning, success, infoonclick- JavaScript 클릭 핸들러
Sidebar
모달 좌측 네비게이션 영역입니다.
<div class="rmm-body">
<!-- Sidebar -->
<div class="rmm-sidebar"
data-controller="rmm-sidebar"
data-rmm-sidebar-modal-id-value="my-modal"
data-rmm-sidebar-collapsed-value="false">
<nav class="rmm-sidebar-nav" data-rmm-sidebar-target="nav">
<button type="button" class="rmm-sidebar-item rmm-active"
data-item-id="home"
data-item-label="홈"
title="홈"
data-action="click->rmm-sidebar#selectItem">
<span class="rmm-sidebar-item-icon"><svg>...</svg></span>
<span class="rmm-sidebar-item-label">홈</span>
</button>
<button type="button" class="rmm-sidebar-item"
data-item-id="settings"
data-item-label="설정"
title="설정"
data-action="click->rmm-sidebar#selectItem">
<span class="rmm-sidebar-item-icon"><svg>...</svg></span>
<span class="rmm-sidebar-item-label">설정</span>
<span class="rmm-sidebar-item-badge">3</span>
</button>
</nav>
</div>
<!-- Mobile Overlay -->
<div class="rmm-sidebar-overlay" data-action="click->rmm-sidebar#closeOverlay"></div>
<!-- Content -->
<div class="rmm-content-wrapper">
...
</div>
</div>
이벤트:
rmm-sidebar:itemSelect- 아이템 선택 시 발생
Submenu
컨텐츠 상단 탭 네비게이션입니다.
<div class="rmm-content-wrapper">
<!-- Submenu -->
<div class="rmm-submenu"
data-controller="rmm-submenu"
data-rmm-submenu-modal-id-value="my-modal">
<button type="button" class="rmm-submenu-item rmm-active"
data-item-id="tab1"
data-item-label="탭 1"
data-action="click->rmm-submenu#selectItem">
<span>탭 1</span>
</button>
<button type="button" class="rmm-submenu-item"
data-item-id="tab2"
data-item-label="탭 2"
data-action="click->rmm-submenu#selectItem">
<span>탭 2</span>
</button>
</div>
<!-- Content -->
<div class="rmm-content">
...
</div>
</div>
Footer
모달 하단 버튼 영역입니다.
<div class="rmm-footer">
<div class="rmm-footer-left">
<span class="rmm-footer-message">메시지 영역</span>
</div>
<div class="rmm-footer-right">
<button type="button" class="rmm-btn rmm-btn-secondary"
data-action="click->rmm-header#close">취소</button>
<button type="button" class="rmm-btn rmm-btn-primary">확인</button>
</div>
</div>
버튼 클래스:
rmm-btn-primary- 기본 (파란색)rmm-btn-secondary- 보조 (회색)rmm-btn-success- 성공 (초록색)rmm-btn-danger- 위험 (빨간색)rmm-btn-warning- 경고 (주황색)
Overlay
모달 배경 오버레이입니다.
<div class="rmm-overlay" id="my-modal-overlay"
data-controller="rmm-overlay"
data-rmm-overlay-modal-id-value="my-modal"
data-rmm-overlay-close-on-click-value="true"
data-rmm-overlay-close-on-dblclick-value="false"
data-action="click->rmm-overlay#handleClick">
</div>
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
modal-id |
String | - | 연결된 모달 ID |
close-on-click |
Boolean | true | 클릭으로 닫기 |
close-on-dblclick |
Boolean | false | 더블클릭으로 닫기 |
오버레이 이벤트 통과:
<div class="rmm-overlay rmm-overlay-passthrough" ...>
</div>
Taskbar
최소화된 모달을 표시하는 하단 바입니다.
<div class="rmm-taskbar"
data-controller="rmm-taskbar"
data-rmm-taskbar-target="container">
<div class="rmm-taskbar-items" data-rmm-taskbar-target="items"></div>
</div>
Taskbar는 자동으로 최소화된 모달들을 렌더링합니다.
Resize Handle
모달 리사이즈를 위한 핸들입니다.
<div class="rmm-resize-handles">
<div class="rmm-resize-handle rmm-resize-handle-n"
data-controller="rmm-resize-handle"
data-rmm-resize-handle-direction-value="n"
data-rmm-resize-handle-modal-id-value="my-modal"
data-action="mousedown->rmm-resize-handle#handleMouseDown"></div>
<div class="rmm-resize-handle rmm-resize-handle-s" ...></div>
<div class="rmm-resize-handle rmm-resize-handle-e" ...></div>
<div class="rmm-resize-handle rmm-resize-handle-w" ...></div>
<div class="rmm-resize-handle rmm-resize-handle-ne" ...></div>
<div class="rmm-resize-handle rmm-resize-handle-nw" ...></div>
<div class="rmm-resize-handle rmm-resize-handle-se" ...></div>
<div class="rmm-resize-handle rmm-resize-handle-sw" ...></div>
</div>
방향: n, s, e, w, ne, nw, se, sw
부모-자식 모달
모달 간 종속 관계를 설정할 수 있습니다.
설정
자식 모달에 parent-modal-id 속성을 추가합니다:
<!-- 부모 모달 -->
<div id="parent-modal" data-controller="rmm-modal" ...>
...
</div>
<!-- 자식 모달 -->
<div id="child-modal"
data-controller="rmm-modal"
data-rmm-modal-parent-modal-id-value="parent-modal"
...>
...
</div>
<!-- 손자 모달 -->
<div id="grandchild-modal"
data-controller="rmm-modal"
data-rmm-modal-parent-modal-id-value="child-modal"
...>
...
</div>
동작
- 부모 최소화: 모든 자손 모달도 함께 최소화
- 부모 닫기: 모든 자손 모달도 함께 닫힘
- 자식 닫기: 해당 자식의 모든 자손만 닫힘
- Taskbar: 그룹으로 표시되며, 그룹 크기 배지 표시
JavaScript API
모듈 임포트
import {
VERSION,
modalStore,
historyStackManager,
registerRMMControllers,
initHistoryStack,
openModal,
closeModal,
closeTopModal,
closeAllModals,
arrangeModals,
getOpenModalCount,
getModalStore,
} from "rails_modal_manager"
함수
registerRMMControllers(application)
Stimulus 애플리케이션에 모든 RMM 컨트롤러를 등록합니다.
import { Application } from "@hotwired/stimulus"
import { registerRMMControllers } from "rails_modal_manager"
const application = Application.start()
registerRMMControllers(application)
initHistoryStack()
히스토리 스택 매니저를 초기화합니다. 브라우저 뒤로가기로 모달을 닫을 수 있습니다.
initHistoryStack()
openModal(modalId)
모달을 엽니다.
openModal('my-modal')
closeModal(modalId, source)
모달을 닫습니다.
closeModal('my-modal', 'programmatic')
closeTopModal()
가장 위에 있는 모달을 닫습니다.
const closedId = closeTopModal()
closeAllModals()
모든 모달을 닫습니다.
closeAllModals()
arrangeModals(arrangement, baseSize)
열린 모달들을 정렬합니다.
arrangeModals('cascade', 'sm') // 계단식
arrangeModals('tile', 'sm') // 타일식
arrangeModals('corners', 'sm') // 모서리
arrangeModals('horizontal') // 수평
arrangeModals('vertical') // 수직
getOpenModalCount()
열린 모달 개수를 반환합니다.
const count = getOpenModalCount()
Modal Store
직접 모달 스토어에 접근할 수 있습니다:
import { modalStore } from "rails_modal_manager"
// 모달 설정 가져오기
const config = modalStore.getModalConfig('my-modal')
// 모달 업데이트
modalStore.updateModal('my-modal', { size: 'lg' })
// 맨 앞으로 가져오기
modalStore.bringToFront('my-modal')
// 구독
const unsubscribe = modalStore.subscribe((state) => {
console.log('State changed:', state)
})
// 부모-자식 관계
const rootId = modalStore.getRootModal('child-modal')
const children = modalStore.getChildModals('parent-modal')
const descendants = modalStore.getAllDescendants('parent-modal')
CSS 커스터마이징
CSS 변수
:root {
/* Colors */
--rmm-bg: #ffffff;
--rmm-border: #e2e8f0;
--rmm-overlay-bg: rgba(0, 0, 0, 0.5);
/* Header */
--rmm-header-height: 48px;
--rmm-header-bg: #f8fafc;
--rmm-header-text: #1e293b;
--rmm-header-border: #e2e8f0;
/* Content */
--rmm-content-bg: #ffffff;
--rmm-content-text: #334155;
/* Footer */
--rmm-footer-height: 56px;
--rmm-footer-bg: #f8fafc;
--rmm-footer-border: #e2e8f0;
/* Sidebar */
--rmm-sidebar-width: 220px;
--rmm-sidebar-collapsed-width: 56px;
--rmm-sidebar-bg: #f1f5f9;
--rmm-sidebar-text: #475569;
--rmm-sidebar-hover: #e2e8f0;
--rmm-sidebar-active: #3b82f6;
--rmm-sidebar-active-bg: #dbeafe;
/* Buttons */
--rmm-btn-primary-bg: #3b82f6;
--rmm-btn-primary-text: #ffffff;
--rmm-btn-secondary-bg: #e2e8f0;
--rmm-btn-secondary-text: #334155;
--rmm-btn-danger-bg: #ef4444;
--rmm-btn-danger-text: #ffffff;
/* Taskbar */
--rmm-taskbar-bg: #1e293b;
--rmm-taskbar-text: #f1f5f9;
--rmm-taskbar-height: 40px;
/* Shadows */
--rmm-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
/* Animation */
--rmm-transition-duration: 200ms;
}
다크 모드
.dark {
--rmm-bg: #1e293b;
--rmm-border: #334155;
--rmm-header-bg: #0f172a;
--rmm-header-text: #f1f5f9;
--rmm-content-bg: #1e293b;
--rmm-content-text: #e2e8f0;
/* ... */
}
예제
기본 확인 모달
<%= render 'modals/confirm',
id: 'delete-confirm',
title: '삭제 확인',
message: '정말 삭제하시겠습니까?',
confirm_text: '삭제',
cancel_text: '취소' %>
동적 모달 생성
function createModal(id, title, content) {
const html = `
<div class="rmm-overlay" id="${id}-overlay"
data-controller="rmm-overlay"
data-rmm-overlay-modal-id-value="${id}"
data-rmm-overlay-close-on-click-value="true"
data-action="click->rmm-overlay#handleClick"></div>
<div id="${id}"
class="rmm-modal rmm-size-md rmm-position-center"
data-controller="rmm-modal"
data-rmm-modal-modal-id-value="${id}"
data-rmm-modal-title-value="${title}"
data-rmm-modal-draggable-value="true"
data-rmm-modal-closable-value="true"
data-action="click->rmm-modal#handleClick">
<div class="rmm-header rmm-draggable"
data-controller="rmm-header"
data-rmm-header-modal-id-value="${id}"
data-rmm-header-draggable-value="true"
data-action="mousedown->rmm-header#handleMouseDown">
<div class="rmm-header-left">
<h2 class="rmm-header-title">${title}</h2>
</div>
<div class="rmm-header-controls">
<button type="button" class="rmm-header-btn rmm-close-btn"
data-action="click->rmm-header#close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="rmm-body">
<div class="rmm-content-wrapper">
<div class="rmm-content">
${content}
</div>
</div>
</div>
</div>
`
document.body.insertAdjacentHTML('beforeend', html)
requestAnimationFrame(() => {
const element = document.getElementById(id)
const controller = application.getControllerForElementAndIdentifier(element, 'rmm-modal')
controller.open()
})
}
Turbo Frame과 함께 사용
<%= turbo_frame_tag "modal-content" do %>
<div class="rmm-content">
<%= render partial: "my_partial" %>
</div>
<% end %>
라이선스
MIT License
기여
버그 리포트와 기능 제안은 GitHub Issues를 이용해주세요.
변경 이력
v1.0.12
- 최소화된 모달 자동 복원:
openModal()호출 시 해당 모달이 이미 열려있고 최소화된 상태라면 자동으로 복원되도록 개선
v1.0.11
- 사이드바-서브메뉴 AJAX 연동 개선: 사이드바 메뉴 전환 시 활성화된 서브메뉴의 콘텐츠가 자동으로 로드되도록 개선
- 서브메뉴 컨트롤러에
forceReloadActive()메서드 추가: 외부에서 현재 활성 탭의 콘텐츠를 강제로 다시 로드할 수 있는 메서드 추가
v1.0.10
- 초기 안정화 버전