Skip to main content

priv/registry/tabs.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Tabs do\n  @moduledoc ~S\"\"\"\n  A fully client-driven, uncontrolled tabs component.\n\n  Unlike the React/Radix original there is no server state: `tabs_trigger/1`\n  carries a `Phoenix.LiveView.JS` `phx-click` that, scoped to a shared\n  `data-tabs-id`, hides every sibling panel and shows its own, then flips\n  `aria-selected` / `data-state` across all triggers in the group. The initial\n  state is rendered statically from the required `:default` value: the matching\n  panel is shown and its trigger marked selected.\n\n  Triggers and panels are wired together by id (`\"#{id}-tab-#{value}\"` /\n  `\"#{id}-panel-#{value}\"`) so `aria-controls` / `aria-labelledby` line up, and a\n  small `ShadixTabs` hook on the tablist provides ArrowLeft/ArrowRight roving\n  focus with activation on focus for keyboard a11y.\n\n  data-slots: `tabs`, `tabs-list`, `tabs-trigger`, `tabs-content`.\n  \"\"\"\n  use Phoenix.Component\n\n  alias Phoenix.LiveView.JS\n\n  import Shadix.Cn\n\n  @doc \"\"\"\n  The tabs root. `:default` is the value of the tab that is active on first\n  render; descendant triggers/contents take this `:id` to wire ids together.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:default, :string, required: true)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def tabs(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      data-slot=\"tabs\"\n      data-tabs-default={@default}\n      class={cn([\"flex flex-col gap-2\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  The list of triggers (`role=\"tablist\"`). Carries the `ShadixTabs` hook for\n  roving focus.\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 tabs_list(assigns) do\n    ~H\"\"\"\n    <div\n      id={\"#{@id}-list\"}\n      role=\"tablist\"\n      data-slot=\"tabs-list\"\n      phx-hook=\"ShadixTabs\"\n      class={\n        cn([\n          \"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc ~S\"\"\"\n  A single tab trigger. `:id` is the *tabs'* id and `:value` selects which panel\n  it controls. Clicking shows its panel, hides siblings, and updates selected\n  state across the group via a shared `data-tabs-id` selector.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:value, :string, required: true)\n  attr(:default, :string, required: true)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def tabs_trigger(assigns) do\n    assigns = assign(assigns, :active, assigns.value == assigns.default)\n\n    ~H\"\"\"\n    <button\n      type=\"button\"\n      id={\"#{@id}-tab-#{@value}\"}\n      role=\"tab\"\n      data-slot=\"tabs-trigger\"\n      data-tabs-id={@id}\n      data-tabs-value={@value}\n      data-state={if @active, do: \"active\", else: \"inactive\"}\n      aria-selected={if @active, do: \"true\", else: \"false\"}\n      aria-controls={\"#{@id}-panel-#{@value}\"}\n      tabindex={if @active, do: \"0\", else: \"-1\"}\n      phx-click={select_tab(@id, @value)}\n      class={\n        cn([\n          \"data-[state=active]:bg-background data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 dark:text-muted-foreground dark:hover:text-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all hover:text-foreground focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </button>\n    \"\"\"\n  end\n\n  @doc ~S\"\"\"\n  A tab panel. `:id` is the *tabs'* id and `:value` ties it to its trigger. The\n  panel is `hidden` unless its `:value` equals `:default`.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:value, :string, required: true)\n  attr(:default, :string, required: true)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def tabs_content(assigns) do\n    assigns = assign(assigns, :active, assigns.value == assigns.default)\n\n    ~H\"\"\"\n    <div\n      id={\"#{@id}-panel-#{@value}\"}\n      role=\"tabpanel\"\n      data-slot=\"tabs-content\"\n      data-tabs-id={@id}\n      data-tabs-value={@value}\n      aria-labelledby={\"#{@id}-tab-#{@value}\"}\n      tabindex=\"0\"\n      class={cn([\"flex-1 outline-none\", !@active && \"hidden\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that activates the tab `value` in\n  the tabs group `id`: hides every sibling panel and shows the chosen one, then\n  resets selected/active state on all triggers before marking the chosen one.\n  \"\"\"\n  def select_tab(id, value) do\n    %JS{}\n    |> JS.hide(to: \"[data-tabs-id='#{id}'][role='tabpanel']\")\n    |> JS.set_attribute({\"aria-selected\", \"false\"},\n      to: \"[data-tabs-id='#{id}'][role='tab']\"\n    )\n    |> JS.set_attribute({\"data-state\", \"inactive\"}, to: \"[data-tabs-id='#{id}'][role='tab']\")\n    |> JS.set_attribute({\"tabindex\", \"-1\"}, to: \"[data-tabs-id='#{id}'][role='tab']\")\n    |> JS.show(to: \"##{id}-panel-#{value}\", display: \"block\")\n    |> JS.set_attribute({\"aria-selected\", \"true\"}, to: \"##{id}-tab-#{value}\")\n    |> JS.set_attribute({\"data-state\", \"active\"}, to: \"##{id}-tab-#{value}\")\n    |> JS.set_attribute({\"tabindex\", \"0\"}, to: \"##{id}-tab-#{value}\")\n  end\nend\n",
      "path": "tabs.ex"
    }
  ],
  "hooks": [
    {
      "content": "interface TabsHook {\n  el: HTMLElement;\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixTabs = {\n  mounted(this: TabsHook) {\n    const list = this.el;\n\n    const tabs = () => Array.from(list.querySelectorAll<HTMLElement>('[role=\"tab\"]'));\n    const isDisabled = (t: HTMLElement) =>\n      (t as HTMLButtonElement).disabled || t.getAttribute(\"aria-disabled\") === \"true\";\n    const activeIndex = () => {\n      const all = tabs();\n      const i = all.indexOf(document.activeElement as HTMLElement);\n      return i === -1 ? all.findIndex((t) => t.getAttribute(\"aria-selected\") === \"true\") : i;\n    };\n    // Activating a tab is just clicking it: the trigger's phx-click runs the\n    // JS command that swaps panels and selected state across the group.\n    // Disabled tabs stay focusable per WAI-ARIA APG but are skipped when\n    // navigating with the keyboard and are never activated.\n    const focusTab = (start: number, step: number) => {\n      const all = tabs();\n      if (!all.length) return;\n      const n = all.length;\n      for (let offset = 0; offset < n; offset++) {\n        const tab = all[(((start + step * (offset + 1)) % n) + n) % n];\n        if (!isDisabled(tab)) {\n          tab.focus();\n          tab.click();\n          return;\n        }\n      }\n    };\n    const focusEdge = (from: \"start\" | \"end\") => {\n      const all = tabs();\n      if (!all.length) return;\n      focusTab(from === \"start\" ? -1 : all.length, from === \"start\" ? 1 : -1);\n    };\n\n    const onKeydown = (e: KeyboardEvent) => {\n      switch (e.key) {\n        case \"ArrowRight\":\n        case \"ArrowDown\":\n          e.preventDefault();\n          focusTab(activeIndex(), 1);\n          break;\n        case \"ArrowLeft\":\n        case \"ArrowUp\":\n          e.preventDefault();\n          focusTab(activeIndex(), -1);\n          break;\n        case \"Home\":\n          e.preventDefault();\n          focusEdge(\"start\");\n          break;\n        case \"End\":\n          e.preventDefault();\n          focusEdge(\"end\");\n          break;\n      }\n    };\n\n    list.addEventListener(\"keydown\", onKeydown);\n    (list as unknown as { _cleanup?: () => void })._cleanup = () => {\n      list.removeEventListener(\"keydown\", onKeydown);\n    };\n  },\n\n  destroyed(this: TabsHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixTabs",
      "path": "tabs.ts"
    }
  ],
  "name": "tabs",
  "npm_deps": [],
  "registry_deps": [
    "cn"
  ]
}