{
"files": [
{
"content": "defmodule Shadix.Components.Sidebar do\n @moduledoc \"\"\"\n A collapsible application sidebar built from a small set of composable parts.\n\n `sidebar_provider/1` is a flex wrapper that owns the open/closed state via a\n `data-state` attribute (`\"expanded\"` / `\"collapsed\"`) and carries the\n `ShadixSidebar` hook (assets/ts/sidebar.ts). It also gets a Tailwind group\n marker (`group/sidebar`) so descendant elements can react to the state purely\n in CSS via `group-data-[state=collapsed]/sidebar:*` variants. The provider\n holds both the `sidebar/1` and the main content.\n\n `sidebar_trigger/1` is a button carrying `data-slot=\"sidebar-trigger\"`; the\n hook listens for clicks on any such trigger inside the provider and flips the\n provider's `data-state`. The CSS reacts: `sidebar/1` is an `<aside>` that\n animates its width between `w-64` (expanded) and `w-0`/`overflow-hidden`\n (collapsed). No server round-trip is involved — toggling is entirely\n client-side.\n\n The remaining parts (`sidebar_header/1`, `sidebar_content/1`,\n `sidebar_footer/1`, `sidebar_group/1`, `sidebar_group_label/1`,\n `sidebar_menu/1`, `sidebar_menu_item/1`, `sidebar_menu_button/1`) are\n layout/structure primitives styled with the `--sidebar*` theme tokens\n (`bg-sidebar`, `text-sidebar-foreground`, `bg-sidebar-accent`, …).\n\n ## Simplifications (vs. shadcn's Sidebar)\n\n shadcn's Sidebar is a large system; this is a focused v1. Intentionally\n omitted:\n\n * **No mobile off-canvas sheet.** There is no `Sheet`-based mobile variant;\n the sidebar collapses in place on every viewport.\n * **No rail.** There is no draggable/clickable rail handle along the edge.\n * **No cookie persistence.** Open state is not persisted to a cookie (or to\n the server); it resets on reload.\n * **No keyboard shortcut.** There is no global `⌘B` / `Ctrl+B` toggle; only\n the trigger button toggles the sidebar.\n * **No `side`/`variant`/`collapsible` modes.** The sidebar is always on the\n left, always the \"sidebar\" variant, and always collapses to zero width\n (no \"icon\" collapse mode).\n * **No `useSidebar` context.** State lives in a DOM `data-state` attribute,\n not a shared context object; `sidebar_menu_button/1` does not auto-render\n tooltips when collapsed.\n \"\"\"\n use Phoenix.Component\n\n import Shadix.Cn\n\n @doc \"\"\"\n The flex wrapper that owns sidebar open/closed state.\n\n Renders a `div` with `data-slot=\"sidebar-provider\"`, the `ShadixSidebar` hook,\n and `data-state` (defaults to `\"expanded\"`). Place a `sidebar/1` and the main\n content inside its `:inner_block`.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_provider(assigns) do\n ~H\"\"\"\n <div\n id={@id}\n data-slot=\"sidebar-provider\"\n phx-hook=\"ShadixSidebar\"\n data-state=\"expanded\"\n class={cn([\"group/sidebar flex min-h-svh w-full\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n The sidebar surface: an `<aside>` whose width animates with the provider state.\n\n Expanded is `w-64`; collapsed is `w-0` with `overflow-hidden`, driven by the\n provider's `data-state` via the `group/sidebar` marker.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar(assigns) do\n ~H\"\"\"\n <aside\n data-slot=\"sidebar\"\n class={\n cn([\n \"flex h-svh shrink-0 flex-col overflow-hidden border-r bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-linear motion-reduce:transition-none\",\n \"w-64 group-data-[state=collapsed]/sidebar:w-0\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </aside>\n \"\"\"\n end\n\n @doc \"\"\"\n The toggle button. Clicking it flips the provider's `data-state`.\n\n Carries `data-slot=\"sidebar-trigger\"`; the `ShadixSidebar` hook reacts to the\n click. Renders a panel icon by default; override via the `:inner_block` slot.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block)\n\n def sidebar_trigger(assigns) do\n ~H\"\"\"\n <button\n type=\"button\"\n data-slot=\"sidebar-trigger\"\n aria-label=\"Toggle sidebar\"\n aria-expanded=\"true\"\n class={\n cn([\n \"inline-flex size-7 items-center justify-center rounded-md text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n @class\n ])\n }\n {@rest}\n >\n <%= if @inner_block != [] do %>\n {render_slot(@inner_block)}\n <% else %>\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n <path d=\"M9 3v18\" />\n </svg>\n <% end %>\n </button>\n \"\"\"\n end\n\n @doc \"The sidebar header region (top of the sidebar).\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_header(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"sidebar-header\"\n class={cn([\"flex flex-col gap-2 p-2\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"The scrollable main content region of the sidebar.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_content(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"sidebar-content\"\n class={cn([\"flex min-h-0 flex-1 flex-col gap-2 overflow-auto p-2\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"The sidebar footer region (bottom of the sidebar).\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_footer(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"sidebar-footer\"\n class={cn([\"flex flex-col gap-2 p-2\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"A logical group of menu items within the sidebar content.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_group(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"sidebar-group\"\n class={cn([\"relative flex w-full min-w-0 flex-col p-2\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"A non-interactive label for a `sidebar_group/1`.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_group_label(assigns) do\n ~H\"\"\"\n <div\n data-slot=\"sidebar-group-label\"\n class={\n cn([\n \"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"The menu list (`ul`) holding `sidebar_menu_item/1`s.\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_menu(assigns) do\n ~H\"\"\"\n <ul\n data-slot=\"sidebar-menu\"\n class={cn([\"flex w-full min-w-0 flex-col gap-1\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </ul>\n \"\"\"\n end\n\n @doc \"A menu list item (`li`).\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def sidebar_menu_item(assigns) do\n ~H\"\"\"\n <li\n data-slot=\"sidebar-menu-item\"\n class={cn([\"group/menu-item relative\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </li>\n \"\"\"\n end\n\n @doc \"\"\"\n A styled menu entry.\n\n Renders an `<a>` so the caller can pass `href`/`navigate`/`patch` (or any\n attribute) through `:rest`. Set `active` to mark the current item.\n \"\"\"\n attr(:active, :boolean, default: false)\n attr(:class, :string, default: nil)\n attr(:rest, :global, include: ~w(href navigate patch method download))\n slot(:inner_block, required: true)\n\n def sidebar_menu_button(assigns) do\n ~H\"\"\"\n <a\n data-slot=\"sidebar-menu-button\"\n data-active={to_string(@active)}\n class={\n cn([\n \"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </a>\n \"\"\"\n end\nend\n",
"path": "sidebar.ex"
}
],
"hooks": [
{
"content": "interface SidebarHook {\n el: HTMLElement;\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixSidebar = {\n mounted(this: SidebarHook) {\n const provider = this.el;\n const sidebar = provider.querySelector<HTMLElement>(\n '[data-slot=\"sidebar\"]',\n );\n\n // Give the sidebar surface a stable id so triggers can point at it via\n // `aria-controls`, and so screen-reader users know what the button governs.\n if (sidebar && !sidebar.id) {\n sidebar.id = `${provider.id}-sidebar`;\n }\n\n const triggers = () =>\n Array.from(\n provider.querySelectorAll<HTMLElement>(\n '[data-slot=\"sidebar-trigger\"]',\n ),\n ).filter(\n // Only triggers that belong to this provider, not a nested one.\n (trigger) =>\n trigger.closest('[data-slot=\"sidebar-provider\"]') === provider,\n );\n\n // Reflect the open/closed state to assistive tech: `aria-expanded` on each\n // trigger, and `inert` + `aria-hidden` on the collapsed surface so its links\n // leave the tab order and the accessibility tree while visually hidden.\n const sync = () => {\n const collapsed = provider.getAttribute(\"data-state\") === \"collapsed\";\n for (const trigger of triggers()) {\n trigger.setAttribute(\"aria-expanded\", collapsed ? \"false\" : \"true\");\n if (sidebar) trigger.setAttribute(\"aria-controls\", sidebar.id);\n }\n if (sidebar) {\n if (collapsed) {\n sidebar.setAttribute(\"inert\", \"\");\n sidebar.setAttribute(\"aria-hidden\", \"true\");\n } else {\n sidebar.removeAttribute(\"inert\");\n sidebar.removeAttribute(\"aria-hidden\");\n }\n }\n };\n\n const onClick = (e: Event) => {\n const target = e.target as Element | null;\n const trigger = target?.closest('[data-slot=\"sidebar-trigger\"]');\n // Only react to triggers that belong to this provider, not a nested one.\n if (!trigger || !provider.contains(trigger)) return;\n const state = provider.getAttribute(\"data-state\");\n provider.setAttribute(\n \"data-state\",\n state === \"collapsed\" ? \"expanded\" : \"collapsed\",\n );\n };\n\n sync();\n\n provider.addEventListener(\"click\", onClick);\n const observer = new MutationObserver(sync);\n observer.observe(provider, {\n attributes: true,\n attributeFilter: [\"data-state\"],\n });\n\n (provider as unknown as { _cleanup?: () => void })._cleanup = () => {\n provider.removeEventListener(\"click\", onClick);\n observer.disconnect();\n };\n },\n\n destroyed(this: SidebarHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixSidebar",
"path": "sidebar.ts"
}
],
"name": "sidebar",
"npm_deps": [],
"registry_deps": [
"cn"
]
}