import React, { createContext, useContext, useState, useMemo, useCallback, useRef, useLayoutEffect, } from 'react' import type { DirectoryExpandedItems, NavItemProps, NavbarItem } from './types' import { ChevronDown } from 'lucide-react' import Block from '../Primitives/Block/Block' import styled from 'styled-components' import { useResponsiveTokens } from '../../hooks/useResponsiveTokens' import { DirectoryTokenType } from './directory.tokens' import { getItemPathSegment, getItemVisualState, handleKeyDown, isActiveAncestorPath, normalizeExpandedItems, resolveItemColors, } from './utils' import type { DirectoryItemVisualState } from './directory.tokens.types' import { TooltipV2 } from '../TooltipV2/TooltipV2' import { TooltipV2Side } from '../TooltipV2/tooltipV2.types' import { TruncatedTextWithTooltipV2 } from '../common/TruncatedTextWithTooltipV2' import { useSectionScroll } from '../../hooks/useSectionScroll' import { addPxToValue } from '../../global-utils/GlobalUtils' import { MenuV2 } from '../MenuV2' import { MenuV2Alignment, MenuV2Side, type MenuV2ItemType, } from '../MenuV2/menuV2.types' const StyledElement = styled(Block)<{ $isLink?: boolean $visualState: DirectoryItemVisualState $tokens: DirectoryTokenType $iconOnlyMode?: boolean $hasHierarchyLineInset?: boolean }>` background-color: ${({ $visualState, $tokens }) => resolveItemColors($tokens, $visualState).backgroundColor}; border: none; width: ${({ $hasHierarchyLineInset, $tokens }) => $hasHierarchyLineInset ? `calc(100% - ${$tokens.section.itemList.nested.connector.itemInset})` : '100%'}; margin-left: ${({ $hasHierarchyLineInset, $tokens }) => $hasHierarchyLineInset ? $tokens.section.itemList.nested.connector.itemInset : 0}; min-width: 0; flex-shrink: 0; display: flex; align-items: center; justify-content: ${({ $iconOnlyMode }) => $iconOnlyMode ? 'center' : 'flex-start'}; gap: ${({ $tokens, $iconOnlyMode }) => $iconOnlyMode ? '0' : $tokens.section.itemList.item.gap}; padding: ${({ $tokens, $iconOnlyMode, $hasHierarchyLineInset }) => $iconOnlyMode ? `${$tokens.section.itemList.item.iconOnlyPadding.paddingTop} ${$tokens.section.itemList.item.iconOnlyPadding.paddingRight} ${$tokens.section.itemList.item.iconOnlyPadding.paddingBottom} ${$tokens.section.itemList.item.iconOnlyPadding.paddingLeft}` : `${$tokens.section.itemList.item.padding.y} ${$tokens.section.itemList.item.padding.x} ${$tokens.section.itemList.item.padding.y} ${ $hasHierarchyLineInset ? $tokens.section.itemList.nested.connector .itemPaddingLeft : $tokens.section.itemList.item.padding.x }`}; color: ${({ $visualState, $tokens }) => resolveItemColors($tokens, $visualState).color}; font-weight: ${({ $tokens }) => $tokens.section.itemList.item.fontWeight}; font-size: ${({ $tokens }) => addPxToValue($tokens.section.itemList.item.fontSize)}; border-radius: ${({ $tokens }) => $tokens.section.itemList.item.borderRadius}; transition: ${({ $tokens }) => $tokens.section.itemList.item.transition}; text-align: left; user-select: none; cursor: pointer; overflow: hidden; /* muted rows lift to the hover tier here, so a de-emphasised row regains full contrast the moment it is hovered or keyboard-focused */ &:hover, &:focus-visible { background-color: ${({ $visualState, $tokens }) => $visualState === 'active' ? $tokens.section.itemList.item.backgroundColor.active : $tokens.section.itemList.item.backgroundColor.hover}; color: ${({ $visualState, $tokens }) => $visualState === 'active' ? $tokens.section.itemList.item.color.active : $tokens.section.itemList.item.color.hover}; outline: none; ring: 0; } ` const IconWrapper = styled.div<{ $tokens: DirectoryTokenType }>` display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: ${({ $tokens }) => $tokens.section.itemList.item.icon.width}; height: ${({ $tokens }) => $tokens.section.itemList.item.icon.width}; /* Smooth icon transitions during sidebar expand/collapse */ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); will-change: transform; transform: translateZ(0); & > svg { width: ${({ $tokens }) => $tokens.section.itemList.item.icon.width} !important; height: ${({ $tokens }) => $tokens.section.itemList.item.icon.width} !important; /* Prevent icon flickering during transitions */ backface-visibility: hidden; transform: translateZ(0); } ` const ChevronWrapper = styled(Block)<{ $isExpanded: boolean $tokens: DirectoryTokenType }>` display: flex; align-items: center; justify-content: center; margin-left: auto; & > svg { width: ${({ $tokens }) => $tokens.section.itemList.item.chevron.width} !important; height: ${({ $tokens }) => $tokens.section.itemList.item.chevron.width} !important; transition: transform 150ms; transform: ${({ $isExpanded }) => $isExpanded ? 'rotate(180deg)' : 'rotate(0)'}; } ` const NestedList = styled(Block)<{ $tokens: DirectoryTokenType $showHierarchyLines?: boolean }>` width: 100%; padding-left: ${({ $tokens }) => $tokens.section.itemList.nested.paddingLeft}; margin-top: ${({ $tokens, $showHierarchyLines }) => $showHierarchyLines ? '0' : $tokens.section.itemList.nested.marginTop}; position: relative; display: flex; flex-direction: column; gap: ${({ $tokens, $showHierarchyLines }) => $showHierarchyLines ? 0 : $tokens.section.itemList.gap}; ` const NavListItem = styled.li<{ $showHierarchyLines?: boolean $isLast?: boolean $tokens: DirectoryTokenType $hierarchyLineBorderRadius: React.CSSProperties['borderRadius'] // ::before (vertical guide) is on the active path $verticalActive?: boolean // ::after (elbow) is on the active path $elbowActive?: boolean }>` width: 100%; display: flex; flex-direction: column; justify-content: flex-start; align-items: stretch; position: relative; ${({ $showHierarchyLines, $isLast, $tokens, $hierarchyLineBorderRadius, $verticalActive, $elbowActive, }) => { if (!$showHierarchyLines) return '' const nested = $tokens.section.itemList.nested const activeColor = nested.border.activeColor ?? nested.border.color // Vertical and elbow are fully decoupled (z-index model): // - The ::before vertical guide is painted ON TOP of the ::after elbow, // so an above-sibling's active guide covers the elbow's default nub — // the vertical stays continuous with no recolouring of the elbow. // - The elbow (::after) is therefore coloured as ONE piece by elbowColor: // default for every off-path row (no active bleed onto the horizontal // or the rounded corner), active only for the on-path child. const guideColor = $verticalActive ? activeColor : nested.border.color const elbowColor = $elbowActive ? activeColor : nested.border.color return ` --directory-connector-elbow-top: ${nested.connector.elbowTop}; padding-bottom: ${$isLast ? '0' : $tokens.section.itemList.gap}; &::before, &::after { content: ''; position: absolute; pointer-events: none; } &::after { /* Crisp full elbow (vertical drop + horizontal + rounded corner), coloured as ONE piece by elbowColor. Off-path (z 0): sits under the guide, so the active guide covers its default vertical drop and the vertical stays continuous; its corner stays default (no active bleed). On-path (z 2): sits above the guide so the whole active elbow renders crisply and connects to the active stub. */ z-index: ${$elbowActive ? 2 : 0}; left: calc(-1 * ${nested.paddingLeft} + ${nested.border.leftOffset}); top: var(--directory-connector-elbow-top); width: calc(${nested.paddingLeft} - ${nested.border.leftOffset} + ${nested.connector.elbowWidthOffset}); height: ${nested.connector.elbowHeight}; border-left: ${nested.border.width} solid ${elbowColor}; border-bottom: ${nested.border.width} solid ${elbowColor}; border-bottom-left-radius: ${addPxToValue($hierarchyLineBorderRadius)}; } &::before { z-index: 1; left: calc(-1 * ${nested.paddingLeft} + ${nested.border.leftOffset}); top: calc(-1 * ${$tokens.section.itemList.gap}); bottom: ${ $isLast ? 'calc(100% - var(--directory-connector-elbow-top))' : `calc(-1 * ${$tokens.section.itemList.gap})` }; border-left: ${nested.border.width} solid ${guideColor}; } ` }} ` // Highlighted vertical stub for the on-path child: an active guide that runs // from the gap above the row down to exactly where the elbow's curve begins. // It sits above the (default) ::before guide and the elbow, so the active path // reaches the corner without recolouring the full-height guide line below it. const ActivePathStub = styled.span<{ $tokens: DirectoryTokenType }>` position: absolute; z-index: 2; pointer-events: none; left: ${({ $tokens }) => `calc(-1 * ${$tokens.section.itemList.nested.paddingLeft} + ${$tokens.section.itemList.nested.border.leftOffset})`}; top: ${({ $tokens }) => `calc(-1 * ${$tokens.section.itemList.gap})`}; /* from the gap above the row down to the top of the elbow's vertical drop, where the on-path elbow's own (active) border-left takes over */ height: ${({ $tokens }) => `calc(${$tokens.section.itemList.gap} + ${$tokens.section.itemList.nested.connector.elbowTop})`}; border-left: ${({ $tokens }) => `${$tokens.section.itemList.nested.border.width} solid ${ $tokens.section.itemList.nested.border.activeColor ?? $tokens.section.itemList.nested.border.color }`}; ` const NavItemContentFrame = styled.div<{ $showHierarchyLines?: boolean }>` position: relative; /* above the connector layers (elbow 0, guide 1, active stub 2) so the row's background/label always paints over the hierarchy lines */ z-index: ${({ $showHierarchyLines }) => ($showHierarchyLines ? 3 : 'auto')}; ` const NestedListFrame = styled.div` position: relative; ` type ActiveItemContextValue = { activeItem: string | null setActiveItem: (item: string | null) => void isControlled: boolean } // Create context without default value to force usage within provider const ActiveItemContext = createContext(null) // Hook to safely use the context with error handling const useActiveItemContext = () => { const context = useContext(ActiveItemContext) if (!context) { throw new Error( 'useActiveItemContext must be used within ActiveItemProvider' ) } return context } // Improved Provider Props type ActiveItemProviderProps = { children: React.ReactNode /** * Controlled mode: Parent controls the active item * If provided, internal state is ignored */ activeItem?: string | null /** * Callback when active item changes (for controlled mode) */ onActiveItemChange?: (item: string | null) => void /** * Initial active item (for uncontrolled mode) */ defaultActiveItem?: string | null } export const ActiveItemProvider: React.FC = ({ children, activeItem: controlledActiveItem, onActiveItemChange, defaultActiveItem = null, }) => { const [internalActiveItem, setInternalActiveItem] = useState( defaultActiveItem ) const isControlled = controlledActiveItem !== undefined const activeItem = isControlled ? controlledActiveItem! : internalActiveItem const setActiveItem = useCallback( (item: string | null) => { if (!isControlled) { setInternalActiveItem(item) } // fired in both modes, matching the virtualized renderer onActiveItemChange?.(item) }, [isControlled, onActiveItemChange] ) const contextValue = useMemo( () => ({ activeItem, setActiveItem, isControlled, }), [activeItem, setActiveItem, isControlled] ) return ( {children} ) } type ExpandedItemsContextValue = { isItemExpanded: (itemPath: string) => boolean setItemExpanded: (item: NavbarItem, itemPath: string, next: boolean) => void } const ExpandedItemsContext = createContext( null ) const useExpandedItemsContext = () => { const context = useContext(ExpandedItemsContext) if (!context) { throw new Error( 'useExpandedItemsContext must be used within ExpandedItemsProvider' ) } return context } type ExpandedItemsProviderProps = { children: React.ReactNode /** * Controlled mode: parent owns the expanded item paths. * If provided, internal state is ignored. */ expandedItems?: DirectoryExpandedItems defaultExpandedItems?: DirectoryExpandedItems onExpandedItemsChange?: (items: string[]) => void onItemExpand?: (item: NavbarItem, itemPath: string) => void | Promise } export const ExpandedItemsProvider: React.FC = ({ children, expandedItems, defaultExpandedItems, onExpandedItemsChange, onItemExpand, }) => { const isControlled = expandedItems !== undefined const [internalExpandedItems, setInternalExpandedItems] = useState< Set >(() => normalizeExpandedItems(defaultExpandedItems)) const currentExpandedItems = useMemo( () => isControlled ? normalizeExpandedItems(expandedItems) : internalExpandedItems, [expandedItems, internalExpandedItems, isControlled] ) const setItemExpanded = useCallback( (item: NavbarItem, itemPath: string, next: boolean) => { const nextExpandedItems = new Set(currentExpandedItems) if (next) { nextExpandedItems.add(itemPath) void onItemExpand?.(item, itemPath) } else { nextExpandedItems.delete(itemPath) } if (!isControlled) { setInternalExpandedItems(nextExpandedItems) } onExpandedItemsChange?.(Array.from(nextExpandedItems)) }, [ currentExpandedItems, isControlled, onExpandedItemsChange, onItemExpand, ] ) const isItemExpanded = useCallback( (itemPath: string) => currentExpandedItems.has(itemPath), [currentExpandedItems] ) const contextValue = useMemo( () => ({ isItemExpanded, setItemExpanded }), [isItemExpanded, setItemExpanded] ) return ( {children} ) } const NavItem = ({ item, index, onNavigate, itemPath = getItemPathSegment(item), iconOnlyMode = false, showHierarchyLines = false, hierarchyLineBorderRadius = 0, isLast = false, isNested = false, enableParentSelection = false, highlightActivePath = false, pathVerticalActive = false, pathElbowActive = false, }: NavItemProps) => { const tokens = useResponsiveTokens('DIRECTORY') const { isItemExpanded, setItemExpanded } = useExpandedItemsContext() const isExpanded = isItemExpanded(itemPath) const setIsExpanded = (value: boolean) => setItemExpanded(item, itemPath, value) const { activeItem, setActiveItem } = useActiveItemContext() const hasChildren = item.items && item.items.length > 0 const isIconOnlyMenuTrigger = iconOnlyMode && hasChildren const isSelectable = enableParentSelection || !hasChildren // bare-label matching is a backward-compat fallback for id-less items // only, so a label-valued activeItem can't co-select id'd duplicates const isActive = item.isSelected !== undefined ? item.isSelected && isSelectable : isSelectable && (activeItem === itemPath || (!item.id && activeItem === item.label)) const visualState = getItemVisualState({ isActive, itemPath, activeItem, highlightActivePath, }) const itemColors = resolveItemColors(tokens, visualState) // Icons use a neutral colour (design), falling back to the row text colour // when the token doesn't define one. const iconColor = tokens.section.itemList.item.icon.color ?? itemColors.color // Index of this item's child that lies on the active path (the selected // node or one of its ancestors). Its vertical guide highlights from the top // of the column down to that child; children below it stay default. const activeChildIndex = highlightActivePath && activeItem && item.items ? item.items.findIndex((childItem) => { const childPath = `${itemPath}/${getItemPathSegment(childItem)}` return ( activeItem === childPath || isActiveAncestorPath(childPath, activeItem) ) }) : -1 const itemRef = React.useRef(null) const nestedListRef = useRef(null) const refCallback = React.useCallback( (node: HTMLButtonElement | HTMLAnchorElement | null) => { itemRef.current = node }, [] ) const { scrollIntoView } = useSectionScroll() const previousIsExpanded = useRef(isExpanded) // Auto-scroll expanded nested menu items into view useLayoutEffect(() => { const wasCollapsed = !previousIsExpanded.current const isExpanding = isExpanded && wasCollapsed previousIsExpanded.current = isExpanded if (isExpanding && nestedListRef.current && !iconOnlyMode) { scrollIntoView(nestedListRef.current) } }, [isExpanded, iconOnlyMode, scrollIntoView]) const activateItem = () => { if (isIconOnlyMenuTrigger) { return } if (hasChildren && !iconOnlyMode) { if (enableParentSelection) { setActiveItem(itemPath) if (!isExpanded) setIsExpanded(true) } else { setIsExpanded(!isExpanded) } item.onClick?.() } else { setActiveItem(itemPath) item.onClick?.() } } // Chevron toggles disclosure only; stop the row click from also selecting. const toggleExpanded = (event: React.MouseEvent) => { event.stopPropagation() event.preventDefault() setIsExpanded(!isExpanded) } const handleClick = ( event: React.MouseEvent ) => { if ( event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return } if (item.href) { event.preventDefault() } activateItem() } const Element = item.href && !isIconOnlyMenuTrigger ? 'a' : 'button' const elementProps = item.href && !isIconOnlyMenuTrigger ? { href: item.href } : {} const iconOnlyMenuItems = useMemo((): MenuV2ItemType[] => { const toMenuItems = ( items: NavbarItem[], parentPath: string ): MenuV2ItemType[] => items.map((nestedItem) => { const nestedItemPath = `${parentPath}/${getItemPathSegment(nestedItem)}` const nestedHasChildren = !!nestedItem.items?.length const nestedIsSelectable = enableParentSelection || !nestedHasChildren const nestedIsSelected = nestedItem.isSelected !== undefined ? nestedItem.isSelected && nestedIsSelectable : nestedIsSelectable && (activeItem === nestedItemPath || (!nestedItem.id && activeItem === nestedItem.label)) return { id: nestedItemPath, label: { text: nestedItem.label, leftSlot: React.isValidElement(nestedItem.leftSlot) ? nestedItem.leftSlot : undefined, }, selected: nestedIsSelected, onClick: () => { if (nestedIsSelectable) { setActiveItem(nestedItemPath) } nestedItem.onClick?.() }, subMenu: nestedHasChildren ? toMenuItems(nestedItem.items!, nestedItemPath) : undefined, } }) return hasChildren ? toMenuItems(item.items!, itemPath) : [] }, [ activeItem, enableParentSelection, hasChildren, item.items, itemPath, setActiveItem, ]) const renderContent = () => { if (iconOnlyMode) { if (!item.leftSlot) { return ( ) } if (React.isValidElement(item.leftSlot)) { return ( {React.cloneElement( item.leftSlot as React.ReactElement< React.SVGProps & { size?: number } >, { color: iconColor } )} ) } return null } return ( <> {item.leftSlot && React.isValidElement(item.leftSlot) && ( )} {item.rightSlot && React.isValidElement(item.rightSlot) && ( )} {hasChildren && !iconOnlyMode && ( )} ) } const itemElement = ( handleKeyDown(e, { hasChildren, isExpanded, setIsExpanded, handleClick: activateItem, index, onNavigate, }) } aria-expanded={ hasChildren && !iconOnlyMode ? isExpanded ? true : false : undefined } aria-label={item.label} tabIndex={0} data-sidebar-expanded={ hasChildren && !iconOnlyMode ? isExpanded : undefined } data-element="sidebar-sub-section" data-id={getItemPathSegment(item)} data-status={isActive ? 'selected' : 'not selected'} data-path-state={visualState} > {renderContent()} ) return ( {showHierarchyLines && isNested && pathElbowActive && ( ) } export default NavItem