File size: 4,789 Bytes
9b906ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | import React from "react";
import { Pin } from "lucide-react";
import { NavigationLink } from "#/components/shared/navigation-link";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import { useNavigation } from "#/context/navigation-context";
import { hoverRevealActionClassName } from "#/utils/hover-reveal-classes";
import { cn } from "#/utils/utils";
import { SidebarCollapsedIconSlot } from "./sidebar-collapsed-icon-slot";
import {
SIDEBAR_ICON_SLOT_CLASS,
SIDEBAR_ROW_INTERACTIVE_CLASS,
sidebarNavLabelClassName,
sidebarNavRowClassName,
} from "./sidebar-layout";
function isPathActive(currentPath: string, to: string, end: boolean) {
if (to === "/") {
return currentPath === to;
}
if (end) {
return currentPath === to;
}
return currentPath === to || currentPath.startsWith(`${to}/`);
}
export interface SidebarNavLinkPinAction {
pinned: boolean;
onToggle: () => void;
/** Localized aria-label; pin vs unpin variants resolved by the caller. */
label: string;
testId: string;
}
interface SidebarNavLinkProps {
to: string;
label: string;
end?: boolean;
indent?: boolean;
testId?: string;
disabled?: boolean;
icon?: React.ReactElement;
collapsed?: boolean;
hoverContent?: React.ReactNode;
/**
* When true, forces the active style regardless of the current path.
* Useful for links that should appear active for multiple related routes
* (e.g. the Extensions link being active on /mcp and /plugins too).
*/
forceActive?: boolean;
/** Pin-as-home toggle; rendered only when the sidebar is expanded. */
pinAction?: SidebarNavLinkPinAction;
}
export function SidebarNavLink({
to,
label,
end = false,
indent = false,
testId,
disabled = false,
icon,
collapsed = false,
hoverContent,
forceActive = false,
pinAction,
}: SidebarNavLinkProps) {
const { currentPath } = useNavigation();
const active = forceActive || isPathActive(currentPath, to, end);
const showPinAction = !collapsed && pinAction != null;
const link = (
<NavigationLink
to={to}
end={end}
data-testid={testId}
tabIndex={disabled ? -1 : 0}
aria-label={collapsed ? label : undefined}
// Announce the disabled state to assistive tech. The visual disabled
// styling plus tabIndex=-1 + preventDefault gives sighted/keyboard users
// the right behaviour already; this closes the screen-reader gap so the
// link doesn't sound "actionable."
aria-disabled={disabled || undefined}
onClick={(e) => {
if (disabled) {
e.preventDefault();
}
}}
className={cn(
sidebarNavRowClassName({ indent, collapsed }),
!collapsed &&
(active
? SIDEBAR_ROW_INTERACTIVE_CLASS.active
: SIDEBAR_ROW_INTERACTIVE_CLASS.idle),
disabled && "opacity-50",
disabled && "pointer-events-none",
// Constant right reserve so label truncation doesn't reflow on hover.
showPinAction && "pr-9",
)}
>
{icon ? (
collapsed ? (
<SidebarCollapsedIconSlot active={active}>
{icon}
</SidebarCollapsedIconSlot>
) : (
<span className={SIDEBAR_ICON_SLOT_CLASS}>{icon}</span>
)
) : null}
<span className={sidebarNavLabelClassName(collapsed)}>{label}</span>
</NavigationLink>
);
if (!collapsed) {
if (!pinAction) return link;
// NavigationLink renders an <a>, so the pin toggle is an absolutely
// positioned sibling rather than a nested button. Bare `group` is safe
// here: only collapsed rows use it, and this branch is expanded-only.
return (
<div className="group relative">
{link}
<button
type="button"
data-testid={pinAction.testId}
aria-pressed={pinAction.pinned}
aria-label={pinAction.label}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
pinAction.onToggle();
}}
className={cn(
"absolute right-1.5 top-1/2 -translate-y-1/2",
"flex shrink-0 cursor-pointer items-center justify-center rounded-md p-1",
"text-[var(--oh-muted)] hover:bg-white/10 hover:text-white",
hoverRevealActionClassName(pinAction.pinned),
)}
>
<Pin
className={cn("h-3.5 w-3.5", pinAction.pinned && "fill-current")}
aria-hidden
/>
</button>
</div>
);
}
return (
<StyledTooltip
content={hoverContent ?? label}
placement="right"
tooltipClassName={hoverContent ? "p-0 bg-tertiary text-white" : undefined}
>
{link}
</StyledTooltip>
);
}
|