blob: 471f55e08db160bcb6de1a2545abb152d087f1ed [file] [log] [blame]
Copybara botbe50d492023-11-30 00:16:42 +01001// nb. This is for IE10 and lower _only_.
2var supportCustomEvent = window.CustomEvent;
3if (!supportCustomEvent || typeof supportCustomEvent === 'object') {
4 supportCustomEvent = function CustomEvent(event, x) {
5 x = x || {};
6 var ev = document.createEvent('CustomEvent');
7 ev.initCustomEvent(event, !!x.bubbles, !!x.cancelable, x.detail || null);
8 return ev;
9 };
10 supportCustomEvent.prototype = window.Event.prototype;
11}
12
13/**
Renovate botf591dcf2023-12-30 14:13:54 +000014 * Dispatches the passed event to both an "on<type>" handler as well as via the
15 * normal dispatch operation. Does not bubble.
16 *
17 * @param {!EventTarget} target
18 * @param {!Event} event
19 * @return {boolean}
20 */
21function safeDispatchEvent(target, event) {
22 var check = 'on' + event.type.toLowerCase();
23 if (typeof target[check] === 'function') {
24 target[check](event);
25 }
26 return target.dispatchEvent(event);
27}
28
29/**
Copybara botbe50d492023-11-30 00:16:42 +010030 * @param {Element} el to check for stacking context
31 * @return {boolean} whether this el or its parents creates a stacking context
32 */
33function createsStackingContext(el) {
34 while (el && el !== document.body) {
35 var s = window.getComputedStyle(el);
36 var invalid = function(k, ok) {
37 return !(s[k] === undefined || s[k] === ok);
38 };
Renovate botf591dcf2023-12-30 14:13:54 +000039
Copybara botbe50d492023-11-30 00:16:42 +010040 if (s.opacity < 1 ||
41 invalid('zIndex', 'auto') ||
42 invalid('transform', 'none') ||
43 invalid('mixBlendMode', 'normal') ||
44 invalid('filter', 'none') ||
45 invalid('perspective', 'none') ||
46 s['isolation'] === 'isolate' ||
47 s.position === 'fixed' ||
48 s.webkitOverflowScrolling === 'touch') {
49 return true;
50 }
51 el = el.parentElement;
52 }
53 return false;
54}
55
56/**
57 * Finds the nearest <dialog> from the passed element.
58 *
59 * @param {Element} el to search from
60 * @return {HTMLDialogElement} dialog found
61 */
62function findNearestDialog(el) {
63 while (el) {
64 if (el.localName === 'dialog') {
65 return /** @type {HTMLDialogElement} */ (el);
66 }
67 el = el.parentElement;
68 }
69 return null;
70}
71
72/**
73 * Blur the specified element, as long as it's not the HTML body element.
74 * This works around an IE9/10 bug - blurring the body causes Windows to
75 * blur the whole application.
76 *
77 * @param {Element} el to blur
78 */
79function safeBlur(el) {
Renovate botf591dcf2023-12-30 14:13:54 +000080 // Find the actual focused element when the active element is inside a shadow root
81 while (el && el.shadowRoot && el.shadowRoot.activeElement) {
82 el = el.shadowRoot.activeElement;
83 }
84
Copybara botbe50d492023-11-30 00:16:42 +010085 if (el && el.blur && el !== document.body) {
86 el.blur();
87 }
88}
89
90/**
91 * @param {!NodeList} nodeList to search
92 * @param {Node} node to find
93 * @return {boolean} whether node is inside nodeList
94 */
95function inNodeList(nodeList, node) {
96 for (var i = 0; i < nodeList.length; ++i) {
97 if (nodeList[i] === node) {
98 return true;
99 }
100 }
101 return false;
102}
103
104/**
105 * @param {HTMLFormElement} el to check
106 * @return {boolean} whether this form has method="dialog"
107 */
108function isFormMethodDialog(el) {
109 if (!el || !el.hasAttribute('method')) {
110 return false;
111 }
112 return el.getAttribute('method').toLowerCase() === 'dialog';
113}
114
115/**
Renovate botf591dcf2023-12-30 14:13:54 +0000116 * @param {!DocumentFragment|!Element} hostElement
117 * @return {?Element}
118 */
119function findFocusableElementWithin(hostElement) {
120 // Note that this is 'any focusable area'. This list is probably not exhaustive, but the
121 // alternative involves stepping through and trying to focus everything.
122 var opts = ['button', 'input', 'keygen', 'select', 'textarea'];
123 var query = opts.map(function(el) {
124 return el + ':not([disabled])';
125 });
126 // TODO(samthor): tabindex values that are not numeric are not focusable.
127 query.push('[tabindex]:not([disabled]):not([tabindex=""])'); // tabindex != "", not disabled
128 var target = hostElement.querySelector(query.join(', '));
129
130 if (!target && 'attachShadow' in Element.prototype) {
131 // If we haven't found a focusable target, see if the host element contains an element
132 // which has a shadowRoot.
133 // Recursively search for the first focusable item in shadow roots.
134 var elems = hostElement.querySelectorAll('*');
135 for (var i = 0; i < elems.length; i++) {
136 if (elems[i].tagName && elems[i].shadowRoot) {
137 target = findFocusableElementWithin(elems[i].shadowRoot);
138 if (target) {
139 break;
140 }
141 }
142 }
143 }
144 return target;
145}
146
147/**
148 * Determines if an element is attached to the DOM.
149 * @param {Element} element to check
150 * @return {Boolean} whether the element is in DOM
151 */
152function isConnected(element) {
153 return element.isConnected || document.body.contains(element);
154}
155
156/**
157 * @param {!Event} event
158 */
159function findFormSubmitter(event) {
160 if (event.submitter) {
161 return event.submitter;
162 }
163
164 var form = event.target;
165 if (!(form instanceof HTMLFormElement)) {
166 return null;
167 }
168
169 var submitter = dialogPolyfill.formSubmitter;
170 if (!submitter) {
171 var target = event.target;
172 var root = ('getRootNode' in target && target.getRootNode() || document);
173 submitter = root.activeElement;
174 }
175
176 if (submitter.form !== form) {
177 return null;
178 }
179 return submitter;
180}
181
182/**
183 * @param {!Event} event
184 */
185function maybeHandleSubmit(event) {
186 if (event.defaultPrevented) {
187 return;
188 }
189 var form = /** @type {!HTMLFormElement} */ (event.target);
190
191 // We'd have a value if we clicked on an imagemap.
192 var value = dialogPolyfill.useValue;
193 var submitter = findFormSubmitter(event);
194 if (value === null && submitter) {
195 value = submitter.value;
196 }
197
198 // There should always be a dialog as this handler is added specifically on them, but check just
199 // in case.
200 var dialog = findNearestDialog(form);
201 if (!dialog) {
202 return;
203 }
204
205 // Prefer formmethod on the button.
206 var formmethod = submitter && submitter.getAttribute('formmethod') || form.getAttribute('method');
207 if (formmethod !== 'dialog') {
208 return;
209 }
210 event.preventDefault();
211
212 if (submitter) {
213 dialog.close(value);
214 } else {
215 dialog.close();
216 }
217}
218
219/**
Copybara botbe50d492023-11-30 00:16:42 +0100220 * @param {!HTMLDialogElement} dialog to upgrade
221 * @constructor
222 */
223function dialogPolyfillInfo(dialog) {
224 this.dialog_ = dialog;
225 this.replacedStyleTop_ = false;
226 this.openAsModal_ = false;
227
228 // Set a11y role. Browsers that support dialog implicitly know this already.
229 if (!dialog.hasAttribute('role')) {
230 dialog.setAttribute('role', 'dialog');
231 }
232
233 dialog.show = this.show.bind(this);
234 dialog.showModal = this.showModal.bind(this);
235 dialog.close = this.close.bind(this);
236
Renovate botf591dcf2023-12-30 14:13:54 +0000237 dialog.addEventListener('submit', maybeHandleSubmit, false);
238
Copybara botbe50d492023-11-30 00:16:42 +0100239 if (!('returnValue' in dialog)) {
240 dialog.returnValue = '';
241 }
242
243 if ('MutationObserver' in window) {
244 var mo = new MutationObserver(this.maybeHideModal.bind(this));
245 mo.observe(dialog, {attributes: true, attributeFilter: ['open']});
246 } else {
247 // IE10 and below support. Note that DOMNodeRemoved etc fire _before_ removal. They also
248 // seem to fire even if the element was removed as part of a parent removal. Use the removed
249 // events to force downgrade (useful if removed/immediately added).
250 var removed = false;
251 var cb = function() {
252 removed ? this.downgradeModal() : this.maybeHideModal();
253 removed = false;
254 }.bind(this);
255 var timeout;
256 var delayModel = function(ev) {
257 if (ev.target !== dialog) { return; } // not for a child element
258 var cand = 'DOMNodeRemoved';
259 removed |= (ev.type.substr(0, cand.length) === cand);
260 window.clearTimeout(timeout);
261 timeout = window.setTimeout(cb, 0);
262 };
263 ['DOMAttrModified', 'DOMNodeRemoved', 'DOMNodeRemovedFromDocument'].forEach(function(name) {
264 dialog.addEventListener(name, delayModel);
265 });
266 }
267 // Note that the DOM is observed inside DialogManager while any dialog
268 // is being displayed as a modal, to catch modal removal from the DOM.
269
270 Object.defineProperty(dialog, 'open', {
271 set: this.setOpen.bind(this),
272 get: dialog.hasAttribute.bind(dialog, 'open')
273 });
274
275 this.backdrop_ = document.createElement('div');
276 this.backdrop_.className = 'backdrop';
Renovate botf591dcf2023-12-30 14:13:54 +0000277 this.backdrop_.addEventListener('mouseup' , this.backdropMouseEvent_.bind(this));
278 this.backdrop_.addEventListener('mousedown', this.backdropMouseEvent_.bind(this));
279 this.backdrop_.addEventListener('click' , this.backdropMouseEvent_.bind(this));
Copybara botbe50d492023-11-30 00:16:42 +0100280}
281
Renovate botf591dcf2023-12-30 14:13:54 +0000282dialogPolyfillInfo.prototype = /** @type {HTMLDialogElement.prototype} */ ({
Copybara botbe50d492023-11-30 00:16:42 +0100283
284 get dialog() {
285 return this.dialog_;
286 },
287
288 /**
289 * Maybe remove this dialog from the modal top layer. This is called when
290 * a modal dialog may no longer be tenable, e.g., when the dialog is no
291 * longer open or is no longer part of the DOM.
292 */
293 maybeHideModal: function() {
Renovate botf591dcf2023-12-30 14:13:54 +0000294 if (this.dialog_.hasAttribute('open') && isConnected(this.dialog_)) { return; }
Copybara botbe50d492023-11-30 00:16:42 +0100295 this.downgradeModal();
296 },
297
298 /**
299 * Remove this dialog from the modal top layer, leaving it as a non-modal.
300 */
301 downgradeModal: function() {
302 if (!this.openAsModal_) { return; }
303 this.openAsModal_ = false;
304 this.dialog_.style.zIndex = '';
305
306 // This won't match the native <dialog> exactly because if the user set top on a centered
307 // polyfill dialog, that top gets thrown away when the dialog is closed. Not sure it's
308 // possible to polyfill this perfectly.
309 if (this.replacedStyleTop_) {
310 this.dialog_.style.top = '';
311 this.replacedStyleTop_ = false;
312 }
313
314 // Clear the backdrop and remove from the manager.
315 this.backdrop_.parentNode && this.backdrop_.parentNode.removeChild(this.backdrop_);
316 dialogPolyfill.dm.removeDialog(this);
317 },
318
319 /**
320 * @param {boolean} value whether to open or close this dialog
321 */
322 setOpen: function(value) {
323 if (value) {
324 this.dialog_.hasAttribute('open') || this.dialog_.setAttribute('open', '');
325 } else {
326 this.dialog_.removeAttribute('open');
327 this.maybeHideModal(); // nb. redundant with MutationObserver
328 }
329 },
330
331 /**
Renovate botf591dcf2023-12-30 14:13:54 +0000332 * Handles mouse events ('mouseup', 'mousedown', 'click') on the fake .backdrop element, redirecting them as if
Copybara botbe50d492023-11-30 00:16:42 +0100333 * they were on the dialog itself.
334 *
335 * @param {!Event} e to redirect
336 */
Renovate botf591dcf2023-12-30 14:13:54 +0000337 backdropMouseEvent_: function(e) {
Copybara botbe50d492023-11-30 00:16:42 +0100338 if (!this.dialog_.hasAttribute('tabindex')) {
339 // Clicking on the backdrop should move the implicit cursor, even if dialog cannot be
340 // focused. Create a fake thing to focus on. If the backdrop was _before_ the dialog, this
341 // would not be needed - clicks would move the implicit cursor there.
342 var fake = document.createElement('div');
343 this.dialog_.insertBefore(fake, this.dialog_.firstChild);
344 fake.tabIndex = -1;
345 fake.focus();
346 this.dialog_.removeChild(fake);
347 } else {
348 this.dialog_.focus();
349 }
350
351 var redirectedEvent = document.createEvent('MouseEvents');
352 redirectedEvent.initMouseEvent(e.type, e.bubbles, e.cancelable, window,
353 e.detail, e.screenX, e.screenY, e.clientX, e.clientY, e.ctrlKey,
354 e.altKey, e.shiftKey, e.metaKey, e.button, e.relatedTarget);
355 this.dialog_.dispatchEvent(redirectedEvent);
356 e.stopPropagation();
357 },
358
359 /**
360 * Focuses on the first focusable element within the dialog. This will always blur the current
361 * focus, even if nothing within the dialog is found.
362 */
363 focus_: function() {
364 // Find element with `autofocus` attribute, or fall back to the first form/tabindex control.
365 var target = this.dialog_.querySelector('[autofocus]:not([disabled])');
366 if (!target && this.dialog_.tabIndex >= 0) {
367 target = this.dialog_;
368 }
369 if (!target) {
Renovate botf591dcf2023-12-30 14:13:54 +0000370 target = findFocusableElementWithin(this.dialog_);
Copybara botbe50d492023-11-30 00:16:42 +0100371 }
372 safeBlur(document.activeElement);
373 target && target.focus();
374 },
375
376 /**
377 * Sets the zIndex for the backdrop and dialog.
378 *
379 * @param {number} dialogZ
380 * @param {number} backdropZ
381 */
382 updateZIndex: function(dialogZ, backdropZ) {
383 if (dialogZ < backdropZ) {
384 throw new Error('dialogZ should never be < backdropZ');
385 }
386 this.dialog_.style.zIndex = dialogZ;
387 this.backdrop_.style.zIndex = backdropZ;
388 },
389
390 /**
391 * Shows the dialog. If the dialog is already open, this does nothing.
392 */
393 show: function() {
394 if (!this.dialog_.open) {
395 this.setOpen(true);
396 this.focus_();
397 }
398 },
399
400 /**
401 * Show this dialog modally.
402 */
403 showModal: function() {
404 if (this.dialog_.hasAttribute('open')) {
405 throw new Error('Failed to execute \'showModal\' on dialog: The element is already open, and therefore cannot be opened modally.');
406 }
Renovate botf591dcf2023-12-30 14:13:54 +0000407 if (!isConnected(this.dialog_)) {
Copybara botbe50d492023-11-30 00:16:42 +0100408 throw new Error('Failed to execute \'showModal\' on dialog: The element is not in a Document.');
409 }
410 if (!dialogPolyfill.dm.pushDialog(this)) {
411 throw new Error('Failed to execute \'showModal\' on dialog: There are too many open modal dialogs.');
412 }
413
414 if (createsStackingContext(this.dialog_.parentElement)) {
415 console.warn('A dialog is being shown inside a stacking context. ' +
416 'This may cause it to be unusable. For more information, see this link: ' +
417 'https://github.com/GoogleChrome/dialog-polyfill/#stacking-context');
418 }
419
420 this.setOpen(true);
421 this.openAsModal_ = true;
422
423 // Optionally center vertically, relative to the current viewport.
424 if (dialogPolyfill.needsCentering(this.dialog_)) {
425 dialogPolyfill.reposition(this.dialog_);
426 this.replacedStyleTop_ = true;
427 } else {
428 this.replacedStyleTop_ = false;
429 }
430
431 // Insert backdrop.
432 this.dialog_.parentNode.insertBefore(this.backdrop_, this.dialog_.nextSibling);
433
434 // Focus on whatever inside the dialog.
435 this.focus_();
436 },
437
438 /**
439 * Closes this HTMLDialogElement. This is optional vs clearing the open
440 * attribute, however this fires a 'close' event.
441 *
442 * @param {string=} opt_returnValue to use as the returnValue
443 */
444 close: function(opt_returnValue) {
445 if (!this.dialog_.hasAttribute('open')) {
446 throw new Error('Failed to execute \'close\' on dialog: The element does not have an \'open\' attribute, and therefore cannot be closed.');
447 }
448 this.setOpen(false);
449
450 // Leave returnValue untouched in case it was set directly on the element
451 if (opt_returnValue !== undefined) {
452 this.dialog_.returnValue = opt_returnValue;
453 }
454
455 // Triggering "close" event for any attached listeners on the <dialog>.
456 var closeEvent = new supportCustomEvent('close', {
457 bubbles: false,
458 cancelable: false
459 });
Renovate botf591dcf2023-12-30 14:13:54 +0000460 safeDispatchEvent(this.dialog_, closeEvent);
Copybara botbe50d492023-11-30 00:16:42 +0100461 }
462
Renovate botf591dcf2023-12-30 14:13:54 +0000463});
Copybara botbe50d492023-11-30 00:16:42 +0100464
465var dialogPolyfill = {};
466
467dialogPolyfill.reposition = function(element) {
468 var scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
469 var topValue = scrollTop + (window.innerHeight - element.offsetHeight) / 2;
470 element.style.top = Math.max(scrollTop, topValue) + 'px';
471};
472
473dialogPolyfill.isInlinePositionSetByStylesheet = function(element) {
474 for (var i = 0; i < document.styleSheets.length; ++i) {
475 var styleSheet = document.styleSheets[i];
476 var cssRules = null;
477 // Some browsers throw on cssRules.
478 try {
479 cssRules = styleSheet.cssRules;
480 } catch (e) {}
481 if (!cssRules) { continue; }
482 for (var j = 0; j < cssRules.length; ++j) {
483 var rule = cssRules[j];
484 var selectedNodes = null;
485 // Ignore errors on invalid selector texts.
486 try {
487 selectedNodes = document.querySelectorAll(rule.selectorText);
488 } catch(e) {}
489 if (!selectedNodes || !inNodeList(selectedNodes, element)) {
490 continue;
491 }
492 var cssTop = rule.style.getPropertyValue('top');
493 var cssBottom = rule.style.getPropertyValue('bottom');
494 if ((cssTop && cssTop !== 'auto') || (cssBottom && cssBottom !== 'auto')) {
495 return true;
496 }
497 }
498 }
499 return false;
500};
501
502dialogPolyfill.needsCentering = function(dialog) {
503 var computedStyle = window.getComputedStyle(dialog);
504 if (computedStyle.position !== 'absolute') {
505 return false;
506 }
507
508 // We must determine whether the top/bottom specified value is non-auto. In
509 // WebKit/Blink, checking computedStyle.top == 'auto' is sufficient, but
510 // Firefox returns the used value. So we do this crazy thing instead: check
511 // the inline style and then go through CSS rules.
512 if ((dialog.style.top !== 'auto' && dialog.style.top !== '') ||
513 (dialog.style.bottom !== 'auto' && dialog.style.bottom !== '')) {
514 return false;
515 }
516 return !dialogPolyfill.isInlinePositionSetByStylesheet(dialog);
517};
518
519/**
520 * @param {!Element} element to force upgrade
521 */
522dialogPolyfill.forceRegisterDialog = function(element) {
523 if (window.HTMLDialogElement || element.showModal) {
524 console.warn('This browser already supports <dialog>, the polyfill ' +
525 'may not work correctly', element);
526 }
527 if (element.localName !== 'dialog') {
528 throw new Error('Failed to register dialog: The element is not a dialog.');
529 }
530 new dialogPolyfillInfo(/** @type {!HTMLDialogElement} */ (element));
531};
532
533/**
534 * @param {!Element} element to upgrade, if necessary
535 */
536dialogPolyfill.registerDialog = function(element) {
537 if (!element.showModal) {
538 dialogPolyfill.forceRegisterDialog(element);
539 }
540};
541
542/**
543 * @constructor
544 */
545dialogPolyfill.DialogManager = function() {
546 /** @type {!Array<!dialogPolyfillInfo>} */
547 this.pendingDialogStack = [];
548
549 var checkDOM = this.checkDOM_.bind(this);
550
551 // The overlay is used to simulate how a modal dialog blocks the document.
552 // The blocking dialog is positioned on top of the overlay, and the rest of
553 // the dialogs on the pending dialog stack are positioned below it. In the
554 // actual implementation, the modal dialog stacking is controlled by the
555 // top layer, where z-index has no effect.
556 this.overlay = document.createElement('div');
557 this.overlay.className = '_dialog_overlay';
558 this.overlay.addEventListener('click', function(e) {
559 this.forwardTab_ = undefined;
560 e.stopPropagation();
561 checkDOM([]); // sanity-check DOM
562 }.bind(this));
563
564 this.handleKey_ = this.handleKey_.bind(this);
565 this.handleFocus_ = this.handleFocus_.bind(this);
566
567 this.zIndexLow_ = 100000;
568 this.zIndexHigh_ = 100000 + 150;
569
570 this.forwardTab_ = undefined;
571
572 if ('MutationObserver' in window) {
573 this.mo_ = new MutationObserver(function(records) {
574 var removed = [];
575 records.forEach(function(rec) {
576 for (var i = 0, c; c = rec.removedNodes[i]; ++i) {
577 if (!(c instanceof Element)) {
578 continue;
579 } else if (c.localName === 'dialog') {
580 removed.push(c);
581 }
582 removed = removed.concat(c.querySelectorAll('dialog'));
583 }
584 });
585 removed.length && checkDOM(removed);
586 });
587 }
588};
589
590/**
591 * Called on the first modal dialog being shown. Adds the overlay and related
592 * handlers.
593 */
594dialogPolyfill.DialogManager.prototype.blockDocument = function() {
595 document.documentElement.addEventListener('focus', this.handleFocus_, true);
596 document.addEventListener('keydown', this.handleKey_);
597 this.mo_ && this.mo_.observe(document, {childList: true, subtree: true});
598};
599
600/**
601 * Called on the first modal dialog being removed, i.e., when no more modal
602 * dialogs are visible.
603 */
604dialogPolyfill.DialogManager.prototype.unblockDocument = function() {
605 document.documentElement.removeEventListener('focus', this.handleFocus_, true);
606 document.removeEventListener('keydown', this.handleKey_);
607 this.mo_ && this.mo_.disconnect();
608};
609
610/**
611 * Updates the stacking of all known dialogs.
612 */
613dialogPolyfill.DialogManager.prototype.updateStacking = function() {
614 var zIndex = this.zIndexHigh_;
615
616 for (var i = 0, dpi; dpi = this.pendingDialogStack[i]; ++i) {
617 dpi.updateZIndex(--zIndex, --zIndex);
618 if (i === 0) {
619 this.overlay.style.zIndex = --zIndex;
620 }
621 }
622
623 // Make the overlay a sibling of the dialog itself.
624 var last = this.pendingDialogStack[0];
625 if (last) {
626 var p = last.dialog.parentNode || document.body;
627 p.appendChild(this.overlay);
628 } else if (this.overlay.parentNode) {
629 this.overlay.parentNode.removeChild(this.overlay);
630 }
631};
632
633/**
634 * @param {Element} candidate to check if contained or is the top-most modal dialog
635 * @return {boolean} whether candidate is contained in top dialog
636 */
637dialogPolyfill.DialogManager.prototype.containedByTopDialog_ = function(candidate) {
638 while (candidate = findNearestDialog(candidate)) {
639 for (var i = 0, dpi; dpi = this.pendingDialogStack[i]; ++i) {
640 if (dpi.dialog === candidate) {
641 return i === 0; // only valid if top-most
642 }
643 }
644 candidate = candidate.parentElement;
645 }
646 return false;
647};
648
649dialogPolyfill.DialogManager.prototype.handleFocus_ = function(event) {
Renovate botf591dcf2023-12-30 14:13:54 +0000650 var target = event.composedPath ? event.composedPath()[0] : event.target;
651
652 if (this.containedByTopDialog_(target)) { return; }
Copybara botbe50d492023-11-30 00:16:42 +0100653
654 if (document.activeElement === document.documentElement) { return; }
655
656 event.preventDefault();
657 event.stopPropagation();
Renovate botf591dcf2023-12-30 14:13:54 +0000658 safeBlur(/** @type {Element} */ (target));
Copybara botbe50d492023-11-30 00:16:42 +0100659
660 if (this.forwardTab_ === undefined) { return; } // move focus only from a tab key
661
662 var dpi = this.pendingDialogStack[0];
663 var dialog = dpi.dialog;
Renovate botf591dcf2023-12-30 14:13:54 +0000664 var position = dialog.compareDocumentPosition(target);
Copybara botbe50d492023-11-30 00:16:42 +0100665 if (position & Node.DOCUMENT_POSITION_PRECEDING) {
666 if (this.forwardTab_) {
667 // forward
668 dpi.focus_();
Renovate botf591dcf2023-12-30 14:13:54 +0000669 } else if (target !== document.documentElement) {
Copybara botbe50d492023-11-30 00:16:42 +0100670 // backwards if we're not already focused on <html>
671 document.documentElement.focus();
672 }
673 }
674
675 return false;
676};
677
678dialogPolyfill.DialogManager.prototype.handleKey_ = function(event) {
679 this.forwardTab_ = undefined;
680 if (event.keyCode === 27) {
681 event.preventDefault();
682 event.stopPropagation();
683 var cancelEvent = new supportCustomEvent('cancel', {
684 bubbles: false,
685 cancelable: true
686 });
687 var dpi = this.pendingDialogStack[0];
Renovate botf591dcf2023-12-30 14:13:54 +0000688 if (dpi && safeDispatchEvent(dpi.dialog, cancelEvent)) {
Copybara botbe50d492023-11-30 00:16:42 +0100689 dpi.dialog.close();
690 }
691 } else if (event.keyCode === 9) {
692 this.forwardTab_ = !event.shiftKey;
693 }
694};
695
696/**
697 * Finds and downgrades any known modal dialogs that are no longer displayed. Dialogs that are
698 * removed and immediately readded don't stay modal, they become normal.
699 *
700 * @param {!Array<!HTMLDialogElement>} removed that have definitely been removed
701 */
702dialogPolyfill.DialogManager.prototype.checkDOM_ = function(removed) {
703 // This operates on a clone because it may cause it to change. Each change also calls
704 // updateStacking, which only actually needs to happen once. But who removes many modal dialogs
705 // at a time?!
706 var clone = this.pendingDialogStack.slice();
707 clone.forEach(function(dpi) {
708 if (removed.indexOf(dpi.dialog) !== -1) {
709 dpi.downgradeModal();
710 } else {
711 dpi.maybeHideModal();
712 }
713 });
714};
715
716/**
717 * @param {!dialogPolyfillInfo} dpi
718 * @return {boolean} whether the dialog was allowed
719 */
720dialogPolyfill.DialogManager.prototype.pushDialog = function(dpi) {
721 var allowed = (this.zIndexHigh_ - this.zIndexLow_) / 2 - 1;
722 if (this.pendingDialogStack.length >= allowed) {
723 return false;
724 }
725 if (this.pendingDialogStack.unshift(dpi) === 1) {
726 this.blockDocument();
727 }
728 this.updateStacking();
729 return true;
730};
731
732/**
733 * @param {!dialogPolyfillInfo} dpi
734 */
735dialogPolyfill.DialogManager.prototype.removeDialog = function(dpi) {
736 var index = this.pendingDialogStack.indexOf(dpi);
737 if (index === -1) { return; }
738
739 this.pendingDialogStack.splice(index, 1);
740 if (this.pendingDialogStack.length === 0) {
741 this.unblockDocument();
742 }
743 this.updateStacking();
744};
745
746dialogPolyfill.dm = new dialogPolyfill.DialogManager();
747dialogPolyfill.formSubmitter = null;
748dialogPolyfill.useValue = null;
749
750/**
751 * Installs global handlers, such as click listers and native method overrides. These are needed
752 * even if a no dialog is registered, as they deal with <form method="dialog">.
753 */
754if (window.HTMLDialogElement === undefined) {
755
756 /**
757 * If HTMLFormElement translates method="DIALOG" into 'get', then replace the descriptor with
758 * one that returns the correct value.
759 */
760 var testForm = document.createElement('form');
761 testForm.setAttribute('method', 'dialog');
762 if (testForm.method !== 'dialog') {
763 var methodDescriptor = Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, 'method');
764 if (methodDescriptor) {
765 // nb. Some older iOS and older PhantomJS fail to return the descriptor. Don't do anything
766 // and don't bother to update the element.
767 var realGet = methodDescriptor.get;
768 methodDescriptor.get = function() {
769 if (isFormMethodDialog(this)) {
770 return 'dialog';
771 }
772 return realGet.call(this);
773 };
774 var realSet = methodDescriptor.set;
Renovate botf591dcf2023-12-30 14:13:54 +0000775 /** @this {HTMLElement} */
Copybara botbe50d492023-11-30 00:16:42 +0100776 methodDescriptor.set = function(v) {
777 if (typeof v === 'string' && v.toLowerCase() === 'dialog') {
778 return this.setAttribute('method', v);
779 }
780 return realSet.call(this, v);
781 };
782 Object.defineProperty(HTMLFormElement.prototype, 'method', methodDescriptor);
783 }
784 }
785
786 /**
787 * Global 'click' handler, to capture the <input type="submit"> or <button> element which has
788 * submitted a <form method="dialog">. Needed as Safari and others don't report this inside
789 * document.activeElement.
790 */
791 document.addEventListener('click', function(ev) {
792 dialogPolyfill.formSubmitter = null;
793 dialogPolyfill.useValue = null;
794 if (ev.defaultPrevented) { return; } // e.g. a submit which prevents default submission
795
796 var target = /** @type {Element} */ (ev.target);
Renovate botf591dcf2023-12-30 14:13:54 +0000797 if ('composedPath' in ev) {
798 var path = ev.composedPath();
799 target = path.shift() || target;
800 }
Copybara botbe50d492023-11-30 00:16:42 +0100801 if (!target || !isFormMethodDialog(target.form)) { return; }
802
803 var valid = (target.type === 'submit' && ['button', 'input'].indexOf(target.localName) > -1);
804 if (!valid) {
805 if (!(target.localName === 'input' && target.type === 'image')) { return; }
806 // this is a <input type="image">, which can submit forms
807 dialogPolyfill.useValue = ev.offsetX + ',' + ev.offsetY;
808 }
809
810 var dialog = findNearestDialog(target);
811 if (!dialog) { return; }
812
813 dialogPolyfill.formSubmitter = target;
814
815 }, false);
816
817 /**
Renovate botf591dcf2023-12-30 14:13:54 +0000818 * Global 'submit' handler. This handles submits of `method="dialog"` which are invalid, i.e.,
819 * outside a dialog. They get prevented.
820 */
821 document.addEventListener('submit', function(ev) {
822 var form = ev.target;
823 var dialog = findNearestDialog(form);
824 if (dialog) {
825 return; // ignore, handle there
826 }
827
828 var submitter = findFormSubmitter(ev);
829 var formmethod = submitter && submitter.getAttribute('formmethod') || form.getAttribute('method');
830 if (formmethod === 'dialog') {
831 ev.preventDefault();
832 }
833 });
834
835 /**
Copybara botbe50d492023-11-30 00:16:42 +0100836 * Replace the native HTMLFormElement.submit() method, as it won't fire the
837 * submit event and give us a chance to respond.
838 */
839 var nativeFormSubmit = HTMLFormElement.prototype.submit;
840 var replacementFormSubmit = function () {
841 if (!isFormMethodDialog(this)) {
842 return nativeFormSubmit.call(this);
843 }
844 var dialog = findNearestDialog(this);
845 dialog && dialog.close();
846 };
847 HTMLFormElement.prototype.submit = replacementFormSubmit;
Copybara botbe50d492023-11-30 00:16:42 +0100848}
849
850export default dialogPolyfill;