mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-25 14:08:12 +08:00
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:
committed by
Keavon Chambers
co-authored by
Keavon Chambers
parent
eca9797597
commit
889e988fb3
@@ -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[][] {
|
||||
|
||||
Reference in New Issue
Block a user