├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .sasslintrc ├── LICENSE ├── README.md ├── deploy.docs.sh ├── docs ├── .vitepress │ ├── components │ │ └── interactive-code │ │ │ ├── interactive-code.sass │ │ │ ├── interactive-code.vue │ │ │ └── transform-code-for-codepen.js │ ├── config.js │ └── theme │ │ ├── custom.sass │ │ └── index.js ├── examples │ ├── autofocus-on-activation.vue │ ├── autofocus-on-mount.vue │ ├── index.md │ ├── roving-grid.vue │ ├── roving-gridcell.vue │ ├── roving-nested.vue │ ├── roving-rtl.vue │ ├── roving-simple.vue │ ├── trap-escexits.vue │ ├── trap-escrefocus.vue │ ├── trap-rtl.vue │ ├── trap-simple.vue │ └── use-as-composable.vue ├── guide │ └── index.md ├── index.md ├── links │ └── index.md ├── public │ ├── favicon.ico │ ├── googlec0cade17e5e27188.html │ ├── icons │ │ ├── apple-launch-1125x2436.png │ │ ├── apple-launch-1170x2532.png │ │ ├── apple-launch-1242x2208.png │ │ ├── apple-launch-1242x2688.png │ │ ├── apple-launch-1284x2778.png │ │ ├── apple-launch-1536x2048.png │ │ ├── apple-launch-1620x2160.png │ │ ├── apple-launch-1668x2224.png │ │ ├── apple-launch-1668x2388.png │ │ ├── apple-launch-2048x2732.png │ │ ├── apple-launch-750x1334.png │ │ ├── apple-launch-828x1792.png │ │ ├── favicon-128x128.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── favicon-96x96.png │ ├── logo.png │ └── sitemap.xml └── vite.config.js ├── gitpkg.config.cjs ├── index.html ├── jsconfig.json ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico └── icons │ ├── apple-launch-1125x2436.png │ ├── apple-launch-1170x2532.png │ ├── apple-launch-1242x2208.png │ ├── apple-launch-1242x2688.png │ ├── apple-launch-1284x2778.png │ ├── apple-launch-1536x2048.png │ ├── apple-launch-1620x2160.png │ ├── apple-launch-1668x2224.png │ ├── apple-launch-1668x2388.png │ ├── apple-launch-2048x2732.png │ ├── apple-launch-750x1334.png │ ├── apple-launch-828x1792.png │ ├── favicon-128x128.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon-96x96.png ├── src ├── App.vue ├── assets │ └── logo.png ├── directives │ └── keyboard-trap │ │ ├── directive.js │ │ ├── helpers.js │ │ ├── index.js │ │ └── options.js ├── exports.js ├── main.js └── public │ ├── styles │ ├── index.css │ └── index.sass │ ├── types │ └── index.d.ts │ └── web-types │ └── index.json ├── vite.dev.config.js └── vite.src.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | *.html 4 | 5 | !/docs/.vitepress 6 | /docs/.vitepress/dist 7 | /docs/.vitepress/cache 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | node: true, 7 | 'vue/setup-compiler-macros': true, 8 | }, 9 | extends: [ 10 | 'plugin:vue/vue3-essential', 11 | 'airbnb-base', 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | }, 17 | plugins: [ 18 | 'vue', 19 | ], 20 | rules: { 21 | 'import/first': 'off', 22 | 'import/named': 'error', 23 | 'import/namespace': 'error', 24 | 'import/default': 'error', 25 | 'import/export': 'error', 26 | 'import/extensions': 'off', 27 | 'import/no-unresolved': 'off', 28 | 'import/no-extraneous-dependencies': 'off', 29 | 'import/no-dynamic-require': 'off', 30 | 'import/prefer-default-export': 'off', 31 | 'prefer-promise-reject-errors': 'off', 32 | 33 | // allow console.log during development only 34 | 'no-console': process.env.NODE_ENV === 'production' ? ['error', { allow: ['info', 'warn', 'error'] }] : 'off', 35 | // allow debugger during development only 36 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 37 | 38 | 'linebreak-style': 'off', 39 | 'arrow-parens': ['error', 'always'], 40 | 'max-len': 'off', 41 | 'template-curly-spacing': ['error', 'always'], 42 | 'no-underscore-dangle': 'off', 43 | 'no-var': 'error', 44 | 'no-param-reassign': ['error', { props: false }], 45 | indent: 'off', 46 | 'indent-legacy': ['error', 2, { 47 | SwitchCase: 1, 48 | }], 49 | 'vue/script-indent': ['error', 2, { 50 | baseIndent: 0, 51 | switchCase: 1, 52 | ignores: [], 53 | }], 54 | 'vue/max-attributes-per-line': ['error', { 55 | singleline: 3, 56 | multiline: 1, 57 | }], 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [pdanpdan] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]:" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | dist-ssr 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # vitepress build output 97 | docs/.vitepress/dist 98 | docs/.vitepress/cache 99 | 100 | # Serverless directories 101 | .serverless/ 102 | 103 | # FuseBox cache 104 | .fusebox/ 105 | 106 | # DynamoDB Local files 107 | .dynamodb/ 108 | 109 | # TernJS port file 110 | .tern-port 111 | 112 | *.local 113 | 114 | .DS_STORE 115 | .temp 116 | -------------------------------------------------------------------------------- /.sasslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "extends-before-mixins": 2, 4 | "extends-before-declarations": 2, 5 | "placeholder-in-extend": 2, 6 | "mixins-before-declarations": [ 7 | 2, 8 | { 9 | "exclude": [ 10 | "breakpoint", 11 | "mq" 12 | ] 13 | } 14 | ], 15 | "no-warn": 1, 16 | "no-debug": 1, 17 | "no-ids": 0, 18 | "no-important": 0, 19 | "hex-notation": 0, 20 | "indentation": [ 21 | 2, 22 | { 23 | "size": 2 24 | } 25 | ], 26 | "class-name-format": 0, 27 | "no-color-literals": 0, 28 | "empty-line-between-blocks": 0, 29 | "single-line-per-selector": 1, 30 | "force-element-nesting": 0, 31 | "property-sort-order": 0, 32 | "variable-for-property": 0, 33 | "leading-zero": 0 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Popescu Dan 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 | # VueKeyboardTrap (vue-keyboard-trap) 2 | 3 | [![License: MIT](https://img.shields.io/github/license/pdanpdan/vue-keyboard-trap?style=for-the-badge)](https://opensource.org/licenses/MIT)   4 | [![minzip](https://img.shields.io/bundlephobia/minzip/@pdanpdan/vue-keyboard-trap/latest?style=for-the-badge)](https://bundlephobia.com/result?p=@pdanpdan/vue-keyboard-trap)   5 | ![github release](https://img.shields.io/github/v/tag/pdanpdan/vue-keyboard-trap?sort=semver&style=for-the-badge)   6 | ![jsdelivr hits](https://img.shields.io/jsdelivr/gh/hm/pdanpdan/vue-keyboard-trap?style=for-the-badge)   7 | ![npm release](https://img.shields.io/npm/v/@pdanpdan/vue-keyboard-trap?style=for-the-badge)   8 | ![npm downloads](https://img.shields.io/npm/dm/@pdanpdan/vue-keyboard-trap?style=for-the-badge) 9 | 10 | ## Project description 11 | 12 | Vue directive and composable for keyboard navigation - roving movement and trapping inside container. 13 | 14 | Works both for Vue3 and Vue2, as a directive (`v-kbd-trap`) or as a composable (`useKeyboardTrap`). 15 | 16 | [Demo codepen](https://codepen.io/pdanpdan/pen/MWrzLdM) 17 | 18 | [Docs and examples](https://pdanpdan.github.io/vue-keyboard-trap/) 19 | 20 | [Source code, Issues, Discussions](https://github.com/pdanpdan/vue-keyboard-trap) 21 | 22 | ## Install 23 | 24 | ```bash 25 | pnpm add @pdanpdan/vue-keyboard-trap 26 | ``` 27 | or 28 | ```bash 29 | yarn add @pdanpdan/vue-keyboard-trap 30 | ``` 31 | or 32 | ```bash 33 | npm install @pdanpdan/vue-keyboard-trap 34 | ``` 35 | 36 | ## Playground 37 | 38 | [Demo codepen](https://codepen.io/pdanpdan/pen/MWrzLdM) 39 | 40 | ## Usage 41 | 42 | ### Usage as ESM 43 | 44 | #### As composable (both Vue3 and Vue2) 45 | 46 | ```html 47 | 68 | 69 | 74 | ``` 75 | 76 | #### As plugin on Vue3 - directive 77 | 78 | ```javascript 79 | import { createApp } from 'vue'; 80 | import { VueKeyboardTrapDirectivePlugin } from '@pdanpdan/vue-keyboard-trap'; 81 | import App from './App.vue'; 82 | 83 | const app = createApp(App); 84 | 85 | app.use(VueKeyboardTrapDirectivePlugin, { 86 | // ...options if required 87 | }); 88 | 89 | app.mount('#app'); 90 | ``` 91 | 92 | #### As plugin on Vue2 - directive 93 | 94 | ```javascript 95 | import Vue from 'vue'; 96 | import { VueKeyboardTrapDirectivePlugin } from '@pdanpdan/vue-keyboard-trap'; 97 | import App from './App.vue'; 98 | 99 | Vue.use(VueKeyboardTrapDirectivePlugin, { 100 | // ...options if required 101 | }); 102 | 103 | new Vue({ 104 | el: '#app', 105 | }); 106 | ``` 107 | 108 | #### Included in specific components (Vue3 script setup) - directive 109 | 110 | ```html 111 | 118 | ``` 119 | 120 | #### Included in specific components (Vue3 script) - directive 121 | 122 | ```html 123 | 137 | ``` 138 | 139 | #### Included in specific components (Vue2) - directive 140 | 141 | ```html 142 | 155 | ``` 156 | 157 | #### User hint styles (cosmetic) 158 | 159 | The directive does not require any CSS styles to work, but for cosmetic purposes (as user hints) some example styles are provided in `dist/styles/index.sass`. 160 | 161 | in Javascript 162 | ```javascript 163 | import '@pdanpdan/vue-keyboard-trap/styles'; 164 | ``` 165 | 166 | or in SASS 167 | ```sass 168 | @import '@pdanpdan/vue-keyboard-trap/styles' 169 | ``` 170 | 171 | or (if the `/styles` export is not used by your bundler) 172 | 173 | in Javascript 174 | ```javascript 175 | import '@pdanpdan/vue-keyboard-trap/dist/styles/index.sass'; 176 | ``` 177 | 178 | or in SASS 179 | ```sass 180 | @import '@pdanpdan/vue-keyboard-trap/dist/styles/index.sass' 181 | ``` 182 | 183 | ### Usage as UMD 184 | 185 | Load the javascript from [https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/index.umd.js](https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/index.umd.js). 186 | 187 | It will expose a global object `VueKeyboardTrap` with `VueKeyboardTrapDirectivePlugin` and `VueKeyboardTrapDirectiveFactory` keys. 188 | 189 | In order to work it requires that `VueDemi` is already loaded on the page. You can do it like this: 190 | 191 | ```html 192 | 193 | 194 | 195 | 196 | ``` 197 | 198 | #### As composable (both Vue3 and Vue2) 199 | 200 | ```javascript 201 | const { ref } = Vue; 202 | 203 | const { useKeyboardTrapFactory } = VueKeyboardTrap; 204 | const useKeyboardTrap = useKeyboardTrapFactory({ 205 | // ...options if required 206 | }); 207 | 208 | const elRef = ref(null); 209 | useKeyboardTrap( 210 | // element (reactive) 211 | elRef, 212 | // modifiers (optional, reactive, default all modifiers are false) 213 | { 214 | roving: true, 215 | }, 216 | // active (optional, reactive, default true) 217 | true 218 | ); 219 | ``` 220 | 221 | #### As plugin on Vue3 - directive 222 | 223 | ```javascript 224 | const { createApp } = Vue; 225 | const { VueKeyboardTrapDirectivePlugin } = VueKeyboardTrap; 226 | 227 | const app = createApp({}); 228 | 229 | app.use(VueKeyboardTrapDirectivePlugin, { 230 | // ...options if required 231 | }); 232 | 233 | app.mount('#app'); 234 | ``` 235 | 236 | #### As plugin on Vue2 - directive 237 | 238 | ```javascript 239 | const { VueKeyboardTrapDirectivePlugin } = VueKeyboardTrap; 240 | 241 | Vue.use(VueKeyboardTrapDirectivePlugin, { 242 | // ...options if required 243 | }); 244 | 245 | new Vue({ 246 | el: '#app', 247 | }); 248 | ``` 249 | 250 | #### As directive on Vue3 - directive 251 | 252 | ```javascript 253 | const { createApp } = Vue; 254 | const { VueKeyboardTrapDirectiveFactory } = VueKeyboardTrap; 255 | 256 | const app = createApp({}); 257 | 258 | const { name, directive } = VueKeyboardTrapDirectiveFactory({ 259 | // ...options if required 260 | }); 261 | 262 | app.directive(name, directive); 263 | 264 | app.mount('#app'); 265 | ``` 266 | 267 | #### As directive on Vue2 - directive 268 | 269 | ```javascript 270 | const { VueKeyboardTrapDirectiveFactory } = VueKeyboardTrap; 271 | 272 | const { name, directive } = VueKeyboardTrapDirectiveFactory({ 273 | // ...options if required 274 | }); 275 | 276 | Vue.directive(name, directive); 277 | ``` 278 | 279 | #### User hint styles (cosmetic) 280 | 281 | If you want you can access the CSS cosmetic style (user hints) from [https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/styles/index.css](https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/styles/index.css). 282 | 283 | ### Directive configuration options 284 | 285 | | Option | Description | Default | 286 | |--------|-------------|:-------:| 287 | | `name` | snake-case name of the directive (without `v-` prefix) | `kbd-trap` | 288 | | `datasetName` | camelCase name of the `data-attribute` to be set on element when trap is enabled | `v${PascalCase from name}` | 289 | | `focusableSelector` | CSS selector for focusable elements | [see here](#default-focusableselector) | 290 | | `rovingSkipSelector` | CSS selector for elements that should not respond to roving key navigation (input, textarea, ...) | [see here](#default-rovingskipselector) | 291 | | `gridSkipSelector` | CSS selector that will be applied in .roving.grid mode to exclude elements - must be a series of `:not()` selectors | [see here](#default-gridskipselector) | 292 | | `autofocusSelector` | CSS selector for the elements that should be autofocused | [see here](#default-autofocusselector) | 293 | | `trapTabIndex` | tabIndex value to be used when trap element has a tabIndex of -1 and has no `tabindex` attribute | -9999 | 294 | 295 | #### Default `focusableSelector`: 296 | 297 | ```css 298 | :focus, 299 | a[href]:not([tabindex^="-"]), 300 | area[href]:not([tabindex^="-"]), 301 | video[controls]:not([tabindex^="-"]), 302 | audio[controls]:not([tabindex^="-"]), 303 | iframe:not([tabindex^="-"]), 304 | [tabindex]:not(slot):not([tabindex^="-"]), 305 | [contenteditable]:not([contenteditable="false"]):not([tabindex^="-"]), 306 | details > summary:first-of-type:not([tabindex^="-"]), 307 | input:not([type="hidden"]):not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]), 308 | select:not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]), 309 | textarea:not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]), 310 | button:not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]), 311 | fieldset[disabled]:not(fieldset[disabled] fieldset) > legend input:not([type="hidden"]):not([disabled]):not([tabindex^="-"]), 312 | fieldset[disabled]:not(fieldset[disabled] fieldset) > legend select:not([disabled]):not([tabindex^="-"]), 313 | fieldset[disabled]:not(fieldset[disabled] fieldset) > legend textarea:not([disabled]):not([tabindex^="-"]), 314 | fieldset[disabled]:not(fieldset[disabled] fieldset) > legend button:not([disabled]):not([tabindex^="-"]), 315 | [class*="focusable"]:not([disabled]):not([tabindex^="-"]) 316 | ``` 317 | 318 | By default `a` tags without href are not focusable - add a `tabindex="0"` attribute on them to make them focusable. 319 | This can be done for all other elements if you want them to be focusable. 320 | 321 | #### Default `rovingSkipSelector`: 322 | 323 | ```css 324 | input:not([disabled]):not([type="button"]):not([type="checkbox"]):not([type="file"]):not([type="image"]):not([type="radio"]):not([type="reset"]):not([type="submit"]), 325 | select:not([disabled]), 326 | select:not([disabled]) *, 327 | textarea:not([disabled]), 328 | [contenteditable]:not([contenteditable="false"]), 329 | [contenteditable]:not([contenteditable="false"]) * 330 | ``` 331 | 332 | #### Default `gridSkipSelector`: 333 | 334 | ```css 335 | :not([disabled]), 336 | :not([tabindex^="-"]) 337 | ``` 338 | 339 | #### Default `autofocusSelector`: 340 | 341 | ```css 342 | [autofocus]:not([disabled]):not([autofocus="false"]), 343 | [data-autofocus]:not([disabled]):not([data-autofocus="false"]) 344 | ``` 345 | 346 | ### Dynamic enable/disable 347 | 348 | Use the value of the directive (boolean) to enable/disable it. 349 | 350 | ```html 351 |
352 | ``` 353 | 354 | The modifiers are reactive so if you use render functions you can dynamically change the behaviour. 355 | 356 | ### Directive modifiers 357 | 358 | | Modifier | Description | 359 | |----------|-------------| 360 | | `.autofocus` | autofocuses the first element that matches [autofocusSelector](#default-autofocusselector) or (if no such element is found) the first focusable child element **when the directive is mounted or enabled** (**only if it not covered by another element**) | 361 | | `.roving` or `.roving.vertical.horizontal` | allow roving navigation (`Home`, `End`, `ArrowKeys`) | 362 | | `.roving.vertical` | allow roving navigation (`Home`, `End`, `ArrowUp`, `ArrowDown`) | 363 | | `.roving.horizontal` | allow roving navigation (`Home`, `End`, `ArrowLeft`, `ArrowRight`) | 364 | | `.roving.grid` | allow roving navigation (`Home`, `End`, `ArrowKeys`) using dataset attrs on elements `[data-${camelCase from datasetName}-(row/col)]`; `[data-${camelCase from datasetName}-(row/col)~="*"]` is a catchall | 365 | | `.roving` used on an element with `[role="grid"]` | allow roving navigation (`Home`, `End`, `ArrowKeys`) using role attrs on elements `[role="row/gridcell"]` | 366 | | `.roving.tabinside` | `Tab` key navigates to next/prev element inside trap (by default `Tab` key navigates to next/prev element outside trap in roving mode) | 367 | | `.escrefocus` | refocus element that was in focus before activating the trap on `Esc` | 368 | | `.escexits` | refocus a parent trap on `Esc` (has priority over `.escrefocus`) | 369 | | `.indexorder` used without `.grid` modifier and on elements without `[role="grid"]` | force usage of order in `tabindex` (`tabindex` in ascending order and then DOM order) | 370 | 371 | ## Keyboard navigation 372 | 373 | - `TAB` / `SHIFT`+`TAB` key 374 | - moves to next / previous focusable element inside the trap group (moves from last one to first one or from first one to last one when no more focusable elements are available in the group) 375 | - if `.roving` modifier is used moves to next / previous trap group or focusable element outside the current trap group 376 | - if `.roving.tabinside` modifiers are used then move inside the trap group 377 | - if `.indexorder` modifier is used without `.grid` and on elements without `[role="grid"]` - the order of tabindex will be used 378 | - `ESC` key 379 | - disables / enables the current tab group 380 | - if `.escexits` modifier is used then refocus the last active focusable element in a parent trap group 381 | - if `.escrefocus` modifier is used then refocus the last focusable element that was active before the current trap group got focus 382 | - if `.escexits` or `.escrefocus` are used then press `SHIFT + ESC` to disable / enable the current tab group 383 | - `HOME` / `END` when `.roving` modifier is used 384 | - move to first / last focusable element in the current trap group 385 | - `ARROW_KEYS` when `.roving` modifier is used (`.roving.horizontal.vertical` is the same as `.roving`) 386 | - if only `.horizontal` modifier is used then only `ARROW_LEFT` / `ARROW_RIGHT` keys can be used 387 | - if only `.vertical` modifier is used then only `ARROW_UP` / `ARROW_DOWN` keys can be used 388 | - `ARROW_LEFT` / `ARROW_UP` move to the previous focusable element inside the trap group 389 | - `ARROW_RIGHT` / `ARROW_DOWN` move to the next focusable element inside the trap group 390 | - if `.indexorder` modifier is used without `.grid` and on elements without `[role="grid"]` - the order of tabindex will be used 391 | - `ARROW_KEYS` when `.roving.grid` modifiers are used or `.roving` modifier on a trap element with [role="grid"] 392 | - move in the grid inside the current trap group 393 | 394 | ### Keyboard navigation inside `.roving.grid` trap groups 395 | 396 | In order to specify the navigation pattern you must use 2 dataset attributes on the focusable elements inside the `.roving` trap group: 397 | 398 | - `data-v-kbd-trap-row` specifies the numeric identifier of the row the element belongs to (numbers need not be consecutive, but their natural order determines the navigation order) 399 | - `data-v-kbd-trap-col` specifies the numeric identifier of the column the element belongs to (numbers need not be consecutive, but their natural order determines the navigation order) 400 | 401 | Any or both attributes can have a value of `*` that means that it is an alement that can be focused from elements having any coresponding (row or col) attribute. 402 | 403 | #### Navigation rules 404 | 405 | - the first focusable element on the row / col (based on direction of movement) is focused 406 | - an element with `*` for row or col is considered to belong to any row / col 407 | 408 | ### Keyboard navigation inside `.roving` trap groups with `[role="grid"]` 409 | 410 | In order to specify the navigation pattern you must use role attributes `[role="row"]` and `[role="gridcell"]`. 411 | 412 | All focusable element must have `[role="gridcell"]` and must be inside `[role="row"]` elements inside `[role="grid"]` trap element. 413 | 414 | The `gridcell`s will be considered inline-start aligned in every row. 415 | 416 | #### Navigation rules 417 | 418 | - the first focusable element on the row / col (based on direction of movement) is focused 419 | 420 | ### RTL / LTR 421 | 422 | The directive checks the closest parent DOM Element of the active element that has a `[dir="rtl"]` or `[dir="ltr`]` attribute. 423 | 424 | If the direction is RTL the `ARROW_LEFT` and `ARROW_RIGHT` keys move in reverse (according to document order of the focusable elements) but consistent to the way the elements are ordered on screen. 425 | 426 | ## CSS (visual hints for users) 427 | 428 | The directive does not require any styles, but it might help the users to have visual hints for navigation. 429 | 430 | A default style is provided as SASS in `dist/styles/index.sass` (can be imported as `import '@pdapdan/vue-keyboard-trap/styles'`, as `import '@pdapdan/vue-keyboard-trap/dist/styles/index.sass'` (if the bundler does not use the `/styles` export) or included from [https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/styles/index.sass](https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/styles/index.sass)). 431 | 432 | The default style is also provided as CSS in `dist/styles/index.css` (can be imported as `import '@pdapdan/vue-keyboard-trap/dist/styles/index.css'` or included from [https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/styles/index.css](https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/styles/index.css)). 433 | 434 | There are some CSS variables that can be used to customize the aspect of the hints: 435 | 436 | | Variable | Role | Default | 437 | |----------|------|:-------:| 438 | | `--color-v-kbd-trap-enabled` | the text color when directive is enabled | `#c33` | 439 | | `--color-v-kbd-trap-disabled` | the text color when directive is disabled | `#999` | 440 | | `--color-v-kbd-trap-background` | the background color of the hint area | `#eeee` | 441 | | `--text-v-kbd-trap-separator` | separator between elements | `/` | 442 | | `--text-v-kbd-trap-enabled` | indicator for enabled but not active trap | `Trap` | 443 | | `--text-v-kbd-trap-esc` | indicator for `Esc` key active | `Esc` | 444 | | `--text-v-kbd-trap-esc-refocus` | indicator for `Esc` key active when it refocuses | `Esc\2949` / `Esc⥉` | 445 | | `--text-v-kbd-trap-esc-exits` | indicator for `Esc` key active when it exits trap | `Esc\2923` / `Esc⤣` | 446 | | `--text-v-kbd-trap-tab` | indicator for `Tab` key active inside trap | `Tab` | 447 | | `--text-v-kbd-trap-tab-exits` | indicator for `Tab` key active when it exits trap | `Tab\21C5` / `Tab⇅` | 448 | | `--text-v-kbd-trap-grid` | indicator for grid mode active | `\229E` / `⊞` | 449 | | `--text-v-kbd-trap-arrows-all` | indicator for move keys active in roving mode | `\2962\2963\2965\2964` / `⥢⥣⥥⥤` | 450 | | `--text-v-kbd-trap-arrows-horizontal` | indicator for move keys active in roving mode horizontal | `\2962\2964` / `⥢⥤` | 451 | | `--text-v-kbd-trap-arrows-vertical` | indicator for move keys active in roving mode vertical | `\2963\2965` / `⥣⥥` | 452 | 453 | In the default style the hint is positioned on the top-right corner of the trap group. 454 | 455 | <<< @/../src/public/styles/index.sass 456 | 457 | ## Development 458 | 459 | ### Install the dependencies 460 | 461 | ```bash 462 | pnpm i 463 | ``` 464 | 465 | ### Start development mode (hot-code reloading, error reporting, etc.) 466 | 467 | ```bash 468 | pnpm dev 469 | ``` 470 | 471 | ### Lint the files 472 | 473 | ```bash 474 | pnpm lint 475 | ``` 476 | 477 | ### Build for production 478 | 479 | ```bash 480 | pnpm build 481 | ``` 482 | 483 | ## Source code, issues, bug reports, feature requests 484 | 485 | [Vue Keyboard Trap (vue-keyboard-trap)](https://github.com/pdanpdan/vue-keyboard-trap) 486 | 487 | ## Author 488 | 489 | * Name: Dan Popescu (PDan) 490 | * Email: [pdan.popescu@gmail.com](mailto:pdan.popescu@gmail.com) 491 | * Website: https://github.com/pdanpdan/ 492 | * Github: [@pdanpdan](https://github.com/pdanpdan) 493 | 494 | ## License 495 | 496 | Copyright © 2022-present [Dan Popescu](https://github.com/pdanpdan). 497 | 498 | This application is distributed under [![License: MIT](https://img.shields.io/github/license/pdanpdan/vue-keyboard-trap?style=for-the-badge)](https://opensource.org/licenses/MIT), see LICENSE for more information. 499 | -------------------------------------------------------------------------------- /deploy.docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | pnpm docs:build 5 | 6 | cd docs/.vitepress/dist 7 | 8 | git init 9 | git add -A 10 | git commit --allow-empty -m 'deploy' 11 | git push -f git@github.com:pdanpdan/vue-keyboard-trap.git main:gh-pages 12 | 13 | cd - 14 | -------------------------------------------------------------------------------- /docs/.vitepress/components/interactive-code/interactive-code.sass: -------------------------------------------------------------------------------- 1 | $interactiveCodeBorderRadius: 6px !default 2 | 3 | .interactive-code 4 | color: var(--vp-c-text-code) 5 | background-color: var(--vp-c-bg) 6 | border: 1px solid var(--vp-c-divider) 7 | border-radius: var(--interactive-code-border-radius, $interactiveCodeBorderRadius) 8 | margin-block: 16px 9 | overflow: hidden 10 | 11 | &__header 12 | background-color: var(--vp-c-bg-alt) 13 | border-bottom: 1px dashed var(--vp-c-divider) 14 | padding: .5em 1em 15 | 16 | &-title 17 | font-size: 1.1em 18 | font-weight: bold 19 | color: var(--vp-c-text-1) 20 | 21 | &-desc 22 | font-size: .9em 23 | font-weight: light 24 | color: var(--vp-c-text-2) 25 | 26 | &__component 27 | padding: 1em 28 | 29 | &__source 30 | > div 31 | margin: 0 !important 32 | border-radius: 0 !important 33 | 34 | &__actions 35 | background-color: var(--vp-c-bg-alt) 36 | border-top: 1px dashed var(--vp-c-divider) 37 | padding: .35em 38 | display: flex 39 | flex-wrap: wrap 40 | max-width: 100% 41 | align-items: center 42 | 43 | > .spacer 44 | display: inline-block 45 | flex: 1 1 auto 46 | 47 | > .separator 48 | display: inline-block 49 | align-self: stretch 50 | width: 1px 51 | background-color: var(--vp-c-divider-light) 52 | margin-inline: .5em 53 | margin-block: .25em 54 | 55 | > button 56 | cursor: pointer 57 | color: var(--vp-c-text-2) 58 | background-color: var(--vp-button-alt-bg) 59 | border: 1px solid var(--vp-button-alt-border) 60 | padding: .3em .6em 61 | min-height: 36px 62 | border-radius: .2em 63 | outline: none 64 | font-size: 1em 65 | font-weight: 500 66 | 67 | &:active, 68 | &:focus-visible 69 | background-color: var(--vp-button-alt-active-bg) 70 | border: 1px solid var(--vp-button-alt-active-border) 71 | &:focus-visible 72 | outline: auto 73 | outline-offset: 1px 74 | &:hover 75 | background-color: var(--vp-button-alt-hover-bg) 76 | border: 1px solid var(--vp-button-alt-hover-border) 77 | 78 | &__action 79 | &--icon 80 | min-width: 36px 81 | background-position: 50% 82 | background-size: 28px 83 | background-repeat: no-repeat 84 | 85 | &--source 86 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='rgba(128,128,128,1)' height='20' width='20' stroke='rgba(128,128,128,1)' viewBox='0 0 32 32'%3E%3Cpath d='M14.194,27a1.2,1.2,0,0,1-.276-.033,1.388,1.388,0,0,1-.968-1.618L16.562,6.105a1.383,1.383,0,0,1,.592-.91,1.192,1.192,0,0,1,.928-.163,1.39,1.39,0,0,1,.969,1.617L15.436,25.9a1.378,1.378,0,0,1-.59.908A1.2,1.2,0,0,1,14.194,27Z'/%3E%3Cpath d='M21.437,24.273l-.091,0a1.242,1.242,0,0,1-.891-.5,1.461,1.461,0,0,1,.136-1.893L26.8,15.807l-6.185-5.652a1.462,1.462,0,0,1-.187-1.888,1.25,1.25,0,0,1,.881-.533,1.2,1.2,0,0,1,.945.316l7.294,6.668a1.463,1.463,0,0,1,.191,1.889,1.415,1.415,0,0,1-.189.218l-7.265,7.1A1.222,1.222,0,0,1,21.437,24.273Z'/%3E%3Cpath d='M10.563,24.277a1.219,1.219,0,0,1-.852-.355l-7.271-7.1a1.2,1.2,0,0,1-.182-.21,1.459,1.459,0,0,1,.188-1.886l7.3-6.67a1.175,1.175,0,0,1,.938-.317,1.254,1.254,0,0,1,.887.53,1.462,1.462,0,0,1-.187,1.89L5.2,15.809l6.212,6.069a1.457,1.457,0,0,1,.133,1.893,1.235,1.235,0,0,1-.893.5C10.622,24.275,10.593,24.277,10.563,24.277Z'/%3E%3C/svg%3E") 87 | 88 | &--copy 89 | position: relative 90 | background-image: var(--vp-icon-copy) 91 | 92 | &--copied 93 | background-image: var(--vp-icon-copied) 94 | 95 | &::before 96 | content: 'Copied' 97 | position: absolute 98 | top: -1px 99 | bottom: -1px 100 | left: -5em 101 | background-color: var(--vp-button-alt-bg) 102 | border: 1px solid var(--vp-button-alt-border) 103 | padding: .35em .6em 104 | border-radius: .2em 105 | -------------------------------------------------------------------------------- /docs/.vitepress/components/interactive-code/interactive-code.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 132 | -------------------------------------------------------------------------------- /docs/.vitepress/components/interactive-code/transform-code-for-codepen.js: -------------------------------------------------------------------------------- 1 | const reHtml = /(?:^|[\n\r]+)]*?(?:\s+lang="([^"]+)")?[^>]*?>[\r\n]*(.*?)[\r\n]*<\/template>/is; 2 | const reCss = /(?:^|[\n\r]+)]*?(?:\s+lang="([^"]+)")?[^>]*?>[\r\n](.*?)[\r\n]<\/style>/is; 3 | const reJs = /(?:^|[\n\r]+)]*?(?:\s+lang="([^"]+)")?[^>]*?>[\r\n](.*?)[\r\n]<\/script>/is; 4 | const reImport = /(^|[\n\r]+)import(?:\s+(.*?)\s+from)?\s+(['"])(.+?)\3(;)?(?:\s*\/\/\s*asGlobal=(['"])(.+?)\6)?/isg; 5 | const reExternal = /(?:^|[\n\r]+)\s*\/\/\s*external(js|css)=(['"])(.+?)\2/isg; 6 | 7 | // reverse order, will be unshifted 8 | const vueUmdSrcs = [ 9 | 'https://cdn.jsdelivr.net/npm/vue-demi/lib/index.iife.js', 10 | 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js', 11 | ]; 12 | 13 | function extractMatch(text, re) { 14 | const match = re.exec(text); 15 | 16 | return match === null 17 | ? { 18 | type: 'none', 19 | code: '', 20 | } 21 | : { 22 | type: match[1] || 'none', 23 | code: match[2] || '', 24 | }; 25 | } 26 | 27 | function formatImport(_match, initialSpace, importedNames, _quote1, importedFrom, semicolon, _quote2, asGlobal) { 28 | if (typeof importedNames !== 'string' || importedNames.length === 0) { 29 | return ''; 30 | } 31 | 32 | if (typeof asGlobal === 'string' && asGlobal.length > 0) { 33 | return `${ initialSpace }const ${ importedNames } = ${ asGlobal }${ semicolon }`; 34 | } 35 | 36 | const packageMatch = /^(?:@[^/]+\/)?([^@]+)$/i.exec(importedFrom); 37 | 38 | if (packageMatch === null) { 39 | return ''; 40 | } 41 | 42 | const objFrom = packageMatch[1] 43 | .split('/') 44 | .map( 45 | (s) => s 46 | .split(/[^a-z0-9]+/i) 47 | .filter((w) => w.length > 0) 48 | .map((w) => `${ w[0].toLocaleUpperCase() }${ w.slice(1).toLocaleLowerCase() }`) 49 | .join(''), 50 | ) 51 | .join('.'); 52 | 53 | return `${ initialSpace }const ${ importedNames } = ${ objFrom }${ semicolon || '' }`; 54 | } 55 | 56 | export default function parseCodeForPen({ 57 | title, 58 | text, 59 | externalCss, 60 | externalJs, 61 | }) { 62 | const parsedHtml = extractMatch(text, reHtml); 63 | const parsedCss = extractMatch(text, reCss); 64 | const parsedJs = extractMatch(text, reJs); 65 | // eslint-disable-next-line no-nested-ternary 66 | const cssExternal = Array.isArray(externalCss) === true 67 | ? externalCss 68 | : (typeof externalCss === 'string' && externalCss.length > 0 ? [externalCss] : []); 69 | // eslint-disable-next-line no-nested-ternary 70 | const jsExternal = Array.isArray(externalJs) === true 71 | ? externalJs 72 | : (typeof externalJs === 'string' && externalJs.length > 0 ? [externalJs] : []); 73 | 74 | vueUmdSrcs.forEach((vueUmdSrc) => { 75 | if (jsExternal.indexOf(vueUmdSrc) === -1) { 76 | jsExternal.unshift(vueUmdSrc); 77 | } 78 | }); 79 | 80 | parsedJs.code = parsedJs.code.replace(reExternal, (_match, type, _quote, src) => { 81 | if (type.toLowerCase() === 'js') { 82 | if (jsExternal.indexOf(src) === -1) { 83 | jsExternal.push(src); 84 | } 85 | } else if (cssExternal.indexOf(src) === -1) { 86 | cssExternal.push(src); 87 | } 88 | 89 | return ''; 90 | }); 91 | parsedJs.code = parsedJs.code.replaceAll(reImport, formatImport); 92 | 93 | let parsedHtmlCode = parsedHtml.code 94 | .replace(/(