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:
parent
8b94c62697
commit
81c3420470
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[][] {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue