diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx
index e272a19..8413413 100644
--- a/frontend/src/sidebar/Sidebar.jsx
+++ b/frontend/src/sidebar/Sidebar.jsx
@@ -1,4 +1,5 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useId } from 'react';
+import { createPortal } from 'react-dom';
import {
ChevronIcon,
TrashIcon,
@@ -17,6 +18,7 @@ import {
DesktopIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
+import useFloatingMenu from '../ui/useFloatingMenu';
import { getTagColorStyle } from '../utils/colors';
@@ -282,44 +284,32 @@ const Sidebar = ({
onSearchClear?.();
}, [onSearchClear]);
const tenantButtonRef = useRef(null);
- const tenantMenuRef = useRef(null);
- const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
+ const {
+ isOpen: tenantMenuOpen,
+ toggle: toggleTenantMenuFloating,
+ close: closeTenantMenu,
+ menuRef: tenantMenuRef,
+ menuStyle: tenantMenuStyle,
+ updatePosition: refreshTenantMenuPosition,
+ } = useFloatingMenu({
+ anchorRef: tenantButtonRef,
+ minWidth: 220,
+ offset: 6,
+ });
const showTenantList = tenants.length > 1;
const toggleTenantMenu = useCallback(() => {
- const next = !tenantMenuOpen;
- setTenantMenuOpen(next);
- if (next && tenants.length === 0 && onSelectTenant) {
+ if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) {
onSelectTenant(null, { refreshOnly: true });
}
- }, [tenantMenuOpen, tenants.length, onSelectTenant]);
+ toggleTenantMenuFloating();
+ }, [tenantMenuOpen, tenants.length, onSelectTenant, toggleTenantMenuFloating]);
useEffect(() => {
- if (!tenantMenuOpen) {
- return undefined;
+ if (tenantMenuOpen) {
+ refreshTenantMenuPosition();
}
- const handlePointer = (event) => {
- const menuNode = tenantMenuRef.current;
- const buttonNode = tenantButtonRef.current;
- if (!menuNode) return;
- if (menuNode.contains(event.target)) return;
- if (buttonNode && buttonNode.contains(event.target)) return;
- setTenantMenuOpen(false);
- };
- const handleKeyDown = (event) => {
- if (event.key === 'Escape') {
- setTenantMenuOpen(false);
- }
- };
- document.addEventListener('mousedown', handlePointer);
- document.addEventListener('touchstart', handlePointer);
- document.addEventListener('keydown', handleKeyDown);
- return () => {
- document.removeEventListener('mousedown', handlePointer);
- document.removeEventListener('touchstart', handlePointer);
- document.removeEventListener('keydown', handleKeyDown);
- };
- }, [tenantMenuOpen]);
+ }, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]);
const handleTenantSelect = useCallback(
(tenant) => {
@@ -327,21 +317,21 @@ const Sidebar = ({
if (!targetId) {
return;
}
- setTenantMenuOpen(false);
+ closeTenantMenu();
onSelectTenant?.(tenant);
},
- [onSelectTenant],
+ [closeTenantMenu, onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
- setTenantMenuOpen(false);
+ closeTenantMenu();
onLogout?.();
- }, [onLogout]);
+ }, [closeTenantMenu, onLogout]);
const handleSettingsFromMenu = useCallback(() => {
- setTenantMenuOpen(false);
+ closeTenantMenu();
onOpenSettings?.();
- }, [onOpenSettings]);
+ }, [closeTenantMenu, onOpenSettings]);
const renderNodes = useCallback(
(ids, depth) =>
@@ -452,59 +442,65 @@ const Sidebar = ({
size={16}
/>
- {tenantMenuOpen ? (
-
- {showTenantList ? (
- <>
-
Switch tenant
-
- {tenants.map((tenant) => {
- const tenantId = tenant?.id || null;
- const isActive = tenantId === activeTenantId;
- const tenantLabel = tenant?.name;
- return (
-
- );
- })}
+ {tenantMenuOpen
+ && tenantMenuStyle
+ && typeof document !== 'undefined'
+ ? createPortal(
+
+ {showTenantList ? (
+ <>
+
Switch tenant
+
+ {tenants.map((tenant) => {
+ const tenantId = tenant?.id || null;
+ const isActive = tenantId === activeTenantId;
+ const tenantLabel = tenant?.name;
+ return (
+
+ );
+ })}
+
+ >
+ ) : null}
+
+
+
- >
- ) : null}
-
-
-
-
-
- ) : null}
+
,
+ document.body,
+ )
+ : null}
>
)}
actions={
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index b0fe3f9..4280b69 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -306,10 +306,6 @@ button[disabled] {
cursor: not-allowed;
}
-button:hover:not([disabled]) {
- background: var(--accent-hover);
-}
-
a.button-link {
display: inline-flex;
align-items: center;
@@ -2422,6 +2418,7 @@ button.danger:hover:not([disabled]) {
align-items: center;
gap: 0.5rem;
overflow: hidden;
+ text-wrap: nowrap;
}
.panel-header > .panel-header__title:first-child {
diff --git a/frontend/src/ui/BreadcrumbTrail.jsx b/frontend/src/ui/BreadcrumbTrail.jsx
index 040c596..d1dc229 100644
--- a/frontend/src/ui/BreadcrumbTrail.jsx
+++ b/frontend/src/ui/BreadcrumbTrail.jsx
@@ -1,6 +1,13 @@
-import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import React, {
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { createPortal } from 'react-dom';
import PropTypes from 'prop-types';
+import useFloatingMenu from './useFloatingMenu';
const normalizeEntries = (entries) =>
(Array.isArray(entries) ? entries : [])
@@ -16,23 +23,34 @@ const normalizeEntries = (entries) =>
.filter(Boolean);
const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null };
+const WIDTH_TOLERANCE = 1;
+const WIDTH_BUFFER_RATIO = 0.99;
const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
const normalized = useMemo(() => normalizeEntries(entries), [entries]);
const containerRef = useRef(null);
const measurementRef = useRef(null);
- const ellipsisRef = useRef(null);
- const menuRef = useRef(null);
+ const ellipsisButtonRef = useRef(null);
const [availableWidth, setAvailableWidth] = useState(null);
const [startIndex, setStartIndex] = useState(0);
- const [showHiddenMenu, setShowHiddenMenu] = useState(false);
- const [menuPosition, setMenuPosition] = useState(null);
+
+ const {
+ isOpen: ellipsisMenuOpen,
+ toggle: toggleEllipsisMenu,
+ close: closeEllipsisMenu,
+ menuRef: ellipsisMenuRef,
+ menuStyle: ellipsisMenuStyle,
+ updatePosition: refreshEllipsisMenuPosition,
+ } = useFloatingMenu({
+ anchorRef: ellipsisButtonRef,
+ minWidth: 192,
+ offset: 6,
+ });
useEffect(() => {
setStartIndex(0);
- setShowHiddenMenu(false);
- setMenuPosition(null);
- }, [normalized]);
+ closeEllipsisMenu();
+ }, [normalized, closeEllipsisMenu]);
useEffect(() => {
const container = containerRef.current;
@@ -123,13 +141,13 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
return;
}
- const TOLERANCE = 1;
// Reserve a tiny buffer so the live trail doesn't oscillate when the
- // container width barely fits; shrink the measured allowance a bit.
- const adjustedAvailable = available * 0.98;
+ // container width barely fits; shrink the measured allowance by 1%.
+ const adjustedAvailable = available * WIDTH_BUFFER_RATIO;
+
let nextStart = widths.length - 1;
for (let start = 0; start < widths.length; start += 1) {
- if (widths[start] <= adjustedAvailable + TOLERANCE) {
+ if (widths[start] <= adjustedAvailable + WIDTH_TOLERANCE) {
nextStart = start;
break;
}
@@ -140,119 +158,42 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
}
}, [normalized, separator, availableWidth, startIndex]);
- useEffect(() => {
- if (!showHiddenMenu) {
- return undefined;
- }
-
- if (typeof document === 'undefined') {
- setShowHiddenMenu(false);
- setMenuPosition(null);
- return undefined;
- }
-
- const handleGlobalInteraction = (event) => {
- const ellipsisEl = ellipsisRef.current;
- const menuEl = menuRef.current;
- if ((ellipsisEl && ellipsisEl.contains(event.target))
- || (menuEl && menuEl.contains(event.target))) {
- return;
- }
- setShowHiddenMenu(false);
- setMenuPosition(null);
- };
-
- const handleKey = (event) => {
- if (event.key === 'Escape') {
- setShowHiddenMenu(false);
- setMenuPosition(null);
- }
- };
-
- document.addEventListener('mousedown', handleGlobalInteraction);
- document.addEventListener('touchstart', handleGlobalInteraction, { passive: true });
- document.addEventListener('focusin', handleGlobalInteraction);
- document.addEventListener('keydown', handleKey);
-
- return () => {
- document.removeEventListener('mousedown', handleGlobalInteraction);
- document.removeEventListener('touchstart', handleGlobalInteraction);
- document.removeEventListener('focusin', handleGlobalInteraction);
- document.removeEventListener('keydown', handleKey);
- };
- }, [showHiddenMenu]);
-
- useEffect(() => {
- if (!showHiddenMenu) {
- return undefined;
- }
-
- if (typeof window === 'undefined') {
- setShowHiddenMenu(false);
- setMenuPosition(null);
- return undefined;
- }
-
- const handleWindowChange = () => {
- if (!ellipsisRef.current) {
- setShowHiddenMenu(false);
- setMenuPosition(null);
- return;
- }
-
- const rect = ellipsisRef.current.getBoundingClientRect();
- const menuWidth = 192;
- const viewportWidth = window.innerWidth;
- const left = Math.min(Math.max(rect.left, 8), Math.max(viewportWidth - menuWidth - 8, 8));
- setMenuPosition({
- top: rect.bottom + 6,
- left,
- minWidth: menuWidth,
- });
- };
-
- handleWindowChange();
- window.addEventListener('resize', handleWindowChange);
- window.addEventListener('scroll', handleWindowChange, true);
-
- return () => {
- window.removeEventListener('resize', handleWindowChange);
- window.removeEventListener('scroll', handleWindowChange, true);
- };
- }, [showHiddenMenu]);
-
- if (!normalized.length) {
- return null;
- }
-
const sliceStart = Math.min(startIndex, Math.max(0, normalized.length - 1));
const slice = normalized.slice(sliceStart);
const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice;
const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : [];
const hasHiddenEntries = hiddenEntries.length > 0;
+ useEffect(() => {
+ if (!hasHiddenEntries) {
+ closeEllipsisMenu();
+ return;
+ }
+ if (ellipsisMenuOpen) {
+ refreshEllipsisMenuPosition();
+ }
+ }, [
+ hasHiddenEntries,
+ ellipsisMenuOpen,
+ closeEllipsisMenu,
+ refreshEllipsisMenuPosition,
+ hiddenEntries.length,
+ ]);
+
+ if (!normalized.length) {
+ return null;
+ }
+
const wrapperClassName = className
? `breadcrumb-trail ${className}`.trim()
: 'breadcrumb-trail';
- useEffect(() => {
- if (!hasHiddenEntries && showHiddenMenu) {
- setShowHiddenMenu(false);
- setMenuPosition(null);
- }
- }, [hasHiddenEntries, showHiddenMenu]);
-
return (
<>
{visibleEntries.map((entry, index) => {
const isEllipsis = entry.id === ELLIPSIS.id;
const isLast = index === visibleEntries.length - 1;
- const commonProps = {
- className: `breadcrumb-trail__link${isLast && !isEllipsis ? ' is-current' : ''}`,
- title: entry.label,
- 'aria-current': isLast && !isEllipsis ? 'page' : undefined,
- };
if (isEllipsis) {
return (
@@ -262,47 +203,19 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
{separator}
) : null}
-
+