diff --git a/docs/src/assets/menu.js b/docs/src/assets/menu.js index bdbebc4bd06d..ea6d15d585e9 100644 --- a/docs/src/assets/menu.js +++ b/docs/src/assets/menu.js @@ -701,6 +701,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 de2cf0835eeb..cad2520f6662 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 a3544109b6cd..7fd13cf02a85 100644 --- a/ui/dev/src/pages/components/tabs.vue +++ b/ui/dev/src/pages/components/tabs.vue @@ -36,7 +36,7 @@ - + Wifi @@ -414,11 +414,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 0aa2e6fd6b60..4a2f31534bc0 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 d232554cc2c1..539048aa90d3 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,7 +107,9 @@ export default Vue.extend({ }, view () { - this.$refs.blurTarget !== void 0 && this.$refs.blurTarget.focus() + this.$nextTick(() => { + this.$refs.viewTarget !== void 0 && this.$refs.viewTarget.$el.focus() + }) }, 'viewModel.year' (year) { @@ -951,6 +958,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', @@ -981,7 +992,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: { @@ -995,6 +1010,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, @@ -1006,7 +1022,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 }) @@ -1036,6 +1053,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, @@ -1066,7 +1084,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) }, @@ -1074,7 +1096,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 ( @@ -1093,8 +1122,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, @@ -1129,7 +1159,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', { @@ -1458,15 +1492,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 4d7ebcddb5d3..8a2d7b673a77 100644 --- a/ui/src/components/dialog/QDialog.js +++ b/ui/src/components/dialog/QDialog.js @@ -5,13 +5,12 @@ import ModelToggleMixin from '../../mixins/model-toggle.js' import PortalMixin from '../../mixins/portal.js' import PreventScrollMixin from '../../mixins/prevent-scroll.js' import AttrsMixin, { ariaHidden } from '../../mixins/attrs.js' +import FocusWrapMixin from '../../mixins/focus-wrap.js' import { childHasFocus } from '../../utils/dom.js' import EscapeKey from '../../utils/escape-key.js' -import { slot } from '../../utils/slot.js' import { create, stop } from '../../utils/event.js' import cache from '../../utils/cache.js' -import { addFocusFn } from '../../utils/focus-manager.js' import { client } from '../../plugins/Platform.js' let maximizedModals = 0 @@ -40,7 +39,8 @@ export default Vue.extend({ HistoryMixin, ModelToggleMixin, PortalMixin, - PreventScrollMixin + PreventScrollMixin, + FocusWrapMixin ], props: { @@ -95,7 +95,6 @@ export default Vue.extend({ useBackdrop (v) { this.__preventScroll(v) - this.__preventFocusout(v) } }, @@ -151,21 +150,8 @@ export default Vue.extend({ }, methods: { - focus () { - addFocusFn(() => { - let node = this.__getInnerNode() - - if (node === void 0 || node.contains(document.activeElement) === true) { - return - } - - node = node.querySelector('[autofocus], [data-autofocus]') || node - node.focus({ preventScroll: true }) - }) - }, - shake () { - this.focus() + this.__focusFirst() this.$emit('shake') const node = this.__getInnerNode() @@ -180,12 +166,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() @@ -199,14 +179,32 @@ export default Vue.extend({ EscapeKey.register(this, () => { 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() + } } else { this.$emit('escape-key') this.hide() } } + // 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() @@ -279,7 +277,6 @@ export default Vue.extend({ if (this.seamless !== true) { this.__preventScroll(false) - this.__preventFocusout(false) } } }, @@ -303,13 +300,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) @@ -363,7 +353,7 @@ export default Vue.extend({ class: this.classes, attrs: { tabindex: -1 }, on: this.onEvents - }, slot(this, 'default')) : null + }, this.__getFocusWrappedContent('default')) : null ]) ]) } diff --git a/ui/src/components/editor/QEditor.js b/ui/src/components/editor/QEditor.js index 4ea7f0adf750..eacaad2fbe0a 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, @@ -492,7 +498,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 c0723920e265..ea4b7a7ec5a6 100644 --- a/ui/src/components/field/QField.js +++ b/ui/src/components/field/QField.js @@ -565,7 +565,7 @@ export default Vue.extend({ this.__onPreRender !== void 0 && this.__onPreRender() 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: this.attrs }, [ diff --git a/ui/src/components/knob/QKnob.js b/ui/src/components/knob/QKnob.js index e787692b8f38..9a86ed35a285 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 b73d724ccee9..a70d13f9201e 100644 --- a/ui/src/components/menu/QMenu.js +++ b/ui/src/components/menu/QMenu.js @@ -6,6 +6,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' @@ -13,9 +14,6 @@ import { getScrollTarget } from '../../utils/scroll.js' import { create, stop, position, stopAndPrevent } from '../../utils/event.js' import EscapeKey from '../../utils/escape-key.js' -import { slot } from '../../utils/slot.js' -import { addFocusFn } from '../../utils/focus-manager.js' - import { validatePosition, validateOffset, setPosition, parsePosition } from '../../utils/position-engine.js' @@ -29,7 +27,8 @@ export default Vue.extend({ AnchorMixin, ModelToggleMixin, PortalMixin, - TransitionMixin + TransitionMixin, + FocusWrapMixin ], directives: { @@ -142,19 +141,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], [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 @@ -162,7 +148,21 @@ export default Vue.extend({ : void 0 EscapeKey.register(this, () => { - 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() } @@ -354,7 +354,7 @@ export default Vue.extend({ style: this.contentStyle, attrs: this.attrs, on: this.onEvents - }, slot(this, 'default')) + }, this.__getFocusWrappedContent('default')) ]) : null ]) } diff --git a/ui/src/components/rating/QRating.js b/ui/src/components/rating/QRating.js index 9f6aa42222c7..3df91d7978a6 100644 --- a/ui/src/components/rating/QRating.js +++ b/ui/src/components/rating/QRating.js @@ -214,7 +214,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 27c54759c168..1c1a4f9ef36c 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/stepper/QStep.js b/ui/src/components/stepper/QStep.js index 6342a344221c..9c7f409a4db4 100644 --- a/ui/src/components/stepper/QStep.js +++ b/ui/src/components/stepper/QStep.js @@ -102,6 +102,7 @@ export default Vue.extend({ 'div', { staticClass: 'q-stepper__step', + attrs: { role: 'tabpanel' }, on: this.onEvents }, vertical === true diff --git a/ui/src/components/tab-panels/QTabPanel.js b/ui/src/components/tab-panels/QTabPanel.js index 087b28755969..e239202d6dcc 100644 --- a/ui/src/components/tab-panels/QTabPanel.js +++ b/ui/src/components/tab-panels/QTabPanel.js @@ -11,6 +11,7 @@ export default Vue.extend({ render (h) { return h('div', { staticClass: 'q-tab-panel', + attrs: { role: 'tabpanel' }, on: { ...this.qListeners } }, slot(this, 'default')) } diff --git a/ui/src/components/tabs/QTab.js b/ui/src/components/tabs/QTab.js index e377db006f4d..f165aa75846c 100644 --- a/ui/src/components/tabs/QTab.js +++ b/ui/src/components/tabs/QTab.js @@ -191,7 +191,7 @@ export default Vue.extend({ h('div', { staticClass: 'q-focus-helper', attrs: { tabindex: -1 }, ref: 'blurTarget' }), h('div', { - staticClass: 'q-tab__content self-stretch flex-center relative-position q-anchor--skip non-selectable', + staticClass: 'q-tab__content self-stretch flex-center relative-position q-anchor--skip q-key-group-navigation--ignore-key non-selectable', class: this.innerClass }, mergeSlot(content, this, 'default')) ] diff --git a/ui/src/components/time/QTime.js b/ui/src/components/time/QTime.js index dfff4ec54c87..ed848832ffd9 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 e29246190d6d..ad977b20c720 100644 --- a/ui/src/css/core/visibility.sass +++ b/ui/src/css/core/visibility.sass @@ -161,3 +161,5 @@ body.desktop > .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 8b18b431dea8..bb5b5aa77433 100644 --- a/ui/src/css/core/visibility.styl +++ b/ui/src/css/core/visibility.styl @@ -160,3 +160,5 @@ body.desktop > .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..812233541e6c --- /dev/null +++ b/ui/src/directives/KeyGroupNavigation.js @@ -0,0 +1,349 @@ +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/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', 'capturePassive' ], + [ el, 'focusout', 'focusOut', 'capturePassive' ], + [ el, 'mousedown', 'setRestoreEl', 'capturePassive' ], + [ el, 'touchstart', 'setRestoreEl', 'capturePassive' ] + ]) + + 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..9a991528b934 --- /dev/null +++ b/ui/src/mixins/focus-wrap.js @@ -0,0 +1,47 @@ +import { mergeSlot } from '../utils/slot.js' +import { FOCUSABLE_SELECTOR, changeFocusedElement } from '../utils/focus-manager.js' + +export default { + computed: { + focusGuardElements () { + return { + firstGuard: this.$createElement('span', { + staticClass: 'no-outline absolute no-pointer-events q-key-group-navigation--ignore-focus', + attrs: { tabindex: 0 }, + on: { focus: this.__focusLast } + }), + lastGuard: this.$createElement('span', { + staticClass: 'no-outline absolute no-pointer-events q-key-group-navigation--ignore-focus', + attrs: { tabindex: 0 }, + on: { focus: this.__focusFirst } + }) + } + } + }, + + methods: { + __focusFirst () { + 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 () { + 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 (slotName) { + return mergeSlot([ + this.focusGuardElements.firstGuard + ], this, slotName).concat( + this.focusGuardElements.lastGuard + ) + } + } +} diff --git a/ui/src/mixins/panel.js b/ui/src/mixins/panel.js index e08128f73534..f3b992bd7bf3 100644 --- a/ui/src/mixins/panel.js +++ b/ui/src/mixins/panel.js @@ -13,7 +13,6 @@ import cache, { cacheWithFn } from '../utils/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 cb0cad46dfb9..42dd99ed0634 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/focus-manager.js' +import { addFocusWaitFlag, removeFocusWaitFlag, addFocusFn, FOCUSABLE_SELECTOR, changeFocusedElement } from '../utils/focus-manager.js' import debounce from '../utils/debounce.js' export function closePortalMenus (vm, evt) { @@ -76,6 +76,28 @@ const Portal = { }, methods: { + focus () { + addFocusFn(() => { + const node = this.__getInnerNode() + + if (node !== void 0 && node.contains(document.activeElement) !== true) { + 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)) + 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) @@ -156,6 +178,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/focus-manager.js b/ui/src/utils/focus-manager.js index 5d0e74897810..f500c48e016c 100644 --- a/ui/src/utils/focus-manager.js +++ b/ui/src/utils/focus-manager.js @@ -1,3 +1,5 @@ +import { normalizeToInterval } from './format.js' + let queue = [] let waitFlags = [] @@ -32,3 +34,56 @@ export function addFocusFn (fn) { export function removeFocusFn (fn) { queue = queue.filter(entry => entry !== fn) } + +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])', + '.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) + } +}