// Copyright (c) Meta Platforms, Inc. and affiliates. "use client"; /** * @file SideNav.tsx * @input Uses React, HTMLAttributes, ReactNode, StyleX * @output Exports SideNav component and SideNavProps * @position Core implementation; consumed by index.ts, tested by SideNav.test.tsx * * Sidebar navigation container with five zones: header + topContent (sticky together), * children (scrollable), footer, and footerIcons (sticky bottom). * * Supports optional resize via a drag handle at the inline-end edge. The * resizable wrapper clips handle overflow while keeping its focus ring inside. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/SideNav/SideNav.doc.mjs * - /packages/core/src/SideNav/SideNav.test.tsx * - /packages/core/src/SideNav/index.ts * - /apps/storybook/stories/SideNav.stories.tsx * - /packages/cli/assets/templates/blocks/components/SideNav/ (showcase blocks) */ import { useCallback, useEffect, useImperativeHandle, useRef, type ReactNode, } from "react"; import type { BaseProps } from "../BaseProps.js"; import * as stylex from "../style-runtime.js"; import type { StyleXStyles } from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { focusVars, spacingVars } from "../theme/tokens.stylex.js"; import { mergeProps } from "../utils/index.js"; import { SideNavCollapseContext, type SideNavCollapseState, type SideNavCollapsibleConfig, type SideNavImperativeCollapseHandle, } from "./SideNavCollapseContext.js"; import { SideNavCollapseButton } from "./SideNavCollapseButton.js"; import { useSideNavRenderMode } from "./SideNavRenderContext.js"; import { MobileNav } from "../MobileNav/MobileNav.js"; import { useResizable } from "../Resizable/useResizable.js"; import type { ResizableConfig } from "../Resizable/useResizable.js"; import { ResizeHandle } from "../Resizable/ResizeHandle.js"; import { themeProps } from "../utils/themeProps.js"; import { SizeProvider } from "../SizeContext/SizeContext.js"; import { useDevWarning } from "../hooks/useDevWarning.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; // ============================================================================= // Constants // ============================================================================= /** Width below which dragging collapses the sidebar (when collapsible). */ const COLLAPSE_THRESHOLD = 160; // ============================================================================= // Collapse config // ============================================================================= interface ResolvedCollapseConfig { isCollapsible: boolean; defaultIsCollapsed: boolean; isCollapsed: boolean | undefined; onCollapsedChange: ((isCollapsed: boolean) => void) | undefined; /** Dev-warning text when both props address the same state, else null. */ conflict: string | null; } /** * `collapsible` and `resizable` can each carry collapse state, so they are * normalized here into one config with one owner — two independently * initialized copies of the same boolean is what rendered an expanded nav at * width 0 in #4790. `resizable` is the superset, so its keys win. */ function resolveCollapseConfig( collapsible: boolean | SideNavCollapsibleConfig, resizable: boolean | ResizableConfig, ): ResolvedCollapseConfig { const fromCollapsible = typeof collapsible === "object" ? collapsible : {}; const fromResizable = typeof resizable === "object" ? resizable : {}; const resizableCarriesCollapse = fromResizable.defaultIsCollapsed !== undefined || fromResizable.isCollapsed !== undefined || fromResizable.onCollapseChange !== undefined; return { isCollapsible: !!collapsible || resizableCarriesCollapse, defaultIsCollapsed: (resizableCarriesCollapse ? fromResizable.defaultIsCollapsed : fromCollapsible.defaultIsCollapsed) ?? false, isCollapsed: resizableCarriesCollapse ? fromResizable.isCollapsed : fromCollapsible.isCollapsed, onCollapsedChange: resizableCarriesCollapse ? fromResizable.onCollapseChange : fromCollapsible.onCollapsedChange, conflict: describeCollapseConflict(fromCollapsible, fromResizable), }; } /** * Names the keys that address the same collapse state on both props, and * which one won. Silent for the common non-overlapping combinations — * `collapsible: true` alongside a resize config is not a conflict. */ function describeCollapseConflict( collapsible: SideNavCollapsibleConfig, resizable: ResizableConfig, ): string | null { const collapsibleKeys = [ collapsible.defaultIsCollapsed !== undefined && "defaultIsCollapsed", collapsible.isCollapsed !== undefined && "isCollapsed", collapsible.onCollapsedChange !== undefined && "onCollapsedChange", ].filter((key): key is string => key !== false); const resizableKeys = [ resizable.defaultIsCollapsed !== undefined && "defaultIsCollapsed", resizable.isCollapsed !== undefined && "isCollapsed", resizable.onCollapseChange !== undefined && "onCollapseChange", ].filter((key): key is string => key !== false); if (collapsibleKeys.length === 0 || resizableKeys.length === 0) { return null; } return ( `${collapsibleKeys.map((key) => `collapsible.${key}`).join(", ")} and ` + `${resizableKeys.map((key) => `resizable.${key}`).join(", ")} address the ` + `same collapse state. resizable wins; the collapsible ` + `${collapsibleKeys.length === 1 ? "value is" : "values are"} ignored.` ); } // ============================================================================= // Styles // ============================================================================= const styles = { root: { k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kZKoxP: "x5yr21d", kzqmXN: "x1hfn5x7", kWkggS: "xgcd1z6", kB7OPa: "x9f619", kVQacm: "xb3r6kr", $$css: true as const, }, rootCollapsed: { kzqmXN: "xhgvbfk", $$css: true as const, }, resizableHandle: { kInvED: "x1wfwxd8 x1l5jwag", $$css: true as const, }, topbar: { k1xSpc: "x78zum5", kXwgrk: "x1q0g3np", kGNEyG: "x6s0dn4", kjj79g: "x1qughib", kZKoxP: "xsdox4t", kzqmXN: "xh8yej3", kWkggS: "xgcd1z6", kB7OPa: "x9f619", kVQacm: "xb3r6kr", $$css: true as const, }, }; /** * Cascaded to the icon rows through `SizeContext` so the built-in collapse * button and the consumer's `footerIcons` come out one height. An explicit * `size` on a child still wins. */ const FOOTER_ICON_SIZE = "sm"; // ============================================================================= // Types // ============================================================================= export interface SideNavProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Imperative collapse handle for SideNavCollapseButton instances rendered * outside this SideNav. This intentionally stays separate from `ref`, which * continues to expose the root HTMLElement. * * @deprecated Hand the same controlled `collapsible` config to SideNav and * to the outside button instead. */ handleRef?: React.Ref; /** * Header area — typically SideNavHeading. Sticky at top. */ header?: ReactNode; /** * Content pinned below header (e.g., create button, top-level items). Sticky. */ topContent?: ReactNode; /** * Navigation sections and items. Scrollable. */ children: ReactNode; /** * Footer area above icon bar (e.g., promo cards). */ footer?: ReactNode; /** * Footer icon bar (e.g., help, notifications, avatar). */ footerIcons?: ReactNode; /** * StyleX styles created via `stylex.create()`. Merged with the component's * base styles inside a single `stylex.props()` call for optimal deduplication. * * @example * ``` * const overrides = stylex.create({ root: { marginBottom: 8 } }); * * ``` */ xstyle?: StyleXStyles; /** * CSS class name(s) appended to the root element. * If you're using StyleX, prefer `xstyle` for optimal style deduplication. */ className?: string; /** * Inline styles to apply to the root element. Spread after StyleX * inline styles, so these values take priority. */ style?: React.CSSProperties; /** * Test ID for the root element. */ "data-testid"?: string; /** * Enables a resize handle at the inline-end edge for resizing the sidebar. * Uses `useResizable` internally and renders `ResizeHandle` in * overlay mode. When collapsed, the handle is hidden. * * - `true` — resizable with defaults (260px initial, 180–480px range) * - Object — configured via `ResizableConfig`: * - `defaultWidth` — initial width in pixels (default: 260) * - `minWidth` — minimum width in pixels (default: 180) * - `maxWidth` — maximum width in pixels (default: 480) * - `autoSaveId` — localStorage key for persisting width and collapse state * - `onWidthChange` — called when the width changes * - `defaultIsCollapsed` / `isCollapsed` / `onCollapseChange` — collapse * state, when `resizable` should own it rather than `collapsible` * * When both props carry collapse state, `resizable` wins and a dev warning * names the conflicting keys. * * @default false */ resizable?: boolean | ResizableConfig; /** * Enables collapse behavior. The sidebar can be collapsed to a narrow * icon-only toolbar. * * - `true` — enables collapse with default toggle button and uncontrolled state * - Object — enables collapse with advanced configuration: * - `defaultIsCollapsed` — start collapsed (uncontrolled) * - `isCollapsed` + `onCollapsedChange` — controlled mode. Pass the same * object to a `SideNavCollapseButton` rendered outside this SideNav * - `hasButton` — render built-in collapse button (default: true) * - `buttonLabel` — accessibility label for the collapse button * * @default false */ collapsible?: boolean | SideNavCollapsibleConfig; /** Opt in to Cmd/Ctrl+B collapse. Editable fields and handled keys are ignored. */ collapseShortcut?: boolean; } // ============================================================================= // Component // ============================================================================= /** * Sidebar navigation container for application pages. * * Five vertical zones: sticky header + action area at top, * scrollable nav content in the middle, and sticky footer + icon bar at bottom. * * @example * ``` * } * topContent={