PageSourceSearch

https://site.fr/_static/8c8af5c529149f32/scripts/ui/modal.js

js site.fr collected 2026-09-24 18:19:17 UTC 11,588 bytes, 390 lines download raw bytes

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.