├── .eslintrc.cjs ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── app.html ├── global.d.ts ├── lib │ ├── action │ │ ├── focus.ts │ │ └── index.ts │ ├── component │ │ ├── Focus │ │ │ ├── Focus.svelte │ │ │ └── index.ts │ │ └── index.ts │ └── index.ts ├── routes │ ├── index.svelte │ └── test.svelte └── test │ └── Container.svelte ├── static └── favicon.png ├── svelte.config.js ├── tests └── focus.spec.ts └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 5 | plugins: ["svelte3", "@typescript-eslint"], 6 | ignorePatterns: ["*.cjs"], 7 | overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }], 8 | settings: { 9 | "svelte3/typescript": () => require("typescript"), 10 | }, 11 | parserOptions: { 12 | sourceType: "module", 13 | ecmaVersion: 2019, 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": false, 4 | "trailingComma": "all", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:3000", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Chance Dinkins 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :mouse_trap: focus-svelte 2 | 3 | Focus trap for svelte with zero dependencies. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install -D focus-svelte 9 | # yarn add -D focus-svelte 10 | # pnpm add -D focus-svelte 11 | ``` 12 | 13 | ## Example 14 | 15 | [https://svelte.dev/repl/4b31b2f4a45c4ee08230f6d47d31db48](https://svelte.dev/repl/4b31b2f4a45c4ee08230f6d47d31db48?version=3.42.6) 16 | 17 | ## Description 18 | 19 | focus-svelte works a bit differently than other focus traps I've encounted. 20 | Rather than using an event listener to track user activity and override the 21 | default behavior of the browser, the DOM is manipulated instead. All elements 22 | outside of an active focus trap's descendants or ancestory have their 23 | `tabindex` set to `-1` if it was `0` or greater previously. 24 | 25 | To keep track of changes after the trap is enabled, a `MutationObserver` monitors 26 | the DOM for updates. Once all focus traps are disabled or removed, the observer 27 | is stopped and the elements' properties are reset. If a focus trap later becomes active, 28 | the observer is restarted and nodes are decorated accordingly. 29 | 30 | When a trap becomes active for the first time, the `HTMLElement` that is assigned focus is 31 | determined by the configuration options passed to the component or action. 32 | - If `element` is 33 | assigned and is tabbable, it will be focused upon. 34 | - If `element` is `undefined` or not tabbable and `focusable` is `true`, the `HTMLElement` with `use:focus` is granted focus. 35 | - If neither of the previous conditions are met, focus will be placed on the first tabbable element. 36 | 37 | ## Usage 38 | 39 | There is both an action and a component that can be utilized. 40 | 41 | ### Options 42 | 43 | | option | description | type | default | 44 | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------ | 45 | | `element` | If `element` is assigned and is tabbable, it will be focused upon when the trap is enabled. `string` values will be considered a query selector. | `Element \| string` | `undefined` | 46 | | `focusable` | The `HTMLElement` the action is assigned to gets a `tabindex` of `0` when the trap becomes active | `boolean` | `false` | 47 | | `focusDelay` | can either be a number of ms to wait or an async function that resolves (`void`) when the focus of an element should be set. | `number \| () => Promise` | [`tick`](https://svelte.dev/tutorial/tick) | 48 | | `delay` | Determines how long to wait before batching updates to `tabIndex` and `ariaHidden`. | `number \| () => Promise` | [`tick`](https://svelte.dev/tutorial/tick) | 49 | | `assignAriaHidden` | When a focus trap becomes enabled and is `true`, all elements outside of an active trap or their ancestory have their [aria-hidden](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-hidden_attribute) attribute set to `"true"`. | `boolean` | `false` | 50 | | `preventScroll` | sets [`preventScroll`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#parameters) when focusing. | `boolean` | `false` | 51 | | `enabled` | If `true`, the focus trap becomes active. | `boolean` | `false` | 52 | 53 | ### action 54 | 55 | ```html 56 | 63 | 64 | 65 | 66 |
67 | 68 |
69 | 70 | 71 | ``` 72 | 73 | #### With `assignAriaHidden` 74 | 75 | ```html 76 | 83 | 84 | 85 | 86 |
87 | 88 |
89 | 90 | 91 | ``` 92 | 93 | ### component 94 | 95 | ```html 96 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ``` 112 | 113 | **Note**: As the action needs an `HTMLElement`, the component version wraps your content with a `div`. 114 | 115 | ### override 116 | 117 | If you wish to override the behavior of an element, you can set `data-focus-override="true"` 118 | and it will retain its original tabindex. 119 | 120 | ## Contributing 121 | 122 | Pull requests are always welcome. 123 | 124 | ## License 125 | 126 | [MIT](https://choosealicense.com/licenses/mit/) 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "focus-svelte", 3 | "version": "0.3.4", 4 | "license": "MIT", 5 | "author": { 6 | "name": "chance dinkins", 7 | "email": "chanceusc@gmail.com" 8 | }, 9 | "repository": { 10 | "url": "https://github.com/chanced/focus-svelte", 11 | "type": "git" 12 | }, 13 | "keywords": [ 14 | "focus", 15 | "tabindex", 16 | "focus trap", 17 | "focus lock", 18 | "accessibility", 19 | "ada", 20 | "svelte", 21 | "sveltekit", 22 | "aria" 23 | ], 24 | "bugs": { 25 | "url": "https://github.com/chanced/focus-svelte/issues" 26 | }, 27 | "scripts": { 28 | "dev": "svelte-kit dev", 29 | "build": "svelte-kit build", 30 | "package": "npm run format && npm run lint && svelte-kit package", 31 | "publish": "npm run package && npm publish ./package", 32 | "playwright:test": "npx playwright test", 33 | "test": "concurrently -k \"npm:dev\" \"npm:playwright:test\"", 34 | "preview": "svelte-kit preview", 35 | "check": "svelte-check --tsconfig ./tsconfig.json", 36 | "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", 37 | "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", 38 | "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." 39 | }, 40 | "devDependencies": { 41 | "@sveltejs/kit": "next", 42 | "@typescript-eslint/eslint-plugin": "^4.31.2", 43 | "@typescript-eslint/parser": "^4.31.2", 44 | "concurrently": "^6.2.1", 45 | "eslint": "^7.32.0", 46 | "eslint-config-prettier": "^8.3.0", 47 | "eslint-plugin-svelte3": "^3.2.1", 48 | "prettier": "^2.4.1", 49 | "prettier-plugin-svelte": "^2.4.0", 50 | "svelte": "^3.43.0", 51 | "svelte-check": "^2.2.6", 52 | "svelte-preprocess": "^4.9.5", 53 | "svelte2tsx": "^0.4.6", 54 | "tslib": "^2.3.1", 55 | "typescript": "^4.4.3", 56 | "@playwright/test": "^1.15.0" 57 | }, 58 | "type": "module" 59 | } 60 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %svelte.head% 8 | 9 | 10 |
%svelte.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/lib/action/focus.ts: -------------------------------------------------------------------------------- 1 | import { readable } from "svelte/store"; 2 | import { tick } from "svelte"; 3 | import type { Unsubscriber } from "svelte/store"; 4 | 5 | export interface FocusOptions { 6 | /** 7 | * enables focus 8 | */ 9 | enabled?: boolean; 10 | /** 11 | * determines whether or not to assign `area-hidden="true"` to elements 12 | * outside of the trap 13 | */ 14 | assignAriaHidden?: boolean; 15 | /** 16 | * focusable indicates whether or not to make the containing element 17 | * focusable 18 | */ 19 | focusable?: boolean; 20 | 21 | /** 22 | * the element to focus upon. 23 | * 24 | * If the element is not tabbable and `focusable` is set to `true`, the 25 | * element with `use:focus` will be granted focus. If `focusable` is falsy, 26 | * the first tabbable child node will be granted focus. 27 | * 28 | * `string` values will be considered query selectors 29 | */ 30 | element?: HTMLElement | string; 31 | 32 | /** 33 | * focusDelay can either be a number or a function which resolves with a promise 34 | * when it is appropriate to set focus on the target Element 35 | * 36 | * Defaults to `tick` 37 | */ 38 | focusDelay?: number | (() => Promise); 39 | /** 40 | * delay can either be a number or an async function which resolves 41 | * when it is appropriate to set assign tab indexes and ariaHidden (if applicable) 42 | * 43 | * Defaults to `tick` 44 | */ 45 | delay?: number | (() => Promise); 46 | /** A Boolean value indicating whether or not the browser should scroll the 47 | * document to bring the newly-focused element into view. A value of false 48 | * for preventScroll (the default) means that the browser will scroll the 49 | * element into view after focusing it. If preventScroll is set to true, no 50 | * scrolling will occur. */ 51 | preventScroll?: boolean; 52 | } 53 | 54 | type Options = Omit & { 55 | trap: HTMLElement; 56 | focusDelay: () => Promise; 57 | delay: () => Promise; 58 | }; 59 | 60 | const OVERRIDE = "focusOverride"; 61 | const DATA_OVERRIDE = "data-focus-override"; 62 | 63 | type Operation = null | (() => void); 64 | // eslint-disable-next-line @typescript-eslint/ban-types 65 | type Key = {}; 66 | 67 | class NodeState { 68 | private tabIndexOriginAssigned!: number | null; 69 | private tabIndexOriginValue!: number; 70 | private tabIndexAssigned: number | null; 71 | private override!: boolean; 72 | private ariaHiddenOrigin!: boolean | null; 73 | private ariaHiddenAssignedValue: boolean | null; 74 | private unfocusedBy: Set; 75 | private focusedBy: Set; 76 | private hiddenBy: Set; 77 | private shownBy: Set; 78 | constructor(node: HTMLElement) { 79 | this.shownBy = new Set(); 80 | this.hiddenBy = new Set(); 81 | this.focusedBy = new Set(); 82 | this.unfocusedBy = new Set(); 83 | this.updateTabIndexOrigin(node); 84 | this.updateOverride(node); 85 | this.updateAriaHiddenOrigin(node); 86 | this.tabIndexAssigned = null; 87 | this.ariaHiddenAssignedValue = null; 88 | } 89 | 90 | tabbable(): boolean { 91 | if (this.tabIndexAssigned !== null && this.tabIndexAssigned === -1) { 92 | return false; 93 | } 94 | if (this.tabIndexAssigned !== null && this.tabIndexAssigned > -1) { 95 | return true; 96 | } 97 | return this.tabIndexOriginValue > -1; 98 | } 99 | 100 | updateAriaHiddenOrigin(node: HTMLElement): boolean { 101 | const value = this.parseAriaHidden(node); 102 | if (this.ariaHiddenOrigin === undefined) { 103 | this.ariaHiddenOrigin = value; 104 | return true; 105 | } 106 | if (this.ariaHiddenOrigin === value || this.ariaHiddenAssignedValue === value) { 107 | return false; 108 | } 109 | this.ariaHiddenOrigin = value; 110 | return true; 111 | } 112 | 113 | updateTabIndexOrigin(node: HTMLElement, value?: number | null): boolean { 114 | if (value !== undefined) { 115 | if (this.tabIndexAssigned !== value && this.tabIndexOriginAssigned !== value) { 116 | if (value != null) { 117 | this.tabIndexOriginValue = value; 118 | } 119 | this.tabIndexOriginAssigned = value; 120 | return true; 121 | } 122 | return false; 123 | } 124 | const tabIndex = node.tabIndex; 125 | if (this.tabIndexOriginValue !== tabIndex && this.tabIndexAssigned !== tabIndex) { 126 | this.tabIndexOriginValue = tabIndex; 127 | this.tabIndexOriginAssigned = this.parseTabIndex(node); 128 | return true; 129 | } 130 | return false; 131 | } 132 | 133 | private parseOverride(value: string | null | undefined): boolean { 134 | if (!value) { 135 | return false; 136 | } 137 | value = value.toLowerCase(); 138 | return value === "true" || value === "focus"; 139 | } 140 | updateOverride(node: HTMLElement, value?: string): boolean { 141 | value = value !== undefined ? value : node.dataset[OVERRIDE]; 142 | const val = this.parseOverride(value); 143 | if (this.override !== val) { 144 | this.override = val; 145 | return true; 146 | } 147 | return false; 148 | } 149 | operationsFor(node: HTMLElement, assignAriaHidden: boolean): Operation[] { 150 | return [this.tabIndexOp(node), this.ariaHiddenOp(node, assignAriaHidden)]; 151 | } 152 | ariaHiddenOp(node: HTMLElement, assignAriaHidden: boolean): Operation { 153 | if (!assignAriaHidden || this.override) { 154 | return null; 155 | } 156 | if (this.shownBy.size) { 157 | this.ariaHiddenAssignedValue = false; 158 | } else if (this.hiddenBy.size) { 159 | this.ariaHiddenAssignedValue = true; 160 | } else { 161 | if (this.ariaHiddenAssignedValue !== null) { 162 | if (this.ariaHiddenOrigin === null) { 163 | return () => { 164 | node.removeAttribute("aria-hidden"); 165 | this.ariaHiddenAssignedValue = null; 166 | }; 167 | } 168 | const ariaHiddenOrigin = this.ariaHiddenOrigin.toString(); 169 | return () => { 170 | node.ariaHidden = ariaHiddenOrigin; 171 | }; 172 | } 173 | } 174 | if (this.ariaHiddenAssignedValue !== null) { 175 | const value = this.ariaHiddenAssignedValue.toString(); 176 | return () => { 177 | node.ariaHidden = value; 178 | }; 179 | } 180 | return null; 181 | } 182 | tabIndexOp(node: HTMLElement): Operation { 183 | if (this.override) { 184 | return null; 185 | } 186 | if (this.focusedBy.size) { 187 | if (this.tabIndexAssigned === -1 || node.tabIndex !== -1) { 188 | this.tabIndexAssigned = 0; 189 | } else if (this.tabIndexAssigned === null || node.tabIndex === this.tabIndexAssigned) { 190 | return null; 191 | } 192 | } else if (this.unfocusedBy.size) { 193 | const parsed = this.parseTabIndex(node); 194 | if ( 195 | (parsed !== null && parsed >= 0) || 196 | (this.tabIndexAssigned === null && this.tabIndexOriginValue >= 0) || 197 | this.tabIndexAssigned === 0 198 | ) { 199 | this.tabIndexAssigned = -1; 200 | } else { 201 | return null; 202 | } 203 | } else { 204 | if (this.tabIndexAssigned !== null) { 205 | if (this.tabIndexOriginAssigned === null) { 206 | this.tabIndexAssigned = null; 207 | return () => { 208 | node.removeAttribute("tabindex"); 209 | }; 210 | } 211 | const value = this.tabIndexOriginAssigned; 212 | this.tabIndexOriginAssigned = null; 213 | return () => { 214 | node.tabIndex = value; 215 | }; 216 | } 217 | } 218 | if (this.tabIndexAssigned !== null && node.tabIndex !== this.tabIndexAssigned) { 219 | const { tabIndexAssigned } = this; 220 | return () => { 221 | node.tabIndex = tabIndexAssigned; 222 | }; 223 | } 224 | return null; 225 | } 226 | addTrap(key: Key, options: Options, node: HTMLElement): Operation[] { 227 | const { trap, focusable, assignAriaHidden } = options; 228 | if (node === trap) { 229 | if (focusable) { 230 | this.tabIndexAssigned = 0; 231 | } 232 | this.focusedBy.add(key); 233 | this.unfocusedBy.delete(key); 234 | this.shownBy.add(key); 235 | this.hiddenBy.delete(key); 236 | 237 | return this.operationsFor(node, !!assignAriaHidden); 238 | } 239 | 240 | if (trap.contains(node) || node.contains(trap)) { 241 | this.focusedBy.add(key); 242 | this.unfocusedBy.delete(key); 243 | if (assignAriaHidden) { 244 | this.shownBy.add(key); 245 | this.hiddenBy.delete(key); 246 | } 247 | return this.operationsFor(node, !!assignAriaHidden); 248 | } 249 | 250 | this.unfocusedBy.add(key); 251 | this.focusedBy.delete(key); 252 | if (assignAriaHidden) { 253 | this.hiddenBy.add(key); 254 | this.shownBy.delete(key); 255 | } 256 | return this.operationsFor(node, !!assignAriaHidden); 257 | } 258 | 259 | removeLock(key: Key) { 260 | this.focusedBy.delete(key); 261 | this.unfocusedBy.delete(key); 262 | this.hiddenBy.delete(key); 263 | this.shownBy.delete(key); 264 | } 265 | 266 | private parseTabIndex(node: HTMLElement, value?: string | null): number | null { 267 | if (value === undefined) { 268 | if (!node.hasAttribute("tabindex")) { 269 | return null; 270 | } 271 | return node.tabIndex; 272 | } 273 | if (value == null) { 274 | value = ""; 275 | } 276 | value = value.trim(); 277 | if (value === "") { 278 | return null; 279 | } 280 | const parsed = parseInt(value); 281 | if (isNaN(parsed)) { 282 | return null; 283 | } 284 | return parsed; 285 | } 286 | private parseAriaHidden(node: HTMLElement): boolean | null { 287 | const val = node.getAttribute("aria-hidden"); 288 | if (val === "true") { 289 | return true; 290 | } 291 | if (val === "false") { 292 | return false; 293 | } 294 | return null; 295 | } 296 | } 297 | 298 | const context = readable>(undefined, (set) => { 299 | set(new WeakMap()); 300 | return () => { 301 | set(new WeakMap()); 302 | }; 303 | }); 304 | 305 | export interface FocusAction { 306 | update(enabled: boolean): void; 307 | update(opts: FocusOptions): void; 308 | destroy(): void; 309 | } 310 | 311 | let observer: MutationObserver; 312 | 313 | const mutations = readable([], function (set) { 314 | if (typeof document === "undefined") { 315 | set([]); 316 | return; 317 | } 318 | if (!observer) { 319 | observer = new MutationObserver((mutations) => { 320 | set(mutations); 321 | }); 322 | } 323 | observer.observe(document.body, { 324 | attributes: true, 325 | attributeFilter: ["tabindex", "aria-hidden", DATA_OVERRIDE], 326 | attributeOldValue: false, 327 | childList: true, 328 | subtree: true, 329 | }); 330 | return () => { 331 | observer.disconnect(); 332 | }; 333 | }); 334 | 335 | const allBodyNodes = (): NodeListOf => document.body.querySelectorAll("*"); 336 | 337 | // eslint-disable-next-line @typescript-eslint/no-empty-function 338 | function noop() {} 339 | 340 | const exec = (op: Operation) => op && op(); 341 | 342 | export function focus(trap: HTMLElement, opts: FocusOptions | boolean): FocusAction { 343 | const key = Object.freeze({}); 344 | let state: WeakMap; 345 | let enabled = false; 346 | let assignAriaHidden = false; 347 | let focusable = false; 348 | let element: string | HTMLElement | undefined = undefined; 349 | let options: Options; 350 | let unsubscribeFromMutations: Unsubscriber | undefined = undefined; 351 | let unsubscribeFromState: Unsubscriber | undefined = undefined; 352 | 353 | let previousElement: HTMLElement | undefined = undefined; 354 | 355 | if (typeof document === "undefined") { 356 | return { update: noop, destroy: noop }; 357 | } 358 | 359 | function nodeState(node: HTMLElement): NodeState { 360 | let ns = state.get(node); 361 | if (!ns) { 362 | ns = new NodeState(node); 363 | state.set(node, ns); 364 | } 365 | return ns; 366 | } 367 | 368 | function addTrapToNodeState(node: Node): Operation[] { 369 | if (!(node instanceof HTMLElement)) { 370 | return []; 371 | } 372 | const ns = nodeState(node); 373 | 374 | return ns.addTrap(key, options, node); 375 | } 376 | 377 | function removeTrapFromNodeState(node: Node): (Operation | null)[] { 378 | if (!(node instanceof HTMLElement)) { 379 | return []; 380 | } 381 | if (!state) { 382 | return []; 383 | } 384 | const ns = state.get(node); 385 | if (!ns) { 386 | return []; 387 | } 388 | 389 | ns.removeLock(key); 390 | return ns.operationsFor(node, assignAriaHidden); 391 | } 392 | 393 | async function createTrap(nodes: NodeList) { 394 | let ops: Operation[] = []; 395 | nodes.forEach((node) => { 396 | ops = ops.concat(addTrapToNodeState(node)); 397 | }); 398 | await options.delay(); 399 | ops.forEach((fn) => exec(fn)); 400 | } 401 | 402 | async function destroyTrap(nodes: NodeList) { 403 | let ops: Operation[] = []; 404 | nodes.forEach((node) => { 405 | ops = ops.concat(removeTrapFromNodeState(node)); 406 | }); 407 | if (options) { 408 | await options.delay(); 409 | } 410 | ops.forEach((fn) => exec(fn)); 411 | } 412 | 413 | async function handleAttributeChange(mutation: MutationRecord) { 414 | const { target: node } = mutation; 415 | if (!(node instanceof HTMLElement)) { 416 | return; 417 | } 418 | const { attributeName } = mutation; 419 | if (attributeName === null) { 420 | return; 421 | } 422 | 423 | const ns = state.get(node); 424 | if (!ns) { 425 | return; 426 | } 427 | let ops: Operation[] | undefined = undefined; 428 | switch (attributeName) { 429 | case "tabindex": 430 | if (ns.updateTabIndexOrigin(node, node.hasAttribute("tabindex") ? node.tabIndex : null)) { 431 | ops = [ns.tabIndexOp(node)]; 432 | } 433 | break; 434 | case DATA_OVERRIDE: 435 | if (ns.updateOverride(node, node.dataset[OVERRIDE])) { 436 | ops = ns.operationsFor(node, assignAriaHidden); 437 | } 438 | break; 439 | case "aria-hidden": 440 | if (ns.updateAriaHiddenOrigin(node)) { 441 | ops = [ns.ariaHiddenOp(node, assignAriaHidden)]; 442 | } 443 | break; 444 | } 445 | if (!ops) { 446 | return; 447 | } 448 | await options.delay(); 449 | ops.forEach((op) => exec(op)); 450 | } 451 | 452 | function handleNodesAdded(mutation: MutationRecord) { 453 | const { addedNodes } = mutation; 454 | if (addedNodes === null) { 455 | return; 456 | } 457 | createTrap(addedNodes); 458 | mutation.addedNodes.forEach((node) => { 459 | createTrap(node.childNodes); 460 | }); 461 | } 462 | function handleMutation(mutation: MutationRecord) { 463 | if (!state) { 464 | return; 465 | } 466 | if (mutation.type === "childList" && mutation.addedNodes) { 467 | handleNodesAdded(mutation); 468 | } 469 | if (mutation.type === "attributes") { 470 | handleAttributeChange(mutation); 471 | } 472 | } 473 | const handleMutations = (mutations: MutationRecord[]) => mutations.forEach(handleMutation); 474 | 475 | async function setFocus() { 476 | await options.focusDelay(); 477 | const { preventScroll } = options; 478 | 479 | if (element) { 480 | let elem: Element | null = null; 481 | if (typeof element === "string") { 482 | try { 483 | elem = trap.querySelector(element); 484 | } catch (err) { 485 | elem = null; 486 | } 487 | } 488 | if (element instanceof Element) { 489 | elem = element; 490 | } 491 | 492 | if (elem && elem instanceof HTMLElement && elem.tabIndex > -1) { 493 | elem.focus({ preventScroll }); 494 | previousElement = elem; 495 | return; 496 | } 497 | } 498 | 499 | if (trap.tabIndex > -1) { 500 | trap.focus({ preventScroll }); 501 | } 502 | if (typeof document !== "undefined" && document.activeElement === trap) { 503 | previousElement = trap; 504 | return; 505 | } 506 | 507 | const nodes = trap.querySelectorAll("*"); 508 | for (let i = 0; i < nodes.length; i++) { 509 | const node = nodes.item(i); 510 | const ns = state.get(node); 511 | if (!ns) { 512 | continue; 513 | } 514 | if (ns.tabbable() && node instanceof HTMLElement) { 515 | node.focus({ preventScroll }); 516 | previousElement = node; 517 | return; 518 | } 519 | } 520 | } 521 | 522 | function blurFocus() { 523 | const current = document.activeElement; 524 | if (current instanceof HTMLElement) { 525 | const ns = state.get(current); 526 | if (ns && !ns.tabbable()) { 527 | current.blur(); 528 | } 529 | } 530 | } 531 | 532 | const subscribeToState = () => 533 | context.subscribe(($state) => { 534 | state = $state; 535 | }); 536 | function update(opts: FocusOptions | boolean) { 537 | const previouslyEnabled = enabled; 538 | 539 | if (typeof opts === "boolean") { 540 | enabled = opts; 541 | assignAriaHidden = false; 542 | opts = {}; 543 | } else if (typeof opts == "object") { 544 | enabled = !!opts?.enabled; 545 | } else { 546 | enabled = false; 547 | opts = {}; 548 | } 549 | assignAriaHidden = !!opts?.assignAriaHidden; 550 | focusable = !!opts.focusable; 551 | element = opts.element; 552 | let { focusDelay, delay } = opts; 553 | const { preventScroll } = opts; 554 | 555 | if (typeof focusDelay === "number") { 556 | const ms = focusDelay; 557 | focusDelay = () => new Promise((res) => setTimeout(res, ms)); 558 | } 559 | 560 | if (typeof delay === "number") { 561 | const ms = delay; 562 | delay = () => new Promise((res) => setTimeout(res, ms)); 563 | } 564 | if (!focusDelay) { 565 | focusDelay = tick; 566 | } 567 | if (!delay) { 568 | delay = tick; 569 | } 570 | 571 | options = { 572 | assignAriaHidden, 573 | enabled, 574 | focusable, 575 | trap, 576 | element, 577 | focusDelay, 578 | delay, 579 | preventScroll, 580 | }; 581 | if (!enabled) { 582 | return destroy(); 583 | } 584 | if (!state && unsubscribeFromState) { 585 | unsubscribeFromState(); 586 | unsubscribeFromState = subscribeToState(); 587 | } 588 | 589 | if (!unsubscribeFromState) { 590 | unsubscribeFromState = subscribeToState(); 591 | } 592 | 593 | createTrap(allBodyNodes()); 594 | 595 | if (!unsubscribeFromMutations) { 596 | unsubscribeFromMutations = mutations.subscribe(handleMutations); 597 | } 598 | 599 | if ( 600 | !previouslyEnabled || 601 | !previousElement || 602 | (element !== undefined && element !== previousElement) 603 | ) { 604 | blurFocus(); 605 | setFocus(); 606 | } 607 | } 608 | function destroy() { 609 | if (unsubscribeFromMutations) { 610 | unsubscribeFromMutations(); 611 | unsubscribeFromMutations = undefined; 612 | } 613 | 614 | destroyTrap(allBodyNodes()); 615 | 616 | if (unsubscribeFromState) { 617 | unsubscribeFromState(); 618 | unsubscribeFromState = undefined; 619 | } 620 | if (typeof document !== "undefined") { 621 | const { activeElement } = document; 622 | if (trap === activeElement || trap.contains(activeElement)) { 623 | if (activeElement instanceof HTMLElement) { 624 | activeElement.blur(); 625 | } 626 | } 627 | } 628 | } 629 | if (opts === true || (typeof opts === "object" && opts?.enabled)) { 630 | update(opts); 631 | } 632 | 633 | return { update, destroy }; 634 | } 635 | -------------------------------------------------------------------------------- /src/lib/action/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./focus"; 2 | -------------------------------------------------------------------------------- /src/lib/component/Focus/Focus.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
15 | 16 |
17 | -------------------------------------------------------------------------------- /src/lib/component/Focus/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Focus.svelte"; 2 | -------------------------------------------------------------------------------- /src/lib/component/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Focus"; 2 | export { default as Focus } from "./Focus"; 3 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./component"; 2 | export { default as Focus } from "./component"; 3 | export * from "./action"; 4 | -------------------------------------------------------------------------------- /src/routes/index.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 21 | 22 |
23 | 24 | 25 | 26 | 35 |
36 | 37 | 46 | -------------------------------------------------------------------------------- /src/routes/test.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | test page 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 |