rails_modal_manager 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e9713f9fdbf22102fe25d256b8f63a9bc67f9bb9d72580a9299e6ea4288d39e
4
- data.tar.gz: 8b5f42ba84a0e0c3afe0bbbbf6ccdebe026b8b0b17f466e3566e134aead82273
3
+ metadata.gz: 02b2719477c30526ae7b96cff7a9f494855477568ec11279bb48c6009370b3f7
4
+ data.tar.gz: d88ca69cd83c2db76f2b5c9132bb652aeacdddbcb2afdc69189b9688e35081ed
5
5
  SHA512:
6
- metadata.gz: 933aad7d32e4ed154a72462879794128cfc93a517221165e64a7fdd91a7ffb49954dd64844088fe2ea27d2fd96a908a1a8c6dd536e6e3ec2c4a987c7cbba7fa1
7
- data.tar.gz: c89c896fff7e3f584d304f6e803ab0a7cf7abec70709a39ca23869689b67ac7a6ae49d9c561d0389d3ea5f47be7b9b53fb6e8a5b1dad4a24a50df4ae8a95db1a
6
+ metadata.gz: f82434fb170f66af13e95d040dfaf4c381360c345d6cbf55e70022624733e808e2aa422eb9d4dcdd862428578f5cc217b232b5d7ecf2145493671c8b6cc02939
7
+ data.tar.gz: 5e1c4d5fe59746d264a1a99ae443f383943a30e31aa660eda90527eeff99cd81f60e120cc46eee5cf633e92c58d32e7a267cc52374a07623268fd54ebf562e11
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Rails 애플리케이션을 위한 고급 모달 매니저입니다.
4
4
  `@reshacs/react-modal-manager`에서 포팅되었습니다.
5
5
 
6
- **Version:** 1.0.6
6
+ **Version:** 1.0.10
7
7
 
8
8
  ---
9
9
 
@@ -47,7 +47,7 @@ bundle install
47
47
 
48
48
  ```javascript
49
49
  import { Application } from "@hotwired/stimulus"
50
- import { registerRMMControllers, initHistoryStack } from "rails_modal_manager"
50
+ import { registerRMMControllers, initHistoryStack, initGlobalClickHandler } from "rails_modal_manager"
51
51
 
52
52
  const application = Application.start()
53
53
 
@@ -56,6 +56,9 @@ registerRMMControllers(application)
56
56
 
57
57
  // 히스토리 스택 초기화 (브라우저 뒤로가기 지원)
58
58
  initHistoryStack()
59
+
60
+ // 글로벌 클릭 핸들러 초기화 (data-rmm-modal-id 버튼 지원)
61
+ initGlobalClickHandler()
59
62
  ```
60
63
 
61
64
  ### 4. CSS 설정
@@ -180,7 +183,7 @@ closeModal('my-modal')
180
183
  |------|------|--------|------|
181
184
  | `modal-id` | String | - | 모달 고유 ID (필수) |
182
185
  | `title` | String | "Modal" | 모달 제목 |
183
- | `size` | String | "md" | 크기 (xs, sm, md, lg, xl, full) |
186
+ | `size` | String | "md" | 크기 (fit, xss, xs, sm, md, lg, xl, full) |
184
187
  | `position` | String | "center" | 위치 (center, top, bottom, top-left, top-right, bottom-left, bottom-right) |
185
188
  | `height` | String | "auto" | 높이 (auto, 300px, 50vh 등) |
186
189
  | `draggable` | Boolean | false | 드래그 가능 여부 |
@@ -202,14 +205,16 @@ closeModal('my-modal')
202
205
 
203
206
  ### 크기 (Size)
204
207
 
205
- | 값 | 너비 | 최소 너비 |
206
- |----|------|----------|
207
- | xs | 30% | 280px |
208
- | sm | 40% | 360px |
209
- | md | 50% | 480px |
210
- | lg | 60% | 600px |
211
- | xl | 70% | 720px |
212
- | full | 100% | 100% |
208
+ | 값 | 너비 | 최소 너비 | 설명 |
209
+ |----|------|----------|------|
210
+ | fit | auto | 150px | 콘텐츠에 맞춰 자동 조절 |
211
+ | xss | 20% | 200px | 매우 작은 크기 |
212
+ | xs | 30% | 280px | 작은 크기 |
213
+ | sm | 40% | 360px | 중소 크기 |
214
+ | md | 50% | 480px | 중간 크기 (기본값) |
215
+ | lg | 60% | 600px | 큰 크기 |
216
+ | xl | 70% | 720px | 매우 큰 크기 |
217
+ | full | 100% | 100% | 전체 화면 |
213
218
 
214
219
  ---
215
220
 
@@ -41,13 +41,13 @@
41
41
  --rmm-sidebar-item-active-text: #3b82f6;
42
42
 
43
43
  /* Submenu */
44
- --rmm-submenu-bg: #f8fafc;
45
- --rmm-submenu-border: rgba(0, 0, 0, 0.1);
46
- --rmm-submenu-height: 48px;
44
+ --rmm-submenu-bg: #f1f5f9;
45
+ --rmm-submenu-container-bg: #ffffff;
46
+ --rmm-submenu-height: 56px;
47
47
  --rmm-submenu-item-text: #64748b;
48
- --rmm-submenu-item-hover: rgba(0, 0, 0, 0.05);
49
- --rmm-submenu-item-active-bg: #ffffff;
50
- --rmm-submenu-item-active-text: #3b82f6;
48
+ --rmm-submenu-item-hover-bg: rgba(0, 0, 0, 0.04);
49
+ --rmm-submenu-item-active-bg: #3b82f6;
50
+ --rmm-submenu-item-active-text: #ffffff;
51
51
 
52
52
  /* Content */
53
53
  --rmm-content-bg: #ffffff;
@@ -121,11 +121,12 @@
121
121
  --rmm-sidebar-item-hover: rgba(255, 255, 255, 0.05);
122
122
  --rmm-sidebar-item-active-bg: rgba(59, 130, 246, 0.2);
123
123
 
124
- --rmm-submenu-bg: #0f172a;
125
- --rmm-submenu-border: rgba(255, 255, 255, 0.1);
124
+ --rmm-submenu-bg: #1e293b;
125
+ --rmm-submenu-container-bg: #0f172a;
126
126
  --rmm-submenu-item-text: #94a3b8;
127
- --rmm-submenu-item-hover: rgba(255, 255, 255, 0.05);
128
- --rmm-submenu-item-active-bg: #1e293b;
127
+ --rmm-submenu-item-hover-bg: rgba(255, 255, 255, 0.08);
128
+ --rmm-submenu-item-active-bg: #3b82f6;
129
+ --rmm-submenu-item-active-text: #ffffff;
129
130
 
130
131
  --rmm-content-bg: #1e293b;
131
132
  --rmm-content-text: #f1f5f9;
@@ -201,11 +202,12 @@
201
202
  transform: scale(1) translateY(0);
202
203
  }
203
204
 
204
- /* Minimized state with fade animation */
205
+ /* Minimized state with fade animation - animates to bottom-left */
205
206
  .rmm-modal.rmm-minimized {
206
207
  opacity: 0 !important;
207
208
  visibility: hidden !important;
208
- transform: scale(0.8) translateY(20px) !important;
209
+ transform: scale(0.2) !important;
210
+ transform-origin: 0% 100% !important;
209
211
  pointer-events: none !important;
210
212
  }
211
213
 
@@ -217,6 +219,8 @@
217
219
  }
218
220
 
219
221
  /* Size variants */
222
+ .rmm-modal.rmm-size-fit { width: auto; min-width: 150px; }
223
+ .rmm-modal.rmm-size-xss { width: 20%; min-width: 200px; }
220
224
  .rmm-modal.rmm-size-xs { width: 30%; min-width: 280px; }
221
225
  .rmm-modal.rmm-size-sm { width: 40%; min-width: 360px; }
222
226
  .rmm-modal.rmm-size-md { width: 50%; min-width: 480px; }
@@ -685,6 +689,7 @@
685
689
  transition: opacity var(--rmm-animation-duration) var(--rmm-animation-timing),
686
690
  visibility var(--rmm-animation-duration) var(--rmm-animation-timing);
687
691
  z-index: 9;
692
+ cursor: pointer;
688
693
  }
689
694
 
690
695
  .rmm-sidebar:not(.rmm-sidebar-collapsed) ~ .rmm-sidebar-overlay {
@@ -694,20 +699,29 @@
694
699
  }
695
700
 
696
701
  /* ============================================
697
- Modal Submenu
702
+ Modal Submenu (Pill/Tab Style)
698
703
  ============================================ */
699
704
  .rmm-submenu {
700
705
  display: flex;
701
706
  align-items: center;
702
- gap: 4px;
703
- padding: 0 16px;
704
- height: var(--rmm-submenu-height);
705
- background: var(--rmm-submenu-bg);
706
- border-bottom: 1px solid var(--rmm-submenu-border);
707
+ justify-content: flex-start;
708
+ gap: 6px;
709
+ padding: 8px 12px;
710
+ min-height: var(--rmm-submenu-height);
711
+ background: var(--rmm-submenu-container-bg);
707
712
  overflow-x: auto;
708
713
  flex-shrink: 0;
709
714
  }
710
715
 
716
+ .rmm-submenu-inner {
717
+ display: flex;
718
+ align-items: center;
719
+ gap: 4px;
720
+ padding: 4px;
721
+ background: var(--rmm-submenu-bg);
722
+ border-radius: 8px;
723
+ }
724
+
711
725
  .rmm-submenu::-webkit-scrollbar {
712
726
  height: 4px;
713
727
  }
@@ -724,9 +738,10 @@
724
738
  .rmm-submenu-item {
725
739
  display: flex;
726
740
  align-items: center;
741
+ justify-content: center;
727
742
  gap: 6px;
728
- padding: 8px 12px;
729
- font-size: 13px;
743
+ padding: 8px 16px;
744
+ font-size: 14px;
730
745
  font-weight: 500;
731
746
  color: var(--rmm-submenu-item-text);
732
747
  background: transparent;
@@ -734,16 +749,23 @@
734
749
  border-radius: 6px;
735
750
  cursor: pointer;
736
751
  white-space: nowrap;
737
- transition: background-color 0.15s, color 0.15s;
752
+ transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
738
753
  }
739
754
 
740
- .rmm-submenu-item:hover {
741
- background: var(--rmm-submenu-item-hover);
755
+ .rmm-submenu-item:hover:not(.rmm-active):not(.rmm-disabled) {
756
+ background: var(--rmm-submenu-item-hover-bg);
757
+ color: var(--rmm-content-text);
742
758
  }
743
759
 
744
760
  .rmm-submenu-item.rmm-active {
745
761
  background: var(--rmm-submenu-item-active-bg);
746
762
  color: var(--rmm-submenu-item-active-text);
763
+ box-shadow: 0 1px 3px rgba(59, 130, 246, 0.3);
764
+ }
765
+
766
+ .rmm-submenu-item.rmm-disabled {
767
+ opacity: 0.5;
768
+ cursor: not-allowed;
747
769
  }
748
770
 
749
771
  .rmm-submenu-item-icon {
@@ -754,6 +776,90 @@
754
776
  height: 16px;
755
777
  }
756
778
 
779
+ /* ============================================
780
+ Tab Panels (Submenu Content)
781
+ ============================================ */
782
+ .rmm-tab-panel {
783
+ display: none;
784
+ }
785
+
786
+ .rmm-tab-panel.rmm-tab-panel-active {
787
+ display: block;
788
+ }
789
+
790
+ /* AJAX Content Container */
791
+ .rmm-tab-content {
792
+ min-height: 100px;
793
+ position: relative;
794
+ }
795
+
796
+ .rmm-tab-content.rmm-tab-loading {
797
+ min-height: 150px;
798
+ }
799
+
800
+ /* Loading State */
801
+ .rmm-tab-loader {
802
+ display: flex;
803
+ flex-direction: column;
804
+ align-items: center;
805
+ justify-content: center;
806
+ gap: 12px;
807
+ padding: 40px 20px;
808
+ color: var(--rmm-submenu-item-text);
809
+ font-size: 14px;
810
+ }
811
+
812
+ .rmm-spinner {
813
+ width: 32px;
814
+ height: 32px;
815
+ border: 3px solid var(--rmm-modal-border);
816
+ border-top-color: var(--rmm-btn-primary-bg);
817
+ border-radius: 50%;
818
+ animation: rmm-spin 0.8s linear infinite;
819
+ }
820
+
821
+ /* Error State */
822
+ .rmm-tab-error {
823
+ display: flex;
824
+ flex-direction: column;
825
+ align-items: center;
826
+ justify-content: center;
827
+ gap: 16px;
828
+ padding: 40px 20px;
829
+ color: var(--rmm-btn-danger-bg);
830
+ font-size: 14px;
831
+ text-align: center;
832
+ }
833
+
834
+ /* ============================================
835
+ Sidebar Panels (Sidebar Content Switching)
836
+ ============================================ */
837
+ .rmm-sidebar-panel {
838
+ display: none;
839
+ }
840
+
841
+ .rmm-sidebar-panel.rmm-sidebar-panel-active {
842
+ display: block;
843
+ }
844
+
845
+ /* ============================================
846
+ Submenu Groups (Sidebar-linked Submenus)
847
+ ============================================ */
848
+ .rmm-submenu-group {
849
+ display: none;
850
+ flex-shrink: 0;
851
+ }
852
+
853
+ .rmm-submenu-group.rmm-submenu-group-active {
854
+ display: block;
855
+ flex-shrink: 0;
856
+ }
857
+
858
+ /* Wrapper for submenu groups - contains multiple submenus */
859
+ .rmm-submenu-container {
860
+ display: contents;
861
+ }
862
+
757
863
  /* ============================================
758
864
  Modal Footer
759
865
  ============================================ */
@@ -94,12 +94,18 @@ module RailsModalManager
94
94
  # @param items [Array<Hash>] Array of item definitions
95
95
  # @return [Array<Hash>] Formatted sidebar items
96
96
  #
97
- # @example
97
+ # @example Basic sidebar
98
98
  # rmm_sidebar_items([
99
99
  # { id: "home", label: "Home", icon: "home", active: true },
100
100
  # { id: "settings", label: "Settings", icon: "settings", badge: "3" }
101
101
  # ])
102
102
  #
103
+ # @example With panel and submenu linking
104
+ # rmm_sidebar_items([
105
+ # { id: "home", label: "Home", panel_id: "home-panel", submenu_id: "home-submenu", active: true },
106
+ # { id: "settings", label: "Settings", panel_id: "settings-panel" }
107
+ # ])
108
+ #
103
109
  def rmm_sidebar_items(items)
104
110
  items.map do |item|
105
111
  {
@@ -107,6 +113,8 @@ module RailsModalManager
107
113
  label: item[:label],
108
114
  icon_svg: item[:icon_svg] || item[:icon],
109
115
  badge: item[:badge],
116
+ panel_id: item[:panel_id],
117
+ submenu_id: item[:submenu_id],
110
118
  active: item[:active] || false,
111
119
  disabled: item[:disabled] || false
112
120
  }
@@ -137,6 +145,38 @@ module RailsModalManager
137
145
  end
138
146
  end
139
147
 
148
+ # Helper to create submenu items array
149
+ #
150
+ # @param items [Array<Hash>] Array of item definitions
151
+ # @return [Array<Hash>] Formatted submenu items
152
+ #
153
+ # @example Preload mode (with panel_id)
154
+ # rmm_submenu_items([
155
+ # { id: "tab1", label: "Tab 1", panel_id: "panel-1", active: true },
156
+ # { id: "tab2", label: "Tab 2", panel_id: "panel-2" },
157
+ # { id: "tab3", label: "Tab 3", panel_id: "panel-3", disabled: true }
158
+ # ])
159
+ #
160
+ # @example AJAX mode (with url)
161
+ # rmm_submenu_items([
162
+ # { id: "tab1", label: "Tab 1", url: "/content/tab1", active: true },
163
+ # { id: "tab2", label: "Tab 2", url: "/content/tab2" }
164
+ # ])
165
+ #
166
+ def rmm_submenu_items(items)
167
+ items.map do |item|
168
+ {
169
+ id: item[:id],
170
+ label: item[:label],
171
+ icon_svg: item[:icon_svg] || item[:icon],
172
+ panel_id: item[:panel_id],
173
+ url: item[:url],
174
+ active: item[:active] || false,
175
+ disabled: item[:disabled] || false
176
+ }
177
+ end
178
+ end
179
+
140
180
  # Common icon SVGs for sidebar/submenu
141
181
  module Icons
142
182
  def self.home
@@ -11,10 +11,22 @@ export default class extends Controller {
11
11
  defaultSize: { type: String, default: "md" },
12
12
  }
13
13
 
14
- static targets = ["maximizeBtn"]
14
+ static targets = ["maximizeBtn", "sidebarToggle"]
15
15
 
16
16
  connect() {
17
- // Header controller connected
17
+ // Listen for sidebar toggled event to update icon
18
+ this.handleSidebarToggled = this.handleSidebarToggled.bind(this)
19
+ const modal = this.element.closest('.rmm-modal')
20
+ if (modal) {
21
+ modal.addEventListener('rmm-sidebar:toggled', this.handleSidebarToggled)
22
+ }
23
+ }
24
+
25
+ disconnect() {
26
+ const modal = this.element.closest('.rmm-modal')
27
+ if (modal) {
28
+ modal.removeEventListener('rmm-sidebar:toggled', this.handleSidebarToggled)
29
+ }
18
30
  }
19
31
 
20
32
  // Helper method to find the modal element
@@ -123,4 +135,27 @@ export default class extends Controller {
123
135
  controller.changeSize(newSize)
124
136
  }
125
137
  }
138
+
139
+ // Toggle sidebar and dispatch event
140
+ toggleSidebar() {
141
+ const modal = this.getModalElement()
142
+ if (!modal) return
143
+
144
+ const sidebar = modal.querySelector('.rmm-sidebar')
145
+ if (sidebar) {
146
+ sidebar.dispatchEvent(new CustomEvent('rmm-sidebar:toggle', { bubbles: false }))
147
+ }
148
+ }
149
+
150
+ // Handle sidebar toggled event to update icon
151
+ handleSidebarToggled(e) {
152
+ if (!this.hasSidebarToggleTarget) return
153
+
154
+ const isCollapsed = e.detail.collapsed
155
+ const collapseIcon = this.sidebarToggleTarget.querySelector('.rmm-icon-collapse')
156
+ const expandIcon = this.sidebarToggleTarget.querySelector('.rmm-icon-expand')
157
+
158
+ if (collapseIcon) collapseIcon.style.display = isCollapsed ? 'none' : 'block'
159
+ if (expandIcon) expandIcon.style.display = isCollapsed ? 'block' : 'none'
160
+ }
126
161
  }
@@ -369,7 +369,7 @@ export default class extends Controller {
369
369
  const posConfig = POSITION_CONFIG[config.position] || POSITION_CONFIG.center
370
370
 
371
371
  // Update size CSS class
372
- const sizeClasses = ['xs', 'sm', 'md', 'lg', 'xl', 'full']
372
+ const sizeClasses = ['fit', 'xss', 'xs', 'sm', 'md', 'lg', 'xl', 'full']
373
373
  sizeClasses.forEach(s => modal.classList.remove(`rmm-size-${s}`))
374
374
  modal.classList.add(`rmm-size-${config.size}`)
375
375
 
@@ -3,6 +3,23 @@ import modalStore from "rails_modal_manager/modal_store"
3
3
 
4
4
  /**
5
5
  * Rails Modal Manager - Sidebar Controller
6
+ *
7
+ * Supports content panel switching and submenu integration.
8
+ *
9
+ * Usage:
10
+ * ```html
11
+ * <div class="rmm-sidebar" data-controller="rmm-sidebar">
12
+ * <button data-item-id="home" data-panel-id="home-panel" data-submenu-id="home-submenu">Home</button>
13
+ * <button data-item-id="settings" data-panel-id="settings-panel">Settings (no submenu)</button>
14
+ * </div>
15
+ *
16
+ * <!-- Panels -->
17
+ * <div id="home-panel" class="rmm-sidebar-panel rmm-sidebar-panel-active">...</div>
18
+ * <div id="settings-panel" class="rmm-sidebar-panel">...</div>
19
+ *
20
+ * <!-- Submenus -->
21
+ * <div id="home-submenu" class="rmm-submenu-group rmm-submenu-group-active">...</div>
22
+ * ```
6
23
  */
7
24
  export default class extends Controller {
8
25
  static targets = ["nav", "overlay"]
@@ -12,10 +29,18 @@ export default class extends Controller {
12
29
  }
13
30
 
14
31
  connect() {
32
+ this.currentItemId = null
33
+
15
34
  if (this.collapsedValue) {
16
35
  this.element.classList.add('rmm-sidebar-collapsed')
17
36
  }
18
37
 
38
+ // Find initial active item
39
+ const activeItem = this.element.querySelector('.rmm-sidebar-item.rmm-active')
40
+ if (activeItem) {
41
+ this.currentItemId = activeItem.dataset.itemId
42
+ }
43
+
19
44
  // Listen for toggle event from header button
20
45
  this.handleToggleEvent = () => this.toggle()
21
46
  this.element.addEventListener('rmm-sidebar:toggle', this.handleToggleEvent)
@@ -33,20 +58,43 @@ export default class extends Controller {
33
58
  modalStore.updateModal(this.modalIdValue, {
34
59
  sidebarOpen: !this.collapsedValue,
35
60
  })
61
+
62
+ // Dispatch toggled event so toggle button can update its icon
63
+ const modal = this.element.closest('.rmm-modal')
64
+ if (modal) {
65
+ modal.dispatchEvent(new CustomEvent('rmm-sidebar:toggled', {
66
+ detail: { collapsed: this.collapsedValue },
67
+ bubbles: false
68
+ }))
69
+ }
36
70
  }
37
71
 
38
72
  selectItem(e) {
39
73
  e.preventDefault()
40
74
  e.stopPropagation()
41
75
 
76
+ const item = e.currentTarget
77
+ const itemId = item.dataset.itemId
78
+
79
+ // Skip if already active
80
+ if (itemId === this.currentItemId) {
81
+ return
82
+ }
83
+
42
84
  // Remove active from all items
43
- this.element.querySelectorAll('.rmm-sidebar-item').forEach(item => {
44
- item.classList.remove('rmm-active')
85
+ this.element.querySelectorAll('.rmm-sidebar-item').forEach(el => {
86
+ el.classList.remove('rmm-active')
45
87
  })
46
88
 
47
89
  // Add active to clicked item
48
- const item = e.currentTarget
49
90
  item.classList.add('rmm-active')
91
+ this.currentItemId = itemId
92
+
93
+ // Switch content panel
94
+ this.switchPanel(item)
95
+
96
+ // Switch submenu group
97
+ this.switchSubmenuGroup(item)
50
98
 
51
99
  // On mobile, close sidebar after selection
52
100
  if (window.innerWidth < 768) {
@@ -60,10 +108,75 @@ export default class extends Controller {
60
108
  modalId: this.modalIdValue,
61
109
  itemId: item.dataset.itemId,
62
110
  itemLabel: item.dataset.itemLabel,
111
+ hasSubmenu: !!item.dataset.submenuId,
63
112
  }
64
113
  })
65
114
  }
66
115
 
116
+ /**
117
+ * Switch content panel based on sidebar selection
118
+ */
119
+ switchPanel(item) {
120
+ const panelId = item.dataset.panelId
121
+ if (!panelId) return
122
+
123
+ const modal = this.element.closest('.rmm-modal')
124
+ if (!modal) return
125
+
126
+ // Hide all sidebar panels
127
+ modal.querySelectorAll('.rmm-sidebar-panel').forEach(panel => {
128
+ panel.classList.remove('rmm-sidebar-panel-active')
129
+ })
130
+
131
+ // Show target panel
132
+ const targetPanel = modal.querySelector(`#${panelId}`)
133
+ if (targetPanel) {
134
+ targetPanel.classList.add('rmm-sidebar-panel-active')
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Switch submenu group based on sidebar selection
140
+ */
141
+ switchSubmenuGroup(item) {
142
+ const submenuId = item.dataset.submenuId
143
+ const modal = this.element.closest('.rmm-modal')
144
+ if (!modal) return
145
+
146
+ // Hide all submenu groups
147
+ modal.querySelectorAll('.rmm-submenu-group').forEach(group => {
148
+ group.classList.remove('rmm-submenu-group-active')
149
+ })
150
+
151
+ // Show target submenu group (if exists)
152
+ if (submenuId) {
153
+ const targetSubmenu = modal.querySelector(`#${submenuId}`)
154
+ if (targetSubmenu) {
155
+ targetSubmenu.classList.add('rmm-submenu-group-active')
156
+
157
+ // Auto-select first submenu item if none is active
158
+ const submenuController = targetSubmenu.querySelector('[data-controller="rmm-submenu"]')
159
+ if (submenuController) {
160
+ const activeSubmenuItem = submenuController.querySelector('.rmm-submenu-item.rmm-active')
161
+ if (!activeSubmenuItem) {
162
+ const firstItem = submenuController.querySelector('.rmm-submenu-item')
163
+ if (firstItem) {
164
+ firstItem.click()
165
+ }
166
+ } else {
167
+ // Ensure the panel for active submenu item is shown
168
+ const panelId = activeSubmenuItem.dataset.panelId
169
+ if (panelId) {
170
+ modal.querySelectorAll('.rmm-tab-panel').forEach(p => p.classList.remove('rmm-tab-panel-active'))
171
+ const panel = modal.querySelector(`#${panelId}`)
172
+ if (panel) panel.classList.add('rmm-tab-panel-active')
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+
67
180
  // Mobile overlay click
68
181
  closeOverlay() {
69
182
  this.collapsedValue = true
@@ -2,21 +2,80 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  /**
4
4
  * Rails Modal Manager - Submenu Controller
5
+ *
6
+ * Supports two content loading modes:
7
+ * 1. preload (default): All panels are pre-rendered, show/hide on tab switch
8
+ * 2. ajax: Load content from URL when tab is selected
9
+ *
10
+ * Usage:
11
+ *
12
+ * Preload mode (default):
13
+ * ```html
14
+ * <div class="rmm-submenu" data-controller="rmm-submenu">
15
+ * <button data-item-id="tab1" data-panel-id="panel-1">Tab 1</button>
16
+ * <button data-item-id="tab2" data-panel-id="panel-2">Tab 2</button>
17
+ * </div>
18
+ * <div class="rmm-content">
19
+ * <div id="panel-1" class="rmm-tab-panel rmm-tab-panel-active">Content 1</div>
20
+ * <div id="panel-2" class="rmm-tab-panel">Content 2</div>
21
+ * </div>
22
+ * ```
23
+ *
24
+ * AJAX mode:
25
+ * ```html
26
+ * <div class="rmm-submenu" data-controller="rmm-submenu" data-rmm-submenu-load-mode-value="ajax">
27
+ * <button data-item-id="tab1" data-url="/content/tab1">Tab 1</button>
28
+ * <button data-item-id="tab2" data-url="/content/tab2">Tab 2</button>
29
+ * </div>
30
+ * <div class="rmm-content">
31
+ * <div class="rmm-tab-content"></div>
32
+ * </div>
33
+ * ```
5
34
  */
6
35
  export default class extends Controller {
7
36
  static values = {
8
37
  modalId: String,
38
+ loadMode: { type: String, default: 'preload' }, // 'preload' or 'ajax'
39
+ cacheAjax: { type: Boolean, default: true }, // Cache AJAX responses
40
+ }
41
+
42
+ static targets = ['content']
43
+
44
+ connect() {
45
+ this.ajaxCache = new Map()
46
+ this.currentItemId = null
47
+
48
+ // Find initial active item
49
+ const activeItem = this.element.querySelector('.rmm-submenu-item.rmm-active')
50
+ if (activeItem) {
51
+ this.currentItemId = activeItem.dataset.itemId
52
+ }
9
53
  }
10
54
 
11
55
  selectItem(e) {
56
+ const item = e.currentTarget
57
+ const itemId = item.dataset.itemId
58
+
59
+ // Skip if already active
60
+ if (itemId === this.currentItemId) {
61
+ return
62
+ }
63
+
12
64
  // Remove active from all items
13
- this.element.querySelectorAll('.rmm-submenu-item').forEach(item => {
14
- item.classList.remove('rmm-active')
65
+ this.element.querySelectorAll('.rmm-submenu-item').forEach(el => {
66
+ el.classList.remove('rmm-active')
15
67
  })
16
68
 
17
69
  // Add active to clicked item
18
- const item = e.currentTarget
19
70
  item.classList.add('rmm-active')
71
+ this.currentItemId = itemId
72
+
73
+ // Handle content switching based on load mode
74
+ if (this.loadModeValue === 'ajax') {
75
+ this.loadAjaxContent(item)
76
+ } else {
77
+ this.switchPanel(item)
78
+ }
20
79
 
21
80
  // Dispatch event
22
81
  this.dispatch('itemSelect', {
@@ -24,7 +83,133 @@ export default class extends Controller {
24
83
  modalId: this.modalIdValue,
25
84
  itemId: item.dataset.itemId,
26
85
  itemLabel: item.dataset.itemLabel,
86
+ loadMode: this.loadModeValue,
27
87
  }
28
88
  })
29
89
  }
90
+
91
+ /**
92
+ * Preload mode: Switch between pre-rendered panels
93
+ */
94
+ switchPanel(item) {
95
+ const panelId = item.dataset.panelId
96
+ if (!panelId) return
97
+
98
+ // Find the content container (modal's content area)
99
+ const modal = this.element.closest('.rmm-modal')
100
+ if (!modal) return
101
+
102
+ // Hide all panels
103
+ modal.querySelectorAll('.rmm-tab-panel').forEach(panel => {
104
+ panel.classList.remove('rmm-tab-panel-active')
105
+ })
106
+
107
+ // Show target panel
108
+ const targetPanel = modal.querySelector(`#${panelId}`)
109
+ if (targetPanel) {
110
+ targetPanel.classList.add('rmm-tab-panel-active')
111
+ }
112
+ }
113
+
114
+ /**
115
+ * AJAX mode: Load content from URL
116
+ */
117
+ async loadAjaxContent(item) {
118
+ const url = item.dataset.url
119
+ if (!url) return
120
+
121
+ const modal = this.element.closest('.rmm-modal')
122
+ if (!modal) return
123
+
124
+ // Find content container
125
+ const contentContainer = modal.querySelector('.rmm-tab-content')
126
+ if (!contentContainer) return
127
+
128
+ // Check cache
129
+ if (this.cacheAjaxValue && this.ajaxCache.has(url)) {
130
+ contentContainer.innerHTML = this.ajaxCache.get(url)
131
+ this.dispatch('contentLoaded', {
132
+ detail: { modalId: this.modalIdValue, itemId: item.dataset.itemId, fromCache: true }
133
+ })
134
+ return
135
+ }
136
+
137
+ // Show loading state
138
+ contentContainer.classList.add('rmm-tab-loading')
139
+ contentContainer.innerHTML = '<div class="rmm-tab-loader"><div class="rmm-spinner"></div><span>로딩 중...</span></div>'
140
+
141
+ try {
142
+ const response = await fetch(url, {
143
+ headers: {
144
+ 'Accept': 'text/html',
145
+ 'X-Requested-With': 'XMLHttpRequest'
146
+ }
147
+ })
148
+
149
+ if (!response.ok) {
150
+ throw new Error(`HTTP error! status: ${response.status}`)
151
+ }
152
+
153
+ const html = await response.text()
154
+
155
+ // Cache the response
156
+ if (this.cacheAjaxValue) {
157
+ this.ajaxCache.set(url, html)
158
+ }
159
+
160
+ // Update content
161
+ contentContainer.innerHTML = html
162
+ contentContainer.classList.remove('rmm-tab-loading')
163
+
164
+ this.dispatch('contentLoaded', {
165
+ detail: { modalId: this.modalIdValue, itemId: item.dataset.itemId, fromCache: false }
166
+ })
167
+ } catch (error) {
168
+ console.error('RMM Submenu: Failed to load content', error)
169
+ contentContainer.innerHTML = `
170
+ <div class="rmm-tab-error">
171
+ <span>컨텐츠를 불러오는데 실패했습니다.</span>
172
+ <button type="button" class="rmm-btn rmm-btn-secondary" onclick="this.closest('.rmm-modal').querySelector('.rmm-submenu-item.rmm-active').click()">
173
+ 다시 시도
174
+ </button>
175
+ </div>
176
+ `
177
+ contentContainer.classList.remove('rmm-tab-loading')
178
+
179
+ this.dispatch('contentError', {
180
+ detail: { modalId: this.modalIdValue, itemId: item.dataset.itemId, error: error.message }
181
+ })
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Clear AJAX cache (can be called programmatically)
187
+ */
188
+ clearCache() {
189
+ this.ajaxCache.clear()
190
+ }
191
+
192
+ /**
193
+ * Clear cache for specific URL
194
+ */
195
+ clearCacheFor(url) {
196
+ this.ajaxCache.delete(url)
197
+ }
198
+
199
+ /**
200
+ * Reload current tab content (useful for AJAX mode)
201
+ */
202
+ reloadCurrentTab() {
203
+ const activeItem = this.element.querySelector('.rmm-submenu-item.rmm-active')
204
+ if (activeItem) {
205
+ if (this.loadModeValue === 'ajax') {
206
+ // Clear cache for this URL and reload
207
+ const url = activeItem.dataset.url
208
+ if (url) {
209
+ this.ajaxCache.delete(url)
210
+ }
211
+ this.loadAjaxContent(activeItem)
212
+ }
213
+ }
214
+ }
30
215
  }
@@ -8,7 +8,7 @@
8
8
  * @license MIT
9
9
  */
10
10
 
11
- export const VERSION = "1.0.3"
11
+ export const VERSION = "1.0.10"
12
12
 
13
13
  // Import core modules for internal use
14
14
  import modalStore, { MODAL_CONSTANTS, SIZE_CONFIG, POSITION_CONFIG, CASCADE_OFFSET, modalSizeStorage, modalUtils } from "rails_modal_manager/modal_store"
@@ -59,6 +59,25 @@ export function initHistoryStack() {
59
59
  return historyStackManager.init()
60
60
  }
61
61
 
62
+ /**
63
+ * Initialize global click handler for modal open buttons
64
+ * Handles clicks on elements with data-rmm-modal-id attribute
65
+ * Should be called once on page load
66
+ */
67
+ export function initGlobalClickHandler() {
68
+ document.addEventListener('click', (e) => {
69
+ // Find the closest element with data-rmm-modal-id
70
+ const trigger = e.target.closest('[data-rmm-modal-id]')
71
+ if (!trigger) return
72
+
73
+ const modalId = trigger.dataset.rmmModalId
74
+ if (!modalId) return
75
+
76
+ e.preventDefault()
77
+ openModal(modalId)
78
+ })
79
+ }
80
+
62
81
  /**
63
82
  * Open a modal by ID
64
83
  * @param {string} modalId - The modal's ID
@@ -24,6 +24,8 @@ export const CASCADE_OFFSET = { x: 30, y: 30 };
24
24
 
25
25
  // Size configurations (matching React version)
26
26
  export const SIZE_CONFIG = {
27
+ fit: { width: 'auto', minWidth: '150px', height: 'auto' },
28
+ xss: { width: '20%', minWidth: '200px', height: 'auto' },
27
29
  xs: { width: '30%', minWidth: '280px', height: 'auto' },
28
30
  sm: { width: '40%', minWidth: '360px', height: 'auto' },
29
31
  md: { width: '50%', minWidth: '480px', height: 'auto' },
@@ -330,8 +332,10 @@ class ModalStore {
330
332
  // ============================================
331
333
 
332
334
  minimizeAll() {
335
+ const minimizedAt = Date.now();
333
336
  Object.keys(this.activeModals).forEach(modalId => {
334
337
  this.activeModals[modalId].isMinimized = true;
338
+ this.activeModals[modalId].minimizedAt = minimizedAt;
335
339
  });
336
340
  this.updateBodyScroll();
337
341
  this.notify();
@@ -443,10 +447,12 @@ class ModalStore {
443
447
  const rootId = this.getRootModal(modalId);
444
448
  const descendants = this.getAllDescendants(rootId);
445
449
  const allModalIds = [rootId, ...descendants];
450
+ const minimizedAt = Date.now();
446
451
 
447
452
  allModalIds.forEach(id => {
448
453
  if (this.activeModals[id]) {
449
454
  this.activeModals[id].isMinimized = true;
455
+ this.activeModals[id].minimizedAt = minimizedAt;
450
456
  }
451
457
  });
452
458
 
@@ -489,7 +495,7 @@ class ModalStore {
489
495
  [rootId, ...descendants].forEach(id => processedModals.add(id));
490
496
  });
491
497
 
492
- return Array.from(minimizedRoots).map(rootId => {
498
+ const groups = Array.from(minimizedRoots).map(rootId => {
493
499
  const descendants = this.getAllDescendants(rootId);
494
500
  const allModalIds = [rootId, ...descendants];
495
501
 
@@ -499,6 +505,7 @@ class ModalStore {
499
505
 
500
506
  const leafModalId = sortedByOrder[sortedByOrder.length - 1] || rootId;
501
507
  const leafConfig = this.activeModals[leafModalId];
508
+ const rootConfig = this.activeModals[rootId];
502
509
 
503
510
  return {
504
511
  rootModalId: rootId,
@@ -507,8 +514,12 @@ class ModalStore {
507
514
  groupSize: allModalIds.length,
508
515
  zIndex: leafConfig?.zIndex || 0,
509
516
  modalIds: allModalIds,
517
+ minimizedAt: rootConfig?.minimizedAt || 0,
510
518
  };
511
519
  });
520
+
521
+ // Sort by minimizedAt timestamp (oldest first)
522
+ return groups.sort((a, b) => a.minimizedAt - b.minimizedAt);
512
523
  }
513
524
  }
514
525
 
@@ -64,12 +64,14 @@
64
64
  <% if show_sidebar_toggle %>
65
65
  <button type="button"
66
66
  class="rmm-header-btn rmm-sidebar-toggle"
67
- data-action="click->rmm-sidebar#toggle"
68
- title="Toggle Sidebar">
69
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
70
- <line x1="3" y1="12" x2="21" y2="12"></line>
71
- <line x1="3" y1="6" x2="21" y2="6"></line>
72
- <line x1="3" y1="18" x2="21" y2="18"></line>
67
+ data-rmm-header-target="sidebarToggle"
68
+ data-action="click->rmm-header#toggleSidebar"
69
+ title="사이드바 토글">
70
+ <svg class="rmm-icon-collapse" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
71
+ <polyline points="15 18 9 12 15 6"></polyline>
72
+ </svg>
73
+ <svg class="rmm-icon-expand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;">
74
+ <polyline points="9 18 15 12 9 6"></polyline>
73
75
  </svg>
74
76
  </button>
75
77
  <% end %>
@@ -77,6 +79,17 @@
77
79
  </div>
78
80
 
79
81
  <div class="rmm-header-controls">
82
+ <% if minimizable %>
83
+ <button type="button"
84
+ class="rmm-header-btn"
85
+ data-action="click->rmm-header#minimize"
86
+ title="최소화">
87
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
88
+ <line x1="5" y1="12" x2="19" y2="12"></line>
89
+ </svg>
90
+ </button>
91
+ <% end %>
92
+
80
93
  <% if show_size_controls %>
81
94
  <%# Default size button - restores to original size %>
82
95
  <button type="button"
@@ -109,17 +122,6 @@
109
122
  </button>
110
123
  <% end %>
111
124
 
112
- <% if minimizable %>
113
- <button type="button"
114
- class="rmm-header-btn"
115
- data-action="click->rmm-header#minimize"
116
- title="최소화">
117
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
118
- <line x1="5" y1="12" x2="19" y2="12"></line>
119
- </svg>
120
- </button>
121
- <% end %>
122
-
123
125
  <%# Custom header buttons %>
124
126
  <% header_buttons.each do |btn| %>
125
127
  <%
@@ -27,6 +27,7 @@
27
27
  min_height: Number - Minimum height in px (default: 150)
28
28
  max_width: Number - Maximum width in px (default: nil)
29
29
  max_height: Number - Maximum height in px (default: nil)
30
+ height: String - Modal height (default: "auto", e.g. "400px", "50vh")
30
31
  animation_duration: Number - Animation duration in ms (default: 200)
31
32
  show_header: Boolean - Show header (default: true)
32
33
  show_footer: Boolean - Show footer (default: false)
@@ -67,6 +68,7 @@
67
68
  min_height ||= 150
68
69
  max_width ||= nil
69
70
  max_height ||= nil
71
+ height ||= "auto"
70
72
  animation_duration ||= 200
71
73
  show_header = true if local_assigns[:show_header].nil?
72
74
  show_footer ||= false
@@ -108,6 +110,7 @@
108
110
  rmm_modal_prevent_body_scroll_value: prevent_body_scroll,
109
111
  rmm_modal_min_width_value: min_width,
110
112
  rmm_modal_min_height_value: min_height,
113
+ rmm_modal_height_value: height,
111
114
  rmm_modal_animation_duration_value: animation_duration,
112
115
  action: "click->rmm-modal#handleClick"
113
116
  }
@@ -158,9 +161,9 @@
158
161
  items: sidebar_items,
159
162
  collapsed: sidebar_collapsed
160
163
  %>
164
+ <!-- Mobile overlay: closes sidebar when tapped (dispatches toggle event) -->
161
165
  <div class="rmm-sidebar-overlay"
162
- data-controller="rmm-sidebar"
163
- data-action="click->rmm-sidebar#closeOverlay"></div>
166
+ onclick="const sidebar = this.previousElementSibling; if(sidebar && sidebar.classList.contains('rmm-sidebar') && !sidebar.classList.contains('rmm-sidebar-collapsed')) { sidebar.dispatchEvent(new CustomEvent('rmm-sidebar:toggle', { bubbles: false })); }"></div>
164
167
  <% end %>
165
168
 
166
169
  <div class="rmm-content-wrapper">
@@ -3,8 +3,12 @@
3
3
 
4
4
  Locals:
5
5
  modal_id: String - The modal ID
6
- items: Array - Menu items [{id:, label:, icon_svg:, badge:, active:, disabled:}]
6
+ items: Array - Menu items [{id:, label:, icon_svg:, badge:, active:, disabled:, panel_id:, submenu_id:}]
7
7
  collapsed: Boolean - Initially collapsed (default: false)
8
+
9
+ Item options:
10
+ - panel_id: ID of the content panel to show when selected
11
+ - submenu_id: ID of the submenu group to show when selected
8
12
  %>
9
13
  <%
10
14
  modal_id ||= ""
@@ -29,6 +33,8 @@
29
33
  class="<%= item_classes.join(' ') %>"
30
34
  data-item-id="<%= item[:id] %>"
31
35
  data-item-label="<%= item[:label] %>"
36
+ <% if item[:panel_id].present? %>data-panel-id="<%= item[:panel_id] %>"<% end %>
37
+ <% if item[:submenu_id].present? %>data-submenu-id="<%= item[:submenu_id] %>"<% end %>
32
38
  data-action="click->rmm-sidebar#selectItem"
33
39
  <% if item[:disabled] %>aria-disabled="true"<% end %>>
34
40
  <% if item[:icon_svg].present? %>
@@ -1,33 +1,51 @@
1
1
  <%#
2
- Rails Modal Manager - Submenu Component
2
+ Rails Modal Manager - Submenu Component (Pill/Tab Style)
3
3
 
4
4
  Locals:
5
5
  modal_id: String - The modal ID
6
- items: Array - Menu items [{id:, label:, icon_svg:, active:, disabled:}]
6
+ items: Array - Menu items [{id:, label:, icon_svg:, active:, disabled:, panel_id:, url:}]
7
+ load_mode: String - 'preload' (default) or 'ajax'
8
+ cache_ajax: Boolean - Cache AJAX responses (default: true)
9
+
10
+ Preload mode:
11
+ - Use panel_id to link to pre-rendered content panels
12
+ - Panels should have class="rmm-tab-panel" and matching id
13
+
14
+ AJAX mode:
15
+ - Use url to specify content endpoint
16
+ - Content container should have class="rmm-tab-content"
7
17
  %>
8
18
  <%
9
19
  modal_id ||= ""
10
20
  items ||= []
21
+ load_mode ||= "preload"
22
+ cache_ajax = cache_ajax.nil? ? true : cache_ajax
11
23
  %>
12
24
  <div class="rmm-submenu"
13
25
  data-controller="rmm-submenu"
14
- data-rmm-submenu-modal-id-value="<%= modal_id %>">
15
- <% items.each do |item| %>
16
- <%
17
- item_classes = ["rmm-submenu-item"]
18
- item_classes << "rmm-active" if item[:active]
19
- item_classes << "rmm-disabled" if item[:disabled]
20
- %>
21
- <button type="button"
22
- class="<%= item_classes.join(' ') %>"
23
- data-item-id="<%= item[:id] %>"
24
- data-item-label="<%= item[:label] %>"
25
- data-action="click->rmm-submenu#selectItem"
26
- <% if item[:disabled] %>disabled<% end %>>
27
- <% if item[:icon_svg].present? %>
28
- <span class="rmm-submenu-item-icon"><%= item[:icon_svg].html_safe %></span>
29
- <% end %>
30
- <span><%= item[:label] %></span>
31
- </button>
32
- <% end %>
26
+ data-rmm-submenu-modal-id-value="<%= modal_id %>"
27
+ data-rmm-submenu-load-mode-value="<%= load_mode %>"
28
+ data-rmm-submenu-cache-ajax-value="<%= cache_ajax %>">
29
+ <div class="rmm-submenu-inner">
30
+ <% items.each do |item| %>
31
+ <%
32
+ item_classes = ["rmm-submenu-item"]
33
+ item_classes << "rmm-active" if item[:active]
34
+ item_classes << "rmm-disabled" if item[:disabled]
35
+ %>
36
+ <button type="button"
37
+ class="<%= item_classes.join(' ') %>"
38
+ data-item-id="<%= item[:id] %>"
39
+ data-item-label="<%= item[:label] %>"
40
+ <% if item[:panel_id].present? %>data-panel-id="<%= item[:panel_id] %>"<% end %>
41
+ <% if item[:url].present? %>data-url="<%= item[:url] %>"<% end %>
42
+ data-action="click->rmm-submenu#selectItem"
43
+ <% if item[:disabled] %>disabled<% end %>>
44
+ <% if item[:icon_svg].present? %>
45
+ <span class="rmm-submenu-item-icon"><%= item[:icon_svg].html_safe %></span>
46
+ <% end %>
47
+ <span><%= item[:label] %></span>
48
+ </button>
49
+ <% end %>
50
+ </div>
33
51
  </div>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsModalManager
4
- VERSION = "1.0.9"
4
+ VERSION = "1.0.10"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_modal_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.9
4
+ version: 1.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - reshacs