diff --git a/lib/KDropdownMenu.vue b/lib/KDropdownMenu.vue index 6d4f3d354..e00a6fbb2 100644 --- a/lib/KDropdownMenu.vue +++ b/lib/KDropdownMenu.vue @@ -18,6 +18,7 @@ @open="handleOpen" > @@ -97,11 +98,15 @@ }, }, beforeDestroy() { - window.removeEventListener('keyup', this.handleKeyUp, true); + window.removeEventListener('keydown', this.handleOpenMenuNavigation, true); }, methods: { handleOpen() { - window.addEventListener('keyup', this.handleKeyUp, true); + this.$nextTick(() => this.setFocus()); + window.addEventListener('keydown', this.handleOpenMenuNavigation, true); + }, + setFocus() { + this.$refs.menu.$el.querySelector('li').focus(); }, handleClose() { const focusedElement = document.activeElement; @@ -116,14 +121,34 @@ } window.removeEventListener('keyup', this.handleKeyUp, true); }, - handleKeyUp(event) { - if (event.shiftKey && event.keyCode == 9) { - const popover = this.$refs.popover.$el; - const popoverIsOpen = popover.clientWidth > 0 && popover.clientHeight > 0; - if (popoverIsOpen && !popover.contains(document.activeElement)) { - this.closePopover(); - this.focusOnButton(); - } + handleOpenMenuNavigation(event) { + // identify the menu state and length + if (!this.$refs.popover && !this.$refs.popover.$el) { + return; + } + const popover = this.$refs.popover.$el; + const menuElements = this.$refs.menu.$el.querySelector('div').children; + const lastChild = menuElements[menuElements.length - 1]; + const popoverIsOpen = popover.clientWidth > 0 && popover.clientHeight > 0; + // set current element and its siblings + let focusedElement = document.activeElement; + let sibling = focusedElement.nextElementSibling; + let prevSibling = focusedElement.previousElementSibling; + + // manage rotating through the options using arrow keys + // UP arrow: .keyCode is depricated and should used only as a fallback + if ((event.key == 'ArrowUp' || event.keyCode == 38) && popoverIsOpen) { + event.preventDefault(); + prevSibling + ? this.$nextTick(() => prevSibling.focus()) + : this.$nextTick(() => lastChild.focus()); + // DOWN arrow + } else if ((event.key == 'ArrowDown' || event.keyCode == 40) && popoverIsOpen) { + event.preventDefault(); + sibling ? this.$nextTick(() => sibling.focus()) : this.$nextTick(() => this.setFocus()); + // if a TAB key, not an arrow key, close the popover and advance to next item in the tab index + } else if ((event.key == 'Tab' || event.keyCode == 9) && popoverIsOpen) { + this.closePopover(); } }, handleSelection(selection) { diff --git a/lib/keen/UiMenu.vue b/lib/keen/UiMenu.vue index c2779e8ba..6221d95b5 100644 --- a/lib/keen/UiMenu.vue +++ b/lib/keen/UiMenu.vue @@ -8,7 +8,6 @@ lazy :class="classes" - :contain-focus="containFocus" > @@ -101,6 +101,9 @@ 'has-secondary-text': this.hasSecondaryText, }; }, + activeOutline() { + return this.isActive ? this.$coreOutline : {}; + }, }, methods: { @@ -112,7 +115,6 @@ this.$emit('select', option); this.closeMenu(); }, - closeMenu() { this.$emit('close'); }, diff --git a/lib/keen/UiMenuOption.vue b/lib/keen/UiMenuOption.vue index 278ce90ad..4f6cb6cf6 100644 --- a/lib/keen/UiMenuOption.vue +++ b/lib/keen/UiMenuOption.vue @@ -3,9 +3,12 @@ import UiIcon from './UiIcon.vue'; + import globalThemeState from '../styles/globalThemeState'; + export default { name: 'UiMenuOption', @@ -49,6 +54,10 @@ UiIcon, }, + data:() => ({ + isActive: false, + }), + props: { type: String, label: String, @@ -77,6 +86,9 @@ }; }, + activeStyle() { + return this.isActive ? {...this.$coreOutline, outlineOffset: '-2px' } : {} + }, isDivider() { return this.type === 'divider'; }, @@ -125,6 +137,11 @@ background-color: $ui-menu-item-hover-color; } + &:focus:not(.is-disabled), + body[modality='keyboard'] &:focus { + background-color: $ui-menu-item-hover-color; + } + &.is-disabled { color: $secondary-text-color; cursor: default; diff --git a/lib/styles/trackInputModality.js b/lib/styles/trackInputModality.js index 2e91577e9..fc3fb7862 100644 --- a/lib/styles/trackInputModality.js +++ b/lib/styles/trackInputModality.js @@ -7,13 +7,68 @@ import globalThemeState from './globalThemeState'; -// only keys listed here will change modality to keyboard -const KEYS_WHITELIST = ['Tab']; - function setUpEventHandlers(disableFocusRingByDefault) { - let recentKeyboardEvent = null; + let hadKeyboardEvent = false; + let hadClickEvent = false; + const keyboardModalityDefaultElements = [ + 'input:not([type])', + 'input[type=text]', + 'input[type=radio]', + 'input[type=checkbox]', + 'input[type=number]', + 'input[type=date]', + 'input[type=time]', + 'input[type=datetime]', + 'textarea', + '[role=textbox]', + 'a', + 'button', + ].join(','); + + // add this to any element to allow keyboard navigation, regardless of focus event + const keyboardModalityOverride = ['[supports-modality=keyboard]']; + let isHandlingKeyboardThrottle; + const matcher = (() => { + const el = document.body; + + if (el.matchesSelector) { + return el.matchesSelector; + } + + if (el.webkitMatchesSelector) { + return el.webkitMatchesSelector; + } + + if (el.mozMatchesSelector) { + return el.mozMatchesSelector; + } + + if (el.msMatchesSelector) { + return el.msMatchesSelector; + } + })(); + + const focusTriggersKeyboardModality = function(el) { + let triggers = false; + + if (matcher) { + triggers = + matcher.call(el, keyboardModalityDefaultElements) && matcher.call(el, ':not([readonly])'); + } + return triggers; + }; + + const focusSetExplicitly = function(el) { + let triggers = false; + + if (matcher) { + triggers = matcher.call(el, keyboardModalityOverride) && matcher.call(el, ':not([readonly])'); + } + return triggers; + }; + if (disableFocusRingByDefault) { const css = 'body:not([modality=keyboard]) :focus { outline: none; }'; const head = document.head || document.getElementsByTagName('head')[0]; @@ -33,25 +88,39 @@ function setUpEventHandlers(disableFocusRingByDefault) { document.body.addEventListener( 'keydown', - event => { - recentKeyboardEvent = event; + () => { + hadKeyboardEvent = true; + hadClickEvent = false; if (isHandlingKeyboardThrottle) { clearTimeout(isHandlingKeyboardThrottle); } isHandlingKeyboardThrottle = setTimeout(() => { - recentKeyboardEvent = null; + hadKeyboardEvent = false; }, 100); }, true ); + document.body.addEventListener('mousedown', () => { + hadClickEvent = true; + hadKeyboardEvent = false; + }); + document.body.addEventListener( 'focus', - () => { - if (recentKeyboardEvent && KEYS_WHITELIST.includes(recentKeyboardEvent.key)) { + e => { + if ( + (hadKeyboardEvent && focusTriggersKeyboardModality(e.target)) || + (focusSetExplicitly(e.target) && !hadClickEvent) + ) { + // both the JS state and the body attribute for keyboard modality globalThemeState.inputModality = 'keyboard'; + document.body.setAttribute('modality', 'keyboard'); + } else { + globalThemeState.inputModality = null; + document.body.setAttribute('modality', ''); } }, true @@ -61,6 +130,7 @@ function setUpEventHandlers(disableFocusRingByDefault) { 'blur', () => { globalThemeState.inputModality = null; + document.body.setAttribute('modality', ''); }, true ); diff --git a/yarn.lock b/yarn.lock index 72f208600..fd34fe893 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9060,7 +9060,7 @@ knuth-shuffle-seeded@^1.0.6: "kolibri-design-system@git://github.com/learningequality/kolibri-design-system#65ee3a69a2ab072ea92e034d95118687678b4f5f": version "0.2.2-beta-2" - resolved "git://github.com/learningequality/kolibri-design-system#65ee3a69a2ab072ea92e034d95118687678b4f5f" + resolved "https://github.com/learningequality/kolibri-design-system#65ee3a69a2ab072ea92e034d95118687678b4f5f" dependencies: aphrodite "https://github.com/learningequality/aphrodite/" autosize "^3.0.21"