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 +4 -4
- data/app/assets/stylesheets/rails_modal_manager.css +33 -0
- data/app/helpers/rails_modal_manager/modal_helper.rb +32 -0
- data/app/javascript/rails_modal_manager/controllers/rmm_modal_controller.js +63 -7
- data/app/javascript/rails_modal_manager/history_stack_manager.js +28 -0
- data/app/javascript/rails_modal_manager/index.js +129 -1
- data/lib/rails_modal_manager/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '09dbfd6b3213ab34ae1874fa5e785061ae3e896415949b76fc54d8504df04420'
|
|
4
|
+
data.tar.gz: f64b4b8b42503cbea4dcdfcf7bf439353456f055863803af2628e39b71859b91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|