├── .gitignore ├── CODEOWNERS ├── example ├── content.html ├── shadow-demo.js └── index.html ├── custom-elements-manifest.config.js ├── src ├── index.css └── index.ts ├── .eslintrc.json ├── tsconfig.json ├── test ├── karma.config.cjs └── test.js ├── .github └── workflows │ ├── publish.yml │ └── node.js.yml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── LICENSE ├── vscode.html-custom-data.json ├── package.json ├── README.md └── custom-elements.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/ui-frameworks-reviewers 2 | -------------------------------------------------------------------------------- /example/content.html: -------------------------------------------------------------------------------- 1 | content.html 2 | -------------------------------------------------------------------------------- /custom-elements-manifest.config.js: -------------------------------------------------------------------------------- 1 | import {generateCustomData} from 'cem-plugin-vs-code-custom-data-generator' 2 | import {readme} from '@github/cem-plugin-readme' 3 | 4 | export default { 5 | packagejson: true, 6 | globs: ['src/index.ts'], 7 | plugins: [ 8 | readme(), 9 | generateCustomData() 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* This lives outside of element so it can work without JS */ 2 | details-dialog { 3 | position: fixed; 4 | margin: 10vh auto; 5 | top: 0; 6 | left: 50%; 7 | transform: translateX(-50%); 8 | z-index: 999; 9 | max-height: 80vh; 10 | max-width: 90vw; 11 | width: 448px; 12 | overflow: auto; 13 | } 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:github/browser", 4 | "plugin:github/es6", 5 | "plugin:github/typescript" 6 | ], 7 | "globals": { 8 | "DetailsDialogElement": "readable" 9 | }, 10 | "overrides": [ 11 | { 12 | "files": "test/**/*.js", 13 | "rules": { 14 | "github/unescaped-html-literal": "off" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2017", 5 | "lib": [ 6 | "es2018", 7 | "dom", 8 | "dom.iterable" 9 | ], 10 | "strict": true, 11 | "declaration": true, 12 | "outDir": "dist", 13 | "removeComments": true, 14 | "preserveConstEnums": true 15 | }, 16 | "files": [ 17 | "src/index.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /test/karma.config.cjs: -------------------------------------------------------------------------------- 1 | process.env.CHROME_BIN = require('chromium').path 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | frameworks: ['mocha', 'chai'], 6 | files: [ 7 | {pattern: '../dist/index.js', type: 'module'}, 8 | {pattern: 'test.js', type: 'module'} 9 | ], 10 | reporters: ['mocha'], 11 | port: 9876, 12 | colors: true, 13 | logLevel: config.LOG_INFO, 14 | browsers: ['ChromeHeadless'], 15 | autoWatch: false, 16 | singleRun: true, 17 | concurrency: Infinity 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 14 15 | registry-url: https://registry.npmjs.org/ 16 | cache: npm 17 | - run: npm ci 18 | - run: npm test 19 | - run: npm version ${TAG_NAME} --git-tag-version=false 20 | env: 21 | TAG_NAME: ${{ github.event.release.tag_name }} 22 | - run: npm whoami; npm --ignore-scripts publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 25 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2018 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "16" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "dbaeumer.vscode-eslint" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | // "postCreateCommand": "yarn install", 26 | 27 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | "features": { 30 | "git": "latest" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vscode.html-custom-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": [ 3 | { 4 | "name": "details-dialog", 5 | "description": "### Markup\n\n```html\n
\n Open dialog\n \n Modal content\n \n \n
\n```\n\n## Deferred loading\n\nDialog content can be loaded from a server by embedding an [``][fragment] element.\n\n```html\n
\n Robots\n \n Loading…\n \n
\n```\n\nThe `src` attribute value is copied to the `` the first time the `
` button is toggled open, which starts the server fetch.\n\nIf the `preload` attribute is present, hovering over the `
` element will trigger the server fetch.\n\n## Events\n\n### `details-dialog-close`\n\n`details-dialog-close` event is fired from `` when a request to close the dialog is made from\n\n- pressing escape,\n- submitting a `form[method=\"dialog\"]`\n- clicking on `summary, form button[formmethod=\"dialog\"], [data-close-dialog]`, or\n- `dialog.toggle(false)`\n\nThis event bubbles, and can be canceled to keep the dialog open.\n\n```js\ndocument.addEventListener('details-dialog-close', function(event) {\n if (!confirm('Are you sure?')) {\n event.preventDefault()\n }\n})\n```", 6 | "attributes": [{ "name": "src" }, { "name": "preload" }] 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /example/shadow-demo.js: -------------------------------------------------------------------------------- 1 | class ShadowDialog extends HTMLElement { 2 | constructor() { 3 | super() 4 | const root = this.attachShadow({mode: 'open'}) 5 | root.innerHTML = ` 6 | 40 |
41 | Dialog with Shadow DOM 42 | 43 | 44 | content 45 | 46 | 47 |
` 48 | } 49 | } 50 | 51 | window.customElements.define('shadow-dialog', ShadowDialog) 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/details-dialog-element", 3 | "version": "3.1.3", 4 | "description": "A modal dialog opened with a
button.", 5 | "repository": "github/details-dialog-element", 6 | "main": "dist/index.js", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "type": "module", 10 | "scripts": { 11 | "clean": "rm -rf dist", 12 | "lint": "github-lint", 13 | "prebuild": "npm run clean && mkdir dist", 14 | "build": "tsc && cp src/index.css dist/index.css", 15 | "pretest": "npm run build", 16 | "test": "karma start test/karma.config.cjs", 17 | "prepublishOnly": "npm run build", 18 | "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'" 19 | }, 20 | "keywords": [ 21 | "dialog", 22 | "details" 23 | ], 24 | "license": "MIT", 25 | "files": [ 26 | "dist", 27 | "custom-elements.json", 28 | "vscode.html-custom-data.json" 29 | ], 30 | "prettier": "@github/prettier-config", 31 | "devDependencies": { 32 | "@custom-elements-manifest/analyzer": "^0.6.4", 33 | "@github/cem-plugin-readme": "^0.0.1", 34 | "@github/prettier-config": "0.0.4", 35 | "cem-plugin-vs-code-custom-data-generator": "^1.0.0-beta.5", 36 | "chai": "^4.3.4", 37 | "chromium": "^3.0.3", 38 | "eslint": "^7.32.0", 39 | "eslint-plugin-github": "^4.3.0", 40 | "karma": "^6.3.4", 41 | "karma-chai": "^0.1.0", 42 | "karma-chrome-launcher": "^3.1.0", 43 | "karma-mocha": "^2.0.1", 44 | "karma-mocha-reporter": "^2.2.5", 45 | "mocha": "^9.1.1", 46 | "typescript": "^4.4.3" 47 | }, 48 | "eslintIgnore": [ 49 | "dist/" 50 | ], 51 | "customElements": "custom-elements.json" 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <details-dialog> element 2 | 3 | A modal dialog opened with a <details> button. 4 | 5 | ## DEPRECATION WARNING 6 | 7 | This web component has been deprecated. There are a number of accessibility concerns with this approach and so we no longer recommend using this component. 8 | 9 | ### Accessibility and Usability Concerns 10 | 11 | * Semantically, using a details-summary pattern for a dialog solution can be confusing for screen reader users. 12 | * If the user performs a "find in page" operation on a website using details-dialog elements, the content on those elements will appear when they shouldn't. 13 | * Opening the dialog does not disable scrolling on the underlying page. 14 | 15 | GitHub are moving towards using [a dialog Primer View Component](https://primer.style/view-components/components/alpha/dialog) which enforces certain aspects of the design (such as always having a close button and a title). 16 | 17 | ## Installation 18 | Available on [npm](https://www.npmjs.com/) as [**@github/details-dialog-element**](https://www.npmjs.com/package/@github/details-dialog-element). 19 | ``` 20 | $ npm install --save @github/details-dialog-element 21 | ``` 22 | 23 | ## Usage 24 | 25 | ### Script 26 | 27 | Import as ES modules: 28 | 29 | ```js 30 | import '@github/details-dialog-element' 31 | ``` 32 | 33 | Include with a script tag: 34 | 35 | ```html 36 | 6 | 7 | 8 | 9 | details-dialog-element demo 10 | 11 | 12 | 41 | 42 | 43 |
44 | Bare 45 | 46 |

But you can click anywhere to dismiss without JS.

47 | 48 |
49 |
50 | 51 |
52 | Dialog 53 | 54 |
55 |
56 | Title 57 |
58 |
59 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 60 |
61 | 64 |
65 |
66 |
67 | 68 |
69 | Dialog with <include-fragment> 70 | 71 |
loading
72 |
73 |
74 | 75 |
76 | Dialog with autofocus 77 | 78 |
79 |
80 | Title 81 |
82 |
83 |
84 | 88 |
89 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 90 |
91 | 94 |
95 |
96 |
97 | 98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | const CLOSE_ATTR = 'data-close-dialog' 2 | const CLOSE_SELECTOR = `[${CLOSE_ATTR}]` 3 | 4 | type Target = Disableable | Focusable 5 | 6 | type Disableable = HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement 7 | 8 | type Focusable = HTMLElement 9 | 10 | type SubmitEvent = Event & { submitter: Element | null } 11 | 12 | function autofocus(el: DetailsDialogElement): void { 13 | let autofocusElement = Array.from(el.querySelectorAll('[autofocus]')).filter(focusable)[0] 14 | if (!autofocusElement) { 15 | autofocusElement = el 16 | el.setAttribute('tabindex', '-1') 17 | } 18 | autofocusElement.focus() 19 | } 20 | 21 | function keydown(event: KeyboardEvent): void { 22 | const details = event.currentTarget 23 | if (!(details instanceof Element)) return 24 | if (event.key === 'Escape' || event.key === 'Esc') { 25 | toggleDetails(details, false) 26 | event.stopPropagation() 27 | } else if (event.key === 'Tab') { 28 | restrictTabBehavior(event) 29 | } 30 | } 31 | 32 | function focusable(el: Target): boolean { 33 | return el.tabIndex >= 0 && !(el as Disableable).disabled && visible(el) 34 | } 35 | 36 | function visible(el: Target): boolean { 37 | return ( 38 | !el.hidden && 39 | (!(el as Disableable).type || (el as Disableable).type !== 'hidden') && 40 | (el.offsetWidth > 0 || el.offsetHeight > 0) 41 | ) 42 | } 43 | 44 | function restrictTabBehavior(event: KeyboardEvent): void { 45 | if (!(event.currentTarget instanceof Element)) return 46 | const dialog = event.currentTarget.querySelector('details-dialog') 47 | if (!dialog) return 48 | event.preventDefault() 49 | 50 | const elements: Target[] = Array.from(dialog.querySelectorAll('*')).filter(focusable) 51 | if (elements.length === 0) return 52 | 53 | const movement = event.shiftKey ? -1 : 1 54 | const root = dialog.getRootNode() as Document | ShadowRoot 55 | const currentFocus = dialog.contains(root.activeElement) ? root.activeElement : null 56 | let targetIndex = movement === -1 ? -1 : 0 57 | 58 | if (currentFocus instanceof HTMLElement) { 59 | const currentIndex = elements.indexOf(currentFocus) 60 | if (currentIndex !== -1) { 61 | targetIndex = currentIndex + movement 62 | } 63 | } 64 | 65 | if (targetIndex < 0) { 66 | targetIndex = elements.length - 1 67 | } else { 68 | targetIndex = targetIndex % elements.length 69 | } 70 | 71 | elements[targetIndex].focus() 72 | } 73 | 74 | function allowClosingDialog(details: Element): boolean { 75 | const dialog = details.querySelector('details-dialog') 76 | if (!(dialog instanceof DetailsDialogElement)) return true 77 | 78 | return dialog.dispatchEvent( 79 | new CustomEvent('details-dialog-close', { 80 | bubbles: true, 81 | cancelable: true 82 | }) 83 | ) 84 | } 85 | 86 | function onSummaryClick(event: Event): void { 87 | if (!(event.currentTarget instanceof Element)) return 88 | const details = event.currentTarget.closest('details') 89 | if (!details || !details.hasAttribute('open')) return 90 | 91 | // Prevent summary click events if details-dialog-close was cancelled 92 | if (!allowClosingDialog(details)) { 93 | event.preventDefault() 94 | event.stopPropagation() 95 | } 96 | } 97 | 98 | function trapFocus(dialog: DetailsDialogElement, details: Element): void { 99 | const root = 'getRootNode' in dialog ? (dialog.getRootNode() as Document | ShadowRoot) : document 100 | if (root.activeElement instanceof HTMLElement) { 101 | initialized.set(dialog, {details, activeElement: root.activeElement}) 102 | } 103 | 104 | autofocus(dialog) 105 | ;(details as HTMLElement).addEventListener('keydown', keydown) 106 | } 107 | 108 | function releaseFocus(dialog: DetailsDialogElement, details: Element): void { 109 | for (const form of dialog.querySelectorAll('form')) { 110 | form.reset() 111 | } 112 | const focusElement = findFocusElement(details, dialog) 113 | if (focusElement) focusElement.focus() 114 | ;(details as HTMLElement).removeEventListener('keydown', keydown) 115 | } 116 | 117 | function toggle(event: Event): void { 118 | const details = event.currentTarget 119 | if (!(details instanceof Element)) return 120 | const dialog = details.querySelector('details-dialog') 121 | if (!(dialog instanceof DetailsDialogElement)) return 122 | 123 | if (details.hasAttribute('open')) { 124 | trapFocus(dialog, details) 125 | } else { 126 | releaseFocus(dialog, details) 127 | } 128 | } 129 | 130 | function findFocusElement(details: Element, dialog: DetailsDialogElement): HTMLElement | null { 131 | const state = initialized.get(dialog) 132 | if (state && state.activeElement instanceof HTMLElement) { 133 | return state.activeElement 134 | } else { 135 | return details.querySelector('summary') 136 | } 137 | } 138 | 139 | function toggleDetails(details: Element, open: boolean) { 140 | // Don't update unless state is changing 141 | if (open === details.hasAttribute('open')) return 142 | 143 | if (open) { 144 | details.setAttribute('open', '') 145 | } else if (allowClosingDialog(details)) { 146 | details.removeAttribute('open') 147 | } 148 | } 149 | 150 | function loadIncludeFragment(event: Event) { 151 | const details = event.currentTarget 152 | if (!(details instanceof Element)) return 153 | const dialog = details.querySelector('details-dialog') 154 | if (!(dialog instanceof DetailsDialogElement)) return 155 | const loader = dialog.querySelector('include-fragment:not([src])') 156 | if (!loader) return 157 | 158 | const src = dialog.src 159 | if (src === null) return 160 | 161 | loader.addEventListener('loadend', () => { 162 | if (details.hasAttribute('open')) autofocus(dialog) 163 | }) 164 | loader.setAttribute('src', src) 165 | removeIncludeFragmentEventListeners(details) 166 | } 167 | 168 | function updateIncludeFragmentEventListeners(details: Element, src: string | null, preload: boolean) { 169 | removeIncludeFragmentEventListeners(details) 170 | 171 | if (src) { 172 | details.addEventListener('toggle', loadIncludeFragment, {once: true}) 173 | } 174 | 175 | if (src && preload) { 176 | details.addEventListener('mouseover', loadIncludeFragment, {once: true}) 177 | } 178 | } 179 | 180 | function removeIncludeFragmentEventListeners(details: Element) { 181 | details.removeEventListener('toggle', loadIncludeFragment) 182 | details.removeEventListener('mouseover', loadIncludeFragment) 183 | } 184 | 185 | type State = { 186 | details: Element | null 187 | activeElement: HTMLElement | null 188 | } 189 | 190 | const initialized: WeakMap = new WeakMap() 191 | 192 | /** 193 | * ### Markup 194 | * 195 | * ```html 196 | *
197 | * Open dialog 198 | * 199 | * Modal content 200 | * 201 | * 202 | *
203 | * ``` 204 | * 205 | * ## Deferred loading 206 | * 207 | * Dialog content can be loaded from a server by embedding an [``][fragment] element. 208 | * 209 | * ```html 210 | *
211 | * Robots 212 | * 213 | * Loading… 214 | * 215 | *
216 | * ``` 217 | * 218 | * The `src` attribute value is copied to the `` the first time the `
` button is toggled open, which starts the server fetch. 219 | * 220 | * If the `preload` attribute is present, hovering over the `
` element will trigger the server fetch. 221 | * 222 | * ## Events 223 | * 224 | * ### `details-dialog-close` 225 | * 226 | * `details-dialog-close` event is fired from `` when a request to close the dialog is made from 227 | * 228 | * - pressing escape, 229 | * - submitting a `form[method="dialog"]` 230 | * - clicking on `summary, form button[formmethod="dialog"], [data-close-dialog]`, or 231 | * - `dialog.toggle(false)` 232 | * 233 | * This event bubbles, and can be canceled to keep the dialog open. 234 | * 235 | * ```js 236 | * document.addEventListener('details-dialog-close', function(event) { 237 | * if (!confirm('Are you sure?')) { 238 | * event.preventDefault() 239 | * } 240 | * }) 241 | * ``` 242 | **/ 243 | class DetailsDialogElement extends HTMLElement { 244 | static get CLOSE_ATTR() { 245 | return CLOSE_ATTR 246 | } 247 | static get CLOSE_SELECTOR() { 248 | return CLOSE_SELECTOR 249 | } 250 | 251 | constructor() { 252 | super() 253 | initialized.set(this, {details: null, activeElement: null}) 254 | this.addEventListener('click', function ({target}: Event) { 255 | if (!(target instanceof Element)) return 256 | const details = target.closest('details') 257 | if (details && target.closest(CLOSE_SELECTOR)) { 258 | toggleDetails(details, false) 259 | } 260 | }) 261 | this.addEventListener('submit', function(event: Event) { 262 | if (!(event.target instanceof HTMLFormElement)) return 263 | 264 | const {target} = event 265 | const submitEvent = 'submitter' in event ? event as SubmitEvent : null 266 | const submitter = submitEvent?.submitter 267 | const method = submitter?.getAttribute('formmethod') || target.getAttribute('method') 268 | 269 | if (method == 'dialog') { 270 | event.preventDefault() 271 | const details = target.closest('details') 272 | if (details) { 273 | toggleDetails(details, false) 274 | } 275 | } 276 | }) 277 | } 278 | 279 | get src(): string | null { 280 | return this.getAttribute('src') 281 | } 282 | 283 | set src(value: string | null) { 284 | this.setAttribute('src', value || '') 285 | } 286 | 287 | get preload(): boolean { 288 | return this.hasAttribute('preload') 289 | } 290 | 291 | set preload(value: boolean) { 292 | value ? this.setAttribute('preload', '') : this.removeAttribute('preload') 293 | } 294 | 295 | connectedCallback() { 296 | this.setAttribute('role', 'dialog') 297 | this.setAttribute('aria-modal', 'true') 298 | const state = initialized.get(this) 299 | if (!state) return 300 | const details = this.parentElement 301 | if (!details) return 302 | 303 | const summary = details.querySelector('summary') 304 | if (summary) { 305 | if (!summary.hasAttribute('role')) summary.setAttribute('role', 'button') 306 | summary.addEventListener('click', onSummaryClick, {capture: true}) 307 | } 308 | 309 | details.addEventListener('toggle', toggle) 310 | state.details = details 311 | if (details.hasAttribute('open')) trapFocus(this, details) 312 | 313 | updateIncludeFragmentEventListeners(details, this.src, this.preload) 314 | } 315 | 316 | disconnectedCallback() { 317 | const state = initialized.get(this) 318 | if (!state) return 319 | const {details} = state 320 | if (!details) return 321 | details.removeEventListener('toggle', toggle) 322 | removeIncludeFragmentEventListeners(details) 323 | const summary = details.querySelector('summary') 324 | if (summary) { 325 | summary.removeEventListener('click', onSummaryClick, {capture: true}) 326 | } 327 | state.details = null 328 | } 329 | 330 | toggle(open: boolean): void { 331 | const state = initialized.get(this) 332 | if (!state) return 333 | const {details} = state 334 | if (!details) return 335 | toggleDetails(details, open) 336 | } 337 | 338 | static get observedAttributes() { 339 | return ['src', 'preload'] 340 | } 341 | 342 | attributeChangedCallback() { 343 | const state = initialized.get(this) 344 | if (!state) return 345 | const {details} = state 346 | if (!details) return 347 | 348 | updateIncludeFragmentEventListeners(details, this.src, this.preload) 349 | } 350 | } 351 | 352 | declare global { 353 | interface Window { 354 | DetailsDialogElement: typeof DetailsDialogElement 355 | } 356 | } 357 | 358 | export default DetailsDialogElement 359 | 360 | if (!window.customElements.get('details-dialog')) { 361 | window.DetailsDialogElement = DetailsDialogElement 362 | window.customElements.define('details-dialog', DetailsDialogElement) 363 | } 364 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const {CLOSE_ATTR, CLOSE_SELECTOR} = window.DetailsDialogElement 2 | 3 | describe('details-dialog-element', function() { 4 | describe('element creation', function() { 5 | it('creates from document.createElement', function() { 6 | const el = document.createElement('details-dialog') 7 | assert.equal('DETAILS-DIALOG', el.nodeName) 8 | }) 9 | 10 | it('creates from constructor', function() { 11 | const el = new window.DetailsDialogElement() 12 | assert.equal('DETAILS-DIALOG', el.nodeName) 13 | }) 14 | }) 15 | 16 | describe('after tree insertion', function() { 17 | let details 18 | let dialog 19 | let summary 20 | let close 21 | 22 | beforeEach(function() { 23 | const container = document.createElement('div') 24 | container.innerHTML = ` 25 |
26 |
27 | Click 28 | 29 |

Hello

30 | 31 | 32 | 33 |
34 |
35 |
36 | 37 |
38 |
39 |
40 | ` 41 | document.body.append(container) 42 | 43 | details = document.querySelector('#details') 44 | dialog = details.querySelector('details-dialog') 45 | summary = details.querySelector('#summary') 46 | close = dialog.querySelector(CLOSE_SELECTOR) 47 | }) 48 | 49 | afterEach(function() { 50 | document.body.innerHTML = '' 51 | }) 52 | 53 | it('initializes', function() { 54 | assert.equal(summary.getAttribute('role'), 'button') 55 | assert.equal(dialog.getAttribute('role'), 'dialog') 56 | assert.equal(dialog.getAttribute('aria-modal'), 'true') 57 | }) 58 | 59 | it('toggles open', function() { 60 | assert(!details.open) 61 | dialog.toggle(true) 62 | assert(details.open) 63 | dialog.toggle(false) 64 | assert(!details.open) 65 | }) 66 | 67 | it('closes with close button', function() { 68 | assert(!details.open) 69 | dialog.toggle(true) 70 | assert(details.open) 71 | close.click() 72 | assert(!details.open) 73 | }) 74 | 75 | it('closes when summary is clicked', function() { 76 | assert(!details.open) 77 | dialog.toggle(true) 78 | assert(details.open) 79 | summary.click() 80 | assert(!details.open) 81 | }) 82 | 83 | it('closes when escape key is pressed', async function() { 84 | assert(!details.open) 85 | dialog.toggle(true) 86 | await waitForToggleEvent(details) 87 | assert(details.open) 88 | triggerKeydownEvent(details, 'Escape') 89 | assert(!details.open) 90 | }) 91 | 92 | it.skip('manages focus', async function() { 93 | summary.click() 94 | await waitForToggleEvent(details) 95 | assert.equal(document.activeElement, dialog) 96 | triggerKeydownEvent(details, 'Tab', true) 97 | assert.equal(document.activeElement, document.querySelector(`[${CLOSE_ATTR}]`)) 98 | triggerKeydownEvent(details, 'Tab') 99 | assert.equal(document.activeElement, document.querySelector(`[data-button]`)) 100 | triggerKeydownEvent(details, 'Tab') 101 | assert.equal(document.activeElement, document.querySelector(`form[method="dialog"] button`)) 102 | triggerKeydownEvent(details, 'Tab') 103 | assert.equal(document.activeElement, document.querySelector(`[formmethod="dialog"]`)) 104 | triggerKeydownEvent(details, 'Tab') 105 | assert.equal(document.activeElement, document.querySelector(`[${CLOSE_ATTR}]`)) 106 | }) 107 | 108 | it('supports a cancellable details-dialog-close event when a summary element is present', async function() { 109 | dialog.toggle(true) 110 | await waitForToggleEvent(details) 111 | assert(details.open) 112 | 113 | let closeRequestCount = 0 114 | let allowCloseToHappen = false 115 | dialog.addEventListener( 116 | 'details-dialog-close', 117 | function(event) { 118 | closeRequestCount++ 119 | if (!allowCloseToHappen) { 120 | event.preventDefault() 121 | event.stopPropagation() 122 | } 123 | }, 124 | {capture: true} 125 | ) 126 | 127 | close.click() 128 | assert(details.open) 129 | assert.equal(closeRequestCount, 1) 130 | 131 | summary.click() 132 | assert(details.open) 133 | assert.equal(closeRequestCount, 2) 134 | 135 | triggerKeydownEvent(details, 'Escape') 136 | assert(details.open) 137 | assert.equal(closeRequestCount, 3) 138 | 139 | dialog.toggle(false) 140 | assert(details.open) 141 | assert.equal(closeRequestCount, 4) 142 | 143 | allowCloseToHappen = true 144 | close.click() 145 | assert(!details.open) 146 | assert.equal(closeRequestCount, 5) 147 | 148 | summary.click() 149 | assert(details.open) 150 | assert.equal(closeRequestCount, 5) 151 | }) 152 | 153 | describe('when no summary element is present', function() { 154 | beforeEach(function() { 155 | summary.remove() 156 | }) 157 | 158 | it('supports a cancellable details-dialog-close event', async function() { 159 | dialog.toggle(true) 160 | await waitForToggleEvent(details) 161 | assert(details.open) 162 | 163 | let closeRequestCount = 0 164 | let allowCloseToHappen = false 165 | dialog.addEventListener( 166 | 'details-dialog-close', 167 | function(event) { 168 | closeRequestCount++ 169 | if (!allowCloseToHappen) { 170 | event.preventDefault() 171 | event.stopPropagation() 172 | } 173 | }, 174 | {capture: true} 175 | ) 176 | 177 | close.click() 178 | assert(details.open) 179 | assert.equal(closeRequestCount, 1) 180 | 181 | triggerKeydownEvent(details, 'Escape') 182 | assert(details.open) 183 | assert.equal(closeRequestCount, 2) 184 | 185 | dialog.toggle(false) 186 | assert(details.open) 187 | assert.equal(closeRequestCount, 3) 188 | 189 | allowCloseToHappen = true 190 | close.click() 191 | assert(!details.open) 192 | }) 193 | 194 | it('toggles open', function() { 195 | assert(!details.open) 196 | dialog.toggle(true) 197 | assert(details.open) 198 | dialog.toggle(false) 199 | assert(!details.open) 200 | }) 201 | 202 | it('closes with close button', function() { 203 | assert(!details.open) 204 | dialog.toggle(true) 205 | assert(details.open) 206 | close.click() 207 | assert(!details.open) 208 | dialog.toggle(true) 209 | assert(details.open) 210 | dialog.querySelector('#method-dialog').click() 211 | assert(!details.open) 212 | dialog.toggle(true) 213 | assert(details.open) 214 | dialog.querySelector('#formmethod-dialog').click() 215 | assert(!details.open) 216 | }) 217 | 218 | it('closes when escape key is pressed', async function() { 219 | assert(!details.open) 220 | dialog.toggle(true) 221 | await waitForToggleEvent(details) 222 | assert(details.open) 223 | triggerKeydownEvent(details, 'Escape') 224 | assert(!details.open) 225 | }) 226 | }) 227 | 228 | describe('when using with inlcude-fragment', function() { 229 | let includeFragment 230 | beforeEach(function() { 231 | includeFragment = document.createElement('include-fragment') 232 | dialog.innerHTML = '' 233 | dialog.append(includeFragment) 234 | dialog.src = '/404' 235 | }) 236 | 237 | afterEach(function() { 238 | dialog.innerHTML = '' 239 | dialog.removeAttribute('src') 240 | }) 241 | 242 | it('transfers src on toggle', async function() { 243 | assert(!details.open) 244 | assert.notOk(includeFragment.getAttribute('src')) 245 | dialog.toggle(true) 246 | await waitForToggleEvent(details) 247 | assert(details.open) 248 | assert.equal(includeFragment.getAttribute('src'), '/404') 249 | }) 250 | 251 | it('transfers src on mouseover when preload is true', async function() { 252 | assert(!details.open) 253 | dialog.preload = true 254 | assert(dialog.hasAttribute('preload')) 255 | assert.notOk(includeFragment.getAttribute('src')) 256 | triggerMouseoverEvent(details) 257 | assert.equal(includeFragment.getAttribute('src'), '/404') 258 | }) 259 | }) 260 | 261 | describe('with inlcude-fragment works for script made dialogs', function() { 262 | let includeFragment 263 | beforeEach(function() { 264 | dialog.remove() 265 | dialog = document.createElement('details-dialog') 266 | dialog.src = '/404' 267 | dialog.preload = true 268 | includeFragment = document.createElement('include-fragment') 269 | dialog.append(includeFragment) 270 | details.append(dialog) 271 | includeFragment = document.querySelector('include-fragment') 272 | }) 273 | 274 | it('transfers src on toggle', async function() { 275 | assert(!details.open) 276 | assert.notOk(includeFragment.getAttribute('src')) 277 | dialog.toggle(true) 278 | await waitForToggleEvent(details) 279 | assert(details.open) 280 | assert.equal(includeFragment.getAttribute('src'), '/404') 281 | }) 282 | 283 | it('transfers src on mouseover', async function() { 284 | assert(!details.open) 285 | assert.notOk(includeFragment.getAttribute('src')) 286 | triggerMouseoverEvent(details) 287 | assert.equal(includeFragment.getAttribute('src'), '/404') 288 | }) 289 | }) 290 | }) 291 | 292 | describe('connected as a child of an already [open]
element', function () { 293 | let details 294 | let dialog 295 | let summary 296 | let close 297 | 298 | beforeEach(function() { 299 | const container = document.createElement('div') 300 | container.innerHTML = ` 301 |
302 | Click 303 | 304 | 305 | 306 | 307 |
308 | ` 309 | document.body.append(container) 310 | 311 | details = document.querySelector('details') 312 | dialog = details.querySelector('details-dialog') 313 | summary = details.querySelector('summary') 314 | close = dialog.querySelector(CLOSE_SELECTOR) 315 | }) 316 | 317 | afterEach(function() { 318 | document.body.innerHTML = '' 319 | }) 320 | 321 | it('manages focus', async function() { 322 | assert.equal(document.activeElement, dialog) 323 | triggerKeydownEvent(document.activeElement, 'Tab', true) 324 | assert.equal(document.activeElement, document.querySelector(`[${CLOSE_ATTR}]`)) 325 | triggerKeydownEvent(document.activeElement, 'Tab') 326 | assert.equal(document.activeElement, document.querySelector(`[data-button]`)) 327 | triggerKeydownEvent(document.activeElement, 'Tab') 328 | assert.equal(document.activeElement, document.querySelector(`[${CLOSE_ATTR}]`)) 329 | triggerKeydownEvent(document.activeElement, 'Tab') 330 | assert.equal(document.activeElement, document.querySelector(`[data-button]`)) 331 | }) 332 | }) 333 | 334 | describe('shadow DOM context', function() { 335 | let shadowRoot, details, summary, dialog 336 | beforeEach(function() { 337 | const container = document.createElement('div') 338 | shadowRoot = container.attachShadow({mode: 'open'}) 339 | shadowRoot.innerHTML = ` 340 |
341 | Summary 342 | 343 | 344 | 345 | 346 | 347 |
` 348 | document.body.append(container) 349 | details = shadowRoot.querySelector('details') 350 | summary = shadowRoot.querySelector('summary') 351 | dialog = shadowRoot.querySelector('details-dialog') 352 | }) 353 | 354 | afterEach(function() { 355 | document.body.innerHTML = '' 356 | }) 357 | 358 | it('closes when escape key is pressed', async function () { 359 | assert(!details.open) 360 | dialog.toggle(true) 361 | await waitForToggleEvent(details) 362 | assert(details.open) 363 | triggerKeydownEvent(details, 'Escape') 364 | assert(!details.open) 365 | }) 366 | 367 | it('manages focus', async function () { 368 | summary.click() 369 | await waitForToggleEvent(details) 370 | assert.equal(shadowRoot.activeElement, dialog) 371 | triggerKeydownEvent(details, 'Tab') 372 | assert.equal(shadowRoot.activeElement, shadowRoot.querySelector(`#button-1`)) 373 | triggerKeydownEvent(details, 'Tab') 374 | assert.equal(shadowRoot.activeElement, shadowRoot.querySelector(`#button-2`)) 375 | triggerKeydownEvent(details, 'Tab') 376 | assert.equal(shadowRoot.activeElement, shadowRoot.querySelector(`[${CLOSE_ATTR}]`)) 377 | triggerKeydownEvent(details, 'Tab') 378 | assert.equal(shadowRoot.activeElement, shadowRoot.querySelector(`#button-1`)) 379 | }) 380 | }) 381 | }) 382 | 383 | function waitForToggleEvent(details) { 384 | return new Promise(function(resolve) { 385 | details.addEventListener( 386 | 'toggle', 387 | function() { 388 | resolve() 389 | }, 390 | {once: true} 391 | ) 392 | }) 393 | } 394 | 395 | function triggerMouseoverEvent(element) { 396 | element.dispatchEvent( 397 | new MouseEvent('mouseover', { 398 | bubbles: true, 399 | cancelable: true 400 | }) 401 | ) 402 | } 403 | function triggerKeydownEvent(element, key, shiftKey) { 404 | element.dispatchEvent( 405 | new KeyboardEvent('keydown', { 406 | bubbles: true, 407 | cancelable: true, 408 | key, 409 | shiftKey 410 | }) 411 | ) 412 | } 413 | --------------------------------------------------------------------------------