diff --git a/package.json b/package.json index dd20909f..d8c06366 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "async-validator": "^3.4.0", "color": "^3.1.2", "date-fns": "^2.15.0", + "focus-visible": "^5.2.0", "jest-transform-stub": "^2.0.0", "lodash-es": "^4.17.15", "resize-observer-polyfill": "^1.5.0", diff --git a/src/main.scss b/src/main.scss index 6877bd5e..50fff273 100644 --- a/src/main.scss +++ b/src/main.scss @@ -45,3 +45,13 @@ a { outline: none; } } + +/* + :focus-visible polyfill. + This will hide the focus indicator if the element receives focus via the mouse, + but it will still show up on keyboard focus. +*/ + +.js-focus-visible :focus:not(.focus-visible) { + outline: none; +} diff --git a/src/onDemand.js b/src/onDemand.js index f546729f..7f89a4de 100644 --- a/src/onDemand.js +++ b/src/onDemand.js @@ -1,6 +1,7 @@ /* eslint-disable global-require */ /* eslint-disable no-param-reassign */ import vClickOutside from 'v-click-outside'; +import 'focus-visible'; import { version } from '../package.json'; import { installI18n } from './qComponents/constants/locales'; diff --git a/src/qComponents/QButton/src/q-button.scss b/src/qComponents/QButton/src/q-button.scss index 0fc430b9..e7f1b90c 100644 --- a/src/qComponents/QButton/src/q-button.scss +++ b/src/qComponents/QButton/src/q-button.scss @@ -70,7 +70,7 @@ &_theme { &_primary { - &:focus { + &.focus-visible { background-color: var(--color-primary-darker); background-image: none; } @@ -93,7 +93,7 @@ box-shadow: var(--box-shadow-pressed); } - &:focus { + &.focus-visible { color: var(--color-tertiary-white); background-color: var(--color-primary-darker); } @@ -109,7 +109,7 @@ background-image: none; box-shadow: none; - &:focus { + &.focus-visible { text-decoration: underline; } diff --git a/src/qComponents/QCascader/src/QCascaderPanel.vue b/src/qComponents/QCascader/src/QCascaderPanel.vue index e13cb322..b08c9521 100644 --- a/src/qComponents/QCascader/src/QCascaderPanel.vue +++ b/src/qComponents/QCascader/src/QCascaderPanel.vue @@ -67,7 +67,10 @@ export default { methods: { navigateFocus(e) { - if (e.target.classList.contains('q-input__inner')) { + if ( + ['ArrowDown', 'ArrowUp'].includes(e.key) && + e.target instanceof HTMLInputElement + ) { const firstNode = this.$el.querySelector( `#${this.cascader.id}-node-0-0` ); diff --git a/src/qComponents/QDrawer/__snapshots__/QDrawer.test.js.snap b/src/qComponents/QDrawer/__snapshots__/QDrawer.test.js.snap index ba5a8187..fc2f352d 100644 --- a/src/qComponents/QDrawer/__snapshots__/QDrawer.test.js.snap +++ b/src/qComponents/QDrawer/__snapshots__/QDrawer.test.js.snap @@ -17,7 +17,8 @@ exports[`QDrawer should match snapshot 1`] = ` style="z-index: 2001;" >
diff --git a/src/qComponents/QOption/src/q-option.scss b/src/qComponents/QOption/src/q-option.scss index 37e3ffce..db3788df 100644 --- a/src/qComponents/QOption/src/q-option.scss +++ b/src/qComponents/QOption/src/q-option.scss @@ -1,6 +1,7 @@ .q-option { --option-background-color-base: var(--color-tertiary-gray-light); --option-background-color-hover: var(--color-tertiary-gray); + --option-background-color-focus: var(--color-tertiary-gray); --option-background-color-selected: var(--color-tertiary-gray-ultra-light); --option-background-color-disabled: var(--color-tertiary-gray-light); @@ -15,6 +16,7 @@ height: 40px; padding: 8px 16px; background-color: var(--option-background-color-base); + outline: none; box-shadow: -1px -1px 3px rgba(var(--color-rgb-white), 0.25), 1px 1px 3px rgba(var(--color-rgb-blue), 0.4); cursor: pointer; @@ -32,6 +34,14 @@ background-color: var(--option-background-color-hover); } + &:focus { + outline: none; + } + + &.focus-visible { + background-color: var(--option-background-color-focus); + } + &_all ~ .q-option { padding-left: 24px; } diff --git a/src/qComponents/QSelect/src/QSelect.vue b/src/qComponents/QSelect/src/QSelect.vue index bd729a36..0e36ba2e 100644 --- a/src/qComponents/QSelect/src/QSelect.vue +++ b/src/qComponents/QSelect/src/QSelect.vue @@ -1,11 +1,12 @@ @@ -49,7 +51,7 @@ :filterable="filterable" :is-disabled="isDisabled" :query.sync="query" - @keydown-enter="handleEnterKeydown" + @keyup-enter="handleEnterKeyUp" @focus="handleFocus" @remove-tag="deleteTag" @exit="visible = false" @@ -432,9 +434,51 @@ export default { if (dropdown?.parentNode === document.body) { document.body.removeChild(dropdown); } + + document.removeEventListener('keyup', this.handleKeyUp, true); }, methods: { + togglePopper() { + if (this.popper) { + this.hidePopper(); + } else { + this.showPopper(); + } + }, + + handleKeyUp(e) { + if ( + this.$refs.input.$el.querySelector('input') === e.target && + e.key === 'Enter' + ) { + this.togglePopper(); + } + + switch (e.key) { + case 'Escape': { + this.visible = false; + break; + } + case 'Tab': { + if (!this.$refs.reference.contains(document.activeElement)) { + this.visible = false; + } + break; + } + case 'ArrowRight': + case 'ArrowUp': + case 'ArrowLeft': + case 'ArrowDown': + case 'Enter': { + this.$refs.dropdown.navigateDropdown(e); + break; + } + default: + break; + } + }, + handleOutsideClick() { this.visible = false; }, @@ -448,13 +492,13 @@ export default { }, createPopper() { - const { reference, dropdown } = this.$refs; + const { input, dropdown } = this.$refs; if (this.appendToBody) { document.body.appendChild(dropdown.$el); } - this.popper = createPopper(reference.$el, dropdown.$el, { + this.popper = createPopper(input.$el, dropdown.$el, { modifiers: [ { name: 'offset', @@ -469,6 +513,7 @@ export default { showPopper() { this.isDropdownShown = true; this.createPopper(); + document.addEventListener('keyup', this.handleKeyUp, true); }, hidePopper() { @@ -479,6 +524,7 @@ export default { this.popper.destroy(); this.popper = null; + document.removeEventListener('keyup', this.handleKeyUp, true); }, getKey(value) { @@ -556,7 +602,7 @@ export default { blur() { this.visible = false; - this.$refs.reference.blur(); + this.$refs.input.blur(); }, handleBlur(event) { @@ -565,7 +611,7 @@ export default { }, 50); }, - handleClearClick() { + clearSelected() { const value = this.multiple ? [] : null; this.emitValueUpdate(value); @@ -619,11 +665,11 @@ export default { } if (this.visible) { - (this.$refs.tags?.$refs.input ?? this.$refs.reference).focus(); + (this.$refs.tags?.$refs.input ?? this.$refs.input).focus(); } }, - handleEnterKeydown() { + handleEnterKeyUp() { if (!this.visible) { this.toggleMenu(); return; diff --git a/src/qComponents/QSelect/src/QSelectDropdown.vue b/src/qComponents/QSelect/src/QSelectDropdown.vue index 88c708d5..31408b7c 100644 --- a/src/qComponents/QSelect/src/QSelectDropdown.vue +++ b/src/qComponents/QSelect/src/QSelectDropdown.vue @@ -13,11 +13,13 @@ >
@@ -134,6 +136,57 @@ export default { }, methods: { + navigateDropdown(e) { + if ( + ['ArrowDown', 'ArrowUp'].includes(e.key) && + e.target instanceof HTMLInputElement + ) { + const firstNode = this.$el.querySelector(`.q-option`); + firstNode?.focus(); + } + + if (!e.target.classList.contains('q-option')) return; + const availableOptions = this.options.filter( + ({ isDisabled, isVisible }) => !isDisabled && isVisible + ); + const availableElements = availableOptions.map(option => option.$el); + let currentNodeIndex; + let nextNodeIndex; + availableElements.forEach((element, index) => { + if (document.activeElement === element) { + currentNodeIndex = index; + } + }); + + switch (e.key) { + case 'ArrowUp': + case 'ArrowLeft': + nextNodeIndex = currentNodeIndex - 1; + break; + + case 'Tab': + this.qSelect.visible = false; + break; + + case 'ArrowDown': + case 'ArrowRight': + nextNodeIndex = currentNodeIndex + 1; + break; + + case 'Enter': { + this.qSelect.toggleOptionSelection( + availableOptions[currentNodeIndex] + ); + break; + } + default: + break; + } + + const node = availableElements[nextNodeIndex]; + + node?.focus(); + }, handleSelectAllClick() { if (this.areAllSelected) { const keysToRemove = this.options diff --git a/src/qComponents/QSelect/src/QSelectTags.vue b/src/qComponents/QSelect/src/QSelectTags.vue index 5780bb27..83ea3f5f 100644 --- a/src/qComponents/QSelect/src/QSelectTags.vue +++ b/src/qComponents/QSelect/src/QSelectTags.vue @@ -40,9 +40,9 @@ class="q-select-tags__input" :autocomplete="autocomplete" @focus="$emit('focus')" - @keydown.enter.prevent="$emit('keydown-enter')" - @keydown.esc.stop.prevent="emitExit" - @keydown.tab="emitExit" + @keyup.esc.stop.prevent="emitExit" + @keyup.enter.prevent="$emit('keyup-enter')" + @keydown.backspace.capture="handleBackspaceKeyDown" @input="handleInput" />
@@ -65,6 +65,12 @@ export default { }, methods: { + handleBackspaceKeyDown() { + if (!this.query) { + this.$emit('remove-tag', this.selected[this.selected.length - 1]); + } + }, + handleTagClose(option) { this.$emit('remove-tag', option); }, diff --git a/src/qComponents/QSelect/src/q-select.scss b/src/qComponents/QSelect/src/q-select.scss index 5ed78d72..6035d9c3 100644 --- a/src/qComponents/QSelect/src/q-select.scss +++ b/src/qComponents/QSelect/src/q-select.scss @@ -17,6 +17,7 @@ .q-input__inner { height: 100%; + user-select: none; } } } diff --git a/src/qComponents/index.js b/src/qComponents/index.js index 8f487f5a..78ae61eb 100644 --- a/src/qComponents/index.js +++ b/src/qComponents/index.js @@ -1,4 +1,5 @@ /* eslint-disable no-underscore-dangle, global-require, no-param-reassign */ +import 'focus-visible'; import { kebabCase, isString } from 'lodash-es'; import vClickOutside from 'v-click-outside'; import { version } from '../../package.json'; diff --git a/yarn.lock b/yarn.lock index 31a43562..5672638d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7356,6 +7356,11 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +focus-visible@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/focus-visible/-/focus-visible-5.2.0.tgz#3a9e41fccf587bd25dcc2ef045508284f0a4d6b3" + integrity sha512-Rwix9pBtC1Nuy5wysTmKy+UjbDJpIfg8eHjw0rjZ1mX4GNLz1Bmd16uDpI3Gk1i70Fgcs8Csg2lPm8HULFg9DQ== + follow-redirects@^1.0.0: version "1.13.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"