feat(ui): implement clampFloatingToViewport utility for dynamic dropdown positioning across multiple components

This commit is contained in:
Ivan
2026-04-22 19:28:50 -05:00
parent c6768bc4ae
commit 9ec2a88817
8 changed files with 318 additions and 32 deletions

View File

@@ -21,11 +21,9 @@
>
<div
v-if="isShowingMenu && dropdownPosition"
class="overflow-hidden fixed z-[200] w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
:style="{
left: dropdownPosition.x + 'px',
top: dropdownPosition.y + 'px',
}"
ref="dropdownPanel"
class="overflow-x-hidden fixed z-[200] w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
:style="dropdownPanelStyle"
@click.stop="hideMenu"
>
<slot name="items" />
@@ -36,6 +34,8 @@
</template>
<script>
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
export default {
name: "DropDownMenu",
data() {
@@ -44,6 +44,24 @@ export default {
dropdownPosition: null,
};
},
computed: {
dropdownPanelStyle() {
if (!this.dropdownPosition) {
return {};
}
const style = {
left: `${this.dropdownPosition.x}px`,
top: `${this.dropdownPosition.y}px`,
};
if (this.dropdownPosition.maxHeight != null) {
style.maxHeight = `${this.dropdownPosition.maxHeight}px`;
style.overflowY = "auto";
} else {
style.overflow = "hidden";
}
return style;
},
},
methods: {
toggleMenu() {
if (this.isShowingMenu) {
@@ -71,24 +89,22 @@ export default {
if (!button) return;
const buttonRect = button.getBoundingClientRect();
const estimatedHeight = 200;
const spaceBelow = window.innerHeight - buttonRect.bottom;
const spaceAbove = buttonRect.top;
const menuWidth = 224;
let x = buttonRect.right - menuWidth;
x = Math.min(Math.max(8, x), window.innerWidth - menuWidth - 8);
let x = buttonRect.right - 224;
if (x < 8) x = 8;
if (x + 224 > window.innerWidth) x = window.innerWidth - 224 - 8;
const spaceBelow = window.innerHeight - buttonRect.bottom - 4;
const spaceAbove = buttonRect.top - 8;
let y = spaceBelow >= spaceAbove ? buttonRect.bottom + 4 : Math.max(8, buttonRect.top - 200 - 4);
let y;
if (spaceBelow >= estimatedHeight || spaceBelow >= spaceAbove) {
y = buttonRect.bottom + 4;
} else {
y = buttonRect.top - estimatedHeight - 4;
}
if (y < 8) y = 8;
if (y + estimatedHeight > window.innerHeight - 8) y = window.innerHeight - estimatedHeight - 8;
this.dropdownPosition = { x, y };
this.dropdownPosition = { x, y, maxHeight: null };
this.$nextTick(() => {
const panel = this.$refs.dropdownPanel;
if (!panel) return;
const rect = panel.getBoundingClientRect();
const { left, top, maxHeight } = clampFloatingToViewport(rect.left, rect.top, rect.width, rect.height);
this.dropdownPosition = { x: left, y: top, maxHeight };
});
});
},
},

View File

@@ -14,8 +14,9 @@
<Teleport to="body">
<div
v-if="isDropdownOpen"
ref="languageDropdown"
v-click-outside="closeDropdown"
class="fixed w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
class="fixed w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-x-hidden"
:style="dropdownStyle"
>
<div class="p-2">
@@ -42,6 +43,7 @@
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
const localeModules = import.meta.glob("../locales/*.json", { eager: true });
const discoveredLanguages = Object.entries(localeModules)
@@ -80,6 +82,7 @@ export default {
return {
isDropdownOpen: false,
dropdownPosition: { top: 0, left: 0 },
dropdownMaxHeight: null,
};
},
computed: {
@@ -90,10 +93,17 @@ export default {
return discoveredLanguages;
},
dropdownStyle() {
return {
const style = {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
if (this.dropdownMaxHeight != null) {
style.maxHeight = `${this.dropdownMaxHeight}px`;
style.overflowY = "auto";
} else {
style.overflow = "hidden";
}
return style;
},
},
methods: {
@@ -106,10 +116,19 @@ export default {
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
this.dropdownMaxHeight = null;
this.dropdownPosition = {
top: rect.bottom + 8,
left: Math.max(8, rect.right - 192), // 192px is w-48
left: Math.max(8, rect.right - 192),
};
this.$nextTick(() => {
const panel = this.$refs.languageDropdown;
if (!panel) return;
const pr = panel.getBoundingClientRect();
const { left, top, maxHeight } = clampFloatingToViewport(pr.left, pr.top, pr.width, pr.height);
this.dropdownPosition = { left, top };
this.dropdownMaxHeight = maxHeight;
});
},
closeDropdown() {
this.isDropdownOpen = false;

View File

@@ -19,8 +19,9 @@
<Teleport to="body">
<div
v-if="isDropdownOpen"
ref="notificationDropdown"
v-click-outside="closeDropdown"
class="fixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
class="fixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[min(500px,calc(100vh-2rem))] overflow-hidden flex flex-col"
:style="dropdownStyle"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
@@ -147,6 +148,7 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Utils from "../js/Utils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
export default {
name: "NotificationBell",
@@ -177,15 +179,20 @@ export default {
unreadCount: 0,
reloadInterval: null,
dropdownPosition: { top: 0, left: 0 },
dropdownMaxHeight: null,
showHistory: false,
};
},
computed: {
dropdownStyle() {
return {
const style = {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
if (this.dropdownMaxHeight != null) {
style.maxHeight = `${this.dropdownMaxHeight}px`;
}
return style;
},
},
beforeUnmount() {
@@ -224,18 +231,30 @@ export default {
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
await this.$nextTick();
this.clampNotificationDropdown();
}
},
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const isMobile = window.innerWidth < 640;
const dropdownWidth = isMobile ? 320 : 384; // 80 (320px) or 96 (384px)
const dropdownWidth = isMobile ? 320 : 384;
this.dropdownMaxHeight = null;
this.dropdownPosition = {
top: rect.bottom + 8,
left: Math.max(16, rect.right - dropdownWidth),
};
this.$nextTick(() => this.clampNotificationDropdown());
},
clampNotificationDropdown() {
const panel = this.$refs.notificationDropdown;
if (!panel || !this.isDropdownOpen) return;
const pr = panel.getBoundingClientRect();
const { left, top, maxHeight } = clampFloatingToViewport(pr.left, pr.top, pr.width, pr.height);
this.dropdownPosition = { top, left };
this.dropdownMaxHeight = maxHeight;
},
closeDropdown() {
this.isDropdownOpen = false;
@@ -251,6 +270,10 @@ export default {
await this.loadNotifications({ updateList: false });
}
}
if (this.isDropdownOpen) {
await this.$nextTick();
this.clampNotificationDropdown();
}
},
async loadNotifications(options = {}) {
const updateList = options.updateList !== false;

View File

@@ -3,9 +3,10 @@
<template>
<div
v-if="show"
ref="panel"
class="context-menu-panel"
:class="panelClass"
:style="{ top: y + 'px', left: x + 'px' }"
:style="panelStyle"
v-bind="$attrs"
>
<slot name="header" />
@@ -14,6 +15,8 @@
</template>
<script>
import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
export default {
name: "ContextMenuPanel",
inheritAttrs: false,
@@ -35,5 +38,90 @@ export default {
default: "",
},
},
data() {
return {
adjustedLeft: 0,
adjustedTop: 0,
panelMaxHeight: null,
repositionRaf: null,
};
},
computed: {
panelStyle() {
const style = {
top: `${this.adjustedTop}px`,
left: `${this.adjustedLeft}px`,
};
if (this.panelMaxHeight != null) {
style.maxHeight = `${this.panelMaxHeight}px`;
style.overflowY = "auto";
}
return style;
},
},
watch: {
show: {
immediate: true,
handler(visible) {
this.cancelReposition();
if (!visible) {
this.panelMaxHeight = null;
return;
}
this.adjustedLeft = this.x;
this.adjustedTop = this.y;
this.panelMaxHeight = null;
this.scheduleReposition();
},
},
x() {
if (this.show) {
this.scheduleReposition();
}
},
y() {
if (this.show) {
this.scheduleReposition();
}
},
},
mounted() {
window.addEventListener("resize", this.onWindowResize);
},
beforeUnmount() {
window.removeEventListener("resize", this.onWindowResize);
this.cancelReposition();
},
methods: {
onWindowResize() {
if (this.show) {
this.repositionToViewport();
}
},
cancelReposition() {
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
},
scheduleReposition() {
this.cancelReposition();
this.repositionRaf = requestAnimationFrame(() => {
this.repositionRaf = null;
this.$nextTick(() => this.repositionToViewport());
});
},
repositionToViewport() {
const el = this.$refs.panel;
if (!el || !this.show) {
return;
}
const rect = el.getBoundingClientRect();
const { left, top, maxHeight } = clampFloatingToViewport(this.x, this.y, rect.width, rect.height);
this.adjustedLeft = left;
this.adjustedTop = top;
this.panelMaxHeight = maxHeight;
},
},
};
</script>

View File

@@ -1616,6 +1616,7 @@
<script>
import Utils from "../../js/Utils";
import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js";
import {
isTelemetryOnly as isTelemetryOnlyMessage,
@@ -3332,10 +3333,13 @@ export default {
const mv = me.touches ? me.touches[0] : me;
const dx = mv.clientX - this.reactionDragState.startX;
const dy = mv.clientY - this.reactionDragState.startY;
this.reactionPickerPos = {
x: this.reactionDragState.originX + dx,
y: this.reactionDragState.originY + dy,
};
const panelEl = this.$refs.reactionPickerPanel;
if (!panelEl) return;
const pr = panelEl.getBoundingClientRect();
const nx = this.reactionDragState.originX + dx;
const ny = this.reactionDragState.originY + dy;
const { left, top } = clampFloatingToViewport(nx, ny, pr.width, pr.height);
this.reactionPickerPos = { x: left, y: top };
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);

View File

@@ -0,0 +1,38 @@
// SPDX-License-Identifier: 0BSD
/**
* Clamp top-left coordinates for a fixed-position panel so it stays on-screen.
*
* @param {number} preferredLeft
* @param {number} preferredTop
* @param {number} width
* @param {number} height
* @param {{ margin?: number }} [options]
* @returns {{ left: number, top: number, maxHeight: number | null }}
*/
export function clampFloatingToViewport(preferredLeft, preferredTop, width, height, options = {}) {
const margin = options.margin ?? 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
const maxW = Math.max(0, vw - 2 * margin);
const maxH = Math.max(0, vh - 2 * margin);
let left = preferredLeft;
let top = preferredTop;
if (width <= maxW) {
left = Math.min(Math.max(margin, left), vw - width - margin);
} else {
left = margin;
}
let maxHeight = null;
if (height <= maxH) {
top = Math.min(Math.max(margin, top), vh - height - margin);
} else {
top = margin;
maxHeight = maxH;
}
return { left, top, maxHeight };
}

View File

@@ -0,0 +1,90 @@
import { readFileSync } from "fs";
import { join } from "path";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { clampFloatingToViewport } from "../../meshchatx/src/frontend/js/clampFloatingToViewport.js";
function readSource(relativePath) {
return readFileSync(join(process.cwd(), relativePath), "utf8");
}
describe("clampFloatingToViewport", () => {
let innerWidth;
let innerHeight;
beforeEach(() => {
innerWidth = 800;
innerHeight = 600;
Object.defineProperty(window, "innerWidth", {
configurable: true,
get: () => innerWidth,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
get: () => innerHeight,
});
});
it("returns preferred position when panel fits inside the viewport", () => {
const { left, top, maxHeight } = clampFloatingToViewport(100, 120, 200, 150);
expect(left).toBe(100);
expect(top).toBe(120);
expect(maxHeight).toBeNull();
});
it("shifts left when the panel would extend past the right edge", () => {
const w = 200;
const { left, top, maxHeight } = clampFloatingToViewport(700, 50, w, 100);
expect(left).toBe(800 - w - 8);
expect(top).toBe(50);
expect(maxHeight).toBeNull();
});
it("shifts up when the panel would extend past the bottom edge", () => {
const h = 200;
const { left, top, maxHeight } = clampFloatingToViewport(40, 520, 160, h);
expect(left).toBe(40);
expect(top).toBe(600 - h - 8);
expect(maxHeight).toBeNull();
});
it("uses custom margin", () => {
const w = 100;
const { left } = clampFloatingToViewport(790, 10, w, 50, { margin: 16 });
expect(left).toBe(800 - w - 16);
});
it("returns maxHeight when content is taller than the viewport allows", () => {
innerHeight = 400;
Object.defineProperty(window, "innerHeight", {
configurable: true,
get: () => innerHeight,
});
const { left, top, maxHeight } = clampFloatingToViewport(10, 10, 200, 900);
expect(left).toBe(10);
expect(top).toBe(8);
expect(maxHeight).toBe(400 - 16);
});
it("pins wide panels to the left margin when wider than usable width", () => {
innerWidth = 300;
Object.defineProperty(window, "innerWidth", {
configurable: true,
get: () => innerWidth,
});
const { left } = clampFloatingToViewport(50, 10, 500, 40);
expect(left).toBe(8);
});
});
describe("clampFloatingToViewport wiring", () => {
it.each([
["DropDownMenu.vue", "meshchatx/src/frontend/components/DropDownMenu.vue", 'ref="dropdownPanel"'],
["LanguageSelector.vue", "meshchatx/src/frontend/components/LanguageSelector.vue", 'ref="languageDropdown"'],
["NotificationBell.vue", "meshchatx/src/frontend/components/NotificationBell.vue", 'ref="notificationDropdown"'],
["ConversationViewer.vue", "meshchatx/src/frontend/components/messages/ConversationViewer.vue", "onReactionPickerDragStart"],
])("%s imports the helper and clamps floating UI", (_, relativePath, anchor) => {
const src = readSource(relativePath);
expect(src).toContain("clampFloatingToViewport");
expect(src).toContain(anchor);
});
});

View File

@@ -27,6 +27,14 @@ describe("context menu styling", () => {
expect(item).toContain("context-item");
});
it("ContextMenuPanel clamps position to the viewport via clampFloatingToViewport", () => {
const panel = readProjectFile("meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue");
expect(panel).toContain("clampFloatingToViewport");
expect(panel).toContain('ref="panel"');
expect(panel).toContain("repositionToViewport");
expect(panel).toContain("resize");
});
it("uses ContextMenuPanel and ContextMenuItem on all right-click context menus", () => {
const files = [
"meshchatx/src/frontend/components/contacts/ContactsPage.vue",