diff --git a/editor/src/document/document_message_handler.rs b/editor/src/document/document_message_handler.rs index 18e26ebb17..7925549e3a 100644 --- a/editor/src/document/document_message_handler.rs +++ b/editor/src/document/document_message_handler.rs @@ -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, diff --git a/editor/src/layout/widgets.rs b/editor/src/layout/widgets.rs index 9a66bf7fad..0999e0d2d4 100644 --- a/editor/src/layout/widgets.rs +++ b/editor/src/layout/widgets.rs @@ -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>, // 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, #[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, } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6f8bdaf6d9..5ba2482389 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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, diff --git a/frontend/src/components/floating-menus/DialogModal.vue b/frontend/src/components/floating-menus/DialogModal.vue index 58a00d1609..3fc8c72d11 100644 --- a/frontend/src/components/floating-menus/DialogModal.vue +++ b/frontend/src/components/floating-menus/DialogModal.vue @@ -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(); + }, }); diff --git a/frontend/src/components/floating-menus/FloatingMenu.vue b/frontend/src/components/floating-menus/FloatingMenu.vue index 98dd0a0174..ba9852296f 100644 --- a/frontend/src/components/floating-menus/FloatingMenu.vue +++ b/frontend/src/components/floating-menus/FloatingMenu.vue @@ -193,6 +193,7 @@ export default defineComponent({ windowEdgeMargin: { type: Number as PropType, default: 6 }, scrollableY: { type: Boolean as PropType, default: false }, minWidth: { type: Number as PropType, default: 0 }, + escapeCloses: { type: Boolean as PropType, 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()` } diff --git a/frontend/src/components/floating-menus/MenuList.vue b/frontend/src/components/floating-menus/MenuList.vue index 94d328ff74..fd125c1440 100644 --- a/frontend/src/components/floating-menus/MenuList.vue +++ b/frontend/src/components/floating-menus/MenuList.vue @@ -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)" > - +
@@ -179,6 +180,7 @@ const MenuList = defineComponent({ direction: { type: String as PropType, default: "Bottom" }, minWidth: { type: Number as PropType, default: 0 }, drawIcon: { type: Boolean as PropType, default: false }, + interactive: { type: Boolean as PropType, default: false }, scrollableY: { type: Boolean as PropType, 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): 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 | 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[][] { diff --git a/frontend/src/components/widgets/buttons/TextButton.vue b/frontend/src/components/widgets/buttons/TextButton.vue index 0d2b07a905..a3e2493024 100644 --- a/frontend/src/components/widgets/buttons/TextButton.vue +++ b/frontend/src/components/widgets/buttons/TextButton.vue @@ -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)" > diff --git a/frontend/src/components/widgets/groups/WidgetSection.vue b/frontend/src/components/widgets/groups/WidgetSection.vue index 2f20fcdc5a..ce46a8b00e 100644 --- a/frontend/src/components/widgets/groups/WidgetSection.vue +++ b/frontend/src/components/widgets/groups/WidgetSection.vue @@ -1,11 +1,11 @@