rails_modal_manager 1.0.60 → 1.0.61

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: 87d8b1b59e52bb56527c8c36d3d2848e7ee1c89eb215acaa63c94d2155f43f75
4
- data.tar.gz: ecd6594800a1efc07f7b206fb02946500dd251c4415c8eb8bfb7a800a2ec2a14
3
+ metadata.gz: '09dbfd6b3213ab34ae1874fa5e785061ae3e896415949b76fc54d8504df04420'
4
+ data.tar.gz: f64b4b8b42503cbea4dcdfcf7bf439353456f055863803af2628e39b71859b91
5
5
  SHA512:
6
- metadata.gz: 7e7917843b1919d861d35f46cbeefcb6bf59d0dcef492d4919ab9488d838f6361fa0ae994c6a8a0711d4601c14b071360ca6d0dea4a27ba7f3d8f82076860607
7
- data.tar.gz: b9c82943073405e3875d96a327951ff3bb27aba67160d626f90387dbd88ad59688d254ef2eea6bc933c73e3efdc7d60c8c530a679d658f137e8888a2482f72d5
6
+ metadata.gz: de7b11bb9f2ea74aa9afb1c61dec4069e544ec98132aefc9e19f513b9baa26d246cb43d4f7de214ee9646eb6e27e81e76c176721bee0a2e0e765d0668980c8fd
7
+ data.tar.gz: 62a26a444c72824cc5964d14004bdf879505b983bd2016a769d2d53c5998b05107ec17ef4974a28e80f32f29b139024e8338411ce737cb673f8661debe9fa224
@@ -2418,3 +2418,36 @@
2418
2418
  visibility: hidden !important;
2419
2419
  }
2420
2420
  }
2421
+
2422
+
2423
+ /* ============================================
2424
+ v1.0.61 — Shell (placeholder) modal
2425
+ openModalShell() 로 띄운 틀과, 틀을 제자리 교체할 때의 «애니메이션 없이 즉시» 표시
2426
+ ============================================ */
2427
+ .rmm-modal.rmm-instant,
2428
+ .rmm-overlay.rmm-instant {
2429
+ transition: none !important;
2430
+ }
2431
+
2432
+ .rmm-shell-skeleton {
2433
+ padding: 20px;
2434
+ display: flex;
2435
+ flex-direction: column;
2436
+ gap: 12px;
2437
+ }
2438
+
2439
+ .rmm-shell-line {
2440
+ height: 14px;
2441
+ border-radius: 6px;
2442
+ background: linear-gradient(90deg, #eef2f6 25%, #f8fafc 37%, #eef2f6 63%);
2443
+ background-size: 400% 100%;
2444
+ animation: rmm-shell-shimmer 1.4s ease infinite;
2445
+ }
2446
+ .rmm-shell-line-sm { width: 35%; }
2447
+ .rmm-shell-line-md { width: 60%; }
2448
+ .rmm-shell-line-lg { width: 90%; }
2449
+
2450
+ @keyframes rmm-shell-shimmer {
2451
+ 0% { background-position: 100% 50%; }
2452
+ 100% { background-position: 0 50%; }
2453
+ }
@@ -42,6 +42,38 @@ module RailsModalManager
42
42
  )
43
43
  end
44
44
 
45
+ # Render the shell (placeholder) modal template — v1.0.61+
46
+ # Render once in the layout. openModalShell() clones it to show a modal frame immediately
47
+ # while the real modal is fetched; replaceModalShell() swaps it in place (same history entry).
48
+ # The block is the loading body (skeleton); a built-in skeleton is used when omitted.
49
+ #
50
+ # @example In layout
51
+ # <%= rmm_modal_shell_template %>
52
+ # <%= rmm_modal_shell_template { render "shared/modal_skeleton" } %>
53
+ #
54
+ def rmm_modal_shell_template(options = {}, &block)
55
+ content = block_given? ? capture(&block) : rmm_default_shell_skeleton
56
+ modal = render(
57
+ partial: "rails_modal_manager/modal",
58
+ locals: {
59
+ title: "__RMM_SHELL_TITLE__",
60
+ size: "md",
61
+ draggable: false,
62
+ resizable: false,
63
+ minimizable: false,
64
+ close_on_overlay: true,
65
+ enable_history_stack: true
66
+ }.merge(options).merge(modal_id: "__RMM_SHELL__", block_content: content)
67
+ )
68
+ content_tag(:template, modal, id: "rmm-modal-shell-template")
69
+ end
70
+
71
+ def rmm_default_shell_skeleton
72
+ content_tag(:div, class: "rmm-shell-skeleton", "aria-hidden": "true") do
73
+ safe_join(%w[lg md lg sm md lg].map { |w| content_tag(:div, "", class: "rmm-shell-line rmm-shell-line-#{w}") })
74
+ end
75
+ end
76
+
45
77
  # Render the global taskbar component
46
78
  # Should be included once in the layout, typically at the end of body
47
79
  #
@@ -148,8 +148,17 @@ export default class extends Controller {
148
148
  return // Already open
149
149
  }
150
150
 
151
+ // v1.0.61+: 자리표시(shell) 모달 인계 — replaceModalShell() 이 data-rmm-adopt-shell 로 지정.
152
+ // 틀이 이미 차지한 브라우저 history 칸과 포커스 복귀 대상을 넘겨받고, 틀은 애니메이션 없이 걷어낸다.
153
+ const adoptShellId = this.element.dataset.rmmAdoptShell
154
+ let shell = null
155
+ if (adoptShellId) {
156
+ delete this.element.dataset.rmmAdoptShell
157
+ shell = this._takeOverShell(adoptShellId, modalId)
158
+ }
159
+
151
160
  // Save previous focus
152
- this.previousFocus = document.activeElement
161
+ this.previousFocus = shell ? shell.previousFocus : document.activeElement
153
162
 
154
163
  // Check for saved size (use persistentId or modalId)
155
164
  let initialSize = this.sizeValue
@@ -196,8 +205,9 @@ export default class extends Controller {
196
205
  historyStackManager.addHisData('modal', modalId, () => this.close('history'))
197
206
  }
198
207
 
199
- // Show modal with animation
200
- this.showModal()
208
+ // Show modal with animation (틀 인계 시에는 제자리 교체 — 열림 애니메이션 없이 즉시)
209
+ this.showModal({ instant: !!shell })
210
+ if (shell) shell.remove()
201
211
 
202
212
  // Add event listeners
203
213
  this.addEventListeners()
@@ -372,7 +382,7 @@ export default class extends Controller {
372
382
  // Animation
373
383
  // ============================================
374
384
 
375
- showModal() {
385
+ showModal({ instant = false } = {}) {
376
386
  const modal = this.element
377
387
  const overlay = this.getOverlay()
378
388
 
@@ -382,8 +392,7 @@ export default class extends Controller {
382
392
  // Apply depth class for visual hierarchy (parent/child/grandchild)
383
393
  this.applyDepthClass()
384
394
 
385
- // Start animation
386
- requestAnimationFrame(() => {
395
+ const activate = () => {
387
396
  modal.classList.add('rmm-active')
388
397
  if (overlay) overlay.classList.add('rmm-active')
389
398
 
@@ -391,7 +400,54 @@ export default class extends Controller {
391
400
  if (this.focusTrapValue) {
392
401
  this.setupFocusTrap()
393
402
  }
394
- })
403
+ }
404
+
405
+ if (instant) {
406
+ // v1.0.61+: 틀(shell) 교체 — 같은 태스크 안에서 틀 제거와 함께 반영돼 중간 프레임이 그려지지 않는다
407
+ modal.classList.add('rmm-instant')
408
+ if (overlay) overlay.classList.add('rmm-instant')
409
+ activate()
410
+ requestAnimationFrame(() => requestAnimationFrame(() => {
411
+ modal.classList.remove('rmm-instant')
412
+ if (overlay) overlay.classList.remove('rmm-instant')
413
+ }))
414
+ return
415
+ }
416
+
417
+ // Start animation
418
+ requestAnimationFrame(activate)
419
+ }
420
+
421
+ /**
422
+ * v1.0.61+: 자리표시(shell) 모달의 자리를 넘겨받는다.
423
+ * - history: 틀이 push 한 칸을 그대로 두고 id·닫기 함수만 이 모달로 이관(replaceHisData)
424
+ * - store: 틀을 조용히 제거(z-index 는 이 모달이 이어서 받는다)
425
+ * 틀이 이미 닫혔으면(뒤로가기·ESC 등) null — 호출 측(replaceModalShell)이 먼저 걸러낸다.
426
+ * @returns {{ previousFocus: Element|null, remove: Function }|null}
427
+ */
428
+ _takeOverShell(shellId, modalId) {
429
+ if (!modalStore.getModalConfig(shellId)) return null
430
+ const shellEl = document.getElementById(shellId)
431
+ const shellCtrl = shellEl && this.application.getControllerForElementAndIdentifier(shellEl, 'rmm-modal')
432
+ const previousFocus = shellCtrl ? shellCtrl.previousFocus : null
433
+
434
+ if (this.enableHistoryStackValue) {
435
+ historyStackManager.replaceHisData('modal', shellId, modalId, () => this.close('history'))
436
+ } else {
437
+ // 이 모달은 history 를 쓰지 않는다 — 틀의 칸은 일반 닫기처럼 되돌린다
438
+ historyStackManager.removeHisData('modal', shellId)
439
+ }
440
+ modalStore.unregisterCloseCallback(shellId)
441
+ modalStore.closeModal(shellId)
442
+
443
+ return {
444
+ previousFocus,
445
+ remove: () => {
446
+ const overlayEl = document.getElementById(`${shellId}-overlay`)
447
+ if (overlayEl) overlayEl.remove()
448
+ if (shellEl) shellEl.remove()
449
+ }
450
+ }
395
451
  }
396
452
 
397
453
  /**
@@ -199,6 +199,33 @@ function silentRemoveHisData(type, id) {
199
199
  return true;
200
200
  }
201
201
 
202
+ /**
203
+ * Hand an existing history entry over to another target without touching browser history (v1.0.61+).
204
+ * Used when a placeholder («shell») modal is swapped for the real modal: the pushState made when the
205
+ * shell opened stays as-is, only the entry's id and close function move to the real modal, so the
206
+ * back button closes the real modal and no orphan history state is left behind.
207
+ * @param {string} type - Target type ('modal', ...)
208
+ * @param {string} oldId - Current owner id
209
+ * @param {string} newId - New owner id
210
+ * @param {Function} close - New close function (optional)
211
+ * @returns {boolean} Whether the entry was found and handed over
212
+ */
213
+ function replaceHisData(type, oldId, newId, close) {
214
+ if (typeof window === 'undefined') return false;
215
+ if (!isEnabledForDevice(type)) return false;
216
+
217
+ const hisData = loadHisData();
218
+ const item = hisData.find((entry) => entry.type === type && entry.id === oldId);
219
+ if (!item) return false;
220
+
221
+ if (item.closeKey) closeFunctions.delete(item.closeKey);
222
+ item.id = newId;
223
+ item.closeKey = generateUUID();
224
+ if (close) closeFunctions.set(item.closeKey, close);
225
+ saveHisData(hisData);
226
+ return true;
227
+ }
228
+
202
229
  /**
203
230
  * Remove multiple items from history stack at once
204
231
  * - Remove all specified items from stack
@@ -392,6 +419,7 @@ const historyStackManager = {
392
419
  removeHisData,
393
420
  removeMultipleHisData,
394
421
  silentRemoveHisData,
422
+ replaceHisData,
395
423
  popHisData,
396
424
  moveToTop,
397
425
 
@@ -8,7 +8,7 @@
8
8
  * @license MIT
9
9
  */
10
10
 
11
- export const VERSION = "1.0.34"
11
+ export const VERSION = "1.0.61"
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"
@@ -192,6 +192,134 @@ export function closeModal(modalId, source = 'programmatic') {
192
192
  }
193
193
  }
194
194
 
195
+ // ============================================
196
+ // Shell (placeholder) modal — v1.0.61+
197
+ // ============================================
198
+ // 서버 응답을 기다리는 동안 «모달 틀»을 먼저 띄우고, 실제 모달이 오면 그 자리를 제자리 교체한다.
199
+ // 틀 마크업은 레이아웃에 한 번 렌더한 rmm_modal_shell_template(<template>)을 복제한다 — gem 의
200
+ // 모달 파셜 그대로이므로 헤더·오버레이·크기 규칙이 실제 모달과 같다.
201
+
202
+ const SHELL_TEMPLATE_ID = 'rmm-modal-shell-template'
203
+ const SHELL_ID_TOKEN = '__RMM_SHELL__'
204
+ const SHELL_TITLE_TOKEN = '__RMM_SHELL_TITLE__'
205
+
206
+ function escapeHtml(value) {
207
+ return String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
208
+ }
209
+
210
+ /**
211
+ * Whether shell modals are available (the layout rendered rmm_modal_shell_template)
212
+ * @returns {boolean}
213
+ */
214
+ export function modalShellAvailable() {
215
+ return !!document.getElementById(SHELL_TEMPLATE_ID)
216
+ }
217
+
218
+ /**
219
+ * Open a placeholder («shell») modal immediately while the real modal is being fetched.
220
+ * Pass the same size/height/persistentId as the real modal so the swap does not jump.
221
+ * @param {string} shellId - Unique id for the shell (e.g. `${modalId}-shell`)
222
+ * @param {Object} options
223
+ * @param {string} options.title - Header title while loading
224
+ * @param {string} options.size - 'fit'|'xss'|'xs'|'sm'|'md'|'lg'|'xl'|'full'
225
+ * @param {string} options.height - e.g. '90vh'
226
+ * @param {string} options.persistentId - Same persistent_id as the real modal (saved size is shared)
227
+ * @param {boolean} options.mobileDefaultMaximized
228
+ * @param {boolean} options.closeOnOverlay
229
+ * @returns {string|null} shellId, or null when the template is missing (caller falls back)
230
+ */
231
+ export function openModalShell(shellId, options = {}) {
232
+ const template = document.getElementById(SHELL_TEMPLATE_ID)
233
+ if (!template || !shellId) return null
234
+ // 같은 id 의 틀이 아직 열려 있으면(같은 대상 재클릭) 그대로 쓴다. 닫히는 중·잔재면 즉시 걷어낸다 —
235
+ // 닫힘 애니메이션을 기다리는 사이 새 틀을 같은 id 로 넣으면 옛 틀의 정리가 새 틀을 지울 수 있다.
236
+ if (isModalShellOpen(shellId)) return shellId
237
+ removeShellDom(shellId)
238
+
239
+ const html = template.innerHTML
240
+ .split(SHELL_ID_TOKEN).join(shellId)
241
+ .split(SHELL_TITLE_TOKEN).join(escapeHtml(options.title || ''))
242
+ const temp = document.createElement('div')
243
+ temp.innerHTML = html
244
+ const modal = temp.querySelector(`#${CSS.escape(shellId)}`)
245
+ if (!modal) return null
246
+
247
+ const set = (name, value) => { if (value !== undefined && value !== null) modal.setAttribute(`data-rmm-modal-${name}-value`, String(value)) }
248
+ set('size', options.size)
249
+ set('height', options.height)
250
+ set('persistent-id', options.persistentId)
251
+ set('mobile-default-maximized', options.mobileDefaultMaximized)
252
+ set('close-on-overlay', options.closeOnOverlay)
253
+ const overlay = temp.querySelector(`#${CSS.escape(shellId)}-overlay`)
254
+ if (overlay && options.closeOnOverlay !== undefined) {
255
+ overlay.setAttribute('data-rmm-overlay-close-on-click-value', String(options.closeOnOverlay))
256
+ }
257
+ // connect() 가 openValue=true 를 보고 곧바로 연다
258
+ modal.setAttribute('data-rmm-modal-open-value', 'true')
259
+ modal.classList.add('rmm-shell')
260
+
261
+ const container = document.getElementById('child-modals-container') || document.body
262
+ while (temp.firstChild) container.appendChild(temp.firstChild)
263
+ return shellId
264
+ }
265
+
266
+ /**
267
+ * Whether the shell is still open (not closed by back button / ESC / overlay while loading)
268
+ * @param {string} shellId
269
+ * @returns {boolean}
270
+ */
271
+ export function isModalShellOpen(shellId) {
272
+ return !!modalStore.getModalConfig(shellId)
273
+ }
274
+
275
+ /**
276
+ * Swap the shell for the real modal in place. The real modal must already be in the DOM (closed).
277
+ * The real modal inherits the shell's history entry, focus-return target and stacking slot, and
278
+ * appears without the opening animation.
279
+ * @param {string} shellId
280
+ * @param {string} modalId - The real modal's id
281
+ * @returns {boolean} false when the shell was already closed — the caller should discard the real modal
282
+ */
283
+ export function replaceModalShell(shellId, modalId) {
284
+ const modalElement = document.getElementById(modalId)
285
+ if (!modalElement) return false
286
+ if (!isModalShellOpen(shellId)) {
287
+ discardModalShell(shellId)
288
+ return false
289
+ }
290
+ modalElement.dataset.rmmAdoptShell = shellId
291
+ openModal(modalId)
292
+ return true
293
+ }
294
+
295
+ /**
296
+ * Close (if open) and remove a shell — e.g. when fetching the real modal failed.
297
+ * @param {string} shellId
298
+ */
299
+ function removeShellDom(shellId) {
300
+ const overlay = document.getElementById(`${shellId}-overlay`)
301
+ const shell = document.getElementById(shellId)
302
+ if (overlay) overlay.remove()
303
+ if (shell) shell.remove()
304
+ }
305
+
306
+ export function discardModalShell(shellId) {
307
+ const shell = document.getElementById(shellId)
308
+ if (!shell) return
309
+ // 정리 대상은 지금 시점의 요소로 고정(나중에 같은 id 로 들어온 새 틀을 건드리지 않게)
310
+ const overlay = document.getElementById(`${shellId}-overlay`)
311
+ const removeDom = () => {
312
+ if (overlay && overlay.parentNode) overlay.remove()
313
+ if (shell.parentNode) shell.remove()
314
+ }
315
+ if (isModalShellOpen(shellId)) {
316
+ shell.addEventListener('rmm-modal:closed', removeDom, { once: true })
317
+ closeModal(shellId)
318
+ } else {
319
+ removeDom()
320
+ }
321
+ }
322
+
195
323
  /**
196
324
  * Close the topmost modal
197
325
  * @returns {string|null} The closed modal's ID or null if no modal was open
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsModalManager
4
- VERSION = "1.0.60"
4
+ VERSION = "1.0.61"
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.60
4
+ version: 1.0.61
5
5
  platform: ruby
6
6
  authors:
7
7
  - reshacs