shadimage
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 · light
iconslucide-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>
  );
}

More from ilinxa

Browse all components in this library →