stimeo-ui 0.1.0-beta.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +6 -6
  3. package/dist/controllers/auto_submit_controller.js +56 -21
  4. package/dist/controllers/auto_submit_controller.js.map +1 -1
  5. package/dist/controllers/character_counter_controller.js +52 -15
  6. package/dist/controllers/character_counter_controller.js.map +1 -1
  7. package/dist/controllers/combobox_controller.d.ts +6 -2
  8. package/dist/controllers/combobox_controller.js +64 -3
  9. package/dist/controllers/combobox_controller.js.map +1 -1
  10. package/dist/controllers/command_palette_controller.d.ts +13 -6
  11. package/dist/controllers/command_palette_controller.js +243 -39
  12. package/dist/controllers/command_palette_controller.js.map +1 -1
  13. package/dist/controllers/confirm_controller.js +94 -8
  14. package/dist/controllers/confirm_controller.js.map +1 -1
  15. package/dist/controllers/context_menu_controller.d.ts +11 -6
  16. package/dist/controllers/context_menu_controller.js +191 -13
  17. package/dist/controllers/context_menu_controller.js.map +1 -1
  18. package/dist/controllers/countdown_controller.d.ts +2 -2
  19. package/dist/controllers/countdown_controller.js +2 -2
  20. package/dist/controllers/countdown_controller.js.map +1 -1
  21. package/dist/controllers/dialog_controller.js +94 -8
  22. package/dist/controllers/dialog_controller.js.map +1 -1
  23. package/dist/controllers/dropdown_controller.d.ts +9 -6
  24. package/dist/controllers/dropdown_controller.js +139 -16
  25. package/dist/controllers/dropdown_controller.js.map +1 -1
  26. package/dist/controllers/focus_controller.js +94 -8
  27. package/dist/controllers/focus_controller.js.map +1 -1
  28. package/dist/controllers/form_validation_controller.js +2 -2
  29. package/dist/controllers/form_validation_controller.js.map +1 -1
  30. package/dist/controllers/hover_card_controller.d.ts +15 -12
  31. package/dist/controllers/hover_card_controller.js +103 -27
  32. package/dist/controllers/hover_card_controller.js.map +1 -1
  33. package/dist/controllers/intersection_controller.js.map +1 -1
  34. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  35. package/dist/controllers/listbox_controller.js +1 -0
  36. package/dist/controllers/listbox_controller.js.map +1 -1
  37. package/dist/controllers/menu_controller.d.ts +18 -11
  38. package/dist/controllers/menu_controller.js +181 -8
  39. package/dist/controllers/menu_controller.js.map +1 -1
  40. package/dist/controllers/otp_controller.js +62 -22
  41. package/dist/controllers/otp_controller.js.map +1 -1
  42. package/dist/controllers/overflow_menu_controller.d.ts +3 -2
  43. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  44. package/dist/controllers/pointer_drag_controller.js +3 -3
  45. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  46. package/dist/controllers/popover_controller.d.ts +18 -10
  47. package/dist/controllers/popover_controller.js +118 -27
  48. package/dist/controllers/popover_controller.js.map +1 -1
  49. package/dist/controllers/scroll_area_controller.js.map +1 -1
  50. package/dist/controllers/scrollspy_controller.js.map +1 -1
  51. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  52. package/dist/controllers/tabs_controller.d.ts +1 -2
  53. package/dist/controllers/tabs_controller.js.map +1 -1
  54. package/dist/controllers/toast_controller.d.ts +7 -9
  55. package/dist/controllers/toast_controller.js +108 -29
  56. package/dist/controllers/toast_controller.js.map +1 -1
  57. package/dist/controllers/tooltip_controller.d.ts +15 -18
  58. package/dist/controllers/tooltip_controller.js +148 -42
  59. package/dist/controllers/tooltip_controller.js.map +1 -1
  60. package/dist/index.d.ts +40 -10
  61. package/dist/index.js +833 -348
  62. package/dist/index.js.map +1 -1
  63. package/dist/inspector/cli.js.map +1 -1
  64. package/dist/inspector/cli_bin.js.map +1 -1
  65. package/dist/inspector/examples.json +6 -6
  66. package/dist/inspector/manifest.json +16 -3
  67. package/package.json +7 -7
@@ -2,6 +2,53 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/combobox_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
5
52
  // src/utils/option_scroll.ts
6
53
  function scrollOptionIntoView(list, option) {
7
54
  if (list.scrollHeight <= list.clientHeight) return;
@@ -27,17 +74,30 @@ var ComboboxController = class extends Controller {
27
74
  * does not immediately re-open the listbox via a `focus`-bound action.
28
75
  */
29
76
  #suppressOpen = false;
77
+ /** Owns IME lifecycle state; confirmed text re-filters the list once. */
78
+ #composition = new CompositionTracker({ onEnd: () => this.filter() });
30
79
  /** Starts closed with no active option and registers the outside-click listener. */
31
80
  connect() {
81
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
32
82
  this.close();
33
83
  document.addEventListener("click", this.#onOutsideClick);
34
84
  }
35
85
  /** Removes the document-level listener registered in {@link connect}. */
36
86
  disconnect() {
87
+ this.#composition.disconnect();
37
88
  document.removeEventListener("click", this.#onOutsideClick);
38
89
  }
39
- /** Filters options by the current input value and opens the listbox. */
40
- filter() {
90
+ /** Tracks an input added initially or after connect. */
91
+ inputTargetConnected(input) {
92
+ this.#composition.observe(input);
93
+ }
94
+ /** Removes composition listeners when the active input is replaced or removed. */
95
+ inputTargetDisconnected(input) {
96
+ this.#composition.unobserve(input);
97
+ }
98
+ /** Filters confirmed input text and opens the listbox. */
99
+ filter(event) {
100
+ if (this.#composition.isComposing(event)) return;
41
101
  this.open();
42
102
  }
43
103
  /**
@@ -74,7 +134,7 @@ var ComboboxController = class extends Controller {
74
134
  }
75
135
  /** Routes keyboard interaction per the APG combobox model. */
76
136
  onKeydown(event) {
77
- if (event.isComposing || event.keyCode === 229) return;
137
+ if (this.#composition.isComposing(event)) return;
78
138
  switch (event.key) {
79
139
  case "ArrowDown": {
80
140
  event.preventDefault();
@@ -120,6 +180,7 @@ var ComboboxController = class extends Controller {
120
180
  break;
121
181
  }
122
182
  case "Escape":
183
+ if (event.defaultPrevented || this.#isClosed) break;
123
184
  event.preventDefault();
124
185
  this.close();
125
186
  break;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/option_scroll.ts","../../src/controllers/combobox_controller.ts"],"names":[],"mappings":"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","file":"combobox_controller.js","sourcesContent":["/**\n * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette keeps\n * its `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\n\n/**\n * Headless, accessible combobox behavior (list autocomplete).\n *\n * Markup contract (identifier: `stimeo--combobox`):\n * <div data-controller=\"stimeo--combobox\">\n * <input type=\"text\" role=\"combobox\" aria-expanded=\"false\"\n * aria-autocomplete=\"list\" aria-controls=\"listbox\"\n * data-stimeo--combobox-target=\"input\"\n * data-action=\"input->stimeo--combobox#filter\n * keydown->stimeo--combobox#onKeydown\n * focus->stimeo--combobox#open\n * click->stimeo--combobox#open\" />\n * <ul id=\"listbox\" role=\"listbox\" data-stimeo--combobox-target=\"list\" hidden>\n * <li role=\"option\" id=\"opt-apple\" data-value=\"apple\"\n * data-stimeo--combobox-target=\"option\"\n * data-action=\"click->stimeo--combobox#selectByClick\">Apple</li>\n * <!-- more options -->\n * </ul>\n * </div>\n *\n * Implements the WAI-ARIA APG **Combobox** pattern with a listbox popup and\n * list-autocomplete. Focus stays in the input; the active option is tracked with\n * `aria-activedescendant` rather than by moving DOM focus.\n *\n * @remarks\n * Behavior only. Options are authored in the DOM; the controller filters them by\n * toggling each option's `hidden` attribute (case-insensitive substring match on\n * its text). The consumer owns styling, typically keyed off `[aria-selected]`.\n * When an open listbox has no matching options, the root element gets\n * `data-stimeo--combobox-empty` so the consumer can style the empty state (hide\n * the list, show a \"no results\" node, …) — the library imposes no visuals.\n *\n * Behavior provided:\n * - Typing filters the options and opens the listbox.\n * - Focusing or clicking the input opens the listbox, re-filtered against the\n * current value (so re-opening with a non-matching value keeps the empty state).\n * - `ArrowDown`/`ArrowUp` move the active option (wrapping); `Enter` selects it;\n * `Escape` closes the listbox; `Home`/`End` jump to the first/last visible\n * option.\n * - Selecting an option fills the input (with the option's `data-value` if set,\n * otherwise its text) and closes the listbox.\n * - A click outside the combobox closes the listbox.\n */\nexport class ComboboxController extends Controller<HTMLElement> {\n static override targets = [\"input\", \"list\", \"option\"];\n static actions = [\"close\", \"filter\", \"onKeydown\", \"open\", \"selectByClick\"] as const;\n static events = [\"selected\"] as const;\n\n declare readonly inputTarget: HTMLInputElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly hasInputTarget: boolean;\n declare readonly hasListTarget: boolean;\n\n /** Index into the *visible* options of the active option, or -1 if none. */\n #activeIndex = -1;\n /**\n * Suppresses {@link open} for the duration of the programmatic re-focus in\n * `#select`, so committing a value (which returns focus to the input)\n * does not immediately re-open the listbox via a `focus`-bound action.\n */\n #suppressOpen = false;\n\n /** Starts closed with no active option and registers the outside-click listener. */\n override connect(): void {\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Removes the document-level listener registered in {@link connect}. */\n override disconnect(): void {\n document.removeEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Filters options by the current input value and opens the listbox. */\n filter(): void {\n this.open();\n }\n\n /**\n * Opens the listbox, re-filtering the options against the current input value\n * so the visible options and empty state always match what is typed (e.g.\n * re-opening with a stale non-matching value still surfaces the empty state).\n */\n open(): void {\n if (!this.hasListTarget || this.#suppressOpen) return;\n this.#applyFilter();\n this.listTarget.hidden = false;\n this.inputTarget.setAttribute(\"aria-expanded\", \"true\");\n this.#setActive(-1);\n this.#reflectEmptyState();\n }\n\n /**\n * Hides options that don't match the current input value (case-insensitive\n * substring). An empty query shows every option. Does not change open state.\n */\n #applyFilter(): void {\n const query = this.inputTarget.value.trim().toLowerCase();\n for (const option of this.optionTargets) {\n const text = (option.textContent ?? \"\").trim().toLowerCase();\n option.hidden = query.length > 0 && !text.includes(query);\n }\n }\n\n /** Closes the listbox, clears the active option, and updates ARIA state. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.inputTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.element.removeAttribute(\"data-stimeo--combobox-empty\");\n }\n\n /** Routes keyboard interaction per the APG combobox model. */\n onKeydown(event: KeyboardEvent): void {\n // Ignore keys fired during IME composition: the `Enter` that confirms a\n // candidate (and arrows that move within it) must not select an option or\n // close the popup. `keyCode === 229` covers browsers that omit `isComposing`\n // on the confirming keydown. Aligns with the library's IME composition-guard\n // policy (the keydown-level equivalent of the input-path guards in\n // character-counter / auto-submit).\n if (event.isComposing || event.keyCode === 229) return;\n switch (event.key) {\n case \"ArrowDown\": {\n event.preventDefault();\n // open() re-filters, so read the visible set afterwards.\n if (this.#isClosed) this.open();\n const visible = this.#visibleOptions();\n if (visible.length > 0) {\n const next = this.#activeIndex === -1 ? 0 : (this.#activeIndex + 1) % visible.length;\n this.#setActive(next);\n }\n break;\n }\n case \"ArrowUp\": {\n event.preventDefault();\n if (this.#isClosed) this.open();\n const visible = this.#visibleOptions();\n if (visible.length > 0) {\n // From the input (no active option) ArrowUp jumps to the last option,\n // per the APG; otherwise it wraps backwards.\n const next =\n this.#activeIndex === -1\n ? visible.length - 1\n : (this.#activeIndex - 1 + visible.length) % visible.length;\n this.#setActive(next);\n }\n break;\n }\n case \"Home\":\n if (!this.#isClosed && this.#visibleOptions().length > 0) {\n event.preventDefault();\n this.#setActive(0);\n }\n break;\n case \"End\": {\n const visible = this.#visibleOptions();\n if (!this.#isClosed && visible.length > 0) {\n event.preventDefault();\n this.#setActive(visible.length - 1);\n }\n break;\n }\n case \"Enter\": {\n const visible = this.#visibleOptions();\n const active = this.#activeIndex === -1 ? undefined : visible[this.#activeIndex];\n if (active) {\n event.preventDefault();\n this.#select(active);\n }\n break;\n }\n case \"Escape\":\n event.preventDefault();\n this.close();\n break;\n case \"Tab\":\n // Let focus leave naturally, but don't keep a stale popup open.\n this.close();\n break;\n default:\n break;\n }\n }\n\n /**\n * Closes the listbox when a click lands outside the combobox. Mirrors the menu\n * button's outside-click behavior; clicks on an option are inside the element,\n * so `#select` (not this handler) closes the popup after committing.\n */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Selects the clicked option. Bound via `data-action` (click). */\n selectByClick(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (option) this.#select(option);\n }\n\n /** Commits an option: fills the input, closes the listbox, notifies listeners. */\n #select(option: HTMLElement): void {\n const value = option.dataset.value ?? (option.textContent ?? \"\").trim();\n const changed = this.inputTarget.value !== value;\n this.inputTarget.value = value;\n this.close();\n // Returning focus to the input would re-trigger a `focus`-bound open(); guard\n // it so the listbox stays closed after a selection.\n this.#suppressOpen = true;\n this.inputTarget.focus();\n this.#suppressOpen = false;\n if (changed) {\n // A native bubbling `change` (matching <select>/listbox semantics: only on\n // an actual value change) so form-level behaviors — validation re-checks,\n // auto-submit — hear the commit without knowing this widget. Deliberately\n // NOT `input`: that is this combobox's own filter trigger and would reopen\n // the popup on every selection.\n this.inputTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"selected\", { detail: { value } });\n }\n\n /**\n * Reflects whether the open listbox currently has zero matching options by\n * toggling `data-stimeo--combobox-empty` on the root element. Behavior only:\n * consumers decide how to present the empty state (hide the list, show a\n * \"no results\" node, etc.) via CSS keyed off this attribute.\n */\n #reflectEmptyState(): void {\n const empty = !this.#isClosed && this.#visibleOptions().length === 0;\n if (empty) {\n this.element.setAttribute(\"data-stimeo--combobox-empty\", \"\");\n } else {\n this.element.removeAttribute(\"data-stimeo--combobox-empty\");\n }\n }\n\n /**\n * Marks the visible option at `index` active via `aria-selected` and the\n * input's `aria-activedescendant`. Pass `-1` to clear the active option.\n */\n #setActive(index: number): void {\n this.#activeIndex = index;\n const visible = this.#visibleOptions();\n const active = index === -1 ? null : visible[index];\n // Clear aria-selected on every option (not just the visible ones) so a\n // previously-active option that became hidden by filtering doesn't keep a\n // stale selected state.\n for (const option of this.optionTargets) {\n option.setAttribute(\"aria-selected\", option === active ? \"true\" : \"false\");\n }\n if (active?.id) {\n this.inputTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.inputTarget.removeAttribute(\"aria-activedescendant\");\n }\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** The options currently shown (not filtered out). */\n #visibleOptions(): HTMLElement[] {\n return this.optionTargets.filter((option) => !option.hidden);\n }\n\n /** Whether the listbox is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/utils/option_scroll.ts","../../src/controllers/combobox_controller.ts"],"names":[],"mappings":"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","file":"combobox_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","/**\n * Scroll-follow for virtually-focused listbox options.\n *\n * Listbox-style widgets that track their active option with\n * `aria-activedescendant` keep DOM focus on the input/trigger, so the\n * browser's native \"scroll the focused element into view\" never runs — in a\n * scrollable list (`max-height` + `overflow`) the active option can walk right\n * out of sight on ArrowDown/ArrowUp. This helper keeps it visible.\n *\n * It adjusts the LIST's own `scrollTop` only — deliberately not\n * `scrollIntoView({ block: \"nearest\" })`, which may also scroll ancestor\n * scrolling boxes (the page) when a floating popup pokes past a viewport\n * edge, desyncing anchored/marker-composed placements. (Command-palette keeps\n * its `scrollIntoView` because its page is scroll-locked behind the modal;\n * page-floating popups must use this helper instead.)\n */\n\n/**\n * Scrolls `option` into view within `list` by minimally adjusting\n * `list.scrollTop`. No-ops when the list does not actually scroll. Never\n * touches any other scrolling box.\n *\n * @param list - The scrollable listbox container.\n * @param option - The (virtually focused) option to keep visible.\n */\nexport function scrollOptionIntoView(list: HTMLElement, option: HTMLElement): void {\n if (list.scrollHeight <= list.clientHeight) return;\n const listRect = list.getBoundingClientRect();\n const optionRect = option.getBoundingClientRect();\n if (optionRect.top < listRect.top) {\n list.scrollTop -= listRect.top - optionRect.top;\n } else if (optionRect.bottom > listRect.bottom) {\n list.scrollTop += optionRect.bottom - listRect.bottom;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\nimport { scrollOptionIntoView } from \"../utils/option_scroll\";\n\n/**\n * Headless, accessible combobox behavior (list autocomplete).\n *\n * Markup contract (identifier: `stimeo--combobox`):\n * <div data-controller=\"stimeo--combobox\">\n * <input type=\"text\" role=\"combobox\" aria-expanded=\"false\"\n * aria-autocomplete=\"list\" aria-controls=\"listbox\"\n * data-stimeo--combobox-target=\"input\"\n * data-action=\"input->stimeo--combobox#filter\n * keydown->stimeo--combobox#onKeydown\n * focus->stimeo--combobox#open\n * click->stimeo--combobox#open\" />\n * <ul id=\"listbox\" role=\"listbox\" data-stimeo--combobox-target=\"list\" hidden>\n * <li role=\"option\" id=\"opt-apple\" data-value=\"apple\"\n * data-stimeo--combobox-target=\"option\"\n * data-action=\"click->stimeo--combobox#selectByClick\">Apple</li>\n * <!-- more options -->\n * </ul>\n * </div>\n *\n * Implements the WAI-ARIA APG **Combobox** pattern with a listbox popup and\n * list-autocomplete. Focus stays in the input; the active option is tracked with\n * `aria-activedescendant` rather than by moving DOM focus.\n *\n * @remarks\n * Behavior only. Options are authored in the DOM; the controller filters them by\n * toggling each option's `hidden` attribute (case-insensitive substring match on\n * its text). The consumer owns styling, typically keyed off `[aria-selected]`.\n * When an open listbox has no matching options, the root element gets\n * `data-stimeo--combobox-empty` so the consumer can style the empty state (hide\n * the list, show a \"no results\" node, …) — the library imposes no visuals.\n *\n * Behavior provided:\n * - Typing filters the options and opens the listbox.\n * - Focusing or clicking the input opens the listbox, re-filtered against the\n * current value (so re-opening with a non-matching value keeps the empty state).\n * - `ArrowDown`/`ArrowUp` move the active option (wrapping); `Enter` selects it;\n * `Escape` closes the listbox; `Home`/`End` jump to the first/last visible\n * option.\n * - Selecting an option fills the input (with the option's `data-value` if set,\n * otherwise its text) and closes the listbox.\n * - A click outside the combobox closes the listbox.\n */\nexport class ComboboxController extends Controller<HTMLElement> {\n static override targets = [\"input\", \"list\", \"option\"];\n static actions = [\"close\", \"filter\", \"onKeydown\", \"open\", \"selectByClick\"] as const;\n static events = [\"selected\"] as const;\n\n declare readonly inputTarget: HTMLInputElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly hasInputTarget: boolean;\n declare readonly hasListTarget: boolean;\n\n /** Index into the *visible* options of the active option, or -1 if none. */\n #activeIndex = -1;\n /**\n * Suppresses {@link open} for the duration of the programmatic re-focus in\n * `#select`, so committing a value (which returns focus to the input)\n * does not immediately re-open the listbox via a `focus`-bound action.\n */\n #suppressOpen = false;\n /** Owns IME lifecycle state; confirmed text re-filters the list once. */\n readonly #composition = new CompositionTracker({ onEnd: () => this.filter() });\n\n /** Starts closed with no active option and registers the outside-click listener. */\n override connect(): void {\n if (this.hasInputTarget) this.#composition.observe(this.inputTarget);\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Removes the document-level listener registered in {@link connect}. */\n override disconnect(): void {\n this.#composition.disconnect();\n document.removeEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Tracks an input added initially or after connect. */\n inputTargetConnected(input: HTMLInputElement): void {\n this.#composition.observe(input);\n }\n\n /** Removes composition listeners when the active input is replaced or removed. */\n inputTargetDisconnected(input: HTMLInputElement): void {\n this.#composition.unobserve(input);\n }\n\n /** Filters confirmed input text and opens the listbox. */\n filter(event?: InputEvent): void {\n if (this.#composition.isComposing(event)) return;\n this.open();\n }\n\n /**\n * Opens the listbox, re-filtering the options against the current input value\n * so the visible options and empty state always match what is typed (e.g.\n * re-opening with a stale non-matching value still surfaces the empty state).\n */\n open(): void {\n if (!this.hasListTarget || this.#suppressOpen) return;\n this.#applyFilter();\n this.listTarget.hidden = false;\n this.inputTarget.setAttribute(\"aria-expanded\", \"true\");\n this.#setActive(-1);\n this.#reflectEmptyState();\n }\n\n /**\n * Hides options that don't match the current input value (case-insensitive\n * substring). An empty query shows every option. Does not change open state.\n */\n #applyFilter(): void {\n const query = this.inputTarget.value.trim().toLowerCase();\n for (const option of this.optionTargets) {\n const text = (option.textContent ?? \"\").trim().toLowerCase();\n option.hidden = query.length > 0 && !text.includes(query);\n }\n }\n\n /** Closes the listbox, clears the active option, and updates ARIA state. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.inputTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.element.removeAttribute(\"data-stimeo--combobox-empty\");\n }\n\n /** Routes keyboard interaction per the APG combobox model. */\n onKeydown(event: KeyboardEvent): void {\n // Ignore keys fired during IME composition: the `Enter` that confirms a\n // candidate (and arrows that move within it) must not select an option or\n // close the popup. Controller-owned lifecycle state covers confirming events\n // that omit the standard per-event signal.\n if (this.#composition.isComposing(event)) return;\n switch (event.key) {\n case \"ArrowDown\": {\n event.preventDefault();\n // open() re-filters, so read the visible set afterwards.\n if (this.#isClosed) this.open();\n const visible = this.#visibleOptions();\n if (visible.length > 0) {\n const next = this.#activeIndex === -1 ? 0 : (this.#activeIndex + 1) % visible.length;\n this.#setActive(next);\n }\n break;\n }\n case \"ArrowUp\": {\n event.preventDefault();\n if (this.#isClosed) this.open();\n const visible = this.#visibleOptions();\n if (visible.length > 0) {\n // From the input (no active option) ArrowUp jumps to the last option,\n // per the APG; otherwise it wraps backwards.\n const next =\n this.#activeIndex === -1\n ? visible.length - 1\n : (this.#activeIndex - 1 + visible.length) % visible.length;\n this.#setActive(next);\n }\n break;\n }\n case \"Home\":\n if (!this.#isClosed && this.#visibleOptions().length > 0) {\n event.preventDefault();\n this.#setActive(0);\n }\n break;\n case \"End\": {\n const visible = this.#visibleOptions();\n if (!this.#isClosed && visible.length > 0) {\n event.preventDefault();\n this.#setActive(visible.length - 1);\n }\n break;\n }\n case \"Enter\": {\n const visible = this.#visibleOptions();\n const active = this.#activeIndex === -1 ? undefined : visible[this.#activeIndex];\n if (active) {\n event.preventDefault();\n this.#select(active);\n }\n break;\n }\n case \"Escape\":\n // Layered-Escape contract: leave a press an inner handler already\n // owned (rule 1), and consume only while owning a dismissable state —\n // with the list already closed the press stays free for the shared\n // resolver, so an enclosing dialog still closes from a focused input.\n if (event.defaultPrevented || this.#isClosed) break;\n event.preventDefault();\n this.close();\n break;\n case \"Tab\":\n // Let focus leave naturally, but don't keep a stale popup open.\n this.close();\n break;\n default:\n break;\n }\n }\n\n /**\n * Closes the listbox when a click lands outside the combobox. Mirrors the menu\n * button's outside-click behavior; clicks on an option are inside the element,\n * so `#select` (not this handler) closes the popup after committing.\n */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Selects the clicked option. Bound via `data-action` (click). */\n selectByClick(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (option) this.#select(option);\n }\n\n /** Commits an option: fills the input, closes the listbox, notifies listeners. */\n #select(option: HTMLElement): void {\n const value = option.dataset.value ?? (option.textContent ?? \"\").trim();\n const changed = this.inputTarget.value !== value;\n this.inputTarget.value = value;\n this.close();\n // Returning focus to the input would re-trigger a `focus`-bound open(); guard\n // it so the listbox stays closed after a selection.\n this.#suppressOpen = true;\n this.inputTarget.focus();\n this.#suppressOpen = false;\n if (changed) {\n // A native bubbling `change` (matching <select>/listbox semantics: only on\n // an actual value change) so form-level behaviors — validation re-checks,\n // auto-submit — hear the commit without knowing this widget. Deliberately\n // NOT `input`: that is this combobox's own filter trigger and would reopen\n // the popup on every selection.\n this.inputTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"selected\", { detail: { value } });\n }\n\n /**\n * Reflects whether the open listbox currently has zero matching options by\n * toggling `data-stimeo--combobox-empty` on the root element. Behavior only:\n * consumers decide how to present the empty state (hide the list, show a\n * \"no results\" node, etc.) via CSS keyed off this attribute.\n */\n #reflectEmptyState(): void {\n const empty = !this.#isClosed && this.#visibleOptions().length === 0;\n if (empty) {\n this.element.setAttribute(\"data-stimeo--combobox-empty\", \"\");\n } else {\n this.element.removeAttribute(\"data-stimeo--combobox-empty\");\n }\n }\n\n /**\n * Marks the visible option at `index` active via `aria-selected` and the\n * input's `aria-activedescendant`. Pass `-1` to clear the active option.\n */\n #setActive(index: number): void {\n this.#activeIndex = index;\n const visible = this.#visibleOptions();\n const active = index === -1 ? null : visible[index];\n // Clear aria-selected on every option (not just the visible ones) so a\n // previously-active option that became hidden by filtering doesn't keep a\n // stale selected state.\n for (const option of this.optionTargets) {\n option.setAttribute(\"aria-selected\", option === active ? \"true\" : \"false\");\n }\n if (active?.id) {\n this.inputTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.inputTarget.removeAttribute(\"aria-activedescendant\");\n }\n // Virtual focus never triggers the browser's native focus-scrolling, so a\n // scrollable list must follow the active option itself (list-only scroll).\n if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);\n }\n\n /** The options currently shown (not filtered out). */\n #visibleOptions(): HTMLElement[] {\n return this.optionTargets.filter((option) => !option.hidden);\n }\n\n /** Whether the listbox is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
@@ -15,11 +15,10 @@ import { Controller } from '@hotwired/stimulus';
15
15
  * data-action="input->stimeo--command-palette#filter
16
16
  * keydown->stimeo--command-palette#onKeydown" />
17
17
  * <ul id="cmdk-list" data-stimeo--command-palette-target="list" role="listbox">
18
- * <li id="cmd-new" role="option"
19
- * data-stimeo--command-palette-target="option"
18
+ * <li id="cmd-new" role="option" data-stimeo--command-palette-target="option"
20
19
  * data-action="click->stimeo--command-palette#selectByClick">New…</li>
21
- * <li role="option" data-stimeo--command-palette-target="option"
22
- * data-disabled="true">Section heading (shown but not selectable)</li>
20
+ * <li id="cmd-heading" role="option" data-stimeo--command-palette-target="option"
21
+ * data-disabled="true">Disabled command (shown but not selectable)</li>
23
22
  * </ul>
24
23
  * <p data-stimeo--command-palette-target="empty" hidden>No commands</p>
25
24
  * </div>
@@ -37,7 +36,9 @@ import { Controller } from '@hotwired/stimulus';
37
36
  * primitive (also used by dialog / alert-dialog / drawer). `Escape` closes and
38
37
  * `Tab`/`Shift+Tab` cycle focus regardless of which element inside the dialog holds
39
38
  * focus (input, close button, …), because the trap listens at the document level.
40
- * Styling, transitions, and the actual command handlers remain the consumer's.
39
+ * IME composition is tracked from start to end: intermediate input does not filter,
40
+ * and conversion-confirming keys never select a command. Styling, transitions, and
41
+ * the actual command handlers remain the consumer's.
41
42
  */
42
43
  declare class CommandPaletteController extends Controller<HTMLElement> {
43
44
  #private;
@@ -79,6 +80,12 @@ declare class CommandPaletteController extends Controller<HTMLElement> {
79
80
  connect(): void;
80
81
  /** Tears down the global hotkey listener and reverts the modal side effects. */
81
82
  disconnect(): void;
83
+ /** Initializes semantics for an option added before or after the controller connects. */
84
+ optionTargetConnected(option: HTMLElement): void;
85
+ /** Tracks an input added initially or after connect without extra consumer actions. */
86
+ inputTargetConnected(input: HTMLInputElement): void;
87
+ /** Removes controller-owned listeners when the input target is replaced or removed. */
88
+ inputTargetDisconnected(input: HTMLInputElement): void;
82
89
  /** Toggles the open state of the command palette. */
83
90
  toggle(): void;
84
91
  /** Opens the palette, traps focus, and shifts focus to the input. */
@@ -86,7 +93,7 @@ declare class CommandPaletteController extends Controller<HTMLElement> {
86
93
  /** Closes the palette and restores focus back to the opener. */
87
94
  close(): void;
88
95
  /** Filters option elements in-memory matching the input value. Bound to input target. */
89
- filter(): void;
96
+ filter(event?: InputEvent): void;
90
97
  /** Selects the clicked option. Bound to option targets. */
91
98
  selectByClick(event: MouseEvent): void;
92
99
  /** Closes when the backdrop (the dialog target itself) is clicked, ignoring inner clicks. */
@@ -2,6 +2,137 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/command_palette_controller.ts
4
4
 
5
+ // src/utils/composition_tracker.ts
6
+ var CompositionTracker = class {
7
+ #observedTargets = /* @__PURE__ */ new Set();
8
+ #activeTargets = /* @__PURE__ */ new Set();
9
+ #onStart;
10
+ #onEnd;
11
+ constructor(options = {}) {
12
+ this.#onStart = options.onStart;
13
+ this.#onEnd = options.onEnd;
14
+ }
15
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
16
+ observe(target) {
17
+ if (this.#observedTargets.has(target)) return;
18
+ target.addEventListener("compositionstart", this.#handleStart);
19
+ target.addEventListener("compositionend", this.#handleEnd);
20
+ this.#observedTargets.add(target);
21
+ }
22
+ /** Stops tracking one target and clears any active composition it owned. */
23
+ unobserve(target) {
24
+ if (!this.#observedTargets.delete(target)) return;
25
+ target.removeEventListener("compositionstart", this.#handleStart);
26
+ target.removeEventListener("compositionend", this.#handleEnd);
27
+ this.#activeTargets.delete(target);
28
+ }
29
+ /** Releases every listener and clears state so reconnect starts cleanly. */
30
+ disconnect() {
31
+ for (const target of this.#observedTargets) {
32
+ target.removeEventListener("compositionstart", this.#handleStart);
33
+ target.removeEventListener("compositionend", this.#handleEnd);
34
+ }
35
+ this.#observedTargets.clear();
36
+ this.#activeTargets.clear();
37
+ }
38
+ /** True when lifecycle tracking or the current event reports composition. */
39
+ isComposing(event) {
40
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
41
+ }
42
+ #handleStart = (event) => {
43
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
44
+ this.#onStart?.(event);
45
+ };
46
+ #handleEnd = (event) => {
47
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
48
+ this.#onEnd?.(event);
49
+ };
50
+ };
51
+
52
+ // src/utils/escape_layer.ts
53
+ var EscapeLayer = class _EscapeLayer {
54
+ static #registries = /* @__PURE__ */ new WeakMap();
55
+ #ownerDocument = null;
56
+ /** Dismissal callback while active; `null` when inactive. */
57
+ #onDismiss = null;
58
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
59
+ #claims = null;
60
+ /**
61
+ * Activates this layer at the top of its document's Escape stack, installing
62
+ * the document's shared resolver listener if this is its first layer.
63
+ * Re-activating an already-active layer moves it to the top.
64
+ */
65
+ activate(ownerDocument = document, options) {
66
+ this.deactivate();
67
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
68
+ if (!registry) {
69
+ registry = _EscapeLayer.#createRegistry();
70
+ _EscapeLayer.#registries.set(ownerDocument, registry);
71
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
72
+ }
73
+ registry.stack.push(this);
74
+ this.#ownerDocument = ownerDocument;
75
+ this.#onDismiss = options.onDismiss;
76
+ this.#claims = options.claims ?? null;
77
+ }
78
+ /**
79
+ * Removes this layer from its document's Escape stack, uninstalling the
80
+ * shared listener when the stack empties. Safe to call when inactive.
81
+ */
82
+ deactivate() {
83
+ const ownerDocument = this.#ownerDocument;
84
+ if (!ownerDocument) return;
85
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
86
+ if (registry) {
87
+ const index = registry.stack.lastIndexOf(this);
88
+ if (index >= 0) registry.stack.splice(index, 1);
89
+ if (registry.stack.length === 0) {
90
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
91
+ _EscapeLayer.#registries.delete(ownerDocument);
92
+ }
93
+ }
94
+ this.#ownerDocument = null;
95
+ this.#onDismiss = null;
96
+ this.#claims = null;
97
+ }
98
+ /**
99
+ * Whether this active layer would own a press right now: it is the topmost
100
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
101
+ * and diagnostics — production dismissal goes through the shared listener.
102
+ */
103
+ get ownsEscape() {
104
+ const ownerDocument = this.#ownerDocument;
105
+ if (!ownerDocument) return false;
106
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
107
+ if (!registry) return false;
108
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
109
+ }
110
+ /** Builds a document's registry with its shared resolver listener. */
111
+ static #createRegistry() {
112
+ const registry = {
113
+ stack: [],
114
+ onKeydown: (event) => {
115
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
116
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
117
+ if (!owner) return;
118
+ event.preventDefault();
119
+ owner.#onDismiss?.();
120
+ }
121
+ };
122
+ return registry;
123
+ }
124
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
125
+ static #resolveOwner(stack) {
126
+ for (let index = stack.length - 1; index >= 0; index--) {
127
+ const layer = stack[index];
128
+ if (!layer) continue;
129
+ if (layer.#claims && !layer.#claims()) continue;
130
+ return layer;
131
+ }
132
+ return null;
133
+ }
134
+ };
135
+
5
136
  // src/utils/focus_trap.ts
6
137
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
138
  var FocusTrap = class {
@@ -15,6 +146,8 @@ var FocusTrap = class {
15
146
  #inertedSiblings = [];
16
147
  /** Whether the modal side effects are currently applied. */
17
148
  #activeState = false;
149
+ /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
150
+ #escapeLayer = new EscapeLayer();
18
151
  /** Returns the trapped element; called on every operation for the live target. */
19
152
  #getContainer;
20
153
  /** Closing/focus hooks; see {@link FocusTrapOptions}. */
@@ -50,6 +183,8 @@ var FocusTrap = class {
50
183
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
51
184
  document.addEventListener("keydown", this.#onKeydown);
52
185
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
186
+ const onEscape = this.#options.onEscape;
187
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
53
188
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
54
189
  }
55
190
  /**
@@ -62,6 +197,7 @@ var FocusTrap = class {
62
197
  deactivate({ restoreFocus = true } = {}) {
63
198
  if (!this.#activeState) return;
64
199
  this.#activeState = false;
200
+ this.#escapeLayer.deactivate();
65
201
  document.removeEventListener("keydown", this.#onKeydown);
66
202
  document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
67
203
  if (this.#scrollLocked) {
@@ -90,15 +226,12 @@ var FocusTrap = class {
90
226
  #onBeforeCache = () => {
91
227
  this.deactivate({ restoreFocus: false });
92
228
  };
93
- /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
229
+ /**
230
+ * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
231
+ * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
232
+ * which layer currently owns Escape.
233
+ */
94
234
  #onKeydown = (event) => {
95
- if (event.key === "Escape") {
96
- if (this.#options.onEscape) {
97
- event.preventDefault();
98
- this.#options.onEscape();
99
- }
100
- return;
101
- }
102
235
  if (event.key === "Tab") this.#trapTab(event);
103
236
  };
104
237
  /** Keeps `Tab` focus cycling within the container's focusable elements. */
@@ -172,7 +305,9 @@ var FocusTrap = class {
172
305
  };
173
306
 
174
307
  // src/controllers/command_palette_controller.ts
175
- var CommandPaletteController = class extends Controller {
308
+ var CommandPaletteController = class _CommandPaletteController extends Controller {
309
+ static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
310
+ static #ABSENT_ARIA_DISABLED = "absent";
176
311
  static targets = ["dialog", "input", "list", "option", "empty"];
177
312
  static values = {
178
313
  hotkey: { type: String, default: "mod+k" },
@@ -190,6 +325,12 @@ var CommandPaletteController = class extends Controller {
190
325
  static events = ["select"];
191
326
  /** The index of the currently active option within the visible subset. */
192
327
  #activeIndex = -1;
328
+ /** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
329
+ #composition = new CompositionTracker({
330
+ onEnd: () => {
331
+ if (this.#isOpen) this.filter();
332
+ }
333
+ });
193
334
  /**
194
335
  * Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
195
336
  * restore). Escape closes; focus on open goes to the input, and is restored to
@@ -212,6 +353,8 @@ var CommandPaletteController = class extends Controller {
212
353
  */
213
354
  connect() {
214
355
  document.addEventListener("keydown", this.#onGlobalKeydown);
356
+ if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
357
+ this.#syncOptionSemantics();
215
358
  const shouldOpen = this.#isOpen || this.openValue;
216
359
  this.#resetToClosedState();
217
360
  if (shouldOpen) this.open();
@@ -219,9 +362,22 @@ var CommandPaletteController = class extends Controller {
219
362
  /** Tears down the global hotkey listener and reverts the modal side effects. */
220
363
  disconnect() {
221
364
  document.removeEventListener("keydown", this.#onGlobalKeydown);
365
+ this.#composition.disconnect();
222
366
  this.#trap.deactivate({ restoreFocus: false });
223
367
  this.#resetToClosedState();
224
368
  }
369
+ /** Initializes semantics for an option added before or after the controller connects. */
370
+ optionTargetConnected(option) {
371
+ this.#syncOptionSemanticsFor(option);
372
+ }
373
+ /** Tracks an input added initially or after connect without extra consumer actions. */
374
+ inputTargetConnected(input) {
375
+ this.#composition.observe(input);
376
+ }
377
+ /** Removes controller-owned listeners when the input target is replaced or removed. */
378
+ inputTargetDisconnected(input) {
379
+ this.#composition.unobserve(input);
380
+ }
225
381
  /** Toggles the open state of the command palette. */
226
382
  toggle() {
227
383
  if (this.#isOpen) {
@@ -246,8 +402,9 @@ var CommandPaletteController = class extends Controller {
246
402
  this.#trap.deactivate();
247
403
  }
248
404
  /** Filters option elements in-memory matching the input value. Bound to input target. */
249
- filter() {
250
- if (!this.hasInputTarget) return;
405
+ filter(event) {
406
+ if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
407
+ this.#syncOptionSemantics();
251
408
  const query = this.inputTarget.value.trim().toLowerCase();
252
409
  let hasSelectableMatch = false;
253
410
  for (const option of this.optionTargets) {
@@ -270,6 +427,7 @@ var CommandPaletteController = class extends Controller {
270
427
  const target = event.currentTarget;
271
428
  if (!target) return;
272
429
  const option = target.closest("[role='option']");
430
+ if (option) this.#syncOptionSemanticsFor(option);
273
431
  if (option && !this.#isDisabled(option)) {
274
432
  this.#confirmSelection(option);
275
433
  }
@@ -285,7 +443,7 @@ var CommandPaletteController = class extends Controller {
285
443
  * focus — not only the input.
286
444
  */
287
445
  onKeydown(event) {
288
- if (!this.#isOpen) return;
446
+ if (this.#composition.isComposing(event) || !this.#isOpen) return;
289
447
  switch (event.key) {
290
448
  case "ArrowDown":
291
449
  event.preventDefault();
@@ -320,23 +478,25 @@ var CommandPaletteController = class extends Controller {
320
478
  this.#setActiveIndex(newIndex);
321
479
  }
322
480
  #setActiveIndex(index) {
481
+ this.#syncOptionSemantics();
323
482
  const visible = this.#visibleOptions;
324
- this.#activeIndex = index;
325
- visible.forEach((option, i) => {
326
- if (i === index) {
327
- option.setAttribute("aria-selected", "true");
328
- option.setAttribute("data-active", "true");
329
- if (this.hasInputTarget) {
330
- this.inputTarget.setAttribute("aria-activedescendant", option.id || "");
331
- }
332
- option.scrollIntoView({ block: "nearest" });
483
+ const activeOption = visible[index] ?? null;
484
+ this.#activeIndex = activeOption ? index : -1;
485
+ for (const option of this.optionTargets) {
486
+ option.setAttribute("aria-selected", "false");
487
+ option.removeAttribute("data-active");
488
+ }
489
+ if (activeOption) {
490
+ activeOption.setAttribute("aria-selected", "true");
491
+ activeOption.setAttribute("data-active", "true");
492
+ activeOption.scrollIntoView({ block: "nearest" });
493
+ }
494
+ if (this.hasInputTarget) {
495
+ if (activeOption?.id) {
496
+ this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
333
497
  } else {
334
- option.setAttribute("aria-selected", "false");
335
- option.removeAttribute("data-active");
498
+ this.inputTarget.removeAttribute("aria-activedescendant");
336
499
  }
337
- });
338
- if (index === -1 && this.hasInputTarget) {
339
- this.inputTarget.removeAttribute("aria-activedescendant");
340
500
  }
341
501
  }
342
502
  #confirmSelection(option) {
@@ -349,8 +509,9 @@ var CommandPaletteController = class extends Controller {
349
509
  for (const option of this.optionTargets) {
350
510
  option.removeAttribute("hidden");
351
511
  }
352
- if (this.hasEmptyTarget) this.emptyTarget.hidden = true;
353
- this.#setActiveIndex(0);
512
+ const hasSelectableOption = this.#visibleOptions.length > 0;
513
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
514
+ this.#setActiveIndex(hasSelectableOption ? 0 : -1);
354
515
  }
355
516
  get #visibleOptions() {
356
517
  return this.optionTargets.filter(
@@ -358,23 +519,66 @@ var CommandPaletteController = class extends Controller {
358
519
  );
359
520
  }
360
521
  #isDisabled(option) {
361
- return option.dataset.disabled === "true";
522
+ return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
523
+ }
524
+ /** Synchronizes every option's controller-owned selection and disabled semantics. */
525
+ #syncOptionSemantics() {
526
+ for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
527
+ }
528
+ /** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
529
+ #syncOptionSemanticsFor(option) {
530
+ if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
531
+ const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
532
+ const originalValue = option.getAttribute(originalAttribute);
533
+ if (option.dataset.disabled === "true") {
534
+ if (originalValue === null) {
535
+ option.setAttribute(
536
+ originalAttribute,
537
+ option.getAttribute("aria-disabled") ?? _CommandPaletteController.#ABSENT_ARIA_DISABLED
538
+ );
539
+ }
540
+ option.setAttribute("aria-disabled", "true");
541
+ return;
542
+ }
543
+ if (originalValue === null) return;
544
+ if (originalValue === _CommandPaletteController.#ABSENT_ARIA_DISABLED) {
545
+ option.removeAttribute("aria-disabled");
546
+ } else {
547
+ option.setAttribute("aria-disabled", originalValue);
548
+ }
549
+ option.removeAttribute(originalAttribute);
362
550
  }
363
551
  get #isOpen() {
364
552
  return this.hasDialogTarget && !this.dialogTarget.hidden;
365
553
  }
366
554
  #onGlobalKeydown = (event) => {
367
- const hotkey = this.hotkeyValue.toLowerCase();
368
- const isMod = hotkey.includes("mod+");
369
- const key = hotkey.split("+").pop();
370
- if (!key) return;
371
- const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
372
- const keyMatch = event.key.toLowerCase() === key;
373
- if (modPressed && keyMatch) {
374
- event.preventDefault();
375
- this.toggle();
376
- }
555
+ if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
556
+ event.preventDefault();
557
+ this.toggle();
377
558
  };
559
+ /** Matches the configured `mod+key` or bare-key hotkey without extra modifiers. */
560
+ #matchesHotkey(event) {
561
+ const hotkey = this.#parseHotkey();
562
+ if (!hotkey || event.altKey || event.shiftKey) return false;
563
+ if (hotkey.requiresMod) {
564
+ const hasExactlyOneModKey = event.metaKey !== event.ctrlKey;
565
+ if (!hasExactlyOneModKey) return false;
566
+ } else if (event.metaKey || event.ctrlKey) {
567
+ return false;
568
+ }
569
+ return event.key.toLowerCase() === hotkey.key;
570
+ }
571
+ /** Parses the intentionally small public hotkey grammar. */
572
+ #parseHotkey() {
573
+ const parts = this.hotkeyValue.toLowerCase().split("+");
574
+ if (parts.length === 1 && parts[0] && parts[0] !== "mod") {
575
+ return { key: parts[0], requiresMod: false };
576
+ }
577
+ if (parts.length === 2 && parts[0] === "mod" && parts[1]) {
578
+ return { key: parts[1], requiresMod: true };
579
+ }
580
+ return null;
581
+ }
378
582
  /** Resets transient open state so reconnect starts from a predictable closed snapshot. */
379
583
  #resetToClosedState() {
380
584
  this.#activeIndex = -1;