Files
bambuddy/frontend/src/components/ContextMenu.tsx
T
maziggy 82d6d98266 Restore the pointer cursor on interactive controls (#2791)
Hovering most of Bambuddy gave an arrow rather than a hand. Not everywhere,
which is what made it read as sloppiness rather than a bug: the update pill was
inert while the buttons beside it were fine, a bed or nozzle tile responded but
the history-graph button in its corner did not, and dropdowns went either way
with no pattern behind it.

The pattern was there. Tailwind v3's Preflight set `button { cursor: pointer }`.
v4 dropped it to match the browser default, which for a button is `default`.
Bambuddy has been on v4 since the frontend was built, and `src/index.css` never
had a base layer restoring it, so a button only looked clickable where someone
had written `cursor-pointer` by hand. 15 of 934 had. 0 of 149 selects, and 19
of 130 checkbox/radio inputs. The 233 ad-hoc `cursor-pointer` usages are why it
looked arbitrary instead of uniformly broken.

One `@layer base` rule now covers button, select, checkbox, radio, summary and
[role=button]. base sits below utilities, so `cursor-not-allowed` and the
`disabled:cursor-*` variants still win; the `:not(:disabled)` guard catches the
disabled controls that carry no such utility. Verified against the built bundle
rather than the source -- the rule lands inside @layer base, and
`.cursor-not-allowed` is emitted after it.

Click-outside backdrops are deliberately excluded. 90 of the 96 remaining
onClick divs are `fixed inset-0` overlays; a full-screen sheet advertising
itself as a button is worse than one that says nothing. Of the rest, 50 are
stopPropagation wrappers and 3 are the temperature tiles, which already set the
cursor through `statusControlClass` -- which is exactly why those tiles worked
while the button nested inside them did not. That left two real ones: Card, now
conditional on an onClick actually being passed, and the queue card, whose
existing `sm:cursor-default` kept the desktop intent.

Separately, from the same report. FilamentHoverCard draws the slot menu twice,
and the two paths had drifted into opposite orders: Configure above Assign Spool
on an empty slot, the reverse on a filled one, so the menu reshuffled itself
depending on whether the slot held filament. Both now lead with the spool
action. Tests assert the order on each path, so one can no longer move without
the other -- checked by reinstating the old order and confirming the empty-slot
test fails.

Those buttons also used justify-center, which centred each label independently
and left the icons in a ragged column; they are justify-start now. Their hover
was a 10% opacity step that was very hard to see, now 20%. And the favourites
star previews yellow on hover, suppressed when the user lacks archives:update.
2026-08-08 10:06:45 +02:00

304 lines
11 KiB
TypeScript

import { useEffect, useRef, useState, useLayoutEffect } from 'react';
import { ChevronRight, Search } from 'lucide-react';
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
divider?: boolean;
submenu?: ContextMenuItem[];
// When set on an item with a submenu, render a search input above the
// submenu items that filters by label (case-insensitive).
submenuSearchPlaceholder?: string;
title?: string;
}
interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}
interface SubmenuPanelProps {
items: ContextMenuItem[];
searchPlaceholder?: string;
onClose: () => void;
className: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
function SubmenuPanel({
items,
searchPlaceholder,
onClose,
className,
onMouseEnter,
onMouseLeave,
}: SubmenuPanelProps) {
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (searchPlaceholder) {
// Defer focus so it survives the mouse event that opened the submenu.
const id = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(id);
}
}, [searchPlaceholder]);
const trimmed = query.trim().toLowerCase();
const filteredItems = searchPlaceholder && trimmed
? items.filter((i) => i.label.toLowerCase().includes(trimmed))
: items;
return (
<div
className={className}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{searchPlaceholder && (
<div className="sticky top-0 z-[1] px-2 py-1.5 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const first = filteredItems.find((i) => !i.disabled);
if (first) {
first.onClick();
onClose();
}
}
}}
placeholder={searchPlaceholder}
className="w-full pl-7 pr-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
)}
{filteredItems.map((subItem, subIndex) => (
<button
key={subIndex}
onClick={() => {
if (!subItem.disabled) {
subItem.onClick();
onClose();
}
}}
disabled={subItem.disabled}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
subItem.disabled
? 'text-bambu-gray cursor-not-allowed'
: subItem.danger
? 'text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-400/10'
: 'text-white hover:bg-bambu-dark-tertiary'
}`}
>
{subItem.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{subItem.icon}</span>}
<span className="flex-1 truncate">{subItem.label}</span>
</button>
))}
{searchPlaceholder && filteredItems.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray text-center italic">—</div>
)}
</div>
);
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
const submenuTimeoutRef = useRef<number | null>(null);
const [position, setPosition] = useState({ x, y, visible: false });
const [openSubmenuLeft, setOpenSubmenuLeft] = useState(false);
const [submenuPositions, setSubmenuPositions] = useState<Record<number, 'top' | 'bottom'>>({});
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
const handleScroll = (e: Event) => {
// Internal submenu scroll (overflow-y-auto on the submenu panel) must
// not dismiss the menu — only close on scroll outside our own subtree.
if (menuRef.current && menuRef.current.contains(e.target as Node)) {
return;
}
onClose();
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('scroll', handleScroll, true);
if (submenuTimeoutRef.current) {
clearTimeout(submenuTimeoutRef.current);
}
};
}, [onClose]);
// Adjust position to keep menu in viewport - use useLayoutEffect for synchronous measurement
useLayoutEffect(() => {
if (menuRef.current) {
// Force a reflow to get accurate measurements
menuRef.current.style.visibility = 'hidden';
menuRef.current.style.display = 'block';
const rect = menuRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const padding = 8;
let adjustedX = x;
let adjustedY = y;
// Adjust horizontal position - if menu would overflow right, shift left
if (x + rect.width > viewportWidth - padding) {
adjustedX = Math.max(padding, viewportWidth - rect.width - padding);
}
// Also check if starting position is negative
if (adjustedX < padding) {
adjustedX = padding;
}
// Adjust vertical position - if menu would overflow bottom, shift up
if (y + rect.height > viewportHeight - padding) {
adjustedY = Math.max(padding, viewportHeight - rect.height - padding);
}
// Also check if starting position is negative
if (adjustedY < padding) {
adjustedY = padding;
}
// Check if submenus should open to the left (more space on left than right)
const submenuWidth = 180;
const spaceOnRight = viewportWidth - adjustedX - rect.width;
const spaceOnLeft = adjustedX;
// Only open left if there's not enough space on right AND there's enough space on left
setOpenSubmenuLeft(spaceOnRight < submenuWidth && spaceOnLeft > submenuWidth);
setPosition({ x: adjustedX, y: adjustedY, visible: true });
}
}, [x, y]);
const handleMouseEnterSubmenu = (index: number, element: HTMLElement) => {
if (submenuTimeoutRef.current) {
clearTimeout(submenuTimeoutRef.current);
submenuTimeoutRef.current = null;
}
// Calculate if submenu should open upward or downward
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const submenuMaxHeight = 300; // matches max-h-[300px]
const padding = 8;
// Check if there's enough space below for the submenu
const spaceBelow = viewportHeight - rect.top - padding;
const shouldOpenUpward = spaceBelow < submenuMaxHeight && rect.top > submenuMaxHeight;
setSubmenuPositions(prev => ({ ...prev, [index]: shouldOpenUpward ? 'bottom' : 'top' }));
setActiveSubmenu(index);
};
const handleMouseLeaveSubmenu = () => {
submenuTimeoutRef.current = window.setTimeout(() => {
setActiveSubmenu(null);
}, 150);
};
return (
<div
ref={menuRef}
className="fixed z-50 min-w-[180px] max-w-[280px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1"
style={{
left: position.x,
top: position.y,
visibility: position.visible ? 'visible' : 'hidden'
}}
>
{items.map((item, index) => {
if (item.divider) {
return <div key={index} className="my-1 border-t border-bambu-dark-tertiary" />;
}
const hasSubmenu = item.submenu && item.submenu.length > 0;
return (
<div
key={index}
className="relative"
onMouseEnter={(e) => hasSubmenu && handleMouseEnterSubmenu(index, e.currentTarget)}
onMouseLeave={() => hasSubmenu && handleMouseLeaveSubmenu()}
>
<button
onMouseEnter={(e) => hasSubmenu && handleMouseEnterSubmenu(index, e.currentTarget.parentElement!)}
onClick={() => {
if (hasSubmenu) {
// Toggle submenu on click as well
setActiveSubmenu(activeSubmenu === index ? null : index);
} else if (!item.disabled) {
item.onClick();
onClose();
}
}}
disabled={item.disabled}
title={item.title}
className={`group w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
item.disabled
? 'text-bambu-gray cursor-not-allowed'
: item.danger
? 'text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-400/10'
: 'text-white hover:bg-bambu-dark-tertiary'
} ${hasSubmenu && activeSubmenu === index ? 'bg-bambu-dark-tertiary' : ''}`}
>
{item.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{item.icon}</span>}
<span className="flex-1">{item.label}</span>
{hasSubmenu && <ChevronRight className="w-4 h-4 text-bambu-gray" />}
</button>
{/* Submenu */}
{hasSubmenu && activeSubmenu === index && (
<SubmenuPanel
items={item.submenu!}
searchPlaceholder={item.submenuSearchPlaceholder}
onClose={onClose}
className={`absolute min-w-[200px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden max-h-[300px] overflow-y-auto z-[60] ${
openSubmenuLeft ? 'right-full mr-1' : 'left-full ml-1'
} ${submenuPositions[index] === 'bottom' ? 'bottom-0' : 'top-0'}`}
onMouseEnter={() => {
if (submenuTimeoutRef.current) {
clearTimeout(submenuTimeoutRef.current);
submenuTimeoutRef.current = null;
}
}}
onMouseLeave={() => handleMouseLeaveSubmenu()}
/>
)}
</div>
);
})}
</div>
);
}