├── .editorconfig ├── .gitignore ├── tsconfig.json ├── LICENSE ├── index.d.ts ├── package.json ├── src ├── types.ts ├── observer.ts ├── index.ts └── listeners.ts ├── index.js └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{md,markdown}] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Note: index.js and index-fn.d.ts are hand-written, not generated 5 | 6 | # Editor directories and files 7 | .idea/ 8 | .vscode/ 9 | *.suo 10 | *.ntvs* 11 | *.njsproj 12 | *.sln 13 | *.sw? 14 | 15 | # OS files 16 | .DS_Store 17 | Thumbs.db 18 | 19 | # Logs 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # Environment files 25 | .env 26 | .env.local 27 | .env.*.local 28 | 29 | # TypeScript 30 | *.tsbuildinfo 31 | 32 | # Test coverage 33 | coverage/ 34 | .nyc_output/ 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ESNext", 5 | // "module": "ESNext", 6 | "moduleResolution": "Node16", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "declaration": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUncheckedIndexedAccess": true, 15 | "downlevelIteration": true, 16 | "outDir": "./", 17 | "rootDir": "./src", 18 | "lib": ["DOM", "esnext"] 19 | }, 20 | "include": ["src/index.ts"], 21 | "exclude": ["node_modules", "**/*.test.*"] 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Takahiro Arai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The set of legal values for the {@link HTMLDialogElement.closedBy | closedBy} 3 | * attribute / property. 4 | * 5 | * * `"any"` – Allow all closing interactions (default). 6 | * * `"closerequest"` – Ignore backdrop clicks, allow Escape & 7 | * `close()` calls. 8 | * * `"none"` – Disallow backdrop clicks *and* Escape. 9 | */ 10 | export type ClosedBy = "any" | "closerequest" | "none"; 11 | 12 | /** 13 | * Detects native support for the `closedBy` property. If this function returns 14 | * `true`, **no** polyfill is needed because the user‑agent already exposes 15 | * the expected behavior. 16 | */ 17 | export function isSupported(): boolean; 18 | 19 | /** Returns `true` once {@link apply} has run successfully. */ 20 | export function isPolyfilled(): boolean; 21 | 22 | /** 23 | * Applies the polyfill exactly **once**. Re‑invocations are ignored. When the 24 | * current engine already supports `closedBy`, the function becomes a no‑op as 25 | * well. 26 | */ 27 | export function apply(): void; 28 | 29 | declare global { 30 | interface HTMLDialogElement { 31 | closedBy: ClosedBy; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dialog-closedby-polyfill", 3 | "version": "1.1.0", 4 | "description": "Polyfill for the HTMLDialogElement closedBy attribute", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "exports": { 8 | ".": { 9 | "types": "./index.d.ts", 10 | "import": "./index.js", 11 | "require": "./index.js" 12 | } 13 | }, 14 | "files": [ 15 | "index.js", 16 | "index.d.ts" 17 | ], 18 | "scripts": { 19 | "build": "esbuild src/index.ts --bundle --format=esm --outfile=index.js", 20 | "prepublishOnly": "npm run build" 21 | }, 22 | "keywords": [ 23 | "dialog", 24 | "closedby", 25 | "polyfill", 26 | "html5", 27 | "web-components" 28 | ], 29 | "author": "Takahiro Arai (https://www.tak-dcxi.com)", 30 | "license": "MIT", 31 | "devDependencies": { 32 | "@types/node": "^20.0.0", 33 | "esbuild": "^0.25.4", 34 | "typescript": "^5.0.0" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/tak-dcxi/dialog-closedby-polyfill.git" 39 | }, 40 | "bugs": { 41 | "url": "https://github.com/tak-dcxi/dialog-closedby-polyfill/issues" 42 | }, 43 | "homepage": "https://github.com/tak-dcxi/dialog-closedby-polyfill#readme" 44 | } 45 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The set of legal values for the {@link HTMLDialogElement.closedBy | closedBy} 3 | * attribute / property. 4 | * 5 | * * `"any"` – Allow all closing interactions (default). 6 | * * `"closerequest"` – Ignore backdrop clicks, allow Escape & 7 | * `close()` calls. 8 | * * `"none"` – Disallow backdrop clicks *and* Escape. 9 | */ 10 | export type ClosedBy = "any" | "closerequest" | "none"; 11 | 12 | /** 13 | * Internal record that bundles together every handler and observer attached to 14 | * a particular `` element. Storing these in a {@link WeakMap} allows 15 | * for automatic garbage collection once the dialog node leaves the document 16 | * tree. 17 | */ 18 | export interface DialogListeners { 19 | /** 20 | * Document-level `keydown` handler shared by all open modal dialogs. It is 21 | * stored redundantly in every record so that we can remove it conditionally 22 | * when the last dialog closes. 23 | */ 24 | handleEscape: (event: KeyboardEvent) => void; 25 | 26 | /** Mouse click handler installed on the dialog element. */ 27 | handleClick: (event: MouseEvent) => void; 28 | 29 | /** Document-level click handler that detects backdrop clicks even when backdrop has display: none. */ 30 | handleDocClick: (e: MouseEvent) => void; 31 | 32 | /** `cancel` event handler installed on the dialog element. */ 33 | handleCancel: (event: Event) => void; 34 | 35 | /** 36 | * Attribute observer that tracks runtime changes to `closedby`. Each dialog 37 | * owns its individual observer instance so that `disconnect()` can be called 38 | * deterministically on `close()`. 39 | */ 40 | attrObserver: MutationObserver; 41 | } 42 | -------------------------------------------------------------------------------- /src/observer.ts: -------------------------------------------------------------------------------- 1 | import { attachDialog, detachDialog } from "./listeners.js"; 2 | 3 | /** 4 | * Sets up a tree‑wide observer for a given {@link Document | ShadowRoot}. It 5 | * reacts to the following events: 6 | * 7 | * 1. A *closedBy*‑decorated dialog is **added** → `attachDialog()`. 8 | * 2. Such a dialog is **removed** from the subtree → `detachDialog()`. 9 | * 3. The dialog’s `open` attribute flips while it remains in the tree 10 | * (handled via patched `showModal` / `close`). 11 | */ 12 | export function observeRoot(root: Document | ShadowRoot): void { 13 | /* Bootstrap existing instances */ 14 | root.querySelectorAll("dialog[closedby]").forEach((d) => { 15 | if (d instanceof HTMLDialogElement && d.open) attachDialog(d); 16 | }); 17 | 18 | const rootObserver = new MutationObserver((mutations) => { 19 | mutations.forEach((m) => { 20 | /* Handle added nodes */ 21 | m.addedNodes.forEach((node) => { 22 | if ( 23 | node instanceof HTMLDialogElement && 24 | node.open && 25 | node.hasAttribute("closedby") 26 | ) { 27 | attachDialog(node); 28 | } 29 | if (node instanceof Element) { 30 | node.querySelectorAll("dialog[closedby]").forEach((d) => { 31 | if (d instanceof HTMLDialogElement && d.open) attachDialog(d); 32 | }); 33 | } 34 | }); 35 | 36 | /* Handle removed nodes */ 37 | m.removedNodes.forEach((node) => { 38 | if (node instanceof HTMLDialogElement) detachDialog(node); 39 | if (node instanceof Element) 40 | node.querySelectorAll("dialog").forEach(detachDialog); 41 | }); 42 | }); 43 | }); 44 | 45 | const observedTarget = root === document ? document.body : root; 46 | rootObserver.observe(observedTarget, { childList: true, subtree: true }); 47 | } 48 | 49 | /** Recursively collects every ShadowRoot below a given element. */ 50 | function findShadowRoots(el: Element): ShadowRoot[] { 51 | const out: ShadowRoot[] = []; 52 | if (el.shadowRoot) out.push(el.shadowRoot); 53 | for (const child of Array.from(el.children)) 54 | out.push(...findShadowRoots(child)); 55 | return out; 56 | } 57 | 58 | /** 59 | * Initializes observation for the document *and* all current / future 60 | * ShadowRoots. This is invoked once from {@link apply}. 61 | */ 62 | export function setupObservers(): void { 63 | observeRoot(document); 64 | 65 | /* Existing ShadowRoots (static page load) */ 66 | if (document.body) findShadowRoots(document.body).forEach(observeRoot); 67 | 68 | /* Future ShadowRoots created via attachShadow() */ 69 | const originalAttachShadow = HTMLElement.prototype.attachShadow; 70 | HTMLElement.prototype.attachShadow = function attachShadowPatched( 71 | init: ShadowRootInit 72 | ): ShadowRoot { 73 | const shadowRoot = originalAttachShadow.call(this, init); 74 | observeRoot(shadowRoot); 75 | return shadowRoot; 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { setupObservers } from "./observer.js"; 2 | import { attachDialog, detachDialog } from "./listeners.js"; 3 | import { ClosedBy } from "./types.js"; 4 | 5 | /* -------------------------------------------------------------------------- */ 6 | /* Public helper utilities */ 7 | /* -------------------------------------------------------------------------- */ 8 | 9 | /** Tracks whether the polyfill has already patched prototypes. */ 10 | let polyfilled = false; 11 | 12 | /** 13 | * Detects native support for the `closedBy` property. If this function returns 14 | * `true`, **no** polyfill is needed because the user‑agent already exposes 15 | * the expected behavior. 16 | */ 17 | export function isSupported(): boolean { 18 | return ( 19 | typeof HTMLDialogElement !== "undefined" && 20 | typeof HTMLDialogElement.prototype === "object" && 21 | "closedBy" in HTMLDialogElement.prototype 22 | ); 23 | } 24 | 25 | /** Returns `true` once {@link apply} has run successfully. */ 26 | export function isPolyfilled(): boolean { 27 | return polyfilled; 28 | } 29 | 30 | /* -------------------------------------------------------------------------- */ 31 | /* Polyfill entry point */ 32 | /* -------------------------------------------------------------------------- */ 33 | 34 | /** 35 | * Applies the polyfill exactly **once**. Re‑invocations are ignored. When the 36 | * current engine already supports `closedBy`, the function becomes a no‑op as 37 | * well. 38 | */ 39 | export function apply(): void { 40 | "use strict"; // eslint-disable-line strict 41 | 42 | if (polyfilled || isSupported()) return; 43 | 44 | // Older WebKit versions ship *no* implementation at all. Abort early 45 | // because patching non‑existent prototypes would throw. 46 | if (!("showModal" in HTMLDialogElement.prototype)) { 47 | console.warn( 48 | "[closedBy polyfill] API not found – polyfill skipped." 49 | ); 50 | return; 51 | } 52 | 53 | /* Cache original methods */ 54 | const originalShowModal = HTMLDialogElement.prototype.showModal; 55 | const originalClose = HTMLDialogElement.prototype.close; 56 | 57 | /** 58 | * Monkey‑patch {@link HTMLDialogElement.showModal} so that event listeners 59 | * are wired up whenever the dialog opens *and* the author declared 60 | * `closedby`. 61 | */ 62 | HTMLDialogElement.prototype.showModal = function showModalPatched(): void { 63 | originalShowModal.call(this); 64 | 65 | // Guard: could be detached from DOM – `.open` would be false. 66 | if (!this.open) return; 67 | 68 | if (this.hasAttribute("closedby")) attachDialog(this); 69 | }; 70 | 71 | /** 72 | * Ensures that listeners are removed before delegating to the native 73 | * `close()` implementation. 74 | */ 75 | HTMLDialogElement.prototype.close = function closePatched( 76 | returnValue?: string 77 | ): void { 78 | detachDialog(this); 79 | originalClose.call(this, returnValue); 80 | }; 81 | 82 | /** 83 | * Defines the JavaScript property counterpart for the `closedby` content 84 | * attribute. Reads return the normalized {@link ClosedBy} semantic. Writes 85 | * update the underlying attribute **and** synchronies listeners in real 86 | * time when the dialog is currently open. 87 | */ 88 | Object.defineProperty(HTMLDialogElement.prototype, "closedBy", { 89 | get(): ClosedBy { 90 | const v = this.getAttribute("closedby"); 91 | return v === "closerequest" || v === "none" ? v : "any"; 92 | }, 93 | set(value: ClosedBy) { 94 | if (value === "any" || value === "closerequest" || value === "none") { 95 | this.setAttribute("closedby", value); 96 | } else { 97 | console.warn( 98 | `[closedBy polyfill] Invalid value '${value}'. Falling back to 'any'.` 99 | ); 100 | this.setAttribute("closedby", "any"); 101 | } 102 | 103 | // Keep listeners in sync with the current open state 104 | if (this.open) { 105 | if (this.hasAttribute("closedby")) { 106 | attachDialog(this); 107 | } else { 108 | detachDialog(this); 109 | } 110 | } 111 | }, 112 | enumerable: true, 113 | configurable: true, 114 | }); 115 | 116 | /* Kick‑off global observers */ 117 | setupObservers(); 118 | 119 | polyfilled = true; 120 | } 121 | 122 | /* -------------------------------------------------------------------------- */ 123 | /* Auto-apply polyfill when imported */ 124 | /* -------------------------------------------------------------------------- */ 125 | 126 | // Automatically apply the polyfill when this module is imported 127 | if (!isSupported()) apply(); 128 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // src/listeners.ts 2 | var dialogStates = /* @__PURE__ */ new WeakMap(); 3 | function getClosedByValue(dialog) { 4 | const raw = dialog.getAttribute("closedby"); 5 | return raw === "closerequest" || raw === "none" ? raw : "any"; 6 | } 7 | function isTopMost(dialog) { 8 | const stack = Array.from(activeDialogs); 9 | return stack[stack.length - 1] === dialog; 10 | } 11 | var activeDialogs = /* @__PURE__ */ new Set(); 12 | function documentEscapeHandler(event) { 13 | if (event.key !== "Escape" || activeDialogs.size === 0) return; 14 | let shouldPreventDefault = false; 15 | let hasClosableDialog = false; 16 | const dialogsArray = Array.from(activeDialogs).reverse(); 17 | for (const dialog of dialogsArray) { 18 | const closedBy = getClosedByValue(dialog); 19 | if (closedBy === "none") { 20 | shouldPreventDefault = true; 21 | break; 22 | } 23 | if (closedBy === "any" || closedBy === "closerequest") { 24 | dialog.close(); 25 | hasClosableDialog = true; 26 | break; 27 | } 28 | } 29 | if (shouldPreventDefault || hasClosableDialog) { 30 | event.preventDefault(); 31 | } 32 | } 33 | document.addEventListener("keydown", documentEscapeHandler); 34 | function createLightDismissHandler(dialog) { 35 | return function handleDocumentClick(event) { 36 | if (!isTopMost(dialog) || getClosedByValue(dialog) !== "any" || !dialog.open) { 37 | return; 38 | } 39 | const rect = dialog.getBoundingClientRect(); 40 | const { clientX: x, clientY: y } = event; 41 | const inside = rect.top <= y && y <= rect.bottom && rect.left <= x && x <= rect.right; 42 | if (!inside) dialog.close(); 43 | }; 44 | } 45 | function createClickHandler(dialog) { 46 | return function handleClick(event) { 47 | if (event.target !== dialog) return; 48 | if (getClosedByValue(dialog) !== "any") return; 49 | const rect = dialog.getBoundingClientRect(); 50 | const inside = rect.top < event.clientY && event.clientY < rect.bottom && rect.left < event.clientX && event.clientX < rect.right; 51 | if (!inside) dialog.close(); 52 | }; 53 | } 54 | function createCancelHandler(dialog) { 55 | return function handleCancel(event) { 56 | if (getClosedByValue(dialog) === "none") event.preventDefault(); 57 | }; 58 | } 59 | function attachDialog(dialog) { 60 | if (dialogStates.has(dialog)) return; 61 | const state = { 62 | handleEscape: documentEscapeHandler, 63 | handleClick: createClickHandler(dialog), 64 | handleDocClick: createLightDismissHandler(dialog), 65 | handleCancel: createCancelHandler(dialog), 66 | attrObserver: new MutationObserver(() => { 67 | }) 68 | }; 69 | dialog.addEventListener("click", state.handleClick); 70 | dialog.addEventListener("cancel", state.handleCancel); 71 | document.addEventListener("click", state.handleDocClick, true); 72 | state.attrObserver.observe(dialog, { 73 | attributes: true, 74 | attributeFilter: ["closedby"] 75 | }); 76 | activeDialogs.add(dialog); 77 | dialogStates.set(dialog, state); 78 | } 79 | function detachDialog(dialog) { 80 | const state = dialogStates.get(dialog); 81 | if (!state) return; 82 | dialog.removeEventListener("click", state.handleClick); 83 | dialog.removeEventListener("cancel", state.handleCancel); 84 | document.removeEventListener("click", state.handleDocClick, true); 85 | state.attrObserver.disconnect(); 86 | activeDialogs.delete(dialog); 87 | dialogStates.delete(dialog); 88 | } 89 | 90 | // src/observer.ts 91 | function observeRoot(root) { 92 | root.querySelectorAll("dialog[closedby]").forEach((d) => { 93 | if (d instanceof HTMLDialogElement && d.open) attachDialog(d); 94 | }); 95 | const rootObserver = new MutationObserver((mutations) => { 96 | mutations.forEach((m) => { 97 | m.addedNodes.forEach((node) => { 98 | if (node instanceof HTMLDialogElement && node.open && node.hasAttribute("closedby")) { 99 | attachDialog(node); 100 | } 101 | if (node instanceof Element) { 102 | node.querySelectorAll("dialog[closedby]").forEach((d) => { 103 | if (d instanceof HTMLDialogElement && d.open) attachDialog(d); 104 | }); 105 | } 106 | }); 107 | m.removedNodes.forEach((node) => { 108 | if (node instanceof HTMLDialogElement) detachDialog(node); 109 | if (node instanceof Element) 110 | node.querySelectorAll("dialog").forEach(detachDialog); 111 | }); 112 | }); 113 | }); 114 | const observedTarget = root === document ? document.body : root; 115 | rootObserver.observe(observedTarget, { childList: true, subtree: true }); 116 | } 117 | function findShadowRoots(el) { 118 | const out = []; 119 | if (el.shadowRoot) out.push(el.shadowRoot); 120 | for (const child of Array.from(el.children)) 121 | out.push(...findShadowRoots(child)); 122 | return out; 123 | } 124 | function setupObservers() { 125 | observeRoot(document); 126 | if (document.body) findShadowRoots(document.body).forEach(observeRoot); 127 | const originalAttachShadow = HTMLElement.prototype.attachShadow; 128 | HTMLElement.prototype.attachShadow = function attachShadowPatched(init) { 129 | const shadowRoot = originalAttachShadow.call(this, init); 130 | observeRoot(shadowRoot); 131 | return shadowRoot; 132 | }; 133 | } 134 | 135 | // src/index.ts 136 | var polyfilled = false; 137 | function isSupported() { 138 | return typeof HTMLDialogElement !== "undefined" && typeof HTMLDialogElement.prototype === "object" && "closedBy" in HTMLDialogElement.prototype; 139 | } 140 | function isPolyfilled() { 141 | return polyfilled; 142 | } 143 | function apply() { 144 | "use strict"; 145 | if (polyfilled || isSupported()) return; 146 | if (!("showModal" in HTMLDialogElement.prototype)) { 147 | console.warn( 148 | "[closedBy polyfill] API not found \u2013 polyfill skipped." 149 | ); 150 | return; 151 | } 152 | const originalShowModal = HTMLDialogElement.prototype.showModal; 153 | const originalClose = HTMLDialogElement.prototype.close; 154 | HTMLDialogElement.prototype.showModal = function showModalPatched() { 155 | originalShowModal.call(this); 156 | if (!this.open) return; 157 | if (this.hasAttribute("closedby")) attachDialog(this); 158 | }; 159 | HTMLDialogElement.prototype.close = function closePatched(returnValue) { 160 | detachDialog(this); 161 | originalClose.call(this, returnValue); 162 | }; 163 | Object.defineProperty(HTMLDialogElement.prototype, "closedBy", { 164 | get() { 165 | const v = this.getAttribute("closedby"); 166 | return v === "closerequest" || v === "none" ? v : "any"; 167 | }, 168 | set(value) { 169 | if (value === "any" || value === "closerequest" || value === "none") { 170 | this.setAttribute("closedby", value); 171 | } else { 172 | console.warn( 173 | `[closedBy polyfill] Invalid value '${value}'. Falling back to 'any'.` 174 | ); 175 | this.setAttribute("closedby", "any"); 176 | } 177 | if (this.open) { 178 | if (this.hasAttribute("closedby")) { 179 | attachDialog(this); 180 | } else { 181 | detachDialog(this); 182 | } 183 | } 184 | }, 185 | enumerable: true, 186 | configurable: true 187 | }); 188 | setupObservers(); 189 | polyfilled = true; 190 | } 191 | if (!isSupported()) apply(); 192 | export { 193 | apply, 194 | isPolyfilled, 195 | isSupported 196 | }; 197 | -------------------------------------------------------------------------------- /src/listeners.ts: -------------------------------------------------------------------------------- 1 | import { ClosedBy, DialogListeners } from "./types.js"; 2 | 3 | /** Maps every open `` element to its active listeners. */ 4 | const dialogStates = new WeakMap(); 5 | 6 | /* -------------------------------------------------------------------------- */ 7 | /* Helper utilities */ 8 | /* -------------------------------------------------------------------------- */ 9 | 10 | /** 11 | * Normalizes the value of the `closedby` attribute. 12 | * 13 | * @param dialog - The dialog whose attribute is inspected. 14 | * @returns `"any"`, `"closerequest"`, or `"none"`. 15 | */ 16 | function getClosedByValue(dialog: HTMLDialogElement): ClosedBy { 17 | const raw = dialog.getAttribute("closedby"); 18 | return raw === "closerequest" || raw === "none" ? raw : "any"; 19 | } 20 | 21 | /** 22 | * NOTE: 23 | * By design, **only the top-most modal dialog in the pending-dialog stack 24 | * should receive user input (pointer and keyboard events)**. 25 | * Lower-layer dialogs are effectively inert until they become top-most. 26 | * The `isTopMost()` helper enforces this rule wherever user actions need 27 | * to be filtered. 28 | */ 29 | 30 | /** 31 | * Returns `true` if the dialog is the top-most (last added) modal in the stack. 32 | * 33 | * @param dialog - Dialog candidate. 34 | */ 35 | function isTopMost(dialog: HTMLDialogElement): boolean { 36 | const stack = Array.from(activeDialogs); 37 | return stack[stack.length - 1] === dialog; 38 | } 39 | 40 | /* -------------------------------------------------------------------------- */ 41 | /* Document-level Escape delegation */ 42 | /* -------------------------------------------------------------------------- */ 43 | 44 | /** Set of currently open modal dialogs that define `closedby`. */ 45 | const activeDialogs = new Set(); 46 | 47 | /** 48 | * Global `keydown` handler attached **once** to document to mirror 49 | * UA behavior for the *Escape* key. When multiple modal dialogs are stacked 50 | * (custom UI), only the topmost (most recently opened) dialog is processed 51 | * to maintain proper modal behavior. 52 | * 53 | * @param event - The keyboard event to handle 54 | * 55 | * @remarks 56 | * This implementation processes dialogs in reverse order of their addition 57 | * to ensure that only the topmost dialog in the stack is affected by the 58 | * Escape key. This follows standard modal dialog UX patterns where only 59 | * the active/focused dialog should respond to dismissal actions. 60 | */ 61 | function documentEscapeHandler(event: KeyboardEvent): void { 62 | if (event.key !== "Escape" || activeDialogs.size === 0) return; 63 | 64 | let shouldPreventDefault = false; 65 | let hasClosableDialog = false; 66 | 67 | // Process dialogs in reverse order (most recently added first) 68 | // to handle the topmost dialog in the stack 69 | const dialogsArray = Array.from(activeDialogs).reverse(); 70 | 71 | for (const dialog of dialogsArray) { 72 | const closedBy = getClosedByValue(dialog); 73 | 74 | if (closedBy === "none") { 75 | // Dialog prevents closure - stop processing and prevent default 76 | shouldPreventDefault = true; 77 | break; 78 | } 79 | 80 | if (closedBy === "any" || closedBy === "closerequest") { 81 | // Close only the topmost closable dialog and stop processing 82 | dialog.close(); 83 | hasClosableDialog = true; 84 | break; 85 | } 86 | } 87 | 88 | // Prevent default browser behavior (like exiting fullscreen) when any dialog 89 | // handles the ESC key, either by preventing closure or by closing the dialog 90 | if (shouldPreventDefault || hasClosableDialog) { 91 | event.preventDefault(); 92 | } 93 | } 94 | 95 | document.addEventListener("keydown", documentEscapeHandler); 96 | 97 | /* -------------------------------------------------------------------------- */ 98 | /* Light-dismiss handler for hidden backdrops */ 99 | /* -------------------------------------------------------------------------- */ 100 | 101 | /** 102 | * Creates a document-wide click handler that emulates backdrop clicks. 103 | * 104 | * @param dialog - The dialog to be controlled. 105 | */ 106 | function createLightDismissHandler(dialog: HTMLDialogElement) { 107 | /** 108 | * Handles clicks captured at the document level. 109 | * 110 | * @param event - Pointer event. 111 | */ 112 | return function handleDocumentClick(event: MouseEvent): void { 113 | // Only the top-most, open dialog with closedby="any" can be dismissed. 114 | if ( 115 | !isTopMost(dialog) || 116 | getClosedByValue(dialog) !== "any" || 117 | !dialog.open 118 | ) { 119 | return; 120 | } 121 | 122 | const rect = dialog.getBoundingClientRect(); 123 | const { clientX: x, clientY: y } = event; 124 | const inside = 125 | rect.top <= y && y <= rect.bottom && rect.left <= x && x <= rect.right; 126 | 127 | if (!inside) dialog.close(); 128 | }; 129 | } 130 | 131 | /* -------------------------------------------------------------------------- */ 132 | /* cancel / click handlers bound per dialog */ 133 | /* -------------------------------------------------------------------------- */ 134 | 135 | /** 136 | * Generates a click handler that closes the dialog when the backdrop 137 | * (the element itself) is clicked and `closedby="any"`. 138 | * 139 | * @param dialog - Host dialog element. 140 | */ 141 | function createClickHandler(dialog: HTMLDialogElement) { 142 | return function handleClick(event: MouseEvent): void { 143 | if (event.target !== dialog) return; 144 | if (getClosedByValue(dialog) !== "any") return; 145 | 146 | const rect = dialog.getBoundingClientRect(); 147 | const inside = 148 | rect.top < event.clientY && 149 | event.clientY < rect.bottom && 150 | rect.left < event.clientX && 151 | event.clientX < rect.right; 152 | 153 | if (!inside) dialog.close(); 154 | }; 155 | } 156 | 157 | /** 158 | * Generates a `cancel` handler (triggered by ESC) that respects `closedby`. 159 | * 160 | * @param dialog - Host dialog element. 161 | */ 162 | function createCancelHandler(dialog: HTMLDialogElement) { 163 | return function handleCancel(event: Event): void { 164 | if (getClosedByValue(dialog) === "none") event.preventDefault(); 165 | }; 166 | } 167 | 168 | /* -------------------------------------------------------------------------- */ 169 | /* Public API */ 170 | /* -------------------------------------------------------------------------- */ 171 | 172 | /** 173 | * Attaches all required listeners to a `` element. 174 | * 175 | * @param dialog - Target dialog element. 176 | * 177 | * @remarks 178 | * The function is idempotent; subsequent calls on the same element are no-ops. 179 | */ 180 | export function attachDialog(dialog: HTMLDialogElement): void { 181 | if (dialogStates.has(dialog)) return; // already initialized 182 | 183 | const state: DialogListeners = { 184 | handleEscape: documentEscapeHandler, 185 | handleClick: createClickHandler(dialog), 186 | handleDocClick: createLightDismissHandler(dialog), 187 | handleCancel: createCancelHandler(dialog), 188 | attrObserver: new MutationObserver(() => { 189 | /* intentionally empty: reactivity handled via getClosedByValue() */ 190 | }), 191 | }; 192 | 193 | dialog.addEventListener("click", state.handleClick); 194 | dialog.addEventListener("cancel", state.handleCancel); 195 | 196 | // Capture phase to avoid stopPropagation() in frameworks 197 | document.addEventListener("click", state.handleDocClick, true); 198 | 199 | state.attrObserver.observe(dialog, { 200 | attributes: true, 201 | attributeFilter: ["closedby"], 202 | }); 203 | 204 | activeDialogs.add(dialog); 205 | dialogStates.set(dialog, state); 206 | } 207 | 208 | /** 209 | * Removes every listener and observer previously installed by {@link attachDialog}. 210 | * 211 | * @param dialog - Dialog element being detached. 212 | */ 213 | export function detachDialog(dialog: HTMLDialogElement): void { 214 | const state = dialogStates.get(dialog); 215 | if (!state) return; 216 | 217 | dialog.removeEventListener("click", state.handleClick); 218 | dialog.removeEventListener("cancel", state.handleCancel); 219 | document.removeEventListener("click", state.handleDocClick, true); 220 | state.attrObserver.disconnect(); 221 | 222 | activeDialogs.delete(dialog); 223 | dialogStates.delete(dialog); 224 | } 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dialog-closedby-polyfill 2 | 3 | A polyfill for the HTMLDialogElement `closedby` attribute, providing control over how modal dialogs can be dismissed. 4 | 5 | > **Note**: The HTML attribute is `closedby` (lowercase), while the JavaScript property is `closedBy` (camelCase). 6 | 7 | ## Features 8 | 9 | - 🎯 Implements the `closedby` attribute for `` elements 10 | - 🔒 Three closing modes: `any`, `closerequest`, and `none` 11 | - 🚀 Zero dependencies 12 | - 📦 TypeScript support included 13 | - 🌐 Works in all modern browsers with `` support 14 | - ✨ Automatically detects native support 15 | 16 | ## Installation 17 | 18 | ```bash 19 | npm install dialog-closedby-polyfill 20 | ``` 21 | 22 | ## Usage 23 | 24 | The polyfill is automatically applied when imported: 25 | 26 | ```javascript 27 | // ES Modules (auto-applies if needed) 28 | import "dialog-closedby-polyfill"; 29 | 30 | // CommonJS (auto-applies if needed) 31 | require("dialog-closedby-polyfill"); 32 | ``` 33 | 34 | ### Manual Control 35 | 36 | If you need more control over when the polyfill is applied: 37 | 38 | ```javascript 39 | import { apply, isSupported } from "dialog-closedby-polyfill"; 40 | 41 | if (!isSupported()) { 42 | apply(); 43 | } 44 | ``` 45 | 46 | Or include it via CDN: 47 | 48 | ```html 49 | 50 | 54 | 55 | 56 | 63 | ``` 64 | 65 | ## Demo 66 | 67 | https://tak-dcxi.github.io/github-pages-demo/closedby.html 68 | 69 | ## How it works 70 | 71 | The `closedby` attribute controls how a modal dialog can be dismissed: 72 | 73 | ### Closing Behavior Matrix 74 | 75 | | `closedby` value | ESC key | Backdrop click | `close()` method | 76 | | ---------------- | ------- | -------------- | ---------------- | 77 | | `"any"` | ✅ | ✅ | ✅ | 78 | | `"closerequest"` | ✅ | ❌ | ✅ | 79 | | `"none"` | ❌ | ❌ | ✅ | 80 | 81 | ### `closedby="any"` (default) 82 | 83 | The dialog can be closed by: 84 | 85 | - Pressing the ESC key 86 | - Clicking the backdrop 87 | - Calling the `close()` method 88 | 89 | ```html 90 | 96 |

any

97 |

This dialog can be closed in any way

98 | 99 |
100 | ``` 101 | 102 | ### `closedby="closerequest"` 103 | 104 | The dialog can be closed by: 105 | 106 | - Pressing the ESC key 107 | - Calling the `close()` method 108 | - ❌ Clicking the backdrop (disabled) 109 | 110 | ```html 111 | 117 |

closerequest

118 |

This dialog cannot be closed by clicking outside

119 | 122 |
123 | ``` 124 | 125 | ### `closedby="none"` 126 | 127 | The dialog can only be closed by: 128 | 129 | - Calling the `close()` method 130 | - ❌ Pressing the ESC key (disabled) 131 | - ❌ Clicking the backdrop (disabled) 132 | 133 | ```html 134 | 140 |

none

141 |

This dialog can only be closed programmatically

142 | 143 |
144 | ``` 145 | 146 | ## JavaScript API 147 | 148 | You can also set the attribute via JavaScript: 149 | 150 | ```javascript 151 | const dialog = document.querySelector("dialog"); 152 | 153 | // Using setAttribute 154 | dialog.setAttribute("closedby", "none"); 155 | 156 | // Using the property (when polyfill is loaded) 157 | dialog.closedBy = "closerequest"; 158 | ``` 159 | 160 | ## Dynamic Changes 161 | 162 | The `closedby` attribute can be changed while the dialog is open: 163 | 164 | ```javascript 165 | const dialog = document.querySelector("dialog"); 166 | dialog.showModal(); 167 | 168 | // Change behavior while dialog is open 169 | setTimeout(() => { 170 | dialog.closedBy = "none"; // Now only closeable via close() method 171 | }, 3000); 172 | ``` 173 | 174 | ## Browser Support 175 | 176 | This polyfill works in all browsers that support the native `` element. 177 | 178 | **Native `closedby` support:** 179 | 180 | - Chrome 134+ 181 | - Safari: Not implemented yet 182 | - Firefox: Not implemented yet 183 | - Edge: 134+ 184 | 185 | **Dialog element support (required for polyfill):** 186 | 187 | - Chrome 37+ 188 | - Firefox 98+ 189 | - Safari 15.4+ 190 | - Edge 79+ 191 | 192 | > **Note**: For browsers without native `closedby` support, this polyfill provides the functionality. For older browsers without `` element support, you'll also need a dialog element polyfill. 193 | 194 | ## API 195 | 196 | ### Functions 197 | 198 | #### `isSupported(): boolean` 199 | 200 | Check if the browser natively supports the `closedby` attribute. 201 | 202 | ```javascript 203 | import { isSupported } from "dialog-closedby-polyfill"; 204 | 205 | if (isSupported()) { 206 | console.log("Native closedby support available!"); 207 | } 208 | ``` 209 | 210 | #### `isPolyfilled(): boolean` 211 | 212 | Check if the polyfill has already been applied. 213 | 214 | ```javascript 215 | import { isPolyfilled } from "dialog-closedby-polyfill"; 216 | 217 | if (isPolyfilled()) { 218 | console.log("Polyfill has been applied"); 219 | } 220 | ``` 221 | 222 | #### `apply(): void` 223 | 224 | Manually apply the polyfill. This is called automatically when importing the main module. 225 | 226 | ```javascript 227 | import { apply } from "dialog-closedby-polyfill"; 228 | 229 | apply(); // Apply the polyfill 230 | ``` 231 | 232 | ## TypeScript Support 233 | 234 | TypeScript definitions are included. The polyfill extends the `HTMLDialogElement` interface: 235 | 236 | ```typescript 237 | interface HTMLDialogElement { 238 | closedBy: "any" | "closerequest" | "none"; 239 | } 240 | ``` 241 | 242 | ## Implementation Details 243 | 244 | The polyfill works by: 245 | 246 | 1. **Extending HTMLDialogElement**: Adds the `closedby` property to dialog elements 247 | 1. **Intercepting `showModal()`**: Sets up event listeners when a modal dialog is opened 248 | 1. **Handling Events**: 249 | - `keydown` event for ESC key detection 250 | - `click` event on the dialog for backdrop clicks 251 | - `cancel` event prevention based on `closedby` value 252 | 1. **Observing Changes**: Uses MutationObserver to watch for attribute changes 253 | 1. **Cleanup**: Removes event listeners when dialog is closed 254 | 255 | ## Differences from Native Implementation 256 | 257 | This polyfill aims to match the native implementation as closely as possible. However, there might be minor differences in edge cases. Please report any discrepancies you find. 258 | 259 | ## Contributing 260 | 261 | Contributions are welcome! Please feel free to submit a Pull Request. 262 | 263 | 1. Fork the repository 264 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 265 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 266 | 4. Push to the branch (`git push origin feature/amazing-feature`) 267 | 5. Open a Pull Request 268 | 269 | ## License 270 | 271 | MIT License - see the [LICENSE](LICENSE) file for details 272 | 273 | ## Recommended Polyfills 274 | 275 | ### Invokers Polyfill 276 | 277 | Declarative `command/commandfor` attributes to provide dialog button operations with markup only. 278 | 279 | - **GitHub**: [keithamus/invokers-polyfill](https://github.com/keithamus/invokers-polyfill) 280 | - **Use case**: Simplifies dialog controls without requiring JavaScript event handlers 281 | 282 | ```html 283 | 284 | 287 | 293 |

Heading

294 |

Content

295 | 296 |
297 | ``` 298 | 299 | ## Related Links 300 | 301 | - [MDN Documentation for closedBy](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy) 302 | - [Chrome Platform Status](https://chromestatus.com/feature/5097714453725184) 303 | - [HTML Specification](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element) 304 | 305 | ## Acknowledgments 306 | 307 | This polyfill is inspired by the native implementation and the work of the web standards community. 308 | --------------------------------------------------------------------------------