{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sidebar","type":"registry:ui","title":"Sidebar","description":"A collapsible, grouped navigation rail with labelled sections, a current-item accent, and badges folded into each item's accessible name.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["navigation"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/sidebar/Sidebar.tsx","content":"\"use client\";\n\nimport { forwardRef, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { SIDEBAR_KEY, type SidebarContext } from \"./types\";\nimport \"./sidebar.css\";\n\nexport interface SidebarProps {\n\t/** Accessible name for the `<nav>` landmark. Defaults to `\"Sidebar\"`. */\n\tlabel?: string;\n\t/**\n\t * Whether the sidebar is collapsed to an icon-only rail. A plain prop —\n\t * `Sidebar` has no internal control of its own that ever changes this, so\n\t * there is nothing for a controlled/uncontrolled split to round-trip. Own\n\t * the state yourself and pass it down.\n\t */\n\tcollapsed?: boolean;\n\t/** `SidebarGroup`, `SidebarSeparator` and `SidebarFooter`. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nexport const Sidebar = forwardRef<HTMLElement, SidebarProps>(function Sidebar(\n\t{ label = \"Sidebar\", collapsed = false, children, className },\n\tref\n) {\n\t// The context VALUE is a plain object rebuilt when `collapsed` changes, and\n\t// that rebuild is what re-renders the nested consumers — the React shape of\n\t// the source's `get collapsed()` getter. Never `useMemo(..., [])` here.\n\tconst context = useMemo<SidebarContext>(() => ({ collapsed }), [collapsed]);\n\n\treturn (\n\t\t<SIDEBAR_KEY.Provider value={context}>\n\t\t\t<nav\n\t\t\t\tref={ref}\n\t\t\t\taria-label={label}\n\t\t\t\tdata-collapsed={collapsed}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"ft-sidebar bg-background flex h-full flex-col gap-3 p-3\",\n\t\t\t\t\tcollapsed ? \"w-[64px] items-center px-2\" : \"w-[240px]\",\n\t\t\t\t\tclassName\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{children}\n\t\t\t</nav>\n\t\t</SIDEBAR_KEY.Provider>\n\t);\n});\n\nSidebar.displayName = \"Sidebar\";\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/Sidebar.tsx"},{"path":"react/src/components/sidebar/SidebarFooter.tsx","content":"\"use client\";\n\nimport { forwardRef, useContext } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { SidebarSeparator } from \"./SidebarSeparator\";\nimport { SIDEBAR_KEY } from \"./types\";\n\nexport interface SidebarFooterProps {\n\t/** A decorative avatar, shown even when the sidebar is collapsed. */\n\tavatar?: ReactNode;\n\t/** The name / text next to the avatar. Moves to `sr-only` while collapsed — never removed. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nexport const SidebarFooter = forwardRef<HTMLDivElement, SidebarFooterProps>(function SidebarFooter(\n\t{ avatar, children, className },\n\tref\n) {\n\tconst sidebar = useContext(SIDEBAR_KEY);\n\tconst collapsed = sidebar?.collapsed ?? false;\n\n\treturn (\n\t\t<div ref={ref} className={cn(\"ft-sidebar-footer mt-auto flex w-full flex-col\", className)}>\n\t\t\t<SidebarSeparator />\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"flex items-center gap-2.5 px-2 py-[7px] text-[12px]\",\n\t\t\t\t\t\"text-muted-foreground\",\n\t\t\t\t\tcollapsed && \"justify-center\"\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{avatar ? (\n\t\t\t\t\t<span className=\"shrink-0\" aria-hidden=\"true\">\n\t\t\t\t\t\t{avatar}\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t\t<span className={cn(\"min-w-0 truncate\", collapsed && \"sr-only\")}>{children}</span>\n\t\t\t</div>\n\t\t</div>\n\t);\n});\n\nSidebarFooter.displayName = \"SidebarFooter\";\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/SidebarFooter.tsx"},{"path":"react/src/components/sidebar/SidebarGroup.tsx","content":"\"use client\";\n\nimport { useContext } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { SIDEBAR_KEY } from \"./types\";\n\nexport interface SidebarGroupProps {\n\t/** The section heading, e.g. `\"General\"`. Required — read by `aria-labelledby`, not decorative. */\n\tlabel: string;\n\t/** The `SidebarItem`s in this section. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nexport function SidebarGroup({ label, children, className }: SidebarGroupProps) {\n\t// `undefined` outside a Sidebar: the group still renders correctly, just\n\t// never in the icon-only collapsed presentation.\n\tconst sidebar = useContext(SIDEBAR_KEY);\n\tconst collapsed = sidebar?.collapsed ?? false;\n\n\t// SSR-stable — `uid()` throws outside the browser, and this id has to\n\t// exist on the very first server-rendered markup for `aria-labelledby`\n\t// to point at something real.\n\tconst headingId = useFancyId();\n\n\treturn (\n\t\t<div className={cn(\"ft-sidebar-group flex w-full flex-col gap-0.5\", className)}>\n\t\t\t<span\n\t\t\t\tid={headingId}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"text-muted-foreground/70 px-2 py-1 text-[10px] font-semibold tracking-[0.08em] uppercase\",\n\t\t\t\t\tcollapsed && \"sr-only\"\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{label}\n\t\t\t</span>\n\t\t\t<ul className=\"m-0 flex list-none flex-col gap-0.5 p-0\" aria-labelledby={headingId}>\n\t\t\t\t{children}\n\t\t\t</ul>\n\t\t</div>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/SidebarGroup.tsx"},{"path":"react/src/components/sidebar/SidebarItem.tsx","content":"\"use client\";\n\nimport { forwardRef, useContext } from \"react\";\nimport type { MouseEvent, ReactNode, Ref } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { SIDEBAR_KEY } from \"./types\";\nimport \"./sidebar-item.css\";\n\nexport interface SidebarItemProps {\n\t/** Renders an `<a>` when set; a `<button type=\"button\">` otherwise. */\n\thref?: string;\n\t/**\n\t * Marks this as the current item: `aria-current=\"page\"` plus the\n\t * accent left bar — current is never conveyed by colour alone.\n\t */\n\tcurrent?: boolean;\n\t/** A count or short flag, e.g. `4` — rendered as a pill and folded into the accessible name. */\n\tbadge?: string | number;\n\t/**\n\t * What the badge means, read alongside its value in the accessible\n\t * name (`\"Inbox, 4 unread\"`). Defaults to just the badge value if not given.\n\t */\n\tbadgeLabel?: string;\n\t/** Disables both the click and keyboard-activation paths. */\n\tdisabled?: boolean;\n\t/** Click handler, for the `<button>` branch. Never called while `disabled`. */\n\tonClick?: (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void;\n\t/** A decorative glyph or icon, shown even when the sidebar is collapsed. */\n\ticon?: ReactNode;\n\t/** The item's label. Moves to `sr-only` text while the sidebar is collapsed — never removed. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the select cue through the sound controller. Off by default;\n\t * only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport const SidebarItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, SidebarItemProps>(\n\tfunction SidebarItem(\n\t\t{\n\t\t\thref,\n\t\t\tcurrent = false,\n\t\t\tbadge,\n\t\t\tbadgeLabel,\n\t\t\tdisabled = false,\n\t\t\tonClick,\n\t\t\ticon,\n\t\t\tchildren,\n\t\t\tclassName,\n\t\t\tsound = false,\n\t\t},\n\t\tref\n\t) {\n\t\tconst sidebar = useContext(SIDEBAR_KEY);\n\t\tconst collapsed = sidebar?.collapsed ?? false;\n\n\t\tconst playCue = useSoundCue(sound);\n\n\t\tconst hasBadge = badge !== undefined && badge !== null && badge !== \"\";\n\n\t\tconst classes = cn(\n\t\t\t\"ft-sidebar-item flex w-full items-center gap-2.5 rounded-[6px] px-2 py-[7px] text-[13px] transition-colors\",\n\t\t\t\"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none\",\n\t\t\tcollapsed && \"justify-center\",\n\t\t\tcurrent\n\t\t\t\t? \"ft-sidebar-item--current bg-accent text-accent-foreground font-medium\"\n\t\t\t\t: \"text-muted-foreground hover:bg-accent hover:text-accent-foreground\",\n\t\t\tdisabled && \"pointer-events-none opacity-50\",\n\t\t\tclassName\n\t\t);\n\n\t\t// The native `disabled` attribute on the `<button>` branch already blocks\n\t\t// real pointer/keyboard input, and the anchor branch strips `href`\n\t\t// entirely below — but a synthetic click (a test's `fireEvent.click`,\n\t\t// some assistive tech) reaches this handler regardless of either, so the\n\t\t// guard is what actually stops the callback in both cases.\n\t\tfunction handleClick(event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) {\n\t\t\tif (disabled) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// One shared handler covers both the anchor and the button branch,\n\t\t\t// so the cue reaches both. It marks moving to a DIFFERENT item:\n\t\t\t// re-activating the one already current plays nothing.\n\t\t\tif (!current) playCue(\"select\");\n\t\t\tonClick?.(event);\n\t\t}\n\n\t\tconst content = (\n\t\t\t<>\n\t\t\t\t{icon ? (\n\t\t\t\t\t<span className=\"ft-sidebar-item-icon shrink-0\" aria-hidden=\"true\">\n\t\t\t\t\t\t{icon}\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t\t<span className={cn(\"min-w-0 flex-1 truncate text-left\", collapsed && \"sr-only\")}>\n\t\t\t\t\t{children}\n\t\t\t\t</span>\n\t\t\t\t{hasBadge ? (\n\t\t\t\t\t<span className={cn(\"ft-sidebar-item-badge shrink-0\", collapsed && \"sr-only\")}>\n\t\t\t\t\t\t{badge}\n\t\t\t\t\t\t{badgeLabel ? <span className=\"sr-only\"> {badgeLabel}</span> : null}\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t</>\n\t\t);\n\n\t\treturn (\n\t\t\t<li className=\"ft-sidebar-item-wrapper w-full list-none\">\n\t\t\t\t{href ? (\n\t\t\t\t\t<a\n\t\t\t\t\t\tref={ref as Ref<HTMLAnchorElement>}\n\t\t\t\t\t\thref={disabled ? undefined : href}\n\t\t\t\t\t\taria-current={current ? \"page\" : undefined}\n\t\t\t\t\t\taria-disabled={disabled ? \"true\" : undefined}\n\t\t\t\t\t\ttabIndex={disabled ? -1 : undefined}\n\t\t\t\t\t\tclassName={classes}\n\t\t\t\t\t\tonClick={handleClick}\n\t\t\t\t\t>\n\t\t\t\t\t\t{content}\n\t\t\t\t\t</a>\n\t\t\t\t) : (\n\t\t\t\t\t<button\n\t\t\t\t\t\tref={ref as Ref<HTMLButtonElement>}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\taria-current={current ? \"page\" : undefined}\n\t\t\t\t\t\tclassName={classes}\n\t\t\t\t\t\tonClick={handleClick}\n\t\t\t\t\t>\n\t\t\t\t\t\t{content}\n\t\t\t\t\t</button>\n\t\t\t\t)}\n\t\t\t</li>\n\t\t);\n\t}\n);\n\nSidebarItem.displayName = \"SidebarItem\";\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/SidebarItem.tsx"},{"path":"react/src/components/sidebar/SidebarSeparator.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\n\nimport { cn } from \"../../utils\";\n\nexport interface SidebarSeparatorProps {\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nexport const SidebarSeparator = forwardRef<HTMLHRElement, SidebarSeparatorProps>(\n\tfunction SidebarSeparator({ className }, ref) {\n\t\t// `<hr>` is `role=\"separator\"` implicitly — no extra ARIA needed.\n\t\treturn <hr ref={ref} className={cn(\"ft-sidebar-separator border-border my-2 w-full\", className)} />;\n\t}\n);\n\nSidebarSeparator.displayName = \"SidebarSeparator\";\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/SidebarSeparator.tsx"},{"path":"react/src/components/sidebar/index.ts","content":"\"use client\";\n\nexport { Sidebar } from \"./Sidebar\";\nexport type { SidebarProps } from \"./Sidebar\";\nexport { SidebarGroup } from \"./SidebarGroup\";\nexport type { SidebarGroupProps } from \"./SidebarGroup\";\nexport { SidebarItem } from \"./SidebarItem\";\nexport type { SidebarItemProps } from \"./SidebarItem\";\nexport { SidebarSeparator } from \"./SidebarSeparator\";\nexport type { SidebarSeparatorProps } from \"./SidebarSeparator\";\nexport { SidebarFooter } from \"./SidebarFooter\";\nexport type { SidebarFooterProps } from \"./SidebarFooter\";\nexport { SIDEBAR_KEY } from \"./types\";\nexport type { SidebarContext } from \"./types\";\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/index.ts"},{"path":"react/src/components/sidebar/sidebar-item.css","content":"/*\n * Declared here too, not just on Sidebar's root: `SidebarItem` is meant to\n * be usable on its own DOM subtree, and relying on inheritance alone\n * would leave the accent bar and badge fill missing wherever it doesn't\n * happen to render as a Sidebar descendant. Same shape as\n * `Autocomplete`/`Combobox`/`TimePicker`'s `--ft-field-accent` and\n * `Button`'s `--ft-btn-accent` — every one of those declares its own copy\n * of the same formula rather than trusting an ancestor. This reads\n * `--ft-accent`, never redeclares it, so a consumer retinting the whole\n * tree from higher up still reaches this. Declared on the item's own\n * root class, not the `--current` modifier, so the badge fill below —\n * which needs it regardless of `current` — inherits it too.\n */\n.ft-sidebar-item {\n\t--ft-nav-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n\t/* The accent bar below is an absolutely-positioned pseudo, which needs a\n\t   containing block. A flex item taking `position: relative` changes no\n\t   layout. */\n\tposition: relative;\n}\n\n/*\n * The accent bar, character-for-character the declaration that used to sit\n * on `.ft-sidebar-item--current` itself — same inset shadow, same inherited\n * radius, so the resting look is unchanged. It lives one layer down for two\n * reasons. It can now be transitioned without transitioning the focus ring,\n * which shares the `box-shadow` property and must never animate. And it\n * frees the host's own `box-shadow`, which Tailwind's `focus-visible:ring-2`\n * compiles to: an unlayered rule beats `@layer utilities` regardless of\n * specificity, so a bar declared on the host itself would suppress the\n * current item's focus ring entirely.\n */\n.ft-sidebar-item::before {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: inherit;\n\tpointer-events: none;\n\tbox-shadow: inset 2px 0 0 var(--ft-nav-accent);\n\topacity: 0;\n}\n\n.ft-sidebar-item--current::before {\n\topacity: 1;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t/* 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) = tokens.EASINGS.inout */\n\t.ft-sidebar-item::before {\n\t\ttransition:\n\t\t\topacity var(--ft-sidebar-signal-duration, var(--ft-duration-fast, 150ms))\n\t\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1)),\n\t\t\ttransform var(--ft-sidebar-signal-duration, var(--ft-duration-fast, 150ms))\n\t\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t\ttransform: scaleY(0.4);\n\t\ttransform-origin: center;\n\t}\n\n\t.ft-sidebar-item--current::before {\n\t\ttransform: scaleY(1);\n\t}\n}\n\n.ft-sidebar-item-badge {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tmin-width: 1.1rem;\n\tborder-radius: 999px;\n\tpadding: 1px 7px;\n\tfont-size: 10px;\n\tfont-weight: 600;\n\tline-height: 1.4;\n\tbackground: var(--ft-nav-accent);\n\tcolor: var(--ft-accent-foreground, oklch(1 0 0));\n}\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/sidebar-item.css"},{"path":"react/src/components/sidebar/sidebar.css","content":"/*\n * `SidebarItem` declares this same fallback again, locally on itself —\n * deliberately, not a drift: an item is a fully standalone component, and\n * this declaration here only reaches it through DOM inheritance, which\n * plain CSS custom properties do regardless of where the rule was authored.\n * `Autocomplete`/`Combobox`/`TimePicker` and its portalled `TimePickerPanel`\n * are the precedent — a component that can render somewhere other than as\n * a literal descendant needs its own copy, not just an ancestor's. Neither\n * declaration redeclares `--ft-accent` itself, so a consumer retinting the\n * whole tree via `--ft-accent` still reaches both.\n */\n.ft-sidebar {\n\t--ft-nav-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n}\n\n/*\n * A NAMED exception to \"only opacity and transform animate\": a collapsing\n * sidebar is a layout change and there is no transform that reflows the\n * content beside it. Kept, retokenised, and declared as an exception rather\n * than quietly claiming compliance.\n *\n * The curve is `--ft-ease-inout`, not `--ft-ease-out`: a collapse is a\n * reversible state flip, not an arrival, and it has to read the same in\n * both directions.\n *\n * 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) = tokens.EASINGS.inout\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-sidebar {\n\t\ttransition: width var(--ft-sidebar-collapse-duration, var(--ft-duration-fast, 150ms))\n\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/sidebar.css"},{"path":"react/src/components/sidebar/types.ts","content":"\"use client\";\n\n/**\n * The contract between `Sidebar` and everything nested inside it.\n *\n * `Sidebar` owns the collapsed/expanded state and publishes it through\n * context so `SidebarGroup`, `SidebarItem` and `SidebarFooter` can each\n * adapt their own rendering (icon-only, `sr-only` labels) without the state\n * being threaded down as a prop through every layer by hand. Unlike\n * `ToggleGroupContext`, this context is read-only from the consumer side —\n * there is no `toggle`/`setCollapsed` method here on purpose: `collapsed` is\n * driven entirely from outside the compound (a consumer's own trigger,\n * passed through `Sidebar`'s `collapsed` prop), never by anything nested\n * inside it.\n *\n * A subcomponent mounted outside a `Sidebar` degrades instead of throwing —\n * the context reads `undefined` and every consumer treats that the same as\n * `collapsed: false`. See each subcomponent for the exact fallback.\n */\n\nimport { createContext } from \"react\";\n\n/** What `Sidebar` publishes. Nested components read it; only `Sidebar` writes it. */\nexport interface SidebarContext {\n\t/** Whether the sidebar is currently in its icon-only, collapsed state. */\n\treadonly collapsed: boolean;\n}\n\n/**\n * The context nested components read to learn whether the surrounding rail is\n * collapsed. The source publishes it under a `Symbol` context key; React's own\n * context object plays that role here, so the exported name is kept and the\n * value is a `React.Context` rather than a symbol:\n *\n * ```tsx\n * const sidebar = useContext(SIDEBAR_KEY);\n * ```\n *\n * Read it as optional — a subcomponent rendered outside a `Sidebar` gets\n * `undefined` rather than throwing.\n */\nexport const SIDEBAR_KEY = createContext<SidebarContext | undefined>(undefined);\nSIDEBAR_KEY.displayName = \"SidebarContext\";\n","type":"registry:ui","target":"@ui/fancy/components/sidebar/types.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/sidebar?framework=react"}}