mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2)
This commit is contained in:
1 parent
8e2221cb8f
commit
c3f6db69e9
3 files changed
+257
-228
No files matched your search
+241
-228
@@ -312,255 +312,268 @@ export function AppRail({
|
||||
);
|
||||
};
|
||||
|
||||
// Expanding on hover draws over the content instead of pushing it, so open
|
||||
// sessions like remote desktop are not resized each time.
|
||||
const takesSpace = pinned || managing;
|
||||
|
||||
return (
|
||||
<div
|
||||
// The desktop app keeps the rail however narrow its window gets.
|
||||
className={`${isElectron() ? "flex" : "hidden md:flex"} flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0`}
|
||||
style={{ width: rem(railExpanded ? 172 : 40) }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// Keep the menu on screen when right-clicking near the viewport edges
|
||||
const MENU_W = 200;
|
||||
const MENU_H = 180;
|
||||
setMenuPos({
|
||||
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
||||
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
||||
});
|
||||
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
|
||||
setMenuTarget(null);
|
||||
}
|
||||
}}
|
||||
className={`${isElectron() ? "flex" : "hidden md:flex"} relative shrink-0 transition-[width] duration-200 min-h-0`}
|
||||
style={{ width: rem(takesSpace ? 172 : 40) }}
|
||||
>
|
||||
{onOpenPalette && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPalette}
|
||||
title={t("nav.searchEverything")}
|
||||
aria-label={t("nav.searchEverything")}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<Search size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
|
||||
</button>
|
||||
<Rule expanded={railExpanded} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||
{!pluginsSettled
|
||||
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
|
||||
<div
|
||||
key={`rail-skeleton-${i}`}
|
||||
style={btnStyle}
|
||||
className="flex items-center h-7 shrink-0"
|
||||
>
|
||||
<Skeleton className="size-4 shrink-0" />
|
||||
</div>
|
||||
))
|
||||
: bands.map((band, i) => (
|
||||
<div key={band.group} className="flex flex-col gap-1">
|
||||
{i > 0 && (
|
||||
<GroupDivider group={band.group} expanded={railExpanded} />
|
||||
)}
|
||||
{band.items.map(renderItem)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pluginsSettled && hiddenItems.length > 0 && (
|
||||
<>
|
||||
<Rule expanded={railExpanded} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManaging((v) => !v)}
|
||||
aria-expanded={managing}
|
||||
title={t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${managing ? activeClass : idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<EyeOff size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
</RailLabel>
|
||||
</button>
|
||||
{managing &&
|
||||
hiddenItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={`hidden-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => void toggleHiddenRailTab(item.id)}
|
||||
title={t("nav.showInRail")}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
<RailIcon>
|
||||
<Icon size={16} />
|
||||
</RailIcon>
|
||||
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
{showPinButton && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 z-40 flex flex-col items-stretch bg-sidebar border-r border-border overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
|
||||
style={{ width: rem(railExpanded ? 172 : 40) }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// Keep the menu on screen when right-clicking near the viewport edges
|
||||
const MENU_W = 200;
|
||||
const MENU_H = 180;
|
||||
setMenuPos({
|
||||
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
||||
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
||||
});
|
||||
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
|
||||
setMenuTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{onOpenPalette && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRailPreference("pinAppRail", !pinned)}
|
||||
onClick={onOpenPalette}
|
||||
title={t("nav.searchEverything")}
|
||||
aria-label={t("nav.searchEverything")}
|
||||
style={btnStyle}
|
||||
title={
|
||||
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||
}
|
||||
className={`${btnBase} ${pinned ? activeClass : idle}`}
|
||||
className={`${btnBase} ${idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<Pin size={16} />
|
||||
<Search size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
|
||||
</RailLabel>
|
||||
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
|
||||
</button>
|
||||
<Rule expanded={railExpanded} />
|
||||
</>
|
||||
)}
|
||||
{footerItems.map(renderItem)}
|
||||
{footerItems.length > 0 && !isElectron() && (
|
||||
<Rule expanded={railExpanded} />
|
||||
)}
|
||||
{/* The desktop signs in to its own local profile by itself. */}
|
||||
{!isElectron() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLogout({ manual: true })}
|
||||
style={btnStyle}
|
||||
title={t("common.logout")}
|
||||
aria-label={t("common.logout")}
|
||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||
>
|
||||
<RailIcon>
|
||||
<LogOut size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title={t("nav.openSettings")}
|
||||
aria-label={t("nav.openSettings")}
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
|
||||
style={{ padding: `0 ${rem(8)}` }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
|
||||
>
|
||||
{username.charAt(0).toUpperCase() || "U"}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||
{username || t("nav.roleUser")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||
{!pluginsSettled
|
||||
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
|
||||
<div
|
||||
key={`rail-skeleton-${i}`}
|
||||
style={btnStyle}
|
||||
className="flex items-center h-7 shrink-0"
|
||||
>
|
||||
<Skeleton className="size-4 shrink-0" />
|
||||
</div>
|
||||
))
|
||||
: bands.map((band, i) => (
|
||||
<div key={band.group} className="flex flex-col gap-1">
|
||||
{i > 0 && (
|
||||
<GroupDivider group={band.group} expanded={railExpanded} />
|
||||
)}
|
||||
{band.items.map(renderItem)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{menuPos && (
|
||||
<div
|
||||
data-rail-context-menu
|
||||
role="menu"
|
||||
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
|
||||
>
|
||||
{menuTarget && (
|
||||
{pluginsSettled && hiddenItems.length > 0 && (
|
||||
<>
|
||||
{menuTarget.promotable && (
|
||||
<MenuItem
|
||||
icon={<SquareArrowOutUpRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenTab?.(menuTarget.id as TabType);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.openAsTab")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.rightDockable && (
|
||||
<MenuItem
|
||||
icon={<PanelRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenInRightDock?.(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.openInRightDock")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.hideable && (
|
||||
<MenuItem
|
||||
icon={<EyeOff className="size-3" />}
|
||||
onClick={() => {
|
||||
void toggleHiddenRailTab(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.hideFromRail")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
<Rule expanded={railExpanded} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManaging((v) => !v)}
|
||||
aria-expanded={managing}
|
||||
title={t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${managing ? activeClass : idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<EyeOff size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
</RailLabel>
|
||||
</button>
|
||||
{managing &&
|
||||
hiddenItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={`hidden-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => void toggleHiddenRailTab(item.id)}
|
||||
title={t("nav.showInRail")}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
<RailIcon>
|
||||
<Icon size={16} />
|
||||
</RailIcon>
|
||||
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={pinned ? <Check className="size-3" /> : null}
|
||||
onClick={togglePinned}
|
||||
checked={pinned}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.pinAppRail")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={expandOnHover ? <Check className="size-3" /> : null}
|
||||
onClick={toggleExpandOnHover}
|
||||
checked={expandOnHover}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
||||
</MenuItem>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenNavigationSettings();
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.openSettings")}
|
||||
</MenuItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
{showPinButton && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRailPreference("pinAppRail", !pinned)}
|
||||
style={btnStyle}
|
||||
title={
|
||||
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||
}
|
||||
className={`${btnBase} ${pinned ? activeClass : idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<Pin size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{pinned
|
||||
? t("nav.collapseSideMenu")
|
||||
: t("nav.keepSideMenuOpen")}
|
||||
</RailLabel>
|
||||
</button>
|
||||
<Rule expanded={railExpanded} />
|
||||
</>
|
||||
)}
|
||||
{footerItems.map(renderItem)}
|
||||
{footerItems.length > 0 && !isElectron() && (
|
||||
<Rule expanded={railExpanded} />
|
||||
)}
|
||||
{/* The desktop signs in to its own local profile by itself. */}
|
||||
{!isElectron() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLogout({ manual: true })}
|
||||
style={btnStyle}
|
||||
title={t("common.logout")}
|
||||
aria-label={t("common.logout")}
|
||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||
>
|
||||
<RailIcon>
|
||||
<LogOut size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{t("common.logout")}
|
||||
</RailLabel>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title={t("nav.openSettings")}
|
||||
aria-label={t("nav.openSettings")}
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
|
||||
style={{ padding: `0 ${rem(8)}` }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
style={{ width: rem(24), height: rem(24), fontSize: rem(11) }}
|
||||
>
|
||||
{username.charAt(0).toUpperCase() || "U"}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||
{username || t("nav.roleUser")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{menuPos && (
|
||||
<div
|
||||
data-rail-context-menu
|
||||
role="menu"
|
||||
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
|
||||
>
|
||||
{menuTarget && (
|
||||
<>
|
||||
{menuTarget.promotable && (
|
||||
<MenuItem
|
||||
icon={<SquareArrowOutUpRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenTab?.(menuTarget.id as TabType);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.openAsTab")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.rightDockable && (
|
||||
<MenuItem
|
||||
icon={<PanelRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenInRightDock?.(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.openInRightDock")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.hideable && (
|
||||
<MenuItem
|
||||
icon={<EyeOff className="size-3" />}
|
||||
onClick={() => {
|
||||
void toggleHiddenRailTab(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.hideFromRail")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={pinned ? <Check className="size-3" /> : null}
|
||||
onClick={togglePinned}
|
||||
checked={pinned}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.pinAppRail")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={expandOnHover ? <Check className="size-3" /> : null}
|
||||
onClick={toggleExpandOnHover}
|
||||
checked={expandOnHover}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
||||
</MenuItem>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenNavigationSettings();
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.openSettings")}
|
||||
</MenuItem>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@ describe("AppRail", () => {
|
||||
expect(screen.queryByLabelText("common.logout")).toBeNull();
|
||||
});
|
||||
|
||||
it("expands over the content on hover without taking more room", () => {
|
||||
renderRail();
|
||||
const rail = screen
|
||||
.getByLabelText("common.logout")
|
||||
.closest(".absolute") as HTMLElement;
|
||||
const slot = rail.parentElement as HTMLElement;
|
||||
const collapsed = slot.style.width;
|
||||
fireEvent.mouseEnter(rail);
|
||||
expect(rail.style.width).not.toBe(collapsed);
|
||||
expect(slot.style.width).toBe(collapsed);
|
||||
});
|
||||
|
||||
it("puts a footer item above the profile with its badge", async () => {
|
||||
registerRailItem({
|
||||
id: "inbox",
|
||||
|
||||
Reference in new issue
Block a user