diff --git a/assets/ts/command.ts b/assets/ts/command.ts index 5684125..db1df32 100644 --- a/assets/ts/command.ts +++ b/assets/ts/command.ts @@ -1,6 +1,7 @@ interface CommandHook { el: HTMLElement; mounted(): void; + updated(): void; destroyed(): void; } @@ -20,11 +21,15 @@ export const ShadixCommand = { // Focus stays on the input; the active option is conveyed via // aria-activedescendant (APG editable-combobox pattern) rather than by // moving DOM focus, so screen readers announce the current option. - if (list && !list.id) list.id = `${root.id}-list`; - if (input && list) input.setAttribute("aria-controls", list.id); - allOptions().forEach((o, i) => { - if (!o.id) o.id = `${root.id}-option-${i}`; - }); + // Idempotent, and re-run after server patches replace the option nodes + // (which drops the ids we assign). + const ensureIds = () => { + if (list && !list.id) list.id = `${root.id}-list`; + if (input && list) input.setAttribute("aria-controls", list.id); + allOptions().forEach((o, i) => { + if (!o.id) o.id = `${root.id}-option-${i}`; + }); + }; const markActive = (target: HTMLElement | null) => { for (const o of allOptions()) { @@ -74,6 +79,17 @@ export const ShadixCommand = { } }; + // Re-establish the ids + active highlight. Server-side filtering + // (phx-change) re-renders the option list on every keystroke; that DOM + // patch drops the client-set ids and the data-selected highlight, so we + // re-apply them after each update (and once on mount). Without this, fast + // typing can leave the top result unhighlighted (though Enter still falls + // back to the first option). + const refresh = () => { + ensureIds(); + filter(); + }; + const onInput = () => filter(); const onKeydown = (e: KeyboardEvent) => { @@ -110,15 +126,20 @@ export const ShadixCommand = { input?.addEventListener("input", onInput); root.addEventListener("keydown", onKeydown); - // Initial state: focus the input, run the filter (no query => all visible). - filter(); + // Initial state: focus the input, wire aria + run the filter (no query => + // all visible). + refresh(); window.requestAnimationFrame(() => input?.focus()); + (root as unknown as { _refresh?: () => void })._refresh = refresh; (root as unknown as { _cleanup?: () => void })._cleanup = () => { input?.removeEventListener("input", onInput); root.removeEventListener("keydown", onKeydown); }; }, + updated(this: CommandHook) { + (this.el as unknown as { _refresh?: () => void })._refresh?.(); + }, destroyed(this: CommandHook) { (this.el as unknown as { _cleanup?: () => void })._cleanup?.(); }, diff --git a/priv/registry/command.json b/priv/registry/command.json index 75bb204..d8a79b4 100644 --- a/priv/registry/command.json +++ b/priv/registry/command.json @@ -8,7 +8,7 @@ ], "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(\"[data-command-search]\");\n const list = root.querySelector('[data-slot=\"command-list\"]');\n const empty = root.querySelector('[data-slot=\"command-empty\"]');\n\n const allOptions = () =>\n Array.from(root.querySelectorAll('[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", + "content": "interface CommandHook {\n el: HTMLElement;\n mounted(): void;\n updated(): void;\n destroyed(): void;\n}\n\nexport const ShadixCommand = {\n mounted(this: CommandHook) {\n const root = this.el;\n const input = root.querySelector(\"[data-command-search]\");\n const list = root.querySelector('[data-slot=\"command-list\"]');\n const empty = root.querySelector('[data-slot=\"command-empty\"]');\n\n const allOptions = () =>\n Array.from(root.querySelectorAll('[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 // 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) o.id = `${root.id}-option-${i}`;\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 if (!list.some((o) => o.getAttribute(\"data-selected\") === \"true\")) {\n markActive(list[0] ?? null);\n }\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 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 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" }