Skip to main content

priv/registry/combobox.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Combobox do\n  @moduledoc \"\"\"\n  A searchable select, built on client-side JS commands, Floating UI, and a small\n  LiveView hook.\n\n  shadcn composes its combobox from `Popover` + `Command`; here it is a single,\n  self-contained, field-aware control. It renders a hidden `<input>` (carrying the\n  field's `name` so the selection submits with the form), a trigger `<button>`\n  (`role=\"combobox\"`, showing the selected label or the placeholder plus a chevron),\n  and a hidden content panel positioned by Floating UI. The panel holds a search\n  text input at the top and a `[role=\"listbox\"]` of `combobox_item/1` options.\n\n  The `ShadixCombobox` hook (assets/ts/combobox.ts) is modelled on the dropdown\n  menu hook: it watches the content's visibility via a `MutationObserver`,\n  positions it under the trigger with `@floating-ui/dom` (`computePosition` +\n  `autoUpdate`, `bottom-start`/`flip`/`shift`), focuses the search input on open,\n  filters options by `textContent` as the user types (toggling a `hidden` class and\n  an empty-state element), navigates the visible options with the arrow keys, and\n  selects on `Enter`/click (writing the hidden value, updating the trigger label,\n  and closing). It closes on `Escape` or outside pointerdown via the `data-on-close`\n  JS (`hide_combobox/1`).\n\n  Trigger and content ids are derived from the required `:id` so `aria-controls`,\n  `data-trigger`, and the JS targets line up stably.\n  \"\"\"\n  use Phoenix.Component\n\n  alias Phoenix.LiveView.JS\n\n  import Shadix.Cn\n  import Shadix.Form\n\n  @doc \"\"\"\n  Renders a searchable select bound to a `Phoenix.HTML.FormField`.\n\n  The selected option's `value` is submitted via a hidden input named for the\n  field. The `:inner_block` holds `combobox_item/1` options.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:field, Phoenix.HTML.FormField, required: true)\n  attr(:placeholder, :string, default: \"Select an option\")\n  attr(:class, :string, default: nil)\n  slot(:inner_block, required: true)\n\n  def combobox(assigns) do\n    %{name: name, value: value, errors: errors} = field_attrs(assigns.field)\n    assigns = assign(assigns, name: name, value: value, errors: errors)\n\n    ~H\"\"\"\n    <input type=\"hidden\" id={\"#{@id}-value\"} name={@name} value={@value} data-combobox-value />\n    <button\n      type=\"button\"\n      id={\"#{@id}-trigger\"}\n      role=\"combobox\"\n      data-slot=\"combobox-trigger\"\n      phx-click={toggle_combobox(@id)}\n      aria-label={@placeholder}\n      aria-haspopup=\"listbox\"\n      aria-expanded=\"false\"\n      aria-controls={\"#{@id}-content\"}\n      aria-invalid={@errors != [] && \"true\"}\n      class={\n        cn([\n          \"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n          @class\n        ])\n      }\n    >\n      <span data-combobox-label data-placeholder={@value in [nil, \"\"] && \"true\"} class=\"truncate\">\n        {@placeholder}\n      </span>\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width=\"24\"\n        height=\"24\"\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        class=\"pointer-events-none size-4 shrink-0 text-muted-foreground\"\n      >\n        <path d=\"m6 9 6 6 6-6\" />\n      </svg>\n    </button>\n    <div\n      id={\"#{@id}-content\"}\n      data-slot=\"combobox-content\"\n      tabindex=\"-1\"\n      phx-hook=\"ShadixCombobox\"\n      data-trigger={\"#{@id}-trigger\"}\n      data-input={\"#{@id}-value\"}\n      data-on-close={hide_combobox(@id)}\n      class=\"hidden fixed left-0 top-0 z-50 w-72 origin-top overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-md\"\n    >\n      <div class=\"flex items-center border-b px-3\">\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\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          class=\"mr-2 size-4 shrink-0 opacity-50\"\n        >\n          <circle cx=\"11\" cy=\"11\" r=\"8\" />\n          <path d=\"m21 21-4.3-4.3\" />\n        </svg>\n        <input\n          type=\"text\"\n          data-slot=\"combobox-input\"\n          data-combobox-search\n          placeholder=\"Search…\"\n          aria-label=\"Search\"\n          role=\"combobox\"\n          aria-autocomplete=\"list\"\n          aria-expanded=\"true\"\n          aria-controls={\"#{@id}-listbox\"}\n          autocomplete=\"off\"\n          class=\"flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\"\n        />\n      </div>\n      <div id={\"#{@id}-listbox\"} role=\"listbox\" aria-label={@placeholder} class=\"max-h-72 overflow-y-auto p-1\">\n        {render_slot(@inner_block)}\n        <div\n          data-slot=\"combobox-empty\"\n          data-combobox-empty\n          role=\"status\"\n          aria-live=\"polite\"\n          class=\"hidden py-6 text-center text-sm text-muted-foreground\"\n        >\n          No results found.\n        </div>\n      </div>\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  A selectable option within a `combobox/1`.\n\n  `:value` is the value written to the hidden input on select; the `:inner_block`\n  is the visible label. Carries `role=\"option\"`, `data-value`, and\n  `data-slot=\"combobox-item\"` so the hook can read/filter/select it.\n  \"\"\"\n  attr(:value, :string, required: true)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def combobox_item(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"option\"\n      tabindex=\"-1\"\n      aria-selected=\"false\"\n      data-slot=\"combobox-item\"\n      data-value={@value}\n      class={\n        cn([\n          \"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground data-[active]:bg-accent data-[active]:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      <span\n        data-combobox-item-indicator\n        class=\"absolute left-2 flex size-4 items-center justify-center opacity-0\"\n      >\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\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          class=\"size-4\"\n        >\n          <path d=\"M20 6 9 17l-5-5\" />\n        </svg>\n      </span>\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that opens the combobox with `id`.\n\n  Reveals the content panel with an enter transition. The `ShadixCombobox` hook\n  reacts to the resulting visibility change to position the panel, focus the\n  search input, and wire up keyboard/outside-click handling.\n  \"\"\"\n  def toggle_combobox(id) do\n    JS.toggle(\n      to: \"##{id}-content\",\n      in: {\"transition ease-out duration-100\", \"opacity-0 scale-95\", \"opacity-100 scale-100\"},\n      out: {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"},\n      display: \"block\"\n    )\n  end\n\n  @doc \"\"\"\n  Returns the `Phoenix.LiveView.JS` command that closes the combobox with `id`.\n\n  Hides the content panel with a leave transition. Used by the hook's\n  `data-on-close` (Escape / outside click) and on select.\n  \"\"\"\n  def hide_combobox(id) do\n    JS.hide(\n      to: \"##{id}-content\",\n      transition:\n        {\"transition ease-in duration-75\", \"opacity-100 scale-100\", \"opacity-0 scale-95\"}\n    )\n  end\nend\n",
      "path": "combobox.ex"
    }
  ],
  "hooks": [
    {
      "content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface ComboboxHook {\n  el: HTMLElement;\n  liveSocket: { execJS(el: HTMLElement, js: string): void };\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixCombobox = {\n  mounted(this: ComboboxHook) {\n    const content = this.el;\n    const trigger = document.getElementById(content.getAttribute(\"data-trigger\") ?? \"\");\n    const hiddenInput = document.getElementById(\n      content.getAttribute(\"data-input\") ?? \"\",\n    ) as HTMLInputElement | null;\n    const search = content.querySelector<HTMLInputElement>(\"[data-combobox-search]\");\n    const empty = content.querySelector<HTMLElement>(\"[data-combobox-empty]\");\n    const label = trigger?.querySelector<HTMLElement>(\"[data-combobox-label]\");\n    // Snapshot the placeholder so we can restore it if a selection is cleared.\n    const placeholder = label?.textContent ?? \"\";\n\n    let stopAutoUpdate: (() => void) | null = null;\n    let isOpen = false;\n    let activeIndex = -1;\n\n    const baseId = content.id || trigger?.id || \"combobox\";\n\n    const allItems = () =>\n      Array.from(content.querySelectorAll<HTMLElement>('[role=\"option\"]'));\n    const visibleItems = () => allItems().filter((i) => !i.classList.contains(\"hidden\"));\n\n    // Each option needs a stable id so the search input can point at the\n    // active one via aria-activedescendant (APG list-autocomplete pattern).\n    allItems().forEach((el, i) => {\n      if (!el.id) el.id = `${baseId}-option-${i}`;\n    });\n\n    const setActive = (i: number) => {\n      const list = visibleItems();\n      list.forEach((el) => el.removeAttribute(\"data-active\"));\n      if (!list.length) {\n        activeIndex = -1;\n        search?.removeAttribute(\"aria-activedescendant\");\n        return;\n      }\n      activeIndex = ((i % list.length) + list.length) % list.length;\n      const el = list[activeIndex];\n      el.setAttribute(\"data-active\", \"\");\n      el.scrollIntoView({ block: \"nearest\" });\n      search?.setAttribute(\"aria-activedescendant\", el.id);\n    };\n\n    const filter = () => {\n      const q = (search?.value ?? \"\").trim().toLowerCase();\n      let anyVisible = false;\n      for (const item of allItems()) {\n        const match = (item.textContent ?? \"\").trim().toLowerCase().includes(q);\n        item.classList.toggle(\"hidden\", !match);\n        if (match) anyVisible = true;\n      }\n      if (empty) empty.classList.toggle(\"hidden\", anyVisible);\n      setActive(0);\n    };\n\n    const select = (item: HTMLElement) => {\n      const value = item.getAttribute(\"data-value\") ?? \"\";\n      if (hiddenInput) {\n        hiddenInput.value = value;\n        hiddenInput.dispatchEvent(new Event(\"input\", { bubbles: true }));\n      }\n      if (label) {\n        label.textContent = (item.textContent ?? \"\").trim();\n        label.removeAttribute(\"data-placeholder\");\n      }\n      for (const el of allItems()) el.setAttribute(\"aria-selected\", String(el === item));\n      const indicator = item.querySelector<HTMLElement>(\"[data-combobox-item-indicator]\");\n      content\n        .querySelectorAll<HTMLElement>(\"[data-combobox-item-indicator]\")\n        .forEach((el) => (el.style.opacity = \"0\"));\n      if (indicator) indicator.style.opacity = \"1\";\n      closeJS();\n    };\n\n    const closeJS = () => {\n      const js = content.getAttribute(\"data-on-close\");\n      if (js) this.liveSocket.execJS(content, js);\n    };\n\n    const onSearchInput = () => filter();\n\n    const onKeydown = (e: KeyboardEvent) => {\n      switch (e.key) {\n        case \"ArrowDown\":\n          e.preventDefault();\n          setActive(activeIndex + 1);\n          break;\n        case \"ArrowUp\":\n          e.preventDefault();\n          setActive(activeIndex - 1);\n          break;\n        case \"Home\":\n          e.preventDefault();\n          setActive(0);\n          break;\n        case \"End\":\n          e.preventDefault();\n          setActive(visibleItems().length - 1);\n          break;\n        case \"Escape\":\n          e.preventDefault();\n          trigger?.focus();\n          closeJS();\n          break;\n        case \"Enter\": {\n          const list = visibleItems();\n          if (activeIndex >= 0 && activeIndex < list.length) {\n            e.preventDefault();\n            select(list[activeIndex]);\n          }\n          break;\n        }\n      }\n    };\n\n    const onClick = (e: Event) => {\n      const item = (e.target as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n      if (item && content.contains(item)) select(item);\n    };\n\n    const onPointerDown = (e: Event) => {\n      const t = e.target as Node;\n      if (!content.contains(t) && !(trigger && trigger.contains(t))) closeJS();\n    };\n\n    const openLogic = () => {\n      isOpen = true;\n      trigger?.setAttribute(\"aria-expanded\", \"true\");\n      const anchor = trigger;\n      if (anchor) {\n        stopAutoUpdate = autoUpdate(anchor, content, () => {\n          computePosition(anchor, content, {\n            placement: \"bottom-start\",\n            strategy: \"fixed\",\n            middleware: [offset(4), flip(), shift({ padding: 8 })],\n          }).then(({ x, y }) =>\n            Object.assign(content.style, { left: `${x}px`, top: `${y}px` }),\n          );\n        });\n      }\n      if (search) {\n        search.value = \"\";\n        search.addEventListener(\"input\", onSearchInput);\n      }\n      filter();\n      document.addEventListener(\"keydown\", onKeydown);\n      document.addEventListener(\"pointerdown\", onPointerDown, true);\n      content.addEventListener(\"click\", onClick);\n      window.requestAnimationFrame(() => search?.focus());\n    };\n\n    const closeLogic = () => {\n      isOpen = false;\n      activeIndex = -1;\n      trigger?.setAttribute(\"aria-expanded\", \"false\");\n      search?.removeAttribute(\"aria-activedescendant\");\n      stopAutoUpdate?.();\n      stopAutoUpdate = null;\n      search?.removeEventListener(\"input\", onSearchInput);\n      document.removeEventListener(\"keydown\", onKeydown);\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      content.removeEventListener(\"click\", onClick);\n    };\n\n    // Keep the placeholder styling truthful even before any interaction.\n    if (label && hiddenInput && hiddenInput.value === \"\") {\n      label.textContent = placeholder;\n    }\n\n    const visible = () => getComputedStyle(content).display !== \"none\";\n    const observer = new MutationObserver(() => {\n      const v = visible();\n      if (v && !isOpen) openLogic();\n      else if (!v && isOpen) closeLogic();\n    });\n    observer.observe(content, { attributes: true, attributeFilter: [\"style\", \"class\"] });\n\n    (content as unknown as { _cleanup?: () => void })._cleanup = () => {\n      observer.disconnect();\n      if (isOpen) closeLogic();\n    };\n  },\n  destroyed(this: ComboboxHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixCombobox",
      "path": "combobox.ts"
    }
  ],
  "name": "combobox",
  "npm_deps": [
    "@floating-ui/dom"
  ],
  "registry_deps": [
    "cn",
    "form"
  ]
}