diff --git a/docs/src/assets/menu.js b/docs/src/assets/menu.js index ff203e4fe7c4..904e7b2552ad 100644 --- a/docs/src/assets/menu.js +++ b/docs/src/assets/menu.js @@ -660,6 +660,11 @@ const directives = [ name: 'Go Back (Handling Back Button)', path: 'go-back' }, + { + name: 'Key Group Navigation', + badge: 'new', + path: 'key-group-navigation' + }, { name: 'Intersection', path: 'intersection' diff --git a/docs/src/examples/KeyGroupNavigation/Bar.vue b/docs/src/examples/KeyGroupNavigation/Bar.vue new file mode 100644 index 000000000000..e1d07d2d7519 --- /dev/null +++ b/docs/src/examples/KeyGroupNavigation/Bar.vue @@ -0,0 +1,91 @@ + diff --git a/docs/src/examples/KeyGroupNavigation/FormControls.vue b/docs/src/examples/KeyGroupNavigation/FormControls.vue new file mode 100644 index 000000000000..4d46ba0f6659 --- /dev/null +++ b/docs/src/examples/KeyGroupNavigation/FormControls.vue @@ -0,0 +1,24 @@ + + + diff --git a/docs/src/examples/KeyGroupNavigation/List.vue b/docs/src/examples/KeyGroupNavigation/List.vue new file mode 100644 index 000000000000..12c4797eee11 --- /dev/null +++ b/docs/src/examples/KeyGroupNavigation/List.vue @@ -0,0 +1,71 @@ + + + diff --git a/docs/src/examples/KeyGroupNavigation/Toolbar.vue b/docs/src/examples/KeyGroupNavigation/Toolbar.vue new file mode 100644 index 000000000000..77398c974c5f --- /dev/null +++ b/docs/src/examples/KeyGroupNavigation/Toolbar.vue @@ -0,0 +1,76 @@ + + + diff --git a/docs/src/pages/vue-directives/key-group-navigation.md b/docs/src/pages/vue-directives/key-group-navigation.md new file mode 100644 index 000000000000..8626c7cf7c24 --- /dev/null +++ b/docs/src/pages/vue-directives/key-group-navigation.md @@ -0,0 +1,39 @@ +--- +title: Handling Keyboard Navigation in Groups of Controls +desc: How to improve keyboard accessibility when using groups of controls in a Quasar app. +--- +Quasar offers a simple way to improve keyboard accessibility when using a large number of controls that can be grouped. + +## Installation + + +## Usage +Attach the directive on a group wrapping component or DOM element (like QList, QBar, QToolbar). +Keyboard navigation using `TAB` or `SHIFT` + `TAB` keys will only select one tabbable element inside the group: +- the first / last tabbable element depending on navigation direction when first entering the group +- the last selected tabbable element when the group was visited before +- pressing the `TAB` or `SHIFT` + `TAB` keys when an element is focused inside the group will focus the next tabbable element after the group or the previous une before the group +Keyboard navigation inside the group can be performed using: +- `HOME`, `ARROW_LEFT`, `ARROW_RIGHT` and `END` keys when `horizontal` modifier is used +- `PG_UP`, `ARROW_UP`, `ARROW_DOWN` and `PG_DOWN` keys when `vertical` modifier is used +- any of the above keys when neither `horizontal` nor `vertical` modifiers are used (default) +The navigation wraps at the start / end, moving to the last / first tabbable element. + +::: tip +To skip processing key events for some elements set a `q-key-group-navigation-ignore` class on them or on a parent of them. +::: + +::: warning +Try not to mix keyboard controlled components (like QTab, QTabs, QKnob, QRange, QSlider, QRating, QTime) in key navigation groups as it might get confusing to the user. +::: + + + + + + + + + +## KeyGroupNavigation API + diff --git a/ui/dev/src/pages/components/list-item.vue b/ui/dev/src/pages/components/list-item.vue index 6fb6cc6a8e36..601d26c8616d 100644 --- a/ui/dev/src/pages/components/list-item.vue +++ b/ui/dev/src/pages/components/list-item.vue @@ -21,7 +21,17 @@ - + + + Group Key Navigation in first list + + + + + + + + Single line item @@ -792,6 +802,7 @@ export default { return { dark: null, separator: false, + keyNavEnabled: true, check1: true, check2: false, diff --git a/ui/dev/src/pages/touch-directives/key-group-navigation.vue b/ui/dev/src/pages/touch-directives/key-group-navigation.vue new file mode 100644 index 000000000000..69d0e2c0d63d --- /dev/null +++ b/ui/dev/src/pages/touch-directives/key-group-navigation.vue @@ -0,0 +1,235 @@ + + + diff --git a/ui/src/components/date/QDate.js b/ui/src/components/date/QDate.js index 3c81554fe02b..69cdc3c6d9fc 100644 --- a/ui/src/components/date/QDate.js +++ b/ui/src/components/date/QDate.js @@ -1,5 +1,6 @@ import Vue from 'vue' +import VKeyGroupNavigation from '../../directives/KeyGroupNavigation.js' import QBtn from '../btn/QBtn.js' import DateTimeMixin from '../../mixins/datetime.js' @@ -951,7 +952,8 @@ export default Vue.extend({ return h('div', { class: this.classes, attrs: this.attrs, - on: this.$listeners + on: this.$listeners, + directives: [ VKeyGroupNavigation ] }, [ this.__getHeader(h), diff --git a/ui/src/components/editor/QEditor.js b/ui/src/components/editor/QEditor.js index 865d79726353..9e82c986c411 100644 --- a/ui/src/components/editor/QEditor.js +++ b/ui/src/components/editor/QEditor.js @@ -1,5 +1,7 @@ import Vue from 'vue' +import VKeyGroupNavigation from '../../directives/KeyGroupNavigation.js' + import { getToolbar, getFonts, getLinkEditor } from './editor-utils.js' import { Caret } from './editor-caret.js' @@ -428,7 +430,8 @@ export default Vue.extend({ toolbars = h('div', { key: 'toolbar_ctainer', - staticClass: 'q-editor__toolbars-container' + staticClass: 'q-editor__toolbars-container', + directives: [ VKeyGroupNavigation ] }, bars) } diff --git a/ui/src/components/knob/QKnob.js b/ui/src/components/knob/QKnob.js index f331c3caa437..04b7932452d5 100644 --- a/ui/src/components/knob/QKnob.js +++ b/ui/src/components/knob/QKnob.js @@ -243,7 +243,7 @@ export default Vue.extend({ render (h) { const data = { - staticClass: 'q-knob non-selectable', + staticClass: 'q-knob non-selectable q-key-group-navigation-ignore', class: this.classes, attrs: this.attrs, diff --git a/ui/src/components/rating/QRating.js b/ui/src/components/rating/QRating.js index 80ba696b3d6c..ec8597e8b4d9 100644 --- a/ui/src/components/rating/QRating.js +++ b/ui/src/components/rating/QRating.js @@ -195,7 +195,7 @@ export default Vue.extend({ } return h('div', { - staticClass: 'q-rating row inline items-center', + staticClass: 'q-rating row inline items-center q-key-group-navigation-ignore', class: this.classes, style: this.sizeStyle, attrs: this.attrs, diff --git a/ui/src/components/slider/slider-utils.js b/ui/src/components/slider/slider-utils.js index cbec44df9e1e..005272dbcbcd 100644 --- a/ui/src/components/slider/slider-utils.js +++ b/ui/src/components/slider/slider-utils.js @@ -90,7 +90,7 @@ export let SliderMixin = { computed: { classes () { - return `q-slider q-slider--${this.active === true ? '' : 'in'}active` + + return `q-slider q-key-group-navigation-ignore q-slider--${this.active === true ? '' : 'in'}active` + (this.isReversed === true ? ' q-slider--reversed' : '') + (this.vertical === true ? ' q-slider--vertical' : ' q-slider--horizontal') + (this.color !== void 0 ? ` text-${this.color}` : '') + diff --git a/ui/src/components/tabs/QTabs.js b/ui/src/components/tabs/QTabs.js index 715b4df85e01..a8ca8c5136b2 100644 --- a/ui/src/components/tabs/QTabs.js +++ b/ui/src/components/tabs/QTabs.js @@ -498,7 +498,7 @@ export default Vue.extend({ ) return h('div', { - staticClass: 'q-tabs row no-wrap items-center', + staticClass: 'q-tabs row no-wrap items-center q-key-group-navigation-ignore', class: this.classes, on: { input: stop, diff --git a/ui/src/components/time/QTime.js b/ui/src/components/time/QTime.js index aa5a97182625..71c401acb0ab 100644 --- a/ui/src/components/time/QTime.js +++ b/ui/src/components/time/QTime.js @@ -98,7 +98,7 @@ export default Vue.extend({ computed: { classes () { - return `q-time q-time--${this.landscape === true ? 'landscape' : 'portrait'}` + + return `q-time q-key-group-navigation-ignore q-time--${this.landscape === true ? 'landscape' : 'portrait'}` + (this.isDark === true ? ' q-time--dark q-dark' : '') + (this.disable === true ? ' disabled' : (this.readonly === true ? ' q-time--readonly' : '')) + (this.bordered === true ? ` q-time--bordered` : '') + diff --git a/ui/src/directives.js b/ui/src/directives.js index 83c5f94920bc..f5baea2370b2 100644 --- a/ui/src/directives.js +++ b/ui/src/directives.js @@ -1,6 +1,7 @@ import ClosePopup from './directives/ClosePopup.js' import GoBack from './directives/GoBack.js' import Intersection from './directives/Intersection.js' +import KeyGroupNavigation from './directives/KeyGroupNavigation.js' import Mutation from './directives/Mutation.js' import Ripple from './directives/Ripple.js' import ScrollFire from './directives/ScrollFire.js' @@ -14,6 +15,7 @@ export { ClosePopup, GoBack, Intersection, + KeyGroupNavigation, Mutation, Ripple, ScrollFire, diff --git a/ui/src/directives/KeyGroupNavigation.js b/ui/src/directives/KeyGroupNavigation.js new file mode 100644 index 000000000000..a11722391948 --- /dev/null +++ b/ui/src/directives/KeyGroupNavigation.js @@ -0,0 +1,218 @@ +import { addEvt, cleanEvt } from '../utils/touch.js' +import { prevent } from '../utils/event.js' +import { + FOCUSABLE_SELECTOR, + KEY_SKIP_SELECTOR, + changeFocusedElement +} from '../utils/focus.js' + +const keyCodes = { + horizontal: { + first: [ 36 ], // HOME + prev: [ 37 ], // ARROW_LEFT + next: [ 39 ], // ARROW_RIGHT + last: [ 35 ] // END + }, + vertical: { + first: [ 33 ], // PG_UP + prev: [ 38 ], // ARROW_UP + next: [ 40 ], // ARROW_DOWN + last: [ 34 ] // PG_DOWN + } +} + +keyCodes.all = Object.keys(keyCodes.horizontal).reduce((acc, key) => ({ + ...acc, + [key]: keyCodes.horizontal[key].concat(keyCodes.vertical[key]) +}), {}) + +keyCodes.horizontal.list = Object.values(keyCodes.horizontal).reduce((acc, v) => acc.concat(v), []) +keyCodes.vertical.list = Object.values(keyCodes.vertical).reduce((acc, v) => acc.concat(v), []) +keyCodes.all.list = Object.values(keyCodes.all).reduce((acc, v) => acc.concat(v), []) + +function createFocusTargets (ctx) { + const target = document.createElement('span') + target.setAttribute('tabindex', -1) + target.classList.add('hide-outline') + target.classList.add('absolute') + target.classList.add('no-pointer-events') + + ctx.firstTarget = target + ctx.lastTarget = target.cloneNode() + ctx.lastTarget.setAttribute('tabindex', -1) +} + +function addFocusTargets (ctx, el) { + el.appendChild(ctx.lastTarget) + + if (el.childElementCount > 0) { + el.insertBefore(ctx.firstTarget, el.childNodes[0]) + } + else { + el.appendChild(ctx.firstTarget) + } +} + +function removeFocusTargets (ctx) { + ctx.firstTarget !== void 0 && ctx.firstTarget.remove() + ctx.lastTarget !== void 0 && ctx.lastTarget.remove() +} + +function update (el, { modifiers, value }) { + const ctx = el.__qkeygrpnav + + if (ctx !== void 0) { + if (modifiers.vertical === true) { + ctx.keyCodes = keyCodes.vertical + } + else { + ctx.keyCodes = modifiers.horizontal === true + ? keyCodes.horizontal + : keyCodes.all + } + + const disabled = [false, 0, '0'].indexOf(value) > -1 + + if (ctx.enabled === true && disabled === true) { + ctx.enabled = false + cleanEvt(ctx, 'main') + } + else if (ctx.enabled !== true && disabled !== true) { + ctx.enabled = true + addEvt(ctx, 'main', [ + [ el, 'keydown', 'keyDown', 'capturePassive' ], + [ el, 'focusin', 'focusIn', 'capture' ], + [ el, 'mousedown', 'setRestoreEl', 'capturePassive' ], + [ el, 'touchstart', 'setRestoreEl', 'capturePassive' ] + ]) + } + } +} + +export default { + name: 'key-group-navigation', + + bind (el, binding) { + const ctx = { + keyCodes: keyCodes.all, + + keyDown (evt) { + const { keyCode, shiftKey } = evt + + if ( + (keyCode !== 9 && ctx.keyCodes.list.indexOf(keyCode) === -1) || + evt.target.matches(KEY_SKIP_SELECTOR) === true + ) { + return + } + + if (keyCode === 9) { // TAB + addFocusTargets(ctx, el) + + if (shiftKey === true) { + if (ctx.firstTarget !== void 0) { + ctx.firstTarget.focus() + } + else { + prevent(evt) + } + } + else { + if (ctx.lastTarget !== void 0) { + ctx.lastTarget.focus() + } + else { + prevent(evt) + } + } + + removeFocusTargets(ctx) + + return + } + + const focusableElements = Array.prototype.slice.call(el.querySelectorAll(FOCUSABLE_SELECTOR)) + const lastElementIndex = focusableElements.length - 1 + + if (lastElementIndex < 0) { + return + } + + if (ctx.keyCodes.first.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, 0, 1) + } + else if (ctx.keyCodes.last.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, lastElementIndex, -1) + } + else { + const currentIndex = document.activeElement === null + ? -1 + : focusableElements.indexOf(document.activeElement.closest(FOCUSABLE_SELECTOR)) + + if (ctx.keyCodes.prev.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, currentIndex - 1, -1) + } + if (ctx.keyCodes.next.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, currentIndex + 1, 1) + } + } + + prevent(evt) + }, + + setRestoreEl (evt) { + if (evt.target) { + ctx.focusRestoreEl = evt.target + } + }, + + focusIn (evt) { + if (evt.target === ctx.firstTarget || evt.target === ctx.lastTarget) { + return + } + + if ( + ctx.focusRestoreEl === void 0 || + ctx.focusRestoreEl === null || + el.contains(evt.relatedTarget) === true + ) { + ctx.focusRestoreEl = document.activeElement + } + else { + const focusedEl = ctx.focusRestoreEl.closest(FOCUSABLE_SELECTOR) + + if (focusedEl === null || typeof focusedEl.focus !== 'function') { + if (typeof ctx.focusRestoreEl.focus === 'function') { + ctx.focusRestoreEl.focus() + } + } + else { + focusedEl.focus() + } + } + } + } + + if (el.__qkeygrpnav) { + el.__qkeygrpnav_old = el.__qkeygrpnav + } + + el.__qkeygrpnav = ctx + + createFocusTargets(ctx) + + update(el, binding) + }, + + update, + + unbind (el) { + const ctx = el.__qkeygrpnav_old || el.__qkeygrpnav + if (ctx !== void 0) { + removeFocusTargets(ctx) + cleanEvt(ctx, 'main') + + delete el[el.__qkeygrpnav_old ? '__qkeygrpnav_old' : '__qkeygrpnav'] + } + } +} diff --git a/ui/src/directives/KeyGroupNavigation.json b/ui/src/directives/KeyGroupNavigation.json new file mode 100644 index 000000000000..eb1fde886b86 --- /dev/null +++ b/ui/src/directives/KeyGroupNavigation.json @@ -0,0 +1,36 @@ +{ + "meta": { + "docsUrl": "https://v1.quasar.dev/vue-directives/key-group-navigation" + }, + + "value": { + "type": [ "Boolean", "Number", "String" ], + "desc": "If value is 0, '0' or 'false' then directive is disabled; else it is enabled)", + "examples": [ + "v-key-group-navigation", + "v-key-group-navigation=\"booleanState\"", + "v-key-group-navigation=\"0\"", + "v-key-group-navigation=\"false\"" + ] + }, + + "modifiers": { + "horizontal": { + "type": "Boolean", + "desc": "Navigate using HOME, ARROW_LEFT, ARROW_RIGHT or END keys", + "reactive": true + }, + + "vertical": { + "type": "Boolean", + "desc": "Navigate using PG_UP, ARROW_UP, ARROW_DOWN or PG_DOWN keys", + "reactive": true + }, + + "all": { + "type": "Boolean", + "desc": "Default - Navigate using HOME / PG_UP, ARROW_LEFT / ARROW_UP, ARROW_RIGHT / ARROW_DOWN or END / PG_DOWN keys", + "reactive": true + } + } +} diff --git a/ui/src/mixins/portal.js b/ui/src/mixins/portal.js index ae4ccfee2549..35c49961e016 100644 --- a/ui/src/mixins/portal.js +++ b/ui/src/mixins/portal.js @@ -1,5 +1,7 @@ import Vue from 'vue' +import { FOCUSABLE_SELECTOR, changeFocusedElement } from '../utils/focus' + export function closePortalMenus (vm, evt) { do { if (vm.$options.name === 'QMenu') { @@ -56,7 +58,17 @@ export default { const node = this.__getInnerNode() if (node !== void 0 && node.contains(document.activeElement) !== true) { - (node.querySelector('[autofocus], [data-autofocus]') || this.__portal.$refs.firstFocusTarget).focus() + const autofocusNode = node.querySelector('[autofocus], [data-autofocus]') + + if (autofocusNode !== null && typeof autofocusNode.focus === 'function') { + autofocusNode.focus() + } + else if (this.__portal.$refs.firstFocusTarget !== void 0) { + this.__portal.$refs.firstFocusTarget.focus() + + const focusableElements = Array.prototype.slice.call(node.querySelectorAll(FOCUSABLE_SELECTOR)) + changeFocusedElement(focusableElements, 0, 1) + } } }, diff --git a/ui/src/utils/focus.js b/ui/src/utils/focus.js new file mode 100644 index 000000000000..1294e1842f1a --- /dev/null +++ b/ui/src/utils/focus.js @@ -0,0 +1,38 @@ +import { normalizeToInterval } from './format.js' + +export const FOCUSABLE_SELECTOR = [ + 'a[href]:not([tabindex="-1"])', + 'area[href]:not([tabindex="-1"])', + 'input:not([disabled]):not([tabindex="-1"])', + 'select:not([disabled]):not([tabindex="-1"])', + 'textarea:not([disabled]):not([tabindex="-1"])', + 'button:not([disabled]):not([tabindex="-1"])', + 'iframe:not([tabindex="-1"])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable]:not([tabindex="-1"]):not([contenteditable=false])' +].join(',') + +export const KEY_SKIP_SELECTOR = [ + 'input:not([disabled])', + 'select:not([disabled])', + 'select:not([disabled]) *', + 'textarea:not([disabled])', + '[contenteditable]:not([contenteditable=false])', + '[contenteditable]:not([contenteditable=false]) *', + '.q-key-group-navigation-ignore', + '.q-key-group-navigation-ignore *' +].join(',') + +export function changeFocusedElement (list, to, direction = 1, start) { + const index = normalizeToInterval(to, 0, list.length - 1) + + if (index === start) { + return + } + + list[index].focus() + + if (document.activeElement !== list[index]) { + changeFocusedElement(list, index + direction, direction, start === void 0 ? index : start) + } +}