Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions editor/src/document/document_message_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions editor/src/layout/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,15 +359,17 @@ 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)
#[serde(rename = "selectedIndex")]
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,
}
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/floating-menus/DialogModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
9 changes: 9 additions & 0 deletions frontend/src/components/floating-menus/FloatingMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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
Expand All @@ -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()`
}
Expand Down
90 changes: 88 additions & 2 deletions frontend/src/components/floating-menus/MenuList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>

Expand Down Expand Up @@ -179,19 +180,22 @@ 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 },
},
data() {
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);
Expand Down Expand Up @@ -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[][] {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/widgets/buttons/TextButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
>
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/components/widgets/groups/WidgetSection.vue
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/widgets/inputs/CheckboxInput.vue
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/components/widgets/inputs/DropdownInput.vue
Original file line number Diff line number Diff line change
@@ -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'" />
Expand All @@ -11,8 +21,10 @@
@naturalWidth="(newNaturalWidth: number) => (minWidth = newNaturalWidth)"
:entries="entries"
:drawIcon="drawIcon"
:interactive="interactive"
:direction="'Bottom'"
:scrollableY="true"
ref="menuList"
/>
</LayoutRow>
</template>
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -129,6 +142,9 @@ export default defineComponent({
}
return DASH_ENTRY;
},
keydown(e: KeyboardEvent) {
(this.$refs.menuList as typeof MenuList).keydown(e, false);
},
},
components: {
IconLabel,
Expand Down
25 changes: 21 additions & 4 deletions frontend/src/components/widgets/inputs/MenuBarInput.vue
Original file line number Diff line number Diff line change
@@ -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>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading