{ "default_style": "nova", "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 Pass the command's required `:id` to both `command_input/1` and `command_list/1`.\n They derive matching `*-input` and `*-list` ids, while the hook locates elements\n by their `data-slot` attributes.\n\n Styling lives in colocated CSS (`priv/styles//command.css`), keyed off each\n element's stable `data-slot`. Caller-supplied `class` is appended last; Tailwind\n cascade layers ensure it wins over the defaults.\n \"\"\"\n use Phoenix.Component\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 <%!-- shadix:colocated-css --%>\n \n {render_slot(@inner_block)}\n \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\n attr(:rest, :global,\n include:\n ~w(name value disabled readonly autocomplete autofocus required min max step pattern inputmode maxlength minlength multiple size list form)\n )\n\n def command_input(assigns) do\n ~H\"\"\"\n
\n \n \n \n \n \n
\n \"\"\"\n end\n\n @doc \"\"\"\n A scrollable container holding the command groups, items, and empty state.\n\n `:id` is the *command's* id; the list gets `#\\#{id}-list`, matching the\n `aria-controls` emitted by `command_input/1`.\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_list(assigns) do\n ~H\"\"\"\n \n {render_slot(@inner_block)}\n \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 \n \n {@heading}\n \n {render_slot(@inner_block)}\n \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 \n {render_slot(@inner_block)}\n \n \"\"\"\n end\n\n @doc \"\"\"\n The empty state, shown by the hook when no options match the search. It remains\n a disabled `role=\"option\"` so the parent listbox always has a permitted child.\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 \n {render_slot(@inner_block)}\n \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 \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 \n {render_slot(@inner_block)}\n \n \"\"\"\n end\nend\n", "path": "command.ex" } ], "hooks": [ { "content": "interface CommandHook {\n el: HTMLElement;\n mounted(): void;\n beforeUpdate(): void;\n updated(): void;\n destroyed(): void;\n preparePatch?: () => void;\n}\n\nexport const ShadixCommand = {\n mounted(this: CommandHook) {\n const root = this.el;\n let input = root.querySelector(\"[data-command-search]\");\n let list = root.querySelector('[data-slot=\"command-list\"]');\n let empty = root.querySelector('[data-slot=\"command-empty\"]');\n const generatedIds = new WeakMap();\n\n const allOptions = () =>\n Array.from(root.querySelectorAll('[role=\"option\"]:not([data-command-empty])'));\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 // Idempotent, and re-run after server patches replace the option nodes\n // (which drops the ids we assign).\n const ensureIds = () => {\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) {\n o.id = `${root.id}-option-${i}`;\n generatedIds.set(o, o.id);\n }\n });\n };\n\n // Client ids must not replace LiveView's server identity while morphing\n // skipped static options. Restore them in updated(), preserving caller ids.\n this.preparePatch = () => {\n for (const option of allOptions()) {\n if (generatedIds.get(option) === option.id) option.removeAttribute(\"id\");\n }\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 markActive(list.find((o) => o.getAttribute(\"data-selected\") === \"true\") ?? list[0] ?? null);\n };\n\n // Re-establish the ids + active highlight. Server-side filtering\n // (phx-change) re-renders the option list on every keystroke; that DOM\n // patch drops the client-set ids and the data-selected highlight, so we\n // re-apply them after each update (and once on mount). Without this, fast\n // typing can leave the top result unhighlighted (though Enter still falls\n // back to the first option).\n const refresh = () => {\n const currentInput = root.querySelector(\"[data-command-search]\");\n if (currentInput !== input) {\n input?.removeEventListener(\"input\", onInput);\n input = currentInput;\n input?.addEventListener(\"input\", onInput);\n }\n list = root.querySelector('[data-slot=\"command-list\"]');\n empty = root.querySelector('[data-slot=\"command-empty\"]');\n ensureIds();\n filter();\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, wire aria + run the filter (no query =>\n // all visible).\n refresh();\n window.requestAnimationFrame(() => input?.focus());\n\n (root as unknown as { _refresh?: () => void })._refresh = refresh;\n (root as unknown as { _cleanup?: () => void })._cleanup = () => {\n input?.removeEventListener(\"input\", onInput);\n root.removeEventListener(\"keydown\", onKeydown);\n };\n },\n beforeUpdate(this: CommandHook) {\n this.preparePatch?.();\n },\n updated(this: CommandHook) {\n (this.el as unknown as { _refresh?: () => void })._refresh?.();\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": [ "colocated_css" ], "styles": { "nova": "@layer components {\n [data-slot=\"command\"] {\n @apply flex size-full flex-col overflow-hidden bg-popover text-popover-foreground rounded-xl! p-1;\n }\n\n [data-slot=\"command-input-wrapper\"] {\n @apply flex items-center gap-2 p-1 pb-0;\n }\n\n [data-slot=\"command-input\"] {\n @apply outline-hidden disabled:cursor-not-allowed disabled:opacity-50 w-full text-sm;\n }\n\n [data-slot=\"command-list\"] {\n @apply overflow-x-hidden overflow-y-auto no-scrollbar max-h-72 scroll-py-1 outline-none;\n }\n\n [data-slot=\"command-empty\"] {\n @apply py-6 text-center text-sm;\n }\n\n [data-slot=\"command-group\"] {\n @apply text-foreground overflow-hidden p-1;\n }\n\n [data-slot=\"command-group-heading\"] {\n @apply text-muted-foreground px-2 py-1.5 text-xs font-medium;\n }\n\n [data-slot=\"command-separator\"] {\n @apply bg-border -mx-1 h-px;\n }\n\n [data-slot=\"command-item\"] {\n @apply data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4;\n }\n\n [data-slot=\"command-shortcut\"] {\n @apply text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest;\n }\n}\n", "vega": "@layer components {\n [data-slot=\"command\"] {\n @apply flex size-full flex-col overflow-hidden bg-popover text-popover-foreground rounded-xl! p-1;\n }\n\n [data-slot=\"command-input-wrapper\"] {\n @apply flex items-center gap-2 p-1 pb-0;\n }\n\n [data-slot=\"command-input\"] {\n @apply outline-hidden disabled:cursor-not-allowed disabled:opacity-50 w-full text-sm;\n }\n\n [data-slot=\"command-list\"] {\n @apply overflow-x-hidden overflow-y-auto no-scrollbar max-h-72 scroll-py-1 outline-none;\n }\n\n [data-slot=\"command-empty\"] {\n @apply py-6 text-center text-sm;\n }\n\n [data-slot=\"command-group\"] {\n @apply text-foreground overflow-hidden p-1;\n }\n\n [data-slot=\"command-group-heading\"] {\n @apply text-muted-foreground px-2 py-1.5 text-xs font-medium;\n }\n\n [data-slot=\"command-separator\"] {\n @apply bg-border -mx-1 h-px w-auto;\n }\n\n [data-slot=\"command-item\"] {\n @apply data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 data-selected:bg-muted data-selected:text-foreground data-selected:**:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4;\n }\n\n [data-slot=\"command-shortcut\"] {\n @apply text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest;\n }\n}\n" } }