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 22:10:18 +01:00 committed by Keavon Chambers
parent 8b94c62697
commit 81c3420470
12 changed files with 176 additions and 15 deletions

View File

@ -780,6 +780,7 @@ impl DocumentMessageHandler {
selected_index: blend_mode.map(|blend_mode| blend_mode as u32),
disabled: blend_mode.is_none() && !blend_mode_is_mixed,
draw_icon: false,
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Related,

View File

@ -359,8 +359,8 @@ pub struct PopoverButton {
pub text: String,
}
#[derive(Clone, Serialize, Deserialize, Derivative, Default)]
#[derivative(Debug, PartialEq)]
#[derive(Clone, Serialize, Deserialize, Derivative)]
#[derivative(Debug, PartialEq, Default)]
pub struct DropdownInput {
pub entries: Vec<Vec<DropdownEntryData>>,
// This uses `u32` instead of `usize` since it will be serialized as a normal JS number (replace with usize when we switch to a native UI)
@ -368,6 +368,8 @@ pub struct DropdownInput {
pub selected_index: Option<u32>,
#[serde(rename = "drawIcon")]
pub draw_icon: bool,
#[derivative(Default(value = "true"))]
pub interactive: bool,
// `on_update` exists on the `DropdownEntryData`, not this parent `DropdownInput`
pub disabled: bool,
}

View File

@ -190,6 +190,23 @@ img {
}
}
.icon-button,
.text-button,
.popover-button,
.checkbox-input label,
.color-input .swatch .swatch-button,
.dropdown-input .dropdown-box,
.font-input .dropdown-box,
.radio-input button,
.menu-list,
.menu-bar-input .entry {
&:focus-visible,
&.dropdown-box:focus {
outline: 1px dashed var(--color-accent);
outline-offset: -1px;
}
}
// For placeholder messages (remove eventually)
.floating-menu {
h1,

View File

@ -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>

View File

@ -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()`
}

View File

@ -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[][] {

View File

@ -4,6 +4,7 @@
:class="{ emphasized, disabled }"
:data-emphasized="emphasized || null"
:data-disabled="disabled || null"
data-text-button
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
@click="(e: MouseEvent) => action(e)"
>

View File

@ -1,11 +1,11 @@
<!-- TODO: Implement collapsable sections with properties system -->
<template>
<LayoutCol class="widget-section">
<LayoutRow class="header" @click.stop="() => (expanded = !expanded)">
<button class="header" @click.stop="() => (expanded = !expanded)">
<div class="expand-arrow" :class="{ expanded }"></div>
<Separator :type="'Related'" />
<TextLabel :bold="true">{{ widgetData.name }}</TextLabel>
</LayoutRow>
</button>
<LayoutCol class="body" v-if="expanded">
<component :is="layoutRowType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layoutTarget" v-for="(layoutRow, index) in widgetData.layout" :key="index"></component>
</LayoutCol>
@ -17,11 +17,14 @@
flex: 0 0 auto;
.header {
display: flex;
flex: 0 0 24px;
background: var(--color-4-dimgray);
align-items: center;
border: 0;
text-align: left;
padding: 0 8px;
margin: 0 -4px;
background: var(--color-4-dimgray);
align-items: center;
.expand-arrow {
width: 6px;

View File

@ -1,7 +1,7 @@
<template>
<LayoutRow class="checkbox-input" :class="{ 'outline-style': outlineStyle }">
<input type="checkbox" :id="`checkbox-input-${id}`" :checked="checked" @change="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)" />
<label :for="`checkbox-input-${id}`">
<label :for="`checkbox-input-${id}`" :tabindex="disableTabIndex ? -1 : 0" @keydown.enter="(e) => ((e.target as HTMLElement).previousSibling as HTMLInputElement).click()">
<LayoutRow class="checkbox-box">
<IconLabel :icon="icon" />
</LayoutRow>
@ -21,6 +21,8 @@
label {
display: flex;
height: 16px;
// Provides rounded corners for the :focus outline
border-radius: 2px;
.checkbox-box {
flex: 0 0 auto;
@ -105,6 +107,7 @@ export default defineComponent({
checked: { type: Boolean as PropType<boolean>, default: false },
icon: { type: String as PropType<IconName>, default: "Checkmark" },
outlineStyle: { type: Boolean as PropType<boolean>, default: false },
disableTabIndex: { type: Boolean as PropType<boolean>, default: false },
},
components: {
IconLabel,

View File

@ -1,6 +1,16 @@
<template>
<LayoutRow class="dropdown-input">
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" ref="dropdownBox" data-hover-menu-spawner>
<LayoutRow
class="dropdown-box"
:class="{ disabled }"
:style="{ minWidth: `${minWidth}px` }"
tabindex="0"
@click="() => !disabled && (open = true)"
@blur="() => (open = false)"
@keydown="(e) => keydown(e)"
ref="dropdownBox"
data-hover-menu-spawner
>
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<span>{{ activeEntry.label }}</span>
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
@ -11,8 +21,10 @@
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
:entries="entries"
:drawIcon="drawIcon"
:interactive="interactive"
:direction="'Bottom'"
:scrollableY="true"
ref="menuList"
/>
</LayoutRow>
</template>
@ -99,6 +111,7 @@ export default defineComponent({
entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
selectedIndex: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
drawIcon: { type: Boolean as PropType<boolean>, default: false },
interactive: { type: Boolean as PropType<boolean>, default: true },
disabled: { type: Boolean as PropType<boolean>, default: false },
},
data() {
@ -129,6 +142,9 @@ export default defineComponent({
}
return DASH_ENTRY;
},
keydown(e: KeyboardEvent) {
(this.$refs.menuList as typeof MenuList).keydown(e, false);
},
},
components: {
IconLabel,

View File

@ -1,12 +1,20 @@
<template>
<div class="menu-bar-input">
<div class="entry-container">
<div @click="() => visitWebsite('https://graphite.rs')" class="entry">
<button @click="() => visitWebsite('https://graphite.rs')" class="entry">
<IconLabel :icon="'GraphiteLogo'" />
</div>
</button>
</div>
<div class="entry-container" v-for="(entry, index) in entries" :key="index">
<div @click="() => onClick(entry)" class="entry" :class="{ open: entry.ref?.open }" data-hover-menu-spawner>
<div
@click="(e) => onClick(entry, e.target)"
@blur="() => close(entry)"
tabindex="0"
@keydown="entry.ref?.keydown"
class="entry"
:class="{ open: entry.ref?.open }"
data-hover-menu-spawner
>
<IconLabel v-if="entry.icon" :icon="entry.icon" />
<span v-if="entry.label">{{ entry.label }}</span>
</div>
@ -36,6 +44,9 @@
align-items: center;
white-space: nowrap;
padding: 0 8px;
background: none;
border: 0;
margin: 0;
svg {
fill: var(--color-e-nearwhite);
@ -207,10 +218,16 @@ function makeEntries(editor: Editor): MenuListEntries {
export default defineComponent({
inject: ["editor"],
methods: {
onClick(menuEntry: MenuListEntry) {
onClick(menuEntry: MenuListEntry, target: EventTarget | null) {
// Focus the target so that keyboard inputs are sent to the dropdown
(target as HTMLElement)?.focus();
if (menuEntry.ref) menuEntry.ref.isOpen = true;
else throw new Error("The menu bar floating menu has no associated ref");
},
close(menuEntry: MenuListEntry) {
if (menuEntry.ref) menuEntry.ref.isOpen = false;
},
// TODO: Move to backend
visitWebsite(url: string) {
// This method is required because `window` isn't accessible from the Vue component HTML

View File

@ -67,7 +67,7 @@ export function createInputManager(editor: Editor, container: HTMLElement, dialo
// Don't redirect tab or enter if not in canvas (to allow navigating elements)
const inCanvas = e.target instanceof Element && e.target.closest("[data-canvas]");
if (!inCanvas && (key === "tab" || key === "enter")) return false;
if (!inCanvas && ["tab", "enter", " ", "arrowdown", "arrowup", "arrowleft", "arrowright"].includes(key.toLowerCase())) return false;
// Redirect to the backend
return true;