Skip to main content

priv/registry/sidebar.json

{
  "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"
  ]
}