Skip to main content

priv/registry/toggle_group.json

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