stimeo-ui 0.4.0 → 0.6.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 (173) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/controllers/announcer_controller.js.map +1 -1
  3. package/dist/controllers/aspect_ratio_controller.d.ts +3 -5
  4. package/dist/controllers/aspect_ratio_controller.js +19 -11
  5. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  6. package/dist/controllers/avatar_controller.d.ts +36 -15
  7. package/dist/controllers/avatar_controller.js +195 -40
  8. package/dist/controllers/avatar_controller.js.map +1 -1
  9. package/dist/controllers/breadcrumb_controller.js +5 -1
  10. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  11. package/dist/controllers/carousel_controller.d.ts +12 -3
  12. package/dist/controllers/carousel_controller.js +90 -10
  13. package/dist/controllers/carousel_controller.js.map +1 -1
  14. package/dist/controllers/checkbox_controller.d.ts +31 -5
  15. package/dist/controllers/checkbox_controller.js +136 -25
  16. package/dist/controllers/checkbox_controller.js.map +1 -1
  17. package/dist/controllers/clipboard_controller.js +8 -3
  18. package/dist/controllers/clipboard_controller.js.map +1 -1
  19. package/dist/controllers/collapsible_controller.d.ts +1 -1
  20. package/dist/controllers/collapsible_controller.js +4 -1
  21. package/dist/controllers/collapsible_controller.js.map +1 -1
  22. package/dist/controllers/color_picker_controller.d.ts +9 -3
  23. package/dist/controllers/color_picker_controller.js +41 -11
  24. package/dist/controllers/color_picker_controller.js.map +1 -1
  25. package/dist/controllers/combobox_controller.js.map +1 -1
  26. package/dist/controllers/command_palette_controller.js.map +1 -1
  27. package/dist/controllers/context_menu_controller.d.ts +1 -1
  28. package/dist/controllers/context_menu_controller.js +2 -2
  29. package/dist/controllers/context_menu_controller.js.map +1 -1
  30. package/dist/controllers/countdown_controller.d.ts +5 -3
  31. package/dist/controllers/countdown_controller.js +5 -1
  32. package/dist/controllers/countdown_controller.js.map +1 -1
  33. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  34. package/dist/controllers/date_range_picker_controller.js +162 -31
  35. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  36. package/dist/controllers/direct_upload_controller.d.ts +1 -1
  37. package/dist/controllers/direct_upload_controller.js +3 -3
  38. package/dist/controllers/direct_upload_controller.js.map +1 -1
  39. package/dist/controllers/empty_state_controller.d.ts +27 -7
  40. package/dist/controllers/empty_state_controller.js +107 -16
  41. package/dist/controllers/empty_state_controller.js.map +1 -1
  42. package/dist/controllers/file_dropzone_controller.d.ts +7 -1
  43. package/dist/controllers/file_dropzone_controller.js +26 -3
  44. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  45. package/dist/controllers/flash_controller.d.ts +25 -5
  46. package/dist/controllers/flash_controller.js +161 -21
  47. package/dist/controllers/flash_controller.js.map +1 -1
  48. package/dist/controllers/focus_controller.d.ts +2 -3
  49. package/dist/controllers/focus_controller.js.map +1 -1
  50. package/dist/controllers/form_validation_controller.js +8 -2
  51. package/dist/controllers/form_validation_controller.js.map +1 -1
  52. package/dist/controllers/frame_loading_controller.d.ts +19 -2
  53. package/dist/controllers/frame_loading_controller.js +94 -22
  54. package/dist/controllers/frame_loading_controller.js.map +1 -1
  55. package/dist/controllers/highlight_controller.d.ts +8 -4
  56. package/dist/controllers/highlight_controller.js +38 -1
  57. package/dist/controllers/highlight_controller.js.map +1 -1
  58. package/dist/controllers/hover_card_controller.d.ts +2 -2
  59. package/dist/controllers/hover_card_controller.js.map +1 -1
  60. package/dist/controllers/idle_controller.d.ts +5 -4
  61. package/dist/controllers/idle_controller.js +39 -6
  62. package/dist/controllers/idle_controller.js.map +1 -1
  63. package/dist/controllers/listbox_controller.js.map +1 -1
  64. package/dist/controllers/local_time_controller.js +2 -0
  65. package/dist/controllers/local_time_controller.js.map +1 -1
  66. package/dist/controllers/masonry_controller.d.ts +1 -1
  67. package/dist/controllers/masonry_controller.js +1 -1
  68. package/dist/controllers/masonry_controller.js.map +1 -1
  69. package/dist/controllers/menubar_controller.js +5 -3
  70. package/dist/controllers/menubar_controller.js.map +1 -1
  71. package/dist/controllers/meter_controller.js +3 -1
  72. package/dist/controllers/meter_controller.js.map +1 -1
  73. package/dist/controllers/multi_select_controller.d.ts +46 -12
  74. package/dist/controllers/multi_select_controller.js +460 -151
  75. package/dist/controllers/multi_select_controller.js.map +1 -1
  76. package/dist/controllers/network_status_controller.d.ts +15 -13
  77. package/dist/controllers/network_status_controller.js +1 -3
  78. package/dist/controllers/network_status_controller.js.map +1 -1
  79. package/dist/controllers/number_input_controller.d.ts +29 -3
  80. package/dist/controllers/number_input_controller.js +455 -64
  81. package/dist/controllers/number_input_controller.js.map +1 -1
  82. package/dist/controllers/overflow_menu_controller.js +5 -1
  83. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  84. package/dist/controllers/pagination_controller.js +38 -1
  85. package/dist/controllers/pagination_controller.js.map +1 -1
  86. package/dist/controllers/password_strength_controller.d.ts +4 -4
  87. package/dist/controllers/password_strength_controller.js +21 -3
  88. package/dist/controllers/password_strength_controller.js.map +1 -1
  89. package/dist/controllers/persist_controller.d.ts +4 -4
  90. package/dist/controllers/persist_controller.js +24 -5
  91. package/dist/controllers/persist_controller.js.map +1 -1
  92. package/dist/controllers/pointer_drag_controller.js +10 -0
  93. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  94. package/dist/controllers/popover_controller.d.ts +2 -2
  95. package/dist/controllers/popover_controller.js.map +1 -1
  96. package/dist/controllers/portal_controller.d.ts +1 -2
  97. package/dist/controllers/portal_controller.js +10 -0
  98. package/dist/controllers/portal_controller.js.map +1 -1
  99. package/dist/controllers/progress_controller.d.ts +1 -1
  100. package/dist/controllers/progress_controller.js +7 -3
  101. package/dist/controllers/progress_controller.js.map +1 -1
  102. package/dist/controllers/radio_group_controller.d.ts +41 -15
  103. package/dist/controllers/radio_group_controller.js +540 -56
  104. package/dist/controllers/radio_group_controller.js.map +1 -1
  105. package/dist/controllers/range_slider_controller.d.ts +23 -0
  106. package/dist/controllers/range_slider_controller.js +385 -94
  107. package/dist/controllers/range_slider_controller.js.map +1 -1
  108. package/dist/controllers/rating_controller.d.ts +36 -31
  109. package/dist/controllers/rating_controller.js +274 -89
  110. package/dist/controllers/rating_controller.js.map +1 -1
  111. package/dist/controllers/relative_time_controller.js +2 -0
  112. package/dist/controllers/relative_time_controller.js.map +1 -1
  113. package/dist/controllers/resizable_controller.js +33 -0
  114. package/dist/controllers/resizable_controller.js.map +1 -1
  115. package/dist/controllers/roving_controller.d.ts +4 -0
  116. package/dist/controllers/roving_controller.js +60 -5
  117. package/dist/controllers/roving_controller.js.map +1 -1
  118. package/dist/controllers/scroll_area_controller.d.ts +7 -1
  119. package/dist/controllers/scroll_area_controller.js +155 -23
  120. package/dist/controllers/scroll_area_controller.js.map +1 -1
  121. package/dist/controllers/scroll_visibility_controller.js +33 -0
  122. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  123. package/dist/controllers/separator_controller.js +13 -17
  124. package/dist/controllers/separator_controller.js.map +1 -1
  125. package/dist/controllers/skeleton_controller.d.ts +2 -2
  126. package/dist/controllers/skeleton_controller.js +71 -3
  127. package/dist/controllers/skeleton_controller.js.map +1 -1
  128. package/dist/controllers/slider_controller.d.ts +17 -1
  129. package/dist/controllers/slider_controller.js +325 -48
  130. package/dist/controllers/slider_controller.js.map +1 -1
  131. package/dist/controllers/spinner_controller.d.ts +15 -10
  132. package/dist/controllers/spinner_controller.js +18 -3
  133. package/dist/controllers/spinner_controller.js.map +1 -1
  134. package/dist/controllers/step_indicator_controller.d.ts +1 -1
  135. package/dist/controllers/step_indicator_controller.js +3 -1
  136. package/dist/controllers/step_indicator_controller.js.map +1 -1
  137. package/dist/controllers/stepper_controller.js +2 -0
  138. package/dist/controllers/stepper_controller.js.map +1 -1
  139. package/dist/controllers/switch_controller.d.ts +12 -8
  140. package/dist/controllers/switch_controller.js +162 -18
  141. package/dist/controllers/switch_controller.js.map +1 -1
  142. package/dist/controllers/tags_input_controller.d.ts +41 -11
  143. package/dist/controllers/tags_input_controller.js +356 -120
  144. package/dist/controllers/tags_input_controller.js.map +1 -1
  145. package/dist/controllers/textarea_autosize_controller.js +1 -1
  146. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  147. package/dist/controllers/time_picker_controller.d.ts +42 -10
  148. package/dist/controllers/time_picker_controller.js +296 -104
  149. package/dist/controllers/time_picker_controller.js.map +1 -1
  150. package/dist/controllers/toggle_group_controller.d.ts +39 -13
  151. package/dist/controllers/toggle_group_controller.js +378 -55
  152. package/dist/controllers/toggle_group_controller.js.map +1 -1
  153. package/dist/controllers/toolbar_controller.js +5 -3
  154. package/dist/controllers/toolbar_controller.js.map +1 -1
  155. package/dist/controllers/tooltip_controller.d.ts +2 -2
  156. package/dist/controllers/tooltip_controller.js.map +1 -1
  157. package/dist/controllers/transition_controller.d.ts +2 -3
  158. package/dist/controllers/transition_controller.js.map +1 -1
  159. package/dist/controllers/tree_view_controller.d.ts +1 -2
  160. package/dist/controllers/tree_view_controller.js +24 -4
  161. package/dist/controllers/tree_view_controller.js.map +1 -1
  162. package/dist/index.js +3811 -1048
  163. package/dist/index.js.map +1 -1
  164. package/dist/inspector/cli.d.ts +92 -4
  165. package/dist/inspector/cli.js +174 -6
  166. package/dist/inspector/cli.js.map +1 -1
  167. package/dist/inspector/cli_bin.js +234 -8
  168. package/dist/inspector/cli_bin.js.map +1 -1
  169. package/dist/inspector/examples.json +24 -24
  170. package/dist/inspector/manifest.json +573 -59
  171. package/dist/positioning/index.d.ts +2 -2
  172. package/dist/positioning/index.js.map +1 -1
  173. package/package.json +3 -3
@@ -29,23 +29,120 @@ var EmptyStateController = class extends Controller {
29
29
  };
30
30
  static events = ["change"];
31
31
  #observer = null;
32
+ /** Whether the controller is between `connect()` and `disconnect()`. */
33
+ #connected = false;
32
34
  /** Last applied empty state; `null` until the first sync so connect emits nothing. */
33
35
  #empty = null;
34
36
  connect() {
35
- if (!this.hasListTarget) return;
36
- if (typeof MutationObserver !== "undefined") {
37
- this.#observer = new MutationObserver(() => this.#apply());
38
- this.#observer.observe(this.listTarget, { childList: true });
39
- }
40
- this.#apply();
37
+ this.#connected = true;
38
+ this.#syncObservation();
39
+ this.#update();
41
40
  }
42
41
  disconnect() {
42
+ this.#connected = false;
43
+ this.#stopObserving();
44
+ }
45
+ /** Follows a `list` element swapped in at runtime (Turbo Stream `replace` / morph). */
46
+ listTargetConnected() {
47
+ this.#resync();
48
+ }
49
+ /** Releases the observation when the `list` element leaves the target set. */
50
+ listTargetDisconnected() {
51
+ this.#resync();
52
+ }
53
+ /** Syncs an `empty` element that arrives — or is replaced — at runtime. */
54
+ emptyTargetConnected() {
55
+ this.#resync();
56
+ }
57
+ /**
58
+ * Syncs the `empty` element that remains when one leaves the target set. A
59
+ * single-target getter resolves to the first `empty` element in document order,
60
+ * so a swap that inserts the replacement *before* removing the original (Turbo
61
+ * Stream `after` / `before` / `append` followed by `remove`) leaves the
62
+ * replacement untouched until the original goes — this callback is that moment.
63
+ */
64
+ emptyTargetDisconnected() {
65
+ this.#resync();
66
+ }
67
+ /** Re-renders when application code (or a Turbo morph) changes `itemSelector` at runtime. */
68
+ itemSelectorValueChanged() {
69
+ this.#resync();
70
+ }
71
+ /**
72
+ * Re-points the observation and re-renders after a target or selector change.
73
+ * The `#connected` guard is load-bearing: Stimulus runs value and target
74
+ * callbacks for the initial markup *before* `connect()` and runs target
75
+ * callbacks during teardown *after* `disconnect()`, and re-observing there
76
+ * would outlive the controller.
77
+ */
78
+ #resync() {
79
+ if (!this.#connected) return;
80
+ this.#syncObservation();
81
+ this.#update();
82
+ }
83
+ /**
84
+ * Points the mutation observation at the current `list` target — re-resolved on
85
+ * every sync rather than captured at connect, so an element swapped in at
86
+ * runtime is observed instead of the detached original.
87
+ *
88
+ * The observation covers exactly what the count predicate reads. With no
89
+ * `itemSelector` the count is the child element count, which only `childList`
90
+ * can change. With one, the predicate reads the children themselves, so
91
+ * attribute and descendant mutations are watched too — and the controller's own
92
+ * writes are filtered back out, or toggling `hidden` would re-enter the render.
93
+ */
94
+ #syncObservation() {
95
+ this.#stopObserving();
96
+ if (!this.hasListTarget || typeof MutationObserver === "undefined") return;
97
+ const watchesItems = this.itemSelectorValue.length > 0;
98
+ this.#observer = new MutationObserver((records) => {
99
+ if (records.some((record) => this.#affectsCount(record))) this.#update();
100
+ });
101
+ this.#observer.observe(this.listTarget, {
102
+ childList: true,
103
+ subtree: watchesItems,
104
+ attributes: watchesItems
105
+ });
106
+ }
107
+ #stopObserving() {
43
108
  this.#observer?.disconnect();
44
109
  this.#observer = null;
45
110
  }
46
- /** Recomputes the count and syncs visibility, hooks, and the change event. */
47
- #apply() {
48
- if (!this.hasListTarget) return;
111
+ /**
112
+ * Whether a mutation can change the item count. An attribute written on a
113
+ * target this controller owns is its own echo — `hidden` on `list` / `empty`,
114
+ * and the hooks on the controller element when the list *is* that element.
115
+ */
116
+ #affectsCount(record) {
117
+ if (record.type !== "attributes") return true;
118
+ const own = record.target === this.listTarget || this.hasEmptyTarget && record.target === this.emptyTarget;
119
+ return !own;
120
+ }
121
+ /**
122
+ * Renders the count, then reports a crossed boundary. The announcement copy is
123
+ * read here rather than while rendering: it is the wording of the report, not
124
+ * an input to what is displayed.
125
+ */
126
+ #update() {
127
+ const count = this.#render();
128
+ if (count === null) return;
129
+ const empty = count === 0;
130
+ const crossed = this.#empty !== null && empty !== this.#empty;
131
+ this.#empty = empty;
132
+ if (!crossed) return;
133
+ this.dispatch("change", { detail: { count, empty } });
134
+ announce(
135
+ fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
136
+ );
137
+ }
138
+ /**
139
+ * Syncs visibility and the state hooks to the current item count, and returns
140
+ * it. `null` when there is no `list` target to count.
141
+ *
142
+ * @stimeoRenderRoot
143
+ */
144
+ #render() {
145
+ if (!this.hasListTarget) return null;
49
146
  const count = this.#count();
50
147
  const empty = count === 0;
51
148
  this.element.setAttribute("data-count", String(count));
@@ -56,13 +153,7 @@ var EmptyStateController = class extends Controller {
56
153
  }
57
154
  this.listTarget.hidden = empty;
58
155
  if (this.hasEmptyTarget) this.emptyTarget.hidden = !empty;
59
- if (this.#empty !== null && empty !== this.#empty) {
60
- this.dispatch("change", { detail: { count, empty } });
61
- announce(
62
- fillTemplate(empty ? this.announceTextValue : this.announceFilledTextValue, { count })
63
- );
64
- }
65
- this.#empty = empty;
156
+ return count;
66
157
  }
67
158
  /** Item count: element children matching `itemSelector`, or all element children. */
68
159
  #count() {
@@ -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"]}
@@ -38,7 +38,8 @@ import { Controller } from '@hotwired/stimulus';
38
38
  * `stimeo--file-dropzone:change` with the current `File[]`.
39
39
  * - Removing a file revokes its `objectURL` and moves focus to the next (else
40
40
  * previous) remove button, falling back to the trigger; `disconnect()` revokes
41
- * every outstanding `objectURL`.
41
+ * every outstanding `objectURL`. Replacing the `list` target rebinds delegated
42
+ * removal and moves the client-only previews into the replacement.
42
43
  */
43
44
  declare class FileDropzoneController extends Controller<HTMLElement> {
44
45
  #private;
@@ -61,6 +62,7 @@ declare class FileDropzoneController extends Controller<HTMLElement> {
61
62
  static events: readonly ["change", "reject"];
62
63
  readonly inputTarget: HTMLInputElement;
63
64
  readonly listTarget: HTMLElement;
65
+ readonly listTargets: HTMLElement[];
64
66
  readonly triggerTarget: HTMLElement;
65
67
  readonly itemTemplateTarget: HTMLTemplateElement;
66
68
  readonly statusTarget: HTMLElement;
@@ -77,6 +79,10 @@ declare class FileDropzoneController extends Controller<HTMLElement> {
77
79
  connect(): void;
78
80
  /** Revokes any outstanding preview URLs so none leaks across navigations. */
79
81
  disconnect(): void;
82
+ /** Rebinds removal and restores client-only previews when Turbo replaces the list target. */
83
+ listTargetConnected(list: HTMLElement): void;
84
+ /** Releases only the list target that actually disconnected. */
85
+ listTargetDisconnected(list: HTMLElement): void;
80
86
  /** Opens the native file dialog. Bound via `data-action` (trigger click). */
81
87
  openDialog(): void;
82
88
  /** Adds the files chosen through the native dialog. */
@@ -12,18 +12,38 @@ var FileDropzoneController = class extends Controller {
12
12
  static events = ["change", "reject"];
13
13
  /** Selected files paired with their rendered item and any preview objectURL. */
14
14
  #entries = [];
15
+ /** Prevents initial and teardown target callbacks from binding outside controller lifetime. */
16
+ #connected = false;
15
17
  /** Wires file removal as a delegated listener on the list container. */
16
18
  connect() {
17
- if (this.hasListTarget) this.listTarget.addEventListener("click", this.#onItemClick);
19
+ this.#connected = true;
20
+ if (this.hasListTarget) this.#bindList(this.listTarget);
18
21
  }
19
22
  /** Revokes any outstanding preview URLs so none leaks across navigations. */
20
23
  disconnect() {
21
- if (this.hasListTarget) this.listTarget.removeEventListener("click", this.#onItemClick);
24
+ this.#connected = false;
25
+ for (const list of this.listTargets) list.removeEventListener("click", this.#onItemClick);
22
26
  for (const entry of this.#entries) {
23
27
  if (entry.url) URL.revokeObjectURL(entry.url);
24
28
  }
25
29
  this.#entries.length = 0;
26
30
  }
31
+ /** Rebinds removal and restores client-only previews when Turbo replaces the list target. */
32
+ listTargetConnected(list) {
33
+ if (!this.#connected) return;
34
+ this.#bindList(list);
35
+ }
36
+ /** Releases only the list target that actually disconnected. */
37
+ listTargetDisconnected(list) {
38
+ list.removeEventListener("click", this.#onItemClick);
39
+ }
40
+ /** Binds delegated removal once and moves live preview items into the current list. */
41
+ #bindList(list) {
42
+ list.addEventListener("click", this.#onItemClick);
43
+ for (const entry of this.#entries) {
44
+ if (!list.contains(entry.item)) list.appendChild(entry.item);
45
+ }
46
+ }
27
47
  /** Opens the native file dialog. Bound via `data-action` (trigger click). */
28
48
  openDialog() {
29
49
  this.inputTarget.click();
@@ -55,8 +75,11 @@ var FileDropzoneController = class extends Controller {
55
75
  * an item is appended without waiting on Stimulus to wire a freshly created element.
56
76
  */
57
77
  #onItemClick = (event) => {
78
+ const list = event.currentTarget;
58
79
  const button = event.target.closest("button");
59
- if (!button || !this.hasListTarget || !this.listTarget.contains(button)) return;
80
+ if (!this.#connected || !list || !button || !this.hasListTarget || list !== this.listTarget || !list.contains(button)) {
81
+ return;
82
+ }
60
83
  const index = this.#entries.findIndex((entry) => entry.item.contains(button));
61
84
  if (index !== -1) this.#removeAt(index);
62
85
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/file_dropzone_controller.ts"],"names":[],"mappings":";;;AA0CO,IAAM,sBAAA,GAAN,cAAqC,UAAA,CAAwB;AAAA,EAClE,OAAgB,UAAU,CAAC,MAAA,EAAQ,WAAW,OAAA,EAAS,MAAA,EAAQ,MAAA,EAAQ,cAAA,EAAgB,QAAQ,CAAA;AAAA,EAC/F,OAAgB,MAAA,GAAS;AAAA,IACvB,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACpC,QAAA,EAAU,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACrC,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,wBAAA;AAAyB,GAC/D;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,YAAY,aAAA,EAAe,YAAA,EAAc,UAAU,YAAY,CAAA;AAAA,EACjF,OAAO,MAAA,GAAS,CAAC,QAAA,EAAU,QAAQ,CAAA;AAAA;AAAA,EAmB1B,WAAmE,EAAC;AAAA;AAAA,EAGpE,OAAA,GAAgB;AACvB,IAAA,IAAI,KAAK,aAAA,EAAe,IAAA,CAAK,WAAW,gBAAA,CAAiB,OAAA,EAAS,KAAK,YAAY,CAAA;AAAA,EACrF;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,IAAI,KAAK,aAAA,EAAe,IAAA,CAAK,WAAW,mBAAA,CAAoB,OAAA,EAAS,KAAK,YAAY,CAAA;AACtF,IAAA,KAAA,MAAW,KAAA,IAAS,KAAK,QAAA,EAAU;AACjC,MAAA,IAAI,KAAA,CAAM,GAAA,EAAK,GAAA,CAAI,eAAA,CAAgB,MAAM,GAAG,CAAA;AAAA,IAC9C;AACA,IAAA,IAAA,CAAK,SAAS,MAAA,GAAS,CAAA;AAAA,EACzB;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,IAAA,CAAK,YAAY,KAAA,EAAM;AAAA,EACzB;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,IAAI,KAAK,WAAA,CAAY,KAAA,OAAY,SAAA,CAAU,IAAA,CAAK,YAAY,KAAK,CAAA;AAEjE,IAAA,IAAA,CAAK,YAAY,KAAA,GAAQ,EAAA;AAAA,EAC3B;AAAA;AAAA,EAGA,WAAW,KAAA,EAAwB;AACjC,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAI,KAAK,aAAA,EAAe,IAAA,CAAK,UAAA,CAAW,YAAA,CAAa,iBAAiB,EAAE,CAAA;AACxE,IAAA,IAAA,CAAK,UAAA,CAAW,KAAK,cAAc,CAAA;AAAA,EACrC;AAAA;AAAA,EAGA,WAAA,GAAoB;AAClB,IAAA,IAAI,IAAA,CAAK,aAAA,EAAe,IAAA,CAAK,UAAA,CAAW,gBAAgB,eAAe,CAAA;AAAA,EACzE;AAAA;AAAA,EAGA,OAAO,KAAA,EAAwB;AAC7B,IAAA,KAAA,CAAM,cAAA,EAAe;AACrB,IAAA,IAAI,IAAA,CAAK,aAAA,EAAe,IAAA,CAAK,UAAA,CAAW,gBAAgB,eAAe,CAAA;AACvE,IAAA,IAAI,MAAM,YAAA,EAAc,KAAA,OAAY,SAAA,CAAU,KAAA,CAAM,aAAa,KAAK,CAAA;AAAA,EACxE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOS,YAAA,GAAe,CAAC,KAAA,KAA4B;AACnD,IAAA,MAAM,MAAA,GAAU,KAAA,CAAM,MAAA,CAAuB,OAAA,CAAQ,QAAQ,CAAA;AAC7D,IAAA,IAAI,CAAC,MAAA,IAAU,CAAC,IAAA,CAAK,aAAA,IAAiB,CAAC,IAAA,CAAK,UAAA,CAAW,QAAA,CAAS,MAAM,CAAA,EAAG;AACzE,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,QAAA,CAAS,SAAA,CAAU,CAAC,UAAU,KAAA,CAAM,IAAA,CAAK,QAAA,CAAS,MAAM,CAAC,CAAA;AAC5E,IAAA,IAAI,KAAA,KAAU,EAAA,EAAI,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA;AAAA,EACxC,CAAA;AAAA;AAAA,EAGA,UAAU,KAAA,EAAuB;AAC/B,IAAA,IAAI,OAAA,GAAU,KAAA;AACd,IAAA,IAAI,IAAA,CAAK,aAAA,EAAe,IAAA,CAAK,UAAA,CAAW,gBAAgB,oCAAoC,CAAA;AAC5F,IAAA,KAAA,MAAW,IAAA,IAAQ,KAAA,CAAM,IAAA,CAAK,KAAK,CAAA,EAAG;AACpC,MAAA,MAAM,MAAA,GAAS,IAAA,CAAK,SAAA,CAAU,IAAI,CAAA;AAClC,MAAA,IAAI,MAAA,EAAQ;AACV,QAAA,IAAI,KAAK,aAAA,EAAe;AACtB,UAAA,IAAA,CAAK,UAAA,CAAW,YAAA,CAAa,oCAAA,EAAsC,EAAE,CAAA;AAAA,QACvE;AACA,QAAA,IAAA,CAAK,UAAA,CAAW,KAAK,IAAI,CAAA;AACzB,QAAA,IAAA,CAAK,QAAA,CAAS,UAAU,EAAE,MAAA,EAAQ,EAAE,IAAA,EAAM,MAAA,IAAU,CAAA;AACpD,QAAA;AAAA,MACF;AACA,MAAA,IAAA,CAAK,YAAY,IAAI,CAAA;AACrB,MAAA,IAAA,CAAK,UAAA,CAAW,KAAK,IAAI,CAAA;AACzB,MAAA,OAAA,GAAU,IAAA;AAAA,IACZ;AACA,IAAA,IAAI,OAAA,EAAS,IAAA,CAAK,QAAA,CAAS,QAAA,EAAU,EAAE,MAAA,EAAQ,EAAE,KAAA,EAAO,IAAA,CAAK,MAAA,EAAO,EAAG,CAAA;AAAA,EACzE;AAAA;AAAA,EAGA,UAAU,IAAA,EAA8C;AACtD,IAAA,MAAM,QAAQ,IAAA,CAAK,kBAAA;AACnB,IAAA,IAAI,QAAQ,CAAA,IAAK,IAAA,CAAK,QAAA,CAAS,MAAA,IAAU,OAAO,OAAO,OAAA;AACvD,IAAA,IAAI,CAAC,IAAA,CAAK,cAAA,CAAe,IAAI,GAAG,OAAO,MAAA;AACvC,IAAA,IAAI,KAAK,YAAA,GAAe,CAAA,IAAK,KAAK,IAAA,GAAO,IAAA,CAAK,cAAc,OAAO,MAAA;AACnE,IAAA,OAAO,IAAA;AAAA,EACT;AAAA;AAAA,EAGA,YAAY,IAAA,EAAkB;AAC5B,IAAA,IAAI,CAAC,IAAA,CAAK,qBAAA,IAAyB,CAAC,KAAK,aAAA,EAAe;AACxD,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,kBAAA,CAAmB,OAAA,CAAQ,UAAU,IAAI,CAAA;AAC/D,IAAA,MAAM,IAAA,GAAO,QAAA,CAAS,aAAA,CAA2B,4CAA4C,CAAA;AAC7F,IAAA,MAAM,IAAA,GAAO,QAAA,CAAS,aAAA,CAA2B,kCAAkC,CAAA;AACnF,IAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,aAAA,CAAgC,mCAAmC,CAAA;AAC1F,IAAA,MAAM,MAAA,GAAS,QAAA,CAAS,aAAA,CAAiC,QAAQ,CAAA;AACjE,IAAA,IAAI,CAAC,IAAA,EAAM;AACX,IAAA,IAAI,IAAA,EAAM,IAAA,CAAK,WAAA,GAAc,IAAA,CAAK,IAAA;AAClC,IAAA,IAAI,QAAQ,MAAA,CAAO,YAAA,CAAa,cAAc,CAAA,OAAA,EAAU,IAAA,CAAK,IAAI,CAAA,CAAE,CAAA;AAEnE,IAAA,IAAI,GAAA;AACJ,IAAA,IAAI,KAAA,IAAS,IAAA,CAAK,IAAA,CAAK,UAAA,CAAW,QAAQ,CAAA,EAAG;AAC3C,MAAA,GAAA,GAAM,GAAA,CAAI,gBAAgB,IAAI,CAAA;AAC9B,MAAA,KAAA,CAAM,GAAA,GAAM,GAAA;AACZ,MAAA,KAAA,CAAM,MAAM,IAAA,CAAK,IAAA;AACjB,MAAA,KAAA,CAAM,MAAA,GAAS,KAAA;AAAA,IACjB,WAAW,KAAA,EAAO;AAChB,MAAA,KAAA,CAAM,MAAA,GAAS,IAAA;AAAA,IACjB;AAEA,IAAA,IAAA,CAAK,UAAA,CAAW,YAAY,QAAQ,CAAA;AACpC,IAAA,IAAA,CAAK,SAAS,IAAA,CAAK,EAAE,IAAA,EAAM,IAAA,EAAM,KAAK,CAAA;AAAA,EACxC;AAAA;AAAA,EAGA,UAAU,KAAA,EAAqB;AAC7B,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,QAAA,CAAS,KAAK,CAAA;AACjC,IAAA,IAAI,CAAC,KAAA,EAAO;AACZ,IAAA,IAAI,KAAA,CAAM,GAAA,EAAK,GAAA,CAAI,eAAA,CAAgB,MAAM,GAAG,CAAA;AAC5C,IAAA,KAAA,CAAM,KAAK,MAAA,EAAO;AAClB,IAAA,IAAA,CAAK,QAAA,CAAS,MAAA,CAAO,KAAA,EAAO,CAAC,CAAA;AAC7B,IAAA,IAAA,CAAK,UAAA,CAAW,KAAA,CAAM,IAAA,CAAK,IAAI,CAAA;AAC/B,IAAA,IAAA,CAAK,QAAA,CAAS,UAAU,EAAE,MAAA,EAAQ,EAAE,KAAA,EAAO,IAAA,CAAK,MAAA,EAAO,EAAG,CAAA;AAE1D,IAAA,MAAM,UAAU,IAAA,CAAK,cAAA;AACrB,IAAA,IAAI,OAAA,CAAQ,WAAW,CAAA,EAAG;AACxB,MAAA,IAAI,IAAA,CAAK,gBAAA,EAAkB,IAAA,CAAK,aAAA,CAAc,KAAA,EAAM;AAAA,IACtD,CAAA,MAAO;AACL,MAAA,CAAC,OAAA,CAAQ,KAAK,CAAA,IAAK,OAAA,CAAQ,QAAQ,MAAA,GAAS,CAAC,IAAI,KAAA,EAAM;AAAA,IACzD;AAAA,EACF;AAAA;AAAA,EAGA,eAAe,IAAA,EAAqB;AAClC,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,WAAA,CAAY,MAAA,CAAO,IAAA,EAAK;AAC5C,IAAA,IAAI,MAAA,KAAW,IAAI,OAAO,IAAA;AAC1B,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,IAAA,CAAK,WAAA,EAAY;AACnC,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,IAAA,CAAK,WAAA,EAAY;AACnC,IAAA,OAAO,OAAO,KAAA,CAAM,GAAG,CAAA,CAAE,IAAA,CAAK,CAAC,GAAA,KAAQ;AACrC,MAAA,MAAM,KAAA,GAAQ,GAAA,CAAI,IAAA,EAAK,CAAE,WAAA,EAAY;AACrC,MAAA,IAAI,KAAA,KAAU,IAAI,OAAO,KAAA;AACzB,MAAA,IAAI,MAAM,UAAA,CAAW,GAAG,GAAG,OAAO,IAAA,CAAK,SAAS,KAAK,CAAA;AACrD,MAAA,IAAI,KAAA,CAAM,QAAA,CAAS,IAAI,CAAA,EAAG,OAAO,IAAA,CAAK,UAAA,CAAW,KAAA,CAAM,KAAA,CAAM,CAAA,EAAG,EAAE,CAAC,CAAA;AACnE,MAAA,OAAO,IAAA,KAAS,KAAA;AAAA,IAClB,CAAC,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,WAAW,IAAA,EAAoB;AAC7B,IAAA,IAAI,IAAA,CAAK,eAAA,EAAiB,IAAA,CAAK,YAAA,CAAa,WAAA,GAAc,IAAA;AAAA,EAC5D;AAAA;AAAA,EAGA,IAAI,kBAAA,GAA6B;AAC/B,IAAA,IAAI,IAAA,CAAK,aAAA,GAAgB,CAAA,EAAG,OAAO,IAAA,CAAK,aAAA;AACxC,IAAA,OAAO,IAAA,CAAK,WAAA,CAAY,QAAA,GAAW,CAAA,GAAI,CAAA;AAAA,EACzC;AAAA;AAAA,EAGA,IAAI,cAAA,GAAsC;AACxC,IAAA,OAAO,MAAM,IAAA,CAAK,IAAA,CAAK,UAAA,CAAW,gBAAA,CAAoC,QAAQ,CAAC,CAAA;AAAA,EACjF;AAAA;AAAA,EAGA,IAAI,MAAA,GAAiB;AACnB,IAAA,OAAO,KAAK,QAAA,CAAS,GAAA,CAAI,CAAC,KAAA,KAAU,MAAM,IAAI,CAAA;AAAA,EAChD;AACF","file":"file_dropzone_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible file drag-and-drop / upload field.\n *\n * Markup contract (identifier: `stimeo--file-dropzone`):\n * <div data-controller=\"stimeo--file-dropzone\"\n * data-stimeo--file-dropzone-max-size-value=\"5242880\">\n * <div data-stimeo--file-dropzone-target=\"zone\"\n * data-action=\"dragover->stimeo--file-dropzone#onDragOver\n * dragleave->stimeo--file-dropzone#onDragLeave\n * drop->stimeo--file-dropzone#onDrop\">\n * <button type=\"button\" data-stimeo--file-dropzone-target=\"trigger\"\n * data-action=\"click->stimeo--file-dropzone#openDialog\">Choose…</button>\n * <input type=\"file\" accept=\"image/*\" multiple class=\"visually-hidden\"\n * data-stimeo--file-dropzone-target=\"input\"\n * data-action=\"change->stimeo--file-dropzone#onChange\" />\n * </div>\n * <ul data-stimeo--file-dropzone-target=\"list\" aria-label=\"Selected files\"></ul>\n * <span role=\"status\" aria-live=\"polite\" class=\"visually-hidden\"\n * data-stimeo--file-dropzone-target=\"status\"></span>\n * <template data-stimeo--file-dropzone-target=\"itemTemplate\">…</template>\n * </div>\n *\n * There is no single APG pattern; the native `<input type=\"file\">` stays the\n * primary, keyboard-operable path and the drop zone is an enhancement, mapping to\n * WCAG 2.1.1, 2.4.7, 4.1.2, 4.1.3, and 1.4.1 (drag state is conveyed in words,\n * not color alone).\n *\n * Behavior provided:\n * - Click / keyboard via the `trigger` opens the native file dialog; drag-and-drop\n * over the `zone` adds files (with a `data-dragover` flag and a spoken hint).\n * - Each file is validated against `accept`, `maxSize`, and the file count\n * (`maxFiles`, or 1 when the input is not `multiple`); rejects fire\n * `stimeo--file-dropzone:reject` and set `data-…-invalid`.\n * - Accepted files render from `itemTemplate` with a `Remove {name}` button and an\n * image thumbnail (`objectURL`); every change dispatches\n * `stimeo--file-dropzone:change` with the current `File[]`.\n * - Removing a file revokes its `objectURL` and moves focus to the next (else\n * previous) remove button, falling back to the trigger; `disconnect()` revokes\n * every outstanding `objectURL`.\n */\nexport class FileDropzoneController extends Controller<HTMLElement> {\n static override targets = [\"zone\", \"trigger\", \"input\", \"list\", \"item\", \"itemTemplate\", \"status\"];\n static override values = {\n maxSize: { type: Number, default: 0 },\n maxFiles: { type: Number, default: 0 },\n dragLabel: { type: String, default: \"Drop files to add them\" },\n };\n static actions = [\"onChange\", \"onDragLeave\", \"onDragOver\", \"onDrop\", \"openDialog\"] as const;\n static events = [\"change\", \"reject\"] as const;\n\n declare readonly inputTarget: HTMLInputElement;\n declare readonly listTarget: HTMLElement;\n declare readonly triggerTarget: HTMLElement;\n declare readonly itemTemplateTarget: HTMLTemplateElement;\n declare readonly statusTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasItemTemplateTarget: boolean;\n declare readonly hasStatusTarget: boolean;\n declare readonly hasZoneTarget: boolean;\n declare readonly zoneTarget: HTMLElement;\n\n declare maxSizeValue: number;\n declare maxFilesValue: number;\n declare dragLabelValue: string;\n\n /** Selected files paired with their rendered item and any preview objectURL. */\n readonly #entries: Array<{ file: File; item: HTMLElement; url?: string }> = [];\n\n /** Wires file removal as a delegated listener on the list container. */\n override connect(): void {\n if (this.hasListTarget) this.listTarget.addEventListener(\"click\", this.#onItemClick);\n }\n\n /** Revokes any outstanding preview URLs so none leaks across navigations. */\n override disconnect(): void {\n if (this.hasListTarget) this.listTarget.removeEventListener(\"click\", this.#onItemClick);\n for (const entry of this.#entries) {\n if (entry.url) URL.revokeObjectURL(entry.url);\n }\n this.#entries.length = 0;\n }\n\n /** Opens the native file dialog. Bound via `data-action` (trigger click). */\n openDialog(): void {\n this.inputTarget.click();\n }\n\n /** Adds the files chosen through the native dialog. */\n onChange(): void {\n if (this.inputTarget.files) this.#addFiles(this.inputTarget.files);\n // Clear so re-selecting the same file still fires `change`.\n this.inputTarget.value = \"\";\n }\n\n /** Marks the zone as a drop target and announces the affordance in words. */\n onDragOver(event: DragEvent): void {\n event.preventDefault();\n if (this.hasZoneTarget) this.zoneTarget.setAttribute(\"data-dragover\", \"\");\n this.#setStatus(this.dragLabelValue);\n }\n\n /** Clears the drag-over flag when the pointer leaves the zone. */\n onDragLeave(): void {\n if (this.hasZoneTarget) this.zoneTarget.removeAttribute(\"data-dragover\");\n }\n\n /** Accepts dropped files, clearing the drag-over state. */\n onDrop(event: DragEvent): void {\n event.preventDefault();\n if (this.hasZoneTarget) this.zoneTarget.removeAttribute(\"data-dragover\");\n if (event.dataTransfer?.files) this.#addFiles(event.dataTransfer.files);\n }\n\n /**\n * Removes the file whose remove button was clicked. Delegated on the list\n * container rather than bound per item via `data-action`, so it works the instant\n * an item is appended without waiting on Stimulus to wire a freshly created element.\n */\n readonly #onItemClick = (event: MouseEvent): void => {\n const button = (event.target as HTMLElement).closest(\"button\");\n if (!button || !this.hasListTarget || !this.listTarget.contains(button)) return;\n const index = this.#entries.findIndex((entry) => entry.item.contains(button));\n if (index !== -1) this.#removeAt(index);\n };\n\n /** Validates each incoming file and renders the accepted ones. */\n #addFiles(files: FileList): void {\n let changed = false;\n if (this.hasZoneTarget) this.zoneTarget.removeAttribute(\"data-stimeo--file-dropzone-invalid\");\n for (const file of Array.from(files)) {\n const reason = this.#validate(file);\n if (reason) {\n if (this.hasZoneTarget) {\n this.zoneTarget.setAttribute(\"data-stimeo--file-dropzone-invalid\", \"\");\n }\n this.#setStatus(file.name);\n this.dispatch(\"reject\", { detail: { file, reason } });\n continue;\n }\n this.#appendFile(file);\n this.#setStatus(file.name);\n changed = true;\n }\n if (changed) this.dispatch(\"change\", { detail: { files: this.#files } });\n }\n\n /** Returns the rejection reason for `file`, or `null` when it is acceptable. */\n #validate(file: File): \"type\" | \"size\" | \"count\" | null {\n const limit = this.#effectiveMaxFiles;\n if (limit > 0 && this.#entries.length >= limit) return \"count\";\n if (!this.#matchesAccept(file)) return \"type\";\n if (this.maxSizeValue > 0 && file.size > this.maxSizeValue) return \"size\";\n return null;\n }\n\n /** Builds one preview item (name, optional thumbnail, remove button). */\n #appendFile(file: File): void {\n if (!this.hasItemTemplateTarget || !this.hasListTarget) return;\n const fragment = this.itemTemplateTarget.content.cloneNode(true) as DocumentFragment;\n const item = fragment.querySelector<HTMLElement>('[data-stimeo--file-dropzone-target=\"item\"]');\n const name = fragment.querySelector<HTMLElement>('[data-file-dropzone-slot=\"name\"]');\n const thumb = fragment.querySelector<HTMLImageElement>('[data-file-dropzone-slot=\"thumb\"]');\n const button = fragment.querySelector<HTMLButtonElement>(\"button\");\n if (!item) return;\n if (name) name.textContent = file.name;\n if (button) button.setAttribute(\"aria-label\", `Remove ${file.name}`);\n\n let url: string | undefined;\n if (thumb && file.type.startsWith(\"image/\")) {\n url = URL.createObjectURL(file);\n thumb.src = url;\n thumb.alt = file.name;\n thumb.hidden = false;\n } else if (thumb) {\n thumb.hidden = true;\n }\n\n this.listTarget.appendChild(fragment);\n this.#entries.push({ file, item, url });\n }\n\n /** Removes entry `index`, revokes its preview, and re-homes focus. */\n #removeAt(index: number): void {\n const entry = this.#entries[index];\n if (!entry) return;\n if (entry.url) URL.revokeObjectURL(entry.url);\n entry.item.remove();\n this.#entries.splice(index, 1);\n this.#setStatus(entry.file.name);\n this.dispatch(\"change\", { detail: { files: this.#files } });\n\n const buttons = this.#removeButtons;\n if (buttons.length === 0) {\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n } else {\n (buttons[index] ?? buttons[buttons.length - 1])?.focus();\n }\n }\n\n /** Whether `file` satisfies the input's `accept` list (empty accepts all). */\n #matchesAccept(file: File): boolean {\n const accept = this.inputTarget.accept.trim();\n if (accept === \"\") return true;\n const name = file.name.toLowerCase();\n const type = file.type.toLowerCase();\n return accept.split(\",\").some((raw) => {\n const token = raw.trim().toLowerCase();\n if (token === \"\") return false;\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n }\n\n /** Updates the live region so assistive tech announces the change. */\n #setStatus(text: string): void {\n if (this.hasStatusTarget) this.statusTarget.textContent = text;\n }\n\n /** Effective file cap: `maxFiles`, or 1 when the input is single-select. */\n get #effectiveMaxFiles(): number {\n if (this.maxFilesValue > 0) return this.maxFilesValue;\n return this.inputTarget.multiple ? 0 : 1;\n }\n\n /** The remove buttons currently in the list, in order. */\n get #removeButtons(): HTMLButtonElement[] {\n return Array.from(this.listTarget.querySelectorAll<HTMLButtonElement>(\"button\"));\n }\n\n /** The accepted files in selection order. */\n get #files(): File[] {\n return this.#entries.map((entry) => entry.file);\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/controllers/file_dropzone_controller.ts"],"names":[],"mappings":"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","file":"file_dropzone_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible file drag-and-drop / upload field.\n *\n * Markup contract (identifier: `stimeo--file-dropzone`):\n * <div data-controller=\"stimeo--file-dropzone\"\n * data-stimeo--file-dropzone-max-size-value=\"5242880\">\n * <div data-stimeo--file-dropzone-target=\"zone\"\n * data-action=\"dragover->stimeo--file-dropzone#onDragOver\n * dragleave->stimeo--file-dropzone#onDragLeave\n * drop->stimeo--file-dropzone#onDrop\">\n * <button type=\"button\" data-stimeo--file-dropzone-target=\"trigger\"\n * data-action=\"click->stimeo--file-dropzone#openDialog\">Choose…</button>\n * <input type=\"file\" accept=\"image/*\" multiple class=\"visually-hidden\"\n * data-stimeo--file-dropzone-target=\"input\"\n * data-action=\"change->stimeo--file-dropzone#onChange\" />\n * </div>\n * <ul data-stimeo--file-dropzone-target=\"list\" aria-label=\"Selected files\"></ul>\n * <span role=\"status\" aria-live=\"polite\" class=\"visually-hidden\"\n * data-stimeo--file-dropzone-target=\"status\"></span>\n * <template data-stimeo--file-dropzone-target=\"itemTemplate\">…</template>\n * </div>\n *\n * There is no single APG pattern; the native `<input type=\"file\">` stays the\n * primary, keyboard-operable path and the drop zone is an enhancement, mapping to\n * WCAG 2.1.1, 2.4.7, 4.1.2, 4.1.3, and 1.4.1 (drag state is conveyed in words,\n * not color alone).\n *\n * Behavior provided:\n * - Click / keyboard via the `trigger` opens the native file dialog; drag-and-drop\n * over the `zone` adds files (with a `data-dragover` flag and a spoken hint).\n * - Each file is validated against `accept`, `maxSize`, and the file count\n * (`maxFiles`, or 1 when the input is not `multiple`); rejects fire\n * `stimeo--file-dropzone:reject` and set `data-…-invalid`.\n * - Accepted files render from `itemTemplate` with a `Remove {name}` button and an\n * image thumbnail (`objectURL`); every change dispatches\n * `stimeo--file-dropzone:change` with the current `File[]`.\n * - Removing a file revokes its `objectURL` and moves focus to the next (else\n * previous) remove button, falling back to the trigger; `disconnect()` revokes\n * every outstanding `objectURL`. Replacing the `list` target rebinds delegated\n * removal and moves the client-only previews into the replacement.\n */\nexport class FileDropzoneController extends Controller<HTMLElement> {\n static override targets = [\"zone\", \"trigger\", \"input\", \"list\", \"item\", \"itemTemplate\", \"status\"];\n static override values = {\n maxSize: { type: Number, default: 0 },\n maxFiles: { type: Number, default: 0 },\n dragLabel: { type: String, default: \"Drop files to add them\" },\n };\n static actions = [\"onChange\", \"onDragLeave\", \"onDragOver\", \"onDrop\", \"openDialog\"] as const;\n static events = [\"change\", \"reject\"] as const;\n\n declare readonly inputTarget: HTMLInputElement;\n declare readonly listTarget: HTMLElement;\n declare readonly listTargets: HTMLElement[];\n declare readonly triggerTarget: HTMLElement;\n declare readonly itemTemplateTarget: HTMLTemplateElement;\n declare readonly statusTarget: HTMLElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasItemTemplateTarget: boolean;\n declare readonly hasStatusTarget: boolean;\n declare readonly hasZoneTarget: boolean;\n declare readonly zoneTarget: HTMLElement;\n\n declare maxSizeValue: number;\n declare maxFilesValue: number;\n declare dragLabelValue: string;\n\n /** Selected files paired with their rendered item and any preview objectURL. */\n readonly #entries: Array<{ file: File; item: HTMLElement; url?: string }> = [];\n /** Prevents initial and teardown target callbacks from binding outside controller lifetime. */\n #connected = false;\n\n /** Wires file removal as a delegated listener on the list container. */\n override connect(): void {\n this.#connected = true;\n if (this.hasListTarget) this.#bindList(this.listTarget);\n }\n\n /** Revokes any outstanding preview URLs so none leaks across navigations. */\n override disconnect(): void {\n this.#connected = false;\n for (const list of this.listTargets) list.removeEventListener(\"click\", this.#onItemClick);\n for (const entry of this.#entries) {\n if (entry.url) URL.revokeObjectURL(entry.url);\n }\n this.#entries.length = 0;\n }\n\n /** Rebinds removal and restores client-only previews when Turbo replaces the list target. */\n listTargetConnected(list: HTMLElement): void {\n if (!this.#connected) return;\n this.#bindList(list);\n }\n\n /** Releases only the list target that actually disconnected. */\n listTargetDisconnected(list: HTMLElement): void {\n list.removeEventListener(\"click\", this.#onItemClick);\n }\n\n /** Binds delegated removal once and moves live preview items into the current list. */\n #bindList(list: HTMLElement): void {\n list.addEventListener(\"click\", this.#onItemClick);\n for (const entry of this.#entries) {\n if (!list.contains(entry.item)) list.appendChild(entry.item);\n }\n }\n\n /** Opens the native file dialog. Bound via `data-action` (trigger click). */\n openDialog(): void {\n this.inputTarget.click();\n }\n\n /** Adds the files chosen through the native dialog. */\n onChange(): void {\n if (this.inputTarget.files) this.#addFiles(this.inputTarget.files);\n // Clear so re-selecting the same file still fires `change`.\n this.inputTarget.value = \"\";\n }\n\n /** Marks the zone as a drop target and announces the affordance in words. */\n onDragOver(event: DragEvent): void {\n event.preventDefault();\n if (this.hasZoneTarget) this.zoneTarget.setAttribute(\"data-dragover\", \"\");\n this.#setStatus(this.dragLabelValue);\n }\n\n /** Clears the drag-over flag when the pointer leaves the zone. */\n onDragLeave(): void {\n if (this.hasZoneTarget) this.zoneTarget.removeAttribute(\"data-dragover\");\n }\n\n /** Accepts dropped files, clearing the drag-over state. */\n onDrop(event: DragEvent): void {\n event.preventDefault();\n if (this.hasZoneTarget) this.zoneTarget.removeAttribute(\"data-dragover\");\n if (event.dataTransfer?.files) this.#addFiles(event.dataTransfer.files);\n }\n\n /**\n * Removes the file whose remove button was clicked. Delegated on the list\n * container rather than bound per item via `data-action`, so it works the instant\n * an item is appended without waiting on Stimulus to wire a freshly created element.\n */\n readonly #onItemClick = (event: MouseEvent): void => {\n const list = event.currentTarget as HTMLElement | null;\n const button = (event.target as HTMLElement).closest(\"button\");\n if (\n !this.#connected ||\n !list ||\n !button ||\n !this.hasListTarget ||\n list !== this.listTarget ||\n !list.contains(button)\n ) {\n return;\n }\n const index = this.#entries.findIndex((entry) => entry.item.contains(button));\n if (index !== -1) this.#removeAt(index);\n };\n\n /** Validates each incoming file and renders the accepted ones. */\n #addFiles(files: FileList): void {\n let changed = false;\n if (this.hasZoneTarget) this.zoneTarget.removeAttribute(\"data-stimeo--file-dropzone-invalid\");\n for (const file of Array.from(files)) {\n const reason = this.#validate(file);\n if (reason) {\n if (this.hasZoneTarget) {\n this.zoneTarget.setAttribute(\"data-stimeo--file-dropzone-invalid\", \"\");\n }\n this.#setStatus(file.name);\n this.dispatch(\"reject\", { detail: { file, reason } });\n continue;\n }\n this.#appendFile(file);\n this.#setStatus(file.name);\n changed = true;\n }\n if (changed) this.dispatch(\"change\", { detail: { files: this.#files } });\n }\n\n /** Returns the rejection reason for `file`, or `null` when it is acceptable. */\n #validate(file: File): \"type\" | \"size\" | \"count\" | null {\n const limit = this.#effectiveMaxFiles;\n if (limit > 0 && this.#entries.length >= limit) return \"count\";\n if (!this.#matchesAccept(file)) return \"type\";\n if (this.maxSizeValue > 0 && file.size > this.maxSizeValue) return \"size\";\n return null;\n }\n\n /** Builds one preview item (name, optional thumbnail, remove button). */\n #appendFile(file: File): void {\n if (!this.hasItemTemplateTarget || !this.hasListTarget) return;\n const fragment = this.itemTemplateTarget.content.cloneNode(true) as DocumentFragment;\n const item = fragment.querySelector<HTMLElement>('[data-stimeo--file-dropzone-target=\"item\"]');\n const name = fragment.querySelector<HTMLElement>('[data-file-dropzone-slot=\"name\"]');\n const thumb = fragment.querySelector<HTMLImageElement>('[data-file-dropzone-slot=\"thumb\"]');\n const button = fragment.querySelector<HTMLButtonElement>(\"button\");\n if (!item) return;\n if (name) name.textContent = file.name;\n if (button) button.setAttribute(\"aria-label\", `Remove ${file.name}`);\n\n let url: string | undefined;\n if (thumb && file.type.startsWith(\"image/\")) {\n url = URL.createObjectURL(file);\n thumb.src = url;\n thumb.alt = file.name;\n thumb.hidden = false;\n } else if (thumb) {\n thumb.hidden = true;\n }\n\n this.listTarget.appendChild(fragment);\n this.#entries.push({ file, item, url });\n }\n\n /** Removes entry `index`, revokes its preview, and re-homes focus. */\n #removeAt(index: number): void {\n const entry = this.#entries[index];\n if (!entry) return;\n if (entry.url) URL.revokeObjectURL(entry.url);\n entry.item.remove();\n this.#entries.splice(index, 1);\n this.#setStatus(entry.file.name);\n this.dispatch(\"change\", { detail: { files: this.#files } });\n\n const buttons = this.#removeButtons;\n if (buttons.length === 0) {\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n } else {\n (buttons[index] ?? buttons[buttons.length - 1])?.focus();\n }\n }\n\n /** Whether `file` satisfies the input's `accept` list (empty accepts all). */\n #matchesAccept(file: File): boolean {\n const accept = this.inputTarget.accept.trim();\n if (accept === \"\") return true;\n const name = file.name.toLowerCase();\n const type = file.type.toLowerCase();\n return accept.split(\",\").some((raw) => {\n const token = raw.trim().toLowerCase();\n if (token === \"\") return false;\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n }\n\n /** Updates the live region so assistive tech announces the change. */\n #setStatus(text: string): void {\n if (this.hasStatusTarget) this.statusTarget.textContent = text;\n }\n\n /** Effective file cap: `maxFiles`, or 1 when the input is single-select. */\n get #effectiveMaxFiles(): number {\n if (this.maxFilesValue > 0) return this.maxFilesValue;\n return this.inputTarget.multiple ? 0 : 1;\n }\n\n /** The remove buttons currently in the list, in order. */\n get #removeButtons(): HTMLButtonElement[] {\n return Array.from(this.listTarget.querySelectorAll<HTMLButtonElement>(\"button\"));\n }\n\n /** The accepted files in selection order. */\n get #files(): File[] {\n return this.#entries.map((entry) => entry.file);\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
  }