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