mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-25 08:52:15 +00:00
feat(ui): implement clampFloatingToViewport utility for dynamic dropdown positioning across multiple components
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
meshchatx/src/frontend/js/clampFloatingToViewport.js
Normal file
38
meshchatx/src/frontend/js/clampFloatingToViewport.js
Normal 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 };
|
||||
}
|
||||
90
tests/frontend/clampFloatingToViewport.test.js
Normal file
90
tests/frontend/clampFloatingToViewport.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user