ilinxa
blockapp-sidebarsource: ok · hit
App Sidebar
App-shell sidebar with mobile drawer mode, twelve composition slots, prefab nav parts, and a headless state hook.
npx shadcn add "https://ui.ilinxa.com/r/app-sidebar.json"free
Where it goes
statssidebardashboard-shell
Interaction
animated-counter
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
avatarbuttondropdown-menusheet
Files37 · source cached 10/8/2026, 7:41:37 PM
src/registry/components/navigation/app-sidebar/app-sidebar.tsx
"use client";
import { PanelLeft, PanelLeftClose } from "lucide-react";
import {
useCallback,
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { cn } from "@/lib/utils";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useActiveDetection } from "./hooks/use-active-detection";
import { useMatchMedia, resolveBreakpointQuery } from "./hooks/use-match-media";
import { useSidebarReducer } from "./hooks/use-sidebar-reducer";
import { useStorageSync } from "./hooks/use-storage-sync";
import {
AppSidebarContext,
type AppSidebarContextValue,
} from "./contexts/sidebar-nav-context";
import { buildHandle } from "./lib/build-handle";
import { deriveCssVars } from "./lib/derive-css-vars";
import { flattenEntriesForKeyboard } from "./lib/flatten-entries";
import { handleSidebarKeydown } from "./lib/keyboard-handler";
import { DefaultLink } from "./parts/default-link";
import { NavBrand } from "./parts/nav-brand";
import { NavPrimaryAction } from "./parts/nav-primary-action";
import { NavUser } from "./parts/nav-user";
import { SidebarEmptyState } from "./parts/sidebar-empty-state";
import { SidebarLoadingSkeleton } from "./parts/sidebar-loading-skeleton";
import { SidebarNavList } from "./parts/sidebar-nav-list";
import { SidebarSkipLink } from "./parts/sidebar-skip-link";
import type {
NavItem,
NavUserMenuItemSelectEvent,
AppSidebarEmptyReason,
AppSidebarHandle,
AppSidebarMobileOpenReason,
AppSidebarProps,
} from "./types";
/** Breakpoint → CSS class lookup (L44 — CSS-gated, not JS-gated rendering). */
const BREAKPOINT_DESKTOP_VISIBLE: Record<string, string> = {
sm: "sm:flex",
md: "md:flex",
lg: "lg:flex",
xl: "xl:flex",
"2xl": "2xl:flex",
};
const BREAKPOINT_MOBILE_VISIBLE: Record<string, string> = {
sm: "sm:hidden",
md: "md:hidden",
lg: "lg:hidden",
xl: "xl:hidden",
"2xl": "2xl:hidden",
};
/**
* C3 — items rendering + active detection.
*
* Items now actually render. Active item highlighted (full-fill paint;
* activeVariant matrix lands C5). Sections render their items inline
* (full section UI with header lands C4). Click sequence per L28 wired.
*/
export function AppSidebar(props: AppSidebarProps) {
const {
items,
currentPath,
isActive,
defaultMatch = "exact",
linkComponent = DefaultLink,
className,
id: idProp,
"aria-label": ariaLabel = "Main navigation",
defaultCollapsed,
defaultMobileOpen,
defaultCollapsedSectionIds,
isCollapsed,
isMobileOpen,
onCollapsedChange,
onMobileOpenChange,
onItemClick,
onItemHover,
onItemFocus,
onItemNavigate,
onActiveItemChange,
onMount,
onUnmount,
onSectionToggle,
autoCloseMobileOnNavigate = true,
keepEmptySections = false,
permissions,
renderItem,
renderSection,
renderBadge,
renderTooltipContent,
renderLoading,
renderEmptyState,
loading = false,
state: externalState,
storageKey,
autoExpandActiveSection = true,
autoScrollActiveIntoView = true,
brandSlot,
brand,
headerSlot,
navAccessorySlot,
primaryActionSlot,
primaryAction,
footerSlot,
footer,
side = "left",
activeVariant = "fill",
collapsedWidth,
expandedWidth,
transitionDuration,
style,
mobileBreakpoint = "lg",
mobileDrawerSide,
drawerHeaderSlot,
skipLinkTarget,
skipLinkLabel = "Skip to content",
onPermissionDenied,
onSkipLinkActivated,
onBrandClick,
onPrimaryActionClick,
onFooterTriggerOpen,
onFooterMenuItemClick,
ref: externalRef,
// v0.2.0 — additive (L41–L52)
topSlot,
hrefTemplateValues,
resolveHref,
isOwner,
currentMaxMembers,
bypassFiltering,
} = props;
// L32: id defaults via useId() for <SidebarNavTrigger aria-controls>
const autoId = useId();
const sidebarId = idProp ?? `app-sidebar-${autoId}`;
// Section-collapse init priority (plan §8): explicit prop > per-section
// defaultCollapsed. Storage rehydration (C11) will outrank both via
// EXTERNAL_SYNC after mount.
const initialCollapsedSectionIds = useMemo(() => {
if (defaultCollapsedSectionIds) return defaultCollapsedSectionIds;
const fromItems: string[] = [];
for (const entry of items) {
if (entry.kind === "section" && entry.defaultCollapsed) {
fromItems.push(entry.id);
}
}
return fromItems;
}, [defaultCollapsedSectionIds, items]);
// Reducer + Defense-1 + Defense-2 wiring
const { state, dispatch } = useSidebarReducer({
defaultCollapsed,
defaultMobileOpen,
defaultCollapsedSectionIds: initialCollapsedSectionIds,
isCollapsed,
isMobileOpen,
onCollapsedChange,
onMobileOpenChange,
});
// localStorage opt-in (L23). Per L43: when external `state` provided,
// the hook owns storageKey; component's storageKey is ignored + dev warn.
const effectiveStorageKey = externalState ? undefined : storageKey;
if (
externalState &&
storageKey &&
process.env.NODE_ENV !== "production"
) {
console.warn(
"[app-sidebar] `storageKey` is ignored when `state` (lifted hook) is provided — the hook owns persistence (L43). Move storageKey to useAppSidebarState() options.",
);
}
useStorageSync(state, dispatch, effectiveStorageKey);
// Items pipeline: derive visible entries → compute active item
const { visible, active } = useActiveDetection({
items,
currentPath,
isActive,
defaultMatch,
permissions,
keepEmptySections,
// v0.2.0 — three-gate intersection (L46) + bypass (Q21)
isOwner,
currentMaxMembers,
bypassFiltering,
});
// F1 — auto-expand section containing the active item (L48-b).
// When external state is provided, the hook (useAppSidebarState)
// owns F1; component-internal effect skipped to avoid double-firing.
useEffect(() => {
if (externalState) return;
if (!autoExpandActiveSection) return;
if (!active.sectionId) return;
if (!state.collapsedSectionIds.has(active.sectionId)) return;
dispatch({
type: "SET_SECTION_COLLAPSED",
sectionId: active.sectionId,
collapsed: false,
});
}, [
externalState,
autoExpandActiveSection,
active.sectionId,
state.collapsedSectionIds,
dispatch,
]);
// Breakpoint resolution — CSS class names for the L44 CSS-gated render path.
// `useMatchMedia` returns the live mobile state for JS BEHAVIOR gating only
// (e.g., autoCloseMobileOnNavigate timing decisions — actual visual swap is
// CSS-driven, no SSR flash).
const desktopVisibleClass =
typeof mobileBreakpoint === "string" &&
mobileBreakpoint in BREAKPOINT_DESKTOP_VISIBLE
? BREAKPOINT_DESKTOP_VISIBLE[mobileBreakpoint]
: "lg:flex";
const mobileVisibleClass =
typeof mobileBreakpoint === "string" &&
mobileBreakpoint in BREAKPOINT_MOBILE_VISIBLE
? BREAKPOINT_MOBILE_VISIBLE[mobileBreakpoint]
: "lg:hidden";
const isMobileBehavior = useMatchMedia(resolveBreakpointQuery(mobileBreakpoint));
// closeMobile / toggleSection are wired through `finalHandle` so they
// mutate the EXTERNAL state when one is provided, or the internal
// reducer otherwise. Trade-off (L20 reasons): when external state,
// the auto-close path loses the "item-click" reason discriminator —
// `onMobileOpenChange` fires with "imperative" instead. Acceptable
// for v0.1; a future polish commit can add `setMobileOpenWithReason`
// to the handle if needed.
// Imperative handle — refreshed with items + active-detection results.
// v0.3.0 (C5, F5): delegated to the shared `buildHandle` factory.
// Identical factory consumed by `useAppSidebarState` so the two state
// paths can't drift apart.
const handle = useMemo<AppSidebarHandle>(
() => buildHandle({ state, dispatch, items, visible, active }),
[state, dispatch, items, visible, active],
);
// L30 — state prop precedence: external lifted state wins entirely over
// internal reducer state. Internal reducer still computed for hooks-rules
// compliance (L47), but its values flow through `finalCollapsed` /
// `finalMobileOpen` / etc. only when external state isn't supplied.
const finalCollapsed = externalState?.collapsed ?? state.collapsed;
const finalMobileOpen = externalState?.mobileOpen ?? state.mobileOpen;
const finalCollapsedSectionIds =
externalState?.collapsedSectionIds ?? state.collapsedSectionIds;
const finalActiveItem = externalState?.activeItem ?? active.item;
const finalVisibleEntries = externalState?.visibleEntries ?? visible.entries;
const finalHandle: AppSidebarHandle = externalState ?? handle;
// F-cross-13 (v0.3.2): `onPointerDownOutside` / `onEscapeKeyDown` are
// Radix-only SheetContent props — Base UI's DialogPopup rejects them. The
// public `onMobileOpenChange.reason` discriminator ("outside-click" |
// "escape") survives via own attribution: capture-phase document listeners
// record the reason (with a freshness stamp so a non-closing Escape can't
// mis-tag a later close); the cross-backend onOpenChange dispatch reads it.
const mobilePanelRef = useRef<HTMLDivElement | null>(null);
const mobileCloseReasonRef = useRef<{
reason: AppSidebarMobileOpenReason;
t: number;
} | null>(null);
useEffect(() => {
if (!finalMobileOpen) return;
const onPointerDown = (e: PointerEvent) => {
const panel = mobilePanelRef.current;
if (panel && e.target instanceof Node && !panel.contains(e.target)) {
mobileCloseReasonRef.current = { reason: "outside-click", t: Date.now() };
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
mobileCloseReasonRef.current = { reason: "escape", t: Date.now() };
}
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
mobileCloseReasonRef.current = null;
};
}, [finalMobileOpen]);
// Flattened keyboard traversal sequence (L37). Section headers (collapsible
// only) interleave with their items; items hide when section is collapsed.
const keyboardFlat = useMemo(
() =>
flattenEntriesForKeyboard(finalVisibleEntries, finalCollapsedSectionIds),
[finalVisibleEntries, finalCollapsedSectionIds],
);
const keyboardEntryId = keyboardFlat.length > 0 ? keyboardFlat[0].id : null;
// Reducer-driven focus state. Even when external `state` is provided,
// focus tracking lives in the internal reducer — focus is transient UI,
// not persistable consumer state.
const focusedItemId = state.focusedItemId;
// L38 — onPermissionDenied diff-firing. Fire once per item initially +
// again for any item NEWLY entering the filtered set on subsequent renders.
// Comparison runs on each render via the ref-tracked previous set.
const prevFilteredRef = useRef<ReadonlySet<string> | null>(null);
useEffect(() => {
const currentFiltered = visible.filteredByPermission;
const currentIds = new Set(currentFiltered.map((f) => f.item.id));
if (onPermissionDenied) {
const prevIds = prevFilteredRef.current;
for (const entry of currentFiltered) {
if (prevIds === null || !prevIds.has(entry.item.id)) {
onPermissionDenied({
item: entry.item,
requiredPermission: entry.requiredPermission,
});
}
}
}
prevFilteredRef.current = currentIds;
}, [visible.filteredByPermission, onPermissionDenied]);
// Programmatic focus follow-up — when reducer's focusedItemId changes,
// move DOM focus to the matching row (via `data-nav-id`). Layout effect
// so focus lands before the browser paints, avoiding visible flashes.
useEffect(() => {
if (!focusedItemId) return;
if (typeof document === "undefined") return;
const nav = document.getElementById(sidebarId);
if (!nav) return;
// v0.3.0 (C3, F3): CSS.escape is universal in modern browsers
// (Chrome 46+ / Edge 79+ / Firefox 31+ / Safari 10+) — the SSR guard
// above already gates this code to the browser. The old `replace`
// fallback only escaped `"` and `\`, missing `:`, `]`, leading digits,
// and dozens of other selector-illegal characters.
const escaped = window.CSS.escape(focusedItemId);
const target = nav.querySelector<HTMLElement>(
`[data-nav-id="${escaped}"]`,
);
if (target && document.activeElement !== target) {
target.focus();
}
}, [focusedItemId, sidebarId]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
handleSidebarKeydown(event, {
flat: keyboardFlat,
focusedId: focusedItemId,
setFocusedId: (id) => dispatch({ type: "FOCUS_ITEM", itemId: id }),
toggleSection: (id) => finalHandle.toggleSection(id),
isSectionCollapsed: (id) => finalCollapsedSectionIds.has(id),
});
},
[keyboardFlat, focusedItemId, dispatch, finalHandle, finalCollapsedSectionIds],
);
// F2 — auto-scroll active item into view on mount + currentPath change (L48-c)
useEffect(() => {
if (!autoScrollActiveIntoView) return;
if (typeof document === "undefined") return;
if (!finalActiveItem) return;
const nav = document.getElementById(sidebarId);
if (!nav) return;
const activeEl = nav.querySelector('[data-active="true"]');
if (!(activeEl instanceof HTMLElement)) return;
const reducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
activeEl.scrollIntoView({
block: "nearest",
behavior: reducedMotion ? "auto" : "smooth",
});
}, [autoScrollActiveIntoView, finalActiveItem, sidebarId]);
// Helpers used by render — wired through finalHandle so they mutate
// external state (when provided) or internal reducer (default).
// v0.3.0 (C2, L54): closeMobile accepts a reason for the discriminator
// (default "imperative" if unspecified — preserves v0.2.x callers).
const closeMobile = useCallback(
(reason?: AppSidebarMobileOpenReason) => {
if (!isMobileBehavior) return;
finalHandle.closeMobile(reason);
},
[finalHandle, isMobileBehavior],
);
const toggleSection = useCallback(
(sectionId: string) => {
finalHandle.toggleSection(sectionId);
},
[finalHandle],
);
// Attach the imperative handle to the consumer-supplied ref (React 19
// ref-as-prop pattern). When external state provided, ref exposes the
// external handle so trigger toggles drive the right state machine.
useImperativeHandle(externalRef, () => finalHandle, [finalHandle]);
/*
* v0.3.1 — lifecycle + derived-state events.
*
* `onItemHover`, `onItemFocus`, `onActiveItemChange`, `onMount` and
* `onUnmount` were all declared in `AppSidebarProps`, documented in the
* description, and never read by a single line of implementation. The plan
* doc even carries a High review finding correcting `onActiveItemChange`'s
* TIMING (P2) — a careful argument about a callback that was then wired to
* nothing. `NavLinkProps` had likewise declared `onMouseEnter`/`onFocus`
* from the start with nothing passing them.
*/
const firedMountRef = useRef(false);
useEffect(() => {
if (firedMountRef.current) return;
firedMountRef.current = true;
onMount?.({ initialState: finalHandle.getState() });
// Mount-only by contract: `initialState` is the state AT mount. Re-running
// on handle identity would re-announce a mount that already happened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Unmount fires during cleanup, when props are already gone — so the latest
// callback is held in a ref rather than captured in the effect closure.
const onUnmountRef = useRef(onUnmount);
useEffect(() => {
onUnmountRef.current = onUnmount;
}, [onUnmount]);
useEffect(
() => () => {
onUnmountRef.current?.();
},
[],
);
/*
* `activeItem` is DERIVED from `currentPath`, which changes when the host's
* router commits — so this is reactive, not click-synchronous. Firing it from
* the click handler (the original design) would report a change that had not
* happened yet, and miss every navigation the host performed on its own.
*
* The initial value is not a "change": it is delivered by `onMount`'s
* `initialState`. Comparing by `id` keeps a re-rendered-but-equal item from
* emitting a phantom transition.
*/
const previousActiveItemRef = useRef<NavItem | null>(null);
const activeChangeInitialisedRef = useRef(false);
useEffect(() => {
const item = finalActiveItem ?? null;
if (!activeChangeInitialisedRef.current) {
activeChangeInitialisedRef.current = true;
previousActiveItemRef.current = item;
return;
}
const previousItem = previousActiveItemRef.current;
if ((previousItem?.id ?? null) === (item?.id ?? null)) return;
previousActiveItemRef.current = item;
onActiveItemChange?.({ item, previousItem });
}, [finalActiveItem, onActiveItemChange]);
const contextValue = useMemo<AppSidebarContextValue>(
() => ({
state: { ...state, collapsed: finalCollapsed, mobileOpen: finalMobileOpen, collapsedSectionIds: finalCollapsedSectionIds },
dispatch,
handle: finalHandle,
sidebarId,
}),
[state, dispatch, finalHandle, sidebarId, finalCollapsed, finalMobileOpen, finalCollapsedSectionIds],
);
const cssVars = useMemo(
() => deriveCssVars({ collapsedWidth, expandedWidth, transitionDuration }),
[collapsedWidth, expandedWidth, transitionDuration],
);
const defaultAccessory = (
<button
type="button"
onClick={() => finalHandle.toggleCollapse()}
aria-label={finalCollapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!finalCollapsed}
aria-controls={sidebarId}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-muted-foreground hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
)}
>
{finalCollapsed ? (
<PanelLeft className="h-4 w-4" aria-hidden="true" />
) : (
<PanelLeftClose className="h-4 w-4" aria-hidden="true" />
)}
</button>
);
// Shared inner chrome — used by BOTH desktop <nav> and mobile <Sheet>.
// Single source of truth for the header / list / footer composition.
//
// Header-row visibility rules:
// desktop: ALWAYS renders (defaultAccessory always available unless
// consumer explicitly passes navAccessorySlot={null} to hide it
// AND supplies neither header nor brand → only then suppressed)
// mobile: renders only when drawerHeaderSlot OR headerSlot OR resolvedBrand
// supplied (mobile drawer has no collapse toggle — sheet close
// button handles that)
// Slot-vs-config resolution (L13): slot wins over shorthand config.
// Component-level events (onBrandClick / onPrimaryActionClick /
// onFooterTriggerOpen / onFooterMenuItemClick) are wired into the resolved
// prefabs here so they fire regardless of whether the consumer used the
// shorthand config or supplied their own slot. Slot consumers wire their
// own callbacks directly — the component-level callbacks only apply to
// the prefab path.
const resolvedBrand = useMemo(() => {
if (brandSlot) return brandSlot;
if (!brand) return null;
const brandElement = <NavBrand {...brand} />;
if (!onBrandClick) return brandElement;
return (
<span
onClick={(event) => onBrandClick({ event })}
className="contents"
>
{brandElement}
</span>
);
}, [brandSlot, brand, onBrandClick]);
const resolvedPrimaryAction = useMemo(() => {
if (primaryActionSlot) return primaryActionSlot;
if (!primaryAction) return null;
if (!onPrimaryActionClick) return <NavPrimaryAction {...primaryAction} />;
return (
<NavPrimaryAction
{...primaryAction}
onClick={(event) => {
primaryAction.onClick?.(event);
onPrimaryActionClick({ event });
}}
/>
);
}, [primaryActionSlot, primaryAction, onPrimaryActionClick]);
const resolvedFooter = useMemo(() => {
if (footerSlot) return footerSlot;
if (!footer) return null;
const wireMenuItem = onFooterMenuItemClick
? (() => {
const wired = footer.menuItems.map((entry) => {
if (entry.kind === "separator") return entry;
const item = entry;
return {
...item,
// v0.3.0 (C4, F10): widened event arg matches NavUserMenuItem.onClick.
onClick: (event: NavUserMenuItemSelectEvent) => {
item.onClick?.(event);
onFooterMenuItemClick({ menuItem: item, event });
},
};
});
return wired;
})()
: footer.menuItems;
return (
<NavUser
{...footer}
menuItems={wireMenuItem}
onTriggerOpen={(args) => {
footer.onTriggerOpen?.(args);
onFooterTriggerOpen?.(args);
}}
/>
);
}, [footerSlot, footer, onFooterTriggerOpen, onFooterMenuItemClick]);
const desktopAccessory =
navAccessorySlot === null ? null : (navAccessorySlot ?? defaultAccessory);
const showMobileHeader = !!drawerHeaderSlot || !!headerSlot || !!resolvedBrand;
// L39 — loading/empty branching precedence:
// loading=true → renderLoading slot OR default skeleton
// else items.length === 0 → renderEmptyState({reason: "no-items"})
// else visibleEntries.length === 0 AND filtered count > 0
// → renderEmptyState({reason: "all-filtered-by-permission"})
// else visibleEntries.length === 0 AND hidden count > 0
// → renderEmptyState({reason: "all-hidden"})
// else → normal list render
//
// v0.3.0 (C6, F7): wrapped in useCallback so consumers in non-React-Compiler
// environments (registry installs into apps without the compiler) still get
// stable references and avoid the cascade of <SidebarNavList> re-renders on
// unrelated parent state changes.
const renderListBody = useCallback(
(mode: "desktop" | "mobile") => {
const collapsed = mode === "mobile" ? false : finalCollapsed;
if (loading) {
const defaultRender = (
<SidebarLoadingSkeleton isCollapsed={collapsed} />
);
if (renderLoading) {
return renderLoading({ isCollapsed: collapsed, defaultRender });
}
return defaultRender;
}
if (finalVisibleEntries.length === 0) {
// When external state is provided, internal `visible.*` diagnostic
// counts are best-effort (may not match externalState.items). Reason
// resolution is lossy in that path — defaults to "no-items".
const reason: AppSidebarEmptyReason =
items.length === 0
? "no-items"
: visible.filteredByPermission.length > 0
? "all-filtered-by-permission"
: visible.hiddenItemCount > 0
? "all-hidden"
: "no-items";
if (renderEmptyState) {
return renderEmptyState({ reason });
}
return <SidebarEmptyState reason={reason} />;
}
return (
<SidebarNavList
entries={finalVisibleEntries}
activeItemId={finalActiveItem?.id ?? null}
focusedItemId={focusedItemId}
keyboardEntryId={keyboardEntryId}
isCollapsed={collapsed}
linkComponent={linkComponent}
activeVariant={activeVariant}
autoCloseMobileOnNavigate={autoCloseMobileOnNavigate}
isMobileOpen={finalMobileOpen}
onCloseMobile={closeMobile}
collapsedSectionIds={finalCollapsedSectionIds}
onToggleSection={toggleSection}
onItemClick={onItemClick}
onItemHover={onItemHover}
onItemFocus={onItemFocus}
onItemNavigate={onItemNavigate}
onSectionToggle={onSectionToggle}
renderItem={renderItem}
renderSection={renderSection}
renderBadge={renderBadge}
renderTooltipContent={renderTooltipContent}
hrefTemplateValues={hrefTemplateValues}
resolveHref={resolveHref}
/>
);
},
[
finalCollapsed,
loading,
renderLoading,
finalVisibleEntries,
items.length,
visible.filteredByPermission.length,
visible.hiddenItemCount,
renderEmptyState,
finalActiveItem?.id,
focusedItemId,
keyboardEntryId,
linkComponent,
activeVariant,
autoCloseMobileOnNavigate,
finalMobileOpen,
closeMobile,
finalCollapsedSectionIds,
toggleSection,
onItemClick,
onItemHover,
onItemFocus,
onItemNavigate,
onSectionToggle,
renderItem,
renderSection,
renderBadge,
renderTooltipContent,
hrefTemplateValues,
resolveHref,
],
);
// v0.3.0 (C6, F7): wrapped in useCallback — see note on renderListBody.
const renderInnerChrome = useCallback(
(mode: "desktop" | "mobile") => {
const headerCollapsedDesktop = mode === "desktop" && finalCollapsed;
// v0.2 — when topSlot is supplied with NO brand/headerSlot, the brand
// row would otherwise render empty just to host the collapse toggle
// (desktopAccessory), leaving a wasteful gap below topSlot. Merge the
// accessory into the topSlot row instead (expanded desktop only —
// collapsed mode keeps its own vertical-stack layout for the toggle).
const mergeAccessoryIntoTopSlot =
mode === "desktop" &&
!headerCollapsedDesktop &&
!!topSlot &&
!headerSlot &&
!resolvedBrand &&
desktopAccessory !== null;
const shouldShowBrandRow =
mode === "desktop"
? !!headerSlot ||
!!resolvedBrand ||
(desktopAccessory !== null && !mergeAccessoryIntoTopSlot)
: showMobileHeader;
return (
<>
{/* v0.2.0 — topSlot above the brand zone (L41). Geographically distinct
* from headerSlot (which renders INSIDE the brand row). Zero layout
* shift when null/undefined per success #10. PQ3: unlabeled wrapper —
* ARIA semantics are the consumer's responsibility.
*
* When `mergeAccessoryIntoTopSlot` is true (topSlot present, no brand
* row content of its own), the collapse toggle moves into this row
* pinned to the right — eliminates the empty brand-row gap. */}
{topSlot ? (
<div
className={cn(
"ilinxa-sidebar-top-slot border-b border-border",
// Collapsed desktop: 80px-wide sidebar — center the topSlot
// content so icon-only widgets (the canonical AccountSwitcher
// 40×40 trigger) line up with the nav-row icons below. Without
// this the icon sits left-aligned in p-3 padding (12+40+24 in
// an 80px column) and visually drifts from the rest.
headerCollapsedDesktop
? "flex items-center justify-center px-2 py-2"
: "p-3",
mergeAccessoryIntoTopSlot && "flex items-center gap-2",
)}
>
{mergeAccessoryIntoTopSlot ? (
<>
<div className="min-w-0 flex-1">{topSlot}</div>
<div className="shrink-0">{desktopAccessory}</div>
</>
) : (
topSlot
)}
</div>
) : null}
{/* Brand / header zone.
*
* Layout:
* - expanded desktop / mobile drawer: horizontal flex — brand on the
* left, toggle on the right (`ml-auto`).
* - collapsed desktop: stack vertically and center — there isn't
* room for both brand (32px) and toggle (32px) plus gap inside
* an 80px-wide column without the two visually overlapping. */}
{shouldShowBrandRow && (
<div
className={cn(
"flex border-b border-border min-h-14",
headerCollapsedDesktop
? "flex-col items-center gap-1 px-2 py-2"
: "items-center gap-2 p-3",
)}
>
{mode === "mobile" && drawerHeaderSlot ? (
<div className="contents">{drawerHeaderSlot}</div>
) : (
<>
{headerSlot && <div className="contents">{headerSlot}</div>}
{resolvedBrand && (
<div
className={cn(
headerCollapsedDesktop
? "flex shrink-0 items-center justify-center"
: "flex-1 min-w-0",
)}
>
{resolvedBrand}
</div>
)}
{!resolvedBrand && !headerSlot && !headerCollapsedDesktop && (
<div className="flex-1" aria-hidden />
)}
{mode === "desktop" &&
desktopAccessory !== null &&
!mergeAccessoryIntoTopSlot && (
<div
className={cn(
headerCollapsedDesktop ? "shrink-0" : "ml-auto shrink-0",
)}
>
{desktopAccessory}
</div>
)}
</>
)}
</div>
)}
{/* Nav list — loading/empty branching per L39 */}
<div className="flex flex-1 flex-col gap-2 overflow-hidden p-3">
{renderListBody(mode)}
{resolvedPrimaryAction && (
<div className="pt-2">{resolvedPrimaryAction}</div>
)}
</div>
{/* Footer zone */}
{resolvedFooter && (
<div className="border-t border-border p-3">{resolvedFooter}</div>
)}
</>
);
},
[
finalCollapsed,
topSlot,
headerSlot,
resolvedBrand,
desktopAccessory,
showMobileHeader,
drawerHeaderSlot,
renderListBody,
resolvedPrimaryAction,
resolvedFooter,
],
);
const drawerSide = mobileDrawerSide ?? side;
return (
<AppSidebarContext.Provider value={contextValue}>
{/* Desktop render path — CSS-hidden BELOW breakpoint (L44) */}
<nav
id={sidebarId}
aria-label={ariaLabel}
data-component="app-sidebar"
data-collapsed={finalCollapsed}
data-mobile-open={finalMobileOpen}
data-side={side}
onKeyDown={handleKeyDown}
style={{ ...cssVars, ...style }}
className={cn(
"relative hidden h-full flex-col bg-card",
desktopVisibleClass,
// Side-aware border
side === "left" ? "border-r" : "border-l",
"border-border",
// CSS-var-driven width + motion-safe transition
"data-[collapsed=false]:w-(--ilinxa-sidebar-w-expanded)",
"data-[collapsed=true]:w-(--ilinxa-sidebar-w-collapsed)",
"motion-safe:transition-[width] motion-safe:duration-(--ilinxa-sidebar-transition-duration)",
// RTL hook
"rtl:border-x-0",
side === "left" ? "rtl:border-l" : "rtl:border-r",
className,
)}
>
{skipLinkTarget && (
<SidebarSkipLink
target={skipLinkTarget}
label={skipLinkLabel}
onActivated={onSkipLinkActivated}
/>
)}
{renderInnerChrome("desktop")}
</nav>
{/* Mobile render path — CSS-hidden ABOVE breakpoint */}
<div
className={mobileVisibleClass}
data-component="app-sidebar-mobile-wrapper"
style={cssVars}
>
<Sheet
open={finalMobileOpen}
// F-cross-13 defensive: Radix passes (open: boolean); Base UI may pass
// undefined or different shape. Runtime-check before mutating.
// Routed through finalHandle so external state (when provided)
// is the mutation target.
//
// v0.3.2 (F-cross-13): close reasons come from the document-level
// attribution refs above (the Radix-only SheetContent props are
// gone). A stamp older than 500ms is stale — e.g. an Escape that
// didn't close — and falls back to the default reason.
onOpenChange={(nextOpen: boolean | undefined) => {
if (typeof nextOpen !== "boolean") return;
if (nextOpen) {
finalHandle.openMobile();
return;
}
const stamped = mobileCloseReasonRef.current;
mobileCloseReasonRef.current = null;
if (stamped && Date.now() - stamped.t < 500) {
finalHandle.closeMobile(stamped.reason);
} else {
finalHandle.closeMobile();
}
}}
>
<SheetContent
ref={mobilePanelRef}
side={drawerSide}
// NOTE: do NOT add `relative` here — it would tailwind-merge
// away Radix's `fixed inset-y-0 {side}-0`, leaving the panel
// unpositioned and invisible against the overlay backdrop.
className="flex w-72 flex-col bg-card p-0"
style={cssVars}
aria-describedby={undefined}
onKeyDown={handleKeyDown}
>
<SheetTitle className="sr-only">{ariaLabel}</SheetTitle>
{skipLinkTarget && (
<SidebarSkipLink
target={skipLinkTarget}
label={skipLinkLabel}
onActivated={onSkipLinkActivated}
/>
)}
{renderInnerChrome("mobile")}
</SheetContent>
</Sheet>
</div>
</AppSidebarContext.Provider>
);
}
src/registry/components/navigation/app-sidebar/index.ts
export { AppSidebar } from "./app-sidebar";
export { AppSidebarTrigger } from "./parts/sidebar-nav-trigger";
export { NavBadge } from "./parts/nav-badge";
export { NavBrand } from "./parts/nav-brand";
export { NavPrimaryAction } from "./parts/nav-primary-action";
export { NavUser } from "./parts/nav-user";
export { useAppSidebarState } from "./hooks/use-sidebar-nav-state";
export { useFilteredNavSections } from "./hooks/use-filtered-nav-sections";
export type { UseFilteredNavSectionsOpts } from "./hooks/use-filtered-nav-sections";
export type {
// Items schema
NavItem,
NavSection,
NavSeparator,
NavEntry,
BasicNavItems,
SidebarNavItems,
// Link primitive
NavLinkProps,
NavLinkComponent,
// Prefab part configs
NavBadgeConfig,
NavBrandConfig,
NavPrimaryActionConfig,
NavUserMenuItem,
NavUserMenuItemSelectEvent, // v0.3.0 (L56) — event arg type for NavUserMenuItem.onClick
NavUserConfig,
// Main component
AppSidebarProps,
AppSidebarHandle,
AppSidebarStateValue,
AppSidebarMobileOpenReason,
AppSidebarEmptyReason,
AppSidebarEventArgs,
// Render-prop slot args
AppSidebarRenderItemArgs,
AppSidebarRenderBadgeArgs,
AppSidebarRenderTooltipContentArgs,
AppSidebarRenderSectionArgs,
AppSidebarRenderLoadingArgs,
AppSidebarRenderEmptyStateArgs,
// Companion + hook options
AppSidebarTriggerProps,
UseAppSidebarStateOptions,
// v0.2.0 — additive
NavContext,
} from "./types";
src/registry/components/navigation/app-sidebar/types.ts
import type { ComponentType, CSSProperties, ReactNode, Ref } from "react";
// ─────────────────────────────────────────────────────────────────────────
// Items schema (L4 + L5 + L36 + L48)
// ─────────────────────────────────────────────────────────────────────────
export interface NavItem {
kind?: "item";
id: string;
label: string;
icon?: ReactNode | ComponentType<{ className?: string }>;
href?: string;
onClick?: (event: React.MouseEvent) => void;
badge?: number | string | NavBadgeConfig;
match?: "exact" | "prefix";
shortcut?: string;
description?: string;
accessory?: ReactNode;
tooltipContent?: ReactNode;
target?: "_blank" | "_self" | "_parent" | "_top";
rel?: string;
permission?: string;
/**
* v0.2.0 — When `true`, item is hidden in the filter pass unless the
* sidebar's `isOwner` prop is also `true`. Default `false`. Works
* alongside `permission` and `minMembers` — all three gates pass
* independently (intersection per L46).
*/
ownerOnly?: boolean;
/**
* v0.2.0 — When set, item is hidden unless sidebar's `currentMaxMembers`
* prop is `>=` this value. Default unset (no min). Useful for plan-tier
* gating (Members tab visible only when seat capacity ≥ N).
*/
minMembers?: number;
disabled?: boolean;
hidden?: boolean;
className?: string;
"data-testid"?: string;
}
export interface NavSection {
kind: "section";
id: string;
title?: string;
icon?: ReactNode | ComponentType<{ className?: string }>;
collapsible?: boolean;
defaultCollapsed?: boolean;
items: ReadonlyArray<NavItem>;
permission?: string;
hidden?: boolean;
}
export interface NavSeparator {
kind: "separator";
id?: string;
}
export type NavEntry = NavItem | NavSection | NavSeparator;
export type BasicNavItems = ReadonlyArray<NavItem>;
export type SidebarNavItems = ReadonlyArray<NavEntry>;
// ─────────────────────────────────────────────────────────────────────────
// NavContext discriminated union (v0.2.0 — L48 + I-6)
// ─────────────────────────────────────────────────────────────────────────
/**
* Discriminated union for app-shell context types — exported helper for
* consumers building multi-tenant SaaS surfaces. Type-only export; library
* does NOT ship a `useNavContext` hook because the URL→context derivation
* is coupled to the consumer's router (Next.js, TanStack Router, plain
* `location` — all different). Consumers type their derivation function
* with `(): NavContext` and TypeScript narrows correctly across the
* discriminant.
*
* Source of the 5-case shape: migration analysis §8.2 (the kasder
* socialmedia-adv-nav-system app-shell). Consumers needing different
* context shapes type their own; this is a documented helper, not
* mandatory. R13 acknowledges the opinionated shape.
*/
export type NavContext =
| { type: "personal" }
| { type: "business"; slug: string; accountId: string; accountName: string }
| { type: "platform"; accountId: string }
| { type: "governance" }
| { type: "cms"; mode: "platform" }
| { type: "cms"; mode: "business"; slug: string; accountId: string; accountName: string };
// ─────────────────────────────────────────────────────────────────────────
// Link primitive (L10 + L15)
// ─────────────────────────────────────────────────────────────────────────
export interface NavLinkProps {
href: string;
className?: string;
"aria-current"?: "page" | undefined;
"aria-label"?: string;
"aria-disabled"?: boolean | "true" | "false" | undefined;
"data-active"?: boolean;
children?: ReactNode;
onClick?: (e: React.MouseEvent) => void;
onMouseEnter?: (e: React.MouseEvent) => void;
onFocus?: (e: React.FocusEvent) => void;
target?: string;
rel?: string;
tabIndex?: number;
ref?: Ref<HTMLAnchorElement>;
[key: `data-${string}`]: unknown;
}
export type NavLinkComponent = ComponentType<NavLinkProps>;
// ─────────────────────────────────────────────────────────────────────────
// Prefab part configs (L14, L15)
// ─────────────────────────────────────────────────────────────────────────
export interface NavBadgeConfig {
value: number | string | ReactNode;
max?: number;
variant?: "number" | "dot" | "pulse";
tone?: "default" | "accent" | "destructive" | "muted";
position?: "inline-end" | "corner";
showZero?: boolean;
className?: string;
}
export interface NavBrandConfig {
logo?: ReactNode | { src: string; alt?: string };
label: string;
href?: string;
linkComponent?: NavLinkComponent;
}
export interface NavPrimaryActionConfig {
icon: ReactNode | ComponentType<{ className?: string }>;
label: string;
onClick?: (event: React.MouseEvent) => void;
href?: string;
linkComponent?: NavLinkComponent;
variant?: "default" | "outline" | "ghost" | "secondary";
tone?: "default" | "accent" | "destructive";
}
/**
* v0.3.0 — Event union for `NavUserMenuItem.onClick` callbacks. The underlying
* primitive (`DropdownMenuItem.onSelect`) passes a plain `Event` for keyboard
* activations and a `React.MouseEvent` for clicks. Use this alias to type
* custom `onClick` handlers without spelling out the union:
*
* ```ts
* const handler = (event: NavUserMenuItemSelectEvent) => {
* if (event instanceof MouseEvent) { console.log(event.clientX); }
* };
* ```
*/
export type NavUserMenuItemSelectEvent = Event | React.MouseEvent;
export interface NavUserMenuItem {
kind: "item";
icon?: ReactNode | ComponentType<{ className?: string }>;
label: string;
/**
* v0.3.0 — widened from `React.MouseEvent` to `Event | React.MouseEvent` to
* honestly type the event arg passed by Radix's `DropdownMenuItem.onSelect`
* (which may be a plain `Event` or `MouseEvent` depending on input modality
* and primitive vendor). v0.2.x consumers reading MouseEvent-only fields
* (e.g. `event.clientX`) narrow with `if (event instanceof MouseEvent) { … }`
* or cast at the call site.
*/
onClick?: (event: NavUserMenuItemSelectEvent) => void;
href?: string;
linkComponent?: NavLinkComponent;
variant?: "default" | "destructive";
shortcut?: string;
disabled?: boolean;
}
export interface NavUserConfig {
user: {
name: string;
handle?: string;
avatarUrl?: string;
avatarFallback?: string;
status?: "online" | "offline" | "busy" | "away" | "invisible";
};
menuItems: ReadonlyArray<NavUserMenuItem | { kind: "separator" }>;
onTriggerOpen?: (args: { open: boolean }) => void;
}
// ─────────────────────────────────────────────────────────────────────────
// Events + state (L20 — object-args throughout)
// ─────────────────────────────────────────────────────────────────────────
export type AppSidebarMobileOpenReason =
| "trigger"
| "item-click"
| "outside-click"
| "escape"
| "imperative";
export interface AppSidebarHandle {
// Collapse
toggleCollapse(): void;
setCollapsed(next: boolean): void;
isCollapsed(): boolean;
// Mobile drawer
// v0.3.0 (L54): optional `reason?` param propagates through to
// `onMobileOpenChange.reason`. Default `"imperative"` preserves v0.2.x
// call sites — `handle.closeMobile()` still works exactly as before.
/** Open the mobile drawer. Optional `reason` reaches `onMobileOpenChange.reason`. Default `"imperative"`. */
openMobile(reason?: AppSidebarMobileOpenReason): void;
/** Close the mobile drawer. Optional `reason` reaches `onMobileOpenChange.reason`. Default `"imperative"`. */
closeMobile(reason?: AppSidebarMobileOpenReason): void;
/** Toggle the mobile drawer. Optional `reason` reaches `onMobileOpenChange.reason`. Default `"imperative"`. */
toggleMobile(reason?: AppSidebarMobileOpenReason): void;
isMobileOpen(): boolean;
// Section state
toggleSection(sectionId: string): void;
expandSection(sectionId: string): void;
collapseSection(sectionId: string): void;
/** Expand every section (clears the entire collapsed-set). */
expandAllSections(): void;
/**
* Collapse every currently-VISIBLE section.
*
* v0.3.0 NOTE: operates on sections that survive the permission /
* ownerOnly / minMembers filter pass — sections hidden by gates are not
* touched. To collapse a section regardless of visibility, call
* `collapseSection(id)` directly with its id.
*/
collapseAllSections(): void;
isSectionCollapsed(sectionId: string): boolean;
// Items + active
getItems(): ReadonlyArray<NavEntry>;
getItemById(id: string): NavItem | undefined;
getActiveItem(): NavItem | undefined;
// Focus
focusItem(id: string): void;
focusFirstItem(): void;
focusLastItem(): void;
// Snapshot
getState(): AppSidebarStateValue;
}
export interface AppSidebarStateValue extends AppSidebarHandle {
collapsed: boolean;
mobileOpen: boolean;
collapsedSectionIds: ReadonlySet<string>;
activeItemId: string | null;
activeItem: NavItem | null;
visibleEntries: ReadonlyArray<NavEntry>;
}
// ─────────────────────────────────────────────────────────────────────────
// Empty-state reason (L48)
// ─────────────────────────────────────────────────────────────────────────
export type AppSidebarEmptyReason =
| "no-items"
| "all-filtered-by-permission"
| "all-hidden"
| "all-filtered-by-loading";
// ─────────────────────────────────────────────────────────────────────────
// Render-prop slot arg shapes (L29)
// ─────────────────────────────────────────────────────────────────────────
export interface AppSidebarRenderItemArgs {
item: NavItem;
isActive: boolean;
isCollapsed: boolean;
isFocused: boolean;
isDisabled: boolean;
sectionId: string | null;
indexInSection: number;
defaultRender: ReactNode;
}
export interface AppSidebarRenderBadgeArgs {
item: NavItem;
badge: NavBadgeConfig;
position: "inline-end" | "corner";
defaultRender: ReactNode;
}
export interface AppSidebarRenderTooltipContentArgs {
item: NavItem;
isActive: boolean;
}
export interface AppSidebarRenderSectionArgs {
section: NavSection;
isCollapsed: boolean;
visibleItemCount: number;
defaultRender: ReactNode;
}
export interface AppSidebarRenderLoadingArgs {
isCollapsed: boolean;
defaultRender: ReactNode;
}
export interface AppSidebarRenderEmptyStateArgs {
reason: AppSidebarEmptyReason;
}
// ─────────────────────────────────────────────────────────────────────────
// Event arg shapes (L20)
// ─────────────────────────────────────────────────────────────────────────
export interface AppSidebarEventArgs {
collapsedChange: { collapsed: boolean };
mobileOpenChange: { open: boolean; reason: AppSidebarMobileOpenReason };
itemClick: { item: NavItem; isActive: boolean; event: React.MouseEvent };
itemHover: { item: NavItem; event: React.MouseEvent };
itemFocus: { item: NavItem; event: React.FocusEvent };
itemNavigate: { item: NavItem };
activeItemChange: { item: NavItem | null; previousItem: NavItem | null };
sectionToggle: { section: NavSection; collapsed: boolean };
permissionDenied: { item: NavItem; requiredPermission: string };
brandClick: { event: React.MouseEvent };
primaryActionClick: { event: React.MouseEvent };
footerTriggerOpen: { open: boolean };
// v0.3.0 (C4, F10): event widened to NavUserMenuItemSelectEvent to match
// the widened NavUserMenuItem.onClick. Same callback chain — must be
// self-consistent. Consumers narrow with `event instanceof MouseEvent`.
footerMenuItemClick: { menuItem: NavUserMenuItem; event: NavUserMenuItemSelectEvent };
skipLinkActivated: { event: React.MouseEvent };
mount: { initialState: AppSidebarStateValue };
}
// ─────────────────────────────────────────────────────────────────────────
// Main component props (the full surface)
// ─────────────────────────────────────────────────────────────────────────
export interface AppSidebarProps {
// Items
items: ReadonlyArray<NavEntry>;
// Active detection
currentPath: string;
isActive?: (item: NavItem, currentPath: string) => boolean;
defaultMatch?: "exact" | "prefix";
// Link primitive
linkComponent?: NavLinkComponent;
// Collapse (uncontrolled / controlled / lifted via state)
defaultCollapsed?: boolean;
isCollapsed?: boolean;
/**
* Fired when the collapsed state changes.
*
* v0.3.0 NOTE: does NOT fire during localStorage rehydration on mount. The
* persisted collapsed state is already reflected in the initial render via
* the storage-read effect; firing the callback at that point would be
* confusing (the consumer didn't request the change). The callback only
* fires on user-initiated transitions: toggle button, controlled-prop
* change, or imperative handle call (`handle.toggleCollapse()` /
* `setCollapsed(next)`).
*/
onCollapsedChange?: (args: AppSidebarEventArgs["collapsedChange"]) => void;
// Mobile drawer (L8 + L24 + L44)
defaultMobileOpen?: boolean;
isMobileOpen?: boolean;
onMobileOpenChange?: (args: AppSidebarEventArgs["mobileOpenChange"]) => void;
mobileBreakpoint?: "sm" | "md" | "lg" | "xl" | "2xl" | (string & {});
mobileDrawerSide?: "left" | "right";
autoCloseMobileOnNavigate?: boolean;
// Lifted state (L30 — wins over individual props above)
state?: AppSidebarStateValue;
// Layout
side?: "left" | "right";
collapsedWidth?: string;
expandedWidth?: string;
transitionDuration?: string;
activeVariant?: "fill" | "left-bar" | "right-bar" | "outline" | "subtle";
// Sections (L48-b)
autoExpandActiveSection?: boolean;
defaultCollapsedSectionIds?: ReadonlyArray<string>;
keepEmptySections?: boolean;
// Active item view (L48-c)
autoScrollActiveIntoView?: boolean;
// Persistence (L23)
storageKey?: string;
// Permissions (L22)
permissions?: ReadonlySet<string>;
// Slots — named (L14)
headerSlot?: ReactNode;
brandSlot?: ReactNode;
brand?: NavBrandConfig;
navAccessorySlot?: ReactNode;
primaryActionSlot?: ReactNode;
primaryAction?: NavPrimaryActionConfig;
footerSlot?: ReactNode;
footer?: NavUserConfig;
drawerHeaderSlot?: ReactNode;
// Slots — render-prop (L13)
renderItem?: (args: AppSidebarRenderItemArgs) => ReactNode;
renderBadge?: (args: AppSidebarRenderBadgeArgs) => ReactNode;
renderTooltipContent?: (args: AppSidebarRenderTooltipContentArgs) => ReactNode;
renderSection?: (args: AppSidebarRenderSectionArgs) => ReactNode;
renderLoading?: (args: AppSidebarRenderLoadingArgs) => ReactNode;
renderEmptyState?: (args: AppSidebarRenderEmptyStateArgs) => ReactNode;
// Loading
loading?: boolean;
// Events
onItemClick?: (args: AppSidebarEventArgs["itemClick"]) => void;
onItemHover?: (args: AppSidebarEventArgs["itemHover"]) => void;
onItemFocus?: (args: AppSidebarEventArgs["itemFocus"]) => void;
onItemNavigate?: (args: AppSidebarEventArgs["itemNavigate"]) => void;
onActiveItemChange?: (args: AppSidebarEventArgs["activeItemChange"]) => void;
onSectionToggle?: (args: AppSidebarEventArgs["sectionToggle"]) => void;
onPermissionDenied?: (args: AppSidebarEventArgs["permissionDenied"]) => void;
onBrandClick?: (args: AppSidebarEventArgs["brandClick"]) => void;
onPrimaryActionClick?: (args: AppSidebarEventArgs["primaryActionClick"]) => void;
onFooterTriggerOpen?: (args: AppSidebarEventArgs["footerTriggerOpen"]) => void;
onFooterMenuItemClick?: (args: AppSidebarEventArgs["footerMenuItemClick"]) => void;
onSkipLinkActivated?: (args: AppSidebarEventArgs["skipLinkActivated"]) => void;
onMount?: (args: AppSidebarEventArgs["mount"]) => void;
onUnmount?: () => void;
// Standard
className?: string;
style?: CSSProperties;
"aria-label"?: string;
id?: string;
skipLinkTarget?: string;
skipLinkLabel?: string;
ref?: Ref<AppSidebarHandle>;
// ───────────────────────────────────────────────────────────────────────
// v0.2.0 — additive expansion (L41–L52). Zero breaking changes for v0.1.x
// consumers; every new prop is optional and defaults to v0.1 behavior.
// ───────────────────────────────────────────────────────────────────────
/**
* v0.2.0 — Slot above the brand row. Geographically distinct from v0.1's
* `headerSlot` (which is INSIDE the brand row, to the LEFT of brand).
* Hierarchy top → bottom: `topSlot` → `headerSlot` → `brandSlot` →
* `navAccessorySlot`. Consumer Fragment-stacks if multiple widgets
* needed (L41). Canonical occupant: `<AccountSwitcher>`.
*/
topSlot?: ReactNode;
/**
* v0.2.0 — Map of placeholder values for href substitution. When present,
* every `{key}` substring in any `NavItem.href` is replaced with
* `templateValues[key]` via `String.prototype.replaceAll`. Items whose
* href has no `{...}` placeholders render unchanged. Combines with
* `resolveHref` — `resolveHref` wins when both provided (L43).
*
* Dev-warns when an href references a `{xxx}` placeholder not present
* in the map (only at the substitution site; tree-shaken in prod).
*/
hrefTemplateValues?: Record<string, string>;
/**
* v0.2.0 — Escape-hatch callback for href resolution. When provided,
* wins precedence over `hrefTemplateValues`. Called per item per render
* — should be a stable function (`useCallback`). Return value is the
* final href string (strict `string`; consumer suppresses href by
* removing the item from `items`).
*/
resolveHref?: (
item: NavItem,
templateValues: Record<string, string> | undefined,
) => string;
/**
* v0.2.0 — Whether the current user is an owner. Fed into the filter
* pass; default `false` → all `ownerOnly` items hidden. Raw scalar, not
* opaque membership object (L52).
*/
isOwner?: boolean;
/**
* v0.2.0 — Current plan-tier seat capacity. Fed into the filter pass;
* default `Infinity` → all `minMembers` items visible.
*/
currentMaxMembers?: number;
/**
* v0.2.0 — When `true`, bypass the three permission gates (`permission`
* / `ownerOnly` / `minMembers`) at BOTH section AND item levels (Finding
* 4 — prevents "section disappears, items remain" inconsistency).
* `hidden: true` is still respected (Q21). Use case: personal-context
* shortcuts, admin overrides, debug views.
*/
bypassFiltering?: boolean;
}
// ─────────────────────────────────────────────────────────────────────────
// Companion component (L17)
// ─────────────────────────────────────────────────────────────────────────
export interface AppSidebarTriggerProps {
controls?: Ref<AppSidebarHandle> | AppSidebarHandle | null;
className?: string;
children?: ReactNode;
"aria-label"?: string;
asChild?: boolean;
}
// ─────────────────────────────────────────────────────────────────────────
// Headless hook options (L16)
// ─────────────────────────────────────────────────────────────────────────
export interface UseAppSidebarStateOptions {
defaultCollapsed?: boolean;
defaultMobileOpen?: boolean;
defaultCollapsedSectionIds?: ReadonlyArray<string>;
items?: ReadonlyArray<NavEntry>;
currentPath?: string;
isActive?: (item: NavItem, currentPath: string) => boolean;
defaultMatch?: "exact" | "prefix";
permissions?: ReadonlySet<string>;
storageKey?: string;
autoExpandActiveSection?: boolean;
}
src/registry/components/navigation/app-sidebar/contexts/sidebar-nav-context.tsx
"use client";
import { createContext, useContext } from "react";
import type { SidebarReducerAction, SidebarReducerState } from "../lib/sidebar-reducer";
import type { AppSidebarHandle } from "../types";
/**
* Context shape consumed by <AppSidebarTrigger> (C7), prefab parts
* (<NavBrand>, <NavUser>, <NavBadge>) (C6/C9), and any consumer-rendered
* descendant that wants to read sidebar state without prop drilling.
*
* L40: each <AppSidebar> creates its own provider scoped to its subtree,
* so multi-instance pages get isolated contexts automatically. <Trigger>
* reads the NEAREST provider (standard React context behavior).
*
* The context value is `null` when there's no provider above — descendants
* MUST handle this (e.g., <Trigger> falls back to disabled + dev warn;
* <NavBadge> falls back to position="inline-end" per L46).
*/
export interface AppSidebarContextValue {
// Latest reducer state — descendants read here, not via prop drilling
state: SidebarReducerState;
// Dispatch — descendants use this to mutate (e.g., <Trigger> dispatches TOGGLE_MOBILE)
dispatch: React.Dispatch<SidebarReducerAction>;
// Imperative handle — same surface as the <AppSidebar ref={...}>
// (descendants like <Trigger> use this when they're given an explicit `controls` prop)
handle: AppSidebarHandle;
// Element id of the sidebar — `<Trigger aria-controls>` hooks into this
sidebarId: string;
}
export const AppSidebarContext = createContext<AppSidebarContextValue | null>(null);
AppSidebarContext.displayName = "AppSidebarContext";
/** Internal helper — returns the context value or null. Used by prefab parts + trigger. */
export function useAppSidebarContextOrNull(): AppSidebarContextValue | null {
return useContext(AppSidebarContext);
}
src/registry/components/navigation/app-sidebar/hooks/use-active-detection.ts
import { useMemo } from "react";
import { computeActiveItem, type ActiveItemResult } from "../lib/compute-active-item";
import { deriveVisibleEntries, type VisibleEntriesResult } from "../lib/derive-visible-entries";
import type { NavEntry, NavItem } from "../types";
interface UseActiveDetectionOptions {
items: ReadonlyArray<NavEntry>;
currentPath: string;
isActive?: (item: NavItem, currentPath: string) => boolean;
defaultMatch?: "exact" | "prefix";
permissions?: ReadonlySet<string>;
keepEmptySections?: boolean;
// v0.2.0 — gates threaded to deriveVisibleEntries (L44 / L45 / Q21).
isOwner?: boolean;
currentMaxMembers?: number;
bypassFiltering?: boolean;
}
export interface UseActiveDetectionResult {
visible: VisibleEntriesResult;
active: ActiveItemResult;
}
/**
* Memoize the items[] → visible-entries → active-item pipeline.
*
* Reference stability of `items` is critical (L34) — non-memoized items[]
* invalidate this memo every render. Guide.md teaches consumers to memoize.
*
* v0.2.0 — also threads `isOwner`, `currentMaxMembers`, `bypassFiltering`
* into the deriveVisibleEntries call (L44–L46 + Q21).
*/
export function useActiveDetection(
options: UseActiveDetectionOptions,
): UseActiveDetectionResult {
const visible = useMemo(
() =>
deriveVisibleEntries({
items: options.items,
permissions: options.permissions,
keepEmptySections: options.keepEmptySections,
isOwner: options.isOwner,
currentMaxMembers: options.currentMaxMembers,
bypassFiltering: options.bypassFiltering,
}),
[
options.items,
options.permissions,
options.keepEmptySections,
options.isOwner,
options.currentMaxMembers,
options.bypassFiltering,
],
);
const active = useMemo(
() =>
computeActiveItem({
entries: visible.entries,
currentPath: options.currentPath,
isActive: options.isActive,
defaultMatch: options.defaultMatch,
}),
[visible.entries, options.currentPath, options.isActive, options.defaultMatch],
);
return { visible, active };
}
src/registry/components/navigation/app-sidebar/hooks/use-match-media.ts
import { useCallback, useSyncExternalStore } from "react";
/**
* SSR-safe matchMedia hook via `useSyncExternalStore`.
*
* Server snapshot returns `false` (matches the SSR HTML). Client snapshot
* reads `window.matchMedia(query).matches`. Subscribe wires the change
* event. This avoids the React-19 cascading-render antipattern
* (setState-in-effect) — `useSyncExternalStore` is the right primitive
* for synchronizing with browser APIs.
*
* IMPORTANT (L44): Mobile-vs-desktop RENDERING is gated by CSS classes
* (`hidden lg:flex` etc.), NOT by this hook. This hook is for JS BEHAVIOR
* gating only — e.g., deciding whether `autoCloseMobileOnNavigate` should
* trigger when an item is clicked. The SSR-default `false` is harmless
* because CSS owns the visual decision.
*/
export function useMatchMedia(query: string): boolean {
const subscribe = useCallback(
(callback: () => void) => {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mql = window.matchMedia(query);
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
// Safari < 14 fallback
mql.addListener(callback);
return () => mql.removeListener(callback);
},
[query],
);
const getSnapshot = useCallback(() => {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(query).matches;
}, [query]);
const getServerSnapshot = useCallback(() => false, []);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
// Tailwind v4 default breakpoints (px). Used to translate the enum
// values `"sm" | "md" | "lg" | "xl" | "2xl"` to CSS media queries.
export const TAILWIND_BREAKPOINTS = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
} as const;
/** Resolve a `mobileBreakpoint` prop value to a CSS media query string. */
export function resolveBreakpointQuery(
bp: "sm" | "md" | "lg" | "xl" | "2xl" | (string & {}),
): string {
if (bp in TAILWIND_BREAKPOINTS) {
const px = TAILWIND_BREAKPOINTS[bp as keyof typeof TAILWIND_BREAKPOINTS];
return `(max-width: ${px - 1}px)`;
}
// Raw CSS query passed through (PQ7 union with raw string)
return bp;
}
src/registry/components/navigation/app-sidebar/hooks/use-sidebar-nav-state.ts
"use client";
import { useEffect, useMemo } from "react";
import type {
NavEntry,
AppSidebarHandle,
AppSidebarStateValue,
UseAppSidebarStateOptions,
} from "../types";
import { buildHandle } from "../lib/build-handle";
import { useActiveDetection } from "./use-active-detection";
import { useSidebarReducer } from "./use-sidebar-reducer";
import { useStorageSync } from "./use-storage-sync";
/**
* Headless state hook — public API (L16).
*
* Returns a AppSidebarStateValue (superset of AppSidebarHandle plus
* live state fields). Consumers using slots heavily OR building their
* own UI lift state via this hook and pass it back to `<AppSidebar>`
* via the `state` prop (which wins over individual props per L30).
*
* `<AppSidebar>` calls this hook internally too — the public hook is
* the durable composition seam, not a parallel state machine.
*/
export function useAppSidebarState(
options: UseAppSidebarStateOptions = {},
): AppSidebarStateValue {
const items = options.items ?? EMPTY_ITEMS;
// Section-collapse init priority: explicit prop > per-section
// defaultCollapsed field. Storage rehydration outranks both via
// EXTERNAL_SYNC after mount.
const initialCollapsedSectionIds = useMemo(() => {
if (options.defaultCollapsedSectionIds) {
return options.defaultCollapsedSectionIds;
}
const fromItems: string[] = [];
for (const entry of items) {
if (entry.kind === "section" && entry.defaultCollapsed) {
fromItems.push(entry.id);
}
}
return fromItems;
}, [options.defaultCollapsedSectionIds, items]);
const { state, dispatch } = useSidebarReducer({
defaultCollapsed: options.defaultCollapsed,
defaultMobileOpen: options.defaultMobileOpen,
defaultCollapsedSectionIds: initialCollapsedSectionIds,
});
// localStorage opt-in
useStorageSync(state, dispatch, options.storageKey);
// Active detection (filtered visible entries + active item)
const { visible, active } = useActiveDetection({
items,
currentPath: options.currentPath ?? "",
isActive: options.isActive,
defaultMatch: options.defaultMatch,
permissions: options.permissions,
});
// F1 — auto-expand section containing the active item (L48-b)
const autoExpandActiveSection = options.autoExpandActiveSection ?? true;
useEffect(() => {
if (!autoExpandActiveSection) return;
if (!active.sectionId) return;
if (!state.collapsedSectionIds.has(active.sectionId)) return;
dispatch({
type: "SET_SECTION_COLLAPSED",
sectionId: active.sectionId,
collapsed: false,
});
}, [
autoExpandActiveSection,
active.sectionId,
state.collapsedSectionIds,
dispatch,
]);
// v0.3.0 (C5, F5): delegated to the shared `buildHandle` factory. Identical
// factory consumed by `app-sidebar.tsx` so the two state paths can't drift.
const handle = useMemo<AppSidebarHandle>(
() => buildHandle({ state, dispatch, items, visible, active }),
[state, dispatch, items, visible, active],
);
return useMemo<AppSidebarStateValue>(
() => ({
...handle,
collapsed: state.collapsed,
mobileOpen: state.mobileOpen,
collapsedSectionIds: state.collapsedSectionIds,
activeItemId: active.item?.id ?? null,
activeItem: active.item,
visibleEntries: visible.entries,
}),
[handle, state, active, visible],
);
}
const EMPTY_ITEMS: ReadonlyArray<NavEntry> = [];
src/registry/components/navigation/app-sidebar/hooks/use-sidebar-reducer.ts
import { useReducer, useEffect, useMemo, useRef, useCallback } from "react";
import {
type SidebarReducerAction,
type SidebarReducerInitOptions,
type SidebarReducerState,
createInitialState,
sidebarReducer,
} from "../lib/sidebar-reducer";
import type { AppSidebarEventArgs } from "../types";
export interface UseSidebarReducerOptions extends SidebarReducerInitOptions {
// Controlled-mode props (when provided, drive state via EXTERNAL_SYNC)
isCollapsed?: boolean;
isMobileOpen?: boolean;
// Defense 1 — microtask-defer for these
onCollapsedChange?: (args: AppSidebarEventArgs["collapsedChange"]) => void;
onMobileOpenChange?: (args: AppSidebarEventArgs["mobileOpenChange"]) => void;
}
export interface UseSidebarReducerResult {
state: SidebarReducerState;
dispatch: React.Dispatch<SidebarReducerAction>;
}
/**
* Internal reducer hook.
*
* Three-defenses controlled-mode wiring (L7):
* • Defense 1 — microtask-defer onCollapsedChange / onMobileOpenChange callbacks
* so consumers that synchronously sync to other state can't re-enter the reducer.
* • Defense 2 — content-equality short-circuit on EXTERNAL_SYNC (handled inside
* reducer's `EXTERNAL_SYNC` case via `lastSyncedSnapshot` check).
* • Defense 3 — N/A for discrete boolean state (no continuous flow to suppress).
*
* NOT exported from index.ts — purely internal.
*/
export function useSidebarReducer(
options: UseSidebarReducerOptions = {},
): UseSidebarReducerResult {
const [state, dispatch] = useReducer(
sidebarReducer,
{
defaultCollapsed: options.defaultCollapsed,
defaultMobileOpen: options.defaultMobileOpen,
defaultCollapsedSectionIds: options.defaultCollapsedSectionIds,
},
createInitialState,
);
// Latest-ref pattern for callbacks (avoid stale closures in microtask defers)
const onCollapsedChangeRef = useRef(options.onCollapsedChange);
const onMobileOpenChangeRef = useRef(options.onMobileOpenChange);
useEffect(() => {
onCollapsedChangeRef.current = options.onCollapsedChange;
onMobileOpenChangeRef.current = options.onMobileOpenChange;
});
// Controlled-mode → reducer sync (Defense 2 short-circuit inside reducer)
useEffect(() => {
if (options.isCollapsed === undefined && options.isMobileOpen === undefined) {
return;
}
dispatch({
type: "EXTERNAL_SYNC",
collapsed: options.isCollapsed ?? state.collapsed,
mobileOpen: options.isMobileOpen ?? state.mobileOpen,
});
// Intentionally NOT including state.* — this effect ONLY fires on controlled
// prop changes; internal state changes shouldn't loop back through here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options.isCollapsed, options.isMobileOpen]);
// Defense 1 — fire change callbacks via microtask defer when state mutates.
// Skipped when the transition was EXTERNAL_SYNC-originated (consumer already
// knows). v0.3.1 (review 5.4): "sync-originated" is now detected by snapshot
// IDENTITY (EXTERNAL_SYNC mints a new `lastSyncedSnapshot` object on every
// transition it causes) instead of comparing values against the snapshot —
// the old value-compare also swallowed INTERNAL toggles that happened to land
// back on the last-synced value (e.g. the 2nd click while a constant
// controlled prop holds the rendered value), so the consumer never got the
// change request.
const prevCollapsedRef = useRef(state.collapsed);
const prevMobileOpenRef = useRef(state.mobileOpen);
const prevSnapshotRef = useRef(state.lastSyncedSnapshot);
useEffect(() => {
const syncOriginated = prevSnapshotRef.current !== state.lastSyncedSnapshot;
prevSnapshotRef.current = state.lastSyncedSnapshot;
if (prevCollapsedRef.current !== state.collapsed) {
const next = state.collapsed;
prevCollapsedRef.current = next;
// Skip if this transition came from EXTERNAL_SYNC (consumer originated it)
if (!(syncOriginated && state.lastSyncedSnapshot.collapsed === next)) {
queueMicrotask(() => {
onCollapsedChangeRef.current?.({ collapsed: next });
});
}
}
if (prevMobileOpenRef.current !== state.mobileOpen) {
const next = state.mobileOpen;
prevMobileOpenRef.current = next;
if (!(syncOriginated && state.lastSyncedSnapshot.mobileOpen === next)) {
// v0.3.0 (C2, L53): read the reason the dispatching action wrote into
// reducer state. EXTERNAL_SYNC resets to "imperative" on transition;
// SET/TOGGLE write the explicit reason; the no-op guard in the reducer
// prevents re-entry from Sheet's onOpenChange from overwriting.
const reason = state.lastMobileOpenReason ?? "imperative";
queueMicrotask(() => {
onMobileOpenChangeRef.current?.({
open: next,
reason,
});
});
}
}
}, [state.collapsed, state.mobileOpen, state.lastSyncedSnapshot, state.lastMobileOpenReason]);
// Stable dispatch (useReducer's dispatch is already stable; wrap for symmetry)
const stableDispatch = useCallback<React.Dispatch<SidebarReducerAction>>(
(action) => dispatch(action),
[],
);
// v0.3.1 (review 5.4) — controlled props win at READ time, not only on prop
// CHANGE. Previously a controlled `isCollapsed`/`isMobileOpen` was only
// honored via the EXTERNAL_SYNC effect above (which fires on prop change),
// so an internal toggle could diverge the rendered state from a CONSTANT
// controlled prop and the prop never won it back. The raw reducer state
// still mutates on internal dispatches (so the Defense-1 effect above still
// fires the change-request callbacks and the consumer can update its state);
// what the component RENDERS is the prop whenever the prop is defined.
const derivedState = useMemo<SidebarReducerState>(() => {
if (
options.isCollapsed === undefined &&
options.isMobileOpen === undefined
) {
return state;
}
return {
...state,
collapsed: options.isCollapsed ?? state.collapsed,
mobileOpen: options.isMobileOpen ?? state.mobileOpen,
};
}, [state, options.isCollapsed, options.isMobileOpen]);
return { state: derivedState, dispatch: stableDispatch };
}
src/registry/components/navigation/app-sidebar/hooks/use-storage-sync.ts
"use client";
import { useEffect, useRef } from "react";
import type { SidebarReducerAction, SidebarReducerState } from "../lib/sidebar-reducer";
import {
STORAGE_SCHEMA_VERSION,
type StoredState,
isStoredState,
} from "../lib/storage-schema";
/**
* Opt-in localStorage persistence for collapse + collapsed-sections state.
*
* Rules (L23):
* • No-op when `storageKey` is undefined or `window` is unavailable (SSR).
* • Read on mount via useEffect — never during render (SSR-safe).
* • Write on state change, debounced 50ms (rapid section-toggle spam
* doesn't thrash localStorage).
* • mobileOpen is NOT persisted (transient UI, not user-preference).
* • Schema-versioned JSON; mismatch silently drops the stored payload.
* • Quota / serialization failures silently swallowed.
*/
export function useStorageSync(
state: SidebarReducerState,
dispatch: React.Dispatch<SidebarReducerAction>,
storageKey: string | undefined,
): void {
const hasReadRef = useRef(false);
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Read once on mount (or when storageKey changes)
useEffect(() => {
if (!storageKey || typeof window === "undefined") return;
if (hasReadRef.current) return;
hasReadRef.current = true;
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) return;
const parsed: unknown = JSON.parse(raw);
if (!isStoredState(parsed)) return;
dispatch({
type: "EXTERNAL_SYNC",
collapsed: parsed.collapsed,
mobileOpen: state.mobileOpen,
});
// v0.3.1 (review): whole-set REPLACE, not per-id collapse replay.
// The old per-id SET-collapsed loop could only ADD collapses — a
// section the user had EXPANDED (i.e. removed from the stored array,
// but present in defaultCollapsedSectionIds) re-collapsed on every
// reload because its default-collapsed state was never cleared.
dispatch({
type: "COLLAPSE_ALL_SECTIONS",
allSectionIds: parsed.collapsedSectionIds,
});
} catch {
// Corrupted storage — silently fall back to defaults.
}
// Intentionally only re-runs when storageKey changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
// Debounced write on state change
useEffect(() => {
if (!storageKey || typeof window === "undefined") return;
if (!hasReadRef.current) return; // don't write before initial read
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
writeTimerRef.current = setTimeout(() => {
const payload: StoredState = {
v: STORAGE_SCHEMA_VERSION,
collapsed: state.collapsed,
collapsedSectionIds: [...state.collapsedSectionIds],
};
try {
window.localStorage.setItem(storageKey, JSON.stringify(payload));
} catch {
// Quota or serialization failure — silently ignored.
}
}, 50);
return () => {
if (writeTimerRef.current) clearTimeout(writeTimerRef.current);
};
}, [storageKey, state.collapsed, state.collapsedSectionIds]);
}
src/registry/components/navigation/app-sidebar/hooks/use-filtered-nav-sections.ts
import { useMemo } from "react";
import { deriveVisibleEntries } from "../lib/derive-visible-entries";
import type { NavEntry } from "../types";
export interface UseFilteredNavSectionsOpts {
sections: ReadonlyArray<NavEntry>;
permissions?: ReadonlySet<string>;
isOwner?: boolean;
currentMaxMembers?: number;
bypassFiltering?: boolean;
}
/**
* Pure helper hook — returns the filtered `NavEntry[]` with all three
* gates applied (permission ∩ ownerOnly ∩ minMembers per L46) and empty
* sections dropped. `bypassFiltering: true` skips the three permission
* gates at BOTH section + item levels; `hidden: true` is unconditionally
* respected (Q21).
*
* **Memoized over 5 inputs (Q16)** — returns referentially-stable sections
* when inputs don't change by reference (or value, for boolean/number).
* Mitigates R14: downstream `<NavSection>` memoization holds.
*
* **Consumer-side memo guidance (Finding 6 / R16):** `permissions` is a
* `ReadonlySet<string>` — wrap construction in `useMemo([source])` on the
* caller side, else the hook's memo will invalidate every render.
*
* Items-only return per PQ1 — diagnostic struct stays internal to
* `<AppSidebar>`. NOT coupled to the `<AppSidebar>` component; consumers
* rendering their own arbitrary sidebar UI use this standalone.
*/
export function useFilteredNavSections(
opts: UseFilteredNavSectionsOpts,
): ReadonlyArray<NavEntry> {
const { sections, permissions, isOwner, currentMaxMembers, bypassFiltering } = opts;
return useMemo(
() =>
deriveVisibleEntries({
items: sections,
permissions,
isOwner,
currentMaxMembers,
bypassFiltering,
}).entries,
[sections, permissions, isOwner, currentMaxMembers, bypassFiltering],
);
}
src/registry/components/navigation/app-sidebar/lib/active-variant-classes.ts
import type { AppSidebarProps } from "../types";
type Variant = NonNullable<AppSidebarProps["activeVariant"]>;
/**
* Per-variant Tailwind class composition (L12 + L17).
*
* Returns the class string for the link element based on:
* - the chosen `activeVariant`
* - whether the row is active or not
*
* renderItem slot (L13) bypasses this helper entirely — the slot decides
* how to paint active state.
*/
export function getActiveVariantClasses(
variant: Variant | undefined,
isActive: boolean,
): string {
if (!isActive) {
return "text-foreground hover:bg-muted";
}
switch (variant ?? "fill") {
case "fill":
return "bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg)";
case "left-bar":
return [
"relative text-(--ilinxa-nav-active-bg)",
"before:absolute before:left-0 before:top-1.5 before:bottom-1.5",
"before:w-(--ilinxa-nav-active-bar-w) before:rounded-r-full",
"before:bg-(--ilinxa-nav-active-bg)",
].join(" ");
case "right-bar":
return [
"relative text-(--ilinxa-nav-active-bg)",
"after:absolute after:right-0 after:top-1.5 after:bottom-1.5",
"after:w-(--ilinxa-nav-active-bar-w) after:rounded-l-full",
"after:bg-(--ilinxa-nav-active-bg)",
].join(" ");
case "outline":
return "ring-2 ring-inset ring-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-bg)";
case "subtle":
return "bg-accent/30 text-foreground font-semibold";
default:
return "bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg)";
}
}
src/registry/components/navigation/app-sidebar/lib/badge-format.ts
/**
* Format a NavBadge `value` for display.
*
* - Numbers > max render as "{max}+" (cap overflow)
* - Numbers ≤ max render as `String(value)`
* - Non-numbers pass through unchanged (consumer supplied a string / ReactNode)
* - Zero is filtered upstream by `<NavBadge>` via `showZero` flag — not handled here.
*/
export function formatBadgeValue(
value: number | string,
max: number,
): string {
if (typeof value === "number") {
return value > max ? `${max}+` : String(value);
}
return value;
}
src/registry/components/navigation/app-sidebar/lib/build-handle.ts
import type { ActiveItemResult } from "./compute-active-item";
import type { VisibleEntriesResult } from "./derive-visible-entries";
import { flattenEntriesForKeyboard } from "./flatten-entries";
import type {
SidebarReducerAction,
SidebarReducerState,
} from "./sidebar-reducer";
import type {
NavEntry,
NavItem,
AppSidebarHandle,
AppSidebarStateValue,
} from "../types";
/**
* Build the imperative handle exposed to consumers (`ref` + `<AppSidebar state={…}>`).
*
* Shared by:
* - `app-sidebar.tsx` — the component's own `useImperativeHandle` target.
* - `hooks/use-sidebar-nav-state.ts` — the headless `useAppSidebarState()` hook.
*
* v0.3.0 (C5, F5): extracted from two near-identical inline builders that had
* silently drifted between v0.2 bumps. Single source of truth.
*
* NOT exported from `index.ts` — internal-only. Consumers interact via the
* handle object or the public `useAppSidebarState()` return value.
*/
export function buildHandle(deps: {
state: SidebarReducerState;
dispatch: React.Dispatch<SidebarReducerAction>;
items: ReadonlyArray<NavEntry>;
visible: VisibleEntriesResult;
active: ActiveItemResult;
}): AppSidebarHandle {
const { state, dispatch, items, visible, active } = deps;
// Lookup table for getItemById — uses visible.entries so consumers don't
// resolve items hidden by permission / ownerOnly / minMembers gates.
const itemsLookup = new Map<string, NavItem>();
for (const entry of visible.entries) {
if (entry.kind === "section") {
for (const child of entry.items) itemsLookup.set(child.id, child);
} else if (entry.kind !== "separator") {
itemsLookup.set(entry.id, entry);
}
}
const methods: Omit<AppSidebarHandle, "getState"> = {
// Collapse
toggleCollapse: () => dispatch({ type: "TOGGLE_COLLAPSED" }),
setCollapsed: (next) =>
dispatch({ type: "SET_COLLAPSED", collapsed: next }),
isCollapsed: () => state.collapsed,
// Mobile drawer — v0.3.0 (C2, L54) reason? plumbing.
openMobile: (reason) =>
dispatch({
type: "SET_MOBILE_OPEN",
open: true,
reason: reason ?? "imperative",
}),
closeMobile: (reason) =>
dispatch({
type: "SET_MOBILE_OPEN",
open: false,
reason: reason ?? "imperative",
}),
// TOGGLE_MOBILE (NOT a translated SET) so the reducer reads FRESH
// state.mobileOpen — handles rapid same-tick double-clicks correctly
// (a translated SET would use closure-captured stale state and the
// reducer's no-op guard would drop the second dispatch).
toggleMobile: (reason) =>
dispatch({ type: "TOGGLE_MOBILE", reason }),
isMobileOpen: () => state.mobileOpen,
// Section state — v0.3.0 (C5, F8): no allSectionIds payload on EXPAND.
toggleSection: (sectionId) =>
dispatch({ type: "TOGGLE_SECTION", sectionId }),
expandSection: (sectionId) =>
dispatch({ type: "SET_SECTION_COLLAPSED", sectionId, collapsed: false }),
collapseSection: (sectionId) =>
dispatch({ type: "SET_SECTION_COLLAPSED", sectionId, collapsed: true }),
expandAllSections: () => dispatch({ type: "EXPAND_ALL_SECTIONS" }),
collapseAllSections: () => {
// v0.3.0 (C5, F9): source from visible.entries (post-filter) so we
// don't collapse sections the user can't see.
const ids = visible.entries
.filter((e) => "kind" in e && e.kind === "section")
.map((e) => (e as { id: string }).id);
dispatch({ type: "COLLAPSE_ALL_SECTIONS", allSectionIds: ids });
},
isSectionCollapsed: (id) => state.collapsedSectionIds.has(id),
// Items + active
getItems: () => items,
getItemById: (id) => itemsLookup.get(id),
getActiveItem: () => active.item ?? undefined,
// Focus — v0.3.1 (review): focusFirstItem/focusLastItem were public
// no-ops (both dispatched `itemId: null`). Now resolved against the
// same flattened keyboard-traversal sequence the arrow keys use
// (visible entries minus collapsed-section items / disabled items).
focusItem: (itemId) => dispatch({ type: "FOCUS_ITEM", itemId }),
focusFirstItem: () => {
const flat = flattenEntriesForKeyboard(
visible.entries,
state.collapsedSectionIds,
);
if (flat.length > 0) {
dispatch({ type: "FOCUS_ITEM", itemId: flat[0].id });
}
},
focusLastItem: () => {
const flat = flattenEntriesForKeyboard(
visible.entries,
state.collapsedSectionIds,
);
if (flat.length > 0) {
dispatch({ type: "FOCUS_ITEM", itemId: flat[flat.length - 1].id });
}
},
};
const handleObj: AppSidebarHandle = {
...methods,
getState: (): AppSidebarStateValue => ({
...handleObj,
collapsed: state.collapsed,
mobileOpen: state.mobileOpen,
collapsedSectionIds: state.collapsedSectionIds,
activeItemId: active.item?.id ?? null,
activeItem: active.item,
visibleEntries: visible.entries,
}),
};
return handleObj;
}
src/registry/components/navigation/app-sidebar/lib/compute-active-item.ts
import type { NavEntry, NavItem } from "../types";
export interface ActiveItemResult {
item: NavItem | null;
// Section ID the active item lives inside (null = top-level)
sectionId: string | null;
}
interface ComputeOptions {
// Already-filtered visible entries (post-permissions, post-hidden)
entries: ReadonlyArray<NavEntry>;
currentPath: string;
isActive?: (item: NavItem, currentPath: string) => boolean;
defaultMatch?: "exact" | "prefix";
}
/**
* Compute the active NavItem given a currentPath.
*
* Resolution order (L9):
* 1. `isActive` predicate (if supplied) wins for every item.
* 2. Per-item `match: "exact" | "prefix"` fallback.
* 3. `defaultMatch` (default "exact") for items without `match`.
*
* Tie-break (L42): longest matching `item.href` wins for prefix matches.
*/
export function computeActiveItem(opts: ComputeOptions): ActiveItemResult {
const { entries, currentPath, isActive, defaultMatch = "exact" } = opts;
// Flatten: track each NavItem alongside its containing sectionId (null if top-level).
const flat: { item: NavItem; sectionId: string | null }[] = [];
for (const entry of entries) {
if (entry.kind === "section") {
for (const child of entry.items) {
if (child.disabled) continue;
flat.push({ item: child, sectionId: entry.id });
}
} else if (entry.kind === "separator") {
continue;
} else {
if (entry.disabled) continue;
flat.push({ item: entry, sectionId: null });
}
}
if (isActive) {
// Predicate wins; first match in flat order.
for (const { item, sectionId } of flat) {
if (isActive(item, currentPath)) return { item, sectionId };
}
return { item: null, sectionId: null };
}
// Exact pass first — any exact match wins immediately (cheaper than longest-prefix)
for (const { item, sectionId } of flat) {
const mode = item.match ?? defaultMatch;
if (mode === "exact" && item.href === currentPath) {
return { item, sectionId };
}
}
// Prefix pass — collect all prefix candidates, pick longest href.
let best: { item: NavItem; sectionId: string | null; length: number } | null = null;
for (const { item, sectionId } of flat) {
const mode = item.match ?? defaultMatch;
if (mode !== "prefix" || !item.href) continue;
// True prefix: currentPath starts with href + (next char is "/" or end)
if (!currentPath.startsWith(item.href)) continue;
const after = currentPath.charAt(item.href.length);
if (after !== "" && after !== "/") continue;
const length = item.href.length;
if (!best || length > best.length) best = { item, sectionId, length };
}
if (best) return { item: best.item, sectionId: best.sectionId };
return { item: null, sectionId: null };
}
src/registry/components/navigation/app-sidebar/lib/derive-avatar-fallback.ts
/**
* Derive 2-character avatar fallback initials from a name (L35).
*
* Examples:
* "Ahmet Kaya" → "AK"
* "Slack" → "SL"
* " spaced out " → "SO"
* "" → "?"
* "🙃 emoji" → "🙃E" (preserves leading char graceful for emoji)
*/
export function deriveAvatarFallback(name: string | undefined | null): string {
if (!name) return "?";
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return "?";
if (words.length === 1) {
return words[0].slice(0, 2).toUpperCase();
}
return (words[0][0] + words[1][0]).toUpperCase();
}
src/registry/components/navigation/app-sidebar/lib/derive-css-vars.ts
import type { CSSProperties } from "react";
import type { AppSidebarProps } from "../types";
/**
* Build the inline-style object that sets the component's CSS variables
* on the <nav> root. Consumer CSS at any ancestor scope wins via cascade;
* consumer props win over defaults (L11 + L16).
*/
export function deriveCssVars(
props: Pick<
AppSidebarProps,
"collapsedWidth" | "expandedWidth" | "transitionDuration"
>,
): CSSProperties {
return {
"--ilinxa-sidebar-w-collapsed": props.collapsedWidth ?? "5rem",
"--ilinxa-sidebar-w-expanded": props.expandedWidth ?? "16rem",
"--ilinxa-sidebar-transition-duration": props.transitionDuration ?? "300ms",
"--ilinxa-sidebar-row-h": "2.75rem",
"--ilinxa-sidebar-row-gap": "0.25rem",
"--ilinxa-sidebar-px": "0.75rem",
"--ilinxa-nav-active-bg": "var(--primary)",
"--ilinxa-nav-active-fg": "var(--primary-foreground)",
"--ilinxa-nav-active-bar-w": "3px",
"--ilinxa-nav-badge-size": "1.25rem",
"--ilinxa-nav-indent-step": "0.75rem",
} as CSSProperties;
}
src/registry/components/navigation/app-sidebar/lib/derive-visible-entries.ts
import type { NavEntry, NavItem, NavSection } from "../types";
export interface VisibleEntriesResult {
// Filtered + ordered for render. Sections retain their inner items[]
// already-filtered. Separators pass through.
entries: ReadonlyArray<NavEntry>;
// Diagnostic — which items dropped due to which gate. Used by
// onPermissionDenied diff-firing (L38).
filteredByPermission: ReadonlyArray<{ item: NavItem; requiredPermission: string }>;
// Total NavItem count BEFORE filtering (for the empty-state "no-items" branch).
totalItemCount: number;
// Count of items that exist but were hidden (manual `hidden: true`).
hiddenItemCount: number;
}
interface DeriveOptions {
items: ReadonlyArray<NavEntry>;
permissions?: ReadonlySet<string>;
keepEmptySections?: boolean;
/**
* v0.2.0 — Whether the current user is an owner. Used by `ownerOnly`
* gate (L44). Default `false` (matches v0.1 behavior — no items have
* `ownerOnly` in v0.1.x so default is a no-op for legacy callers).
*/
isOwner?: boolean;
/**
* v0.2.0 — Current plan-tier seat capacity. Used by `minMembers` gate
* (L45). Default `Infinity` (matches v0.1 behavior — no items have
* `minMembers` in v0.1.x so default is a no-op for legacy callers).
*/
currentMaxMembers?: number;
/**
* v0.2.0 — When `true`, bypass ALL permission gates (permission /
* ownerOnly / minMembers) at BOTH section AND item levels (Q21 +
* re-validation Finding 4). `hidden: true` is unconditionally respected.
* Default `false`.
*/
bypassFiltering?: boolean;
}
/**
* Filter and shape the items[] for render.
*
* Gating order per NavItem:
* 1. hidden === true → drop (no diagnostic, ALWAYS respected — Q21)
* 2. If !bypassFiltering: permission ∩ ownerOnly ∩ minMembers (L46)
*
* NavSection gates the same way at the section level (whole-group drop) —
* `bypassFiltering` applies at BOTH levels coherently per Finding 4 from
* the GATE 2 re-validation (else "section disappears, items remain"
* inconsistency). Empty sections after item filter auto-hide unless
* keepEmptySections.
*
* v0.1 callers (without isOwner / currentMaxMembers / bypassFiltering)
* see byte-identical behavior because the new gates only fire when the
* corresponding optional NavItem fields are present + the bypass flag is
* false; defaults are explicit per Finding 5.
*/
export function deriveVisibleEntries(opts: DeriveOptions): VisibleEntriesResult {
const {
items,
permissions,
keepEmptySections = false,
isOwner = false,
currentMaxMembers = Infinity,
bypassFiltering = false,
} = opts;
const out: NavEntry[] = [];
const filteredByPermission: { item: NavItem; requiredPermission: string }[] = [];
let totalItemCount = 0;
let hiddenItemCount = 0;
const passesItemGates = (item: NavItem): { pass: boolean; missingPermission?: string } => {
if (bypassFiltering) return { pass: true };
if (item.permission && !(permissions?.has(item.permission) ?? false)) {
return { pass: false, missingPermission: item.permission };
}
if (item.ownerOnly && !isOwner) return { pass: false };
if (item.minMembers !== undefined && currentMaxMembers < item.minMembers) {
return { pass: false };
}
return { pass: true };
};
for (const entry of items) {
if (entry.kind === "separator") {
out.push(entry);
continue;
}
if (entry.kind === "section") {
const section = entry as NavSection;
if (section.hidden) continue;
// Section perm gate — also skipped by bypassFiltering per Finding 4.
if (
!bypassFiltering &&
section.permission &&
!(permissions?.has(section.permission) ?? false)
) {
continue;
}
const visibleInner: NavItem[] = [];
for (const child of section.items) {
totalItemCount += 1;
if (child.hidden) {
hiddenItemCount += 1;
continue;
}
const gate = passesItemGates(child);
if (!gate.pass) {
if (gate.missingPermission) {
filteredByPermission.push({ item: child, requiredPermission: gate.missingPermission });
}
continue;
}
visibleInner.push(child);
}
if (visibleInner.length === 0 && !keepEmptySections) continue;
out.push({ ...section, items: visibleInner });
continue;
}
// Top-level NavItem
const item = entry as NavItem;
totalItemCount += 1;
if (item.hidden) {
hiddenItemCount += 1;
continue;
}
const gate = passesItemGates(item);
if (!gate.pass) {
if (gate.missingPermission) {
filteredByPermission.push({ item, requiredPermission: gate.missingPermission });
}
continue;
}
out.push(item);
}
return {
entries: out,
filteredByPermission,
totalItemCount,
hiddenItemCount,
};
}
src/registry/components/navigation/app-sidebar/lib/flatten-entries.ts
import type { NavEntry } from "../types";
/**
* Linear focus-traversal slot in the keyboard nav sequence.
*
* - `kind: "section-header"` — a focusable section header (the section was
* declared `collapsible: true`). Non-collapsible section headers render
* as a static `<h6>` and are excluded from focus traversal (L37).
* - `kind: "item"` — a NavItem row. `sectionId` is the parent section id
* when the item lives inside a section, or `null` for top-level items.
*
* The `id` field is the row's stable id (item.id or section.id) and is
* what the reducer's `FOCUS_ITEM` action stores.
*/
export interface SidebarKeyboardEntry {
id: string;
kind: "section-header" | "item";
sectionId: string | null;
}
/**
* Flatten the rendered NavEntry tree into the keyboard traversal sequence.
*
* Rules (per plan §17.2 + L37):
* - Separators are skipped.
* - Collapsible section headers are included; non-collapsible headers are
* not focusable so they're omitted.
* - When a section is collapsed, its items are not in the sequence.
* - Disabled items are skipped (they're not interactive).
*/
export function flattenEntriesForKeyboard(
entries: ReadonlyArray<NavEntry>,
collapsedSectionIds: ReadonlySet<string>,
): ReadonlyArray<SidebarKeyboardEntry> {
const out: SidebarKeyboardEntry[] = [];
for (const entry of entries) {
if (entry.kind === "separator") continue;
if (entry.kind === "section") {
if (entry.collapsible) {
out.push({ id: entry.id, kind: "section-header", sectionId: entry.id });
}
if (collapsedSectionIds.has(entry.id)) continue;
for (const child of entry.items) {
if (child.disabled) continue;
out.push({ id: child.id, kind: "item", sectionId: entry.id });
}
continue;
}
if (entry.disabled) continue;
out.push({ id: entry.id, kind: "item", sectionId: null });
}
return out;
}
src/registry/components/navigation/app-sidebar/lib/href-resolver.ts
import type { NavItem } from "../types";
export interface HrefResolverOpts {
templateValues?: Record<string, string>;
resolveHref?: (item: NavItem, values: Record<string, string> | undefined) => string;
}
/**
* Resolve a NavItem's href to its final string form (v0.2.0).
*
* Precedence per L43:
* 1. `resolveHref` callback (if provided) — return value is final
* 2. `{key}` substitution from `templateValues` (when item.href has placeholders)
* 3. `item.href` as-is
*
* Returns `undefined` when the item has no href (consumer rendered as label).
*
* Dev-mode warns when `item.href` references `{xxx}` placeholders not present
* in `templateValues` (Q19; missing-only — unused values are common and
* silently ignored). Warning is NODE_ENV-gated so it tree-shakes from prod
* bundles.
*/
export function resolveItemHref(
item: NavItem,
opts: HrefResolverOpts,
): string | undefined {
const { templateValues, resolveHref } = opts;
if (resolveHref) {
return resolveHref(item, templateValues);
}
if (!item.href) return undefined;
if (!templateValues) return item.href;
return substituteTemplate(item.href, templateValues);
}
/**
* Set-based dedup per re-validation Finding 3 — distinct placeholders
* trigger ONE `replaceAll` call each + one dev-warn entry each, regardless
* of how many times the placeholder appears in the href.
*/
function substituteTemplate(
href: string,
values: Record<string, string>,
): string {
const placeholders = new Set<string>();
for (const [, key] of href.matchAll(/\{([^}]+)\}/g)) {
placeholders.add(key);
}
if (placeholders.size === 0) return href;
const missingKeys = new Set<string>();
let result = href;
for (const key of placeholders) {
if (key in values) {
result = result.replaceAll(`{${key}}`, values[key]!);
} else if (process.env.NODE_ENV !== "production") {
missingKeys.add(key);
}
}
if (missingKeys.size > 0 && process.env.NODE_ENV !== "production") {
console.warn(
`[app-sidebar] href "${href}" references placeholder${missingKeys.size === 1 ? "" : "s"} ` +
`{${Array.from(missingKeys).join("}, {")}} not present in hrefTemplateValues. ` +
`Substitution skipped for missing keys.`,
);
}
return result;
}
src/registry/components/navigation/app-sidebar/lib/keyboard-handler.ts
import type { KeyboardEvent } from "react";
import type { SidebarKeyboardEntry } from "./flatten-entries";
export interface SidebarKeyboardContext {
flat: ReadonlyArray<SidebarKeyboardEntry>;
focusedId: string | null;
setFocusedId: (id: string) => void;
toggleSection: (sectionId: string) => void;
isSectionCollapsed: (sectionId: string) => boolean;
}
/**
* Arrow/Home/End/Enter/Esc dispatch over the flattened keyboard sequence.
*
* Behaviors (plan §17.2 + L37):
* - ArrowDown / ArrowUp — move focus across the sequence (wraps at edges).
* - Home / End — jump to first / last entry.
* - ArrowRight on a collapsed section header → expand.
* - ArrowLeft on an expanded section header → collapse.
* - Enter / Space — left to native button/link click on the focused row.
*
* The handler only updates the reducer's focusedItemId; the host effect in
* `<AppSidebar>` programmatically focuses the matching DOM node on each
* change so screen reader + keyboard user expectations line up.
*/
export function handleSidebarKeydown(
event: KeyboardEvent,
ctx: SidebarKeyboardContext,
): void {
const { flat, focusedId, setFocusedId, toggleSection, isSectionCollapsed } = ctx;
if (flat.length === 0) return;
const currentIdx = focusedId ? flat.findIndex((e) => e.id === focusedId) : -1;
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const next =
currentIdx >= 0 && currentIdx + 1 < flat.length
? flat[currentIdx + 1]
: flat[0];
setFocusedId(next.id);
return;
}
case "ArrowUp": {
event.preventDefault();
const prev =
currentIdx > 0 ? flat[currentIdx - 1] : flat[flat.length - 1];
setFocusedId(prev.id);
return;
}
case "Home": {
event.preventDefault();
setFocusedId(flat[0].id);
return;
}
case "End": {
event.preventDefault();
setFocusedId(flat[flat.length - 1].id);
return;
}
case "ArrowRight": {
if (currentIdx < 0) return;
const current = flat[currentIdx];
if (current.kind === "section-header" && isSectionCollapsed(current.id)) {
event.preventDefault();
toggleSection(current.id);
}
return;
}
case "ArrowLeft": {
if (currentIdx < 0) return;
const current = flat[currentIdx];
if (current.kind === "section-header" && !isSectionCollapsed(current.id)) {
event.preventDefault();
toggleSection(current.id);
}
return;
}
}
}
src/registry/components/navigation/app-sidebar/lib/sidebar-reducer.ts
import type { AppSidebarMobileOpenReason } from "../types";
// ─────────────────────────────────────────────────────────────────────────
// Internal reducer state + actions
// (Internal shape — NOT exported via index.ts. Consumers interact with the
// component via the imperative handle + public AppSidebarStateValue.)
// ─────────────────────────────────────────────────────────────────────────
export interface SidebarReducerState {
collapsed: boolean;
mobileOpen: boolean;
collapsedSectionIds: ReadonlySet<string>;
focusedItemId: string | null;
// L7 Defense-2 content-equality short-circuit anchor for EXTERNAL_SYNC.
// Updated only on EXTERNAL_SYNC; never on internal transitions.
lastSyncedSnapshot: {
collapsed: boolean;
mobileOpen: boolean;
};
// v0.3.0 (C2, L53) — reason of the most-recent mobile-drawer transition.
// Drained by Defense-1 microtask effect for the onMobileOpenChange callback.
// OVERWRITES on each transition (no explicit clear). EXTERNAL_SYNC resets to
// "imperative" on transition-causing syncs to prevent stale carry-over.
lastMobileOpenReason: AppSidebarMobileOpenReason | null;
}
export type SidebarReducerAction =
| { type: "SET_COLLAPSED"; collapsed: boolean }
| { type: "TOGGLE_COLLAPSED" }
| { type: "SET_MOBILE_OPEN"; open: boolean; reason: AppSidebarMobileOpenReason }
// v0.3.0 (C2, L54): TOGGLE_MOBILE gains optional `reason?` so the reducer
// can read fresh state.mobileOpen (handles rapid same-tick double-clicks)
// while still propagating the discriminator. Default is "imperative".
| { type: "TOGGLE_MOBILE"; reason?: AppSidebarMobileOpenReason }
| { type: "SET_SECTION_COLLAPSED"; sectionId: string; collapsed: boolean }
| { type: "TOGGLE_SECTION"; sectionId: string }
// v0.3.0 (C5, F8): drop unused allSectionIds payload (reducer always cleared
// the set regardless of payload).
| { type: "EXPAND_ALL_SECTIONS" }
| { type: "COLLAPSE_ALL_SECTIONS"; allSectionIds: ReadonlyArray<string> }
| { type: "FOCUS_ITEM"; itemId: string | null }
| { type: "EXTERNAL_SYNC"; collapsed: boolean; mobileOpen: boolean }
| { type: "REPLACE_STATE"; state: SidebarReducerState };
export interface SidebarReducerInitOptions {
defaultCollapsed?: boolean;
defaultMobileOpen?: boolean;
defaultCollapsedSectionIds?: ReadonlyArray<string>;
}
export function createInitialState(
options: SidebarReducerInitOptions = {},
): SidebarReducerState {
const collapsed = options.defaultCollapsed ?? false;
const mobileOpen = options.defaultMobileOpen ?? false;
return {
collapsed,
mobileOpen,
collapsedSectionIds: new Set(options.defaultCollapsedSectionIds ?? []),
focusedItemId: null,
lastSyncedSnapshot: { collapsed, mobileOpen },
lastMobileOpenReason: null,
};
}
export function sidebarReducer(
state: SidebarReducerState,
action: SidebarReducerAction,
): SidebarReducerState {
switch (action.type) {
case "SET_COLLAPSED":
if (state.collapsed === action.collapsed) return state;
return { ...state, collapsed: action.collapsed };
case "TOGGLE_COLLAPSED":
return { ...state, collapsed: !state.collapsed };
case "SET_MOBILE_OPEN":
// No-op guard preserves prior lastMobileOpenReason on duplicate dispatch
// (load-bearing for the L53 "no double-fire on re-entry" guarantee).
if (state.mobileOpen === action.open) return state;
return {
...state,
mobileOpen: action.open,
lastMobileOpenReason: action.reason,
};
case "TOGGLE_MOBILE":
// Reducer reads FRESH state.mobileOpen — translating to SET at the
// handle would use a stale closure value and the no-op guard would
// drop rapid same-tick double-clicks (R21).
return {
...state,
mobileOpen: !state.mobileOpen,
lastMobileOpenReason: action.reason ?? "imperative",
};
case "SET_SECTION_COLLAPSED": {
const has = state.collapsedSectionIds.has(action.sectionId);
if (action.collapsed && has) return state;
if (!action.collapsed && !has) return state;
const next = new Set(state.collapsedSectionIds);
if (action.collapsed) next.add(action.sectionId);
else next.delete(action.sectionId);
return { ...state, collapsedSectionIds: next };
}
case "TOGGLE_SECTION": {
const next = new Set(state.collapsedSectionIds);
if (next.has(action.sectionId)) next.delete(action.sectionId);
else next.add(action.sectionId);
return { ...state, collapsedSectionIds: next };
}
case "EXPAND_ALL_SECTIONS":
// v0.3.0 (C5, F8): action no longer carries allSectionIds — the field
// was always ignored here (we just clear the set unconditionally).
if (state.collapsedSectionIds.size === 0) return state;
return { ...state, collapsedSectionIds: new Set() };
case "COLLAPSE_ALL_SECTIONS": {
const next = new Set(action.allSectionIds);
// No-op if identical (rare)
if (
next.size === state.collapsedSectionIds.size &&
[...next].every((id) => state.collapsedSectionIds.has(id))
) {
return state;
}
return { ...state, collapsedSectionIds: next };
}
case "FOCUS_ITEM":
if (state.focusedItemId === action.itemId) return state;
return { ...state, focusedItemId: action.itemId };
case "EXTERNAL_SYNC": {
// L7 Defense 2: content-equality short-circuit
if (
state.lastSyncedSnapshot.collapsed === action.collapsed &&
state.lastSyncedSnapshot.mobileOpen === action.mobileOpen
) {
return state;
}
// v0.3.0 (C2, R23): when mobileOpen actually changes via EXTERNAL_SYNC
// (controlled-prop change from consumer), reset lastMobileOpenReason
// to "imperative" — otherwise a stale "item-click" / "escape" /
// "outside-click" from a prior in-app transition would leak into the
// callback for this prop-driven transition. When EXTERNAL_SYNC only
// changes `collapsed`, preserve the mobile reason untouched.
const mobileChanged = state.mobileOpen !== action.mobileOpen;
return {
...state,
collapsed: action.collapsed,
mobileOpen: action.mobileOpen,
lastSyncedSnapshot: {
collapsed: action.collapsed,
mobileOpen: action.mobileOpen,
},
lastMobileOpenReason: mobileChanged
? "imperative"
: state.lastMobileOpenReason,
};
}
case "REPLACE_STATE":
return action.state;
default: {
// Exhaustive check — TypeScript flags unhandled action types at compile time
const _exhaustive: never = action;
void _exhaustive;
return state;
}
}
}
src/registry/components/navigation/app-sidebar/lib/storage-schema.ts
/**
* Versioned schema for localStorage persistence (L23).
*
* Bump STORAGE_SCHEMA_VERSION when the shape changes; the type-guard
* silently drops mismatched payloads so older data doesn't corrupt the
* runtime state.
*/
export const STORAGE_SCHEMA_VERSION = 1;
export interface StoredState {
v: 1;
collapsed: boolean;
collapsedSectionIds: string[];
}
export function isStoredState(value: unknown): value is StoredState {
if (typeof value !== "object" || value === null) return false;
const v = value as Partial<StoredState>;
return (
v.v === STORAGE_SCHEMA_VERSION &&
typeof v.collapsed === "boolean" &&
Array.isArray(v.collapsedSectionIds) &&
v.collapsedSectionIds.every((id) => typeof id === "string")
);
}
src/registry/components/navigation/app-sidebar/parts/default-link.tsx
import { forwardRef } from "react";
import type { NavLinkProps } from "../types";
/**
* Default linkComponent — vanilla `<a href>` wrapper.
*
* Consumers using Next.js / React Router / TanStack Router pass their own
* linkComponent. See usage.tsx for one-liner adapters.
*/
export const DefaultLink = forwardRef<HTMLAnchorElement, NavLinkProps>(
function DefaultLink({ href, children, ...rest }, ref) {
return (
<a ref={ref} href={href} {...rest}>
{children}
</a>
);
},
);
src/registry/components/navigation/app-sidebar/parts/icon.tsx
import { isValidElement, type ComponentType, type ReactNode } from "react";
import { cn } from "@/lib/utils";
interface IconProps {
icon: ReactNode | ComponentType<{ className?: string }> | undefined;
className?: string;
}
/**
* Renders either:
* - a React component (e.g., lucide-react `Home`) — invoked with className
* - a ReactNode (JSX element, string emoji, image, custom mark) — rendered as-is
* - undefined — renders nothing
*
* Convention: icon components receive a className with sizing (`h-5 w-5` or
* similar). ReactNode icons are expected to size themselves.
*/
export function Icon({ icon, className }: IconProps) {
if (icon === undefined || icon === null) return null;
if (isValidElement(icon)) {
return <>{icon}</>;
}
// Plain function components AND forwardRef objects (lucide-react icons in
// v0.475+ ship as `forwardRef` objects whose typeof === "object", not
// "function" — the previous typeof-only check rendered the object as a
// child and crashed the static-prerender path).
const isComponentObject =
typeof icon === "object" && icon !== null && "$$typeof" in icon;
if (typeof icon === "function" || isComponentObject) {
const IconComponent = icon as ComponentType<{ className?: string }>;
return <IconComponent className={cn("h-5 w-5 shrink-0", className)} />;
}
// String / number / fragment — render as-is
return <>{icon}</>;
}
src/registry/components/navigation/app-sidebar/parts/nav-badge.tsx
"use client";
import { isValidElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { useAppSidebarContextOrNull } from "../contexts/sidebar-nav-context";
import { formatBadgeValue } from "../lib/badge-format";
import type { NavBadgeConfig } from "../types";
/**
* Shared NavBadge — exported via index.ts AND imported by
* `bottom-tab-bar-01` via relative path `../app-sidebar/parts/nav-badge`
* per F-S1 cross-procomp lock.
*
* Position resolution (L33):
* 1. Explicit `position` prop wins
* 2. Otherwise: context auto-resolve — "corner" when sidebar collapsed,
* "inline-end" when expanded
* 3. Default "inline-end" if no context (L46 — standalone use, e.g.,
* inside bottom-tab-bar-01 which doesn't have SidebarNav context)
*
* Zero-value skip per L33: returns null when value === 0 && !showZero.
*/
export function NavBadge({
value,
max = 9,
variant = "number",
tone = "destructive",
position,
showZero = false,
className,
}: NavBadgeConfig & { className?: string }) {
const ctx = useAppSidebarContextOrNull();
const resolvedPosition =
position ?? (ctx?.state.collapsed ? "corner" : "inline-end");
// Skip-render for zero (L33)
if (typeof value === "number" && value === 0 && !showZero) return null;
// Tone → token mapping
const toneClasses: Record<NonNullable<NavBadgeConfig["tone"]>, string> = {
default: "bg-muted text-muted-foreground",
accent: "bg-accent text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground",
muted: "bg-muted/60 text-muted-foreground",
};
// Position → layout classes. RTL flip: in collapsed-mode the badge corner
// anchors on the icon's inline-end edge — physically -right-2 in LTR and
// -left-2 in RTL. The deeper offset (-8px instead of -4px) lets the badge
// sit on the icon's outer corner with only a slight overlap, so the
// underlying icon glyph stays readable instead of being masked.
const positionClasses =
resolvedPosition === "corner"
? "absolute -top-2 -right-2 rtl:right-auto rtl:-left-2"
: "relative inline-flex";
if (variant === "dot") {
return (
<span
aria-hidden={typeof value !== "string" && !isValidElement(value)}
className={cn(
positionClasses,
"h-2 w-2 rounded-full",
toneClasses[tone],
className,
)}
/>
);
}
if (variant === "pulse") {
return (
<span
className={cn(positionClasses, "h-2 w-2", className)}
aria-hidden="true"
>
<span
className={cn(
"absolute inset-0 rounded-full motion-safe:animate-ping",
toneClasses[tone],
"opacity-75",
)}
/>
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
toneClasses[tone],
)}
/>
</span>
);
}
// variant === "number"
const display: ReactNode =
typeof value === "number" || typeof value === "string"
? formatBadgeValue(value, max)
: value;
// Corner-position badges run smaller + tighter than inline-end ones so they
// don't mask the icon glyph they're decorating. Inline-end keeps the
// theme-token sizing for visual parity with shadcn Badge.
const sizeClasses =
resolvedPosition === "corner"
? "min-w-4 h-4 px-1 text-[9px] ring-2 ring-card"
: "min-w-(--ilinxa-nav-badge-size) h-(--ilinxa-nav-badge-size) px-1.5 text-[10px]";
return (
<span
className={cn(
positionClasses,
"inline-flex items-center justify-center",
sizeClasses,
"font-semibold leading-none rounded-full",
toneClasses[tone],
className,
)}
>
{display}
</span>
);
}
src/registry/components/navigation/app-sidebar/parts/nav-brand.tsx
"use client";
import { cn } from "@/lib/utils";
import { useAppSidebarContextOrNull } from "../contexts/sidebar-nav-context";
import type { NavBrandConfig } from "../types";
import { DefaultLink } from "./default-link";
/**
* Default brand zone (logo + label + optional href).
*
* Collapse-aware: when ancestor sidebar is collapsed (read via context),
* the label hides and only the logo renders.
*
* Use via:
* <AppSidebar brand={{ logo: <Logo/>, label: "Acme", href: "/" }} />
* Or directly inside brandSlot:
* <AppSidebar brandSlot={<NavBrand label="Acme" />} />
*/
export function NavBrand({
logo,
label,
href,
linkComponent,
className,
}: NavBrandConfig & { className?: string }) {
const ctx = useAppSidebarContextOrNull();
const isCollapsed = ctx?.state.collapsed ?? false;
const renderLogo = () => {
if (!logo) return null;
if (typeof logo === "object" && logo !== null && "src" in logo) {
return (
<img
src={logo.src}
alt={logo.alt ?? label}
className="h-8 w-8 rounded-md object-contain"
/>
);
}
return <span className="flex h-8 w-8 items-center justify-center">{logo}</span>;
};
const content = (
<span className={cn("flex items-center gap-2 min-w-0", className)}>
{renderLogo()}
{!isCollapsed && (
<span className="truncate text-base font-semibold text-foreground">
{label}
</span>
)}
</span>
);
if (!href) {
return content;
}
const LinkComponent = linkComponent ?? DefaultLink;
return (
<LinkComponent
href={href}
className="-m-1 rounded-md p-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={label}
>
{content}
</LinkComponent>
);
}
src/registry/components/navigation/app-sidebar/parts/nav-primary-action.tsx
"use client";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useAppSidebarContextOrNull } from "../contexts/sidebar-nav-context";
import type { NavPrimaryActionConfig } from "../types";
import { DefaultLink } from "./default-link";
import { Icon } from "./icon";
import { TooltipWrapper } from "./tooltip-wrapper";
/**
* Default primary-action button (e.g., "Create post" / "New project").
*
* Collapses to icon-only when ancestor sidebar collapses; label hidden +
* tooltip exposed so the action stays accessible and the icon stays
* centered.
*
* Use via:
* <AppSidebar primaryAction={{
* icon: PlusSquare,
* label: "Share",
* onClick: () => openComposer(),
* }} />
* Or directly inside primaryActionSlot:
* <AppSidebar primaryActionSlot={<NavPrimaryAction ... />} />
*/
export function NavPrimaryAction({
icon,
label,
onClick,
href,
linkComponent,
variant = "default",
tone = "default",
className,
}: NavPrimaryActionConfig & { className?: string }) {
const ctx = useAppSidebarContextOrNull();
const isCollapsed = ctx?.state.collapsed ?? false;
const innerContent = (
<>
<Icon icon={icon} className={cn("h-4 w-4 shrink-0", isCollapsed && "")} />
{!isCollapsed && <span className="truncate">{label}</span>}
</>
);
const buttonClasses = cn(
"w-full gap-2",
isCollapsed && "px-0",
tone === "accent" && "bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg) hover:bg-(--ilinxa-nav-active-bg)/90",
tone === "destructive" && "bg-destructive text-destructive-foreground hover:bg-destructive/90",
className,
);
// href present → render as <a> via linkComponent; otherwise <button>.
// v0.3.2 (F-cross-13 path-b): no `<Button asChild>` — Base UI's Button has
// no asChild (and the CLI's render-rewrite breaks mixed consumers). The
// link IS the button: buttonVariants classes applied to the anchor directly.
const buttonEl = href ? (
(() => {
const LinkComponent = linkComponent ?? DefaultLink;
return (
<LinkComponent
href={href}
onClick={onClick}
aria-label={isCollapsed ? label : undefined}
className={cn(buttonVariants({ variant }), buttonClasses)}
>
{innerContent}
</LinkComponent>
);
})()
) : (
<Button
type="button"
variant={variant}
onClick={onClick}
aria-label={isCollapsed ? label : undefined}
className={buttonClasses}
>
{innerContent}
</Button>
);
return (
<TooltipWrapper
content={<span className="font-medium">{label}</span>}
side="right"
disabled={!isCollapsed}
>
{buttonEl}
</TooltipWrapper>
);
}
src/registry/components/navigation/app-sidebar/parts/nav-user.tsx
"use client";
import { useRef, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useAppSidebarContextOrNull } from "../contexts/sidebar-nav-context";
import { deriveAvatarFallback } from "../lib/derive-avatar-fallback";
import type {
NavUserConfig,
NavUserMenuItem,
NavUserMenuItemSelectEvent,
} from "../types";
import { DefaultLink } from "./default-link";
import { Icon } from "./icon";
const STATUS_DOT_CLASSES: Record<string, string> = {
online: "bg-emerald-500",
offline: "bg-zinc-400",
busy: "bg-red-500",
away: "bg-amber-500",
};
/**
* User footer (avatar + identity + dropdown menu).
*
* Collapse-aware: identity text hidden when sidebar collapsed; only the
* avatar shows. Dropdown align flips center↔end based on collapsed state.
*
* F-cross-13 defensive (R7 carrier #3 — DropdownMenu):
* - onOpenChange runtime-checks for boolean (Radix passes boolean;
* Base UI variants may pass undefined or different shape)
* - DropdownMenuItem.onSelect callbacks shaped to accept either Event
* (Radix) or undefined (Base UI fallback) — runtime-narrowed
* - v0.3.2 (path-b): zero `asChild` — Base UI primitives reject it. The
* DropdownMenuTrigger IS the footer button (native props only) and
* href menu rows nest the anchor INSIDE the item (see
* NavUserLinkMenuItem below).
*
* menuItems is a discriminated union (L15 + L22-b):
* { kind: "item"; ... } — clickable menu row
* { kind: "separator" } — divider
*/
export function NavUser({
user,
menuItems,
onTriggerOpen,
className,
}: NavUserConfig & { className?: string }) {
const ctx = useAppSidebarContextOrNull();
const isCollapsed = ctx?.state.collapsed ?? false;
const [open, setOpen] = useState(false);
const initials = deriveAvatarFallback(user.name);
const statusDot = user.status && user.status !== "invisible"
? STATUS_DOT_CLASSES[user.status]
: null;
// v0.3.2 (F-cross-13 path-b): no `asChild` — Base UI's trigger rejects it.
// The DropdownMenuTrigger IS the footer button: both backends render a
// native <button> and pass native DOM props straight through.
const trigger = (
<DropdownMenuTrigger
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-md p-1.5",
"hover:bg-muted",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
isCollapsed && "justify-center",
className,
)}
aria-label={isCollapsed ? `${user.name} — open menu` : undefined}
>
<span className="relative inline-flex shrink-0">
<Avatar className="h-9 w-9">
{user.avatarUrl && (
<AvatarImage src={user.avatarUrl} alt={user.name} />
)}
<AvatarFallback>{user.avatarFallback ?? initials}</AvatarFallback>
</Avatar>
{statusDot && (
<span
aria-hidden="true"
className={cn(
"absolute -bottom-0.5 -right-0.5 inline-flex h-2.5 w-2.5 rounded-full ring-2 ring-card",
// RTL flip — status dot stays on the avatar's inline-end side
"rtl:right-auto rtl:-left-0.5",
statusDot,
)}
/>
)}
</span>
{!isCollapsed && (
<span className="flex min-w-0 flex-1 flex-col text-left">
<span className="truncate text-sm font-medium text-foreground">
{user.name}
</span>
{user.handle && (
<span className="truncate text-xs text-muted-foreground">
{user.handle}
</span>
)}
</span>
)}
</DropdownMenuTrigger>
);
return (
<DropdownMenu
open={open}
// F-cross-13: runtime-check (Radix → boolean; Base UI → possible undefined)
onOpenChange={(next: boolean | undefined) => {
if (typeof next === "boolean") {
setOpen(next);
onTriggerOpen?.({ open: next });
}
}}
>
{trigger}
<DropdownMenuContent
align={isCollapsed ? "center" : "end"}
side="top"
className="w-56"
>
{menuItems.map((entry, i) => {
if (entry.kind === "separator") {
return <DropdownMenuSeparator key={`sep-${i}`} />;
}
const item = entry as NavUserMenuItem;
if (item.href) {
return (
<NavUserLinkMenuItem
key={item.label + i}
item={item}
href={item.href}
/>
);
}
return (
<DropdownMenuItem
key={item.label + i}
disabled={item.disabled}
// v0.3.2 (F-cross-13): param is `unknown` — Radix types onSelect's
// arg as Event, Base UI as BaseUIEvent<SyntheticEvent>; naming
// either breaks the other backend's contravariance check. The
// runtime contract (v0.3.0 C4/F10 widening) is unchanged;
// consumers still narrow with `instanceof MouseEvent`.
onSelect={(eventArg: unknown) => {
item.onClick?.(eventArg as NavUserMenuItemSelectEvent);
}}
className={cn(
"gap-2",
item.variant === "destructive" && "text-destructive focus:text-destructive",
)}
>
<Icon icon={item.icon} className="h-4 w-4" />
<span className="flex-1">{item.label}</span>
{item.shortcut && (
<span className="ml-auto font-mono text-xs text-muted-foreground">
{item.shortcut}
</span>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
/**
* v0.3.2 (F-cross-13 path-b): link-flavored menu row. Previously
* `<DropdownMenuItem asChild>` made the anchor the [role=menuitem] itself —
* Base UI's DropdownMenuItem has no `asChild`, so the item stays the
* menuitem host and the anchor renders INSIDE it, filling the whole row
* (the item's padding moves onto the anchor via `p-0`). An anchor inside a
* div[role=menuitem] is valid HTML and keeps href semantics — middle-click,
* ctrl-click, copy-link — working in both backends. Keyboard activation
* dispatches the synthetic click on the ITEM, not the anchor, so onSelect
* forwards it via anchor.click() exactly once:
* - nativeClickRef — the anchor's own onClick marks pointer activations
* (navigation already happened natively; don't forward)
* - forwardingRef — re-entrancy guard: the forwarded click bubbles back
* into the item's select pipeline and would double-fire item.onClick
* The anchor is looked up via querySelector so custom linkComponents work
* whether or not they forward refs.
*/
function NavUserLinkMenuItem({
item,
href,
}: {
item: NavUserMenuItem;
href: string;
}) {
const itemRef = useRef<HTMLDivElement | null>(null);
const nativeClickRef = useRef(false);
const forwardingRef = useRef(false);
const LinkComponent = item.linkComponent ?? DefaultLink;
return (
<DropdownMenuItem
ref={itemRef}
disabled={item.disabled}
// F-cross-13: `unknown` param — see the plain-item handler above.
onSelect={(eventArg: unknown) => {
if (forwardingRef.current) return;
item.onClick?.(eventArg as NavUserMenuItemSelectEvent);
if (!nativeClickRef.current) {
// Fall back to the row's root element for linkComponents that
// render no <a> (their own onClick navigation still runs).
const anchor =
itemRef.current?.querySelector("a") ??
(itemRef.current?.firstElementChild instanceof HTMLElement
? itemRef.current.firstElementChild
: null);
if (anchor) {
forwardingRef.current = true;
try {
anchor.click();
} finally {
forwardingRef.current = false;
}
}
}
nativeClickRef.current = false;
}}
className={cn(
// p-0 — padding moves onto the anchor so the whole row is the link
"p-0",
item.variant === "destructive" && "text-destructive focus:text-destructive",
)}
>
<LinkComponent
href={href}
// the menuitem is the focus stop; keep the anchor out of the tab order
tabIndex={-1}
onClick={() => {
nativeClickRef.current = true;
}}
className="flex w-full items-center gap-2 rounded-md px-1.5 py-1"
>
<Icon icon={item.icon} className="h-4 w-4" />
<span className="flex-1">{item.label}</span>
{item.shortcut && (
<span className="ml-auto font-mono text-xs text-muted-foreground">
{item.shortcut}
</span>
)}
</LinkComponent>
</DropdownMenuItem>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-empty-state.tsx
"use client";
import { Inbox, ShieldOff, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AppSidebarEmptyReason } from "../types";
interface SidebarEmptyStateProps {
reason: AppSidebarEmptyReason;
className?: string;
}
const REASON_COPY: Record<
AppSidebarEmptyReason,
{ icon: React.ComponentType<{ className?: string }>; title: string; body: string }
> = {
"no-items": {
icon: Inbox,
title: "No items",
body: "Configure `items` prop to populate the navigation.",
},
"all-filtered-by-permission": {
icon: ShieldOff,
title: "Nothing visible",
body: "All items filtered by current permissions.",
},
"all-hidden": {
icon: EyeOff,
title: "All items hidden",
body: "Every item has `hidden: true`.",
},
"all-filtered-by-loading": {
icon: Inbox,
title: "No items",
body: "Loading completed with no items.",
},
};
/**
* Default empty-state renderer — per L48 reason branching.
*
* Override via `renderEmptyState` slot (L13 priority).
* Each reason gets a distinct icon + title + body for diagnostic clarity.
*/
export function SidebarEmptyState({ reason, className }: SidebarEmptyStateProps) {
const { icon: IconComp, title, body } = REASON_COPY[reason];
return (
<div
role="status"
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border p-6 text-center",
className,
)}
>
<IconComp className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{body}</p>
</div>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-loading-skeleton.tsx
"use client";
import { cn } from "@/lib/utils";
interface SidebarLoadingSkeletonProps {
isCollapsed: boolean;
rowCount?: number;
className?: string;
}
/**
* Default loading skeleton — 6 shimmer rows by default.
*
* Width matches sidebar's collapsed/expanded mode automatically (inherits
* the parent <nav>'s width). Shimmer animation gated motion-safe;
* reduced-motion users see static muted rows.
*
* Override entirely via the `renderLoading` slot (L13 priority).
*/
export function SidebarLoadingSkeleton({
isCollapsed,
rowCount = 6,
className,
}: SidebarLoadingSkeletonProps) {
return (
<ul
role="list"
aria-busy="true"
aria-label="Loading navigation items"
className={cn("flex flex-col gap-1", className)}
>
{Array.from({ length: rowCount }, (_, i) => (
<li key={i} className="list-none">
<div
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5",
"motion-safe:animate-pulse",
)}
aria-hidden="true"
>
<span className="h-5 w-5 shrink-0 rounded-md bg-muted" />
{!isCollapsed && (
<span
className="h-4 rounded bg-muted"
style={{
// Vary the width slightly so the skeleton looks real
width: `${60 + ((i * 7) % 30)}%`,
}}
/>
)}
</div>
</li>
))}
</ul>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-nav-list.tsx
"use client";
import { Fragment } from "react";
import { cn } from "@/lib/utils";
import type {
NavEntry,
NavItem,
NavLinkComponent,
AppSidebarEventArgs,
AppSidebarMobileOpenReason,
AppSidebarProps,
} from "../types";
import { SidebarNavRow } from "./sidebar-nav-row";
import { SidebarNavSection } from "./sidebar-nav-section";
import { SidebarNavSeparator } from "./sidebar-nav-separator";
interface SidebarNavListProps {
entries: ReadonlyArray<NavEntry>;
activeItemId: string | null;
focusedItemId: string | null;
/**
* Stable id of the first focusable item/section header in the rendered
* traversal — used as the roving tabindex anchor when nothing is focused.
* Null if no row is focusable (loading / empty).
*/
keyboardEntryId: string | null;
isCollapsed: boolean;
linkComponent: NavLinkComponent;
activeVariant?: AppSidebarProps["activeVariant"];
autoCloseMobileOnNavigate: boolean;
isMobileOpen: boolean;
// v0.3.0 (C2, L54): accepts reason for the discriminator. Click handler
// passes "item-click"; parent forwards to finalHandle.closeMobile(reason).
onCloseMobile: (reason: AppSidebarMobileOpenReason) => void;
// Section state — driven by reducer in parent
collapsedSectionIds: ReadonlySet<string>;
onToggleSection: (sectionId: string) => void;
// Consumer event hooks
onItemClick?: (args: AppSidebarEventArgs["itemClick"]) => void;
onItemHover?: (args: AppSidebarEventArgs["itemHover"]) => void;
onItemFocus?: (args: AppSidebarEventArgs["itemFocus"]) => void;
onItemNavigate?: (args: AppSidebarEventArgs["itemNavigate"]) => void;
onSectionToggle?: (args: AppSidebarEventArgs["sectionToggle"]) => void;
// Slot priority
renderItem?: AppSidebarProps["renderItem"];
renderSection?: AppSidebarProps["renderSection"];
renderBadge?: AppSidebarProps["renderBadge"];
renderTooltipContent?: AppSidebarProps["renderTooltipContent"];
// v0.2.0 — href resolution (L42 + L43)
hrefTemplateValues?: AppSidebarProps["hrefTemplateValues"];
resolveHref?: AppSidebarProps["resolveHref"];
}
/**
* Renders the NavEntry[] list — flat NavItems, NavSections, and NavSeparators.
*
* C3 surface: flat items + separators. Sections render their items in a
* single ungrouped list (full section UI with title + collapsible header
* lands C4 in parts/sidebar-nav-section.tsx).
*
* Click sequence per L28:
* 1. consumer's item.onClick fires sync
* 2. onItemClick (component-level) fires sync
* 3. if event.defaultPrevented → stop
* 4. queueMicrotask → onItemNavigate + autoCloseMobileOnNavigate
*
* Disabled items short-circuit at step 1 (L27).
*/
export function SidebarNavList({
entries,
activeItemId,
focusedItemId,
keyboardEntryId,
isCollapsed,
linkComponent,
activeVariant,
autoCloseMobileOnNavigate,
isMobileOpen,
onCloseMobile,
collapsedSectionIds,
onToggleSection,
onItemClick,
onItemHover,
onItemFocus,
onItemNavigate,
onSectionToggle,
renderItem,
renderSection,
renderBadge,
renderTooltipContent,
hrefTemplateValues,
resolveHref,
}: SidebarNavListProps) {
// Roving tabindex (L37). The "entry point" rule:
// - Some row has the user's focus → only that row is tabbable.
// - Nothing focused yet → the keyboard entry row is tabbable.
// - Disabled items always pass to -1.
const resolveRovingTabIndex = (itemId: string): 0 | -1 => {
if (focusedItemId) return focusedItemId === itemId ? 0 : -1;
return keyboardEntryId === itemId ? 0 : -1;
};
const handleItemClick =
(item: NavItem, sectionId: string | null, indexInSection: number) =>
(event: React.MouseEvent) => {
// L27 — disabled items short-circuit at step 1
if (item.disabled) {
event.preventDefault();
return;
}
// Step 1 — consumer's per-item onClick
item.onClick?.(event);
// Step 2 — component-level onItemClick
onItemClick?.({ item, isActive: activeItemId === item.id, event });
// Step 3 — short-circuit if cancelled
if (event.defaultPrevented) return;
// Step 4 — microtask defer the navigation-side effects
queueMicrotask(() => {
onItemNavigate?.({ item });
if (autoCloseMobileOnNavigate && isMobileOpen) {
onCloseMobile("item-click");
}
});
// Intentionally allow native <a> navigation to proceed unless
// consumer called preventDefault in step 1 or 2.
void sectionId;
void indexInSection;
};
// v0.3.0 (C1, L55): renderRow always wraps in a SINGLE `<li>`. SidebarNavRow
// returns only the tooltip-wrapped link (no internal `<li>`). The renderItem
// slot path uses the SAME `<li>` wrapper — so consumer's
// `renderItem={({ defaultRender }) => defaultRender}` produces
// `<li><a>…</a></li>` instead of the v0.2.x `<li><li>…</li></li>` bug.
// Item-level `className` + `data-testid` are applied to this wrapper.
/*
* v0.3.1 — hover/focus emitters. Disabled rows short-circuit exactly as they
* do for clicks (L27): a row the user cannot activate should not report
* interest in it either, or a consumer using these for prefetch would warm
* routes the user can never reach.
*/
const handleItemHover =
(item: NavItem) => (event: React.MouseEvent) => {
if (item.disabled) return;
onItemHover?.({ item, event });
};
const handleItemFocus =
(item: NavItem) => (event: React.FocusEvent) => {
if (item.disabled) return;
onItemFocus?.({ item, event });
};
const renderRow = (item: NavItem, sectionId: string | null, indexInSection: number) => {
const isActive = activeItemId === item.id;
const isFocused = focusedItemId === item.id;
const rovingTabIndex = resolveRovingTabIndex(item.id);
const defaultRender = (
<SidebarNavRow
item={item}
isActive={isActive}
isCollapsed={isCollapsed}
isFocused={isFocused}
rovingTabIndex={rovingTabIndex}
linkComponent={linkComponent}
activeVariant={activeVariant}
onClick={handleItemClick(item, sectionId, indexInSection)}
onMouseEnter={onItemHover ? handleItemHover(item) : undefined}
onFocus={onItemFocus ? handleItemFocus(item) : undefined}
renderBadge={renderBadge}
renderTooltipContent={renderTooltipContent}
hrefTemplateValues={hrefTemplateValues}
resolveHref={resolveHref}
/>
);
const body = renderItem
? renderItem({
item,
isActive,
isCollapsed,
isFocused,
isDisabled: item.disabled ?? false,
sectionId,
indexInSection,
defaultRender,
})
: defaultRender;
return (
<li
key={item.id}
className={cn("list-none", item.className)}
data-testid={item["data-testid"]}
>
{body}
</li>
);
};
return (
<ul
role="list"
className={cn(
"flex flex-1 flex-col gap-1 overflow-y-auto",
// C5 hooks: motion-safe transitions will live here
)}
>
{entries.map((entry, i) => {
if (entry.kind === "separator") {
return <SidebarNavSeparator key={entry.id ?? `sep-${i}`} />;
}
if (entry.kind === "section") {
const isSectionCollapsed = collapsedSectionIds.has(entry.id);
const isSectionFocused = focusedItemId === entry.id;
const sectionRovingTabIndex =
entry.collapsible ? resolveRovingTabIndex(entry.id) : -1;
const handleSectionToggle = () => {
onToggleSection(entry.id);
onSectionToggle?.({ section: entry, collapsed: !isSectionCollapsed });
};
const defaultSection = (
<SidebarNavSection
key={entry.id}
section={entry}
isCollapsed={isSectionCollapsed}
isSidebarCollapsed={isCollapsed}
visibleItemCount={entry.items.length}
isFocused={isSectionFocused}
rovingTabIndex={sectionRovingTabIndex}
onToggle={handleSectionToggle}
>
{entry.items.map((child, idx) => renderRow(child, entry.id, idx))}
</SidebarNavSection>
);
if (!renderSection) return defaultSection;
// `renderSection` is expected to return an `<li>` (typically by
// forwarding `defaultRender` or composing its own list-item).
// Fragment carries the key without injecting an invalid DOM node
// between the `<ul>` parent and the consumer's `<li>`.
return (
<Fragment key={entry.id}>
{renderSection({
section: entry,
isCollapsed: isSectionCollapsed,
visibleItemCount: entry.items.length,
defaultRender: defaultSection,
})}
</Fragment>
);
}
// Top-level NavItem — renderRow returns an `<li>` directly with
// the key already applied, so no wrapper element is needed.
return renderRow(entry, null, i);
})}
</ul>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-nav-row.tsx
"use client";
import { cn } from "@/lib/utils";
import { getActiveVariantClasses } from "../lib/active-variant-classes";
import { resolveItemHref } from "../lib/href-resolver";
import type {
NavBadgeConfig,
NavItem,
NavLinkComponent,
AppSidebarProps,
} from "../types";
import { Icon } from "./icon";
import { NavBadge } from "./nav-badge";
import { TooltipWrapper } from "./tooltip-wrapper";
interface SidebarNavRowProps {
item: NavItem;
isActive: boolean;
isCollapsed: boolean;
isFocused: boolean;
/**
* Roving tabindex (L37). `0` when this row is the keyboard entry point
* (= focused row OR — when nothing is focused — the first focusable row);
* `-1` otherwise. Disabled items always pass through to `tabIndex={-1}`.
*/
rovingTabIndex: 0 | -1;
linkComponent: NavLinkComponent;
activeVariant?: AppSidebarProps["activeVariant"];
onClick: (event: React.MouseEvent) => void;
// v0.3.1 — `NavLinkProps` declared `onMouseEnter`/`onFocus` from the start;
// nothing ever passed them, so `onItemHover`/`onItemFocus` were inert props
// on the public surface for the component's whole life.
onMouseEnter?: (event: React.MouseEvent) => void;
onFocus?: (event: React.FocusEvent) => void;
renderBadge?: AppSidebarProps["renderBadge"];
renderTooltipContent?: AppSidebarProps["renderTooltipContent"];
// v0.2.0 — href resolution (L42 + L43)
hrefTemplateValues?: AppSidebarProps["hrefTemplateValues"];
resolveHref?: AppSidebarProps["resolveHref"];
}
/**
* Resolve a NavItem's badge config (shorthand number/string → full config).
*/
function resolveBadgeConfig(
badge: NavItem["badge"],
): NavBadgeConfig | null {
if (badge === undefined || badge === null) return null;
if (typeof badge === "number" || typeof badge === "string") {
return { value: badge };
}
// Already a NavBadgeConfig (or arbitrary ReactNode — treat as value)
if (typeof badge === "object" && "value" in badge) {
return badge as NavBadgeConfig;
}
// Bare ReactNode badge
return { value: badge as NavBadgeConfig["value"] };
}
export function SidebarNavRow({
item,
isActive,
isCollapsed,
isFocused,
rovingTabIndex,
linkComponent: LinkComponent,
activeVariant,
onClick,
onMouseEnter,
onFocus,
renderBadge,
renderTooltipContent,
hrefTemplateValues,
resolveHref,
}: SidebarNavRowProps) {
const isDisabled = item.disabled ?? false;
// v0.2.0: resolveItemHref applies the precedence pipeline (callback wins
// over template-value substitution; fallback to item.href as-is). When
// neither v0.2 prop is provided, this is byte-identical to v0.1 behavior.
const resolvedHref = resolveItemHref(item, { templateValues: hrefTemplateValues, resolveHref });
const href = resolvedHref ?? "#";
const badgeConfig = resolveBadgeConfig(item.badge);
const defaultBadge = badgeConfig ? (
<NavBadge {...badgeConfig} />
) : null;
const badgeNode = renderBadge && badgeConfig
? renderBadge({
item,
badge: badgeConfig,
position: isCollapsed ? "corner" : "inline-end",
defaultRender: defaultBadge,
})
: defaultBadge;
// Tooltip content when collapsed — label + optional shortcut + optional description
const tooltipContent = renderTooltipContent
? renderTooltipContent({ item, isActive })
: (item.tooltipContent ?? (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{item.label}</span>
{item.shortcut && (
<span className="text-[10px] text-muted-foreground">
{item.shortcut}
</span>
)}
{!isActive && item.description && (
<span className="text-[10px] text-muted-foreground/90">
{item.description}
</span>
)}
</div>
));
const linkEl = (
<LinkComponent
href={href}
onClick={onClick}
onMouseEnter={onMouseEnter}
onFocus={onFocus}
aria-current={isActive ? "page" : undefined}
aria-label={isCollapsed ? item.label : undefined}
aria-disabled={isDisabled || undefined}
data-active={isActive}
data-focused={isFocused || undefined}
data-nav-id={item.id}
tabIndex={isDisabled ? -1 : rovingTabIndex}
target={item.target}
rel={item.rel}
className={cn(
"group relative flex items-center gap-3 rounded-lg px-3 py-2.5",
"text-sm font-medium",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
// Active vs inactive paint via L12 variant matrix
getActiveVariantClasses(activeVariant, isActive),
// Disabled (L27)
isDisabled && "pointer-events-none cursor-not-allowed opacity-50",
// Center icon when collapsed
isCollapsed && "justify-center",
)}
>
{(item.icon !== undefined && item.icon !== null) || isCollapsed ? (
<span className="relative inline-flex h-5 w-5 shrink-0 items-center justify-center">
{item.icon !== undefined && item.icon !== null ? (
<Icon icon={item.icon} />
) : (
// Fallback glyph when collapsed AND no explicit icon —
// first letter of the label, styled like an icon. Keeps the
// row visually anchored so the corner-badge has something to
// sit on top of.
<span
aria-hidden="true"
className="inline-flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold uppercase text-muted-foreground"
>
{item.label?.[0] ?? "•"}
</span>
)}
{/* Badge in corner position when collapsed */}
{isCollapsed && badgeNode}
</span>
) : null}
{!isCollapsed && (
<span className="flex-1 truncate">{item.label}</span>
)}
{!isCollapsed && item.shortcut && (
<span
className={cn(
"rounded px-1.5 py-0.5 font-mono text-xs",
isActive ? "bg-primary-foreground/10" : "bg-muted text-muted-foreground",
)}
>
{item.shortcut}
</span>
)}
{!isCollapsed && item.accessory && (
<span className="inline-flex items-center">{item.accessory}</span>
)}
{/* Badge in inline-end position when expanded */}
{!isCollapsed && badgeNode}
</LinkComponent>
);
// v0.3.0 (C1, L55): SidebarNavRow returns ONLY the tooltip-wrapped link.
// The outer `<li>` is owned by SidebarNavList — consistent across the
// default path and the renderItem-slot path, so consumer's
// `renderItem={({ defaultRender }) => defaultRender}` no longer produces
// double-nested `<li><li>...</li></li>`.
return (
<TooltipWrapper
content={tooltipContent}
side="right"
disabled={!isCollapsed}
>
{linkEl}
</TooltipWrapper>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-nav-section.tsx
"use client";
import { ChevronDown } from "lucide-react";
import { useId } from "react";
import { cn } from "@/lib/utils";
import type { NavSection } from "../types";
import { Icon } from "./icon";
interface SidebarNavSectionProps {
section: NavSection;
isCollapsed: boolean; // section's own collapse state (NOT sidebar's)
isSidebarCollapsed: boolean; // sidebar collapsed = icon-only mode
visibleItemCount: number;
isFocused: boolean;
/** Roving tabindex for the collapsible header button (L37). */
rovingTabIndex: 0 | -1;
children: React.ReactNode; // the section's items (already rendered by parent)
onToggle: () => void;
}
/**
* Section header + collapsible body wrapper.
*
* Behavior:
* - collapsible=true → header is a <button aria-expanded aria-controls> that toggles
* - collapsible=false → header is a <h6 role="heading"> (label only, not focusable)
*
* At sidebar-collapsed mode (icon-only), section title hides; only the icon
* shows. If section has no icon, the section header collapses to a thin
* separator-like divider so groups remain visually distinct.
*/
export function SidebarNavSection({
section,
isCollapsed,
isSidebarCollapsed,
visibleItemCount,
isFocused,
rovingTabIndex,
children,
onToggle,
}: SidebarNavSectionProps) {
const bodyId = useId();
const collapsible = section.collapsible ?? false;
// Sidebar at icon-only mode: render condensed
if (isSidebarCollapsed) {
return (
<li className="list-none">
{section.icon || section.title ? (
<div className="px-3 py-1.5" role="presentation" aria-hidden="true">
{section.icon ? (
<Icon
icon={section.icon}
className="h-3.5 w-3.5 text-muted-foreground"
/>
) : (
<div className="h-px w-full bg-border" />
)}
</div>
) : (
<div className="my-1 h-px w-full bg-border" role="presentation" />
)}
{!isCollapsed && (
// <ul> (not <div>) so the `<li>` rows produced by SidebarNavRow
// are valid direct children. Outer wrapper is also <li> (section
// root), so the structure is <li><ul><li>...</li></ul></li> —
// valid HTML for a nested list group.
<ul
id={bodyId}
role="group"
aria-label={section.title}
className="flex flex-col gap-1"
>
{children}
</ul>
)}
</li>
);
}
// Expanded sidebar — full section UI
const headerInner = (
<span className="flex flex-1 items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.icon && <Icon icon={section.icon} className="h-3.5 w-3.5" />}
<span className="truncate">{section.title}</span>
{visibleItemCount > 0 && (
<span
className="ml-1 text-[10px] font-normal text-muted-foreground/70"
aria-hidden="true"
>
{visibleItemCount}
</span>
)}
</span>
);
return (
<li className="list-none">
{collapsible ? (
<button
type="button"
onClick={onToggle}
aria-expanded={!isCollapsed}
aria-controls={bodyId}
data-nav-id={section.id}
data-focused={isFocused || undefined}
tabIndex={rovingTabIndex}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left",
"hover:bg-muted/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
)}
>
{headerInner}
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground",
"motion-safe:transition-transform motion-safe:duration-200",
isCollapsed && "-rotate-90",
// RTL: chevron rotates the OTHER way when collapsed so it
// still points "outward" from the open direction
isCollapsed && "rtl:rotate-90",
)}
aria-hidden="true"
/>
</button>
) : section.title ? (
// Non-collapsible header — pure label, not focusable
<h6 className="px-3 py-1.5">{headerInner}</h6>
) : null}
{!isCollapsed && (
<ul
id={bodyId}
role="group"
aria-label={section.title}
className="mt-0.5 flex flex-col gap-1"
>
{children}
</ul>
)}
</li>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-nav-separator.tsx
import { cn } from "@/lib/utils";
interface SidebarNavSeparatorProps {
className?: string;
}
/**
* Thin horizontal divider between nav groups.
* Spec'd as separate part so the visual can be themed via consumer CSS.
*/
export function SidebarNavSeparator({ className }: SidebarNavSeparatorProps) {
return (
<li role="presentation" className={cn("my-2 px-3", className)}>
<div className="h-px w-full bg-border" />
</li>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-nav-trigger.tsx
"use client";
import { cloneElement, isValidElement } from "react";
import { PanelLeft } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAppSidebarContextOrNull } from "../contexts/sidebar-nav-context";
import type { AppSidebarHandle, AppSidebarTriggerProps } from "../types";
type SlotCloneProps = React.HTMLAttributes<HTMLElement> & {
type?: "button";
children?: React.ReactNode;
};
/**
* Minimal local slot (v0.3.1 — replaces the former `radix-ui` Slot import,
* which was undeclared in meta/registry.json AND forbidden by the validator;
* it broke module resolution on Base-UI consumers). Clones the single child
* element and merges the trigger props onto it: `className` joined, `style`
* merged (child wins), event handlers composed (child first, then trigger),
* any other overlapping prop child-wins. The child's own `ref` is preserved
* by `cloneElement` — no ref is ever passed in `triggerProps`.
*/
function SlotClone({ children, ...slotProps }: SlotCloneProps) {
if (!isValidElement(children)) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[app-sidebar] <AppSidebarTrigger asChild> expects a single React element child — got none. Rendering nothing.",
);
}
return null;
}
const childProps = children.props as Record<string, unknown>;
const merged: Record<string, unknown> = { ...slotProps };
for (const key of Object.keys(slotProps)) {
const slotValue = (slotProps as Record<string, unknown>)[key];
const childValue = childProps[key];
if (childValue === undefined) continue;
if (/^on[A-Z]/.test(key) && typeof slotValue === "function" && typeof childValue === "function") {
merged[key] = (...args: unknown[]) => {
(childValue as (...a: unknown[]) => void)(...args);
(slotValue as (...a: unknown[]) => void)(...args);
};
} else if (key === "className") {
merged[key] = cn(slotValue as string | undefined, childValue as string | undefined);
} else if (key === "style") {
merged[key] = {
...(slotValue as React.CSSProperties),
...(childValue as React.CSSProperties),
};
} else {
merged[key] = childValue;
}
}
return cloneElement(children, merged);
}
/**
* Companion trigger — mount in your app header to open the mobile drawer.
*
* Resolution order (L17 + L40):
* 1. Explicit `controls` ref/handle (escape hatch) — wins
* 2. Nearest AppSidebarContext (works when trigger is a DESCENDANT
* of <AppSidebar>; siblings can't read it because the context
* provider sits inside the sidebar's own render tree)
* 3. Neither → dev-only console.warn + no-op
*
* Resolution happens at CLICK TIME, not render time, because the ref's
* `.current` populates after the sidebar's `useImperativeHandle` runs
* (post-mount). Resolving at render time would see `null` on first paint.
*
* Visual: hamburger icon (PanelLeft) — opens the drawer. Closing is
* handled by the drawer's own SheetContent close-X (shipped by shadcn's
* Sheet primitive) + Esc + outside-click. The trigger button itself
* does NOT flip its icon based on drawer state — keeps the component
* stateless and avoids the cross-tree re-render problem for v0.1.
* A future AppSidebarProvider wrapper will enable trigger state
* reflection across React subtrees if needed.
*
* Override the icon entirely via `children` (custom node) or `asChild`
* (compose onto consumer's own element via the local SlotClone above).
*/
export function AppSidebarTrigger({
controls,
className,
children,
"aria-label": ariaLabel,
asChild = false,
}: AppSidebarTriggerProps) {
const ctx = useAppSidebarContextOrNull();
const handleClick = () => {
// Resolve handle at click time (not render time) — ref.current may
// still be null on first render before useImperativeHandle runs.
let resolved: AppSidebarHandle | null = null;
if (controls) {
if (typeof controls === "object" && controls !== null && "current" in controls) {
resolved = (controls as React.RefObject<AppSidebarHandle | null>).current;
} else {
resolved = controls as AppSidebarHandle;
}
}
if (!resolved && ctx) {
resolved = ctx.handle;
}
if (resolved) {
// v0.3.0 (C2, L54 + Q25): companion trigger always identifies as
// "trigger" so consumers wiring onMobileOpenChange to analytics can
// distinguish hamburger taps from other close paths.
resolved.toggleMobile("trigger");
} else if (process.env.NODE_ENV !== "production") {
console.warn(
"[app-sidebar] <AppSidebarTrigger> clicked with no <AppSidebar> in context and no `controls` prop — no-op.",
);
}
};
const sidebarId = ctx?.sidebarId;
const triggerProps = {
type: "button" as const,
onClick: handleClick,
"aria-controls": sidebarId,
"aria-haspopup": "dialog" as const,
"aria-label": ariaLabel ?? "Open navigation",
className: cn(
!asChild &&
"inline-flex h-9 w-9 items-center justify-center rounded-md text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
),
};
if (asChild) {
return <SlotClone {...triggerProps}>{children}</SlotClone>;
}
return (
<button {...triggerProps}>
{children ?? <PanelLeft className="h-4 w-4" aria-hidden="true" />}
</button>
);
}
src/registry/components/navigation/app-sidebar/parts/sidebar-skip-link.tsx
"use client";
import { cn } from "@/lib/utils";
import type { AppSidebarEventArgs } from "../types";
interface SidebarSkipLinkProps {
target: string;
label: string;
onActivated?: (args: AppSidebarEventArgs["skipLinkActivated"]) => void;
}
/**
* Opt-in skip link (L31). Hidden until focused; appears at the top of the
* sidebar's stacking context on keyboard focus. Activating jumps focus /
* scroll to the consumer-supplied `skipLinkTarget` (e.g. `#main-content`).
*
* The component intentionally renders a plain `<a>` — using the consumer's
* `linkComponent` for an in-page anchor would risk SPA frameworks treating
* it as a route change. Native anchor behavior is the right primitive.
*/
export function SidebarSkipLink({
target,
label,
onActivated,
}: SidebarSkipLinkProps) {
return (
<a
href={target}
data-sidebar-skip-link
onClick={(event) => onActivated?.({ event })}
className={cn(
// sr-only by default — appears on focus
"sr-only focus:not-sr-only",
"focus:absolute focus:left-2 focus:top-2 focus:z-50",
// RTL: skip link should sit on the same logical inline-start edge,
// which means physical-right when direction is RTL
"rtl:focus:left-auto rtl:focus:right-2",
"focus:inline-flex focus:items-center focus:rounded-md",
"focus:bg-card focus:px-3 focus:py-2",
"focus:text-sm focus:font-medium focus:text-foreground",
"focus:shadow-md focus:ring-1 focus:ring-border",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
)}
>
{label}
</a>
);
}
src/registry/components/navigation/app-sidebar/parts/tooltip-wrapper.tsx
"use client";
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
interface TooltipWrapperProps {
content: ReactNode;
children: ReactNode;
side?: "right" | "top" | "bottom" | "left";
disabled?: boolean;
/** Hover show-delay in ms (keyboard focus shows immediately). Default 300. */
delay?: number;
}
/** Gap between the wrapped element and the bubble (old Radix sideOffset parity). */
const SIDE_OFFSET_PX = 8;
type TooltipSide = NonNullable<TooltipWrapperProps["side"]>;
interface TooltipPosition {
top: number;
left: number;
}
function computePosition(rect: DOMRect, side: TooltipSide): TooltipPosition {
switch (side) {
case "left":
return { top: rect.top + rect.height / 2, left: rect.left - SIDE_OFFSET_PX };
case "top":
return { top: rect.top - SIDE_OFFSET_PX, left: rect.left + rect.width / 2 };
case "bottom":
return { top: rect.bottom + SIDE_OFFSET_PX, left: rect.left + rect.width / 2 };
case "right":
return { top: rect.top + rect.height / 2, left: rect.right + SIDE_OFFSET_PX };
}
}
const SIDE_TRANSFORM: Record<TooltipSide, string> = {
right: "translateY(-50%)",
left: "translate(-100%, -50%)",
top: "translate(-50%, -100%)",
bottom: "translateX(-50%)",
};
/**
* Tooltip wrapper for collapsed-sidebar row labels.
*
* v0.3.2 (F-cross-13 path-b): the shadcn Tooltip primitive is GONE from this
* wrapper. `children` is an arbitrary interactive element (nav link,
* primary-action button) and BOTH backends render `TooltipTrigger` as a
* native `<button>` — composing required `asChild` (Base UI rejects it) and
* wrapping without it nests interactive elements inside a button. So this is
* now a tiny local tooltip: a `position: fixed` bubble (escapes the nav
* list's overflow clipping the same way the old Radix portal did), shown
* after `delay` ms on hover / immediately on keyboard (:focus-visible)
* focus, hidden on leave / blur / press / Escape / any scroll.
* `aria-describedby` is cloned onto the child element. `delay` is honored
* cross-backend via setTimeout — no Radix-only `delayDuration` anywhere.
* Trade-offs vs Radix: no arrow, no collision flipping, no cross-tooltip
* skip-delay grouping.
*
* When `disabled` is true (e.g., expanded sidebar mode), children render
* without any wrapper element at all.
*/
export function TooltipWrapper({
content,
children,
side = "right",
disabled,
delay = 300,
}: TooltipWrapperProps) {
const tooltipId = useId();
const wrapperRef = useRef<HTMLSpanElement | null>(null);
const showTimerRef = useRef<number | null>(null);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearShowTimer = useCallback(() => {
if (showTimerRef.current !== null) {
window.clearTimeout(showTimerRef.current);
showTimerRef.current = null;
}
}, []);
const show = useCallback(() => {
const el = wrapperRef.current;
if (!el) return;
// Parity with the old asChild path: a pointer-events-none child (disabled
// nav row) never received hover, so its tooltip never opened.
const child = el.firstElementChild;
if (
child instanceof HTMLElement &&
getComputedStyle(child).pointerEvents === "none"
) {
return;
}
setPosition(computePosition(el.getBoundingClientRect(), side));
}, [side]);
const hide = useCallback(() => {
clearShowTimer();
setPosition(null);
}, [clearShowTimer]);
// Fixed-position coords go stale the moment anything scrolls (Radix
// repositioned via floating middleware; we simply dismiss). Capture phase
// so the nav list's own scroll container is caught too.
useEffect(() => {
if (position === null) return;
const onScroll = () => setPosition(null);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
};
}, [position]);
// Unmount — drop any pending show timer.
useEffect(() => clearShowTimer, [clearShowTimer]);
// `disabled` flipping true while shown (e.g. the rail expands under a
// resting pointer) skips render below but keeps state — the stale bubble
// would resurrect at old coords on the next collapse. Render-phase
// adjustment (react.dev "adjusting state when a prop changes"); a pending
// show-timer self-guards via the nulled wrapperRef, so only committed
// position needs the reset.
const [prevDisabled, setPrevDisabled] = useState(disabled);
if (disabled !== prevDisabled) {
setPrevDisabled(disabled);
if (disabled) setPosition(null);
}
if (disabled) return <>{children}</>;
// Wire aria-describedby onto the wrapped element itself (the focusable
// thing), composing with any describedby it already carries. Non-element
// children (arbitrary renderItem output) render unwired — best-effort.
let describedChildren = children;
if (isValidElement(children)) {
const element = children as ReactElement<{ "aria-describedby"?: string }>;
const existing = element.props["aria-describedby"];
describedChildren = cloneElement(element, {
"aria-describedby": existing ? `${existing} ${tooltipId}` : tooltipId,
});
}
return (
<span
ref={wrapperRef}
data-slot="app-sidebar-tooltip-wrapper"
className="relative block"
onMouseEnter={() => {
clearShowTimer();
if (delay <= 0) {
show();
return;
}
showTimerRef.current = window.setTimeout(show, delay);
}}
onMouseLeave={hide}
onFocus={(event) => {
// Radix parity: keyboard focus opens instantly; mouse-press focus doesn't.
const target = event.target as HTMLElement;
if (typeof target.matches === "function" && target.matches(":focus-visible")) {
show();
}
}}
onBlur={hide}
onPointerDown={hide}
onKeyDown={(event) => {
if (event.key === "Escape") hide();
}}
>
{describedChildren}
<span
role="tooltip"
id={tooltipId}
data-side={side}
style={
position
? { top: position.top, left: position.left, transform: SIDE_TRANSFORM[side] }
: undefined
}
className={cn(
"pointer-events-none fixed z-50 w-fit max-w-xs items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background",
position ? "inline-flex" : "hidden",
)}
>
{content}
</span>
</span>
);
}