Skip to content

Commit 81c3420

Browse files
0HyperCubeKeavon
andcommitted
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 <[email protected]>
1 parent 8b94c62 commit 81c3420

File tree

12 files changed

+176
-15
lines changed

12 files changed

+176
-15
lines changed

editor/src/document/document_message_handler.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,7 @@ impl DocumentMessageHandler {
780780
selected_index: blend_mode.map(|blend_mode| blend_mode as u32),
781781
disabled: blend_mode.is_none() && !blend_mode_is_mixed,
782782
draw_icon: false,
783+
..Default::default()
783784
})),
784785
WidgetHolder::new(Widget::Separator(Separator {
785786
separator_type: SeparatorType::Related,

editor/src/layout/widgets.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,15 +359,17 @@ pub struct PopoverButton {
359359
pub text: String,
360360
}
361361

362-
#[derive(Clone, Serialize, Deserialize, Derivative, Default)]
363-
#[derivative(Debug, PartialEq)]
362+
#[derive(Clone, Serialize, Deserialize, Derivative)]
363+
#[derivative(Debug, PartialEq, Default)]
364364
pub struct DropdownInput {
365365
pub entries: Vec<Vec<DropdownEntryData>>,
366366
// 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)
367367
#[serde(rename = "selectedIndex")]
368368
pub selected_index: Option<u32>,
369369
#[serde(rename = "drawIcon")]
370370
pub draw_icon: bool,
371+
#[derivative(Default(value = "true"))]
372+
pub interactive: bool,
371373
// `on_update` exists on the `DropdownEntryData`, not this parent `DropdownInput`
372374
pub disabled: bool,
373375
}

frontend/src/App.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,23 @@ img {
190190
}
191191
}
192192
193+
.icon-button,
194+
.text-button,
195+
.popover-button,
196+
.checkbox-input label,
197+
.color-input .swatch .swatch-button,
198+
.dropdown-input .dropdown-box,
199+
.font-input .dropdown-box,
200+
.radio-input button,
201+
.menu-list,
202+
.menu-bar-input .entry {
203+
&:focus-visible,
204+
&.dropdown-box:focus {
205+
outline: 1px dashed var(--color-accent);
206+
outline-offset: -1px;
207+
}
208+
}
209+
193210
// For placeholder messages (remove eventually)
194211
.floating-menu {
195212
h1,

frontend/src/components/floating-menus/DialogModal.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,5 +90,11 @@ export default defineComponent({
9090
this.dialog.dismissDialog();
9191
},
9292
},
93+
mounted() {
94+
// Focus the first button in the popup
95+
const element = this.$el as Element | null;
96+
const emphasizedOrFirstButton = (element?.querySelector("[data-emphasized]") as HTMLButtonElement | null) || element?.querySelector("[data-text-button]");
97+
emphasizedOrFirstButton?.focus();
98+
},
9399
});
94100
</script>

frontend/src/components/floating-menus/FloatingMenu.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export default defineComponent({
193193
windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
194194
scrollableY: { type: Boolean as PropType<boolean>, default: false },
195195
minWidth: { type: Number as PropType<number>, default: 0 },
196+
escapeCloses: { type: Boolean as PropType<boolean>, default: true },
196197
},
197198
data() {
198199
// 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({
374375
window.removeEventListener("pointerup", this.pointerUpHandler);
375376
}
376377
},
378+
keyDownHandler(e: KeyboardEvent) {
379+
if (this.escapeCloses && e.key.toLowerCase() === "escape") {
380+
this.$emit("update:open", false);
381+
}
382+
},
377383
pointerDownHandler(e: PointerEvent) {
378384
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
379385
if (this.isPointerEventOutsideFloatingMenu(e)) {
@@ -423,6 +429,8 @@ export default defineComponent({
423429
if (newState && !oldState) {
424430
// Close floating menu if pointer strays far enough away
425431
window.addEventListener("pointermove", this.pointerMoveHandler);
432+
// Close floating menu if esc is pressed
433+
window.addEventListener("keydown", this.keyDownHandler);
426434
// Close floating menu if pointer is outside (but within stray distance)
427435
window.addEventListener("pointerdown", this.pointerDownHandler);
428436
// 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({
444452
this.containerResizeObserver.disconnect();
445453
446454
window.removeEventListener("pointermove", this.pointerMoveHandler);
455+
window.removeEventListener("keydown", this.keyDownHandler);
447456
window.removeEventListener("pointerdown", this.pointerDownHandler);
448457
// The `pointerup` event is removed in `pointerMoveHandler()` and `pointerDownHandler()`
449458
}

frontend/src/components/floating-menus/MenuList.vue

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
@naturalWidth="(newNaturalWidth: number) => $emit('naturalWidth', newNaturalWidth)"
66
:type="'Dropdown'"
77
:windowEdgeMargin="0"
8+
:escapeCloses="false"
89
v-bind="{ direction, scrollableY, minWidth }"
910
ref="floatingMenu"
1011
data-hover-menu-keep-open
@@ -15,12 +16,12 @@
1516
v-for="(entry, entryIndex) in section"
1617
:key="entryIndex"
1718
class="row"
18-
:class="{ open: isEntryOpen(entry), active: entry.label === activeEntry?.label }"
19+
:class="{ open: isEntryOpen(entry), active: entry.label === highlighted?.label }"
1920
@click="() => onEntryClick(entry)"
2021
@pointerenter="() => onEntryPointerEnter(entry)"
2122
@pointerleave="() => onEntryPointerLeave(entry)"
2223
>
23-
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" class="entry-checkbox" />
24+
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :disableTabIndex="true" class="entry-checkbox" />
2425
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" class="entry-icon" />
2526
<div v-else-if="drawIcon" class="no-icon"></div>
2627

@@ -179,19 +180,22 @@ const MenuList = defineComponent({
179180
direction: { type: String as PropType<MenuDirection>, default: "Bottom" },
180181
minWidth: { type: Number as PropType<number>, default: 0 },
181182
drawIcon: { type: Boolean as PropType<boolean>, default: false },
183+
interactive: { type: Boolean as PropType<boolean>, default: false },
182184
scrollableY: { type: Boolean as PropType<boolean>, default: false },
183185
defaultAction: { type: Function as PropType<() => void>, required: false },
184186
},
185187
data() {
186188
return {
187189
isOpen: this.open,
188190
keyboardLockInfoMessage: this.fullscreen.keyboardLockApiSupported ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
191+
highlighted: this.activeEntry as MenuListEntry | undefined,
189192
};
190193
},
191194
watch: {
192195
// Called only when `open` is changed from outside this component (with v-model)
193196
open(newOpen: boolean) {
194197
this.isOpen = newOpen;
198+
this.highlighted = this.activeEntry;
195199
},
196200
isOpen(newIsOpen: boolean) {
197201
this.$emit("update:open", newIsOpen);
@@ -240,6 +244,88 @@ const MenuList = defineComponent({
240244
241245
return this.open;
242246
},
247+
248+
/// Handles keyboard navigation for the menu. Returns if the entire menu stack should be dismissed
249+
keydown(e: KeyboardEvent, submenu: boolean): boolean {
250+
// Interactive menus should keep the active entry the same as the highlighted one
251+
if (this.interactive) this.highlighted = this.activeEntry;
252+
253+
const menuOpen = this.isOpen;
254+
const flatEntries = this.entries.flat();
255+
const openChild = flatEntries.findIndex((entry) => entry.children?.length && entry.ref?.isOpen);
256+
257+
const openSubmenu = (highlighted: MenuListEntry<string>): void => {
258+
if (highlighted.ref && highlighted.children?.length) {
259+
highlighted.ref.isOpen = true;
260+
261+
// Highlight first item
262+
highlighted.ref.setHighlighted(highlighted.children[0][0]);
263+
}
264+
};
265+
266+
if (!menuOpen && (e.key === " " || e.key === "Enter")) {
267+
// Allow opening menu with space or enter
268+
this.isOpen = true;
269+
this.highlighted = this.activeEntry;
270+
} else if (menuOpen && openChild >= 0) {
271+
// Redirect the keyboard navigation to a submenu if one is open
272+
const shouldCloseStack = flatEntries[openChild].ref?.keydown(e, true);
273+
274+
// Highlight the menu item in the parent list that corresponds with the open submenu
275+
if (e.key !== "Escape" && this.highlighted) this.setHighlighted(flatEntries[openChild]);
276+
277+
// Handle the child closing the entire menu stack
278+
if (shouldCloseStack) {
279+
this.isOpen = false;
280+
return true;
281+
}
282+
} else if ((menuOpen || this.interactive) && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
283+
// Navigate to the next and previous entries with arrow keys
284+
285+
let newIndex = e.key === "ArrowUp" ? flatEntries.length - 1 : 0;
286+
if (this.highlighted) {
287+
const index = this.highlighted ? flatEntries.map((entry) => entry.label).indexOf(this.highlighted.label) : 0;
288+
newIndex = index + (e.key === "ArrowUp" ? -1 : 1);
289+
290+
// Interactive dropdowns should lock at the end whereas other dropdowns should loop
291+
if (this.interactive) newIndex = Math.min(flatEntries.length - 1, Math.max(0, newIndex));
292+
else newIndex = (newIndex + flatEntries.length) % flatEntries.length;
293+
}
294+
295+
const newEntry = flatEntries[newIndex];
296+
this.setHighlighted(newEntry);
297+
} else if (menuOpen && e.key === "Escape") {
298+
// Close menu with escape key
299+
this.isOpen = false;
300+
301+
// Reset active to before open
302+
this.setHighlighted(this.activeEntry);
303+
} else if (menuOpen && this.highlighted && e.key === "Enter") {
304+
// Handle clicking on an option if enter is pressed
305+
if (!this.highlighted.children?.length) this.onEntryClick(this.highlighted);
306+
else openSubmenu(this.highlighted);
307+
308+
// Stop the event from triggering a press on a new dialog
309+
e.preventDefault();
310+
311+
// Enter should close the entire menu stack
312+
return true;
313+
} else if (menuOpen && this.highlighted && e.key === "ArrowRight") {
314+
// Right arrow opens a submenu
315+
openSubmenu(this.highlighted);
316+
} else if (menuOpen && e.key === "ArrowLeft") {
317+
// Left arrow closes a submenu
318+
if (submenu) this.isOpen = false;
319+
}
320+
321+
// By default, keep the menu stack open
322+
return false;
323+
},
324+
setHighlighted(newHighlight: MenuListEntry<string> | undefined) {
325+
this.highlighted = newHighlight;
326+
// Interactive menus should keep the active entry the same as the highlighted one
327+
if (this.interactive && newHighlight?.value !== this.activeEntry?.value) this.$emit("update:activeEntry", newHighlight);
328+
},
243329
},
244330
computed: {
245331
entriesWithoutRefs(): MenuListEntryData[][] {

frontend/src/components/widgets/buttons/TextButton.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
:class="{ emphasized, disabled }"
55
:data-emphasized="emphasized || null"
66
:data-disabled="disabled || null"
7+
data-text-button
78
:style="minWidth > 0 ? `min-width: ${minWidth}px` : ''"
89
@click="(e: MouseEvent) => action(e)"
910
>

frontend/src/components/widgets/groups/WidgetSection.vue

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<!-- TODO: Implement collapsable sections with properties system -->
22
<template>
33
<LayoutCol class="widget-section">
4-
<LayoutRow class="header" @click.stop="() => (expanded = !expanded)">
4+
<button class="header" @click.stop="() => (expanded = !expanded)">
55
<div class="expand-arrow" :class="{ expanded }"></div>
66
<Separator :type="'Related'" />
77
<TextLabel :bold="true">{{ widgetData.name }}</TextLabel>
8-
</LayoutRow>
8+
</button>
99
<LayoutCol class="body" v-if="expanded">
1010
<component :is="layoutRowType(layoutRow)" :widgetData="layoutRow" :layoutTarget="layoutTarget" v-for="(layoutRow, index) in widgetData.layout" :key="index"></component>
1111
</LayoutCol>
@@ -17,11 +17,14 @@
1717
flex: 0 0 auto;
1818
1919
.header {
20+
display: flex;
2021
flex: 0 0 24px;
21-
background: var(--color-4-dimgray);
22-
align-items: center;
22+
border: 0;
23+
text-align: left;
2324
padding: 0 8px;
2425
margin: 0 -4px;
26+
background: var(--color-4-dimgray);
27+
align-items: center;
2528
2629
.expand-arrow {
2730
width: 6px;

frontend/src/components/widgets/inputs/CheckboxInput.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
22
<LayoutRow class="checkbox-input" :class="{ 'outline-style': outlineStyle }">
33
<input type="checkbox" :id="`checkbox-input-${id}`" :checked="checked" @change="(e) => $emit('update:checked', (e.target as HTMLInputElement).checked)" />
4-
<label :for="`checkbox-input-${id}`">
4+
<label :for="`checkbox-input-${id}`" :tabindex="disableTabIndex ? -1 : 0" @keydown.enter="(e) => ((e.target as HTMLElement).previousSibling as HTMLInputElement).click()">
55
<LayoutRow class="checkbox-box">
66
<IconLabel :icon="icon" />
77
</LayoutRow>
@@ -21,6 +21,8 @@
2121
label {
2222
display: flex;
2323
height: 16px;
24+
// Provides rounded corners for the :focus outline
25+
border-radius: 2px;
2426
2527
.checkbox-box {
2628
flex: 0 0 auto;
@@ -105,6 +107,7 @@ export default defineComponent({
105107
checked: { type: Boolean as PropType<boolean>, default: false },
106108
icon: { type: String as PropType<IconName>, default: "Checkmark" },
107109
outlineStyle: { type: Boolean as PropType<boolean>, default: false },
110+
disableTabIndex: { type: Boolean as PropType<boolean>, default: false },
108111
},
109112
components: {
110113
IconLabel,

frontend/src/components/widgets/inputs/DropdownInput.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
<template>
22
<LayoutRow class="dropdown-input">
3-
<LayoutRow class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => !disabled && (open = true)" ref="dropdownBox" data-hover-menu-spawner>
3+
<LayoutRow
4+
class="dropdown-box"
5+
:class="{ disabled }"
6+
:style="{ minWidth: `${minWidth}px` }"
7+
tabindex="0"
8+
@click="() => !disabled && (open = true)"
9+
@blur="() => (open = false)"
10+
@keydown="(e) => keydown(e)"
11+
ref="dropdownBox"
12+
data-hover-menu-spawner
13+
>
414
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" />
515
<span>{{ activeEntry.label }}</span>
616
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
@@ -11,8 +21,10 @@
1121
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
1222
:entries="entries"
1323
:drawIcon="drawIcon"
24+
:interactive="interactive"
1425
:direction="'Bottom'"
1526
:scrollableY="true"
27+
ref="menuList"
1628
/>
1729
</LayoutRow>
1830
</template>
@@ -99,6 +111,7 @@ export default defineComponent({
99111
entries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
100112
selectedIndex: { type: Number as PropType<number>, required: false }, // When not provided, a dash is displayed
101113
drawIcon: { type: Boolean as PropType<boolean>, default: false },
114+
interactive: { type: Boolean as PropType<boolean>, default: true },
102115
disabled: { type: Boolean as PropType<boolean>, default: false },
103116
},
104117
data() {
@@ -129,6 +142,9 @@ export default defineComponent({
129142
}
130143
return DASH_ENTRY;
131144
},
145+
keydown(e: KeyboardEvent) {
146+
(this.$refs.menuList as typeof MenuList).keydown(e, false);
147+
},
132148
},
133149
components: {
134150
IconLabel,

0 commit comments

Comments
 (0)