stimeo-ui 0.4.0 → 0.5.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 (123) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/controllers/announcer_controller.js.map +1 -1
  3. package/dist/controllers/aspect_ratio_controller.d.ts +2 -2
  4. package/dist/controllers/aspect_ratio_controller.js +1 -1
  5. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  6. package/dist/controllers/breadcrumb_controller.js +5 -1
  7. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  8. package/dist/controllers/carousel_controller.js +5 -1
  9. package/dist/controllers/carousel_controller.js.map +1 -1
  10. package/dist/controllers/clipboard_controller.js +8 -3
  11. package/dist/controllers/clipboard_controller.js.map +1 -1
  12. package/dist/controllers/collapsible_controller.d.ts +1 -1
  13. package/dist/controllers/collapsible_controller.js +4 -1
  14. package/dist/controllers/collapsible_controller.js.map +1 -1
  15. package/dist/controllers/color_picker_controller.d.ts +2 -2
  16. package/dist/controllers/color_picker_controller.js +6 -2
  17. package/dist/controllers/color_picker_controller.js.map +1 -1
  18. package/dist/controllers/combobox_controller.js.map +1 -1
  19. package/dist/controllers/command_palette_controller.js.map +1 -1
  20. package/dist/controllers/context_menu_controller.d.ts +1 -1
  21. package/dist/controllers/context_menu_controller.js +2 -2
  22. package/dist/controllers/context_menu_controller.js.map +1 -1
  23. package/dist/controllers/countdown_controller.d.ts +5 -3
  24. package/dist/controllers/countdown_controller.js +5 -1
  25. package/dist/controllers/countdown_controller.js.map +1 -1
  26. package/dist/controllers/date_range_picker_controller.js +5 -1
  27. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  28. package/dist/controllers/direct_upload_controller.d.ts +1 -1
  29. package/dist/controllers/direct_upload_controller.js +3 -3
  30. package/dist/controllers/direct_upload_controller.js.map +1 -1
  31. package/dist/controllers/empty_state_controller.d.ts +27 -7
  32. package/dist/controllers/empty_state_controller.js +107 -16
  33. package/dist/controllers/empty_state_controller.js.map +1 -1
  34. package/dist/controllers/flash_controller.d.ts +25 -5
  35. package/dist/controllers/flash_controller.js +161 -21
  36. package/dist/controllers/flash_controller.js.map +1 -1
  37. package/dist/controllers/form_validation_controller.js +8 -2
  38. package/dist/controllers/form_validation_controller.js.map +1 -1
  39. package/dist/controllers/frame_loading_controller.d.ts +19 -2
  40. package/dist/controllers/frame_loading_controller.js +94 -22
  41. package/dist/controllers/frame_loading_controller.js.map +1 -1
  42. package/dist/controllers/highlight_controller.d.ts +8 -4
  43. package/dist/controllers/highlight_controller.js +38 -1
  44. package/dist/controllers/highlight_controller.js.map +1 -1
  45. package/dist/controllers/idle_controller.d.ts +2 -1
  46. package/dist/controllers/idle_controller.js +13 -2
  47. package/dist/controllers/idle_controller.js.map +1 -1
  48. package/dist/controllers/listbox_controller.js.map +1 -1
  49. package/dist/controllers/local_time_controller.js +2 -0
  50. package/dist/controllers/local_time_controller.js.map +1 -1
  51. package/dist/controllers/masonry_controller.d.ts +1 -1
  52. package/dist/controllers/masonry_controller.js +1 -1
  53. package/dist/controllers/masonry_controller.js.map +1 -1
  54. package/dist/controllers/meter_controller.js +3 -1
  55. package/dist/controllers/meter_controller.js.map +1 -1
  56. package/dist/controllers/multi_select_controller.js.map +1 -1
  57. package/dist/controllers/network_status_controller.d.ts +15 -13
  58. package/dist/controllers/network_status_controller.js +1 -3
  59. package/dist/controllers/network_status_controller.js.map +1 -1
  60. package/dist/controllers/number_input_controller.d.ts +8 -0
  61. package/dist/controllers/number_input_controller.js +191 -24
  62. package/dist/controllers/number_input_controller.js.map +1 -1
  63. package/dist/controllers/overflow_menu_controller.js +1 -1
  64. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  65. package/dist/controllers/pagination_controller.js +5 -1
  66. package/dist/controllers/pagination_controller.js.map +1 -1
  67. package/dist/controllers/password_strength_controller.d.ts +1 -1
  68. package/dist/controllers/password_strength_controller.js +1 -1
  69. package/dist/controllers/password_strength_controller.js.map +1 -1
  70. package/dist/controllers/pointer_drag_controller.js +10 -0
  71. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  72. package/dist/controllers/portal_controller.js +10 -0
  73. package/dist/controllers/portal_controller.js.map +1 -1
  74. package/dist/controllers/progress_controller.d.ts +1 -1
  75. package/dist/controllers/progress_controller.js +7 -3
  76. package/dist/controllers/progress_controller.js.map +1 -1
  77. package/dist/controllers/range_slider_controller.d.ts +23 -0
  78. package/dist/controllers/range_slider_controller.js +385 -94
  79. package/dist/controllers/range_slider_controller.js.map +1 -1
  80. package/dist/controllers/rating_controller.js +2 -0
  81. package/dist/controllers/rating_controller.js.map +1 -1
  82. package/dist/controllers/relative_time_controller.js +2 -0
  83. package/dist/controllers/relative_time_controller.js.map +1 -1
  84. package/dist/controllers/scroll_area_controller.d.ts +1 -1
  85. package/dist/controllers/scroll_area_controller.js +1 -1
  86. package/dist/controllers/scroll_area_controller.js.map +1 -1
  87. package/dist/controllers/separator_controller.js +13 -17
  88. package/dist/controllers/separator_controller.js.map +1 -1
  89. package/dist/controllers/skeleton_controller.d.ts +2 -2
  90. package/dist/controllers/skeleton_controller.js +71 -3
  91. package/dist/controllers/skeleton_controller.js.map +1 -1
  92. package/dist/controllers/slider_controller.d.ts +17 -1
  93. package/dist/controllers/slider_controller.js +325 -48
  94. package/dist/controllers/slider_controller.js.map +1 -1
  95. package/dist/controllers/spinner_controller.d.ts +15 -10
  96. package/dist/controllers/spinner_controller.js +18 -3
  97. package/dist/controllers/spinner_controller.js.map +1 -1
  98. package/dist/controllers/step_indicator_controller.d.ts +1 -1
  99. package/dist/controllers/step_indicator_controller.js +3 -1
  100. package/dist/controllers/step_indicator_controller.js.map +1 -1
  101. package/dist/controllers/stepper_controller.js +2 -0
  102. package/dist/controllers/stepper_controller.js.map +1 -1
  103. package/dist/controllers/switch_controller.d.ts +12 -8
  104. package/dist/controllers/switch_controller.js +162 -18
  105. package/dist/controllers/switch_controller.js.map +1 -1
  106. package/dist/controllers/textarea_autosize_controller.js +1 -1
  107. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  108. package/dist/controllers/time_picker_controller.d.ts +3 -0
  109. package/dist/controllers/time_picker_controller.js +6 -3
  110. package/dist/controllers/time_picker_controller.js.map +1 -1
  111. package/dist/controllers/tree_view_controller.d.ts +1 -2
  112. package/dist/controllers/tree_view_controller.js +19 -1
  113. package/dist/controllers/tree_view_controller.js.map +1 -1
  114. package/dist/index.js +1214 -307
  115. package/dist/index.js.map +1 -1
  116. package/dist/inspector/cli.d.ts +76 -1
  117. package/dist/inspector/cli.js +152 -0
  118. package/dist/inspector/cli.js.map +1 -1
  119. package/dist/inspector/cli_bin.js +212 -2
  120. package/dist/inspector/cli_bin.js.map +1 -1
  121. package/dist/inspector/examples.json +17 -17
  122. package/dist/inspector/manifest.json +408 -26
  123. package/package.json +3 -3
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/announce.ts","../../src/controllers/empty_state_controller.ts"],"names":[],"mappings":"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","file":"empty_state_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\n\n/**\n * Headless empty-state behavior: shows an \"empty\" placeholder when a list has no\n * items and hides it once one arrives (and vice-versa), watching the list with a\n * `MutationObserver` (no dedicated APG pattern; follows the WCAG \"status messages\"\n * practice when announcing).\n *\n * Markup contract (identifier: `stimeo--empty-state`):\n * <div data-controller=\"stimeo--empty-state\">\n * <ul data-stimeo--empty-state-target=\"list\"><!-- Turbo Stream rows --></ul>\n * <p data-stimeo--empty-state-target=\"empty\" hidden>No items</p>\n * </div>\n *\n * Counts `list`'s child items (all element children, or those matching\n * `itemSelector`) on connect and on every childList mutation, toggles `hidden` on\n * `list` / `empty` at the 0 ↔ 1+ boundary, and reflects `data-empty` / `data-count`\n * on the controller element. Crossing the boundary dispatches `change`.\n *\n * @remarks\n * Behavior only — the placeholder's look/copy is the consumer's. State is derived\n * from the DOM (no module-scope state), so `connect()` re-syncs after a Turbo\n * Stream insertion. The `MutationObserver` is severed on `disconnect()` (Turbo\n * navigation included). With `announce`, the `empty` target is made a polite live\n * region (only if the author hasn't already), so showing it is announced — SR\n * support for unhiding a live region varies; pair with Announcer for a guarantee.\n *\n * Ownership note: this controller deliberately owns `hidden` on the `list` / `empty`\n * targets as its single source of truth for which one is shown, rather than only\n * emitting `data-empty` and delegating visibility to consumer CSS. The toggle is\n * unconditional (set every sync), so there is nothing to save/restore and no\n * authored `hidden` to preserve — the displayed half is always a pure function of\n * the item count. Consumers wanting CSS-driven visibility should not also set\n * `hidden` on these targets themselves.\n */\nexport class EmptyStateController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"empty\"];\n static override values = {\n itemSelector: { type: String, default: \"\" },\n announceText: { type: String, default: \"\" },\n announceFilledText: { type: String, default: \"\" },\n };\n static events = [\"change\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly emptyTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasEmptyTarget: boolean;\n\n declare itemSelectorValue: string;\n declare announceTextValue: string;\n declare announceFilledTextValue: string;\n\n #observer: MutationObserver | null = null;\n /** Last applied empty state; `null` until the first sync so connect emits nothing. */\n #empty: boolean | null = null;\n\n override connect(): void {\n if (!this.hasListTarget) return;\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#apply());\n this.#observer.observe(this.listTarget, { childList: true });\n }\n this.#apply();\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /** Recomputes the count and syncs visibility, hooks, and the change event. */\n #apply(): void {\n if (!this.hasListTarget) return;\n const count = this.#count();\n const empty = count === 0;\n\n this.element.setAttribute(\"data-count\", String(count));\n if (empty) {\n this.element.setAttribute(\"data-empty\", \"true\");\n } else {\n this.element.removeAttribute(\"data-empty\");\n }\n this.listTarget.hidden = empty;\n if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;\n\n // Emit only when the 0 ↔ 1+ boundary is crossed (not on the initial sync, and\n // not for count changes that stay non-empty, e.g. 2 → 3).\n if (this.#empty !== null && empty !== this.#empty) {\n this.dispatch(\"change\", { detail: { count, empty } });\n // The crossing is the news, and the page's announcer reads it: a region that\n // only becomes live when the empty state appears is not reliably announced.\n announce(\n fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count }),\n );\n }\n this.#empty = empty;\n }\n\n /** Item count: element children matching `itemSelector`, or all element children. */\n #count(): number {\n const selector = this.itemSelectorValue;\n if (selector.length === 0) return this.listTarget.childElementCount;\n try {\n return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;\n } catch {\n // An invalid selector (author typo) must not crash the controller — count all.\n return this.listTarget.childElementCount;\n }\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/announce.ts","../../src/controllers/empty_state_controller.ts"],"names":[],"mappings":"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","file":"empty_state_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce, fillTemplate } from \"../utils/announce\";\n\n/**\n * Headless empty-state behavior: shows an \"empty\" placeholder when a list has no\n * items and hides it once one arrives (and vice-versa), watching the list with a\n * `MutationObserver` (no dedicated APG pattern; follows the WCAG \"status messages\"\n * practice when announcing).\n *\n * Markup contract (identifier: `stimeo--empty-state`):\n * <div data-controller=\"stimeo--empty-state\">\n * <ul data-stimeo--empty-state-target=\"list\"><!-- Turbo Stream rows --></ul>\n * <p data-stimeo--empty-state-target=\"empty\" hidden>No items</p>\n * </div>\n *\n * Counts `list`'s child items (all element children, or those matching\n * `itemSelector`) on connect and on every mutation that can change that count,\n * toggles `hidden` on `list` / `empty` at the 0 ↔ 1+ boundary, and reflects\n * `data-empty` / `data-count` on the controller element. Crossing the boundary\n * dispatches `change` after the display is updated, so a listener reads the\n * state the crossing produced.\n *\n * @remarks\n * Behavior only — the placeholder's look/copy is the consumer's. State is derived\n * from the DOM (no module-scope state), so `connect()` re-syncs after a Turbo\n * Stream insertion. Both targets are re-resolved at runtime: a `list` element\n * swapped in re-points the observation, and a swapped-in `empty` element is\n * re-synced — including when the replacement is inserted while the original is\n * still in the document. The `MutationObserver` is severed on `disconnect()`\n * (Turbo navigation included). `announceText` / `announceFilledText` are read at\n * the crossing and handed to the page's announcer.\n *\n * Ownership note: this controller deliberately owns `hidden` on the `list` / `empty`\n * targets as its single source of truth for which one is shown, rather than only\n * emitting `data-empty` and delegating visibility to consumer CSS. The toggle is\n * unconditional (set every sync), so there is nothing to save/restore and no\n * authored `hidden` to preserve — the displayed half is always a pure function of\n * the item count. Consumers wanting CSS-driven visibility should not also set\n * `hidden` on these targets themselves.\n */\nexport class EmptyStateController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"empty\"];\n static override values = {\n itemSelector: { type: String, default: \"\" },\n announceText: { type: String, default: \"\" },\n announceFilledText: { type: String, default: \"\" },\n };\n static events = [\"change\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly emptyTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasEmptyTarget: boolean;\n\n declare itemSelectorValue: string;\n declare announceTextValue: string;\n declare announceFilledTextValue: string;\n\n #observer: MutationObserver | null = null;\n /** Whether the controller is between `connect()` and `disconnect()`. */\n #connected = false;\n /** Last applied empty state; `null` until the first sync so connect emits nothing. */\n #empty: boolean | null = null;\n\n override connect(): void {\n this.#connected = true;\n this.#syncObservation();\n this.#update();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#stopObserving();\n }\n\n /** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */\n listTargetConnected(): void {\n this.#resync();\n }\n\n /** Releases the observation when the `list` element leaves the target set. */\n listTargetDisconnected(): void {\n this.#resync();\n }\n\n /** Syncs an `empty` element that arrives — or is replaced — at runtime. */\n emptyTargetConnected(): void {\n this.#resync();\n }\n\n /**\n * Syncs the `empty` element that remains when one leaves the target set. A\n * single-target getter resolves to the first `empty` element in document order,\n * so a swap that inserts the replacement *before* removing the original (Turbo\n * Stream `after` / `before` / `append` followed by `remove`) leaves the\n * replacement untouched until the original goes — this callback is that moment.\n */\n emptyTargetDisconnected(): void {\n this.#resync();\n }\n\n /** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */\n itemSelectorValueChanged(): void {\n this.#resync();\n }\n\n /**\n * Re-points the observation and re-renders after a target or selector change.\n * The `#connected` guard is load-bearing: Stimulus runs value and target\n * callbacks for the initial markup *before* `connect()` and runs target\n * callbacks during teardown *after* `disconnect()`, and re-observing there\n * would outlive the controller.\n */\n #resync(): void {\n if (!this.#connected) return;\n this.#syncObservation();\n this.#update();\n }\n\n /**\n * Points the mutation observation at the current `list` target — re-resolved on\n * every sync rather than captured at connect, so an element swapped in at\n * runtime is observed instead of the detached original.\n *\n * The observation covers exactly what the count predicate reads. With no\n * `itemSelector` the count is the child element count, which only `childList`\n * can change. With one, the predicate reads the children themselves, so\n * attribute and descendant mutations are watched too — and the controller's own\n * writes are filtered back out, or toggling `hidden` would re-enter the render.\n */\n #syncObservation(): void {\n this.#stopObserving();\n if (!this.hasListTarget || typeof MutationObserver === \"undefined\") return;\n const watchesItems = this.itemSelectorValue.length > 0;\n this.#observer = new MutationObserver((records) => {\n if (records.some((record) => this.#affectsCount(record))) this.#update();\n });\n this.#observer.observe(this.listTarget, {\n childList: true,\n subtree: watchesItems,\n attributes: watchesItems,\n });\n }\n\n #stopObserving(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /**\n * Whether a mutation can change the item count. An attribute written on a\n * target this controller owns is its own echo — `hidden` on `list` / `empty`,\n * and the hooks on the controller element when the list *is* that element.\n */\n #affectsCount(record: MutationRecord): boolean {\n if (record.type !== \"attributes\") return true;\n const own =\n record.target === this.listTarget ||\n (this.hasEmptyTarget && record.target === this.emptyTarget);\n return !own;\n }\n\n /**\n * Renders the count, then reports a crossed boundary. The announcement copy is\n * read here rather than while rendering: it is the wording of the report, not\n * an input to what is displayed.\n */\n #update(): void {\n const count = this.#render();\n if (count === null) return;\n const empty = count === 0;\n // Report only when the 0 ↔ 1+ boundary is crossed (not on the initial sync,\n // and not for count changes that stay non-empty, e.g. 2 → 3).\n const crossed = this.#empty !== null && empty !== this.#empty;\n this.#empty = empty;\n if (!crossed) return;\n\n this.dispatch(\"change\", { detail: { count, empty } });\n // The crossing is the news, and the page's announcer reads it: a region that\n // only becomes live when the empty state appears is not reliably announced.\n announce(\n fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count }),\n );\n }\n\n /**\n * Syncs visibility and the state hooks to the current item count, and returns\n * it. `null` when there is no `list` target to count.\n *\n * @stimeoRenderRoot\n */\n #render(): number | null {\n if (!this.hasListTarget) return null;\n const count = this.#count();\n const empty = count === 0;\n\n this.element.setAttribute(\"data-count\", String(count));\n if (empty) {\n this.element.setAttribute(\"data-empty\", \"true\");\n } else {\n this.element.removeAttribute(\"data-empty\");\n }\n this.listTarget.hidden = empty;\n if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;\n return count;\n }\n\n /** Item count: element children matching `itemSelector`, or all element children. */\n #count(): number {\n const selector = this.itemSelectorValue;\n if (selector.length === 0) return this.listTarget.childElementCount;\n try {\n return Array.from(this.listTarget.children).filter((child) => child.matches(selector)).length;\n } catch {\n // An invalid selector (author typo) must not crash the controller — count all.\n return this.listTarget.childElementCount;\n }\n }\n}\n"]}
@@ -15,9 +15,9 @@ import { Controller } from '@hotwired/stimulus';
15
15
  *
16
16
  * Each message is mapped by `data-flash-type` to `role="status"` (notice) or
17
17
  * `role="alert"` (alert/error), flagged `data-flash-state="visible"`, auto-dismissed
18
- * after `duration` (paused while hovered/focused when `pauseOnHover`), and capped at
19
- * `max` simultaneous messages. A close button wired to the `dismiss` action removes
20
- * one manually.
18
+ * after `duration` (paused while hovered *or* focused when `pauseOnHover`, and
19
+ * resumed only once both are released), and capped at `max` simultaneous messages.
20
+ * A close button wired to the `dismiss` action removes one manually.
21
21
  *
22
22
  * @remarks
23
23
  * Reading is **delegated to the shared Announcer** — but only for the *initial*,
@@ -26,8 +26,14 @@ import { Controller } from '@hotwired/stimulus';
26
26
  * event. Messages inserted *later* (Turbo Stream) are announced by their own freshly
27
27
  * inserted `role`, exactly like Toast, so they are not bridged again (no double
28
28
  * announcement). Behavior only — no styling; `data-flash-state="leaving"` lets CSS
29
- * animate removal. Focus is never moved (WCAG 2.2 4.1.3). The observer, timers, and
30
- * per-message listeners are torn down on `disconnect()` (Turbo navigation included).
29
+ * animate removal. Focus is never moved (WCAG 2.2 4.1.3): pausing never removes a
30
+ * message, so the control under the pointer or holding focus stays put. The
31
+ * observation follows a `region` element replaced at runtime; the managed set is the
32
+ * current region's subtree, and a message that leaves it gives up its stacking slot,
33
+ * its pending auto-dismiss, and any removal already scheduled. The observer, timers,
34
+ * and per-message listeners are torn down on `disconnect()` (Turbo navigation
35
+ * included), and the managed flashes leave the page before Turbo caches it so a
36
+ * restored snapshot does not replay a notification the visitor already received.
31
37
  */
32
38
  declare class FlashController extends Controller<HTMLElement> {
33
39
  #private;
@@ -56,6 +62,20 @@ declare class FlashController extends Controller<HTMLElement> {
56
62
  maxValue: number;
57
63
  connect(): void;
58
64
  disconnect(): void;
65
+ /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
66
+ regionTargetConnected(): void;
67
+ /** Releases the observation when the `region` element leaves the target set. */
68
+ regionTargetDisconnected(): void;
69
+ /**
70
+ * Releases a message that left the target set (a Turbo Stream `remove`, the consumer
71
+ * detaching the node, or a morph that rewrote the target attribute in place): it
72
+ * stops occupying a `max` slot, and both its pending auto-dismiss and an already
73
+ * scheduled removal are cancelled. A move *within* the region keeps all of them —
74
+ * which is why the element must still be a message to be treated as one: ownership
75
+ * alone reads an in-place attribute rewrite as a move, and a node outside the target
76
+ * set belongs to the consumer, so nothing here may dismiss it.
77
+ */
78
+ messageTargetDisconnected(message: HTMLElement): void;
59
79
  /** Dismisses the flash whose close control fired the event. */
60
80
  dismiss(event: Event): void;
61
81
  }
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/flash_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/safe_timeout.ts
6
35
  var TimerRegistry = class {
7
36
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -103,29 +132,119 @@ var FlashController = class extends Controller {
103
132
  static events = ["show", "dismiss"];
104
133
  #timers = new SafeTimeout();
105
134
  #observer = null;
135
+ /** Whether the controller is between `connect()` and `disconnect()`. */
136
+ #connected = false;
106
137
  /** Auto-dismiss timer state keyed by message element. */
107
138
  #state = /* @__PURE__ */ new Map();
108
139
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
109
140
  #order = [];
110
- #onEnter = (event) => this.#pause(event.currentTarget);
111
- #onLeave = (event) => this.#resume(event.currentTarget);
141
+ /**
142
+ * Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}
143
+ * releases the bookkeeping above *before* the transition wait, so for that window the
144
+ * element is in the DOM but in neither collection — without this set a re-scan would
145
+ * read it as a brand-new flash and show it a second time.
146
+ */
147
+ #leaving = /* @__PURE__ */ new Set();
148
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
149
+ #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
150
+ #onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
112
151
  connect() {
113
- if (!this.hasRegionTarget) return;
152
+ this.#connected = true;
114
153
  for (const message of this.messageTargets) {
115
- this.#process(message, true);
116
- }
117
- if (typeof MutationObserver !== "undefined") {
118
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
119
- this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
154
+ if (this.#owns(message)) this.#process(message, true);
120
155
  }
156
+ this.#syncObservation();
157
+ this.#beforeCache.activate();
121
158
  }
122
159
  disconnect() {
123
- this.#observer?.disconnect();
124
- this.#observer = null;
160
+ this.#connected = false;
161
+ this.#beforeCache.deactivate();
162
+ this.#stopObserving();
125
163
  this.#timers.clearAll();
126
164
  for (const message of this.#order) this.#unbindPause(message);
127
165
  this.#state.clear();
128
166
  this.#order.length = 0;
167
+ this.#leaving.clear();
168
+ }
169
+ /**
170
+ * Takes the managed flashes out of the page just before Turbo freezes it, so a
171
+ * restored snapshot carries no notification the visitor has already received: the
172
+ * fresh `connect()` there reads a leftover flash as a brand-new one and announces it
173
+ * a second time. A message that never auto-dismisses (`duration: 0`) is one of these
174
+ * too — that value governs the timer, not what belongs in a cached page. Removal
175
+ * only: `dismiss` reports a dismissal, and freezing the page is not one.
176
+ */
177
+ #rewindForCache() {
178
+ for (const message of [...this.#order]) {
179
+ message.remove();
180
+ this.#forget(message);
181
+ }
182
+ for (const message of this.#leaving) message.remove();
183
+ this.#leaving.clear();
184
+ }
185
+ /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
186
+ regionTargetConnected() {
187
+ this.#resync();
188
+ }
189
+ /** Releases the observation when the `region` element leaves the target set. */
190
+ regionTargetDisconnected() {
191
+ this.#resync();
192
+ }
193
+ /**
194
+ * Whether this controller owns `message`. Ownership is the current `region`'s
195
+ * subtree: a message target anywhere else in the controller's scope is the
196
+ * consumer's, and so is one in a region that has gone away. The initial scan, a
197
+ * re-scan after a `region` swap, and a departure from the target set all resolve
198
+ * ownership through this one test; the observation gets it structurally, by watching
199
+ * that subtree and nothing else.
200
+ */
201
+ #owns(message) {
202
+ return this.hasRegionTarget && this.regionTarget.contains(message);
203
+ }
204
+ /**
205
+ * Releases a message that left the target set (a Turbo Stream `remove`, the consumer
206
+ * detaching the node, or a morph that rewrote the target attribute in place): it
207
+ * stops occupying a `max` slot, and both its pending auto-dismiss and an already
208
+ * scheduled removal are cancelled. A move *within* the region keeps all of them —
209
+ * which is why the element must still be a message to be treated as one: ownership
210
+ * alone reads an in-place attribute rewrite as a move, and a node outside the target
211
+ * set belongs to the consumer, so nothing here may dismiss it.
212
+ */
213
+ messageTargetDisconnected(message) {
214
+ if (!this.#connected) return;
215
+ const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
216
+ if (moved) return;
217
+ this.#forget(message);
218
+ this.#leaving.delete(message);
219
+ }
220
+ /**
221
+ * Re-points the observation after a `region` swap and picks up the messages the
222
+ * new element brought with it (dynamic inserts, so their own `role` announces
223
+ * them). The `#connected` guard is load-bearing: Stimulus runs target callbacks
224
+ * for the initial markup *before* `connect()` and again during teardown *after*
225
+ * `disconnect()`, and re-observing there would outlive the controller.
226
+ */
227
+ #resync() {
228
+ if (!this.#connected) return;
229
+ this.#syncObservation();
230
+ for (const message of this.messageTargets) {
231
+ if (this.#owns(message)) this.#process(message, false);
232
+ }
233
+ }
234
+ /**
235
+ * Points the mutation observation at the current `region` target, re-resolved on
236
+ * every sync rather than captured at connect, so an element swapped in at runtime
237
+ * is observed instead of the detached original.
238
+ */
239
+ #syncObservation() {
240
+ this.#stopObserving();
241
+ if (!this.hasRegionTarget || typeof MutationObserver === "undefined") return;
242
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
243
+ this.#observer.observe(this.regionTarget, { childList: true, subtree: true });
244
+ }
245
+ #stopObserving() {
246
+ this.#observer?.disconnect();
247
+ this.#observer = null;
129
248
  }
130
249
  /**
131
250
  * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
@@ -171,6 +290,7 @@ var FlashController = class extends Controller {
171
290
  */
172
291
  #process(message, bridge) {
173
292
  if (this.#state.has(message) || this.#order.includes(message)) return;
293
+ if (this.#leaving.has(message)) return;
174
294
  const type = message.getAttribute("data-flash-type") ?? "";
175
295
  const assertive = ASSERTIVE_TYPES.has(type);
176
296
  if (!message.hasAttribute("role")) {
@@ -203,33 +323,53 @@ var FlashController = class extends Controller {
203
323
  const existing = this.#state.get(message);
204
324
  if (existing?.id) this.#timers.clear(existing.id);
205
325
  const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
206
- this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });
326
+ this.#state.set(message, {
327
+ id,
328
+ startedAt: Date.now(),
329
+ remaining: duration,
330
+ paused: existing?.paused ?? /* @__PURE__ */ new Set()
331
+ });
207
332
  }
208
- /** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */
209
- #pause(message) {
333
+ /**
334
+ * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
335
+ * Hover and focus are independent reasons: the remaining time is banked on the
336
+ * first of them, and {@link FlashController.#resume} waits for the last one.
337
+ */
338
+ #pause(message, reason) {
210
339
  const timer = this.#state.get(message);
211
- if (!timer || timer.id === 0) return;
340
+ if (!timer) return;
341
+ timer.paused.add(reason);
342
+ if (timer.id === 0) return;
212
343
  this.#timers.clear(timer.id);
213
- const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
214
- this.#state.set(message, { id: 0, startedAt: 0, remaining });
344
+ const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
345
+ this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
215
346
  }
216
347
  /** Resumes a paused message's auto-dismiss with the banked time. */
217
- #resume(message) {
348
+ #resume(message, reason) {
218
349
  const timer = this.#state.get(message);
219
350
  if (!timer) return;
220
- if (timer.id !== 0 || timer.remaining <= 0) return;
351
+ timer.paused.delete(reason);
352
+ if (timer.paused.size > 0) return;
353
+ if (timer.id !== 0) return;
221
354
  this.#startTimer(message, timer.remaining);
222
355
  }
223
- /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
224
- #beginDismiss(message, reason) {
356
+ /** Releases every per-message resource: timer, stacking slot, pause listeners. */
357
+ #forget(message) {
225
358
  const timer = this.#state.get(message);
226
359
  if (timer?.id) this.#timers.clear(timer.id);
227
360
  this.#state.delete(message);
228
361
  const index = this.#order.indexOf(message);
229
362
  if (index !== -1) this.#order.splice(index, 1);
363
+ this.#unbindPause(message);
364
+ }
365
+ /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
366
+ #beginDismiss(message, reason) {
367
+ if (!this.#state.has(message) && !this.#order.includes(message)) return;
368
+ this.#forget(message);
369
+ this.#leaving.add(message);
230
370
  message.setAttribute("data-flash-state", "leaving");
231
371
  const finalize = () => {
232
- this.#unbindPause(message);
372
+ if (!this.#leaving.delete(message)) return;
233
373
  message.remove();
234
374
  this.dispatch("dismiss", { detail: { element: message, reason } });
235
375
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/utils/transition_completion.ts","../../src/controllers/flash_controller.ts"],"names":[],"mappings":"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","file":"flash_controller.js","sourcesContent":["/**\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 { SafeTimeout } from \"./safe_timeout\";\n\n/** Computed transition fields needed to determine when an element has settled. */\nexport type TransitionStyle = Pick<\n CSSStyleDeclaration,\n \"transitionProperty\" | \"transitionDuration\" | \"transitionDelay\"\n>;\n\n/** Parsed transition property paired with its effective duration and delay. */\ninterface TransitionTiming {\n property: string;\n totalMs: number;\n}\n\n/** Parses a CSS `<time>` into milliseconds, returning `0` for malformed values. */\nfunction timeMs(value: string): number {\n const trimmed = value.trim();\n const amount = Number.parseFloat(trimmed);\n if (!Number.isFinite(amount)) return 0;\n if (trimmed.endsWith(\"ms\")) return amount;\n if (trimmed.endsWith(\"s\")) return amount * 1000;\n return 0;\n}\n\n/** Splits a computed comma-separated CSS list into normalized, non-empty items. */\nfunction cssList(value: string): string[] {\n return value\n .split(\",\")\n .map((item) => item.trim())\n .filter(Boolean);\n}\n\n/**\n * Pairs transition properties, durations, and delays using CSS list repetition.\n *\n * The `transition-property` list determines the number of transitions. Shorter\n * duration/delay lists repeat, while extra values are ignored. A negative delay\n * shortens the remaining wall-clock time, and a non-positive total is settled.\n */\nfunction transitionTimings(style: TransitionStyle): TransitionTiming[] {\n const properties = cssList(style.transitionProperty);\n const durations = cssList(style.transitionDuration).map(timeMs);\n const delays = cssList(style.transitionDelay).map(timeMs);\n const effectiveProperties =\n properties.length > 0\n ? properties\n : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => \"all\");\n const effectiveDurations = durations.length > 0 ? durations : [0];\n const effectiveDelays = delays.length > 0 ? delays : [0];\n\n return effectiveProperties\n .filter((property) => property !== \"none\")\n .map((property, index) => ({\n property,\n totalMs: Math.max(\n 0,\n (effectiveDurations[index % effectiveDurations.length] ?? 0) +\n (effectiveDelays[index % effectiveDelays.length] ?? 0),\n ),\n }));\n}\n\n/** Maximum total over already-parsed timings (0 when the list is empty). */\nfunction maxTotalMs(timings: TransitionTiming[]): number {\n return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);\n}\n\n/** Maximum effective transition duration plus delay, in milliseconds. */\nexport function maxTransitionTotalMs(style: TransitionStyle): number {\n return maxTotalMs(transitionTimings(style));\n}\n\n/** Optional tuning for a single {@link TransitionCompletion.wait} call. */\nexport interface TransitionWaitOptions {\n /**\n * Positive wall-clock override (ms) for the bounded fallback, replacing the\n * auto-computed `max + 50ms` timer verbatim. It also keeps the wait armed for\n * a computed 0ms transition (no synchronous completion): the consumer asserts\n * a transition budget that computed styles cannot see. Non-positive and\n * non-finite (`NaN` / `Infinity`) values are ignored.\n */\n timeoutMs?: number;\n}\n\n/**\n * Owns one cancellable wait for an element's CSS transitions to settle.\n *\n * When available, active CSS transitions provide their expanded property names.\n * Tracking those names lets shorthand declarations settle from their longhand\n * terminal events. A terminal event from an interrupted phase is ignored while a\n * replacement transition for the same property is still active. Because that set\n * is sampled when the wait is armed, a property whose transition only starts a\n * frame later would otherwise be settled over; the still-running set is therefore\n * re-read before completing and adopted when it is not yet empty. Without Web\n * Animations data, explicit transition properties retain the declared-property\n * behavior and the ambiguous `transition-property: all` form waits until the\n * computed maximum wall-clock time. A `max + 50ms` timeout is always armed\n * because browsers emit no terminal event when a property never transitions, an\n * ancestor disappears, or rendering is otherwise interrupted; a consumer with\n * better knowledge can replace that fallback via\n * {@link TransitionWaitOptions.timeoutMs}.\n */\nexport class TransitionCompletion {\n readonly #timers = new SafeTimeout();\n #element: HTMLElement | null = null;\n #complete: (() => void) | null = null;\n #pendingProperties: Set<string> | null = null;\n #deadline = 0;\n\n /**\n * Replaces any prior wait and invokes `complete` synchronously for a 0ms\n * transition (including when `getComputedStyle` is unavailable).\n *\n * With a positive `options.timeoutMs` the synchronous fast-path is skipped and\n * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).\n */\n wait(element: HTMLElement, complete: () => void, options: TransitionWaitOptions = {}): void {\n this.cancel();\n\n const requested = options.timeoutMs ?? 0;\n const override = Number.isFinite(requested) && requested > 0 ? requested : 0;\n // A non-browser / partial-DOM environment cannot report a computable\n // transition, which is indistinguishable from a 0ms one.\n const timings =\n typeof window.getComputedStyle === \"function\"\n ? transitionTimings(window.getComputedStyle(element))\n : [];\n const maximum = maxTotalMs(timings);\n if (maximum <= 0 && override <= 0) {\n complete();\n return;\n }\n\n this.#element = element;\n this.#complete = complete;\n this.#deadline = Date.now() + maximum;\n const activeProperties = this.#activeTransitionProperties(element);\n this.#pendingProperties =\n activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);\n element.addEventListener(\"transitionend\", this.#onTerminal);\n element.addEventListener(\"transitioncancel\", this.#onTerminal);\n this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);\n }\n\n /** Cancels the pending wait without invoking its completion callback. */\n cancel(): void {\n this.#complete = null;\n this.#teardown();\n }\n\n /**\n * Handles terminal events from the observed element only.\n *\n * For explicit property lists, every declared positive-time property must\n * settle. For `all`, no reliable property set exists, so an event can finish\n * only after the computed maximum time; the safety timeout owns the usual path.\n */\n readonly #onTerminal = (event: Event): void => {\n if (event.target !== this.#element) return;\n const transitionEvent = event as TransitionEvent;\n // A pseudo-element's transition is reported with its originating element\n // as `target`; it must not settle the element's own transition.\n if (transitionEvent.pseudoElement) return;\n\n if (this.#pendingProperties) {\n const propertyName = transitionEvent.propertyName;\n const active = this.#activeTransitionProperties(this.#element);\n // An interrupted transition queues its cancel event before the replacement\n // phase starts, but dispatch can happen after the new wait is armed. The\n // replacement is then the active transition for this property, so the stale\n // terminal event must not settle the new phase.\n if (active.has(propertyName)) return;\n if (!this.#pendingProperties.delete(propertyName)) return;\n if (this.#pendingProperties.size > 0) return;\n // The tracked set is an arm-time snapshot, so a transition the phase starts\n // one frame later is absent from it. Re-reading the still-running set before\n // settling adopts those stragglers instead of completing over them; the\n // fallback timer stays armed, so this cannot extend the wait unboundedly.\n if (active.size > 0) {\n this.#pendingProperties = active;\n return;\n }\n this.#finish();\n return;\n }\n\n if (Date.now() >= this.#deadline) this.#finish();\n };\n\n /**\n * Returns active CSS transition properties expanded to the names reported by\n * terminal events. CSS animations and pseudo-element effects are excluded.\n */\n #activeTransitionProperties(element: HTMLElement | null): Set<string> {\n if (!element || typeof element.getAnimations !== \"function\") return new Set();\n\n try {\n const properties = element.getAnimations().flatMap((animation) => {\n if (animation.playState === \"idle\" || animation.playState === \"finished\") return [];\n const effect = animation.effect as KeyframeEffect | null;\n // A pseudo-element effect names the originating element as its target, so\n // `target` alone cannot separate it. Its terminal events are ignored, so\n // tracking its properties would only strand the wait on the fallback.\n if (effect?.pseudoElement) return [];\n const target = effect?.target;\n if (target && target !== element) return [];\n const property = (animation as Partial<CSSTransition>).transitionProperty;\n return typeof property === \"string\" && property.length > 0 ? [property] : [];\n });\n return new Set(properties);\n } catch {\n // Partial DOM implementations can expose getAnimations without supporting\n // it. The declared-property tracker and bounded timer remain safe fallbacks.\n return new Set();\n }\n }\n\n /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */\n #explicitPendingProperties(timings: TransitionTiming[]): Set<string> | null {\n if (timings.some(({ property }) => property === \"all\")) return null;\n const pending = new Set(\n timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property),\n );\n // An empty set is only reachable through a timeout override (a 0ms transition\n // otherwise completes synchronously). With nothing to track, fall back to the\n // deadline rule so any terminal event can settle the wait, exactly like `all`.\n return pending.size > 0 ? pending : null;\n }\n\n /** Completes exactly once, releasing listeners and the fallback before the callback. */\n #finish(): void {\n const complete = this.#complete;\n if (!complete) return;\n this.#complete = null;\n this.#teardown();\n complete();\n }\n\n /** Releases the exact element listeners and timer owned by the current wait. */\n #teardown(): void {\n this.#timers.clearAll();\n this.#element?.removeEventListener(\"transitionend\", this.#onTerminal);\n this.#element?.removeEventListener(\"transitioncancel\", this.#onTerminal);\n this.#element = null;\n this.#pendingProperties = null;\n this.#deadline = 0;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { maxTransitionTotalMs } from \"../utils/transition_completion\";\n\n/** Flash types that map to an assertive `alert` (everything else is a polite `status`). */\nconst ASSERTIVE_TYPES = new Set([\"alert\", \"error\"]);\n\n/** Selector for message targets, used by the MutationObserver to spot dynamic inserts. */\nconst MESSAGE_SELECTOR = '[data-stimeo--flash-target=\"message\"]';\n\n/** Per-message auto-dismiss timer bookkeeping (id 0 means paused). */\ninterface FlashTimer {\n id: number;\n startedAt: number;\n remaining: number;\n}\n\n/**\n * Headless **Rails flash bridge**: turns server-rendered (and Turbo Stream-inserted)\n * `flash` elements into live-region announcements with auto-dismiss and a stacking\n * cap (no dedicated APG pattern; follows the WAI-ARIA status/alert guidance and WCAG\n * 2.2 **4.1.3 Status Messages**). The general-purpose sibling is Toast; this one is\n * specialized to the Rails `flash` convention.\n *\n * Markup contract (identifier: `stimeo--flash`):\n * <div data-controller=\"stimeo--flash\" data-stimeo--flash-target=\"region\">\n * <!-- server-rendered or Turbo Stream-inserted -->\n * <div data-stimeo--flash-target=\"message\" data-flash-type=\"notice\">Saved</div>\n * </div>\n *\n * Each message is mapped by `data-flash-type` to `role=\"status\"` (notice) or\n * `role=\"alert\"` (alert/error), flagged `data-flash-state=\"visible\"`, auto-dismissed\n * after `duration` (paused while hovered/focused when `pauseOnHover`), and capped at\n * `max` simultaneous messages. A close button wired to the `dismiss` action removes\n * one manually.\n *\n * @remarks\n * Reading is **delegated to the shared Announcer** — but only for the *initial*,\n * page-loaded messages: an in-place live region present at load is not announced by\n * assistive tech, so the controller bridges those via an `stimeo--announcer:announce`\n * event. Messages inserted *later* (Turbo Stream) are announced by their own freshly\n * inserted `role`, exactly like Toast, so they are not bridged again (no double\n * announcement). Behavior only — no styling; `data-flash-state=\"leaving\"` lets CSS\n * animate removal. Focus is never moved (WCAG 2.2 4.1.3). The observer, timers, and\n * per-message listeners are torn down on `disconnect()` (Turbo navigation included).\n */\nexport class FlashController extends Controller<HTMLElement> {\n static override targets = [\"region\", \"message\"];\n static override values = {\n duration: { type: Number, default: 5000 },\n pauseOnHover: { type: Boolean, default: true },\n max: { type: Number, default: 0 },\n };\n static actions = [\"dismiss\"] as const;\n static events = [\"show\", \"dismiss\"] as const;\n\n declare readonly regionTarget: HTMLElement;\n declare readonly messageTargets: HTMLElement[];\n declare readonly hasRegionTarget: boolean;\n\n declare durationValue: number;\n declare pauseOnHoverValue: boolean;\n declare maxValue: number;\n\n readonly #timers = new SafeTimeout();\n #observer: MutationObserver | null = null;\n /** Auto-dismiss timer state keyed by message element. */\n readonly #state = new Map<HTMLElement, FlashTimer>();\n /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */\n readonly #order: HTMLElement[] = [];\n\n readonly #onEnter = (event: Event): void => this.#pause(event.currentTarget as HTMLElement);\n readonly #onLeave = (event: Event): void => this.#resume(event.currentTarget as HTMLElement);\n\n override connect(): void {\n if (!this.hasRegionTarget) return;\n // Initial, server-rendered flashes: bridge them to the Announcer because an\n // in-place live region present at page load is not announced on its own.\n for (const message of this.messageTargets) {\n this.#process(message, true);\n }\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));\n this.#observer.observe(this.regionTarget, { childList: true, subtree: true });\n }\n }\n\n override disconnect(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n this.#timers.clearAll();\n for (const message of this.#order) this.#unbindPause(message);\n this.#state.clear();\n this.#order.length = 0;\n }\n\n /**\n * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides\n * stay in sync. Binding is gated by `pauseOnHover`; unbinding is unconditional\n * and idempotent (a no-op when nothing was bound), which keeps teardown correct\n * even if `pauseOnHover` were ever toggled during a message's life.\n */\n #bindPause(message: HTMLElement): void {\n if (!this.pauseOnHoverValue) return;\n message.addEventListener(\"mouseenter\", this.#onEnter);\n message.addEventListener(\"mouseleave\", this.#onLeave);\n message.addEventListener(\"focusin\", this.#onEnter);\n message.addEventListener(\"focusout\", this.#onLeave);\n }\n\n #unbindPause(message: HTMLElement): void {\n message.removeEventListener(\"mouseenter\", this.#onEnter);\n message.removeEventListener(\"mouseleave\", this.#onLeave);\n message.removeEventListener(\"focusin\", this.#onEnter);\n message.removeEventListener(\"focusout\", this.#onLeave);\n }\n\n /** Dismisses the flash whose close control fired the event. */\n dismiss(event: Event): void {\n const target = (event.currentTarget || event.target) as HTMLElement | null;\n const message = target?.closest<HTMLElement>(MESSAGE_SELECTOR);\n if (message) this.#beginDismiss(message, \"user\");\n }\n\n /** Processes messages added after connect (Turbo Stream); their own role announces them. */\n #onMutations(mutations: MutationRecord[]): void {\n for (const mutation of mutations) {\n for (const node of mutation.addedNodes) {\n if (!(node instanceof HTMLElement)) continue;\n if (node.matches(MESSAGE_SELECTOR)) this.#process(node, false);\n for (const message of node.querySelectorAll<HTMLElement>(MESSAGE_SELECTOR)) {\n this.#process(message, false);\n }\n }\n }\n }\n\n /**\n * Applies role/state, wires pause listeners, schedules auto-dismiss, and either\n * bridges to the Announcer (`bridge`, for initial flashes) or leaves the message's\n * own role to do the announcing (dynamic inserts). Idempotent per message.\n */\n #process(message: HTMLElement, bridge: boolean): void {\n if (this.#state.has(message) || this.#order.includes(message)) return;\n\n const type = message.getAttribute(\"data-flash-type\") ?? \"\";\n const assertive = ASSERTIVE_TYPES.has(type);\n // Don't clobber an authored role; otherwise map the flash type to a live role.\n if (!message.hasAttribute(\"role\")) {\n message.setAttribute(\"role\", assertive ? \"alert\" : \"status\");\n }\n message.setAttribute(\"data-flash-state\", \"visible\");\n this.#order.push(message);\n\n this.#bindPause(message);\n\n const text = message.textContent?.trim() ?? \"\";\n this.dispatch(\"show\", { target: message, detail: { type, message: text } });\n if (bridge && text) {\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", { detail: { message: text, assertive } }),\n );\n }\n\n this.#startTimer(message);\n this.#enforceMax();\n }\n\n /** Removes the oldest visible flashes once the count exceeds `max` (0 = unlimited). */\n #enforceMax(): void {\n if (this.maxValue <= 0) return;\n while (this.#order.length > this.maxValue) {\n const oldest = this.#order[0];\n if (!oldest) break;\n this.#beginDismiss(oldest, \"limit\");\n }\n }\n\n #startTimer(message: HTMLElement, duration = this.durationValue): void {\n if (duration <= 0) return;\n const existing = this.#state.get(message);\n if (existing?.id) this.#timers.clear(existing.id);\n const id = this.#timers.set(() => this.#beginDismiss(message, \"timeout\"), duration);\n this.#state.set(message, { id, startedAt: Date.now(), remaining: duration });\n }\n\n /** Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1). */\n #pause(message: HTMLElement): void {\n const timer = this.#state.get(message);\n if (!timer || timer.id === 0) return;\n this.#timers.clear(timer.id);\n const remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));\n this.#state.set(message, { id: 0, startedAt: 0, remaining });\n }\n\n /** Resumes a paused message's auto-dismiss with the banked time. */\n #resume(message: HTMLElement): void {\n const timer = this.#state.get(message);\n if (!timer) return;\n // Only resume a genuinely paused timer (id 0) that still has time banked.\n if (timer.id !== 0 || timer.remaining <= 0) return;\n this.#startTimer(message, timer.remaining);\n }\n\n /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */\n #beginDismiss(message: HTMLElement, reason: \"timeout\" | \"user\" | \"limit\"): void {\n const timer = this.#state.get(message);\n if (timer?.id) this.#timers.clear(timer.id);\n this.#state.delete(message);\n const index = this.#order.indexOf(message);\n if (index !== -1) this.#order.splice(index, 1);\n\n message.setAttribute(\"data-flash-state\", \"leaving\");\n\n const finalize = (): void => {\n this.#unbindPause(message);\n message.remove();\n this.dispatch(\"dismiss\", { detail: { element: message, reason } });\n };\n\n const transition = this.#transitionMs(message);\n if (transition > 0) {\n this.#timers.set(finalize, transition);\n } else {\n finalize();\n }\n }\n\n /** Maximum transition total (duration + delay) of `el` in ms (0 when none / unsupported). */\n #transitionMs(el: HTMLElement): number {\n if (typeof window.getComputedStyle !== \"function\") return 0;\n return maxTransitionTotalMs(window.getComputedStyle(el));\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/safe_timeout.ts","../../src/utils/transition_completion.ts","../../src/controllers/flash_controller.ts"],"names":[],"mappings":"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","file":"flash_controller.js","sourcesContent":["/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way (the `MicrotaskCoalescer` split).\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\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 #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\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 { SafeTimeout } from \"./safe_timeout\";\n\n/** Computed transition fields needed to determine when an element has settled. */\nexport type TransitionStyle = Pick<\n CSSStyleDeclaration,\n \"transitionProperty\" | \"transitionDuration\" | \"transitionDelay\"\n>;\n\n/** Parsed transition property paired with its effective duration and delay. */\ninterface TransitionTiming {\n property: string;\n totalMs: number;\n}\n\n/** Parses a CSS `<time>` into milliseconds, returning `0` for malformed values. */\nfunction timeMs(value: string): number {\n const trimmed = value.trim();\n const amount = Number.parseFloat(trimmed);\n if (!Number.isFinite(amount)) return 0;\n if (trimmed.endsWith(\"ms\")) return amount;\n if (trimmed.endsWith(\"s\")) return amount * 1000;\n return 0;\n}\n\n/** Splits a computed comma-separated CSS list into normalized, non-empty items. */\nfunction cssList(value: string): string[] {\n return value\n .split(\",\")\n .map((item) => item.trim())\n .filter(Boolean);\n}\n\n/**\n * Pairs transition properties, durations, and delays using CSS list repetition.\n *\n * The `transition-property` list determines the number of transitions. Shorter\n * duration/delay lists repeat, while extra values are ignored. A negative delay\n * shortens the remaining wall-clock time, and a non-positive total is settled.\n */\nfunction transitionTimings(style: TransitionStyle): TransitionTiming[] {\n const properties = cssList(style.transitionProperty);\n const durations = cssList(style.transitionDuration).map(timeMs);\n const delays = cssList(style.transitionDelay).map(timeMs);\n const effectiveProperties =\n properties.length > 0\n ? properties\n : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => \"all\");\n const effectiveDurations = durations.length > 0 ? durations : [0];\n const effectiveDelays = delays.length > 0 ? delays : [0];\n\n return effectiveProperties\n .filter((property) => property !== \"none\")\n .map((property, index) => ({\n property,\n totalMs: Math.max(\n 0,\n (effectiveDurations[index % effectiveDurations.length] ?? 0) +\n (effectiveDelays[index % effectiveDelays.length] ?? 0),\n ),\n }));\n}\n\n/** Maximum total over already-parsed timings (0 when the list is empty). */\nfunction maxTotalMs(timings: TransitionTiming[]): number {\n return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);\n}\n\n/** Maximum effective transition duration plus delay, in milliseconds. */\nexport function maxTransitionTotalMs(style: TransitionStyle): number {\n return maxTotalMs(transitionTimings(style));\n}\n\n/** Optional tuning for a single {@link TransitionCompletion.wait} call. */\nexport interface TransitionWaitOptions {\n /**\n * Positive wall-clock override (ms) for the bounded fallback, replacing the\n * auto-computed `max + 50ms` timer verbatim. It also keeps the wait armed for\n * a computed 0ms transition (no synchronous completion): the consumer asserts\n * a transition budget that computed styles cannot see. Non-positive and\n * non-finite (`NaN` / `Infinity`) values are ignored.\n */\n timeoutMs?: number;\n}\n\n/**\n * Owns one cancellable wait for an element's CSS transitions to settle.\n *\n * When available, active CSS transitions provide their expanded property names.\n * Tracking those names lets shorthand declarations settle from their longhand\n * terminal events. A terminal event from an interrupted phase is ignored while a\n * replacement transition for the same property is still active. Because that set\n * is sampled when the wait is armed, a property whose transition only starts a\n * frame later would otherwise be settled over; the still-running set is therefore\n * re-read before completing and adopted when it is not yet empty. Without Web\n * Animations data, explicit transition properties retain the declared-property\n * behavior and the ambiguous `transition-property: all` form waits until the\n * computed maximum wall-clock time. A `max + 50ms` timeout is always armed\n * because browsers emit no terminal event when a property never transitions, an\n * ancestor disappears, or rendering is otherwise interrupted; a consumer with\n * better knowledge can replace that fallback via\n * {@link TransitionWaitOptions.timeoutMs}.\n */\nexport class TransitionCompletion {\n readonly #timers = new SafeTimeout();\n #element: HTMLElement | null = null;\n #complete: (() => void) | null = null;\n #pendingProperties: Set<string> | null = null;\n #deadline = 0;\n\n /**\n * Replaces any prior wait and invokes `complete` synchronously for a 0ms\n * transition (including when `getComputedStyle` is unavailable).\n *\n * With a positive `options.timeoutMs` the synchronous fast-path is skipped and\n * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).\n */\n wait(element: HTMLElement, complete: () => void, options: TransitionWaitOptions = {}): void {\n this.cancel();\n\n const requested = options.timeoutMs ?? 0;\n const override = Number.isFinite(requested) && requested > 0 ? requested : 0;\n // A non-browser / partial-DOM environment cannot report a computable\n // transition, which is indistinguishable from a 0ms one.\n const timings =\n typeof window.getComputedStyle === \"function\"\n ? transitionTimings(window.getComputedStyle(element))\n : [];\n const maximum = maxTotalMs(timings);\n if (maximum <= 0 && override <= 0) {\n complete();\n return;\n }\n\n this.#element = element;\n this.#complete = complete;\n this.#deadline = Date.now() + maximum;\n const activeProperties = this.#activeTransitionProperties(element);\n this.#pendingProperties =\n activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);\n element.addEventListener(\"transitionend\", this.#onTerminal);\n element.addEventListener(\"transitioncancel\", this.#onTerminal);\n this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);\n }\n\n /** Cancels the pending wait without invoking its completion callback. */\n cancel(): void {\n this.#complete = null;\n this.#teardown();\n }\n\n /**\n * Handles terminal events from the observed element only.\n *\n * For explicit property lists, every declared positive-time property must\n * settle. For `all`, no reliable property set exists, so an event can finish\n * only after the computed maximum time; the safety timeout owns the usual path.\n */\n readonly #onTerminal = (event: Event): void => {\n if (event.target !== this.#element) return;\n const transitionEvent = event as TransitionEvent;\n // A pseudo-element's transition is reported with its originating element\n // as `target`; it must not settle the element's own transition.\n if (transitionEvent.pseudoElement) return;\n\n if (this.#pendingProperties) {\n const propertyName = transitionEvent.propertyName;\n const active = this.#activeTransitionProperties(this.#element);\n // An interrupted transition queues its cancel event before the replacement\n // phase starts, but dispatch can happen after the new wait is armed. The\n // replacement is then the active transition for this property, so the stale\n // terminal event must not settle the new phase.\n if (active.has(propertyName)) return;\n if (!this.#pendingProperties.delete(propertyName)) return;\n if (this.#pendingProperties.size > 0) return;\n // The tracked set is an arm-time snapshot, so a transition the phase starts\n // one frame later is absent from it. Re-reading the still-running set before\n // settling adopts those stragglers instead of completing over them; the\n // fallback timer stays armed, so this cannot extend the wait unboundedly.\n if (active.size > 0) {\n this.#pendingProperties = active;\n return;\n }\n this.#finish();\n return;\n }\n\n if (Date.now() >= this.#deadline) this.#finish();\n };\n\n /**\n * Returns active CSS transition properties expanded to the names reported by\n * terminal events. CSS animations and pseudo-element effects are excluded.\n */\n #activeTransitionProperties(element: HTMLElement | null): Set<string> {\n if (!element || typeof element.getAnimations !== \"function\") return new Set();\n\n try {\n const properties = element.getAnimations().flatMap((animation) => {\n if (animation.playState === \"idle\" || animation.playState === \"finished\") return [];\n const effect = animation.effect as KeyframeEffect | null;\n // A pseudo-element effect names the originating element as its target, so\n // `target` alone cannot separate it. Its terminal events are ignored, so\n // tracking its properties would only strand the wait on the fallback.\n if (effect?.pseudoElement) return [];\n const target = effect?.target;\n if (target && target !== element) return [];\n const property = (animation as Partial<CSSTransition>).transitionProperty;\n return typeof property === \"string\" && property.length > 0 ? [property] : [];\n });\n return new Set(properties);\n } catch {\n // Partial DOM implementations can expose getAnimations without supporting\n // it. The declared-property tracker and bounded timer remain safe fallbacks.\n return new Set();\n }\n }\n\n /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */\n #explicitPendingProperties(timings: TransitionTiming[]): Set<string> | null {\n if (timings.some(({ property }) => property === \"all\")) return null;\n const pending = new Set(\n timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property),\n );\n // An empty set is only reachable through a timeout override (a 0ms transition\n // otherwise completes synchronously). With nothing to track, fall back to the\n // deadline rule so any terminal event can settle the wait, exactly like `all`.\n return pending.size > 0 ? pending : null;\n }\n\n /** Completes exactly once, releasing listeners and the fallback before the callback. */\n #finish(): void {\n const complete = this.#complete;\n if (!complete) return;\n this.#complete = null;\n this.#teardown();\n complete();\n }\n\n /** Releases the exact element listeners and timer owned by the current wait. */\n #teardown(): void {\n this.#timers.clearAll();\n this.#element?.removeEventListener(\"transitionend\", this.#onTerminal);\n this.#element?.removeEventListener(\"transitioncancel\", this.#onTerminal);\n this.#element = null;\n this.#pendingProperties = null;\n this.#deadline = 0;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { maxTransitionTotalMs } from \"../utils/transition_completion\";\n\n/** Flash types that map to an assertive `alert` (everything else is a polite `status`). */\nconst ASSERTIVE_TYPES = new Set([\"alert\", \"error\"]);\n\n/** Selector for message targets, used by the MutationObserver to spot dynamic inserts. */\nconst MESSAGE_SELECTOR = '[data-stimeo--flash-target=\"message\"]';\n\n/** Per-message auto-dismiss timer bookkeeping (id 0 means paused). */\ninterface FlashTimer {\n id: number;\n startedAt: number;\n remaining: number;\n /** Active pause reasons; the timer resumes only once every one is released. */\n paused: Set<\"focus\" | \"hover\">;\n}\n\n/**\n * Headless **Rails flash bridge**: turns server-rendered (and Turbo Stream-inserted)\n * `flash` elements into live-region announcements with auto-dismiss and a stacking\n * cap (no dedicated APG pattern; follows the WAI-ARIA status/alert guidance and WCAG\n * 2.2 **4.1.3 Status Messages**). The general-purpose sibling is Toast; this one is\n * specialized to the Rails `flash` convention.\n *\n * Markup contract (identifier: `stimeo--flash`):\n * <div data-controller=\"stimeo--flash\" data-stimeo--flash-target=\"region\">\n * <!-- server-rendered or Turbo Stream-inserted -->\n * <div data-stimeo--flash-target=\"message\" data-flash-type=\"notice\">Saved</div>\n * </div>\n *\n * Each message is mapped by `data-flash-type` to `role=\"status\"` (notice) or\n * `role=\"alert\"` (alert/error), flagged `data-flash-state=\"visible\"`, auto-dismissed\n * after `duration` (paused while hovered *or* focused when `pauseOnHover`, and\n * resumed only once both are released), and capped at `max` simultaneous messages.\n * A close button wired to the `dismiss` action removes one manually.\n *\n * @remarks\n * Reading is **delegated to the shared Announcer** — but only for the *initial*,\n * page-loaded messages: an in-place live region present at load is not announced by\n * assistive tech, so the controller bridges those via an `stimeo--announcer:announce`\n * event. Messages inserted *later* (Turbo Stream) are announced by their own freshly\n * inserted `role`, exactly like Toast, so they are not bridged again (no double\n * announcement). Behavior only — no styling; `data-flash-state=\"leaving\"` lets CSS\n * animate removal. Focus is never moved (WCAG 2.2 4.1.3): pausing never removes a\n * message, so the control under the pointer or holding focus stays put. The\n * observation follows a `region` element replaced at runtime; the managed set is the\n * current region's subtree, and a message that leaves it gives up its stacking slot,\n * its pending auto-dismiss, and any removal already scheduled. The observer, timers,\n * and per-message listeners are torn down on `disconnect()` (Turbo navigation\n * included), and the managed flashes leave the page before Turbo caches it so a\n * restored snapshot does not replay a notification the visitor already received.\n */\nexport class FlashController extends Controller<HTMLElement> {\n static override targets = [\"region\", \"message\"];\n static override values = {\n duration: { type: Number, default: 5000 },\n pauseOnHover: { type: Boolean, default: true },\n max: { type: Number, default: 0 },\n };\n static actions = [\"dismiss\"] as const;\n static events = [\"show\", \"dismiss\"] as const;\n\n declare readonly regionTarget: HTMLElement;\n declare readonly messageTargets: HTMLElement[];\n declare readonly hasRegionTarget: boolean;\n\n declare durationValue: number;\n declare pauseOnHoverValue: boolean;\n declare maxValue: number;\n\n readonly #timers = new SafeTimeout();\n #observer: MutationObserver | null = null;\n /** Whether the controller is between `connect()` and `disconnect()`. */\n #connected = false;\n /** Auto-dismiss timer state keyed by message element. */\n readonly #state = new Map<HTMLElement, FlashTimer>();\n /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */\n readonly #order: HTMLElement[] = [];\n /**\n * Messages between `leaving` and their removal. {@link FlashController.#beginDismiss}\n * releases the bookkeeping above *before* the transition wait, so for that window the\n * element is in the DOM but in neither collection — without this set a re-scan would\n * read it as a brand-new flash and show it a second time.\n */\n readonly #leaving = new Set<HTMLElement>();\n\n readonly #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());\n\n readonly #onEnter = (event: Event): void =>\n this.#pause(event.currentTarget as HTMLElement, event.type === \"focusin\" ? \"focus\" : \"hover\");\n readonly #onLeave = (event: Event): void =>\n this.#resume(event.currentTarget as HTMLElement, event.type === \"focusout\" ? \"focus\" : \"hover\");\n\n override connect(): void {\n this.#connected = true;\n // Initial, server-rendered flashes: bridge them to the Announcer because an\n // in-place live region present at page load is not announced on its own.\n for (const message of this.messageTargets) {\n if (this.#owns(message)) this.#process(message, true);\n }\n this.#syncObservation();\n this.#beforeCache.activate();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#beforeCache.deactivate();\n this.#stopObserving();\n this.#timers.clearAll();\n for (const message of this.#order) this.#unbindPause(message);\n this.#state.clear();\n this.#order.length = 0;\n // The pending finalizes died with the timers above, so a message still marked\n // `leaving` is free to be shown again by the next connect (a snapshot restore).\n this.#leaving.clear();\n }\n\n /**\n * Takes the managed flashes out of the page just before Turbo freezes it, so a\n * restored snapshot carries no notification the visitor has already received: the\n * fresh `connect()` there reads a leftover flash as a brand-new one and announces it\n * a second time. A message that never auto-dismisses (`duration: 0`) is one of these\n * too — that value governs the timer, not what belongs in a cached page. Removal\n * only: `dismiss` reports a dismissal, and freezing the page is not one.\n */\n #rewindForCache(): void {\n for (const message of [...this.#order]) {\n message.remove();\n this.#forget(message);\n }\n for (const message of this.#leaving) message.remove();\n this.#leaving.clear();\n }\n\n /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */\n regionTargetConnected(): void {\n this.#resync();\n }\n\n /** Releases the observation when the `region` element leaves the target set. */\n regionTargetDisconnected(): void {\n this.#resync();\n }\n\n /**\n * Whether this controller owns `message`. Ownership is the current `region`'s\n * subtree: a message target anywhere else in the controller's scope is the\n * consumer's, and so is one in a region that has gone away. The initial scan, a\n * re-scan after a `region` swap, and a departure from the target set all resolve\n * ownership through this one test; the observation gets it structurally, by watching\n * that subtree and nothing else.\n */\n #owns(message: HTMLElement): boolean {\n return this.hasRegionTarget && this.regionTarget.contains(message);\n }\n\n /**\n * Releases a message that left the target set (a Turbo Stream `remove`, the consumer\n * detaching the node, or a morph that rewrote the target attribute in place): it\n * stops occupying a `max` slot, and both its pending auto-dismiss and an already\n * scheduled removal are cancelled. A move *within* the region keeps all of them —\n * which is why the element must still be a message to be treated as one: ownership\n * alone reads an in-place attribute rewrite as a move, and a node outside the target\n * set belongs to the consumer, so nothing here may dismiss it.\n */\n messageTargetDisconnected(message: HTMLElement): void {\n if (!this.#connected) return;\n const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);\n if (moved) return;\n this.#forget(message);\n // Ownership is re-checked at both ends of the leaving transition: dropping the\n // claim here is what the pending finalize reads to leave the node alone.\n this.#leaving.delete(message);\n }\n\n /**\n * Re-points the observation after a `region` swap and picks up the messages the\n * new element brought with it (dynamic inserts, so their own `role` announces\n * them). The `#connected` guard is load-bearing: Stimulus runs target callbacks\n * for the initial markup *before* `connect()` and again during teardown *after*\n * `disconnect()`, and re-observing there would outlive the controller.\n */\n #resync(): void {\n if (!this.#connected) return;\n this.#syncObservation();\n for (const message of this.messageTargets) {\n if (this.#owns(message)) this.#process(message, false);\n }\n }\n\n /**\n * Points the mutation observation at the current `region` target, re-resolved on\n * every sync rather than captured at connect, so an element swapped in at runtime\n * is observed instead of the detached original.\n */\n #syncObservation(): void {\n this.#stopObserving();\n if (!this.hasRegionTarget || typeof MutationObserver === \"undefined\") return;\n this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));\n this.#observer.observe(this.regionTarget, { childList: true, subtree: true });\n }\n\n #stopObserving(): void {\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /**\n * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides\n * stay in sync. Binding is gated by `pauseOnHover`; unbinding is unconditional\n * and idempotent (a no-op when nothing was bound), which keeps teardown correct\n * even if `pauseOnHover` were ever toggled during a message's life.\n */\n #bindPause(message: HTMLElement): void {\n if (!this.pauseOnHoverValue) return;\n message.addEventListener(\"mouseenter\", this.#onEnter);\n message.addEventListener(\"mouseleave\", this.#onLeave);\n message.addEventListener(\"focusin\", this.#onEnter);\n message.addEventListener(\"focusout\", this.#onLeave);\n }\n\n #unbindPause(message: HTMLElement): void {\n message.removeEventListener(\"mouseenter\", this.#onEnter);\n message.removeEventListener(\"mouseleave\", this.#onLeave);\n message.removeEventListener(\"focusin\", this.#onEnter);\n message.removeEventListener(\"focusout\", this.#onLeave);\n }\n\n /** Dismisses the flash whose close control fired the event. */\n dismiss(event: Event): void {\n const target = (event.currentTarget || event.target) as HTMLElement | null;\n const message = target?.closest<HTMLElement>(MESSAGE_SELECTOR);\n if (message) this.#beginDismiss(message, \"user\");\n }\n\n /** Processes messages added after connect (Turbo Stream); their own role announces them. */\n #onMutations(mutations: MutationRecord[]): void {\n for (const mutation of mutations) {\n for (const node of mutation.addedNodes) {\n if (!(node instanceof HTMLElement)) continue;\n if (node.matches(MESSAGE_SELECTOR)) this.#process(node, false);\n for (const message of node.querySelectorAll<HTMLElement>(MESSAGE_SELECTOR)) {\n this.#process(message, false);\n }\n }\n }\n }\n\n /**\n * Applies role/state, wires pause listeners, schedules auto-dismiss, and either\n * bridges to the Announcer (`bridge`, for initial flashes) or leaves the message's\n * own role to do the announcing (dynamic inserts). Idempotent per message.\n */\n #process(message: HTMLElement, bridge: boolean): void {\n if (this.#state.has(message) || this.#order.includes(message)) return;\n if (this.#leaving.has(message)) return;\n\n const type = message.getAttribute(\"data-flash-type\") ?? \"\";\n const assertive = ASSERTIVE_TYPES.has(type);\n // Don't clobber an authored role; otherwise map the flash type to a live role.\n if (!message.hasAttribute(\"role\")) {\n message.setAttribute(\"role\", assertive ? \"alert\" : \"status\");\n }\n message.setAttribute(\"data-flash-state\", \"visible\");\n this.#order.push(message);\n\n this.#bindPause(message);\n\n const text = message.textContent?.trim() ?? \"\";\n this.dispatch(\"show\", { target: message, detail: { type, message: text } });\n if (bridge && text) {\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", { detail: { message: text, assertive } }),\n );\n }\n\n this.#startTimer(message);\n this.#enforceMax();\n }\n\n /** Removes the oldest visible flashes once the count exceeds `max` (0 = unlimited). */\n #enforceMax(): void {\n if (this.maxValue <= 0) return;\n while (this.#order.length > this.maxValue) {\n const oldest = this.#order[0];\n if (!oldest) break;\n this.#beginDismiss(oldest, \"limit\");\n }\n }\n\n #startTimer(message: HTMLElement, duration = this.durationValue): void {\n if (duration <= 0) return;\n const existing = this.#state.get(message);\n if (existing?.id) this.#timers.clear(existing.id);\n const id = this.#timers.set(() => this.#beginDismiss(message, \"timeout\"), duration);\n this.#state.set(message, {\n id,\n startedAt: Date.now(),\n remaining: duration,\n paused: existing?.paused ?? new Set(),\n });\n }\n\n /**\n * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).\n * Hover and focus are independent reasons: the remaining time is banked on the\n * first of them, and {@link FlashController.#resume} waits for the last one.\n */\n #pause(message: HTMLElement, reason: \"focus\" | \"hover\"): void {\n const timer = this.#state.get(message);\n if (!timer) return;\n timer.paused.add(reason);\n if (timer.id === 0) return;\n\n this.#timers.clear(timer.id);\n // The bank has a floor of 1ms, so a deadline already spent when the pause arrives\n // (the timer sat queued in a throttled tab or behind a long task) settles right\n // after the last reason is released rather than during the event that paused it.\n // Pausing therefore never removes a message — which is what keeps the pointer's\n // target under the pointer and the focused control focused (WCAG 2.2 4.1.3).\n // Banking zero instead would strand the message: nothing resumes a spent window.\n const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));\n this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });\n }\n\n /** Resumes a paused message's auto-dismiss with the banked time. */\n #resume(message: HTMLElement, reason: \"focus\" | \"hover\"): void {\n const timer = this.#state.get(message);\n if (!timer) return;\n timer.paused.delete(reason);\n // Still held by the other reason (the pointer left, focus stayed, or vice versa).\n if (timer.paused.size > 0) return;\n // Only resume a genuinely paused timer; a running one keeps its own deadline.\n if (timer.id !== 0) return;\n this.#startTimer(message, timer.remaining);\n }\n\n /** Releases every per-message resource: timer, stacking slot, pause listeners. */\n #forget(message: HTMLElement): void {\n const timer = this.#state.get(message);\n if (timer?.id) this.#timers.clear(timer.id);\n this.#state.delete(message);\n const index = this.#order.indexOf(message);\n if (index !== -1) this.#order.splice(index, 1);\n this.#unbindPause(message);\n }\n\n /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */\n #beginDismiss(message: HTMLElement, reason: \"timeout\" | \"user\" | \"limit\"): void {\n // One removal, one `dismiss`: a close control fired during the leaving\n // transition, or a repeated request, finds the message already released and\n // must not start a second finalize. Reading the bookkeeping rather than\n // `data-flash-state` also keeps `#enforceMax`'s loop terminating, since the\n // release below is what shrinks `#order`.\n if (!this.#state.has(message) && !this.#order.includes(message)) return;\n this.#forget(message);\n this.#leaving.add(message);\n\n message.setAttribute(\"data-flash-state\", \"leaving\");\n\n const finalize = (): void => {\n // The claim taken above is the license to remove: a message that lost the target\n // attribute during the transition belongs to the consumer by the time this runs.\n if (!this.#leaving.delete(message)) return;\n message.remove();\n this.dispatch(\"dismiss\", { detail: { element: message, reason } });\n };\n\n const transition = this.#transitionMs(message);\n if (transition > 0) {\n this.#timers.set(finalize, transition);\n } else {\n finalize();\n }\n }\n\n /** Maximum transition total (duration + delay) of `el` in ms (0 when none / unsupported). */\n #transitionMs(el: HTMLElement): number {\n if (typeof window.getComputedStyle !== \"function\") return 0;\n return maxTransitionTotalMs(window.getComputedStyle(el));\n }\n}\n"]}
@@ -2,6 +2,13 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/form_validation_controller.ts
4
4
 
5
+ // src/utils/default_attribute.ts
6
+ function setDefaultAttribute(element, name, value) {
7
+ if (element.hasAttribute(name)) return false;
8
+ element.setAttribute(name, value);
9
+ return true;
10
+ }
11
+
5
12
  // src/utils/focus_trap.ts
6
13
  var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
14
 
@@ -79,8 +86,7 @@ var FormValidationController = class _FormValidationController extends Controlle
79
86
  };
80
87
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
81
88
  connect() {
82
- if (!this.element.hasAttribute("novalidate")) {
83
- this.element.setAttribute("novalidate", "");
89
+ if (setDefaultAttribute(this.element, "novalidate", "")) {
84
90
  this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
85
91
  }
86
92
  document.addEventListener("submit", this.#onSubmit, true);