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} - +