Skip to main content

priv/registry/select.json

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