Move scrollable behavior into LayoutRow/LayoutCol

This commit is contained in:
Keavon Chambers
2022-01-22 23:02:59 -08:00
parent 3bf5023ef8
commit 45d75bd13f
11 changed files with 153 additions and 147 deletions
@@ -2,14 +2,14 @@
<div class="dialog-modal">
<FloatingMenu :type="'Dialog'" :direction="'Center'">
<LayoutRow>
<LayoutCol :class="'icon-column'">
<LayoutCol class="icon-column">
<!-- `dialog.state.icon` class exists to provide special sizing in CSS to specific icons -->
<IconLabel :icon="dialog.state.icon" :class="dialog.state.icon.toLowerCase()" />
</LayoutCol>
<LayoutCol :class="'main-column'">
<TextLabel :bold="true" :class="'heading'">{{ dialog.state.heading }}</TextLabel>
<TextLabel :class="'details'">{{ dialog.state.details }}</TextLabel>
<LayoutRow :class="'buttons-row'" v-if="dialog.state.buttons.length > 0">
<LayoutCol class="main-column">
<TextLabel :bold="true" class="heading">{{ dialog.state.heading }}</TextLabel>
<TextLabel class="details">{{ dialog.state.details }}</TextLabel>
<LayoutRow class="buttons-row" v-if="dialog.state.buttons.length > 0">
<TextButton v-for="(button, index) in dialog.state.buttons" :key="index" :title="button.tooltip" :action="() => button.callback && button.callback()" v-bind="button.props" />
</LayoutRow>
</LayoutCol>
@@ -2,9 +2,9 @@
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open || type === 'Dialog'" ref="floatingMenu">
<div class="tail" v-if="type === 'Popover'"></div>
<div class="floating-menu-container" ref="floatingMenuContainer">
<div class="floating-menu-content" :class="{ 'scrollable-y': scrollable }" ref="floatingMenuContent" :style="floatingMenuContentStyle">
<LayoutCol class="floating-menu-content" :scrollableY="scrollableY" ref="floatingMenuContent" :style="floatingMenuContentStyle">
<slot></slot>
</div>
</LayoutCol>
</div>
</div>
</template>
@@ -179,6 +179,8 @@
<script lang="ts">
import { defineComponent, PropType, StyleValue } from "vue";
import LayoutCol from "@/components/layout/LayoutCol.vue";
export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
export type MenuType = "Popover" | "Dropdown" | "Dialog";
@@ -190,14 +192,13 @@ export default defineComponent({
type: { type: String as PropType<MenuType>, required: true },
windowEdgeMargin: { type: Number as PropType<number>, default: 6 },
minWidth: { type: Number as PropType<number>, default: 0 },
scrollable: { type: Boolean as PropType<boolean>, default: false },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
},
data() {
const containerResizeObserver = new ResizeObserver((entries) => {
const content = entries[0].target.querySelector(".floating-menu-content") as HTMLElement;
content.style.minWidth = `${entries[0].contentRect.width}px`;
});
return {
open: false,
pointerStillDown: false,
@@ -206,9 +207,11 @@ export default defineComponent({
},
updated() {
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
const floatingMenuContentComponent = this.$refs.floatingMenuContent as typeof LayoutCol;
const floatingMenuContent = floatingMenuContentComponent && (floatingMenuContentComponent.$el as HTMLElement);
const workspace = document.querySelector(".workspace-row");
if (!floatingMenuContainer || !floatingMenuContent || !workspace) return;
if (!floatingMenuContainer || !floatingMenuContentComponent || !floatingMenuContent || !workspace) return;
const workspaceBounds = workspace.getBoundingClientRect();
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
@@ -224,13 +227,11 @@ export default defineComponent({
floatingMenuContent.style.left = `${this.windowEdgeMargin}px`;
if (workspaceBounds.left + floatingMenuContainer.getBoundingClientRect().left === 12) zeroedBorderDirection2 = "Left";
}
if (floatingMenuBounds.right + this.windowEdgeMargin >= workspaceBounds.right) {
floatingMenuContent.style.right = `${this.windowEdgeMargin}px`;
if (workspaceBounds.right - floatingMenuContainer.getBoundingClientRect().right === 12) zeroedBorderDirection2 = "Right";
}
}
if (this.direction === "Left" || this.direction === "Right") {
zeroedBorderDirection2 = this.direction === "Left" ? "Right" : "Left";
@@ -238,7 +239,6 @@ export default defineComponent({
floatingMenuContent.style.top = `${this.windowEdgeMargin}px`;
if (workspaceBounds.top + floatingMenuContainer.getBoundingClientRect().top === 12) zeroedBorderDirection1 = "Top";
}
if (floatingMenuBounds.bottom + this.windowEdgeMargin >= workspaceBounds.bottom) {
floatingMenuContent.style.bottom = `${this.windowEdgeMargin}px`;
if (workspaceBounds.bottom - floatingMenuContainer.getBoundingClientRect().bottom === 12) zeroedBorderDirection1 = "Bottom";
@@ -277,23 +277,21 @@ export default defineComponent({
},
getWidth(callback: (width: number) => void) {
this.$nextTick(() => {
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
const floatingMenuContent = (this.$refs.floatingMenuContent as typeof LayoutCol).$el as HTMLElement;
const width = floatingMenuContent.clientWidth;
callback(width);
});
},
disableMinWidth(callback: (minWidth: string) => void) {
this.$nextTick(() => {
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
const floatingMenuContent = (this.$refs.floatingMenuContent as typeof LayoutCol).$el as HTMLElement;
const initialMinWidth = floatingMenuContent.style.minWidth;
floatingMenuContent.style.minWidth = "0";
callback(initialMinWidth);
});
},
enableMinWidth(minWidth: string) {
const floatingMenuContent = this.$refs.floatingMenuContent as HTMLElement;
const floatingMenuContent = (this.$refs.floatingMenuContent as typeof LayoutCol).$el as HTMLElement;
floatingMenuContent.style.minWidth = minWidth;
},
pointerMoveHandler(e: PointerEvent) {
@@ -303,22 +301,18 @@ export default defineComponent({
// TODO: Simplify the following expression when optional chaining is supported by the build system
const pointerOverOwnFloatingMenuSpawner =
pointerOverFloatingMenuSpawner && pointerOverFloatingMenuSpawner.parentElement && pointerOverFloatingMenuSpawner.parentElement.contains(this.$refs.floatingMenu as HTMLElement);
// Swap this open floating menu with the one created by the floating menu spawner being hovered over
if (pointerOverFloatingMenuSpawner && !pointerOverOwnFloatingMenuSpawner) {
this.setClosed();
pointerOverFloatingMenuSpawner.click();
}
// Close the floating menu if the pointer has strayed far enough from its bounds
if (this.isPointerEventOutsideFloatingMenu(e, POINTER_STRAY_DISTANCE) && !pointerOverOwnFloatingMenuSpawner && !pointerOverFloatingMenuKeepOpen) {
// TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently
// submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu
this.setClosed();
}
const eventIncludesLmb = Boolean(e.buttons & 1);
// Clean up any messes from lost pointerup events
if (!this.open && !eventIncludesLmb) {
this.pointerStillDown = false;
@@ -329,7 +323,6 @@ export default defineComponent({
// Close the floating menu if the pointer clicked outside the floating menu (but within stray distance)
if (this.isPointerEventOutsideFloatingMenu(e)) {
this.setClosed();
// Track if the left pointer button is now down so its later click event can be canceled
const eventIsForLmb = e.button === 0;
if (eventIsForLmb) this.pointerStillDown = true;
@@ -337,12 +330,10 @@ export default defineComponent({
},
pointerUpHandler(e: PointerEvent) {
const eventIsForLmb = e.button === 0;
if (this.pointerStillDown && eventIsForLmb) {
// Clean up self
this.pointerStillDown = false;
window.removeEventListener("pointerup", this.pointerUpHandler);
// Prevent the click event from firing, which would normally occur right after this pointerup event
window.addEventListener("click", this.clickHandlerCapture, true);
}
@@ -350,7 +341,6 @@ export default defineComponent({
clickHandlerCapture(e: MouseEvent) {
// Stop the click event from reopening this floating menu if the click event targets the floating menu's button
e.stopPropagation();
// Clean up self
window.removeEventListener("click", this.clickHandlerCapture, true);
},
@@ -374,13 +364,10 @@ export default defineComponent({
if (newState && !oldState) {
// Close floating menu if pointer strays far enough away
window.addEventListener("pointermove", this.pointerMoveHandler);
// 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
window.addEventListener("pointerup", this.pointerUpHandler);
// Floating menu min-width resize observer
this.$nextTick(() => {
const floatingMenuContainer = this.$refs.floatingMenuContainer as HTMLElement;
@@ -390,12 +377,10 @@ export default defineComponent({
}
});
}
// Switching from open to closed
if (!newState && oldState) {
window.removeEventListener("pointermove", this.pointerMoveHandler);
window.removeEventListener("pointerdown", this.pointerDownHandler);
this.containerResizeObserver.disconnect();
}
},
@@ -407,5 +392,6 @@ export default defineComponent({
};
},
},
components: { LayoutCol },
});
</script>
@@ -1,5 +1,5 @@
<template>
<FloatingMenu :class="'menu-list'" :direction="direction" :type="'Dropdown'" ref="floatingMenu" :windowEdgeMargin="0" :scrollable="scrollable" data-hover-menu-keep-open>
<FloatingMenu class="menu-list" :direction="direction" :type="'Dropdown'" ref="floatingMenu" :windowEdgeMargin="0" :scrollableY="scrollableY" data-hover-menu-keep-open>
<template v-for="(section, sectionIndex) in menuEntries" :key="sectionIndex">
<Separator :type="'List'" :direction="'Vertical'" v-if="sectionIndex > 0" />
<div
@@ -12,8 +12,8 @@
@pointerleave="handleEntryPointerLeave(entry)"
:data-hover-menu-spawner-extend="entry.children && []"
>
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :class="'entry-checkbox'" />
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" :class="'entry-icon'" />
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="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>
<span class="entry-label">{{ entry.label }}</span>
@@ -28,7 +28,7 @@
v-if="entry.children"
:direction="'TopRight'"
:menuEntries="entry.children"
v-bind="{ defaultAction, minWidth, drawIcon, scrollable }"
v-bind="{ defaultAction, minWidth, drawIcon, scrollableY }"
:ref="(ref: any) => setEntryRefs(entry, ref)"
/>
</div>
@@ -168,7 +168,7 @@ const MenuList = defineComponent({
defaultAction: { type: Function as PropType<() => void>, required: false },
minWidth: { type: Number as PropType<number>, default: 0 },
drawIcon: { type: Boolean as PropType<boolean>, default: false },
scrollable: { type: Boolean as PropType<boolean>, default: false },
scrollableY: { type: Boolean as PropType<boolean>, default: false },
},
methods: {
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu) {
@@ -1,9 +1,9 @@
<template>
<div class="dropdown-input">
<div class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px` }" @click="() => clickDropdownBox()" data-hover-menu-spawner>
<IconLabel :class="'dropdown-icon'" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<IconLabel class="dropdown-icon" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<span>{{ activeEntry.label }}</span>
<IconLabel :class="'dropdown-arrow'" :icon="'DropdownArrow'" />
<IconLabel class="dropdown-arrow" :icon="'DropdownArrow'" />
</div>
<MenuList
v-model:activeEntry="activeEntry"
@@ -12,7 +12,7 @@
:menuEntries="menuEntries"
:direction="'Bottom'"
:drawIcon="drawIcon"
:scrollable="true"
:scrollableY="true"
ref="menuList"
/>
</div>