diff --git a/.eslintignore b/.eslintignore index a9d4b7c2e4..33a04bb154 100644 --- a/.eslintignore +++ b/.eslintignore @@ -14,3 +14,4 @@ package-lock.json # disable linting for templates because it will have its own rules when used in a new app packages/cna-template/template packages/astro-template/src/env.d.ts +packages/circuit-ui/vendor diff --git a/package-lock.json b/package-lock.json index 7a658f9ce8..b5ce7c930b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18288,19 +18288,6 @@ "wrappy": "1" } }, - "node_modules/dialog-polyfill": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz", - "integrity": "sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==" - }, - "node_modules/dialog-polyfill-universal": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/dialog-polyfill-universal/-/dialog-polyfill-universal-0.1.1.tgz", - "integrity": "sha512-s8SbSvLQfQzYPoOaaSsxpoqjABhQG6SWEK56XnJjpwVIDPGkd75WkL5S+Z7wnspgncEtIPJFvJF9DoK2iMWvBQ==", - "dependencies": { - "dialog-polyfill": "^0.5.6" - } - }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -43491,7 +43478,6 @@ "dependencies": { "@floating-ui/react-dom": "^2.1.0", "@nanostores/react": "^0.7.2", - "dialog-polyfill-universal": "^0.1.1", "nanostores": "^0.10.3", "react-modal": "^3.16.1", "react-number-format": "5.3.0", @@ -51373,7 +51359,6 @@ "@types/react-dates": "^21.8.6", "@types/react-dom": "^18.3.0", "@types/react-modal": "^3.16.3", - "dialog-polyfill-universal": "^0.1.1", "jest-axe": "^8.0.0", "moment": "^2.29.4", "nanostores": "^0.10.3", @@ -56239,19 +56224,6 @@ "wrappy": "1" } }, - "dialog-polyfill": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz", - "integrity": "sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==" - }, - "dialog-polyfill-universal": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/dialog-polyfill-universal/-/dialog-polyfill-universal-0.1.1.tgz", - "integrity": "sha512-s8SbSvLQfQzYPoOaaSsxpoqjABhQG6SWEK56XnJjpwVIDPGkd75WkL5S+Z7wnspgncEtIPJFvJF9DoK2iMWvBQ==", - "requires": { - "dialog-polyfill": "^0.5.6" - } - }, "diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", diff --git a/packages/circuit-ui/.eslintignore b/packages/circuit-ui/.eslintignore index cc8a8a6063..75c99487d2 100644 --- a/packages/circuit-ui/.eslintignore +++ b/packages/circuit-ui/.eslintignore @@ -10,3 +10,4 @@ __reports__/ *rc.js tsconfig.json scripts/ +vendor/ diff --git a/packages/circuit-ui/components/Toggletip/Toggletip.tsx b/packages/circuit-ui/components/Toggletip/Toggletip.tsx index 0f7ae158a2..0ca6c5cf35 100644 --- a/packages/circuit-ui/components/Toggletip/Toggletip.tsx +++ b/packages/circuit-ui/components/Toggletip/Toggletip.tsx @@ -36,8 +36,8 @@ import { type Placement, type Side, } from '@floating-ui/react-dom'; -import dialogPolyfill from 'dialog-polyfill-universal'; +import dialogPolyfill from '../../vendor/dialog-polyfill/index.js'; import type { ClickEvent } from '../../types/events.js'; import { clsx } from '../../styles/clsx.js'; import { applyMultipleRefs } from '../../util/refs.js'; diff --git a/packages/circuit-ui/package.json b/packages/circuit-ui/package.json index ddab506654..51067d15d1 100644 --- a/packages/circuit-ui/package.json +++ b/packages/circuit-ui/package.json @@ -54,7 +54,6 @@ "@floating-ui/react-dom": "^2.1.0", "@nanostores/react": "^0.7.2", "nanostores": "^0.10.3", - "dialog-polyfill-universal": "^0.1.1", "react-modal": "^3.16.1", "react-number-format": "5.3.0", "temporal-polyfill": "^0.2.5" diff --git a/packages/circuit-ui/vendor/dialog-polyfill/LICENSE b/packages/circuit-ui/vendor/dialog-polyfill/LICENSE new file mode 100644 index 0000000000..3d0f7d3edf --- /dev/null +++ b/packages/circuit-ui/vendor/dialog-polyfill/LICENSE @@ -0,0 +1,27 @@ +// Copyright (c) 2013 The Chromium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/circuit-ui/vendor/dialog-polyfill/README.md b/packages/circuit-ui/vendor/dialog-polyfill/README.md new file mode 100644 index 0000000000..bb78fb48ea --- /dev/null +++ b/packages/circuit-ui/vendor/dialog-polyfill/README.md @@ -0,0 +1,3 @@ +# dialog-polyfill + +This code was copied from [GoogleChrome/dialog-polyfill](https://github.com/GoogleChrome/dialog-polyfill), specifically [GoogleChrome/dialog-polyfill#231](https://github.com/GoogleChrome/dialog-polyfill/pull/231), which adds support for safely importing the polyfill in isomorphic environments. diff --git a/packages/circuit-ui/vendor/dialog-polyfill/index.d.ts b/packages/circuit-ui/vendor/dialog-polyfill/index.d.ts new file mode 100644 index 0000000000..644e9bb3f2 --- /dev/null +++ b/packages/circuit-ui/vendor/dialog-polyfill/index.d.ts @@ -0,0 +1,17 @@ +interface DialogPolyfillType { + registerDialog(dialog: HTMLDialogElement): void; + forceRegisterDialog(dialog: HTMLDialogElement): void; +} + +/** + * If used as CJS, then "dialogPolyfill" is added to the global scope. Just assert it exists. + */ +declare global { + const dialogPolyfill: DialogPolyfillType; +} + +/** + * If used as ESM, then we export a const of type "DialogPolyfillType" as the default. + */ +declare const dialogPolyfill: DialogPolyfillType; +export default dialogPolyfill; diff --git a/packages/circuit-ui/vendor/dialog-polyfill/index.js b/packages/circuit-ui/vendor/dialog-polyfill/index.js new file mode 100644 index 0000000000..955c6aef0c --- /dev/null +++ b/packages/circuit-ui/vendor/dialog-polyfill/index.js @@ -0,0 +1,991 @@ +const dialogPolyfill = {}; + +// This condition assures imports don't break for isomorphic scripts, +// while also preventing unexpected uses of this package on the server side. +if (typeof window === 'undefined') { + function getServerSideThrower(name, isProp) { + let error = ''; + if (isProp) { + error = `Cannot access property \`dialogPolyfill.${ + name + }\` on the server side.`; + } else { + error = `Cannot execute \`dialogPolyfill.${name}()\` on the server side.`; + } + return function () { + throw new Error(error); + }; + } + dialogPolyfill.registerDialog = getServerSideThrower('registerDialog'); + dialogPolyfill.forceRegisterDialog = getServerSideThrower( + 'forceRegisterDialog', + ); + dialogPolyfill.reposition = getServerSideThrower('reposition'); + dialogPolyfill.isInlinePositionSetByStylesheet = getServerSideThrower( + 'isInlinePositionSetByStylesheet', + ); + dialogPolyfill.needsCentering = getServerSideThrower('needsCentering'); + dialogPolyfill.DialogManager = getServerSideThrower('DialogManager'); + Object.defineProperties(dialogPolyfill, { + dm: { get: getServerSideThrower('dm', true) }, + formSubmitter: { get: getServerSideThrower('formSubmitter', true) }, + imagemapUseValue: { get: getServerSideThrower('imagemapUseValue', true) }, + }); +} else { + // nb. This is for IE10 and lower _only_. + let supportCustomEvent = window.CustomEvent; + if (!supportCustomEvent || typeof supportCustomEvent === 'object') { + supportCustomEvent = function CustomEvent(event, x) { + x = x || {}; + const ev = document.createEvent('CustomEvent'); + ev.initCustomEvent(event, !!x.bubbles, !!x.cancelable, x.detail || null); + return ev; + }; + supportCustomEvent.prototype = window.Event.prototype; + } + + /** + * Dispatches the passed event to both an "on" handler as well as via the + * normal dispatch operation. Does not bubble. + * + * @param {!EventTarget} target + * @param {!Event} event + * @return {boolean} + */ + function safeDispatchEvent(target, event) { + const check = `on${event.type.toLowerCase()}`; + if (typeof target[check] === 'function') { + target[check](event); + } + return target.dispatchEvent(event); + } + + /** + * @param {Element} el to check for stacking context + * @return {boolean} whether this el or its parents creates a stacking context + */ + function createsStackingContext(el) { + while (el && el !== document.body) { + var s = window.getComputedStyle(el); + const invalid = function (k, ok) { + return !(s[k] === undefined || s[k] === ok); + }; + + if ( + s.opacity < 1 || + invalid('zIndex', 'auto') || + invalid('transform', 'none') || + invalid('mixBlendMode', 'normal') || + invalid('filter', 'none') || + invalid('perspective', 'none') || + s.isolation === 'isolate' || + s.position === 'fixed' || + s.webkitOverflowScrolling === 'touch' + ) { + return true; + } + el = el.parentElement; + } + return false; + } + + /** + * Finds the nearest from the passed element. + * + * @param {Element} el to search from + * @return {HTMLDialogElement} dialog found + */ + function findNearestDialog(el) { + while (el) { + if (el.localName === 'dialog') { + return /** @type {HTMLDialogElement} */ el; + } + if (el.parentElement) { + el = el.parentElement; + } else if (el.parentNode) { + el = el.parentNode.host; + } else { + el = null; + } + } + return null; + } + + /** + * Blur the specified element, as long as it's not the HTML body element. + * This works around an IE9/10 bug - blurring the body causes Windows to + * blur the whole application. + * + * @param {Element} el to blur + */ + function safeBlur(el) { + // Find the actual focused element when the active element is inside a shadow root + while (el && el.shadowRoot && el.shadowRoot.activeElement) { + el = el.shadowRoot.activeElement; + } + + if (el && el.blur && el !== document.body) { + el.blur(); + } + } + + /** + * @param {!NodeList} nodeList to search + * @param {Node} node to find + * @return {boolean} whether node is inside nodeList + */ + function inNodeList(nodeList, node) { + for (let i = 0; i < nodeList.length; ++i) { + if (nodeList[i] === node) { + return true; + } + } + return false; + } + + /** + * @param {HTMLFormElement} el to check + * @return {boolean} whether this form has method="dialog" + */ + function isFormMethodDialog(el) { + if (!el || !el.hasAttribute('method')) { + return false; + } + return el.getAttribute('method').toLowerCase() === 'dialog'; + } + + /** + * @param {!DocumentFragment|!Element} hostElement + * @return {?Element} + */ + function findFocusableElementWithin(hostElement) { + // Note that this is 'any focusable area'. This list is probably not exhaustive, but the + // alternative involves stepping through and trying to focus everything. + const opts = ['button', 'input', 'keygen', 'select', 'textarea']; + const query = opts.map((el) => `${el}:not([disabled])`); + // TODO(samthor): tabindex values that are not numeric are not focusable. + query.push('[tabindex]:not([disabled]):not([tabindex=""])'); // tabindex != "", not disabled + let target = hostElement.querySelector(query.join(', ')); + + if (!target && 'attachShadow' in Element.prototype) { + // If we haven't found a focusable target, see if the host element contains an element + // which has a shadowRoot. + // Recursively search for the first focusable item in shadow roots. + const elems = hostElement.querySelectorAll('*'); + for (let i = 0; i < elems.length; i++) { + if (elems[i].tagName && elems[i].shadowRoot) { + target = findFocusableElementWithin(elems[i].shadowRoot); + if (target) { + break; + } + } + } + } + return target; + } + + /** + * Determines if an element is attached to the DOM. + * @param {Element} element to check + * @return {boolean} whether the element is in DOM + */ + function isConnected(element) { + return element.isConnected || document.body.contains(element); + } + + /** + * @param {!Event} event + * @return {?Element} + */ + function findFormSubmitter(event) { + if (event.submitter) { + return event.submitter; + } + + const form = event.target; + if (!(form instanceof HTMLFormElement)) { + return null; + } + + let submitter = dialogPolyfill.formSubmitter; + if (!submitter) { + const { target } = event; + const root = + ('getRootNode' in target && target.getRootNode()) || document; + submitter = root.activeElement; + } + + if (!submitter || submitter.form !== form) { + return null; + } + return submitter; + } + + /** + * @param {!Event} event + */ + function maybeHandleSubmit(event) { + if (event.defaultPrevented) { + return; + } + const form = /** @type {!HTMLFormElement} */ event.target; + + // We'd have a value if we clicked on an imagemap. + let value = dialogPolyfill.imagemapUseValue; + const submitter = findFormSubmitter(event); + if (value === null && submitter) { + value = submitter.value; + } + + // There should always be a dialog as this handler is added specifically on them, but check just + // in case. + const dialog = findNearestDialog(form); + if (!dialog) { + return; + } + + // Prefer formmethod on the button. + const formmethod = + (submitter && submitter.getAttribute('formmethod')) || + form.getAttribute('method'); + if (formmethod !== 'dialog') { + return; + } + event.preventDefault(); + + if (value != null) { + // nb. we explicitly check against null/undefined + dialog.close(value); + } else { + dialog.close(); + } + } + + /** + * @param {!HTMLDialogElement} dialog to upgrade + * @constructor + */ + function dialogPolyfillInfo(dialog) { + this.dialog_ = dialog; + this.replacedStyleTop_ = false; + this.openAsModal_ = false; + + // Set a11y role. Browsers that support dialog implicitly know this already. + if (!dialog.hasAttribute('role')) { + dialog.setAttribute('role', 'dialog'); + } + + dialog.show = this.show.bind(this); + dialog.showModal = this.showModal.bind(this); + dialog.close = this.close.bind(this); + + dialog.addEventListener('submit', maybeHandleSubmit, false); + + if (!('returnValue' in dialog)) { + dialog.returnValue = ''; + } + + if ('MutationObserver' in window) { + const mo = new MutationObserver(this.maybeHideModal.bind(this)); + mo.observe(dialog, { attributes: true, attributeFilter: ['open'] }); + } else { + // IE10 and below support. Note that DOMNodeRemoved etc fire _before_ removal. They also + // seem to fire even if the element was removed as part of a parent removal. Use the removed + // events to force downgrade (useful if removed/immediately added). + let removed = false; + const cb = function () { + removed ? this.downgradeModal() : this.maybeHideModal(); + removed = false; + }.bind(this); + let timeout; + const delayModel = function (ev) { + if (ev.target !== dialog) { + return; + } // not for a child element + const cand = 'DOMNodeRemoved'; + removed |= ev.type.substr(0, cand.length) === cand; + window.clearTimeout(timeout); + timeout = window.setTimeout(cb, 0); + }; + [ + 'DOMAttrModified', + 'DOMNodeRemoved', + 'DOMNodeRemovedFromDocument', + ].forEach((name) => { + dialog.addEventListener(name, delayModel); + }); + } + // Note that the DOM is observed inside DialogManager while any dialog + // is being displayed as a modal, to catch modal removal from the DOM. + + Object.defineProperty(dialog, 'open', { + set: this.setOpen.bind(this), + get: dialog.hasAttribute.bind(dialog, 'open'), + }); + + this.backdrop_ = document.createElement('div'); + this.backdrop_.className = 'backdrop'; + this.backdrop_.addEventListener( + 'mouseup', + this.backdropMouseEvent_.bind(this), + ); + this.backdrop_.addEventListener( + 'mousedown', + this.backdropMouseEvent_.bind(this), + ); + this.backdrop_.addEventListener( + 'click', + this.backdropMouseEvent_.bind(this), + ); + } + + dialogPolyfillInfo.prototype = /** @type {HTMLDialogElement.prototype} */ { + get dialog() { + return this.dialog_; + }, + + /** + * Maybe remove this dialog from the modal top layer. This is called when + * a modal dialog may no longer be tenable, e.g., when the dialog is no + * longer open or is no longer part of the DOM. + */ + maybeHideModal() { + if (this.dialog_.hasAttribute('open') && isConnected(this.dialog_)) { + return; + } + this.downgradeModal(); + }, + + /** + * Remove this dialog from the modal top layer, leaving it as a non-modal. + */ + downgradeModal() { + if (!this.openAsModal_) { + return; + } + this.openAsModal_ = false; + this.dialog_.style.zIndex = ''; + + // This won't match the native exactly because if the user set top on a centered + // polyfill dialog, that top gets thrown away when the dialog is closed. Not sure it's + // possible to polyfill this perfectly. + if (this.replacedStyleTop_) { + this.dialog_.style.top = ''; + this.replacedStyleTop_ = false; + } + + // Clear the backdrop and remove from the manager. + this.backdrop_.parentNode && + this.backdrop_.parentNode.removeChild(this.backdrop_); + dialogPolyfill.dm.removeDialog(this); + }, + + /** + * @param {boolean} value whether to open or close this dialog + */ + setOpen(value) { + if (value) { + this.dialog_.hasAttribute('open') || + this.dialog_.setAttribute('open', ''); + } else { + this.dialog_.removeAttribute('open'); + this.maybeHideModal(); // nb. redundant with MutationObserver + } + }, + + /** + * Handles mouse events ('mouseup', 'mousedown', 'click') on the fake .backdrop element, redirecting them as if + * they were on the dialog itself. + * + * @param {!Event} e to redirect + */ + backdropMouseEvent_(e) { + if (!this.dialog_.hasAttribute('tabindex')) { + // Clicking on the backdrop should move the implicit cursor, even if dialog cannot be + // focused. Create a fake thing to focus on. If the backdrop was _before_ the dialog, this + // would not be needed - clicks would move the implicit cursor there. + const fake = document.createElement('div'); + this.dialog_.insertBefore(fake, this.dialog_.firstChild); + fake.tabIndex = -1; + fake.focus(); + this.dialog_.removeChild(fake); + } else { + this.dialog_.focus(); + } + + const redirectedEvent = document.createEvent('MouseEvents'); + redirectedEvent.initMouseEvent( + e.type, + e.bubbles, + e.cancelable, + window, + e.detail, + e.screenX, + e.screenY, + e.clientX, + e.clientY, + e.ctrlKey, + e.altKey, + e.shiftKey, + e.metaKey, + e.button, + e.relatedTarget, + ); + this.dialog_.dispatchEvent(redirectedEvent); + e.stopPropagation(); + }, + + /** + * Focuses on the first focusable element within the dialog. This will always blur the current + * focus, even if nothing within the dialog is found. + */ + focus_() { + // Find element with `autofocus` attribute, or fall back to the first form/tabindex control. + let target = this.dialog_.querySelector('[autofocus]:not([disabled])'); + if (!target && this.dialog_.tabIndex >= 0) { + target = this.dialog_; + } + if (!target) { + target = findFocusableElementWithin(this.dialog_); + } + safeBlur(document.activeElement); + target && target.focus(); + }, + + /** + * Sets the zIndex for the backdrop and dialog. + * + * @param {number} dialogZ + * @param {number} backdropZ + */ + updateZIndex(dialogZ, backdropZ) { + if (dialogZ < backdropZ) { + throw new Error('dialogZ should never be < backdropZ'); + } + this.dialog_.style.zIndex = dialogZ; + this.backdrop_.style.zIndex = backdropZ; + }, + + /** + * Shows the dialog. If the dialog is already open, this does nothing. + */ + show() { + if (!this.dialog_.open) { + this.setOpen(true); + this.focus_(); + } + }, + + /** + * Show this dialog modally. + */ + showModal() { + if (this.dialog_.hasAttribute('open')) { + throw new Error( + "Failed to execute 'showModal' on dialog: The element is already open, and therefore cannot be opened modally.", + ); + } + if (!isConnected(this.dialog_)) { + throw new Error( + "Failed to execute 'showModal' on dialog: The element is not in a Document.", + ); + } + if (!dialogPolyfill.dm.pushDialog(this)) { + throw new Error( + "Failed to execute 'showModal' on dialog: There are too many open modal dialogs.", + ); + } + + if (createsStackingContext(this.dialog_.parentElement)) { + console.warn( + 'A dialog is being shown inside a stacking context. ' + + 'This may cause it to be unusable. For more information, see this link: ' + + 'https://github.com/GoogleChrome/dialog-polyfill/#stacking-context', + ); + } + + this.setOpen(true); + this.openAsModal_ = true; + + // Optionally center vertically, relative to the current viewport. + if (dialogPolyfill.needsCentering(this.dialog_)) { + dialogPolyfill.reposition(this.dialog_); + this.replacedStyleTop_ = true; + } else { + this.replacedStyleTop_ = false; + } + + // Insert backdrop. + this.dialog_.parentNode.insertBefore( + this.backdrop_, + this.dialog_.nextSibling, + ); + + // Focus on whatever inside the dialog. + this.focus_(); + }, + + /** + * Closes this HTMLDialogElement. This is optional vs clearing the open + * attribute, however this fires a 'close' event. + * + * @param {string=} opt_returnValue to use as the returnValue + */ + close(opt_returnValue) { + if (!this.dialog_.hasAttribute('open')) { + throw new Error( + "Failed to execute 'close' on dialog: The element does not have an 'open' attribute, and therefore cannot be closed.", + ); + } + this.setOpen(false); + + // Leave returnValue untouched in case it was set directly on the element + if (opt_returnValue !== undefined) { + this.dialog_.returnValue = opt_returnValue; + } + + // Triggering "close" event for any attached listeners on the . + const closeEvent = new supportCustomEvent('close', { + bubbles: false, + cancelable: false, + }); + safeDispatchEvent(this.dialog_, closeEvent); + }, + }; + + dialogPolyfill.reposition = function (element) { + const scrollTop = + document.body.scrollTop || document.documentElement.scrollTop; + const topValue = + scrollTop + (window.innerHeight - element.offsetHeight) / 2; + element.style.top = `${Math.max(scrollTop, topValue)}px`; + }; + + dialogPolyfill.isInlinePositionSetByStylesheet = function (element) { + for (let i = 0; i < document.styleSheets.length; ++i) { + const styleSheet = document.styleSheets[i]; + let cssRules = null; + // Some browsers throw on cssRules. + try { + cssRules = styleSheet.cssRules; + } catch (e) {} + if (!cssRules) { + continue; + } + for (let j = 0; j < cssRules.length; ++j) { + const rule = cssRules[j]; + let selectedNodes = null; + // Ignore errors on invalid selector texts. + try { + selectedNodes = document.querySelectorAll(rule.selectorText); + } catch (e) {} + if (!selectedNodes || !inNodeList(selectedNodes, element)) { + continue; + } + const cssTop = rule.style.getPropertyValue('top'); + const cssBottom = rule.style.getPropertyValue('bottom'); + if ( + (cssTop && cssTop !== 'auto') || + (cssBottom && cssBottom !== 'auto') + ) { + return true; + } + } + } + return false; + }; + + dialogPolyfill.needsCentering = function (dialog) { + const computedStyle = window.getComputedStyle(dialog); + if (computedStyle.position !== 'absolute') { + return false; + } + + // We must determine whether the top/bottom specified value is non-auto. In + // WebKit/Blink, checking computedStyle.top == 'auto' is sufficient, but + // Firefox returns the used value. So we do this crazy thing instead: check + // the inline style and then go through CSS rules. + if ( + (dialog.style.top !== 'auto' && dialog.style.top !== '') || + (dialog.style.bottom !== 'auto' && dialog.style.bottom !== '') + ) { + return false; + } + return !dialogPolyfill.isInlinePositionSetByStylesheet(dialog); + }; + + /** + * @param {!Element} element to force upgrade + */ + dialogPolyfill.forceRegisterDialog = function (element) { + if (window.HTMLDialogElement || element.showModal) { + console.warn( + 'This browser already supports , the polyfill ' + + 'may not work correctly', + element, + ); + } + if (element.localName !== 'dialog') { + throw new Error( + 'Failed to register dialog: The element is not a dialog.', + ); + } + new dialogPolyfillInfo(/** @type {!HTMLDialogElement} */ element); + }; + + /** + * @param {!Element} element to upgrade, if necessary + */ + dialogPolyfill.registerDialog = function (element) { + if (!element.showModal) { + dialogPolyfill.forceRegisterDialog(element); + } + }; + + /** + * @constructor + */ + dialogPolyfill.DialogManager = function () { + /** @type {!Array} */ + this.pendingDialogStack = []; + + const checkDOM = this.checkDOM_.bind(this); + + // The overlay is used to simulate how a modal dialog blocks the document. + // The blocking dialog is positioned on top of the overlay, and the rest of + // the dialogs on the pending dialog stack are positioned below it. In the + // actual implementation, the modal dialog stacking is controlled by the + // top layer, where z-index has no effect. + this.overlay = document.createElement('div'); + this.overlay.className = '_dialog_overlay'; + this.overlay.addEventListener('click', (e) => { + this.forwardTab_ = undefined; + e.stopPropagation(); + checkDOM([]); // sanity-check DOM + }); + + this.handleKey_ = this.handleKey_.bind(this); + this.handleFocus_ = this.handleFocus_.bind(this); + + this.zIndexLow_ = 100000; + this.zIndexHigh_ = 100000 + 150; + + this.forwardTab_ = undefined; + + if ('MutationObserver' in window) { + this.mo_ = new MutationObserver((records) => { + let removed = []; + records.forEach((rec) => { + for (var i = 0, c; (c = rec.removedNodes[i]); ++i) { + if (!(c instanceof Element)) { + continue; + } else if (c.localName === 'dialog') { + removed.push(c); + } + removed = removed.concat(c.querySelectorAll('dialog')); + } + }); + removed.length && checkDOM(removed); + }); + } + }; + + /** + * Called on the first modal dialog being shown. Adds the overlay and related + * handlers. + */ + dialogPolyfill.DialogManager.prototype.blockDocument = function () { + document.documentElement.addEventListener('focus', this.handleFocus_, true); + document.addEventListener('keydown', this.handleKey_); + this.mo_ && this.mo_.observe(document, { childList: true, subtree: true }); + }; + + /** + * Called on the first modal dialog being removed, i.e., when no more modal + * dialogs are visible. + */ + dialogPolyfill.DialogManager.prototype.unblockDocument = function () { + document.documentElement.removeEventListener( + 'focus', + this.handleFocus_, + true, + ); + document.removeEventListener('keydown', this.handleKey_); + this.mo_ && this.mo_.disconnect(); + }; + + /** + * Updates the stacking of all known dialogs. + */ + dialogPolyfill.DialogManager.prototype.updateStacking = function () { + let zIndex = this.zIndexHigh_; + + for (var i = 0, dpi; (dpi = this.pendingDialogStack[i]); ++i) { + dpi.updateZIndex(--zIndex, --zIndex); + if (i === 0) { + this.overlay.style.zIndex = --zIndex; + } + } + + // Make the overlay a sibling of the dialog itself. + const last = this.pendingDialogStack[0]; + if (last) { + const p = last.dialog.parentNode || document.body; + p.appendChild(this.overlay); + } else if (this.overlay.parentNode) { + this.overlay.parentNode.removeChild(this.overlay); + } + }; + + /** + * @param {Element} candidate to check if contained or is the top-most modal dialog + * @return {boolean} whether candidate is contained in top dialog + */ + dialogPolyfill.DialogManager.prototype.containedByTopDialog_ = function ( + candidate, + ) { + while ((candidate = findNearestDialog(candidate))) { + for (var i = 0, dpi; (dpi = this.pendingDialogStack[i]); ++i) { + if (dpi.dialog === candidate) { + return i === 0; // only valid if top-most + } + } + candidate = candidate.parentElement; + } + return false; + }; + + dialogPolyfill.DialogManager.prototype.handleFocus_ = function (event) { + const target = event.composedPath ? event.composedPath()[0] : event.target; + + if (this.containedByTopDialog_(target)) { + return; + } + + if (document.activeElement === document.documentElement) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + safeBlur(/** @type {Element} */ target); + + if (this.forwardTab_ === undefined) { + return; + } // move focus only from a tab key + + const dpi = this.pendingDialogStack[0]; + const { dialog } = dpi; + const position = dialog.compareDocumentPosition(target); + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + if (this.forwardTab_) { + // forward + dpi.focus_(); + } else if (target !== document.documentElement) { + // backwards if we're not already focused on + document.documentElement.focus(); + } + } else { + // TODO: Focus after the dialog, is ignored. + } + + return false; + }; + + dialogPolyfill.DialogManager.prototype.handleKey_ = function (event) { + this.forwardTab_ = undefined; + if (event.keyCode === 27) { + event.preventDefault(); + event.stopPropagation(); + const cancelEvent = new supportCustomEvent('cancel', { + bubbles: false, + cancelable: true, + }); + const dpi = this.pendingDialogStack[0]; + if (dpi && safeDispatchEvent(dpi.dialog, cancelEvent)) { + dpi.dialog.close(); + } + } else if (event.keyCode === 9) { + this.forwardTab_ = !event.shiftKey; + } + }; + + /** + * Finds and downgrades any known modal dialogs that are no longer displayed. Dialogs that are + * removed and immediately readded don't stay modal, they become normal. + * + * @param {!Array} removed that have definitely been removed + */ + dialogPolyfill.DialogManager.prototype.checkDOM_ = function (removed) { + // This operates on a clone because it may cause it to change. Each change also calls + // updateStacking, which only actually needs to happen once. But who removes many modal dialogs + // at a time?! + const clone = this.pendingDialogStack.slice(); + clone.forEach((dpi) => { + if (removed.indexOf(dpi.dialog) !== -1) { + dpi.downgradeModal(); + } else { + dpi.maybeHideModal(); + } + }); + }; + + /** + * @param {!dialogPolyfillInfo} dpi + * @return {boolean} whether the dialog was allowed + */ + dialogPolyfill.DialogManager.prototype.pushDialog = function (dpi) { + const allowed = (this.zIndexHigh_ - this.zIndexLow_) / 2 - 1; + if (this.pendingDialogStack.length >= allowed) { + return false; + } + if (this.pendingDialogStack.unshift(dpi) === 1) { + this.blockDocument(); + } + this.updateStacking(); + return true; + }; + + /** + * @param {!dialogPolyfillInfo} dpi + */ + dialogPolyfill.DialogManager.prototype.removeDialog = function (dpi) { + const index = this.pendingDialogStack.indexOf(dpi); + if (index === -1) { + return; + } + + this.pendingDialogStack.splice(index, 1); + if (this.pendingDialogStack.length === 0) { + this.unblockDocument(); + } + this.updateStacking(); + }; + + dialogPolyfill.dm = new dialogPolyfill.DialogManager(); + dialogPolyfill.formSubmitter = null; + dialogPolyfill.imagemapUseValue = null; + + /** + * Installs global handlers, such as click listers and native method overrides. These are needed + * even if a no dialog is registered, as they deal with
. + */ + if (window.HTMLDialogElement === undefined) { + /** + * If HTMLFormElement translates method="DIALOG" into 'get', then replace the descriptor with + * one that returns the correct value. + */ + const testForm = document.createElement('form'); + testForm.setAttribute('method', 'dialog'); + if (testForm.method !== 'dialog') { + const methodDescriptor = Object.getOwnPropertyDescriptor( + HTMLFormElement.prototype, + 'method', + ); + if (methodDescriptor) { + // nb. Some older iOS and older PhantomJS fail to return the descriptor. Don't do anything + // and don't bother to update the element. + const realGet = methodDescriptor.get; + methodDescriptor.get = function () { + if (isFormMethodDialog(this)) { + return 'dialog'; + } + return realGet.call(this); + }; + const realSet = methodDescriptor.set; + /** @this {HTMLElement} */ + methodDescriptor.set = function (v) { + if (typeof v === 'string' && v.toLowerCase() === 'dialog') { + return this.setAttribute('method', v); + } + return realSet.call(this, v); + }; + Object.defineProperty( + HTMLFormElement.prototype, + 'method', + methodDescriptor, + ); + } + } + + /** + * Global 'click' handler, to capture the or