Keyboard menu/widget navigation (#628)

* Keyboard menu navigation

* Fix dropdown keyboard navigation

* Fix merge error

* Some code review

* Interactive dropdowns

* Query by data attr not class name

* Add locking behaviour

* Change query selector style

* Change query selector style (again)

* Code review feedback

* Fix highlighted entry regression

* Styling and disabling checkbox tabindex in MenuLists

* Don't redirect space off canvas to backend

* Do not emit update if value same

* Escape closes all floating menus

* Close dropdowns on blur

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0HyperCube
2022-05-25 14:10:18 -07:00
committed by Keavon Chambers
co-authored by Keavon Chambers
parent eca9797597
commit 889e988fb3
12 changed files with 176 additions and 15 deletions
@@ -90,5 +90,11 @@ export default defineComponent({
this.dialog.dismissDialog();
},
},
mounted() {
// Focus the first button in the popup
const element = this.$el as Element | null;
const emphasizedOrFirstButton = (element?.querySelector("[data-emphasized]") as HTMLButtonElement | null) || element?.querySelector("[data-text-button]");
emphasizedOrFirstButton?.focus();
},
});
</script>
@@ -193,6 +193,7 @@ export default defineComponent({
windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
minWidth: { type: Number as PropType<number>, default: 0 },
escapeCloses: { type: Boolean as PropType<boolean>, default: true },
},
data() {
// The resize observer is attached to the floating menu container, which is the zero-height div of the width of the parent element's floating menu spawner.
@@ -374,6 +375,11 @@ export default defineComponent({
window.removeEventListener("pointerup", this.pointerUpHandler);
}
},
keyDownHandler(e: KeyboardEvent) {
if (this.escapeCloses && e.key.toLowerCase() === "escape") {
this.$emit("update:open", false);
}
},
pointerDownHandler(e: PointerEvent) {
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
if (this.isPointerEventOutsideFloatingMenu(e)) {
@@ -423,6 +429,8 @@ export default defineComponent({
if (newState && !oldState) {
// Close floating menu if pointer strays far enough away
window.addEventListener("pointermove", this.pointerMoveHandler);
// Close floating menu if esc is pressed
window.addEventListener("keydown", this.keyDownHandler);
// Close floating menu if pointer is outside (but within stray distance)
window.addEventListener("pointerdown", this.pointerDownHandler);
// Cancel the subsequent click event to prevent the floating menu from reopening if the floating menu's button is the click event target
@@ -444,6 +452,7 @@ export default defineComponent({
this.containerResizeObserver.disconnect();
window.removeEventListener("pointermove", this.pointerMoveHandler);
window.removeEventListener("keydown", this.keyDownHandler);
window.removeEventListener("pointerdown", this.pointerDownHandler);
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
}
@@ -5,6 +5,7 @@
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
:type="'Dropdown'"
:windowEdgeMargin="0"
:escapeCloses="false"
v-bind="{ direction, scrollableY, minWidth }"
ref="floatingMenu"
data-hover-menu-keep-open
@@ -15,12 +16,12 @@
v-for="(entry, entryIndex) in section"
:key="entryIndex"
class="row"
:class="{ open: isEntryOpen(entry), active: entry.label === activeEntry?.label }"
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label }"
@click="() => onEntryClick(entry)"
@pointerenter="() => onEntryPointerEnter(entry)"
@pointerleave="() => onEntryPointerLeave(entry)"
>
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" class="entry-checkbox" />
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :disableTabIndex="true" class="entry-checkbox" />
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
<div v-else-if="drawIcon" class="no-icon"></div>
@@ -179,6 +180,7 @@ const MenuList = defineComponent({
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
minWidth: { type: Number as PropType<number>, default: 0 },
drawIcon: { type: Boolean as PropType<boolean>, default: false },
interactive: { type: Boolean as PropType<boolean>, default: false },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
defaultAction: { type: Function as PropType<() => void>, required: false },
},
@@ -186,12 +188,14 @@ const MenuList = defineComponent({
return {
isOpen: this.open,
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
highlighted: this.activeEntry as MenuListEntry | undefined,
};
},
watch: {
// Called only when `open` is changed from outside this component (with v-model)
open(newOpen: boolean) {
this.isOpen = newOpen;
this.highlighted = this.activeEntry;
},
isOpen(newIsOpen: boolean) {
this.$emit("update:open", newIsOpen);
@@ -240,6 +244,88 @@ const MenuList = defineComponent({
return this.open;
},
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
keydown(e: KeyboardEvent, submenu: boolean): boolean {
// Interactive menus should keep the active entry the same as the highlighted one
if (this.interactive) this.highlighted = this.activeEntry;
const menuOpen = this.isOpen;
const flatEntries = this.entries.flat();
const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.isOpen);
const openSubmenu = (highlighted: MenuListEntry<string>): void => {
if (highlighted.ref && highlighted.children?.length) {
highlighted.ref.isOpen = true;
// Highlight first item
highlighted.ref.setHighlighted(highlighted.children[0][0]);
}
};
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
// Allow opening menu with space or enter
this.isOpen = true;
this.highlighted = this.activeEntry;
} else if (menuOpen && openChild >= 0) {
// Redirect the keyboard navigation to a submenu if one is open
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
// Highlight the menu item in the parent list that corresponds with the open submenu
if (e.key !== "Escape" && this.highlighted) this.setHighlighted(flatEntries[openChild]);
// Handle the child closing the entire menu stack
if (shouldCloseStack) {
this.isOpen = false;
return true;
}
} else if ((menuOpen || this.interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
// Navigate to the next and previous entries with arrow keys
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
if (this.highlighted) {
const index = this.highlighted ? flatEntries.map((entry) => entry.label).indexOf(this.highlighted.label) : 0;
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
// Interactive dropdowns should lock at the end whereas other dropdowns should loop
if (this.interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
}
const newEntry = flatEntries[newIndex];
this.setHighlighted(newEntry);
} else if (menuOpen && e.key === "Escape") {
// Close menu with escape key
this.isOpen = false;
// Reset active to before open
this.setHighlighted(this.activeEntry);
} else if (menuOpen && this.highlighted && e.key === "Enter") {
// Handle clicking on an option if enter is pressed
if (!this.highlighted.children?.length) this.onEntryClick(this.highlighted);
else openSubmenu(this.highlighted);
// Stop the event from triggering a press on a new dialog
e.preventDefault();
// Enter should close the entire menu stack
return true;
} else if (menuOpen && this.highlighted && e.key === "ArrowRight") {
// Right arrow opens a submenu
openSubmenu(this.highlighted);
} else if (menuOpen && e.key === "ArrowLeft") {
// Left arrow closes a submenu
if (submenu) this.isOpen = false;
}
// By default, keep the menu stack open
return false;
},
setHighlighted(newHighlight: MenuListEntry<string> | undefined) {
this.highlighted = newHighlight;
// Interactive menus should keep the active entry the same as the highlighted one
if (this.interactive && newHighlight?.value !== this.activeEntry?.value) this.$emit("update:activeEntry", newHighlight);
},
},
computed: {
entriesWithoutRefs(): MenuListEntryData[][] {