{
"files": [
{
"content": "defmodule Shadix.Components.Select do\n @moduledoc \"\"\"\n A field-aware select (listbox) built on client-side JS commands, Floating UI,\n and the `ShadixSelect` hook.\n\n Unlike a native `<select>`, this reconstructs the shadcn look as a trigger\n button plus a Floating-UI-positioned popup `<div role=\"listbox\">`. It is\n field-aware via `Shadix.Form`: it derives its `name`/`value` from a\n `Phoenix.HTML.FormField` and carries the selected value in a hidden `<input>`\n so it submits with the surrounding form.\n\n The trigger toggles the content with `toggle_listbox/1`; the `ShadixSelect`\n hook (assets/ts/select.ts) watches the content's visibility via a\n `MutationObserver`, positions it under the trigger with `@floating-ui/dom`,\n handles keyboard navigation (arrows/Home/End/type-ahead/Enter/Escape), and on\n select writes the chosen value into the hidden input (dispatching an `input`\n event), updates the trigger label, marks `aria-selected`, then closes and\n refocuses the trigger. It initializes the selection from the field value.\n\n Trigger and content ids derive 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 @trigger_class \"flex h-9 w-fit 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 data-[placeholder=true]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\n\n @content_class \"hidden fixed left-0 top-0 z-50 max-h-96 min-w-[8rem] origin-top overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md\"\n\n @doc \"\"\"\n Renders a field-aware select.\n\n The required `:field` supplies the hidden input's `name` and the initial\n value. The required `:id` derives the trigger/content ids. Pass `select_item/1`\n children in the default slot.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:field, Phoenix.HTML.FormField, required: true)\n attr(:placeholder, :string, default: \"Select…\")\n attr(:class, :string, default: nil)\n slot(:inner_block, required: true)\n\n def select(assigns) do\n %{name: name, value: value, errors: errors} = field_attrs(assigns.field)\n value = if value in [nil, \"\"], do: nil, else: to_string(value)\n\n assigns =\n assign(assigns,\n name: name,\n value: value,\n errors: errors,\n trigger_class: @trigger_class,\n content_class: cn([@content_class, assigns.class])\n )\n\n ~H\"\"\"\n <input type=\"hidden\" id={\"#{@id}-input\"} name={@name} value={@value} data-select-value />\n <button\n type=\"button\"\n id={\"#{@id}-trigger\"}\n data-slot=\"select-trigger\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n aria-expanded=\"false\"\n aria-controls={\"#{@id}-content\"}\n aria-labelledby={\"#{@id}-value\"}\n aria-invalid={@errors != [] && \"true\"}\n aria-describedby={(@errors != [] && \"#{@id}-error\") || nil}\n data-placeholder={if @value == nil, do: \"true\", else: \"false\"}\n phx-click={toggle_listbox(@id)}\n class={@trigger_class}\n >\n <span\n id={\"#{@id}-value\"}\n data-slot=\"select-value\"\n data-select-label\n phx-update=\"ignore\"\n >{@placeholder}</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=\"size-4 opacity-50\"\n >\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n </button>\n <div\n id={\"#{@id}-content\"}\n role=\"listbox\"\n tabindex=\"-1\"\n data-slot=\"select-content\"\n phx-hook=\"ShadixSelect\"\n data-trigger={\"#{@id}-trigger\"}\n data-input={\"#{@id}-input\"}\n data-placeholder={@placeholder}\n data-on-close={hide_listbox(@id)}\n class={@content_class}\n >\n {render_slot(@inner_block)}\n </div>\n <p\n :if={@errors != []}\n id={\"#{@id}-error\"}\n data-slot=\"form-message\"\n class=\"text-destructive text-sm mt-1\"\n >\n {List.first(@errors)}\n </p>\n \"\"\"\n end\n\n @doc \"\"\"\n Returns the `Phoenix.LiveView.JS` command that toggles the listbox with `id`.\n\n Reveals/hides the content panel with enter/leave transitions; the\n `ShadixSelect` hook reacts to the visibility change to position the listbox\n and wire up keyboard/outside-click handling.\n \"\"\"\n def toggle_listbox(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 listbox with `id`.\n\n Used by the hook's `data-on-close` (Escape / outside click) and after a\n selection.\n \"\"\"\n def hide_listbox(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\n\n @doc \"\"\"\n A selectable option within a select.\n\n `:value` is the value submitted with the form when chosen; the inner block is\n the visible label. The `ShadixSelect` hook selects it on click/Enter.\n \"\"\"\n attr(:value, :string, required: true)\n attr(:disabled, :boolean, default: false)\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def select_item(assigns) do\n ~H\"\"\"\n <div\n role=\"option\"\n tabindex=\"-1\"\n data-slot=\"select-item\"\n data-value={@value}\n aria-selected=\"false\"\n aria-disabled={@disabled && \"true\"}\n data-disabled={@disabled && \"true\"}\n class={\n cn([\n \"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n @class\n ])\n }\n {@rest}\n >\n <span\n data-slot=\"select-item-indicator\"\n class=\"absolute right-2 flex size-3.5 items-center justify-center\"\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 data-select-check\n class=\"hidden size-4\"\n >\n <path d=\"M20 6 9 17l-5-5\" />\n </svg>\n </span>\n <span data-select-item-text>{render_slot(@inner_block)}</span>\n </div>\n \"\"\"\n end\nend\n",
"path": "select.ex"
}
],
"hooks": [
{
"content": "import { computePosition, offset, flip, shift, autoUpdate } from \"@floating-ui/dom\";\n\ninterface SelectHook {\n el: HTMLElement;\n liveSocket: { execJS(el: HTMLElement, js: string): void };\n mounted(): void;\n destroyed(): void;\n}\n\nexport const ShadixSelect = {\n mounted(this: SelectHook) {\n const content = this.el;\n const trigger = document.getElementById(content.getAttribute(\"data-trigger\") ?? \"\");\n const input = document.getElementById(content.getAttribute(\"data-input\") ?? \"\") as\n | HTMLInputElement\n | null;\n const placeholder = content.getAttribute(\"data-placeholder\") ?? \"\";\n const label = trigger?.querySelector<HTMLElement>(\"[data-select-label]\") ?? null;\n\n let stopAutoUpdate: (() => void) | null = null;\n let isOpen = false;\n let typeahead = \"\";\n let typeaheadTimer: number | undefined;\n\n const options = () =>\n Array.from(\n content.querySelectorAll<HTMLElement>('[role=\"option\"]:not([aria-disabled=\"true\"])'),\n );\n const focusOption = (i: number) => {\n const list = options();\n if (list.length) list[(i + list.length) % list.length].focus();\n };\n const indexOfActive = () => options().indexOf(document.activeElement as HTMLElement);\n const labelOf = (opt: HTMLElement) =>\n (opt.querySelector<HTMLElement>(\"[data-select-item-text]\")?.textContent ??\n opt.textContent ??\n \"\").trim();\n\n const closeJS = () => {\n const js = content.getAttribute(\"data-on-close\");\n if (js) this.liveSocket.execJS(content, js);\n };\n\n const markSelected = (opt: HTMLElement | null) => {\n for (const o of options()) {\n const selected = o === opt;\n o.setAttribute(\"aria-selected\", selected ? \"true\" : \"false\");\n o.querySelector<HTMLElement>(\"[data-select-check]\")?.classList.toggle(\"hidden\", !selected);\n }\n };\n\n const select = (opt: HTMLElement) => {\n const value = opt.getAttribute(\"data-value\") ?? \"\";\n if (input) {\n input.value = value;\n input.dispatchEvent(new Event(\"input\", { bubbles: true }));\n }\n if (label) label.textContent = labelOf(opt);\n trigger?.setAttribute(\"data-placeholder\", \"false\");\n markSelected(opt);\n trigger?.focus();\n closeJS();\n };\n\n const initSelection = () => {\n const value = input?.value ?? \"\";\n const current = value\n ? options().find((o) => o.getAttribute(\"data-value\") === value) ?? null\n : null;\n markSelected(current);\n if (current && label) {\n label.textContent = labelOf(current);\n trigger?.setAttribute(\"data-placeholder\", \"false\");\n } else if (label) {\n label.textContent = placeholder;\n trigger?.setAttribute(\"data-placeholder\", \"true\");\n }\n };\n\n const onTypeahead = (key: string) => {\n typeahead += key.toLowerCase();\n window.clearTimeout(typeaheadTimer);\n typeaheadTimer = window.setTimeout(() => (typeahead = \"\"), 500);\n const match = options().find((o) => labelOf(o).toLowerCase().startsWith(typeahead));\n if (match) match.focus();\n };\n\n const onKeydown = (e: KeyboardEvent) => {\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n focusOption(indexOfActive() + 1);\n break;\n case \"ArrowUp\":\n e.preventDefault();\n focusOption(indexOfActive() - 1);\n break;\n case \"Home\":\n e.preventDefault();\n focusOption(0);\n break;\n case \"End\":\n e.preventDefault();\n focusOption(options().length - 1);\n break;\n case \"Escape\":\n e.preventDefault();\n trigger?.focus();\n closeJS();\n break;\n case \"Tab\":\n closeJS();\n break;\n case \"Enter\":\n case \" \": {\n const active = document.activeElement as HTMLElement | null;\n if (active && options().includes(active)) {\n e.preventDefault();\n select(active);\n }\n break;\n }\n default:\n if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {\n e.preventDefault();\n onTypeahead(e.key);\n }\n }\n };\n\n const onClick = (e: Event) => {\n const opt = (e.target as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (opt && content.contains(opt) && opt.getAttribute(\"aria-disabled\") !== \"true\") {\n select(opt);\n }\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 if (trigger) {\n const minWidth = `${trigger.offsetWidth}px`;\n content.style.minWidth = minWidth;\n stopAutoUpdate = autoUpdate(trigger, content, () => {\n computePosition(trigger, content, {\n placement: \"bottom-start\",\n strategy: \"fixed\",\n middleware: [offset(4), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => Object.assign(content.style, { left: `${x}px`, top: `${y}px` }));\n });\n }\n document.addEventListener(\"keydown\", onKeydown);\n document.addEventListener(\"pointerdown\", onPointerDown, true);\n content.addEventListener(\"click\", onClick);\n window.requestAnimationFrame(() => {\n const selected = options().find((o) => o.getAttribute(\"aria-selected\") === \"true\");\n if (selected) selected.focus();\n else focusOption(0);\n });\n };\n\n const closeLogic = () => {\n isOpen = false;\n trigger?.setAttribute(\"aria-expanded\", \"false\");\n stopAutoUpdate?.();\n stopAutoUpdate = null;\n document.removeEventListener(\"keydown\", onKeydown);\n document.removeEventListener(\"pointerdown\", onPointerDown, true);\n content.removeEventListener(\"click\", onClick);\n };\n\n initSelection();\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: SelectHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixSelect",
"path": "select.ts"
}
],
"name": "select",
"npm_deps": [
"@floating-ui/dom"
],
"registry_deps": [
"cn",
"form"
]
}