From 34ba15540ab8b5ebeff6f3f7e57c42a9c15687b6 Mon Sep 17 00:00:00 2001 From: Dan Popescu Date: Sun, 18 Jul 2021 10:22:57 +0300 Subject: [PATCH] feat(a11y/QMenu/QDialog/QTabs): Add KeyGroupNavigation directive; Wrap arround keyboard navigation #5266, #4068, #6736, #6562, #6560, #12464, #12506, #12505 - allow unique TAB target point in a group - allow key navigation in group - improve initial focusing on QMenu and QDialog - tab goes from the end of the menu/dialog to the start - shift+tab goes from the start of the menu/dialog to the end - key navigation in tabs --- docs/src/assets/menu.js | 5 + docs/src/components/DocApi.vue | 8 +- docs/src/components/DocExample.vue | 2 +- docs/src/components/DocLink.vue | 3 + docs/src/components/DocPage.vue | 54 ++- docs/src/examples/KeyGroupNavigation/Bar.vue | 91 +++++ .../KeyGroupNavigation/FormControls.vue | 24 ++ docs/src/examples/KeyGroupNavigation/List.vue | 71 ++++ .../examples/KeyGroupNavigation/Toolbar.vue | 72 ++++ docs/src/examples/QDialog/FocusSelection.vue | 108 ++++++ docs/src/pages/options/interaction-plugin.md | 10 +- docs/src/pages/vue-components/dialog.md | 10 + .../vue-directives/key-group-navigation.md | 45 +++ ui/dev/src/pages/components/list-item.vue | 13 +- ui/dev/src/pages/components/tabs.vue | 19 +- .../touch-directives/key-group-navigation.vue | 259 +++++++++++++ ui/src/components/carousel/QCarouselSlide.js | 1 + ui/src/components/date/QDate.js | 59 ++- ui/src/components/date/QDate.sass | 3 - ui/src/components/date/QDate.styl | 3 - ui/src/components/dialog/QDialog.js | 65 ++-- ui/src/components/dialog/QDialog.json | 2 +- ui/src/components/editor/QEditor.js | 9 +- ui/src/components/field/QField.js | 2 +- ui/src/components/knob/QKnob.js | 2 +- ui/src/components/menu/QMenu.js | 41 +-- ui/src/components/menu/QMenu.json | 11 +- ui/src/components/rating/QRating.js | 2 +- ui/src/components/slider/slider-utils.js | 3 +- ui/src/components/tabs/QTab.js | 2 +- ui/src/components/tabs/QTabs.js | 20 +- ui/src/components/time/QTime.js | 3 +- ui/src/css/core/visibility.sass | 3 + ui/src/css/core/visibility.styl | 3 + ui/src/directives.js | 2 + ui/src/directives/KeyGroupNavigation.js | 345 ++++++++++++++++++ ui/src/directives/KeyGroupNavigation.json | 46 +++ ui/src/mixins/focus-wrap.js | 38 ++ ui/src/mixins/panel.js | 2 - ui/src/mixins/portal.js | 32 +- ui/src/utils/private/focus-manager.js | 56 +++ 41 files changed, 1417 insertions(+), 132 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/examples/QDialog/FocusSelection.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/mixins/focus-wrap.js diff --git a/docs/src/assets/menu.js b/docs/src/assets/menu.js index d76babe6dd4d..09cbd3e05231 100644 --- a/docs/src/assets/menu.js +++ b/docs/src/assets/menu.js @@ -700,6 +700,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/components/DocApi.vue b/docs/src/components/DocApi.vue index 3d90df6640ce..372da50745b5 100644 --- a/docs/src/components/DocApi.vue +++ b/docs/src/components/DocApi.vue @@ -76,10 +76,10 @@ q-card.doc-api.q-my-lg(flat bordered) transition-next="slide-up" ) q-tab-panel.q-pa-none(v-for="innerTab in innerTabsList[tab]" :name="innerTab" :key="innerTab") - DocApiEntry(:type="tab" :definition="filteredApi[tab][innerTab]") + DocApiEntry(:type="tab" :definition="filteredApi[tab][innerTab]" tabindex="0") .api-container(v-else) - DocApiEntry(:type="tab" :definition="filteredApi[tab][defaultInnerTabName]") + DocApiEntry(:type="tab" :definition="filteredApi[tab][defaultInnerTabName]" tabindex="0") 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..a3ec95d06082 --- /dev/null +++ b/docs/src/examples/KeyGroupNavigation/Toolbar.vue @@ -0,0 +1,72 @@ + + + diff --git a/docs/src/examples/QDialog/FocusSelection.vue b/docs/src/examples/QDialog/FocusSelection.vue new file mode 100644 index 000000000000..c162eebab58b --- /dev/null +++ b/docs/src/examples/QDialog/FocusSelection.vue @@ -0,0 +1,108 @@ + + + diff --git a/docs/src/pages/options/interaction-plugin.md b/docs/src/pages/options/interaction-plugin.md index 554bcfe5e4fb..be36d38ea5c5 100644 --- a/docs/src/pages/options/interaction-plugin.md +++ b/docs/src/pages/options/interaction-plugin.md @@ -2,12 +2,19 @@ title: Interaction Plugin desc: Quasar plugin that helps in detecting human interactions through Javascript code. --- + The Quasar Interaction plugin detects interactions with the browser and provides useful details about the last event. +## API + + + ## Installation + You don't need to do anything. The Interaction plugin gets installed automatically. ## Usage + Notice `$q.interaction` below. This is just a simple usage example. ```html @@ -37,6 +44,3 @@ import { Interaction } from 'quasar' // Interaction.isPointer // Interaction.event !== null && Interaction.event.target ``` - -## API - diff --git a/docs/src/pages/vue-components/dialog.md b/docs/src/pages/vue-components/dialog.md index 04b5cda3bc85..ffb8636238ab 100644 --- a/docs/src/pages/vue-components/dialog.md +++ b/docs/src/pages/vue-components/dialog.md @@ -79,6 +79,16 @@ You are able to customize the size of the Dialogs. Notice we either tamper with +### Choosing the focused element +When the dialog shows, if `no-focus` property is not set, one element of the dialog will get automatic focus. + +The element that will be focused is selected in this order: +- if any element in the dialog is already in focus it will stay focused +- the first element that has `autofocus` or `data-autofocus` attribute, if it is focusable +- the first focusable element in the dialog + + + ## Cordova/Capacitor back button Quasar handles the back button for you by default so it can hide any opened Dialogs instead of the default behavior which is to return to the previous page (which is not a nice user experience). 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..d980072901b0 --- /dev/null +++ b/docs/src/pages/vue-directives/key-group-navigation.md @@ -0,0 +1,45 @@ +--- +title: Handling Keyboard Navigation in Groups of Controls +desc: How to improve keyboard accessibility when using groups of controls in a Quasar app. +badge: "v1.13+" +--- + +Quasar offers a simple way to improve keyboard accessibility when using a large number of controls that can be grouped. + +## KeyGroupNavigation API + + + +## 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. +* If you want a specific element to be focused when keyboard navigating to a group then add a `q-key-group-navigation__refocus` class to the element. +::: + +::: warning +Try not to mix keyboard controlled components (like QKnob, QRange, QSlider, QRating, QDate, QTime) in key navigation groups as it might get confusing to the user. +::: + + + + + + + + diff --git a/ui/dev/src/pages/components/list-item.vue b/ui/dev/src/pages/components/list-item.vue index 3335927cb893..7b8ba0b44541 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 @@ -813,6 +823,7 @@ export default { return { dark: null, separator: false, + keyNavEnabled: true, check1: true, check2: false, diff --git a/ui/dev/src/pages/components/tabs.vue b/ui/dev/src/pages/components/tabs.vue index 68e058b590cf..a31fbf6c34e4 100644 --- a/ui/dev/src/pages/components/tabs.vue +++ b/ui/dev/src/pages/components/tabs.vue @@ -36,7 +36,7 @@ - + Wifi @@ -400,11 +400,12 @@ indicator-color="yellow" class="bg-cyan text-white" style="margin-bottom: 0" + aria-label="Tabs controlling panels" > - - - - + + + + - + Tab One (Swapped)
Lorem ipsum dolor sit amet consectetur adipisicing elit. Provident obcaecati repellendus dolores totam nostrum ut repudiandae perspiciatis est accusamus, eaque natus modi rem beatae optio cumque, velit ducimus autem magnam.
- + Tab Two (Swapped)
Lorem ipsum dolor sit amet consectetur adipisicing elit. At iusto neque odio porro, animi ducimus iure autem commodi sint, magni voluptatum molestias illo accusamus voluptate ratione aperiam. Saepe, fugiat vel.
- + Tab Three
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quis labore inventore accusantium, perferendis eos sapiente culpa consectetur deserunt praesentium cumque distinctio placeat, recusandae id qui odit similique officia? Mollitia, ea!
- + Tab Four
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quis labore inventore accusantium, perferendis eos sapiente culpa consectetur deserunt praesentium cumque distinctio placeat, recusandae id qui odit similique officia? Mollitia, ea!
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..725a1476ef27 --- /dev/null +++ b/ui/dev/src/pages/touch-directives/key-group-navigation.vue @@ -0,0 +1,259 @@ + + + diff --git a/ui/src/components/carousel/QCarouselSlide.js b/ui/src/components/carousel/QCarouselSlide.js index 085caca40eaf..ce799f284bfc 100644 --- a/ui/src/components/carousel/QCarouselSlide.js +++ b/ui/src/components/carousel/QCarouselSlide.js @@ -27,6 +27,7 @@ export default Vue.extend({ return h('div', { staticClass: 'q-carousel__slide', style: this.style, + attrs: { role: 'tabpanel' }, on: { ...this.qListeners } }, slot(this, 'default')) } diff --git a/ui/src/components/date/QDate.js b/ui/src/components/date/QDate.js index d6f956724ced..3e85d3ca8501 100644 --- a/ui/src/components/date/QDate.js +++ b/ui/src/components/date/QDate.js @@ -1,5 +1,6 @@ import Vue from 'vue' +import KeyGroupNavigation from '../../directives/KeyGroupNavigation.js' import QBtn from '../btn/QBtn.js' import DateTimeMixin from '../../mixins/datetime.js' @@ -21,6 +22,10 @@ export default Vue.extend({ mixins: [ DateTimeMixin ], + directives: { + KeyGroupNavigation + }, + props: { multiple: Boolean, range: Boolean, @@ -102,9 +107,11 @@ export default Vue.extend({ }, view () { - if (this.$refs.blurTarget !== void 0 && this.$el.contains(document.activeElement) === true) { - this.$refs.blurTarget.focus() - } + this.$nextTick(() => { + if (this.$refs.viewTarget !== void 0 && this.$el.contains(document.activeElement) === true) { + this.$refs.viewTarget.$el.focus() + } + }) }, 'viewModel.year' (year) { @@ -953,6 +960,10 @@ export default Vue.extend({ }, __getCalendarView (h) { + const selectedDay = this.days.find(day => day.unelevated === true) + const viewDay = selectedDay === void 0 ? this.days.find(day => day.today === true) : selectedDay + const viewTarget = viewDay === void 0 ? 1 : viewDay.i + return [ h('div', { key: 'calendar-view', @@ -983,7 +994,11 @@ export default Vue.extend({ }, this.daysOfWeek.map(day => h('div', { staticClass: 'q-date__calendar-item' }, [ h('div', [ day ]) ]))), h('div', { - staticClass: 'q-date__calendar-days-container relative-position overflow-hidden' + staticClass: 'q-date__calendar-days-container relative-position overflow-hidden', + directives: cache(this, 'kNavC', [{ + name: 'key-group-navigation', + arg: '7' + }]) }, [ h('transition', { props: { @@ -997,6 +1012,7 @@ export default Vue.extend({ day.in === true ? h(QBtn, { staticClass: day.today === true ? 'q-date__today' : null, + ref: viewTarget === day.i ? 'viewTarget' : void 0, props: { dense: true, flat: day.flat, @@ -1008,7 +1024,8 @@ export default Vue.extend({ }, on: cache(this, 'day#' + day.i, { click: () => { this.__onDayClick(day.i) }, - mouseover: () => { this.__onDayMouseover(day.i) } + focusin: () => { this.__onDayMouseover(day.i) }, + mouseenter: () => { this.__onDayMouseover(day.i) } }) }, day.event !== false ? [ h('div', { staticClass: 'q-date__event bg-' + day.event }) @@ -1038,6 +1055,7 @@ export default Vue.extend({ }, [ h(QBtn, { staticClass: currentYear === true && this.today.month === i + 1 ? 'q-date__today' : null, + ref: this.viewModel.month === i + 1 ? 'viewTarget' : void 0, props: { flat: active !== true, label: month, @@ -1068,7 +1086,11 @@ export default Vue.extend({ return h('div', { key: 'months-view', - staticClass: 'q-date__view q-date__months flex flex-center' + staticClass: 'q-date__view q-date__months flex flex-center', + directives: cache(this, 'kNavYM', [{ + name: 'key-group-navigation', + arg: '3' + }]) }, content) }, @@ -1076,7 +1098,14 @@ export default Vue.extend({ const start = this.startYear, stop = start + yearsInterval, - years = [] + years = [], + viewTarget = this.viewModel.year >= start && this.viewModel.year <= stop + ? this.viewModel.year + : ( + this.today.year >= start && this.today.year <= stop + ? this.today.year + : start + ) const isDisabled = year => { return ( @@ -1095,8 +1124,9 @@ export default Vue.extend({ h(QBtn, { key: 'yr' + i, staticClass: this.today.year === i ? 'q-date__today' : null, + ref: viewTarget === i ? 'viewTarget' : void 0, props: { - flat: !active, + flat: active !== true, label: i, dense: true, unelevated: active, @@ -1131,7 +1161,11 @@ export default Vue.extend({ ]), h('div', { - staticClass: 'q-date__years-content col self-stretch row items-center' + staticClass: 'q-date__years-content col self-stretch row items-center', + directives: cache(this, 'kNavYM', [{ + name: 'key-group-navigation', + arg: '3' + }]) }, years), h('div', { @@ -1460,15 +1494,12 @@ export default Vue.extend({ return h('div', { class: this.classes, attrs: this.attrs, + directives: [ KeyGroupNavigation ], on: { ...this.qListeners } }, [ this.__getHeader(h), - h('div', { - staticClass: 'q-date__main col column', - attrs: { tabindex: -1 }, - ref: 'blurTarget' - }, content) + h('div', { staticClass: 'q-date__main col column' }, content) ]) } }) diff --git a/ui/src/components/date/QDate.sass b/ui/src/components/date/QDate.sass index 1df2db961964..669f7ac31283 100644 --- a/ui/src/components/date/QDate.sass +++ b/ui/src/components/date/QDate.sass @@ -22,9 +22,6 @@ &__actions padding: 0 16px 16px - &__content, &__main - outline: 0 - &__content .q-btn font-weight: normal diff --git a/ui/src/components/date/QDate.styl b/ui/src/components/date/QDate.styl index 1df2db961964..669f7ac31283 100644 --- a/ui/src/components/date/QDate.styl +++ b/ui/src/components/date/QDate.styl @@ -22,9 +22,6 @@ &__actions padding: 0 16px 16px - &__content, &__main - outline: 0 - &__content .q-btn font-weight: normal diff --git a/ui/src/components/dialog/QDialog.js b/ui/src/components/dialog/QDialog.js index 89a124bf5762..b6b5a0c93237 100644 --- a/ui/src/components/dialog/QDialog.js +++ b/ui/src/components/dialog/QDialog.js @@ -7,13 +7,12 @@ import PortalMixin from '../../mixins/portal.js' import PreventScrollMixin from '../../mixins/prevent-scroll.js' import AttrsMixin, { ariaHidden } from '../../mixins/attrs.js' import TransitionMixin from '../../mixins/transition.js' +import FocusWrapMixin from '../../mixins/focus-wrap.js' import { childHasFocus } from '../../utils/dom.js' import EscapeKey from '../../utils/private/escape-key.js' -import { slot } from '../../utils/private/slot.js' import { create, stop } from '../../utils/event.js' import cache from '../../utils/private/cache.js' -import { addFocusFn } from '../../utils/private/focus-manager.js' import { client } from '../../plugins/Platform.js' let maximizedModals = 0 @@ -49,7 +48,8 @@ export default Vue.extend({ TimeoutMixin, ModelToggleMixin, PortalMixin, - PreventScrollMixin + PreventScrollMixin, + FocusWrapMixin ], props: { @@ -96,7 +96,6 @@ export default Vue.extend({ useBackdrop (v) { this.__preventScroll(v) - this.__preventFocusout(v) } }, @@ -154,31 +153,13 @@ export default Vue.extend({ }, methods: { - focus (selector) { - addFocusFn(() => { - let node = this.__getInnerNode() - - if (node === void 0 || node.contains(document.activeElement) === true) { - return - } - - node = (selector !== '' ? node.querySelector(selector) : null) || - node.querySelector('[autofocus][tabindex], [data-autofocus][tabindex]') || - node.querySelector('[autofocus] [tabindex], [data-autofocus] [tabindex]') || - node.querySelector('[autofocus], [data-autofocus]') || - node - node.focus({ preventScroll: true }) - }) - }, - shake (focusTarget) { if (focusTarget && typeof focusTarget.focus === 'function') { focusTarget.focus({ preventScroll: true }) } else { - this.focus() + this.__focusFirst(true) } - this.$emit('shake') const node = this.__getInnerNode() @@ -193,12 +174,6 @@ export default Vue.extend({ } }, - __getInnerNode () { - return this.__portal !== void 0 && this.__portal.$refs !== void 0 - ? this.__portal.$refs.inner - : void 0 - }, - __show (evt) { this.__addHistory() @@ -212,14 +187,32 @@ export default Vue.extend({ EscapeKey.register(this, escEvt => { if (this.seamless !== true) { + // if it should not close then focus at start if (this.persistent === true || this.noEscDismiss === true) { - this.maximized !== true && this.noShake !== true && this.shake() + if (this.maximized !== true && this.noShake !== true) { + this.shake() + } + else { + this.__focusFirst(true) + } } else { this.$emit('escape-key') this.hide(escEvt) } } + // if focus is in menu focus the activator + // if focus is outside menu focus menu + else if ( + this.__refocusTarget !== null && + this.__refocusTarget !== void 0 && + this.__portal.$el.contains(document.activeElement) === true + ) { + this.__refocusTarget.focus() + } + else { + this.__focusFirst() + } }) this.__showPortal() @@ -302,7 +295,6 @@ export default Vue.extend({ if (this.seamless !== true) { this.__preventScroll(false) - this.__preventFocusout(false) } } }, @@ -326,13 +318,6 @@ export default Vue.extend({ } }, - __preventFocusout (state) { - if (this.$q.platform.is.desktop === true) { - const action = `${state === true ? 'add' : 'remove'}EventListener` - document.body[action]('focusin', this.__onFocusChange) - } - }, - __onAutoClose (e) { this.hide(e) this.qListeners.click !== void 0 && this.$emit('click', e) @@ -354,7 +339,7 @@ export default Vue.extend({ this.__portalIsAccessible === true && childHasFocus(this.__portal.$el, e.target) !== true ) { - this.focus('[tabindex]:not([tabindex="-1"])') + this.focus('') } }, @@ -386,7 +371,7 @@ export default Vue.extend({ class: this.classes, attrs: { tabindex: -1 }, on: this.onEvents - }, slot(this, 'default')) : null + }, this.__getFocusWrappedContent(h, 'default')) : null ]) ]) } diff --git a/ui/src/components/dialog/QDialog.json b/ui/src/components/dialog/QDialog.json index 0b6a449535cb..d0376699d072 100644 --- a/ui/src/components/dialog/QDialog.json +++ b/ui/src/components/dialog/QDialog.json @@ -135,7 +135,7 @@ "selector": { "type": "String", "required": false, - "desc": "Optional CSS selector to override default focusable element", + "desc": "Optional CSS selector to override default focusable element - use '' to focus first focusable element instead of the one with autofocus", "examples": [ "[tabindex]:not([tabindex=\"-1\"])" ], "addedIn": "v1.18.9" } diff --git a/ui/src/components/editor/QEditor.js b/ui/src/components/editor/QEditor.js index c059f62d7abe..b7f9a562c87c 100644 --- a/ui/src/components/editor/QEditor.js +++ b/ui/src/components/editor/QEditor.js @@ -1,5 +1,7 @@ import Vue from 'vue' +import KeyGroupNavigation from '../../directives/KeyGroupNavigation.js' + import { getToolbar, getFonts, getLinkEditor } from './editor-utils.js' import { Caret } from './editor-caret.js' @@ -18,6 +20,10 @@ export default Vue.extend({ mixins: [ ListenersMixin, FullscreenMixin, DarkMixin ], + directives: { + KeyGroupNavigation + }, + props: { value: { type: String, @@ -479,7 +485,8 @@ export default Vue.extend({ toolbars = h('div', { key: 'toolbar_ctainer', - staticClass: 'q-editor__toolbars-container' + staticClass: 'q-editor__toolbars-container relative-position', + directives: [ KeyGroupNavigation ] }, bars) } diff --git a/ui/src/components/field/QField.js b/ui/src/components/field/QField.js index edfea121e3d0..ee7873df1936 100644 --- a/ui/src/components/field/QField.js +++ b/ui/src/components/field/QField.js @@ -547,7 +547,7 @@ export default Vue.extend({ : this.attrs return h('label', { - staticClass: 'q-field q-validation-component row no-wrap items-start', + staticClass: 'q-field q-validation-component row no-wrap items-start q-key-group-navigation--ignore-key', class: this.classes, attrs }, [ diff --git a/ui/src/components/knob/QKnob.js b/ui/src/components/knob/QKnob.js index e3b9f1888c11..63c54e928e6e 100644 --- a/ui/src/components/knob/QKnob.js +++ b/ui/src/components/knob/QKnob.js @@ -65,7 +65,7 @@ export default Vue.extend({ computed: { classes () { - return 'q-knob non-selectable' + ( + return 'q-knob non-selectable q-key-group-navigation--ignore-key' + ( this.editable === true ? ' q-knob--editable' : (this.disable === true ? ' disabled' : '') diff --git a/ui/src/components/menu/QMenu.js b/ui/src/components/menu/QMenu.js index e51e40fa29f9..b347e4cfd862 100644 --- a/ui/src/components/menu/QMenu.js +++ b/ui/src/components/menu/QMenu.js @@ -7,6 +7,7 @@ import DarkMixin from '../../mixins/dark.js' import PortalMixin, { closePortalMenus } from '../../mixins/portal.js' import TransitionMixin from '../../mixins/transition.js' import AttrsMixin from '../../mixins/attrs.js' +import FocusWrapMixin from '../../mixins/focus-wrap.js' import { client } from '../../plugins/Platform.js' import ClickOutside from './ClickOutside.js' @@ -14,9 +15,6 @@ import { getScrollTarget } from '../../utils/scroll.js' import { create, stop, position, stopAndPrevent } from '../../utils/event.js' import EscapeKey from '../../utils/private/escape-key.js' -import { slot } from '../../utils/private/slot.js' -import { addFocusFn } from '../../utils/private/focus-manager.js' - import { validatePosition, validateOffset, setPosition, parsePosition } from '../../utils/private/position-engine.js' @@ -31,7 +29,8 @@ export default Vue.extend({ TimeoutMixin, ModelToggleMixin, PortalMixin, - TransitionMixin + TransitionMixin, + FocusWrapMixin ], directives: { @@ -145,22 +144,6 @@ export default Vue.extend({ }, methods: { - focus () { - addFocusFn(() => { - let node = this.__portal !== void 0 && this.__portal.$refs !== void 0 - ? this.__portal.$refs.inner - : void 0 - - if (node !== void 0 && node.contains(document.activeElement) !== true) { - node = node.querySelector('[autofocus][tabindex], [data-autofocus][tabindex]') || - node.querySelector('[autofocus] [tabindex], [data-autofocus] [tabindex]') || - node.querySelector('[autofocus], [data-autofocus]') || - node - node.focus({ preventScroll: true }) - } - }) - }, - __show (evt) { // IE can have null document.activeElement this.__refocusTarget = client.is.mobile !== true && this.noRefocus === false && document.activeElement !== null @@ -168,7 +151,21 @@ export default Vue.extend({ : void 0 EscapeKey.register(this, escEvt => { - if (this.persistent !== true) { + if (this.persistent === true) { + // if focus is in menu focus the activator + // if focus is outside menu focus menu + if ( + this.__refocusTarget !== null && + this.__refocusTarget !== void 0 && + this.__portal.$el.contains(document.activeElement) === true + ) { + this.__refocusTarget.focus() + } + else { + this.__focusFirst() + } + } + else { this.$emit('escape-key') this.hide(escEvt) } @@ -365,7 +362,7 @@ export default Vue.extend({ style: this.contentStyle, attrs: this.attrs, on: this.onEvents - }, slot(this, 'default')) + }, this.__getFocusWrappedContent(h, 'default')) ]) : null ]) } diff --git a/ui/src/components/menu/QMenu.json b/ui/src/components/menu/QMenu.json index 601043ee273c..73a6df647e4a 100644 --- a/ui/src/components/menu/QMenu.json +++ b/ui/src/components/menu/QMenu.json @@ -169,7 +169,16 @@ }, "focus": { - "desc": "Focus menu; if you have content with autofocus attribute, it will directly focus it" + "desc": "Focus menu; if you have content with autofocus attribute, it will directly focus it", + "params": { + "selector": { + "type": "String", + "required": false, + "desc": "Optional CSS selector to override default focusable element - use '' to focus first focusable element instead of the one with autofocus", + "examples": [ "[tabindex]:not([tabindex=\"-1\"])" ], + "addedIn": "v1.18.9" + } + } } } } diff --git a/ui/src/components/rating/QRating.js b/ui/src/components/rating/QRating.js index db986d2c89a9..d5dfe4c62eca 100644 --- a/ui/src/components/rating/QRating.js +++ b/ui/src/components/rating/QRating.js @@ -245,7 +245,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 dfac6602d9c1..15f40793f239 100644 --- a/ui/src/components/slider/slider-utils.js +++ b/ui/src/components/slider/slider-utils.js @@ -197,7 +197,8 @@ export const SliderMixin = { (this.label || this.labelAlways === true ? ' q-slider--label' : '') + (this.labelAlways === true ? ' q-slider--label-always' : '') + ` q-slider--${this.darkSuffix}` + - (this.dense === true ? ' q-slider--dense q-slider--dense' + this.axis : '') + (this.dense === true ? ' q-slider--dense q-slider--dense' + this.axis : '') + + ' q-key-group-navigation--ignore-key' }, selectionBarClass () { diff --git a/ui/src/components/tabs/QTab.js b/ui/src/components/tabs/QTab.js index 909e8a98716c..eca7cdaaceef 100644 --- a/ui/src/components/tabs/QTab.js +++ b/ui/src/components/tabs/QTab.js @@ -70,7 +70,7 @@ export default Vue.extend({ }, innerClass () { - return 'q-tab__content self-stretch flex-center relative-position q-anchor--skip non-selectable ' + + return 'q-tab__content self-stretch flex-center relative-position q-anchor--skip non-selectable q-key-group-navigation--ignore-key ' + (this.$tabs.tabProps.inlineLabel === true ? 'row no-wrap q-tab__content--inline' : 'column') + (this.contentClass !== void 0 ? ` ${this.contentClass}` : '') }, diff --git a/ui/src/components/tabs/QTabs.js b/ui/src/components/tabs/QTabs.js index d7d58f503170..571f8b33bd44 100644 --- a/ui/src/components/tabs/QTabs.js +++ b/ui/src/components/tabs/QTabs.js @@ -379,11 +379,19 @@ export default Vue.extend({ if (len === 0) { return } if (keyCode === 36) { // Home + if (tabs[ 0 ].contains(document.activeElement) === true) { + return false + } + this.__scrollToTabEl(tabs[ 0 ]) tabs[ 0 ].focus() return true } if (keyCode === 35) { // End + if (tabs[ len - 1 ].contains(document.activeElement) === true) { + return false + } + this.__scrollToTabEl(tabs[ len - 1 ]) tabs[ len - 1 ].focus() return true @@ -398,11 +406,17 @@ export default Vue.extend({ const rtlDir = this.isRTL === true ? -1 : 1 const index = tabs.indexOf(fromEl) + dir * rtlDir - if (index >= 0 && index < len) { - this.__scrollToTabEl(tabs[ index ]) - tabs[ index ].focus({ preventScroll: true }) + if ( + index < 0 || + index >= len || + tabs[ index ].contains(document.activeElement) === true + ) { + return false } + this.__scrollToTabEl(tabs[ index ]) + tabs[ index ].focus({ preventScroll: true }) + return true } }, diff --git a/ui/src/components/time/QTime.js b/ui/src/components/time/QTime.js index 135220afa376..bffbc5fad3eb 100644 --- a/ui/src/components/time/QTime.js +++ b/ui/src/components/time/QTime.js @@ -115,7 +115,8 @@ export default Vue.extend({ (this.disable === true ? ' disabled' : (this.readonly === true ? ' q-time--readonly' : '')) + (this.bordered === true ? ' q-time--bordered' : '') + (this.square === true ? ' q-time--square no-border-radius' : '') + - (this.flat === true ? ' q-time--flat no-shadow' : '') + (this.flat === true ? ' q-time--flat no-shadow' : '') + + ' q-key-group-navigation--ignore-key' }, stringModel () { diff --git a/ui/src/css/core/visibility.sass b/ui/src/css/core/visibility.sass index 702433ce6ae9..89fcb28f4aae 100644 --- a/ui/src/css/core/visibility.sass +++ b/ui/src/css/core/visibility.sass @@ -162,3 +162,6 @@ body.desktop .q-focusable:focus, .q-manual-focusable--focused > .q-focus-helper opacity: .22 + + .q-key-group-navigation--active + outline: auto diff --git a/ui/src/css/core/visibility.styl b/ui/src/css/core/visibility.styl index c711c830d500..0a309cb1cc69 100644 --- a/ui/src/css/core/visibility.styl +++ b/ui/src/css/core/visibility.styl @@ -161,3 +161,6 @@ body.desktop .q-focusable:focus, .q-manual-focusable--focused > .q-focus-helper opacity: .22 + + .q-key-group-navigation--active + outline: auto diff --git a/ui/src/directives.js b/ui/src/directives.js index 9cbd8a9b7eba..680d4087e891 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 Morph from './directives/Morph.js' import Mutation from './directives/Mutation.js' import Ripple from './directives/Ripple.js' @@ -15,6 +16,7 @@ export { ClosePopup, GoBack, Intersection, + KeyGroupNavigation, Morph, Mutation, Ripple, diff --git a/ui/src/directives/KeyGroupNavigation.js b/ui/src/directives/KeyGroupNavigation.js new file mode 100644 index 000000000000..138f2593f9f7 --- /dev/null +++ b/ui/src/directives/KeyGroupNavigation.js @@ -0,0 +1,345 @@ +import Interaction from '../plugins/Interaction.js' +import { stop, prevent, addEvt, cleanEvt, getEventPath } from '../utils/event.js' +import { FOCUSABLE_SELECTOR, KEY_SKIP_SELECTOR, changeFocusedElement } from '../utils/private/focus-manager.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.keys(keyCodes.horizontal).reduce((acc, k) => acc.concat(keyCodes.horizontal[k]), [9]) +keyCodes.horizontal.listH = keyCodes.horizontal.list +keyCodes.vertical.list = Object.keys(keyCodes.vertical).reduce((acc, k) => acc.concat(keyCodes.vertical[k]), [9]) +keyCodes.vertical.listH = [] +keyCodes.all.list = Object.keys(keyCodes.all).reduce((acc, k) => acc.concat(keyCodes.all[k]), [9]) +keyCodes.all.listH = keyCodes.horizontal.list + +function matchNavigationKeyIgnoreEl (el) { + return el.classList.contains('q-key-group-navigation--ignore-key') === true +} + +function createFocusTargets (ctx) { + const target = document.createElement('span') + target.setAttribute('tabindex', -1) + target.classList.add('no-outline') + target.classList.add('absolute') + target.classList.add('no-pointer-events') + + ctx.firstTarget = target + ctx.lastTarget = target.cloneNode() +} + +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 parseArg (arg) { + const data = [ 1, 1, 'q-key-group-navigation--active' ] + + if (typeof arg === 'string' && arg.length > 0) { + const splits = arg.split(':') + + for (let i = 0; i < 2; i++) { + const v = parseInt(splits[i], 10) + v && (data[i] = v) + } + } + + return { + offsetY: data[0], + offsetX: data[1], + activeClass: data[2] + } +} + +function configureEvents (el, ctx, modifiers, value) { + if (modifiers.vertical === true) { + ctx.keyCodes = keyCodes.vertical + } + else { + ctx.keyCodes = modifiers.horizontal === true + ? keyCodes.horizontal + : keyCodes.all + } + + const enabled = [false, 0, '0'].indexOf(value) === -1 + + if (ctx.enabled !== enabled) { + ctx.enabled === true && cleanEvt(ctx, 'main') + + enabled === true && addEvt(ctx, 'main', [ + [ el, 'keydown', 'keyDown', 'capture' ], + [ el, 'focusin', 'focusIn', 'passiveCapture' ], + [ el, 'focusout', 'focusOut', 'passiveCapture' ], + [ el, 'mousedown', 'setRestoreEl', 'passiveCapture' ], + [ el, 'touchstart', 'setRestoreEl', 'passiveCapture' ] + ]) + + ctx.enabled = enabled + } +} + +export default { + name: 'key-group-navigation', + + bind (el, { modifiers, arg, value }) { + const ctx = { + keyCodes: keyCodes.all, + arg, + modifiers: {}, + + ...parseArg(arg), + + focusRestoreEl: null, + + keyDown (evt) { + const { keyCode, shiftKey, target } = evt + + if ( + ctx.keyCodes.list.indexOf(keyCode) === -1 || + target.matches(KEY_SKIP_SELECTOR) === true + ) { + return + } + + stop(evt) + + 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) + } + } + + // required for IE11 + requestAnimationFrame(() => { + removeFocusTargets(ctx) + }) + + return + } + + const initialEl = document.activeElement + const keyNavGroup = initialEl + ? initialEl.closest('.q-key-group-navigation') + : null + const ignoredFocusableElements = keyNavGroup !== null && keyNavGroup !== el + ? Array.prototype.filter.call( + keyNavGroup.querySelectorAll(FOCUSABLE_SELECTOR), + elm => elm !== initialEl + ) + : [] + const focusableElements = Array.prototype.filter.call( + el.querySelectorAll(FOCUSABLE_SELECTOR), + elm => ignoredFocusableElements.includes(elm) !== true + ) + 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 = initialEl === null + ? -1 + : focusableElements.indexOf(initialEl.closest(FOCUSABLE_SELECTOR)) + + const offset = ctx.keyCodes.listH.indexOf(keyCode) === -1 + ? ctx.offsetY + : ctx.offsetX + + if (ctx.keyCodes.prev.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, Math.max(-1, currentIndex - offset), -1, offset !== 1) + } + if (ctx.keyCodes.next.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, currentIndex + offset, 1, offset !== 1) + } + } + + if (document.activeElement) { + ctx.focusRestoreEl = document.activeElement + } + + prevent(evt) + }, + + setRestoreEl (evt) { + if (evt.target) { + ctx.focusRestoreEl = evt.target + } + }, + + setActive () { + ctx.active = true + el.classList.add(ctx.activeClass) + }, + + setInactive () { + ctx.active = false + el.classList.remove(ctx.activeClass) + }, + + focusIn (evt) { + if (Interaction.isKeyboard !== true) { + ctx.active === true && ctx.setInactive() + + return + } + + const path = getEventPath(evt) // required for IE11 + const ignored = path.slice(0, path.indexOf(el)).find(matchNavigationKeyIgnoreEl) !== void 0 + + if (ctx.active !== true) { + ignored !== true && ctx.setActive() + } + else if (ignored === true) { + ctx.setInactive() + } + + if ( + evt.target === ctx.firstTarget || + evt.target === ctx.lastTarget || + ( + evt.relatedTarget !== null && + ( + ( + evt.relatedTarget.classList !== void 0 && // required for IE11 + evt.relatedTarget.classList.contains('q-key-group-navigation--ignore-focus') === true + ) || + ( + evt.relatedTarget._qKeyNavIgnore === true && + el.contains(evt.relatedTarget) === true + ) + ) + ) + ) { + return + } + + const refocusEl = el.querySelector('.q-key-group-navigation__refocus') + const focusRestoreEl = refocusEl !== null && refocusEl.closest('.q-key-group-navigation') === el + ? refocusEl + : ctx.focusRestoreEl + + if ( + focusRestoreEl === null || + el.contains(evt.relatedTarget) === true + ) { + if (document.activeElement) { + ctx.focusRestoreEl = document.activeElement + } + } + else { + const focusableEl = focusRestoreEl.closest(FOCUSABLE_SELECTOR) + const focusedEl = focusableEl && typeof focusableEl.focus === 'function' + ? focusableEl + : ( + focusRestoreEl.focus === 'function' + ? focusRestoreEl + : null + ) + + requestAnimationFrame(() => { + if (focusedEl !== null) { + focusedEl._qKeyNavIgnore = true + + focusedEl.focus() + + requestAnimationFrame(() => { + focusedEl && (focusedEl._qKeyNavIgnore = false) + }) + } + }) + } + }, + + focusOut (evt) { + if ( + ctx.active === true && + (evt.relatedTarget === null || el.contains(evt.relatedTarget) === false) + ) { + ctx.setInactive() + } + } + } + + if (el.__qkeygrpnav) { + el.__qkeygrpnav_old = el.__qkeygrpnav + } + + el.__qkeygrpnav = ctx + + el.classList.add('q-key-group-navigation') + createFocusTargets(ctx) + configureEvents(el, ctx, modifiers, value) + }, + + update (el, { modifiers, arg, value }) { + const ctx = el.__qkeygrpnav + if (ctx !== void 0) { + if (ctx.arg !== arg) { + Object.assign(ctx, parseArg(arg)) + } + + configureEvents(el, ctx, modifiers, value) + } + }, + + unbind (el) { + const ctx = el.__qkeygrpnav_old || el.__qkeygrpnav + if (ctx !== void 0) { + el.classList.remove('q-key-group-navigation') + removeFocusTargets(ctx) + cleanEvt(ctx, 'main') + ctx.active === true && ctx.setInactive() + + 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..8c37cf272ad1 --- /dev/null +++ b/ui/src/directives/KeyGroupNavigation.json @@ -0,0 +1,46 @@ +{ + "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\"" + ] + }, + + "arg": { + "type": "String", + "desc": "y:x:z, where y is the step for vertical movement, x is the step for horizontal movement and z is a class name to be applied on the grouping element when it's active (default q-key-group-navigation--active)", + "default": "1:1", + "examples": [ + "v-key-group-navigation:7:1", + "v-key-group-navigation:3" + ] + }, + + "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 new file mode 100644 index 000000000000..00946aecea49 --- /dev/null +++ b/ui/src/mixins/focus-wrap.js @@ -0,0 +1,38 @@ +import { mergeSlot } from '../utils/private/slot.js' +import { FOCUSABLE_SELECTOR, changeFocusedElement } from '../utils/private/focus-manager.js' + +export default { + methods: { + __focusFirst (keepInsideFocus) { + const innerNode = this.__getInnerNode() + if (innerNode !== void 0 && (keepInsideFocus !== true || innerNode.contains(document.activeElement) !== true)) { + const focusableElements = Array.prototype.slice.call(innerNode.querySelectorAll(FOCUSABLE_SELECTOR), 1, -1) + changeFocusedElement(focusableElements, 0, 1) + } + }, + + __focusLast () { + 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) { + return mergeSlot([ + h('span', { + staticClass: 'no-outline absolute no-pointer-events q-key-group-navigation--ignore-focus', + attrs: { tabindex: 0 }, + on: { focus: this.__focusLast } + }) + ], this, slotName).concat( + h('span', { + staticClass: 'no-outline absolute no-pointer-events q-key-group-navigation--ignore-focus', + attrs: { tabindex: 0 }, + on: { focus: this.__focusFirst } + }) + ) + } + } +} diff --git a/ui/src/mixins/panel.js b/ui/src/mixins/panel.js index 4b46494797d6..5b13c6df1bb0 100644 --- a/ui/src/mixins/panel.js +++ b/ui/src/mixins/panel.js @@ -13,7 +13,6 @@ import cache, { cacheWithFn } from '../utils/private/cache.js' function getPanelWrapper (h) { return h('div', { staticClass: 'q-panel scroll', - attrs: { role: 'tabpanel' }, // stop propagation of content emitted @input // which would tamper with Panel's model on: cache(this, 'stop', { input: stop }) @@ -237,7 +236,6 @@ export const PanelParentMixin = { h('div', { staticClass: 'q-panel scroll', key: this.contentKey, - attrs: { role: 'tabpanel' }, // stop propagation of content emitted @input // which would tamper with Panel's model on: cache(this, 'stop', { input: stop }) diff --git a/ui/src/mixins/portal.js b/ui/src/mixins/portal.js index 9fdfad15b8c3..52044fe71e3d 100644 --- a/ui/src/mixins/portal.js +++ b/ui/src/mixins/portal.js @@ -2,7 +2,7 @@ import Vue from 'vue' import { isSSR } from '../plugins/Platform.js' import { getBodyFullscreenElement } from '../utils/dom.js' -import { addFocusWaitFlag, removeFocusWaitFlag } from '../utils/private/focus-manager.js' +import { addFocusWaitFlag, removeFocusWaitFlag, addFocusFn, FOCUSABLE_SELECTOR, changeFocusedElement } from '../utils/private/focus-manager.js' import debounce from '../utils/debounce.js' export function closePortalMenus (vm, evt) { @@ -76,6 +76,30 @@ const Portal = { }, methods: { + focus (selector) { + addFocusFn(() => { + const node = this.__getInnerNode() + + if (node !== void 0 && node.contains(document.activeElement) !== true) { + const autofocusNode = selector === '' + ? null + : node.querySelector(typeof selector === 'string' ? selector : '[autofocus], [data-autofocus]') + + if (autofocusNode !== null && typeof autofocusNode.focus === 'function') { + autofocusNode.focus() + } + else { + const focusableElements = Array.prototype.slice.call(node.querySelectorAll(FOCUSABLE_SELECTOR)) + focusableElements.length > 0 && changeFocusedElement( + focusableElements, + focusableElements[0].classList.contains('q-key-group-navigation--ignore-focus') === true ? 1 : 0, + 1 + ) + } + } + }) + }, + __showPortal (isReady) { if (isReady === true) { removeFocusWaitFlag(this.focusObj) @@ -163,6 +187,12 @@ const Portal = { directives: this.$options.directives }).$mount() } + }, + + __getInnerNode () { + return this.__portal !== void 0 && this.__portal.$refs !== void 0 + ? this.__portal.$refs.inner + : void 0 } }, diff --git a/ui/src/utils/private/focus-manager.js b/ui/src/utils/private/focus-manager.js index 5d0e74897810..23ff27ecf0a4 100644 --- a/ui/src/utils/private/focus-manager.js +++ b/ui/src/utils/private/focus-manager.js @@ -1,3 +1,5 @@ +import { normalizeToInterval } from '../format.js' + let queue = [] let waitFlags = [] @@ -32,3 +34,57 @@ export function addFocusFn (fn) { export function removeFocusFn (fn) { queue = queue.filter(entry => entry !== fn) } + +export const FOCUSABLE_SELECTOR = [ + ':focus', + '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])', + '.q-tab.q-focusable' +].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, noWrap, start) { + const lastIndex = list.length - 1 + + if (noWrap === true && (to > lastIndex || to < 0)) { + return + } + + const index = normalizeToInterval(to, 0, lastIndex) + + if (index === start || index > lastIndex) { + return + } + + const initialEl = document.activeElement + + if (initialEl !== null) { + initialEl._qKeyNavIgnore = true + list[index].focus() + initialEl._qKeyNavIgnore = false + } + else { + list[index].focus() + } + + if (document.activeElement !== list[index]) { + changeFocusedElement(list, index + direction, direction, noWrap, start === void 0 ? index : start) + } +}