{
"files": [
{
"content": "defmodule Shadix.Components.ToggleGroup do\n @moduledoc \"\"\"\n Toggle group component translated from shadcn/ui (new-york-v4).\n\n A set of two-state toggle buttons. Unlike the React/Radix original, selection\n state is uncontrolled and managed entirely on the client by the `ShadixToggleGroup`\n hook (assets/ts/toggle_group.ts): clicking an item flips its `aria-pressed` and\n `data-state` (`on`/`off`). The group's `data-type` decides the selection model:\n\n * `\"single\"` — radio-like; at most one item is on. Clicking an item turns the\n others off; clicking the active item again turns it off.\n * `\"multiple\"` — each item toggles independently.\n\n Item visuals reuse the toggle (`toggle.tsx`) classes, keyed off `data-state=on`.\n There is no server round-trip; the hook is needed because plain\n `Phoenix.LiveView.JS` cannot read/flip a boolean state attribute across siblings.\n\n * `toggle_group/1` — `role=\"group\"` container carrying the hook and `data-type`.\n * `toggle_group_item/1` — a `<button type=\"button\">` toggle.\n\n Caller-supplied `class` is appended last; Tailwind cascade layers ensure it wins\n over the defaults.\n \"\"\"\n use Phoenix.Component\n\n import Shadix.Cn\n\n @group_base \"group/toggle-group flex w-fit items-center rounded-md\"\n\n # Lifted from toggle.tsx (`toggleVariants` base). The toggle-group item\n # overrides (@item_overrides) are appended AFTER the size classes so that\n # `min-w-0`/`px-3` win, mirroring the shadcn class order in toggle-group.tsx.\n @item_base \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\n @item_overrides \"min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10\"\n\n @item_variants %{\n \"default\" => \"bg-transparent\",\n \"outline\" =>\n \"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground\"\n }\n\n @item_sizes %{\n \"default\" => \"h-9 min-w-9 px-2\",\n \"sm\" => \"h-8 min-w-8 px-1.5\",\n \"lg\" => \"h-10 min-w-10 px-2.5\"\n }\n\n @doc \"\"\"\n Renders the toggle-group container.\n\n Carries `role=\"group\"`, the `ShadixToggleGroup` hook, and `data-type`\n (`\"single\"` or `\"multiple\"`) that the hook reads to decide the selection model.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:type, :string, default: \"single\", values: ~w(single multiple))\n attr(:variant, :string, default: \"default\", values: ~w(default outline))\n attr(:size, :string, default: \"default\", values: ~w(default sm lg))\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def toggle_group(assigns) do\n assigns = assign(assigns, :computed_class, cn([@group_base, assigns.class]))\n\n ~H\"\"\"\n <div\n id={@id}\n role=\"group\"\n data-slot=\"toggle-group\"\n data-type={@type}\n data-variant={@variant}\n data-size={@size}\n phx-hook=\"ShadixToggleGroup\"\n class={@computed_class}\n {@rest}\n >\n {render_slot(@inner_block)}\n </div>\n \"\"\"\n end\n\n @doc \"\"\"\n Renders a single toggle button within a `toggle_group/1`.\n\n Starts in the `off` state (`aria-pressed=\"false\"`, `data-state=\"off\"`); the\n `ShadixToggleGroup` hook flips both on click. Pass `disabled` (or any global\n attribute) via the rest.\n \"\"\"\n attr(:value, :string, required: true)\n attr(:variant, :string, default: \"default\", values: ~w(default outline))\n attr(:size, :string, default: \"default\", values: ~w(default sm lg))\n attr(:class, :string, default: nil)\n attr(:rest, :global, include: ~w(disabled))\n slot(:inner_block, required: true)\n\n def toggle_group_item(assigns) do\n assigns =\n assign(\n assigns,\n :computed_class,\n cn([\n @item_base,\n @item_variants[assigns.variant],\n @item_sizes[assigns.size],\n @item_overrides,\n assigns.class\n ])\n )\n\n ~H\"\"\"\n <button\n type=\"button\"\n data-slot=\"toggle-group-item\"\n data-value={@value}\n data-variant={@variant}\n data-size={@size}\n aria-pressed=\"false\"\n data-state=\"off\"\n class={@computed_class}\n {@rest}\n >\n {render_slot(@inner_block)}\n </button>\n \"\"\"\n end\nend\n",
"path": "toggle_group.ex"
}
],
"hooks": [
{
"content": "interface ToggleGroupHook {\n el: HTMLElement;\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixToggleGroup = {\n mounted(this: ToggleGroupHook) {\n const group = this.el;\n\n const items = () =>\n Array.from(group.querySelectorAll<HTMLButtonElement>('[data-slot=\"toggle-group-item\"]'));\n\n const isDisabled = (item: HTMLButtonElement) =>\n item.disabled || item.getAttribute(\"aria-disabled\") === \"true\";\n\n const setState = (item: HTMLElement, on: boolean) => {\n item.setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n item.setAttribute(\"data-state\", on ? \"on\" : \"off\");\n };\n\n // Roving tabindex: the group is a single tab stop and arrow keys move\n // focus between items, matching base-ui's CompositeItem and the WAI-ARIA\n // APG. The currently-pressed item (or the first enabled item) is the only\n // one reachable with Tab; the rest are -1 and focused via the arrow keys.\n const setRovingTarget = (target: HTMLElement | null) => {\n const all = items();\n const fallback = all.find((item) => !isDisabled(item)) ?? null;\n const tabStop = target && !isDisabled(target as HTMLButtonElement) ? target : fallback;\n for (const item of all) {\n item.setAttribute(\"tabindex\", item === tabStop ? \"0\" : \"-1\");\n }\n };\n\n const initRoving = () => {\n const all = items();\n const pressed = all.find((item) => item.getAttribute(\"data-state\") === \"on\");\n setRovingTarget(pressed ?? null);\n };\n\n const focusItem = (start: number, step: number) => {\n const all = items();\n if (!all.length) return;\n const n = all.length;\n for (let offset = 0; offset < n; offset++) {\n const item = all[(((start + step * (offset + 1)) % n) + n) % n];\n if (!isDisabled(item)) {\n setRovingTarget(item);\n item.focus();\n return;\n }\n }\n };\n\n const focusEdge = (from: \"start\" | \"end\") => {\n const all = items();\n if (!all.length) return;\n focusItem(from === \"start\" ? -1 : all.length, from === \"start\" ? 1 : -1);\n };\n\n const activeIndex = () => items().indexOf(document.activeElement as HTMLButtonElement);\n\n const onClick = (e: Event) => {\n const target = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>(\n '[data-slot=\"toggle-group-item\"]',\n );\n if (!target || !group.contains(target)) return;\n\n const isOn = target.getAttribute(\"data-state\") === \"on\";\n const single = group.getAttribute(\"data-type\") === \"single\";\n\n if (single && !isOn) {\n for (const item of items()) setState(item, item === target);\n } else {\n setState(target, !isOn);\n }\n\n setRovingTarget(target);\n };\n\n const onKeydown = (e: KeyboardEvent) => {\n const target = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>(\n '[data-slot=\"toggle-group-item\"]',\n );\n if (!target || !group.contains(target)) return;\n\n switch (e.key) {\n case \"ArrowRight\":\n case \"ArrowDown\":\n e.preventDefault();\n focusItem(activeIndex(), 1);\n break;\n case \"ArrowLeft\":\n case \"ArrowUp\":\n e.preventDefault();\n focusItem(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 initRoving();\n group.addEventListener(\"click\", onClick);\n group.addEventListener(\"keydown\", onKeydown);\n (group as unknown as { _cleanup?: () => void })._cleanup = () => {\n group.removeEventListener(\"click\", onClick);\n group.removeEventListener(\"keydown\", onKeydown);\n };\n },\n\n destroyed(this: ToggleGroupHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixToggleGroup",
"path": "toggle_group.ts"
}
],
"name": "toggle_group",
"npm_deps": [],
"registry_deps": [
"cn"
]
}