Skip to main content

priv/registry/command.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Command do\n  @moduledoc \"\"\"\n  A command palette / filterable list adapted from shadcn/ui (new-york-v4).\n\n  shadcn builds this on the `cmdk` primitive; we reimplement the essential\n  behaviour with a small LiveView hook. The `command/1` root carries the\n  `ShadixCommand` hook (assets/ts/command.ts), which on mount focuses the search\n  input, filters the `[role=\\\"option\\\"]` items by the input's text (matching each\n  option's `textContent`, toggling the Tailwind `hidden` class), shows the\n  `command_empty/1` slot when nothing matches, and provides arrow/Home/End\n  keyboard navigation over the *visible* options with Enter activating the\n  focused option via `.click()`.\n\n  Ids are derived from the required `:id`: the input carries `data-command-search`\n  so the hook can locate it from the root, and the empty/list slots are matched by\n  their `data-slot` attributes.\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  @doc \"\"\"\n  The command root: a rounded, bordered panel hosting the `ShadixCommand` hook.\n\n  Compose `command_input/1`, `command_list/1` (with `command_group/1`,\n  `command_item/1`, `command_empty/1`, `command_separator/1`) inside its\n  `inner_block`.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def command(assigns) do\n    ~H\"\"\"\n    <div\n      id={@id}\n      data-slot=\"command\"\n      phx-hook=\"ShadixCommand\"\n      class={\n        cn([\n          \"flex h-full w-full flex-col overflow-hidden rounded-md border bg-popover text-popover-foreground\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  The search input. `:id` is the *command's* id; the input gets `#\\#{id}-input`,\n  a `data-command-search` marker the hook keys off, and a leading search icon.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:placeholder, :string, default: \"Type a command or search...\")\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n\n  def command_input(assigns) do\n    ~H\"\"\"\n    <div data-slot=\"command-input-wrapper\" class=\"flex h-9 items-center gap-2 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        class=\"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        id={\"#{@id}-input\"}\n        type=\"text\"\n        role=\"combobox\"\n        data-slot=\"command-input\"\n        data-command-search\n        aria-label=\"Search\"\n        aria-autocomplete=\"list\"\n        aria-expanded=\"true\"\n        autocomplete=\"off\"\n        spellcheck=\"false\"\n        placeholder={@placeholder}\n        class={\n          cn([\n            \"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n            @class\n          ])\n        }\n        {@rest}\n      />\n    </div>\n    \"\"\"\n  end\n\n  @doc \"A scrollable container holding the command groups, items, and empty state.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def command_list(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"listbox\"\n      aria-label=\"Suggestions\"\n      data-slot=\"command-list\"\n      class={cn([\"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  A labelled group of items. `:heading` renders a small muted label above the\n  items.\n  \"\"\"\n  attr(:heading, :string, default: nil)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def command_group(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"group\"\n      aria-label={@heading}\n      data-slot=\"command-group\"\n      class={cn([\"overflow-hidden p-1 text-foreground\", @class])}\n      {@rest}\n    >\n      <div\n        :if={@heading}\n        aria-hidden=\"true\"\n        data-slot=\"command-group-heading\"\n        class=\"px-2 py-1.5 text-xs font-medium text-muted-foreground\"\n      >\n        {@heading}\n      </div>\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  A selectable option. `:value` is the option's value (mirrored to `data-value`);\n  wire `phx-click` (or any handler) via `:rest`.\n  \"\"\"\n  attr(:value, :string, default: nil)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def command_item(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"option\"\n      tabindex=\"-1\"\n      data-slot=\"command-item\"\n      data-value={@value}\n      class={\n        cn([\n          \"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground\",\n          @class\n        ])\n      }\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"\"\"\n  The empty state, shown by the hook (via `JS.show/JS.hide`) when no options\n  match the search. Hidden by default with the Tailwind `hidden` class.\n  \"\"\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def command_empty(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"presentation\"\n      data-slot=\"command-empty\"\n      class={cn([\"hidden py-6 text-center text-sm\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  @doc \"A horizontal separator between command sections.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n\n  def command_separator(assigns) do\n    ~H\"\"\"\n    <div\n      aria-hidden=\"true\"\n      data-slot=\"command-separator\"\n      class={cn([\"-mx-1 h-px bg-border\", @class])}\n      {@rest}\n    />\n    \"\"\"\n  end\n\n  @doc \"A trailing keyboard-shortcut hint within a command item.\"\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block, required: true)\n\n  def command_shortcut(assigns) do\n    ~H\"\"\"\n    <span\n      data-slot=\"command-shortcut\"\n      class={cn([\"ml-auto text-xs tracking-widest text-muted-foreground\", @class])}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </span>\n    \"\"\"\n  end\nend\n",
      "path": "command.ex"
    }
  ],
  "hooks": [
    {
      "content": "interface CommandHook {\n  el: HTMLElement;\n  mounted(): void;\n  destroyed(): void;\n}\n\nexport const ShadixCommand = {\n  mounted(this: CommandHook) {\n    const root = this.el;\n    const input = root.querySelector<HTMLInputElement>(\"[data-command-search]\");\n    const list = root.querySelector<HTMLElement>('[data-slot=\"command-list\"]');\n    const empty = root.querySelector<HTMLElement>('[data-slot=\"command-empty\"]');\n\n    const allOptions = () =>\n      Array.from(root.querySelectorAll<HTMLElement>('[role=\"option\"]'));\n    const visibleOptions = () =>\n      allOptions().filter((o) => !o.classList.contains(\"hidden\"));\n\n    // Wire the combobox<->listbox relationship for assistive technology.\n    // Focus stays on the input; the active option is conveyed via\n    // aria-activedescendant (APG editable-combobox pattern) rather than by\n    // moving DOM focus, so screen readers announce the current option.\n    if (list && !list.id) list.id = `${root.id}-list`;\n    if (input && list) input.setAttribute(\"aria-controls\", list.id);\n    allOptions().forEach((o, i) => {\n      if (!o.id) o.id = `${root.id}-option-${i}`;\n    });\n\n    const markActive = (target: HTMLElement | null) => {\n      for (const o of allOptions()) {\n        o.removeAttribute(\"data-selected\");\n        o.setAttribute(\"aria-selected\", \"false\");\n      }\n      if (target) {\n        target.setAttribute(\"data-selected\", \"true\");\n        target.setAttribute(\"aria-selected\", \"true\");\n        input?.setAttribute(\"aria-activedescendant\", target.id);\n      } else {\n        input?.removeAttribute(\"aria-activedescendant\");\n      }\n    };\n\n    const activeIndex = () => {\n      const list = visibleOptions();\n      return list.findIndex((o) => o.getAttribute(\"data-selected\") === \"true\");\n    };\n\n    const focusAt = (i: number) => {\n      const list = visibleOptions();\n      if (!list.length) {\n        markActive(null);\n        return;\n      }\n      const next = list[((i % list.length) + list.length) % list.length];\n      markActive(next);\n      next.scrollIntoView({ block: \"nearest\" });\n    };\n\n    const filter = () => {\n      const query = (input?.value ?? \"\").trim().toLowerCase();\n      let matches = 0;\n      for (const option of allOptions()) {\n        const text = (option.textContent ?? \"\").trim().toLowerCase();\n        const value = (option.getAttribute(\"data-value\") ?? \"\").toLowerCase();\n        const hit = query === \"\" || text.includes(query) || value.includes(query);\n        option.classList.toggle(\"hidden\", !hit);\n        if (hit) matches += 1;\n      }\n      if (empty) empty.classList.toggle(\"hidden\", matches > 0);\n      // Keep a sensible active option among the survivors.\n      const list = visibleOptions();\n      if (!list.some((o) => o.getAttribute(\"data-selected\") === \"true\")) {\n        markActive(list[0] ?? null);\n      }\n    };\n\n    const onInput = () => filter();\n\n    const onKeydown = (e: KeyboardEvent) => {\n      switch (e.key) {\n        case \"ArrowDown\":\n          e.preventDefault();\n          focusAt(activeIndex() + 1);\n          break;\n        case \"ArrowUp\":\n          e.preventDefault();\n          focusAt(activeIndex() - 1);\n          break;\n        case \"Home\":\n          e.preventDefault();\n          focusAt(0);\n          break;\n        case \"End\":\n          e.preventDefault();\n          focusAt(visibleOptions().length - 1);\n          break;\n        case \"Enter\": {\n          const list = visibleOptions();\n          const idx = activeIndex();\n          const target = idx >= 0 ? list[idx] : list[0];\n          if (target) {\n            e.preventDefault();\n            target.click();\n          }\n          break;\n        }\n      }\n    };\n\n    input?.addEventListener(\"input\", onInput);\n    root.addEventListener(\"keydown\", onKeydown);\n\n    // Initial state: focus the input, run the filter (no query => all visible).\n    filter();\n    window.requestAnimationFrame(() => input?.focus());\n\n    (root as unknown as { _cleanup?: () => void })._cleanup = () => {\n      input?.removeEventListener(\"input\", onInput);\n      root.removeEventListener(\"keydown\", onKeydown);\n    };\n  },\n  destroyed(this: CommandHook) {\n    (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n  },\n};\n",
      "name": "ShadixCommand",
      "path": "command.ts"
    }
  ],
  "name": "command",
  "npm_deps": [],
  "registry_deps": [
    "cn"
  ]
}