stimeo-ui 0.8.0 → 0.10.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 (167) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/controllers/accordion_controller.js.map +1 -1
  3. package/dist/controllers/alert_dialog_controller.js +17 -7
  4. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  5. package/dist/controllers/announcer_controller.js +1 -1
  6. package/dist/controllers/announcer_controller.js.map +1 -1
  7. package/dist/controllers/auto_submit_controller.js.map +1 -1
  8. package/dist/controllers/avatar_controller.js.map +1 -1
  9. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  10. package/dist/controllers/bulk_select_controller.d.ts +62 -15
  11. package/dist/controllers/bulk_select_controller.js +139 -28
  12. package/dist/controllers/bulk_select_controller.js.map +1 -1
  13. package/dist/controllers/calendar_controller.js.map +1 -1
  14. package/dist/controllers/carousel_controller.js.map +1 -1
  15. package/dist/controllers/character_counter_controller.js.map +1 -1
  16. package/dist/controllers/clipboard_controller.d.ts +48 -15
  17. package/dist/controllers/clipboard_controller.js +102 -20
  18. package/dist/controllers/clipboard_controller.js.map +1 -1
  19. package/dist/controllers/collapsible_controller.d.ts +1 -1
  20. package/dist/controllers/collapsible_controller.js +1 -1
  21. package/dist/controllers/collapsible_controller.js.map +1 -1
  22. package/dist/controllers/color_picker_controller.d.ts +37 -6
  23. package/dist/controllers/color_picker_controller.js +180 -43
  24. package/dist/controllers/color_picker_controller.js.map +1 -1
  25. package/dist/controllers/combobox_controller.js.map +1 -1
  26. package/dist/controllers/command_palette_controller.js +15 -5
  27. package/dist/controllers/command_palette_controller.js.map +1 -1
  28. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  29. package/dist/controllers/confirm_controller.js +15 -5
  30. package/dist/controllers/confirm_controller.js.map +1 -1
  31. package/dist/controllers/context_menu_controller.d.ts +5 -4
  32. package/dist/controllers/context_menu_controller.js.map +1 -1
  33. package/dist/controllers/count_up_controller.js.map +1 -1
  34. package/dist/controllers/countdown_controller.js +1 -1
  35. package/dist/controllers/countdown_controller.js.map +1 -1
  36. package/dist/controllers/data_grid_controller.d.ts +25 -9
  37. package/dist/controllers/data_grid_controller.js +151 -25
  38. package/dist/controllers/data_grid_controller.js.map +1 -1
  39. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  40. package/dist/controllers/dialog_controller.js +15 -5
  41. package/dist/controllers/dialog_controller.js.map +1 -1
  42. package/dist/controllers/direct_upload_controller.d.ts +2 -2
  43. package/dist/controllers/direct_upload_controller.js +1 -5
  44. package/dist/controllers/direct_upload_controller.js.map +1 -1
  45. package/dist/controllers/dismissible_controller.js.map +1 -1
  46. package/dist/controllers/drawer_controller.d.ts +2 -2
  47. package/dist/controllers/drawer_controller.js +17 -7
  48. package/dist/controllers/drawer_controller.js.map +1 -1
  49. package/dist/controllers/editable_controller.d.ts +34 -9
  50. package/dist/controllers/editable_controller.js +83 -30
  51. package/dist/controllers/editable_controller.js.map +1 -1
  52. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  53. package/dist/controllers/filter_controller.d.ts +15 -3
  54. package/dist/controllers/filter_controller.js +32 -1
  55. package/dist/controllers/filter_controller.js.map +1 -1
  56. package/dist/controllers/flash_controller.js.map +1 -1
  57. package/dist/controllers/focus_controller.d.ts +5 -0
  58. package/dist/controllers/focus_controller.js +45 -7
  59. package/dist/controllers/focus_controller.js.map +1 -1
  60. package/dist/controllers/form_field_controller.js +1 -1
  61. package/dist/controllers/form_field_controller.js.map +1 -1
  62. package/dist/controllers/form_validation_controller.js.map +1 -1
  63. package/dist/controllers/frame_loading_controller.js.map +1 -1
  64. package/dist/controllers/highlight_controller.js.map +1 -1
  65. package/dist/controllers/input_mask_controller.js.map +1 -1
  66. package/dist/controllers/intersection_controller.d.ts +4 -3
  67. package/dist/controllers/intersection_controller.js.map +1 -1
  68. package/dist/controllers/lazy_frame_controller.d.ts +2 -2
  69. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  70. package/dist/controllers/listbox_controller.js.map +1 -1
  71. package/dist/controllers/masonry_controller.d.ts +36 -5
  72. package/dist/controllers/masonry_controller.js +129 -17
  73. package/dist/controllers/masonry_controller.js.map +1 -1
  74. package/dist/controllers/menu_controller.d.ts +1 -1
  75. package/dist/controllers/menu_controller.js.map +1 -1
  76. package/dist/controllers/menubar_controller.js +2 -2
  77. package/dist/controllers/menubar_controller.js.map +1 -1
  78. package/dist/controllers/meter_controller.d.ts +1 -1
  79. package/dist/controllers/meter_controller.js.map +1 -1
  80. package/dist/controllers/multi_select_controller.js +1 -1
  81. package/dist/controllers/multi_select_controller.js.map +1 -1
  82. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  83. package/dist/controllers/nested_form_controller.js.map +1 -1
  84. package/dist/controllers/number_input_controller.js.map +1 -1
  85. package/dist/controllers/otp_controller.d.ts +4 -1
  86. package/dist/controllers/otp_controller.js +29 -16
  87. package/dist/controllers/otp_controller.js.map +1 -1
  88. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  89. package/dist/controllers/overflow_menu_controller.js +2 -2
  90. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  91. package/dist/controllers/pagination_controller.d.ts +1 -1
  92. package/dist/controllers/pagination_controller.js.map +1 -1
  93. package/dist/controllers/password_reveal_controller.d.ts +27 -2
  94. package/dist/controllers/password_reveal_controller.js +102 -3
  95. package/dist/controllers/password_reveal_controller.js.map +1 -1
  96. package/dist/controllers/password_strength_controller.d.ts +71 -17
  97. package/dist/controllers/password_strength_controller.js +287 -42
  98. package/dist/controllers/password_strength_controller.js.map +1 -1
  99. package/dist/controllers/pointer_drag_controller.d.ts +3 -2
  100. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  101. package/dist/controllers/popover_controller.js.map +1 -1
  102. package/dist/controllers/portal_controller.d.ts +1 -1
  103. package/dist/controllers/portal_controller.js.map +1 -1
  104. package/dist/controllers/progress_controller.js.map +1 -1
  105. package/dist/controllers/radio_group_controller.js.map +1 -1
  106. package/dist/controllers/range_slider_controller.js.map +1 -1
  107. package/dist/controllers/rating_controller.js.map +1 -1
  108. package/dist/controllers/read_more_controller.js.map +1 -1
  109. package/dist/controllers/relative_time_controller.js.map +1 -1
  110. package/dist/controllers/reset_before_cache_controller.d.ts +26 -4
  111. package/dist/controllers/reset_before_cache_controller.js +51 -5
  112. package/dist/controllers/reset_before_cache_controller.js.map +1 -1
  113. package/dist/controllers/resizable_controller.d.ts +23 -7
  114. package/dist/controllers/resizable_controller.js +128 -55
  115. package/dist/controllers/resizable_controller.js.map +1 -1
  116. package/dist/controllers/roving_controller.d.ts +2 -1
  117. package/dist/controllers/roving_controller.js.map +1 -1
  118. package/dist/controllers/scroll_area_controller.js.map +1 -1
  119. package/dist/controllers/scroll_restore_controller.d.ts +33 -11
  120. package/dist/controllers/scroll_restore_controller.js +127 -27
  121. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  122. package/dist/controllers/scroll_visibility_controller.d.ts +42 -10
  123. package/dist/controllers/scroll_visibility_controller.js +167 -14
  124. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  125. package/dist/controllers/scrollspy_controller.js.map +1 -1
  126. package/dist/controllers/separator_controller.js.map +1 -1
  127. package/dist/controllers/sidebar_controller.js +15 -5
  128. package/dist/controllers/sidebar_controller.js.map +1 -1
  129. package/dist/controllers/skeleton_controller.js.map +1 -1
  130. package/dist/controllers/slider_controller.js.map +1 -1
  131. package/dist/controllers/spinner_controller.js.map +1 -1
  132. package/dist/controllers/step_indicator_controller.js +0 -5
  133. package/dist/controllers/step_indicator_controller.js.map +1 -1
  134. package/dist/controllers/stick_to_bottom_controller.d.ts +3 -2
  135. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  136. package/dist/controllers/submit_once_controller.js.map +1 -1
  137. package/dist/controllers/switch_controller.js.map +1 -1
  138. package/dist/controllers/tabs_controller.js.map +1 -1
  139. package/dist/controllers/tags_input_controller.js.map +1 -1
  140. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  141. package/dist/controllers/theme_controller.d.ts +32 -6
  142. package/dist/controllers/theme_controller.js +151 -40
  143. package/dist/controllers/theme_controller.js.map +1 -1
  144. package/dist/controllers/time_picker_controller.js.map +1 -1
  145. package/dist/controllers/toast_controller.d.ts +2 -2
  146. package/dist/controllers/toast_controller.js +2 -2
  147. package/dist/controllers/toast_controller.js.map +1 -1
  148. package/dist/controllers/toggle_group_controller.js.map +1 -1
  149. package/dist/controllers/toolbar_controller.js.map +1 -1
  150. package/dist/controllers/transition_controller.d.ts +11 -7
  151. package/dist/controllers/transition_controller.js +79 -15
  152. package/dist/controllers/transition_controller.js.map +1 -1
  153. package/dist/controllers/tree_view_controller.js.map +1 -1
  154. package/dist/index.d.ts +6 -3
  155. package/dist/index.js +1612 -529
  156. package/dist/index.js.map +1 -1
  157. package/dist/inspector/cli.d.ts +3 -4
  158. package/dist/inspector/cli.js +3 -0
  159. package/dist/inspector/cli.js.map +1 -1
  160. package/dist/inspector/cli_bin.js +8 -5
  161. package/dist/inspector/cli_bin.js.map +1 -1
  162. package/dist/inspector/examples.json +25 -25
  163. package/dist/inspector/manifest.json +18 -11
  164. package/dist/positioning/index.d.ts +18 -4
  165. package/dist/positioning/index.js +84 -25
  166. package/dist/positioning/index.js.map +1 -1
  167. package/package.json +5 -5
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/controllers/masonry_controller.ts"],"names":[],"mappings":"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","file":"masonry_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Layout-sensitive widgets (sliders, resizable panes, scroll spies, popovers)\n * need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\n\n/** CSS custom property exposing the current column count to consumer CSS. */\nconst COLUMNS_PROPERTY = \"--stimeo--masonry-columns\";\n\n/**\n * Headless **Masonry** layout helper: assigns each item to the shortest column so\n * variable-height cards pack without vertical gaps. There is no APG widget — this\n * is a layout-only utility that emits state hooks, never visual structure.\n *\n * Markup contract (identifier: `stimeo--masonry`):\n * <div data-controller=\"stimeo--masonry\"\n * data-stimeo--masonry-min-column-width-value=\"240\"\n * data-stimeo--masonry-gap-value=\"16\">\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * </div>\n *\n * The column count is derived responsively from the container width and\n * `minColumnWidth`; each item is then placed into whichever column is currently\n * shortest (measured from item heights). The count is published on the controller\n * element as the `--stimeo--masonry-columns` custom property and each item gets a\n * `data-column` index, so the consumer's CSS owns the actual placement.\n *\n * `layout` dispatches `{ columns: number }`.\n *\n * @remarks\n * Behavior only. **DOM order is never changed** — reading order and focus order\n * stay the source markup order (WCAG 1.3.2). The visual packing is purely the\n * column assignment a consumer reads from `data-column`; this controller writes no\n * positioning styles. Re-layout runs on connect, on resize ({@link LayoutObserver}),\n * and on item add/remove ({@link MutationObserver}); both observers are released on\n * `disconnect()` (Turbo navigation included). Use only for independent cards whose\n * visual order carries no meaning.\n */\nexport class MasonryController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n minColumnWidth: { type: Number, default: 240 },\n gap: { type: Number, default: 16 },\n };\n static events = [\"layout\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare minColumnWidthValue: number;\n declare gapValue: number;\n\n readonly #layout = new LayoutObserver(() => this.#relayout());\n #mutationObserver: MutationObserver | null = null;\n /** Last published column count, so `layout` fires only on real changes. */\n #lastColumns = 0;\n\n /**\n * Re-pack when a descendant resource finishes loading. Images/iframes report a\n * height of 0 until loaded, which would skew the shortest-column packing if the\n * first pass ran before they settled; `load` does not bubble, so this is bound in\n * the capture phase to catch every descendant.\n */\n readonly #onLoad = (): void => this.#relayout();\n\n /** Observes size/content changes and performs the first layout pass. */\n override connect(): void {\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.#relayout());\n this.#mutationObserver.observe(this.element, { childList: true, subtree: true });\n }\n this.element.addEventListener(\"load\", this.#onLoad, true);\n this.#relayout();\n }\n\n /** Releases both observers and the load listener so nothing fires after detach. */\n override disconnect(): void {\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.element.removeEventListener(\"load\", this.#onLoad, true);\n this.#lastColumns = 0;\n }\n\n /**\n * Recomputes the column count and assigns every item to the shortest column.\n * Runs automatically on connect, on resize, on item add/remove, and when a\n * descendant resource loads (private — there is no public action; the observers\n * and the capture-phase `load` listener drive it). Items are walked in DOM\n * order; each lands in the column with the least accumulated height, which\n * keeps the packing balanced without reordering the DOM.\n */\n #relayout(): void {\n const items = this.itemTargets;\n const columns = this.#columnCount();\n\n const heights = new Array<number>(columns).fill(0);\n for (const item of items) {\n let shortest = 0;\n for (let col = 1; col < columns; col++) {\n if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;\n }\n item.setAttribute(\"data-column\", String(shortest));\n heights[shortest] =\n (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;\n }\n\n this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));\n\n if (columns !== this.#lastColumns) {\n this.#lastColumns = columns;\n this.dispatch(\"layout\", { detail: { columns } });\n }\n }\n\n /**\n * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,\n * never fewer than one. When the width is unmeasurable (detached, or a layout\n * engine that reports `0`), it falls back to a single column so every item still\n * gets a valid `data-column`.\n */\n #columnCount(): number {\n const width = this.element.getBoundingClientRect().width;\n const denominator = this.minColumnWidthValue + this.gapValue;\n if (width <= 0 || denominator <= 0) return 1;\n return Math.max(1, Math.floor((width + this.gapValue) / denominator));\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/layout_observer.ts","../../src/utils/microtask_coalescer.ts","../../src/controllers/masonry_controller.ts"],"names":[],"mappings":"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","file":"masonry_controller.js","sourcesContent":["/**\n * Unified element-size and viewport observation for Stimeo controllers.\n *\n * Widgets whose output is measured — an overflow boundary, a masonry column count,\n * an autosized textarea — need to react both to their *own* box changing — via {@link ResizeObserver} —\n * and to the *viewport* changing — via the `window` `resize` event. Wiring those\n * two sources by hand in every controller risks leaked listeners on\n * `disconnect()`. {@link LayoutObserver} owns both behind one callback and one\n * {@link LayoutObserver.disconnect | disconnect()} that releases everything.\n *\n * Behavior only: the helper reports *that* layout changed; it never reads or\n * writes styles. Consumers decide what to recompute.\n */\n\n/** Invoked whenever an observed element or the viewport changes size. */\nexport type LayoutCallback = () => void;\n\n/** Constructs a {@link ResizeObserver}; injectable so tests stay deterministic. */\nexport type ResizeObserverFactory = (callback: ResizeObserverCallback) => ResizeObserver;\n\n/** Options for {@link LayoutObserver}. */\nexport interface LayoutObserverOptions {\n /**\n * Factory for the {@link ResizeObserver} used by {@link LayoutObserver.observe}.\n * Defaults to the global constructor; override it in tests, or to no-op in\n * environments where `ResizeObserver` is unavailable.\n */\n resizeObserverFactory?: ResizeObserverFactory;\n}\n\n/**\n * Observes element resizes and/or viewport resizes through a single callback,\n * with guaranteed teardown.\n *\n * @example\n * ```ts\n * #layout = new LayoutObserver(() => this.#reposition());\n *\n * connect() {\n * this.#layout.observe(this.panelTarget);\n * this.#layout.observeViewport();\n * }\n *\n * disconnect() {\n * this.#layout.disconnect();\n * }\n * ```\n */\nexport class LayoutObserver {\n readonly #callback: LayoutCallback;\n readonly #resizeObserverFactory: ResizeObserverFactory | null;\n #resizeObserver: ResizeObserver | null = null;\n #observingViewport = false;\n\n /** Stable bound handler so add/removeEventListener target the same reference. */\n readonly #handleViewportResize = (): void => {\n this.#callback();\n };\n\n constructor(callback: LayoutCallback, options: LayoutObserverOptions = {}) {\n this.#callback = callback;\n this.#resizeObserverFactory =\n options.resizeObserverFactory ??\n (typeof ResizeObserver === \"undefined\" ? null : (cb) => new ResizeObserver(cb));\n }\n\n /**\n * Starts observing an element's size. Repeated calls observe additional\n * elements through the same shared observer. No-ops when no\n * `ResizeObserver` implementation is available.\n */\n observe(element: Element): void {\n if (!this.#resizeObserverFactory) return;\n if (!this.#resizeObserver) {\n this.#resizeObserver = this.#resizeObserverFactory(() => {\n this.#callback();\n });\n }\n this.#resizeObserver.observe(element);\n }\n\n /** Stops observing a single element while leaving any others in place. */\n unobserve(element: Element): void {\n this.#resizeObserver?.unobserve(element);\n }\n\n /** Starts observing viewport resizes. Idempotent: the listener is added once. */\n observeViewport(): void {\n if (this.#observingViewport) return;\n this.#observingViewport = true;\n window.addEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /** Stops observing viewport resizes without affecting element observation. */\n unobserveViewport(): void {\n if (!this.#observingViewport) return;\n this.#observingViewport = false;\n window.removeEventListener(\"resize\", this.#handleViewportResize);\n }\n\n /**\n * Releases every observation: disconnects the {@link ResizeObserver} and\n * removes the viewport listener. Safe to call multiple times. Call this from a\n * controller's `disconnect()`.\n */\n disconnect(): void {\n this.#resizeObserver?.disconnect();\n this.#resizeObserver = null;\n this.unobserveViewport();\n }\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { LayoutObserver } from \"../utils/layout_observer\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** CSS custom property exposing the current column count to consumer CSS. */\nconst COLUMNS_PROPERTY = \"--stimeo--masonry-columns\";\n\n/** Column width assumed when the declaration is absent or unreadable. */\nconst DEFAULT_MIN_COLUMN_WIDTH = 240;\n/** Item spacing assumed when the declaration is absent or unreadable. */\nconst DEFAULT_GAP = 16;\n\n/**\n * Returns `value` when it is a number the column arithmetic can use, else\n * `fallback`.\n *\n * A unit suffix is the ordinary authoring slip here (`\"240px\"`), and Stimulus'\n * Number reader answers `NaN` rather than raising — which would reach the column\n * count and make the column bookkeeping impossible to allocate, leaving the grid\n * with no hooks at all. An infinity is rejected for the same reason: it divides\n * into itself as `NaN`.\n */\nfunction usableNumber(value: number, fallback: number): number {\n return Number.isFinite(value) ? value : fallback;\n}\n\n/**\n * Headless **Masonry** layout helper: assigns each item to the shortest column so\n * variable-height cards pack without vertical gaps. There is no APG widget — this\n * is a layout-only utility that emits state hooks, never visual structure.\n *\n * Markup contract (identifier: `stimeo--masonry`):\n * <div data-controller=\"stimeo--masonry\"\n * data-stimeo--masonry-min-column-width-value=\"240\"\n * data-stimeo--masonry-gap-value=\"16\">\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * <div data-stimeo--masonry-target=\"item\">…</div>\n * </div>\n *\n * The column count is derived responsively from the container width and\n * `minColumnWidth`; each item is then placed into whichever column is currently\n * shortest (measured from item heights). The count is published on the controller\n * element as the `--stimeo--masonry-columns` custom property and each item gets a\n * `data-column` index, so the consumer's CSS owns the actual placement.\n *\n * `layout` dispatches `{ columns: number }` whenever the published result moves —\n * the column count changed, or some item landed in a different column. A pass that\n * reproduces the previous result stays silent.\n *\n * @remarks\n * Behavior only. **DOM order is never changed** — reading order and focus order\n * stay the source markup order (WCAG 1.3.2). The visual packing is purely the\n * column assignment a consumer reads from `data-column`; this controller writes no\n * positioning styles. Use only for independent cards whose visual order carries no\n * meaning.\n *\n * Re-layout runs on connect, on resize ({@link LayoutObserver}), on item\n * add/remove ({@link MutationObserver}), on an item joining or leaving the target\n * set, when a declared number changes, and when a descendant resource loads.\n * Everything but the first pass is folded into one microtask, so a burst of\n * triggers costs one pass. The observers, the `load` listener and any pending pass\n * are released on `disconnect()` (Turbo navigation included).\n *\n * Consumer contract:\n * - A declaration that cannot be read as a number (`\"240px\"`, an infinity) falls\n * back to that Value's default and the grid keeps working; `0` and negatives are\n * readable numbers and are used as declared; the count falls back to one column\n * only when `minColumnWidth + gap` is not positive, or the container has no\n * measurable width.\n * - `data-column` belongs to this controller: it is written on every item it owns\n * and taken back from an element that stops being one.\n */\nexport class MasonryController extends Controller<HTMLElement> {\n static override targets = [\"item\"];\n static override values = {\n minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },\n gap: { type: Number, default: DEFAULT_GAP },\n };\n static events = [\"layout\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare minColumnWidthValue: number;\n declare gapValue: number;\n\n /**\n * The declared numbers after validation, so the layout path never sees a value\n * it cannot compute with. Both are resolved once per declaration change rather\n * than on every pass.\n */\n #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;\n #gap = DEFAULT_GAP;\n\n /**\n * Collapses every re-layout trigger of one DOM mutation into a single pass, and\n * refuses to run before `connect()` or after `disconnect()`.\n *\n * The triggers arrive in bursts — a resize stream, a morph that syncs several\n * attributes, a batch of rows — and each pass measures every item, so folding\n * them keeps the work proportional to the batch rather than to the events in it.\n */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#relayout());\n\n /** Items that left the target set and still carry the column hook. */\n readonly #released = new Set<HTMLElement>();\n\n readonly #layout = new LayoutObserver(() => this.#reconcile.schedule());\n #mutationObserver: MutationObserver | null = null;\n /** Last published column count, so `layout` fires only on real changes. */\n #lastColumns = 0;\n\n /**\n * Re-pack when a descendant resource finishes loading. Images/iframes report a\n * height of 0 until loaded, which would skew the shortest-column packing if the\n * first pass ran before they settled; `load` does not bubble, so this is bound in\n * the capture phase to catch every descendant.\n */\n readonly #onLoad = (): void => this.#reconcile.schedule();\n\n /** Resolves the declared column width once, falling back when it is unreadable. */\n minColumnWidthValueChanged(): void {\n this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);\n this.#reconcile.schedule();\n }\n\n /** Resolves the declared gap once, falling back when it is unreadable. */\n gapValueChanged(): void {\n this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);\n this.#reconcile.schedule();\n }\n\n /** Packs an element that became an item without moving in the DOM. */\n itemTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Queues the column hook of an element that stopped being an item for removal.\n *\n * The removal is queued rather than immediate because teardown reports every\n * target as disconnected: doing it here would strip the whole grid just before\n * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue\n * with the pass, so only a genuine target change reaches it.\n */\n itemTargetDisconnected(item: HTMLElement): void {\n this.#released.add(item);\n this.#reconcile.schedule();\n }\n\n /** Observes size/content changes and performs the first layout pass. */\n override connect(): void {\n this.#layout.observe(this.element);\n this.#layout.observeViewport();\n\n if (typeof MutationObserver !== \"undefined\") {\n this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());\n this.#mutationObserver.observe(this.element, { childList: true, subtree: true });\n }\n this.element.addEventListener(\"load\", this.#onLoad, true);\n this.#relayout();\n this.#reconcile.activate();\n }\n\n /** Releases both observers and the load listener so nothing fires after detach. */\n override disconnect(): void {\n this.#reconcile.cancel();\n this.#released.clear();\n this.#layout.disconnect();\n this.#mutationObserver?.disconnect();\n this.#mutationObserver = null;\n this.element.removeEventListener(\"load\", this.#onLoad, true);\n this.#lastColumns = 0;\n }\n\n /**\n * Recomputes the column count and assigns every item to the shortest column.\n * Runs automatically on connect, on resize, on item add/remove, when a declared\n * number changes, and when a descendant resource loads (private — there is no\n * public action; the observers, the target callbacks and the capture-phase\n * `load` listener drive it). Items are walked in DOM order; each lands in the\n * column with the least accumulated height, which keeps the packing balanced\n * without reordering the DOM.\n *\n * Every box is measured before anything is written. Interleaving the two would\n * make a consumer's `data-column` rule invalidate style once per item, and the\n * next measurement then has to settle layout again — once per item instead of\n * once per pass. The assignment is independent of the measurement because the\n * columns are uniform in width, so the order of the two passes does not change\n * the result.\n *\n * @stimeoRenderRoot\n */\n #relayout(): void {\n const items = this.itemTargets;\n const columns = this.#columnCount();\n const boxes = items.map((item) => item.getBoundingClientRect().height);\n\n let changed = false;\n if (this.#released.size > 0) {\n // An element that left and rejoined the target set within one batch is\n // queued here while still being an item, so ownership is decided against\n // the set this pass sees rather than against the queue alone.\n const owned = new Set(items);\n for (const released of this.#released) {\n if (owned.has(released)) continue;\n if (released.hasAttribute(\"data-column\")) {\n released.removeAttribute(\"data-column\");\n changed = true;\n }\n }\n this.#released.clear();\n }\n\n const heights = new Array<number>(columns).fill(0);\n items.forEach((item, index) => {\n let shortest = 0;\n for (let col = 1; col < columns; col++) {\n if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;\n }\n const assigned = String(shortest);\n // Writing a value the item already carries would publish a change that did\n // not happen, and the same comparison is what tells the event whether the\n // published layout actually moved.\n if (item.getAttribute(\"data-column\") !== assigned) {\n item.setAttribute(\"data-column\", assigned);\n changed = true;\n }\n heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;\n });\n\n this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));\n\n if (columns !== this.#lastColumns || changed) {\n this.#lastColumns = columns;\n this.dispatch(\"layout\", { detail: { columns } });\n }\n }\n\n /**\n * Derives how many columns fit: `floor((width + gap) / (minColumnWidth + gap))`,\n * never fewer than one. When the width is unmeasurable (detached, or a layout\n * engine that reports `0`), it falls back to a single column so every item still\n * gets a valid `data-column`.\n */\n #columnCount(): number {\n const width = this.element.getBoundingClientRect().width;\n const denominator = this.#minColumnWidth + this.#gap;\n if (width <= 0 || denominator <= 0) return 1;\n return Math.max(1, Math.floor((width + this.#gap) / denominator));\n }\n}\n"]}
@@ -27,7 +27,7 @@ import { Controller } from '@hotwired/stimulus';
27
27
  * `keydown->stimeo--menu#onItemKeydown`) is supported alongside it. A
28
28
  * focus-moving key runs once (the action claims it and the delegate stands
29
29
  * down), and `activate`, reachable on both paths, claims the click so one
30
- * gesture activates once. See {@link #onDelegatedItemClick}.
30
+ * gesture activates once. See the delegated click listener below.
31
31
  *
32
32
  * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu
33
33
  * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/controllers/menu_controller.ts"],"names":[],"mappings":"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","file":"menu_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n","/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest handler resolves the\n * press. A keydown that is part of an IME composition (`event.isComposing`)\n * cancels the composition, never a layer, and is ignored here for every layer\n * at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless, accessible menu button behavior.\n *\n * Markup contract (identifier: `stimeo--menu`):\n * <div data-controller=\"stimeo--menu\">\n * <button id=\"menu-trigger\" data-stimeo--menu-target=\"trigger\"\n * data-action=\"click->stimeo--menu#toggle\n * keydown->stimeo--menu#onTriggerKeydown\"\n * aria-haspopup=\"menu\" aria-expanded=\"false\" aria-controls=\"menu\">\n * Actions\n * </button>\n * <ul id=\"menu\" role=\"menu\" aria-labelledby=\"menu-trigger\"\n * data-stimeo--menu-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\"\n * data-stimeo--menu-target=\"item\">…</button>\n * </li>\n * </ul>\n * </div>\n *\n * The trigger's two actions are required. Items need none: their click and key\n * handling is delegated from the controller element, so an item added, moved, or\n * server-rendered after connect works without the consumer wiring anything onto\n * it. The per-element form (`click->stimeo--menu#activate` /\n * `keydown->stimeo--menu#onItemKeydown`) is supported alongside it. A\n * focus-moving key runs once (the action claims it and the delegate stands\n * down), and `activate`, reachable on both paths, claims the click so one\n * gesture activates once. See {@link #onDelegatedItemClick}.\n *\n * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu\n * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),\n * this is a true `role=\"menu\"` widget with roving focus across `role=\"menuitem\"`\n * children.\n *\n * @remarks\n * Behavior only — the core controller owns no placement. Consumers can use static\n * CSS or compose the menu with the opt-in `stimeo-ui/positioning` entrypoint for\n * viewport-aware placement. State is exposed via `aria-expanded` and the menu's\n * `hidden` attribute.\n *\n * Behavior provided:\n * - Click the trigger to toggle; `ArrowDown`/`ArrowUp` open and focus the\n * first/last item.\n * - Within the menu, `ArrowDown`/`ArrowUp` move focus (wrapping), `Home`/`End`\n * jump to the first/last item, `Tab` lets the browser move focus first and\n * then closes on the next task, and activating an enabled item closes the menu.\n * - `Escape` closes and returns focus to the trigger. While open the menu is a\n * layer on the shared {@link EscapeLayer} stack; it claims a press only while\n * focus is inside the controller or fell to the body, so one keypress closes\n * exactly one layer and a newer layer (e.g. a tooltip shown over an item) is\n * dismissed first.\n * - A click outside the controller closes the menu without moving focus away\n * from the clicked element.\n *\n * Roving focus skips `hidden` and natively `disabled` items. An\n * `aria-disabled=\"true\"` item remains discoverable by arrow-key focus, while its\n * activation is suppressed in the capture phase.\n */\nexport class MenuController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"menu\", \"item\"];\n static actions = [\n \"activate\",\n \"close\",\n \"onItemKeydown\",\n \"onTriggerKeydown\",\n \"open\",\n \"toggle\",\n ] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly itemTargets: HTMLButtonElement[];\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasMenuTarget: boolean;\n\n readonly #timers = new SafeTimeout();\n\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /**\n * The item a click started on, recorded in the capture pass. Read once by the\n * delegated bubble listener, which must not re-derive it: consumer handlers run\n * in between and may detach the item.\n */\n #clickOwner: HTMLButtonElement | null = null;\n\n /** Clicks already turned into an activation, so the two paths run it once. */\n readonly #activated = new WeakSet<Event>();\n\n /** Starts closed and registers activation / outside-click listeners. */\n override connect(): void {\n this.close();\n this.element.addEventListener(\"click\", this.#onItemClickCapture, true);\n this.element.addEventListener(\"click\", this.#onDelegatedItemClick);\n this.element.addEventListener(\"keydown\", this.#onDelegatedItemKeydown);\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n }\n\n /** Releases the listeners, stack membership, and any pending Tab-close task. */\n override disconnect(): void {\n this.#timers.clearAll();\n this.#escapeLayer.deactivate();\n this.element.removeEventListener(\"click\", this.#onItemClickCapture, true);\n this.element.removeEventListener(\"click\", this.#onDelegatedItemClick);\n this.element.removeEventListener(\"keydown\", this.#onDelegatedItemKeydown);\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n }\n\n /** Toggles the menu open/closed. Bound via `data-action` (click). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n this.#focusFirst();\n }\n }\n\n /** Opens the menu and reflects the expanded state on the trigger. */\n open(): void {\n // A reopen must discard a pending Tab close, or the stale task would slam\n // the freshly opened menu shut on the next tick.\n this.#timers.clearAll();\n if (!this.hasMenuTarget) return;\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n this.menuTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n }\n\n /** Closes the menu and reflects the collapsed state on the trigger. */\n close(): void {\n this.#timers.clearAll();\n this.#escapeLayer.deactivate();\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /**\n * Opens the menu with the keyboard per the APG (Down → first, Up → last).\n *\n * Enter/Space are intentionally not handled here: on a native `<button>`\n * trigger the browser turns them into a click, which already runs\n * {@link toggle} (open + focus first item). Handling them again here would\n * open and then immediately re-toggle the menu.\n */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n this.open();\n this.#focusFirst();\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n this.open();\n this.#focusLast();\n }\n }\n\n /** Implements roving focus and closing keys inside the menu. */\n onItemKeydown(event: KeyboardEvent): void {\n this.#handleItemKeydown(event, event.currentTarget);\n }\n\n /**\n * The roving-focus body, shared by the per-element action and its delegated\n * twin. `from` is the item the key belongs to: the bound element for the\n * action, the resolved owner of the event target for the delegate.\n */\n #handleItemKeydown(event: KeyboardEvent, from: EventTarget | null): void {\n // A descendant widget that already claimed the key must not also move the\n // menu's roving focus — composition depends on this yield. It is also what\n // makes the per-element action and the delegated listener idempotent:\n // whichever runs first calls `preventDefault()`, and the other one bows out.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const items = this.#navigableItems;\n const currentIndex = items.indexOf(from as HTMLButtonElement);\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();\n break;\n case \"ArrowUp\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();\n break;\n case \"Home\":\n event.preventDefault();\n items[0]?.focus();\n break;\n case \"End\":\n event.preventDefault();\n items[items.length - 1]?.focus();\n break;\n case \"Tab\":\n // Per APG, Tab closes the menu but focus moves on naturally (it is not\n // returned to the trigger — that is Escape's job). Closing synchronously\n // would remove the focused item before the browser's default Tab action,\n // which can restart traversal at the document head, so the close is\n // deferred to the next task. The key is deliberately left unclaimed, so\n // this is the one branch both handlers can run: rescheduling the same\n // one-shot close is idempotent.\n this.#timers.clearAll();\n this.#timers.set(() => this.close(), 0);\n break;\n default:\n break;\n }\n }\n\n /**\n * Closes the menu after an item is activated. Bound via `data-action`, and\n * also reached from the delegated listener.\n *\n * Markup that carries the per-element action *and* gets the delegate would run\n * this twice for one gesture. `close()` writes the state hooks (`hidden`,\n * `aria-expanded`), and an identical reassign still queues a MutationRecord,\n * so a second pass is observable to anyone watching them. The event is\n * therefore claimed: the path that gets there first does the work, the other\n * one finds it claimed and returns. A programmatic call with no event always\n * runs.\n */\n activate(event?: Event): void {\n if (event !== undefined) {\n if (this.#activated.has(event)) return;\n this.#activated.add(event);\n }\n this.#closeAndRestore();\n }\n\n /** Closes and returns focus to the trigger (Escape / item-activation path). */\n #closeAndRestore(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Closes the menu when a click lands outside the controller's element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (this.#isOpen && !this.element.contains(event.target as Node)) this.close();\n };\n\n /**\n * Delegated twin of {@link onItemKeydown}, bound on the controller element.\n *\n * An item that arrives *after* connect — Overflow Menu moves toolbar controls\n * into this menu at runtime — carries whatever `data-action` its author wrote,\n * and that is exactly the binding a consumer forgets, because the markup that\n * declares the item lives nowhere near the menu. Listening on the container\n * makes membership in the `item` target enough to be operable. The per-element\n * action stays supported and is not double-handled: see\n * {@link #handleItemKeydown}.\n */\n readonly #onDelegatedItemKeydown = (event: KeyboardEvent): void => {\n const item = this.#itemFrom(event.target);\n if (item === null) return; // the trigger and menu chrome own their own keys\n this.#handleItemKeydown(event, item);\n };\n\n /**\n * Delegated twin of {@link activate}, for the same reason as the keydown one.\n *\n * It works from the item the capture pass recorded, not from a fresh lookup:\n * by the time a click bubbles up here, a consumer handler may already have\n * detached the row it lives in, and `itemTargets` is a live query that would\n * then resolve nothing — the activation would be lost and the menu left open\n * over an item that no longer exists. `aria-disabled` items never reach this\n * listener at all: the capture pass stops the click outright.\n */\n readonly #onDelegatedItemClick = (event: MouseEvent): void => {\n const item = this.#clickOwner;\n this.#clickOwner = null;\n if (item === null) return;\n this.activate(event);\n };\n\n /** The item target that is, or contains, `node`; `null` when it is neither. */\n #itemFrom(node: EventTarget | null): HTMLButtonElement | null {\n if (!(node instanceof Node)) return null;\n return this.itemTargets.find((item) => item === node || item.contains(node)) ?? null;\n }\n\n /**\n * First look at every click inside the controller, and the only point at which\n * the DOM is still the one the user clicked. Two jobs: record which item owns\n * the click for the delegate above, and stop an `aria-disabled` command before\n * it reaches consumer handlers (native Enter/Space activation synthesizes a\n * click and is blocked here too).\n */\n readonly #onItemClickCapture = (event: MouseEvent): void => {\n const target = event.target;\n if (!(target instanceof Node)) {\n this.#clickOwner = null;\n return;\n }\n const item = this.#itemFrom(target);\n this.#clickOwner = item;\n if (item === null || item.getAttribute(\"aria-disabled\") !== \"true\") return;\n this.#clickOwner = null;\n event.preventDefault();\n event.stopImmediatePropagation();\n };\n\n /** Moves focus to the first navigable item (no-op if none). */\n #focusFirst(): void {\n this.#navigableItems[0]?.focus();\n }\n\n /** Moves focus to the last navigable item (no-op if none). */\n #focusLast(): void {\n const items = this.#navigableItems;\n items[items.length - 1]?.focus();\n }\n\n /** Menu items eligible for roving focus (excludes disabled / hidden). */\n get #navigableItems(): HTMLButtonElement[] {\n return this.itemTargets.filter((item) => this.#isNavigable(item));\n }\n\n /**\n * An item can take roving focus unless it is `hidden` or a natively `disabled`\n * form control. `aria-disabled` remains focusable for discoverability; capture\n * suppresses activation. CSS-only visibility is the consumer's responsibility.\n */\n #isNavigable(item: HTMLButtonElement): boolean {\n if (item.hasAttribute(\"hidden\")) return false;\n return !item.disabled;\n }\n\n /** Whether the menu is currently visible. */\n get #isOpen(): boolean {\n return this.hasMenuTarget && !this.menuTarget.hidden;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/arrow_step.ts","../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/controllers/menu_controller.ts"],"names":[],"mappings":"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","file":"menu_controller.js","sourcesContent":["import { isRtl } from \"./logical_scroll\";\n\n/**\n * Turns an arrow key into a **logical** step: `+1` for \"next\", `-1` for\n * \"previous\", `0` when the key names neither.\n *\n * APG defines the horizontal pair as *next / previous* and says a vertical\n * arrangement swaps in Down/Up for the same meaning — so the pair is one axis's\n * spelling of an order, and the order reverses with the writing direction. Only\n * the horizontal pair reverses. Down/Up name an axis the writing direction does\n * not mirror, and returning them unchanged is the point: many controllers fold\n * both pairs into one branch, where swapping the branches under RTL would flip\n * the vertical axis too — a bug that reads as \"the arrows work\" until someone\n * presses Down.\n *\n * **Direction is read from the element the caller passes, which should be the\n * container that lays the items out** — not the focused child. A child may carry\n * its own `dir` (an LTR input inside an RTL form is ordinary authoring), and\n * probing per handler makes two handlers disagree at the boundary between them.\n *\n * This decides direction only. Whether the axis is even active (an\n * `orientation=\"horizontal\"` widget ignoring Down/Up), how far the step lands,\n * and what wrapping does all stay with the caller.\n *\n * **It encodes the list-order convention: `ArrowDown` is *next*.** Widgets that\n * pair the arrows by *value* instead — `ArrowUp` meaning \"more\", as a rating or a\n * slider does — must not use this, or their vertical axis inverts. Reverse the\n * horizontal pair on its own there.\n *\n * @example\n * ```ts\n * const step = logicalArrowStep(event.key, this.element);\n * if (step === 0) return;\n * this.#roving.setActive(rovingMove(current, length, step, \"wrap\"), { focus: true });\n * ```\n */\nexport function logicalArrowStep(key: string, element: Element): 1 | -1 | 0 {\n if (key === \"ArrowDown\") return 1;\n if (key === \"ArrowUp\") return -1;\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return 0;\n const forward = isRtl(element) ? \"ArrowLeft\" : \"ArrowRight\";\n return key === forward ? 1 : -1;\n}\n\n/**\n * Rewrites `key` so an existing LTR-shaped branch keeps working under RTL:\n * `ArrowRight` and `ArrowLeft` trade places, everything else passes through.\n *\n * The alternative — negating a delta — silently breaks handlers whose two\n * horizontal branches are **not mirror images**. A grid that clamps one edge but\n * not the other, or a segmented field guarding `index > 0` on one side and\n * `index < length - 1` on the other, ends up applying the wrong guard to the\n * wrong direction. Swapping the key leaves each branch, guards and all, exactly\n * where its author put it.\n *\n * Same rule as {@link logicalArrowStep} about which element to read: pass the\n * container that lays the items out, not the focused child.\n *\n * @example\n * ```ts\n * switch (logicalArrowKey(event.key, this.element)) {\n * case \"ArrowLeft\": // \"previous\" — whatever direction that is on screen\n * ```\n */\nexport function logicalArrowKey(key: string, element: Element): string {\n if (key !== \"ArrowRight\" && key !== \"ArrowLeft\") return key;\n if (!isRtl(element)) return key;\n return key === \"ArrowRight\" ? \"ArrowLeft\" : \"ArrowRight\";\n}\n\n/** Modifiers a widget may claim on an arrow key, named for the `allow` list. */\nexport type ArrowModifier = \"alt\" | \"ctrl\" | \"meta\" | \"shift\";\n\n/**\n * True when an arrow key arrived carrying a modifier the widget must leave to\n * the browser: return without calling `preventDefault()` and without moving any\n * state.\n *\n * A bare arrow belongs to the widget; a chorded one usually does not.\n * `Alt`/`Meta` plus a horizontal arrow is history back/forward on every desktop\n * browser, and a widget that swallows it makes the shortcut work or not\n * depending on where focus happens to sit — a coin-flip the user cannot see.\n *\n * `allow` is for the combinations APG assigns to a pattern **and the widget\n * actually implements** — today only Combobox's optional `Alt+Down`/`Alt+Up`.\n * Listing one the widget does not implement defeats the point: the chord then\n * runs the plain-arrow branch, which is exactly what this guard exists to stop.\n * Non-arrow keys return `false`, so chorded letters and\n * `Control+Home`/`Control+End` are untouched.\n *\n * @example\n * ```ts\n * if (isReservedArrowChord(event)) return;\n * ```\n */\nexport function isReservedArrowChord(\n event: KeyboardEvent,\n allow: readonly ArrowModifier[] = [],\n): boolean {\n if (!event.key.startsWith(\"Arrow\")) return false;\n return (\n (event.altKey && !allow.includes(\"alt\")) ||\n (event.ctrlKey && !allow.includes(\"ctrl\")) ||\n (event.metaKey && !allow.includes(\"meta\")) ||\n (event.shiftKey && !allow.includes(\"shift\"))\n );\n}\n\n/**\n * True when a press arrived carrying any modifier, for the keys the browser and\n * the OS own outright: return without calling `preventDefault()` and without\n * moving any state.\n *\n * `Control+Home` and `Control+End` jump the document to its ends, and a widget\n * that swallows them makes the shortcut work or not depending on where focus\n * happens to sit. {@link isReservedArrowChord} answers the same question for the\n * arrows, but returns `false` for every other key so that this one can decide.\n *\n * There is no `allow` list here on purpose. APG assigns no modifier chord to\n * `Home`/`End`, so a widget that wanted one would be claiming a combination the\n * pattern never gave it.\n *\n * @example\n * ```ts\n * case \"Home\":\n * case \"End\":\n * if (hasModifierChord(event)) return;\n * ```\n */\nexport function hasModifierChord(event: KeyboardEvent): boolean {\n return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;\n}\n","/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest handler resolves the\n * press. A keydown that is part of an IME composition (`event.isComposing`)\n * cancels the composition, never a layer, and is ignored here for every layer\n * at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { isReservedArrowChord } from \"../utils/arrow_step\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless, accessible menu button behavior.\n *\n * Markup contract (identifier: `stimeo--menu`):\n * <div data-controller=\"stimeo--menu\">\n * <button id=\"menu-trigger\" data-stimeo--menu-target=\"trigger\"\n * data-action=\"click->stimeo--menu#toggle\n * keydown->stimeo--menu#onTriggerKeydown\"\n * aria-haspopup=\"menu\" aria-expanded=\"false\" aria-controls=\"menu\">\n * Actions\n * </button>\n * <ul id=\"menu\" role=\"menu\" aria-labelledby=\"menu-trigger\"\n * data-stimeo--menu-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\"\n * data-stimeo--menu-target=\"item\">…</button>\n * </li>\n * </ul>\n * </div>\n *\n * The trigger's two actions are required. Items need none: their click and key\n * handling is delegated from the controller element, so an item added, moved, or\n * server-rendered after connect works without the consumer wiring anything onto\n * it. The per-element form (`click->stimeo--menu#activate` /\n * `keydown->stimeo--menu#onItemKeydown`) is supported alongside it. A\n * focus-moving key runs once (the action claims it and the delegate stands\n * down), and `activate`, reachable on both paths, claims the click so one\n * gesture activates once. See the delegated click listener below.\n *\n * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu\n * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),\n * this is a true `role=\"menu\"` widget with roving focus across `role=\"menuitem\"`\n * children.\n *\n * @remarks\n * Behavior only — the core controller owns no placement. Consumers can use static\n * CSS or compose the menu with the opt-in `stimeo-ui/positioning` entrypoint for\n * viewport-aware placement. State is exposed via `aria-expanded` and the menu's\n * `hidden` attribute.\n *\n * Behavior provided:\n * - Click the trigger to toggle; `ArrowDown`/`ArrowUp` open and focus the\n * first/last item.\n * - Within the menu, `ArrowDown`/`ArrowUp` move focus (wrapping), `Home`/`End`\n * jump to the first/last item, `Tab` lets the browser move focus first and\n * then closes on the next task, and activating an enabled item closes the menu.\n * - `Escape` closes and returns focus to the trigger. While open the menu is a\n * layer on the shared {@link EscapeLayer} stack; it claims a press only while\n * focus is inside the controller or fell to the body, so one keypress closes\n * exactly one layer and a newer layer (e.g. a tooltip shown over an item) is\n * dismissed first.\n * - A click outside the controller closes the menu without moving focus away\n * from the clicked element.\n *\n * Roving focus skips `hidden` and natively `disabled` items. An\n * `aria-disabled=\"true\"` item remains discoverable by arrow-key focus, while its\n * activation is suppressed in the capture phase.\n */\nexport class MenuController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"menu\", \"item\"];\n static actions = [\n \"activate\",\n \"close\",\n \"onItemKeydown\",\n \"onTriggerKeydown\",\n \"open\",\n \"toggle\",\n ] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly itemTargets: HTMLButtonElement[];\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasMenuTarget: boolean;\n\n readonly #timers = new SafeTimeout();\n\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /**\n * The item a click started on, recorded in the capture pass. Read once by the\n * delegated bubble listener, which must not re-derive it: consumer handlers run\n * in between and may detach the item.\n */\n #clickOwner: HTMLButtonElement | null = null;\n\n /** Clicks already turned into an activation, so the two paths run it once. */\n readonly #activated = new WeakSet<Event>();\n\n /** Starts closed and registers activation / outside-click listeners. */\n override connect(): void {\n this.close();\n this.element.addEventListener(\"click\", this.#onItemClickCapture, true);\n this.element.addEventListener(\"click\", this.#onDelegatedItemClick);\n this.element.addEventListener(\"keydown\", this.#onDelegatedItemKeydown);\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n }\n\n /** Releases the listeners, stack membership, and any pending Tab-close task. */\n override disconnect(): void {\n this.#timers.clearAll();\n this.#escapeLayer.deactivate();\n this.element.removeEventListener(\"click\", this.#onItemClickCapture, true);\n this.element.removeEventListener(\"click\", this.#onDelegatedItemClick);\n this.element.removeEventListener(\"keydown\", this.#onDelegatedItemKeydown);\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n }\n\n /** Toggles the menu open/closed. Bound via `data-action` (click). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n this.#focusFirst();\n }\n }\n\n /** Opens the menu and reflects the expanded state on the trigger. */\n open(): void {\n // A reopen must discard a pending Tab close, or the stale task would slam\n // the freshly opened menu shut on the next tick.\n this.#timers.clearAll();\n if (!this.hasMenuTarget) return;\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n this.menuTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n }\n\n /** Closes the menu and reflects the collapsed state on the trigger. */\n close(): void {\n this.#timers.clearAll();\n this.#escapeLayer.deactivate();\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /**\n * Opens the menu with the keyboard per the APG (Down → first, Up → last).\n *\n * Enter/Space are intentionally not handled here: on a native `<button>`\n * trigger the browser turns them into a click, which already runs\n * {@link toggle} (open + focus first item). Handling them again here would\n * open and then immediately re-toggle the menu.\n */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n this.open();\n this.#focusFirst();\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n this.open();\n this.#focusLast();\n }\n }\n\n /** Implements roving focus and closing keys inside the menu. */\n onItemKeydown(event: KeyboardEvent): void {\n this.#handleItemKeydown(event, event.currentTarget);\n }\n\n /**\n * The roving-focus body, shared by the per-element action and its delegated\n * twin. `from` is the item the key belongs to: the bound element for the\n * action, the resolved owner of the event target for the delegate.\n */\n #handleItemKeydown(event: KeyboardEvent, from: EventTarget | null): void {\n // A descendant widget that already claimed the key must not also move the\n // menu's roving focus — composition depends on this yield. It is also what\n // makes the per-element action and the delegated listener idempotent:\n // whichever runs first calls `preventDefault()`, and the other one bows out.\n if (event.defaultPrevented) return;\n if (isReservedArrowChord(event)) return;\n const items = this.#navigableItems;\n const currentIndex = items.indexOf(from as HTMLButtonElement);\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();\n break;\n case \"ArrowUp\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();\n break;\n case \"Home\":\n event.preventDefault();\n items[0]?.focus();\n break;\n case \"End\":\n event.preventDefault();\n items[items.length - 1]?.focus();\n break;\n case \"Tab\":\n // Per APG, Tab closes the menu but focus moves on naturally (it is not\n // returned to the trigger — that is Escape's job). Closing synchronously\n // would remove the focused item before the browser's default Tab action,\n // which can restart traversal at the document head, so the close is\n // deferred to the next task. The key is deliberately left unclaimed, so\n // this is the one branch both handlers can run: rescheduling the same\n // one-shot close is idempotent.\n this.#timers.clearAll();\n this.#timers.set(() => this.close(), 0);\n break;\n default:\n break;\n }\n }\n\n /**\n * Closes the menu after an item is activated. Bound via `data-action`, and\n * also reached from the delegated listener.\n *\n * Markup that carries the per-element action *and* gets the delegate would run\n * this twice for one gesture. `close()` writes the state hooks (`hidden`,\n * `aria-expanded`), and an identical reassign still queues a MutationRecord,\n * so a second pass is observable to anyone watching them. The event is\n * therefore claimed: the path that gets there first does the work, the other\n * one finds it claimed and returns. A programmatic call with no event always\n * runs.\n */\n activate(event?: Event): void {\n if (event !== undefined) {\n if (this.#activated.has(event)) return;\n this.#activated.add(event);\n }\n this.#closeAndRestore();\n }\n\n /** Closes and returns focus to the trigger (Escape / item-activation path). */\n #closeAndRestore(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Closes the menu when a click lands outside the controller's element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (this.#isOpen && !this.element.contains(event.target as Node)) this.close();\n };\n\n /**\n * Delegated twin of {@link onItemKeydown}, bound on the controller element.\n *\n * An item that arrives *after* connect — Overflow Menu moves toolbar controls\n * into this menu at runtime — carries whatever `data-action` its author wrote,\n * and that is exactly the binding a consumer forgets, because the markup that\n * declares the item lives nowhere near the menu. Listening on the container\n * makes membership in the `item` target enough to be operable. The per-element\n * action stays supported and is not double-handled: see\n * {@link #handleItemKeydown}.\n */\n readonly #onDelegatedItemKeydown = (event: KeyboardEvent): void => {\n const item = this.#itemFrom(event.target);\n if (item === null) return; // the trigger and menu chrome own their own keys\n this.#handleItemKeydown(event, item);\n };\n\n /**\n * Delegated twin of {@link activate}, for the same reason as the keydown one.\n *\n * It works from the item the capture pass recorded, not from a fresh lookup:\n * by the time a click bubbles up here, a consumer handler may already have\n * detached the row it lives in, and `itemTargets` is a live query that would\n * then resolve nothing — the activation would be lost and the menu left open\n * over an item that no longer exists. `aria-disabled` items never reach this\n * listener at all: the capture pass stops the click outright.\n */\n readonly #onDelegatedItemClick = (event: MouseEvent): void => {\n const item = this.#clickOwner;\n this.#clickOwner = null;\n if (item === null) return;\n this.activate(event);\n };\n\n /** The item target that is, or contains, `node`; `null` when it is neither. */\n #itemFrom(node: EventTarget | null): HTMLButtonElement | null {\n if (!(node instanceof Node)) return null;\n return this.itemTargets.find((item) => item === node || item.contains(node)) ?? null;\n }\n\n /**\n * First look at every click inside the controller, and the only point at which\n * the DOM is still the one the user clicked. Two jobs: record which item owns\n * the click for the delegate above, and stop an `aria-disabled` command before\n * it reaches consumer handlers (native Enter/Space activation synthesizes a\n * click and is blocked here too).\n */\n readonly #onItemClickCapture = (event: MouseEvent): void => {\n const target = event.target;\n if (!(target instanceof Node)) {\n this.#clickOwner = null;\n return;\n }\n const item = this.#itemFrom(target);\n this.#clickOwner = item;\n if (item === null || item.getAttribute(\"aria-disabled\") !== \"true\") return;\n this.#clickOwner = null;\n event.preventDefault();\n event.stopImmediatePropagation();\n };\n\n /** Moves focus to the first navigable item (no-op if none). */\n #focusFirst(): void {\n this.#navigableItems[0]?.focus();\n }\n\n /** Moves focus to the last navigable item (no-op if none). */\n #focusLast(): void {\n const items = this.#navigableItems;\n items[items.length - 1]?.focus();\n }\n\n /** Menu items eligible for roving focus (excludes disabled / hidden). */\n get #navigableItems(): HTMLButtonElement[] {\n return this.itemTargets.filter((item) => this.#isNavigable(item));\n }\n\n /**\n * An item can take roving focus unless it is `hidden` or a natively `disabled`\n * form control. `aria-disabled` remains focusable for discoverability; capture\n * suppresses activation. CSS-only visibility is the consumer's responsibility.\n */\n #isNavigable(item: HTMLButtonElement): boolean {\n if (item.hasAttribute(\"hidden\")) return false;\n return !item.disabled;\n }\n\n /** Whether the menu is currently visible. */\n get #isOpen(): boolean {\n return this.hasMenuTarget && !this.menuTarget.hidden;\n }\n}\n"]}
@@ -629,8 +629,8 @@ var MenubarController = class extends Controller {
629
629
  * being hidden itself, a menu can stay visible after its owning top was removed,
630
630
  * an open pair can appear from a morph with no layer registered for it, and the
631
631
  * Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
632
- * all. Nothing is remembered between calls (except which side of the Escape stack
633
- * this layer is on, which the stack itself does not expose), so the outcome is the
632
+ * all. The only state carried between calls is the tracked focus record and which side
633
+ * of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
634
634
  * same whichever mutation arrived and calling it more often than needed is free.
635
635
  */
636
636
  #reconcile() {