"use client" import { useCallback, useEffect, useMemo, useState } from 'react' import { useIsomorphicLayoutEffect } from '../../hooks/ui/use-isomorphic-layout-effect' import { useLocalStorage } from '../../hooks/ui/use-local-storage' import { useLgUp, useMdUp } from '../../hooks/ui/use-media-query' import { NavigationSidebarConfig, NavigationSidebarItem } from '../../types/navigation' import { cn } from '../../utils' import { NavigationSidebarHeader } from './navigation-sidebar-header' import { NavigationSidebarItemButton, NavigationSidebarItemSkeleton } from './navigation-sidebar-item' import { NavigationSidebarToggle } from './navigation-sidebar-toggle' const MINIMIZED_WIDTH = 56 // 3.5rem = 56px const EXPANDED_WIDTH = 224 // 14rem = 224px const STORAGE_KEY = 'of.navigationSidebar.minimized' /** * The desktop width, as a custom property on `:root`. Read by the `lg:` rule in * {@link SIDEBAR_GEOMETRY_CLASSES} and written by this component whenever the * user toggles — but NEVER during render. * * It has to be a global variable rather than an inline style because of WHEN the * value is knowable. The width follows a preference in `localStorage`, which the * server cannot read: any markup derived from it differs between the server's * HTML and the client's hydration render, and React tears the tree down and * regenerates it. Seeding this property from a tiny script in `
` moves the * preference OUT of React's rendered output — the markup becomes identical on * both sides, while the width is already correct on the very first paint. * * Consumers are expected to seed it before first paint; unseeded, the fallback * in the class below paints the expanded width until this component's effect * catches up, which is the flash the seed exists to remove. */ export const NAVIGATION_SIDEBAR_WIDTH_VAR = '--of-navigation-sidebar-width' /** * The rail as a query container, so the parts of the sidebar that follow its * collapsed state can read that state off the rail's own width — currently the * collapse chevron's direction, in `navigation-sidebar-toggle.tsx`. * * There is no second source of truth for "is it collapsed", and that is the * point: the width already has to be correct on the first paint and at every * breakpoint, so anything derived from it is too — the seeded desktop * preference, the tablet rail and an overlay opened on tablet all come along for * free. A parallel copy would have to be kept in step with each of the three * separately, and the custom property that used to live here fell out of step on * the last one: it could not see the tablet rail, so that case got its own media * query, which in turn could not see an open overlay and pointed the chevron * backwards under it. * * Queried as `@[140px]/of-nav-sidebar:` — 140px is the midpoint between the 56px * rail and the 224px expanded sidebar, so the width only crosses it mid-toggle. * Container queries are min-width, so a follower styles its COLLAPSED look as the * default and lets the query take it back. Written out literally at each use * rather than interpolated from a constant: Tailwind scans source text, and a * class it cannot see spelled out is a class it never generates. */ const SIDEBAR_CONTAINER = '@container/of-nav-sidebar' /** * Where the sidebar sits and how wide it is, per breakpoint — as literal classes, * because this cannot be decided in JavaScript in time. * * `useMdUp`/`useLgUp` answer `undefined` until an effect has run, so `isTablet` * is false on the first render — the SERVER's render included. A tablet * therefore received the desktop branch in its HTML (`relative`, `width:224px`, * an inline style no class could outrank) and only snapped to the 56px rail once * hydration and an effect had both completed. That is the wide sidebar that * flashed on every load, and no amount of JS could fix it: the server has no * viewport to consult, so the answer has to be deferred to the one consumer that * always knows — the browser's own media evaluation. * * The state that a viewport CANNOT determine still lives outside the render: the * persisted desktop preference reaches the `lg:` rule through * {@link NAVIGATION_SIDEBAR_WIDTH_VAR}. An inline width would be simpler than a * custom property, but it would also override the tablet rail on the very first * paint, which is the bug being fixed. * * The tablet WIDTH is deliberately not here — see {@link SIDEBAR_TABLET_WIDTH}. * * Widths are `w-14`/`w-56` — the class equivalents of `MINIMIZED_WIDTH` and * `EXPANDED_WIDTH`, as is the `14rem` fallback below. Keep them in step; a class * string cannot interpolate a constant. */ const SIDEBAR_GEOMETRY_CLASSES = [ // Tablet: float over the content, anchored to the layout row (`absolute` // within AppLayout's `relative` row) — NOT the viewport — so an optional // `topBar` above the row is not overlapped. With no topBar the row spans the // full viewport, so this is visually identical to a viewport-fixed sidebar. 'md:absolute md:inset-y-0 md:left-0 md:z-[45]', // Desktop: back in the flex flow, width from the persisted preference. The // fallback IS the default, so the property needs no global declaration and an // unseeded consumer gets the full sidebar rather than a rail with no labels. 'lg:relative lg:inset-auto lg:z-auto lg:h-full lg:w-[var(--of-navigation-sidebar-width,14rem)]', ].join(' ') /** * The tablet width, as ONE `md:` class rather than a rail plus an override. * * Tablet is a 56px rail by design and only widens for an overlay the user has * opened — state no viewport can imply, so it has to come from JS. Written as * `md:w-14` + `max-lg:w-56` the two rules both match between md and lg at equal * specificity, and Tailwind emits the `max-lg` block BEFORE the `md` one, so * source order hands the tablet straight back to the rail. Picking one class * removes the contest instead of trying to win it — and keeps the `!important` * that winning it would need out of the tree. * * `lg:` still governs desktop either way: it is emitted after `md:`, so an * overlay left open while the window widens does not leak into the desktop width. */ const SIDEBAR_TABLET_WIDTH = { rail: 'md:w-14', overlay: 'md:w-56' } as const /** A click the browser will resolve itself — new tab, new window, middle button. */ const isModifiedClick = (event?: React.MouseEvent): boolean => !!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) export interface NavigationSidebarProps { config: NavigationSidebarConfig /** * When true, all navigation items are disabled and visually dimmed. * The collapse/expand toggle button remains interactive. */ disabled?: boolean } export function NavigationSidebar({ config, disabled = false }: NavigationSidebarProps) { const isMdUp = useMdUp() ?? false const isLgUp = useLgUp() ?? false // Tablet = md viewport but not lg. On tablet the sidebar floats over the // content area (overlay) instead of pushing it like on desktop. const isTablet = isMdUp && !isLgUp // Desktop preference persists across sessions. Tablet state is in-memory // only so entering tablet always starts minimized without clobbering the // user's desktop choice. const [desktopMinimized, setDesktopMinimized] = useLocalStorage