diff --git a/docs/src/assets/menu.js b/docs/src/assets/menu.js index 9881fbd55a30..8183032ccf98 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/DocExample.vue b/docs/src/components/DocExample.vue index 392e34090f41..fa8575082721 100644 --- a/docs/src/components/DocExample.vue +++ b/docs/src/components/DocExample.vue @@ -5,7 +5,7 @@ q-card.doc-example.q-my-lg(:class="classes", flat, bordered) q-space - div.col-auto + div.col-auto(v-key-group-navigation) q-btn(dense, flat, round, :icon="fabGithub", @click="openGitHub") q-tooltip View on GitHub q-btn.q-ml-sm(v-if="noEdit === false", dense, flat, round, :icon="fabCodepen", @click="openCodepen") diff --git a/docs/src/components/DocLink.vue b/docs/src/components/DocLink.vue index 8cfba64adf67..4360f6d1e9b6 100644 --- a/docs/src/components/DocLink.vue +++ b/docs/src/components/DocLink.vue @@ -53,6 +53,9 @@ export default { &:hover opacity: .8 + &:focus-visible + border-bottom: 2px solid currentColor + .q-icon margin-top: -3px margin-left: 4px diff --git a/docs/src/components/DocPage.vue b/docs/src/components/DocPage.vue index 937d2ee147e0..af17b0c11875 100644 --- a/docs/src/components/DocPage.vue +++ b/docs/src/components/DocPage.vue @@ -17,7 +17,7 @@ q-page.doc-page slot - .doc-page-nav.text-primary.q-pb-lg(v-if="related !== void 0") + .doc-page-nav.text-primary.q-pb-lg(v-if="related !== void 0", v-key-group-navigation) .text-h6.q-pb-md Related .q-gutter-md.flex router-link.q-link.doc-page-related.rounded-borders.q-pa-md.cursor-pointer.column.justify-center.bg-grey-4( @@ -32,7 +32,7 @@ q-page.doc-page q-icon.q-ml-lg(:name="mdiLaunch") - .doc-page-nav.text-primary.q-pb-xl(v-if="nav !== void 0") + .doc-page-nav.text-primary.q-pb-xl(v-if="nav !== void 0", v-key-group-navigation) .text-h6.q-pb-md Ready for more? .q-gutter-md.flex router-link.q-link.doc-page-related.doc-page-related-bordered.rounded-borders.q-pa-md.cursor-pointer.column.justify-center.bg-white( @@ -57,7 +57,7 @@ q-page.doc-page .q-mb-md(v-if="noEdit === false") | Caught a mistake? Suggest an edit on GitHub - .doc-page-footer__icons.row.items-center.q-gutter-sm + .doc-page-footer__icons.row.items-center.q-gutter-sm(v-key-group-navigation) a(href="/pdanpdan/quasar", target="_blank", rel="noopener") q-icon(:name="fabGithub") @@ -164,6 +164,9 @@ export default { text-decoration: none outline: 0 + &:focus-visible + border-bottom: 2px solid currentColor + &__badge vertical-align: super @@ -193,11 +196,15 @@ export default { text-decoration: none outline: 0 color: $primary + border-bottom: 2px solid transparent transition: color .28s &:hover color: $grey-8 + &:focus-visible + border-bottom-color: currentColor + .doc-page-nav margin: 68px 0 0 margin-bottom: 0 !important 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..a3ec95d06082 --- /dev/null +++ b/docs/src/examples/KeyGroupNavigation/Toolbar.vue @@ -0,0 +1,72 @@ + + + diff --git a/docs/src/pages/vue-components/uploader.md b/docs/src/pages/vue-components/uploader.md index 66737b6ed212..0751966827e6 100644 --- a/docs/src/pages/vue-components/uploader.md +++ b/docs/src/pages/vue-components/uploader.md @@ -275,7 +275,7 @@ cors = CORS() cors.init_app(app, resource={r"/api/*": {"origins": "*"}}) @app.route('/upload', methods=['POST']) -def upload(): +def upload(): for fname in request.files: f = request.files.get(fname) print(f) 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..886347be4303 --- /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 QKnob, QRange, QSlider, QRating, QDate, 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/components/tabs.vue b/ui/dev/src/pages/components/tabs.vue index fce4b9bf4cde..a0af9b173a5a 100644 --- a/ui/dev/src/pages/components/tabs.vue +++ b/ui/dev/src/pages/components/tabs.vue @@ -35,8 +35,8 @@ - - + + Wifi @@ -407,11 +407,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..e9cbd71ddd66 --- /dev/null +++ b/ui/dev/src/pages/touch-directives/key-group-navigation.vue @@ -0,0 +1,246 @@ + + + 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 df72b443a314..02c0be7a7b87 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' @@ -20,6 +21,10 @@ export default Vue.extend({ mixins: [ DateTimeMixin ], + directives: { + KeyGroupNavigation + }, + props: { multiple: Boolean, range: Boolean, @@ -101,7 +106,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) { @@ -815,7 +822,8 @@ export default Vue.extend({ return h('div', { staticClass: 'q-date__header', - class: this.headerClass + class: this.headerClass, + directives: [ KeyGroupNavigation ] }, [ h('div', { staticClass: 'relative-position' @@ -939,13 +947,18 @@ 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', staticClass: 'q-date__view q-date__calendar' }, [ h('div', { - staticClass: 'q-date__navigation row items-center no-wrap' + staticClass: 'q-date__navigation row items-center no-wrap', + directives: [ KeyGroupNavigation ] }, this.__getNavigation(h, { label: this.innerLocale.months[ this.viewModel.month - 1 ], view: 'Months', @@ -969,7 +982,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: { @@ -983,6 +1000,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, @@ -994,7 +1012,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 }) @@ -1024,6 +1043,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, @@ -1054,7 +1074,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) }, @@ -1062,7 +1086,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 ( @@ -1081,8 +1112,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, @@ -1117,7 +1149,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', { @@ -1450,11 +1486,7 @@ export default Vue.extend({ }, [ 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 faedbad6c7f9..269703053b9b 100644 --- a/ui/src/components/date/QDate.sass +++ b/ui/src/components/date/QDate.sass @@ -20,9 +20,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 faedbad6c7f9..269703053b9b 100644 --- a/ui/src/components/date/QDate.styl +++ b/ui/src/components/date/QDate.styl @@ -20,9 +20,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 0587c46e4645..4386d3622434 100644 --- a/ui/src/components/dialog/QDialog.js +++ b/ui/src/components/dialog/QDialog.js @@ -5,10 +5,10 @@ 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' @@ -38,7 +38,8 @@ export default Vue.extend({ HistoryMixin, ModelToggleMixin, PortalMixin, - PreventScrollMixin + PreventScrollMixin, + FocusWrapMixin ], props: { @@ -91,7 +92,6 @@ export default Vue.extend({ useBackdrop (v) { this.__preventScroll(v) - this.__preventFocusout(v) } }, @@ -146,19 +146,8 @@ export default Vue.extend({ }, methods: { - focus () { - let node = this.__getInnerNode() - - if (node === void 0 || node.contains(document.activeElement) === true) { - return - } - - node = node.querySelector('[autofocus], [data-autofocus]') || node - node.focus() - }, - shake () { - this.focus() + this.__focusFirst() this.$emit('shake') const node = this.__getInnerNode() @@ -173,12 +162,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() @@ -192,14 +175,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.shake() + if (this.maximized !== 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() @@ -266,7 +267,6 @@ export default Vue.extend({ if (this.seamless !== true) { this.__preventScroll(false) - this.__preventFocusout(false) } } }, @@ -290,13 +290,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) @@ -350,7 +343,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 249d1f9d4a32..de365c953dc4 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' @@ -489,7 +491,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 5d315a3ad71b..a2aec1baa4f8 100644 --- a/ui/src/components/field/QField.js +++ b/ui/src/components/field/QField.js @@ -542,7 +542,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 ac55b0863e01..f0f5c35c2a74 100644 --- a/ui/src/components/knob/QKnob.js +++ b/ui/src/components/knob/QKnob.js @@ -71,7 +71,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 53a4510667b0..f8d575f45714 100644 --- a/ui/src/components/menu/QMenu.js +++ b/ui/src/components/menu/QMenu.js @@ -6,14 +6,13 @@ 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 ClickOutside from './ClickOutside.js' 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 { validatePosition, validateOffset, setPosition, parsePosition } from '../../utils/position-engine.js' @@ -27,7 +26,8 @@ export default Vue.extend({ AnchorMixin, ModelToggleMixin, PortalMixin, - TransitionMixin + TransitionMixin, + FocusWrapMixin ], directives: { @@ -140,17 +140,6 @@ export default Vue.extend({ }, methods: { - focus () { - 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() - } - }, - __show (evt) { // IE can have null document.activeElement this.__refocusTarget = this.noRefocus === false && document.activeElement !== null @@ -158,7 +147,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() } @@ -348,7 +351,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 befe093ec93b..6ca3bcdd734b 100644 --- a/ui/src/components/rating/QRating.js +++ b/ui/src/components/rating/QRating.js @@ -196,7 +196,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 030a5df2baf2..d219e4851c79 100644 --- a/ui/src/components/slider/slider-utils.js +++ b/ui/src/components/slider/slider-utils.js @@ -101,7 +101,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' }, editable () { diff --git a/ui/src/components/stepper/QStep.js b/ui/src/components/stepper/QStep.js index d9c10e818333..5a3ca09f2fc1 100644 --- a/ui/src/components/stepper/QStep.js +++ b/ui/src/components/stepper/QStep.js @@ -99,6 +99,7 @@ export default Vue.extend({ 'div', { staticClass: 'q-stepper__step', + attrs: { role: 'tabpanel' }, on: { ...this.qListeners } }, 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/QRouteTab.js b/ui/src/components/tabs/QRouteTab.js index 9644cb3ae4c7..40b7f3c261ee 100644 --- a/ui/src/components/tabs/QRouteTab.js +++ b/ui/src/components/tabs/QRouteTab.js @@ -15,8 +15,7 @@ export default Vue.extend({ }, inject: { - __activateRoute: {}, - __recalculateScroll: {} + __activateRoute: {} }, watch: { @@ -82,10 +81,10 @@ export default Vue.extend({ } if (keyboard === true) { - this.$el.focus(e) + this.$el.focus({ preventScroll: true }) } - else { - this.$refs.blurTarget !== void 0 && this.$refs.blurTarget.focus(e) + else if (this.$refs.blurTarget !== void 0) { + this.$refs.blurTarget.focus({ preventScroll: true }) } }, @@ -129,12 +128,10 @@ export default Vue.extend({ }, mounted () { - this.__recalculateScroll() this.$router !== void 0 && this.__checkActivation() }, beforeDestroy () { - this.__recalculateScroll() this.__activateRoute({ remove: true, name: this.name }) }, diff --git a/ui/src/components/tabs/QTab.js b/ui/src/components/tabs/QTab.js index 4904ab8d1f88..7a442683a6ee 100644 --- a/ui/src/components/tabs/QTab.js +++ b/ui/src/components/tabs/QTab.js @@ -5,9 +5,9 @@ import QIcon from '../icon/QIcon.js' import RippleMixin from '../../mixins/ripple.js' import ListenersMixin from '../../mixins/listeners.js' -import { stop } from '../../utils/event.js' +import { stop, prevent, stopAndPrevent } from '../../utils/event.js' import { mergeSlot } from '../../utils/slot.js' -import { isKeyCode } from '../../utils/key-composition.js' +import { shouldIgnoreKey } from '../../utils/key-composition.js' let uid = 0 @@ -23,7 +23,11 @@ export default Vue.extend({ } }, __activateTab: {}, - __recalculateScroll: {} + __registerTab: {}, + __unregisterTab: {}, + __focusTab: {}, + __unfocusTab: {}, + __onKbdNavigate: {} }, props: { @@ -69,15 +73,32 @@ export default Vue.extend({ }, computedTabIndex () { - return this.disable === true || this.isActive === true ? -1 : this.tabindex || 0 + if (this.disable === true) { + return -1 + } + + return this.tabs.focused === true || (this.isActive !== true && this.tabs.hasCurrent === true) + ? -1 + : this.tabindex || 0 + }, + + computedRipple () { + return this.ripple === false + ? false + : Object.assign( + { keyCodes: [13, 32], early: true }, + this.ripple === true ? {} : this.ripple + ) }, onEvents () { return { input: stop, ...this.qListeners, + focusin: this.__onFocusin, + focusout: this.__onFocusout, click: this.__activate, - keyup: this.__onKeyup + keydown: this.__onKeydown } }, @@ -85,7 +106,7 @@ export default Vue.extend({ const attrs = { tabindex: this.computedTabIndex, role: 'tab', - 'aria-selected': this.isActive + 'aria-selected': this.isActive === true ? 'true' : 'false' } if (this.disable === true) { @@ -96,9 +117,18 @@ export default Vue.extend({ } }, + watch: { + name (newName, oldName) { + this.__unregisterTab(oldName) + this.__registerTab(newName) + } + }, + methods: { __activate (e, keyboard) { - keyboard !== true && this.$refs.blurTarget !== void 0 && this.$refs.blurTarget.focus() + if (keyboard !== true && this.$refs.blurTarget !== void 0) { + this.$refs.blurTarget.focus({ preventScroll: true }) + } if (this.disable !== true) { this.qListeners.click !== void 0 && this.$emit('click', e) @@ -106,8 +136,30 @@ export default Vue.extend({ } }, - __onKeyup (e) { - isKeyCode(e, 13) === true && this.__activate(e, true) + __onFocusin (e) { + e.target === this.$el && this.__focusTab(this.$el) + + this.qListeners.focusin !== void 0 && this.$emit('focusin', e) + }, + + __onFocusout (e) { + this.__unfocusTab() + + this.qListeners.focusout !== void 0 && this.$emit('focusout', e) + }, + + __onKeydown (e) { + if (shouldIgnoreKey(e)) { + return + } + + if ([ 13, 32 ].indexOf(e.keyCode) !== -1) { + this.__activate(e, true) + prevent(e) + } + else if (e.keyCode >= 35 && e.keyCode <= 40) { + this.__onKbdNavigate(e.keyCode, this.$el) === true && stopAndPrevent(e) + } }, __getContent (h) { @@ -157,7 +209,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')) ] @@ -169,11 +221,11 @@ export default Vue.extend({ __renderTab (h, tag, props) { const data = { - staticClass: 'q-tab relative-position self-stretch flex flex-center text-center', + staticClass: 'q-tab relative-position self-stretch flex flex-center text-center no-outline', class: this.classes, attrs: this.attrs, - directives: this.ripple !== false && this.disable === true ? null : [ - { name: 'ripple', value: this.ripple } + directives: this.ripple === false || this.disable === true ? null : [ + { name: 'ripple', value: this.computedRipple } ], [ tag === 'div' ? 'on' : 'nativeOn' ]: this.onEvents } @@ -187,11 +239,11 @@ export default Vue.extend({ }, mounted () { - this.__recalculateScroll() + this.__registerTab(this.name) }, beforeDestroy () { - this.__recalculateScroll() + this.__unregisterTab(this.name) }, render (h) { diff --git a/ui/src/components/tabs/QTabs.js b/ui/src/components/tabs/QTabs.js index 8a91da103da1..ee8ef67f6334 100644 --- a/ui/src/components/tabs/QTabs.js +++ b/ui/src/components/tabs/QTabs.js @@ -51,9 +51,13 @@ export default Vue.extend({ provide () { return { tabs: this.tabs, - __recalculateScroll: this.__recalculateScroll, + __registerTab: this.__registerTab, + __unregisterTab: this.__unregisterTab, __activateTab: this.__activateTab, - __activateRoute: this.__activateRoute + __activateRoute: this.__activateRoute, + __focusTab: this.__focusTab, + __unfocusTab: this.__unfocusTab, + __onKbdNavigate: this.__onKbdNavigate } }, @@ -98,6 +102,8 @@ export default Vue.extend({ return { tabs: { current: this.value, + focused: false, + hasCurrent: false, activeColor: this.activeColor, activeBgColor: this.activeBgColor, indicatorClass: getIndicatorClass( @@ -154,7 +160,7 @@ export default Vue.extend({ }, outsideArrows () { - this.$nextTick(this.__recalculateScroll()) + this.__recalculateScroll() }, arrowsEnabled (v) { @@ -162,7 +168,7 @@ export default Vue.extend({ ? this.__updateArrowsFn : noop - this.$nextTick(this.__recalculateScroll()) + this.__recalculateScroll() } }, @@ -215,6 +221,7 @@ export default Vue.extend({ if (setCurrent === true || this.qListeners.input === void 0) { this.__animate(this.tabs.current, name) this.tabs.current = name + this.tabs.hasCurrent = this.tabNames.indexOf(name) > -1 } } }, @@ -361,8 +368,10 @@ export default Vue.extend({ : Math.ceil(pos + rect.width) < content.scrollWidth }, - __animScrollTo (value) { + __animScrollTo (value, onEnd) { this.__stopAnimScroll() + + this.__onAnimScrollEnd = onEnd this.__scrollTowards(value) this.scrollTimer = setInterval(() => { @@ -377,26 +386,31 @@ export default Vue.extend({ }, __scrollToEnd () { - this.__animScrollTo(9999) + this.__animScrollTo(Number.MAX_SAFE_INTEGER) }, __stopAnimScroll () { clearInterval(this.scrollTimer) + + if (this.__onAnimScrollEnd !== void 0) { + this.__onAnimScrollEnd() + this.__onAnimScrollEnd = void 0 + } }, __scrollTowards (value) { const content = this.$refs.content - let - pos = this.vertical === true ? content.scrollTop : content.scrollLeft, - done = false + const max = this.vertical === true ? content.scrollHeight - content.offsetHeight : content.scrollWidth - content.offsetWidth + + let pos = this.vertical === true ? content.scrollTop : content.scrollLeft + let done = false + + value = Math.max(0, Math.min(max, value)) + const direction = value < pos ? -1 : 1 pos += direction * 5 - if (pos < 0) { - done = true - pos = 0 - } - else if ( + if ( (direction === -1 && pos <= value) || (direction === 1 && pos >= value) ) { @@ -406,7 +420,130 @@ export default Vue.extend({ content[this.vertical === true ? 'scrollTop' : 'scrollLeft'] = pos this.__updateArrows() + return done + }, + + __scrollToTab (tab, alignEnd, skipFocus) { + if (this.$refs.content === void 0) { + return + } + + const content = this.$refs.content + const startContent = this.vertical === true ? content.scrollTop : content.scrollLeft + const sizeContent = this.vertical === true ? content.offsetHeight : content.offsetWidth + const offsetContent = this.vertical === true || this.$q.lang.rtl !== true ? 0 : content.scrollWidth - sizeContent + + const startTab = this.vertical === true ? tab.offsetTop : tab.offsetLeft + offsetContent + const endTab = startTab + (this.vertical === true ? tab.offsetHeight : tab.offsetWidth) + + const startsBefore = startTab < startContent + const endsAfter = endTab > startContent + sizeContent + + if (startsBefore === true || endsAfter === true) { + if (alignEnd === void 0) { + alignEnd = endsAfter === true && startsBefore !== true + } + + this.__animScrollTo( + alignEnd === true ? endTab - sizeContent : startTab, + () => { + setTimeout(() => { + skipFocus !== true && tab && tab.focus() + }) + } + ) + } + else { + skipFocus !== true && tab && tab.focus() + } + }, + + __focusTab (tab) { + if (this.tabs.focused !== true) { + this.tabs.focused = true + + this.__scrollToTab(tab, void 0, true) + this.__recalculateScroll() + } + }, + + __unfocusTab () { + if (this.tabs.focused !== false) { + this.tabs.focused = false + } + }, + + __onKbdNavigate (keyCode, fromEl) { + const matchTab = el => el === fromEl || el.matches('.q-tab.q-focusable') === true + const tabs = Array.prototype.filter.call(this.$refs.content.children, matchTab) + const tabsLength = tabs.length + + if (tabsLength === 0) { + return + } + + if (keyCode === 36) { // Home + if (tabs[0].contains(document.activeElement) === true) { + return false + } + + this.__scrollToTab(tabs[0], false) + this.__recalculateScroll() + + return true + } + if (keyCode === 35) { // End + if (tabs[tabsLength - 1].contains(document.activeElement) === true) { + return false + } + + this.__scrollToTab(tabs[tabsLength - 1], true) + this.__recalculateScroll() + + return true + } + + const dirPrev = (this.vertical === true && keyCode === 38 /* ArrowUp */) || + (this.vertical !== true && keyCode === 37 /* ArrowLeft */) + const dirNext = (this.vertical === true && keyCode === 40 /* ArrowDown */) || + (this.vertical !== true && keyCode === 39 /* ArrowRight */) + const dir = dirPrev === true ? -1 : (dirNext === true ? 1 : void 0) + + if (dir !== void 0) { + const rtlDir = this.vertical !== true && this.$q.lang.rtl === true ? -1 : 1 + const index = tabs.indexOf(fromEl) + dir * rtlDir + + if ( + index < 0 || + index >= tabsLength || + tabs[index].contains(document.activeElement) === true + ) { + return false + } + + this.__scrollToTab(tabs[index], dir > 0) + this.__recalculateScroll() + + return true + } + }, + + __registerTab (name) { + if (this.tabNames.indexOf(name) === -1) { + this.tabNames.push(name) + this.tabs.hasCurrent = this.tabNames.indexOf(this.tabs.current) > -1 + } + this.__recalculateScroll() + }, + + __unregisterTab (name) { + const index = this.tabNames.indexOf(name) + if (index > -1) { + this.tabNames.splice(index, 1) + this.tabs.hasCurrent = this.tabNames.indexOf(this.tabs.current) > -1 + } + this.__recalculateScroll() } }, @@ -416,6 +553,8 @@ export default Vue.extend({ created () { this.buffer = [] + this.tabNames = [] + this.__updateArrows = this.arrowsEnabled === true ? this.__updateArrowsFn : noop @@ -434,7 +573,7 @@ export default Vue.extend({ h('div', { ref: 'content', - staticClass: 'q-tabs__content row no-wrap items-center self-stretch hide-scrollbar', + staticClass: 'q-tabs__content row no-wrap items-center self-stretch hide-scrollbar relative-position', class: this.innerClass, on: this.arrowsEnabled === true ? cache(this, 'scroll', { scroll: this.__updateArrowsFn }) : void 0 }, slot(this, 'default')) diff --git a/ui/src/components/time/QTime.js b/ui/src/components/time/QTime.js index 5bb3addbfed6..95f11bd9ebc9 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..a6cb52cec0ae 100644 --- a/ui/src/css/core/visibility.sass +++ b/ui/src/css/core/visibility.sass @@ -161,3 +161,6 @@ body.desktop > .q-focus-helper opacity: .22 + .q-key-group-navigation--active + outline: 2px dashed rgba(200, 200, 200, 0.4) + outline-offset: -2px diff --git a/ui/src/css/core/visibility.styl b/ui/src/css/core/visibility.styl index 8b18b431dea8..a1aa8ce6484d 100644 --- a/ui/src/css/core/visibility.styl +++ b/ui/src/css/core/visibility.styl @@ -160,3 +160,6 @@ body.desktop > .q-focus-helper opacity: .22 + .q-key-group-navigation--active + outline: 2px dashed rgba(200, 200, 200, 0.4) + outline-offset: -2px 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..029e59d163db --- /dev/null +++ b/ui/src/directives/KeyGroupNavigation.js @@ -0,0 +1,311 @@ +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.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]), []) +keyCodes.horizontal.listH = keyCodes.horizontal.list +keyCodes.vertical.list = Object.keys(keyCodes.vertical).reduce((acc, k) => acc.concat(keyCodes.vertical[k]), []) +keyCodes.vertical.listH = [] +keyCodes.all.list = Object.keys(keyCodes.all).reduce((acc, k) => acc.concat(keyCodes.all[k]), []) +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() + 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 parseArg (ctx, arg) { + const data = [ 1, 1, 'q-key-group-navigation--active' ] + + if (typeof arg === 'string' && arg.length) { + const splits = arg.split(':') + + for (let i = 0; i < 2; i++) { + const v = parseInt(splits[i], 10) + v && (data[i] = v) + } + + if (typeof splits[3] === 'string' && splits[3].length > 0) { + data[3] = splits[3] + } + } + + 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), + + keyDown (evt) { + const { keyCode, shiftKey, target } = evt + + if ( + (keyCode !== 9 && 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 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)) + + const offset = ctx.keyCodes.listH.indexOf(keyCode) === -1 + ? ctx.offsetY + : ctx.offsetX + + if (ctx.keyCodes.prev.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, currentIndex - offset, -1, offset !== 1) + } + if (ctx.keyCodes.next.indexOf(keyCode) > -1) { + changeFocusedElement(focusableElements, currentIndex + offset, 1, offset !== 1) + } + } + + ctx.focusRestoreEl = document.activeElement + + prevent(evt) + }, + + setRestoreEl (evt) { + if (evt.target) { + ctx.focusRestoreEl = evt.target + } + }, + + focusIn (evt) { + const active = Interaction.isKeyboard + if (active === true) { + 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.active = true + el.classList.add(ctx.activeClass) + } + else if (ctx.active === true && ignored === true) { + ctx.active = false + el.classList.remove(ctx.activeClass) + } + } + else if (active !== true && ctx.active === true) { + ctx.active = false + el.classList.remove(ctx.activeClass) + } + + if ( + evt.target === ctx.firstTarget || + evt.target === ctx.lastTarget || + ( + evt.relatedTarget !== null && + ( + evt.relatedTarget._qKeyNavIgnore === true || + ( + evt.relatedTarget.classList !== void 0 && // required for IE11 + evt.relatedTarget.classList.contains('q-key-group-navigation--ignore-focus') === true + ) + ) + ) + ) { + 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() + } + } + }, + + focusOut (evt) { + if ( + ctx.active === true && + (evt.relatedTarget === null || el.contains(evt.relatedTarget) === false) + ) { + ctx.active = false + el.classList.remove(ctx.activeClass) + } + } + } + + if (el.__qkeygrpnav) { + el.__qkeygrpnav_old = el.__qkeygrpnav + } + + el.__qkeygrpnav = ctx + + createFocusTargets(ctx) + + configureEvents(el, ctx, modifiers, value) + }, + + update (el, { modifiers, arg, value, oldValue }) { + 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) { + removeFocusTargets(ctx) + cleanEvt(ctx, 'main') + ctx.active === true && el.classList.remove(ctx.activeClass) + + 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..c58b2b4ba2aa --- /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' + +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 6f761e378ae0..b451a7627c67 100644 --- a/ui/src/mixins/panel.js +++ b/ui/src/mixins/panel.js @@ -11,7 +11,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 }) @@ -235,7 +234,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 9bf9a5f8a252..04b7dcbc8f5b 100644 --- a/ui/src/mixins/portal.js +++ b/ui/src/mixins/portal.js @@ -2,6 +2,7 @@ import Vue from 'vue' import { isSSR } from '../plugins/Platform.js' import { getBodyFullscreenElement } from '../utils/dom.js' +import { FOCUSABLE_SELECTOR, changeFocusedElement } from '../utils/focus' export function closePortalMenus (vm, evt) { do { @@ -74,6 +75,26 @@ const Portal = { }, methods: { + focus () { + 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 () { if (this.$q.fullscreen !== void 0 && this.$q.fullscreen.isCapable === true) { const append = isFullscreen => { @@ -142,6 +163,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.js b/ui/src/utils/focus.js new file mode 100644 index 000000000000..0382d8b59ddb --- /dev/null +++ b/ui/src/utils/focus.js @@ -0,0 +1,55 @@ +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])', + '.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 initialEl = document.activeElement + + if (initialEl !== null) { + initialEl._qKeyNavIgnore = true + } + + const index = normalizeToInterval(to, 0, lastIndex) + + if (index === start || index > lastIndex) { + return + } + + list[index].focus() + + if (initialEl !== null) { + initialEl._qKeyNavIgnore = false + } + + if (document.activeElement !== list[index]) { + changeFocusedElement(list, index + direction, direction, noWrap, start === void 0 ? index : start) + } +}