1import { lock, unlock } from './scroll-lock.js'; 2 3const MODAL_SHELL_MAX_WIDTH_CLASS_NAMES = Object.freeze({ 4 sm: 'md:max-w-sm', 5 md: 'md:max-w-md', 6 lg: 'md:max-w-lg', 7 xl: 'md:max-w-xl', 8 '2xl': 'md:max-w-2xl', 9 '3xl': 'md:max-w-3xl', 10 '4xl': 'md:max-w-4xl', 11}); 12 13const createModalShell = ({ 14 describedBy = '', 15 documentRef = globalThis.document, 16 labelledBy = '', 17 panelClassName = '', 18 panelId = '', 19 rootAttributes = {}, 20 rootClassName = '', 21 size = '2xl', 22 variant = 'standard', 23 zIndexClassName = 'z-59', 24} = {}) => { 25 if (!documentRef?.body || typeof HTMLElement === 'undefined' || !(documentRef.body instanceof HTMLElement)) { 26 return null; 27 } 28 29 const isFullscreen = variant === 'fullscreen'; 30 const root = documentRef.createElement('div'); 31 root.className = isFullscreen 32 ? `fixed inset-0 ${zIndexClassName} hidden bg-white ${rootClassName}` 33 : `fixed inset-0 ${zIndexClassName} hidden flex items-center justify-center p-0 md:p-4 ${rootClassName}`; 34 root.setAttribute('aria-hidden', 'true'); 35 Object.entries(rootAttributes).forEach(([name, value]) => root.setAttribute(name, String(value ?? ''))); 36 37 const overlay = isFullscreen ? null : documentRef.createElement('div'); 38 if (overlay) { 39 overlay.className = 'absolute inset-0 bg-white/50'; 40 overlay.setAttribute('data-modal-overlay', ''); 41 } 42 43 const panel = documentRef.createElement('div'); 44 if (panelId) { 45 panel.id = panelId; 46 } 47 panel.className = isFullscreen 48 ? `h-full min-h-0 w-full overflow-y-auto bg-background focus:outline-hidden ${panelClassName}` 49 : `relative h-full min-h-0 w-full overflow-y-auto border border-stroke-gray bg-white p-5 shadow-default shadow-yellow/20 focus:outline-hidden sm:p-6 md:h-auto md:max-h-[calc(100vh-2rem)] ${ 50 MODAL_SHELL_MAX_WIDTH_CLASS_NAMES[size] || MODAL_SHELL_MAX_WIDTH_CLASS_NAMES['2xl'] 51 } md:rounded-xl md:p-7 ${panelClassName}`; 52 panel.tabIndex = -1; 53 panel.setAttribute('role', 'dialog'); 54 panel.setAttribute('aria-modal', 'true'); 55 if (labelledBy) { 56 panel.setAttribute('aria-labelledby', labelledBy); 57 } 58 if (describedBy) { 59 panel.setAttribute('aria-describedby', describedBy); 60 } 61 62 const content = documentRef.createElement('div'); 63 panel.appendChild(content); 64 if (overlay) { 65 root.append(overlay, panel); 66 } else { 67 root.appendChild(panel); 68 } 69 documentRef.body.appendChild(root); 70 return { content, overlay, panel, root }; 71}; 72 73// Shared modal primitive with ALWAYS-PORTAL behavior: 74// the modal root is moved to the end of <body> (into a portal) once on init. 75 76const getModalPortal = () => { 77 let portal = document.querySelector('[data-modal-portal]'); 78 if (!portal) { 79 portal = document.createElement('div'); 80 portal.setAttribute('data-modal-portal', ''); 81 document.body.appendChild(portal); 82 } 83 return portal; 84}; 85 86const getFocusableElements = (container) => { 87 if (!container) return []; 88 const selector = [ 89 'a[href]', 90 'button:not([disabled])', 91 'input:not([disabled])', 92 'select:not([disabled])', 93 'textarea:not([disabled])', 94 '[tabindex]:not([tabindex="-1"])', 95 ].join(','); 96 97 return Array.from(container.querySelectorAll(selector)) 98 .filter((el) => el instanceof HTMLElement) 99 .filter((el) => !el.hasAttribute('data-modal-sentinel')) 100 .filter((el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true') 101 .filter((el) => el.getClientRects().length > 0); 102}; 103 104const ensureSentinel = (where) => { 105 const el = document.createElement('span'); 106 el.tabIndex = 0; 107 el.setAttribute('data-modal-sentinel', where); 108 el.style.cssText = 'position:fixed; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap;'; 109 return el; 110}; 111 112const isAllowedPortal = (element) => 113 element.hasAttribute('data-modal-portal') || element.hasAttribute('data-dropdown-portal'); 114 115const setSiblingsAriaHidden = (root, hidden) => { 116 // Hide everything except the modal root from SRs. 117 // Since the root is portaled, "siblings" are the other children in the portal and the rest of body. 118 if (!root || !root.ownerDocument) return () => {}; 119 120 const doc = root.ownerDocument; 121 const bodyChildren = Array.from(doc.body.children); 122 123 const previous = new Map();
124 bodyChildren.forEach((el) => { 125 if (!(el instanceof HTMLElement)) return; 126 if (el === root) return; // allow modal root 127 // Keep modal-owned floating UI (such as a portaled dropdown) available to SRs. 128 if (isAllowedPortal(el)) return; 129 130 previous.set(el, el.getAttribute('aria-hidden')); 131 if (hidden) el.setAttribute('aria-hidden', 'true'); 132 else { 133 const prev = previous.get(el); 134 if (prev === null || prev === undefined) el.removeAttribute('aria-hidden'); 135 else el.setAttribute('aria-hidden', prev); 136 } 137 }); 138 139 return () => { 140 bodyChildren.forEach((el) => { 141 if (!(el instanceof HTMLElement)) return; 142 if (el === root) return; 143 if (isAllowedPortal(el)) return; 144 145 const prev = previous.get(el); 146 if (prev === null || prev === undefined) el.removeAttribute('aria-hidden'); 147 else el.setAttribute('aria-hidden', prev); 148 }); 149 }; 150}; 151 152const createModal = ({ 153 root, 154 panel, 155 overlay, 156 openTriggerSelector, // can match multiple triggers (event delegation) 157 closeTriggerSelector = '[data-modal-close]', 158 closeOnOverlay = true, 159 closeOnEsc = true, 160 trapFocus = true, 161 restoreFocus = true, 162 initialFocus, // () => HTMLElement | null OR selector string (within panel) 163 onOpen, 164 onClose, 165 canClose = () => true, 166} = {}) => { 167 if (!root || !(root instanceof HTMLElement)) return null; 168 169 // Always portal the modal root to the end of body (Radix-like). 170 const portal = getModalPortal(); 171 if (root.parentElement !== portal) { 172 portal.appendChild(root); 173 } 174 175 const resolvedPanel = 176 panel instanceof HTMLElement ? panel : root.querySelector('[role="dialog"], [data-modal-panel]') || root; 177 178 const resolvedOverlay = overlay instanceof HTMLElement ? overlay : root.querySelector('[data-modal-overlay]'); 179 let isOpen = false; 180 let previousActive = null; 181 let restoreSiblings = null; 182 183 const resolveInitialFocusTarget = () => { 184 if (!initialFocus) return null; 185 if (typeof initialFocus === 'string') { 186 const el = resolvedPanel.querySelector(initialFocus); 187 return el instanceof HTMLElement ? el : null; 188 } 189 if (typeof initialFocus === 'function') { 190 const el = initialFocus(); 191 return el instanceof HTMLElement ? el : null; 192 } 193 return null; 194 }; 195 196 const focusFirst = () => { 197 const target = resolveInitialFocusTarget(); 198 if (target) { 199 target.focus(); 200 return; 201 } 202 const focusables = getFocusableElements(resolvedPanel); 203 if (focusables[0]) focusables[0].focus(); 204 else resolvedPanel.focus?.(); 205 }; 206 207 const focusLast = () => { 208 const focusables = getFocusableElements(resolvedPanel); 209 const last = focusables[focusables.length - 1]; 210 if (last) last.focus(); 211 else resolvedPanel.focus?.(); 212 }; 213 214 const startSentinel = ensureSentinel('start'); 215 const endSentinel = ensureSentinel('end'); 216 217 startSentinel.addEventListener('focus', () => { 218 if (isOpen) focusLast(); 219 }); 220 endSentinel.addEventListener('focus', () => { 221 if (isOpen) focusFirst(); 222 }); 223 224 const mountSentinels = () => { 225 if (!trapFocus) return; 226 if (!resolvedPanel.contains(startSentinel)) resolvedPanel.prepend(startSentinel); 227 if (!resolvedPanel.contains(endSentinel)) resolvedPanel.append(endSentinel); 228 }; 229 230 const unmountSentinels = () => { 231 startSentinel.remove(); 232 endSentinel.remove(); 233 }; 234 235 const onKeyDown = (event) => { 236 if (!isOpen) return; 237 238 // Floating controls inside a modal are portaled next to it. Let their own 239 // keyboard handler consume Escape/Tab before applying the dialog behavior. 240 if ( 241 event.target?.closest?.('[data-dropdown-portal]') || 242 event.target?.closest?.('[aria-expanded="true"][aria-controls]') 243 ) { 244 return; 245 } 246 247 if (closeOnEsc && event.key === 'Escape') { 248 event.preventDefault(); 249 close(); 250 return; 251 } 252 253 if (!trapFocus || event.key !== 'Tab') return; 254 255 const focusables = getFocusableElements(resolvedPanel); 256 if (!focusables.length) { 257 event.preventDefault(); 258 return; 259 } 260 261 const active = document.activeElement; 262 const first = focusables[0]; 263 const last = focusables[focusables.length - 1]; 264 265 if (event.shiftKey) { 266 if (active === first || !resolvedPanel.contains(active)) { 267 event.preventDefault(); 268 last.focus(); 269 } 270 } else { 271 if (active === last) { 272 event.preventDefault(); 273 first.focus(); 274 } 275 } 276 }; 277 278 const onOverlayClick = () => { 279 if (!isOpen) return; 280 if (closeOnOverlay) close(); 281 }; 282 283 const onDocumentClick = (event) => { 284 const target = event.target; 285 286 if (closeTriggerSelector) { 287 const closeTrigger = target?.closest?.(closeTriggerSelector); 288 if (closeTrigger) { 289 event.preventDefault(); 290 close(); 291 return; 292 } 293 } 294 295 if (openTriggerSelector) { 296 const openTrigger = target?.closest?.(openTriggerSelector); 297 if (openTrigger) { 298 event.preventDefault(); 299 open(); 300 } 301 } 302 }; 303 304 const open = () => { 305 if (isOpen) return; 306 isOpen = true; 307 308 previousActive = document.activeElement instanceof HTMLElement ? document.activeElement : null; 309 lock(); 310 311 root.classList.remove('hidden'); 312 root.setAttribute('aria-hidden', 'false'); 313 314 restoreSiblings = setSiblingsAriaHidden(root, true); 315 316 mountSentinels(); 317 318 document.addEventListener('keydown', onKeyDown, true); 319 320 if (resolvedOverlay) { 321 resolvedOverlay.addEventListener('click', onOverlayClick); 322 } 323 324 requestAnimationFrame(() => { 325 focusFirst(); 326 }); 327 328 if (typeof onOpen === 'function') onOpen(); 329 }; 330 331 const close = ({ force = false } = {}) => { 332 if (!isOpen || (!force && !canClose())) return; 333 isOpen = false;
334 335 const closeDelay = Number(root.getAttribute('data-modal-close-delay')) || 0; 336 if (closeDelay > 0) { 337 root.setAttribute('data-modal-closing', 'true'); 338 root.setAttribute('aria-hidden', 'true'); 339 window.setTimeout(() => { 340 root.classList.add('hidden'); 341 root.removeAttribute('data-modal-closing'); 342 }, closeDelay); 343 } else { 344 root.classList.add('hidden'); 345 root.setAttribute('aria-hidden', 'true'); 346 } 347 348 unmountSentinels(); 349 350 document.removeEventListener('keydown', onKeyDown, true); 351 352 if (resolvedOverlay) { 353 resolvedOverlay.removeEventListener('click', onOverlayClick); 354 } 355 356 if (restoreSiblings) { 357 restoreSiblings(); 358 restoreSiblings = null; 359 } 360 361 unlock(); 362 363 if (restoreFocus && previousActive && document.contains(previousActive)) { 364 previousActive.focus(); 365 } 366 367 if (typeof onClose === 'function') onClose(); 368 }; 369 370 const toggle = () => (isOpen ? close() : open()); 371 372 const destroy = () => { 373 close({ force: true }); 374 document.removeEventListener('click', onDocumentClick, true); 375 }; 376 377 // Delegated triggers: supports multiple open buttons automatically. 378 if (openTriggerSelector || closeTriggerSelector) { 379 document.addEventListener('click', onDocumentClick, true); 380 } 381 382 return { open, close, toggle, destroy, isOpen: () => isOpen }; 383}; 384 385if (typeof globalThis !== 'undefined' && globalThis) { 386 globalThis.createModal = createModal; 387 globalThis.createModalShell = createModalShell; 388} 389 390export { MODAL_SHELL_MAX_WIDTH_CLASS_NAMES, createModal, createModalShell };
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.