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