Skip to content

VueKeyboardTrap (vue-keyboard-trap)

License: MIT   minzip   github release   jsdelivr hits   npm release   npm downloads

Project description

Vue directive and composable for keyboard navigation - roving movement and trapping inside container.

Works both for Vue3 and Vue2, as a directive (v-kbd-trap) or as a composable (useKeyboardTrap).

Install

bash
pnpm add @pdanpdan/vue-keyboard-trap
bash
yarn add @pdanpdan/vue-keyboard-trap
bash
npm install @pdanpdan/vue-keyboard-trap

Playground

Demo codepen

Usage

Usage as ESM

As composable (both Vue3 and Vue2)

html
<script setup>
  import { ref } from 'vue';
  import { useKeyboardTrapFactory } from '@pdanpdan/vue-keyboard-trap'; 

  // you can do this in another file and import the configured `useKeyboardTrap`
  const useKeyboardTrap = useKeyboardTrapFactory({ 
    // ...options if required
  }); 

  const elRef = ref(null);
  useKeyboardTrap( 
    // element (reactive)
    elRef, 
    // modifiers (optional, reactive, default all modifiers are false)
    {
      roving: true,
    },
    // active (optional, reactive, default true)
    true
  ); 
</script>

<template>
  <div ref="elRef">
    ...
  </div>
</template>

As plugin

javascript
import { createApp } from 'vue';
import { VueKeyboardTrapDirectivePlugin } from '@pdanpdan/vue-keyboard-trap'; 
import App from './App.vue';

const app = createApp(App);

app.use(VueKeyboardTrapDirectivePlugin, { 
  // ...options if required
});

app.mount('#app');
javascript
import Vue from 'vue';
import { VueKeyboardTrapDirectivePlugin } from '@pdanpdan/vue-keyboard-trap'; 
import App from './App.vue';

Vue.use(VueKeyboardTrapDirectivePlugin, { 
  // ...options if required
});

new Vue({
  el: '#app',
});

Include in specific components

html
<script setup>
  import { VueKeyboardTrapDirectiveFactory } from '@pdanpdan/vue-keyboard-trap'; 

  const vKbdTrap = VueKeyboardTrapDirectiveFactory({ 
    // ...options if required
  }).directive;
</script>
html
<script>
  import { defineComponent } from 'vue';
  import { VueKeyboardTrapDirectiveFactory } from '@pdanpdan/vue-keyboard-trap'; 

  const KbdTrap = VueKeyboardTrapDirectiveFactory({ 
    // ...options if required
  }).directive;

  export default defineComponent({
    directives: { 
      KbdTrap,
    },
  });
</script>
html
<script>
  import { VueKeyboardTrapDirectiveFactory } from '@pdanpdan/vue-keyboard-trap'; 

  const KbdTrap = VueKeyboardTrapDirectiveFactory({ 
    // ...options if required
  }).directive;

  export default {
    directives: { 
      KbdTrap,
    },
  };
</script>

User hint styles (cosmetic)

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.

javascript
import '@pdanpdan/vue-keyboard-trap/styles';
sass
@import '@pdanpdan/vue-keyboard-trap/styles'

If the /styles export is not used by your bundler:

javascript
import '@pdanpdan/vue-keyboard-trap/dist/styles/index.sass';
sass
@import '@pdanpdan/vue-keyboard-trap/dist/styles/index.sass'

Usage as UMD

Load the javascript from https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/index.umd.js.

It will expose a global object VueKeyboardTrap with VueKeyboardTrapDirectivePlugin and VueKeyboardTrapDirectiveFactory keys.

In order to work it requires that VueDemi is already loaded on the page. You can do it like this:

html
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-demi/lib/index.iife.js"></script>
<script src="https://cdn.jsdelivr.net/gh/pdanpdan/vue-keyboard-trap/dist/index.umd.js"></script>

As composable

javascript
const { ref } = Vue;

const { useKeyboardTrapFactory } = VueKeyboardTrap; 
const useKeyboardTrap = useKeyboardTrapFactory({ 
  // ...options if required
}); 

const elRef = ref(null);
useKeyboardTrap( 
  // element (reactive)
  elRef, 
  // modifiers (optional, reactive, default all modifiers are false)
  {
    roving: true,
  },
  // active (optional, reactive, default true)
  true
); 

As plugin

javascript
const { createApp } = Vue;
const { VueKeyboardTrapDirectivePlugin } = VueKeyboardTrap; 

const app = createApp({});

app.use(VueKeyboardTrapDirectivePlugin, { 
  // ...options if required
});

app.mount('#app');
javascript
const { VueKeyboardTrapDirectivePlugin } = VueKeyboardTrap; 

Vue.use(VueKeyboardTrapDirectivePlugin, { 
  // ...options if required
});

new Vue({
  el: '#app',
});

As directive

javascript
const { createApp } = Vue;
const { VueKeyboardTrapDirectiveFactory } = VueKeyboardTrap; 

const app = createApp({});

const { name, directive } = VueKeyboardTrapDirectiveFactory({ 
  // ...options if required
});

app.directive(name, directive); 

app.mount('#app');
javascript
const { VueKeyboardTrapDirectiveFactory } = VueKeyboardTrap; 

const { name, directive } = VueKeyboardTrapDirectiveFactory({ 
  // ...options if required
});

Vue.directive(name, directive); 

User hint styles (cosmetic)

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.

Directive configuration options

OptionDescriptionDefault
namesnake-case name of the directive (without v- prefix)kbd-trap
datasetNamecamelCase name of the data-attribute to be set on element when trap is enabledv${PascalCase from name}
focusableSelectorCSS selector for focusable elementssee here
rovingSkipSelectorCSS selector for elements that should not respond to roving key navigation (input, textarea, ...)see here
gridSkipSelectorCSS selector that will be applied in .roving.grid mode to exclude elements - must be a series of :not() selectorssee here
autofocusSelectorCSS selector for the elements that should be autofocusedsee here
trapTabIndextabIndex value to be used when trap element has a tabIndex of -1 and has no tabindex attribute-9999

Default focusableSelector:

css
:focus,
a[href]:not([tabindex^="-"]),
area[href]:not([tabindex^="-"]),
video[controls]:not([tabindex^="-"]),
audio[controls]:not([tabindex^="-"]),
iframe:not([tabindex^="-"]),
[tabindex]:not(slot):not([tabindex^="-"]),
[contenteditable]:not([contenteditable="false"]):not([tabindex^="-"]),
details > summary:first-of-type:not([tabindex^="-"]),
input:not([type="hidden"]):not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]),
select:not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]),
textarea:not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]),
button:not(fieldset[disabled] input):not([disabled]):not([tabindex^="-"]),
fieldset[disabled]:not(fieldset[disabled] fieldset) > legend input:not([type="hidden"]):not([disabled]):not([tabindex^="-"]),
fieldset[disabled]:not(fieldset[disabled] fieldset) > legend select:not([disabled]):not([tabindex^="-"]),
fieldset[disabled]:not(fieldset[disabled] fieldset) > legend textarea:not([disabled]):not([tabindex^="-"]),
fieldset[disabled]:not(fieldset[disabled] fieldset) > legend button:not([disabled]):not([tabindex^="-"]),
[class*="focusable"]:not([disabled]):not([tabindex^="-"])

By default a tags without href are not focusable - add a tabindex="0" attribute on them to make them focusable. This can be done for all other elements if you want them to be focusable.

Default rovingSkipSelector:

css
input:not([disabled]):not([type="button"]):not([type="checkbox"]):not([type="file"]):not([type="image"]):not([type="radio"]):not([type="reset"]):not([type="submit"]),
select:not([disabled]),
select:not([disabled]) *,
textarea:not([disabled]),
[contenteditable]:not([contenteditable="false"]),
[contenteditable]:not([contenteditable="false"]) *

Default gridSkipSelector:

css
:not([disabled]),
:not([tabindex^="-"])

Default autofocusSelector:

css
[autofocus]:not([disabled]):not([autofocus="false"]),
[data-autofocus]:not([disabled]):not([data-autofocus="false"])

Dynamic enable/disable

Use the value of the directive (boolean) to enable/disable it.

html
<div v-kbd-trap="directiveEnabled">

The modifiers are reactive so if you use render functions you can dynamically change the behaviour.

Directive modifiers

ModifierDescription
.autofocusautofocuses the first element that matches 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)
.roving or .roving.vertical.horizontalallow roving navigation (Home, End, ArrowKeys)
.roving.verticalallow roving navigation (Home, End, ArrowUp, ArrowDown)
.roving.horizontalallow roving navigation (Home, End, ArrowLeft, ArrowRight)
.roving.gridallow 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
.roving used on an element with [role="grid"]allow roving navigation (Home, End, ArrowKeys) using role attrs on elements [role="row/gridcell"]
.roving.tabinsideTab key navigates to next/prev element inside trap (by default Tab key navigates to next/prev element outside trap in roving mode)
.escrefocusrefocus element that was in focus before activating the trap on Esc
.escexitsrefocus a parent trap on Esc (has priority over .escrefocus)
.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)

Keyboard navigation

  • TAB / SHIFT+TAB key
    • 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)
    • if .roving modifier is used moves to next / previous trap group or focusable element outside the current trap group
    • if .roving.tabinside modifiers are used then move inside the trap group
    • if .indexorder modifier is used without .grid and on elements without [role="grid"] - the order of tabindex will be used
  • ESC key
    • disables / enables the current tab group
    • if .escexits modifier is used then refocus the last active focusable element in a parent trap group
    • if .escrefocus modifier is used then refocus the last focusable element that was active before the current trap group got focus
    • if .escexits or .escrefocus are used then press SHIFT + ESC to disable / enable the current tab group
  • HOME / END when .roving modifier is used
    • move to first / last focusable element in the current trap group
  • ARROW_KEYS when .roving modifier is used (.roving.horizontal.vertical is the same as .roving)
    • if only .horizontal modifier is used then only ARROW_LEFT / ARROW_RIGHT keys can be used
    • if only .vertical modifier is used then only ARROW_UP / ARROW_DOWN keys can be used
    • ARROW_LEFT / ARROW_UP move to the previous focusable element inside the trap group
    • ARROW_RIGHT / ARROW_DOWN move to the next focusable element inside the trap group
    • if .indexorder modifier is used without .grid and on elements without [role="grid"] - the order of tabindex will be used
  • ARROW_KEYS when .roving.grid modifiers are used or .roving modifier on a trap element with [role="grid"]
    • move in the grid inside the current trap group

Keyboard navigation inside .roving.grid trap groups

In order to specify the navigation pattern you must use 2 dataset attributes on the focusable elements inside the .roving trap group:

  • 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)
  • 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)

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.

  • the first focusable element on the row / col (based on direction of movement) is focused
  • an element with * for row or col is considered to belong to any row / col

Keyboard navigation inside .roving trap groups with [role="grid"]

In order to specify the navigation pattern you must use role attributes [role="row"] and [role="gridcell"].

All focusable element must have [role="gridcell"] and must be inside [role="row"] elements inside [role="grid"] trap element.

The gridcells will be considered inline-start aligned in every row.

  • the first focusable element on the row / col (based on direction of movement) is focused

RTL / LTR

The directive checks the closest parent DOM Element of the active element that has a [dir="rtl"] or [dir="ltr]` attribute.

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.

CSS (visual hints for users)

The directive does not require any styles, but it might help the users to have visual hints for navigation.

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).

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).

There are some CSS variables that can be used to customize the aspect of the hints:

VariableRoleDefault
--color-v-kbd-trap-enabledthe text color when directive is enabled#c33
--color-v-kbd-trap-disabledthe text color when directive is disabled#999
--color-v-kbd-trap-backgroundthe background color of the hint area#eeee
--text-v-kbd-trap-separatorseparator between elements/
--text-v-kbd-trap-enabledindicator for enabled but not active trapTrap
--text-v-kbd-trap-escindicator for Esc key activeEsc
--text-v-kbd-trap-esc-refocusindicator for Esc key active when it refocusesEsc\2949 / Esc⥉
--text-v-kbd-trap-esc-exitsindicator for Esc key active when it exits trapEsc\2923 / Esc⤣
--text-v-kbd-trap-tabindicator for Tab key active inside trapTab
--text-v-kbd-trap-tab-exitsindicator for Tab key active when it exits trapTab\21C5 / Tab⇅
--text-v-kbd-trap-gridindicator for grid mode active\229E /
--text-v-kbd-trap-arrows-allindicator for move keys active in roving mode\2962\2963\2965\2964 / ⥢⥣⥥⥤
--text-v-kbd-trap-arrows-horizontalindicator for move keys active in roving mode horizontal\2962\2964 / ⥢⥤
--text-v-kbd-trap-arrows-verticalindicator for move keys active in roving mode vertical\2963\2965 / ⥣⥥

In the default style the hint is positioned on the top-right corner of the trap group.

sass
@charset "UTF-8"

$ColorVKeyboardTrapEnabled: #c33 !default
$ColorVKeyboardTrapDisabled: #999 !default
$ColorVKeyboardTrapBackground: #eeee !default

$TextVKeyboardTrapSeparator: "/" !default
$TextVKeyboardTrapEnabled: "Trap" !default
$TextVKeyboardTrapEsc: "Esc" !default
$TextVKeyboardTrapEscRefocus: "Esc\2949" !default
$TextVKeyboardTrapEscExits: "Esc\2923" !default
$TextVKeyboardTrapTab: "Tab" !default
$TextVKeyboardTrapTabExits: "Tab\21C5" !default
$TextVKeyboardTrapGrid: "\229E" !default
$TextVKeyboardTrapArrowsAll: "\2962\2963\2965\2964" !default
$TextVKeyboardTrapArrowsHorizontal: "\2962\2964" !default
$TextVKeyboardTrapArrowsVertical: "\2963\2965" !default

// :root
//   --color-v-kbd-trap-enabled: #c33
//   --color-v-kbd-trap-disabled: #999
//   --color-v-kbd-trap-background: #eeee
//   --text-v-kbd-trap-separator: "/"
//   --text-v-kbd-trap-enabled: "Trap"
//   --text-v-kbd-trap-esc: "Esc"
//   --text-v-kbd-trap-esc-refocus: "Esc\2949"
//   --text-v-kbd-trap-esc-exits: "Esc\2923"
//   --text-v-kbd-trap-tab: "Tab"
//   --text-v-kbd-trap-tab-exits: "Tab\21C5"
//   --text-v-kbd-trap-grid: "\229E"
//   --text-v-kbd-trap-arrows-all: "\2962\2963\2965\2964"
//   --text-v-kbd-trap-arrows-horizontal: "\2962\2964"
//   --text-v-kbd-trap-arrows-vertical: "\2963\2965"

[data-v-kbd-trap]
  --v-kbd-trap: var(--text-v-kbd-trap-enabled, "#{$TextVKeyboardTrapEnabled}")
  --v-kbd-trap-esc: ""
  --v-kbd-trap-tab: ""
  --v-kbd-trap-roving: ""

  &:where(:has(:focus-visible))
    --v-kbd-trap: var(--text-v-kbd-trap-enabled, "#{$TextVKeyboardTrapEnabled}") var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}")
    --v-kbd-trap-esc: var(--text-v-kbd-trap-esc, "#{$TextVKeyboardTrapEsc}")

    &:after
      content: var(--v-kbd-trap, "") var(--v-kbd-trap-esc, "") var(--v-kbd-trap-tab, "") var(--v-kbd-trap-roving, "")
      pointer-events: none
      position: absolute
      top: 2px
      right: 2px
      font: italic small-caps bold 14px monospace
      line-height: 1em
      padding: 4px
      background-color: var(--color-v-kbd-trap-background, #{$ColorVKeyboardTrapBackground})
      border-radius: 2px
      z-index: 1

  &:where([tabindex="-9999"], dialog, [popover])
    outline: none

  &:after
    color: var(--color-v-kbd-trap-disabled, #{$ColorVKeyboardTrapDisabled})

  &:where([data-v-kbd-trap-active]):after
    color: var(--color-v-kbd-trap-enabled, #{$ColorVKeyboardTrapEnabled})

  &:where([data-v-kbd-trap-active])
    --v-kbd-trap: ""
    --v-kbd-trap-esc: var(--text-v-kbd-trap-esc, "#{$TextVKeyboardTrapEsc}")
    --v-kbd-trap-tab: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-tab, "#{$TextVKeyboardTrapTab}")
    --v-kbd-trap-roving: ""

    &:where([data-v-kbd-trap~=roving])
      --v-kbd-trap-tab: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-tab-exits, "#{$TextVKeyboardTrapTabExits}")
      --v-kbd-trap-roving: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-arrows-all, "#{$TextVKeyboardTrapArrowsAll}")

      &:where([data-v-kbd-trap~=tabinside])
        --v-kbd-trap-tab: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-tab, "#{$TextVKeyboardTrapTab}")

      &:where([data-v-kbd-trap~=vertical])
        --v-kbd-trap-roving: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-arrows-vertical, "#{$TextVKeyboardTrapArrowsVertical}")

      &:where([data-v-kbd-trap~=horizontal])
        --v-kbd-trap-roving: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-arrows-horizontal, "#{$TextVKeyboardTrapArrowsHorizontal}")

      &:where([data-v-kbd-trap~=grid], [role=grid])
        --v-kbd-trap-roving: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-grid, "#{$TextVKeyboardTrapGrid}")

      &:has(input:not([disabled]):not([type="button"]):not([type="checkbox"]):not([type="file"]):not([type="image"]):not([type="radio"]):not([type="reset"]):not([type="submit"]):focus-visible, select:not([disabled]):focus-visible, select:not([disabled]) *:focus-visible, textarea:not([disabled]):focus-visible, [contenteditable]:not([contenteditable="false"]):focus-visible, [contenteditable]:not([contenteditable="false"]) *:focus-visible)
        --v-kbd-trap-tab: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-tab, "#{$TextVKeyboardTrapTab}")
        --v-kbd-trap-roving: ""

    &:where([data-v-kbd-trap~=escrefocus])
      --v-kbd-trap-esc: var(--text-v-kbd-trap-esc-refocus, "#{$TextVKeyboardTrapEscRefocus}")

    &:where([data-v-kbd-trap~=escexits])
      --v-kbd-trap-esc: var(--text-v-kbd-trap-esc-exits, "#{$TextVKeyboardTrapEscExits}")

  &:where([data-v-kbd-trap~=roving][data-v-kbd-trap~=horizontal]):has([data-v-kbd-trap-active][data-v-kbd-trap~=roving][data-v-kbd-trap~=vertical])
    --v-kbd-trap-roving: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-arrows-horizontal, "#{$TextVKeyboardTrapArrowsHorizontal}")
    &:after
      color: var(--color-v-kbd-trap-enabled, #{$ColorVKeyboardTrapEnabled})

  &:where([data-v-kbd-trap~=roving][data-v-kbd-trap~=vertical]):has([data-v-kbd-trap-active][data-v-kbd-trap~=roving][data-v-kbd-trap~=horizontal])
    --v-kbd-trap-roving: var(--text-v-kbd-trap-separator, "#{$TextVKeyboardTrapSeparator}") var(--text-v-kbd-trap-arrows-vertical, "#{$TextVKeyboardTrapArrowsVertical}")
    &:after
      color: var(--color-v-kbd-trap-enabled, #{$ColorVKeyboardTrapEnabled})

Development

Install the dependencies

bash
pnpm i

Start development mode (hot-code reloading, error reporting, etc.)

bash
pnpm dev

Lint the files

bash
pnpm lint

Build for production

bash
pnpm build

Source code, issues, bug reports, feature requests

Vue Keyboard Trap (vue-keyboard-trap)

Author

License

Copyright © 2022-present Dan Popescu.

This application is distributed under License: MIT, see LICENSE for more information.

MIT Licensed