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