From dfcc43089c05d551c060b3fa46a098ee3d2a313e Mon Sep 17 00:00:00 2001 From: Dan Popescu Date: Mon, 20 Apr 2020 22:22:30 +0300 Subject: [PATCH] feat(i18n): Add KeyGroupNavigation directive #5266, #4068, #6736 - allow unique TAB target point in a group - allow key navigation in group - improve initial focusing on QMenu and QDialog --- docs/src/assets/menu.js | 5 + docs/src/examples/KeyGroupNavigation/Bar.vue | 91 +++++++ .../KeyGroupNavigation/FormControls.vue | 24 ++ docs/src/examples/KeyGroupNavigation/List.vue | 71 ++++++ .../examples/KeyGroupNavigation/Toolbar.vue | 76 ++++++ .../vue-directives/key-group-navigation.md | 39 +++ ui/dev/src/pages/components/list-item.vue | 13 +- .../touch-directives/key-group-navigation.vue | 235 ++++++++++++++++++ ui/src/components/date/QDate.js | 4 +- ui/src/components/dialog/QDialog.js | 2 +- ui/src/components/editor/QEditor.js | 5 +- ui/src/components/knob/QKnob.js | 2 +- ui/src/components/menu/QMenu.js | 2 +- ui/src/components/rating/QRating.js | 2 +- ui/src/components/slider/slider-utils.js | 2 +- ui/src/components/tabs/QTabs.js | 2 +- ui/src/components/time/QTime.js | 2 +- ui/src/directives.js | 2 + ui/src/directives/KeyGroupNavigation.js | 222 +++++++++++++++++ ui/src/directives/KeyGroupNavigation.json | 36 +++ ui/src/mixins/focus-wrap.js | 37 ++- ui/src/mixins/portal.js | 12 +- ui/src/utils/focus.js | 38 +++ 23 files changed, 891 insertions(+), 33 deletions(-) create mode 100644 docs/src/examples/KeyGroupNavigation/Bar.vue create mode 100644 docs/src/examples/KeyGroupNavigation/FormControls.vue create mode 100644 docs/src/examples/KeyGroupNavigation/List.vue create mode 100644 docs/src/examples/KeyGroupNavigation/Toolbar.vue create mode 100644 docs/src/pages/vue-directives/key-group-navigation.md create mode 100644 ui/dev/src/pages/touch-directives/key-group-navigation.vue create mode 100644 ui/src/directives/KeyGroupNavigation.js create mode 100644 ui/src/directives/KeyGroupNavigation.json create mode 100644 ui/src/utils/focus.js diff --git a/docs/src/assets/menu.js b/docs/src/assets/menu.js index 6f56cad80814..b4e5f1ee1635 100644 --- a/docs/src/assets/menu.js +++ b/docs/src/assets/menu.js @@ -683,6 +683,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..3b52bf17cfc1 --- /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..62e86ce2337b --- /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-key` 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/dialog/QDialog.js b/ui/src/components/dialog/QDialog.js index 1e8eefd68c34..389cc751a205 100644 --- a/ui/src/components/dialog/QDialog.js +++ b/ui/src/components/dialog/QDialog.js @@ -339,7 +339,7 @@ export default Vue.extend({ class: this.classes, attrs: { tabindex: -1 }, on - }, this.__getFocusWrappedContent(h, 'default')) : null + }, this.__getFocusWrappedContent('default')) : null ]) ]) } 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..d319ae6259e3 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-key', class: this.classes, attrs: this.attrs, diff --git a/ui/src/components/menu/QMenu.js b/ui/src/components/menu/QMenu.js index 3b33fb31354e..251ed8227fe4 100644 --- a/ui/src/components/menu/QMenu.js +++ b/ui/src/components/menu/QMenu.js @@ -307,7 +307,7 @@ export default Vue.extend({ value: this.__onClickOutside, arg: this.anchorEl }] - }, this.__getFocusWrappedContent(h, 'default')) : null + }, this.__getFocusWrappedContent('default')) : null ]) } }, diff --git a/ui/src/components/rating/QRating.js b/ui/src/components/rating/QRating.js index 80ba696b3d6c..358285c1b014 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-key', 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..8daf0645d375 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-key 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..b18bbd5ea2c8 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-key', class: this.classes, on: { input: stop, diff --git a/ui/src/components/time/QTime.js b/ui/src/components/time/QTime.js index aa5a97182625..807224ef1637 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-key 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..a219f6f03c00 --- /dev/null +++ b/ui/src/directives/KeyGroupNavigation.js @@ -0,0 +1,222 @@ +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 || + (evt.relatedTarget && evt.relatedTarget.classList.contains('q-key-group-navigation--ignore-focus')) + ) { + 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/focus-wrap.js b/ui/src/mixins/focus-wrap.js index 230af9d368ef..206ac24602dd 100644 --- a/ui/src/mixins/focus-wrap.js +++ b/ui/src/mixins/focus-wrap.js @@ -1,26 +1,17 @@ import { mergeSlot } from '../utils/slot.js' +import { FOCUSABLE_SELECTOR, changeFocusedElement } from '../utils/focus' export default { computed: { - focusElementDefs () { + focusGuardElements () { return { firstGuard: this.$createElement('span', { - staticClass: 'hide-outline absolute no-pointer-events', + staticClass: 'hide-outline absolute no-pointer-events q-key-group-navigation--ignore-focus', attrs: { tabindex: 0 }, on: { focus: this.__focusLast } }), - firstTarget: { - ref: 'firstFocusTarget', - staticClass: 'hide-outline absolute no-pointer-events', - attrs: { tabindex: -1 } - }, - lastTarget: { - ref: 'lastFocusTarget', - staticClass: 'hide-outline absolute no-pointer-events', - attrs: { tabindex: -1 } - }, lastGuard: this.$createElement('span', { - staticClass: 'hide-outline absolute no-pointer-events', + staticClass: 'hide-outline absolute no-pointer-events q-key-group-navigation--ignore-focus', attrs: { tabindex: 0 }, on: { focus: this.__focusFirst } }) @@ -30,24 +21,26 @@ export default { methods: { __focusFirst () { - if (this.__portal !== void 0 && this.__portal.$refs !== void 0 && this.__portal.$refs.firstFocusTarget !== void 0) { - this.__portal.$refs.firstFocusTarget.focus() + const innerNode = this.__getInnerNode() + if (innerNode !== void 0) { + const focusableElements = Array.prototype.slice.call(innerNode.querySelectorAll(FOCUSABLE_SELECTOR), 1, -1) + changeFocusedElement(focusableElements, 0, 1) } }, __focusLast () { - if (this.__portal !== void 0 && this.__portal.$refs !== void 0 && this.__portal.$refs.lastFocusTarget !== void 0) { - this.__portal.$refs.lastFocusTarget.focus() + const innerNode = this.__getInnerNode() + if (innerNode !== void 0) { + const focusableElements = Array.prototype.slice.call(innerNode.querySelectorAll(FOCUSABLE_SELECTOR), 1, -1) + changeFocusedElement(focusableElements, focusableElements.length - 1, -1) } }, - __getFocusWrappedContent (h, slotName) { + __getFocusWrappedContent (slotName) { return mergeSlot([ - this.focusElementDefs.firstGuard, - h('span', this.focusElementDefs.firstTarget) + this.focusGuardElements.firstGuard ], this, slotName).concat( - h('span', this.focusElementDefs.lastTarget), - this.focusElementDefs.lastGuard + this.focusGuardElements.lastGuard ) } } diff --git a/ui/src/mixins/portal.js b/ui/src/mixins/portal.js index ae4ccfee2549..04b135bc684b 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,15 @@ 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 { + 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..90e514a15c4f --- /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-key', + '.q-key-group-navigation--ignore-key *' +].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) + } +}