maquina-components 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/drawer.css +8 -8
  3. data/app/assets/stylesheets/form.css +58 -4
  4. data/app/assets/stylesheets/separator.css +6 -1
  5. data/app/assets/tailwind/maquina_components_engine/tokens.css +1 -0
  6. data/app/helpers/maquina_components/calendar_helper.rb +32 -0
  7. data/app/helpers/maquina_components/drawer_helper.rb +3 -5
  8. data/app/helpers/maquina_components/dropdown_menu_helper.rb +22 -5
  9. data/app/helpers/maquina_components/empty_helper.rb +3 -3
  10. data/app/helpers/maquina_components/icons_helper.rb +9 -5
  11. data/app/helpers/maquina_components/pagination_helper.rb +27 -5
  12. data/app/javascript/controllers/calendar_controller.js +28 -25
  13. data/app/javascript/controllers/combobox_controller.js +17 -2
  14. data/app/javascript/controllers/date_picker_controller.js +57 -21
  15. data/app/javascript/controllers/drawer_controller.js +66 -44
  16. data/app/javascript/controllers/drawer_trigger_controller.js +13 -0
  17. data/app/javascript/controllers/sidebar_controller.js +84 -42
  18. data/app/javascript/controllers/sidebar_trigger_controller.js +13 -0
  19. data/app/javascript/controllers/toast_controller.js +2 -3
  20. data/app/javascript/controllers/toaster_controller.js +23 -1
  21. data/app/javascript/controllers/toggle_group_controller.js +17 -6
  22. data/app/views/components/_badge.html.erb +1 -1
  23. data/app/views/components/_breadcrumbs.html.erb +7 -7
  24. data/app/views/components/_calendar.html.erb +13 -28
  25. data/app/views/components/_combobox.html.erb +2 -0
  26. data/app/views/components/_date_picker.html.erb +19 -33
  27. data/app/views/components/_drawer.html.erb +1 -1
  28. data/app/views/components/_menu_button.html.erb +1 -1
  29. data/app/views/components/_sidebar.html.erb +1 -1
  30. data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
  31. data/app/views/components/breadcrumbs/_item.html.erb +1 -1
  32. data/app/views/components/breadcrumbs/_link.html.erb +1 -1
  33. data/app/views/components/breadcrumbs/_list.html.erb +1 -1
  34. data/app/views/components/breadcrumbs/_page.html.erb +1 -1
  35. data/app/views/components/breadcrumbs/_separator.html.erb +1 -2
  36. data/app/views/components/calendar/_week.html.erb +7 -4
  37. data/app/views/components/card/_content.html.erb +1 -1
  38. data/app/views/components/card/_footer.html.erb +3 -10
  39. data/app/views/components/card/_header.html.erb +1 -1
  40. data/app/views/components/card/_title.html.erb +1 -1
  41. data/app/views/components/combobox/_option.html.erb +2 -2
  42. data/app/views/components/drawer/_close.html.erb +11 -5
  43. data/app/views/components/drawer/_content.html.erb +1 -1
  44. data/app/views/components/drawer/_footer.html.erb +1 -1
  45. data/app/views/components/drawer/_header.html.erb +1 -1
  46. data/app/views/components/drawer/_provider.html.erb +1 -1
  47. data/app/views/components/drawer/_trigger.html.erb +4 -2
  48. data/app/views/components/pagination/_ellipsis.html.erb +5 -8
  49. data/app/views/components/pagination/_link.html.erb +1 -1
  50. data/app/views/components/pagination/_next.html.erb +6 -7
  51. data/app/views/components/pagination/_previous.html.erb +6 -7
  52. data/app/views/components/sidebar/_content.html.erb +1 -1
  53. data/app/views/components/sidebar/_footer.html.erb +1 -1
  54. data/app/views/components/sidebar/_group.html.erb +1 -1
  55. data/app/views/components/sidebar/_header.html.erb +1 -1
  56. data/app/views/components/sidebar/_inset.html.erb +1 -1
  57. data/app/views/components/sidebar/_menu.html.erb +1 -1
  58. data/app/views/components/sidebar/_menu_button.html.erb +1 -1
  59. data/app/views/components/sidebar/_menu_item.html.erb +1 -1
  60. data/app/views/components/sidebar/_menu_link.html.erb +3 -3
  61. data/app/views/components/sidebar/_provider.html.erb +1 -1
  62. data/app/views/components/sidebar/_trigger.html.erb +1 -1
  63. data/app/views/components/stats/_stats_card.html.erb +7 -6
  64. data/app/views/components/toast/_action.html.erb +2 -1
  65. data/lib/generators/maquina_components/install/templates/theme.css.tt +11 -0
  66. data/lib/maquina_components/version.rb +1 -1
  67. metadata +1 -1
@@ -24,7 +24,10 @@ export default class extends Controller {
24
24
  selectedEnd: String,
25
25
  format: { type: String, default: "long" },
26
26
  placeholder: String,
27
- placeholderRange: String
27
+ placeholderRange: String,
28
+ // I18n date data from date_picker_locale_data: strftime patterns and
29
+ // localized names, so the display matches the server-rendered text
30
+ locale: Object
28
31
  }
29
32
 
30
33
  connect() {
@@ -188,22 +191,54 @@ export default class extends Controller {
188
191
  }
189
192
 
190
193
  /**
191
- * Format date for display
194
+ * Format date for display, mirroring the server's date_picker_format:
195
+ * the locale's strftime pattern when there is one, Intl otherwise.
192
196
  */
193
197
  formatDate(dateStr, format = "long") {
194
198
  if (!dateStr) return ""
195
199
 
196
- try {
197
- const date = new Date(dateStr + "T00:00:00")
198
- const options = format === "short"
199
- ? { month: "short", day: "numeric", year: "numeric" }
200
- : { weekday: "long", month: "long", day: "numeric", year: "numeric" }
200
+ const date = new Date(dateStr + "T00:00:00")
201
+ if (isNaN(date)) return dateStr
201
202
 
202
- const locale = document.documentElement.lang || undefined
203
- return date.toLocaleDateString(locale, options)
204
- } catch {
205
- return dateStr
206
- }
203
+ const pattern = this.localeValue.formats?.[format === "short" ? "short" : "long"]
204
+ if (pattern) return this.strftime(date, pattern)
205
+
206
+ const options = format === "short"
207
+ ? { month: "short", day: "numeric", year: "numeric" }
208
+ : { month: "long", day: "numeric", year: "numeric" }
209
+
210
+ return date.toLocaleDateString(document.documentElement.lang || undefined, options)
211
+ }
212
+
213
+ /**
214
+ * The strftime subset Rails locale date formats use. "%-d" drops padding.
215
+ */
216
+ strftime(date, pattern) {
217
+ const locale = this.localeValue
218
+ const pad = (number, width = 2) => String(number).padStart(width, "0")
219
+
220
+ return pattern.replace(/%(-?)([a-zA-Z%])/g, (match, noPad, directive) => {
221
+ const padded = (number) => noPad ? String(number) : pad(number)
222
+
223
+ switch (directive) {
224
+ case "Y": return String(date.getFullYear())
225
+ case "y": return pad(date.getFullYear() % 100)
226
+ case "m": return padded(date.getMonth() + 1)
227
+ case "d": return padded(date.getDate())
228
+ case "e": return noPad ? String(date.getDate()) : String(date.getDate()).padStart(2, " ")
229
+ case "j": {
230
+ const start = new Date(date.getFullYear(), 0, 1)
231
+ const day = Math.round((date - start) / 86400000) + 1
232
+ return noPad ? String(day) : pad(day, 3)
233
+ }
234
+ case "B": return locale.month_names?.[date.getMonth() + 1] ?? match
235
+ case "b": return locale.abbr_month_names?.[date.getMonth() + 1] ?? match
236
+ case "A": return locale.day_names?.[date.getDay()] ?? match
237
+ case "a": return locale.abbr_day_names?.[date.getDay()] ?? match
238
+ case "%": return "%"
239
+ default: return match
240
+ }
241
+ })
207
242
  }
208
243
 
209
244
  /**
@@ -237,20 +272,21 @@ export default class extends Controller {
237
272
  * Clear the selection
238
273
  */
239
274
  clear() {
275
+ const calendarController = this.hasCalendarTarget &&
276
+ this.application.getControllerForElementAndIdentifier(this.calendarTarget, "calendar")
277
+
278
+ // The calendar's own calendar:change runs handleChange, which updates the
279
+ // inputs and display and dispatches date-picker:change exactly once
280
+ if (calendarController) {
281
+ calendarController.clear()
282
+ return
283
+ }
284
+
240
285
  this.selectedValue = ""
241
286
  this.selectedEndValue = ""
242
287
  this.updateInputs()
243
288
  this.updateDisplay()
244
289
 
245
- // Also clear the calendar
246
- if (this.hasCalendarTarget) {
247
- const calendarController = this.application.getControllerForElementAndIdentifier(
248
- this.calendarTarget,
249
- "calendar"
250
- )
251
- calendarController?.clear()
252
- }
253
-
254
290
  this.dispatch("change", {
255
291
  detail: { selected: null, selectedEnd: null }
256
292
  })
@@ -20,7 +20,7 @@ export default class extends Controller {
20
20
  keyboardShortcut: { type: String, default: "d" }
21
21
  }
22
22
 
23
- static targets = ["drawer", "container", "backdrop", "panel"]
23
+ static targets = ["drawer", "backdrop", "panel"]
24
24
 
25
25
  initialize() {
26
26
  const cookieValue = this.getCookie(this.cookieNameValue)
@@ -28,35 +28,60 @@ export default class extends Controller {
28
28
  ? cookieValue === "true"
29
29
  : this.defaultOpenValue
30
30
 
31
- this._morphing = false
31
+ // Set by a same-URL "replace" visit (a page refresh) until it renders
32
+ this._refreshing = false
32
33
  }
33
34
 
34
35
  connect() {
35
- this.resizeHandler = this.debounce(this.checkScreenSize.bind(this), 150)
36
- window.addEventListener("resize", this.resizeHandler)
37
-
36
+ this.boundTrackVisit = this.trackVisit.bind(this)
37
+ this.boundClearVisit = this.clearVisit.bind(this)
38
38
  this.boundTeardown = this.teardown.bind(this)
39
- this.boundBeforeMorphElement = this.beforeMorphElement.bind(this)
39
+ this.boundPreserveAttribute = this.preserveAttribute.bind(this)
40
40
  this.boundHandleMorph = this.handleMorph.bind(this)
41
+ document.addEventListener("turbo:visit", this.boundTrackVisit)
42
+ document.addEventListener("turbo:load", this.boundClearVisit)
41
43
  document.addEventListener("turbo:before-cache", this.boundTeardown)
42
- document.addEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
44
+ document.addEventListener("turbo:before-morph-attribute", this.boundPreserveAttribute)
43
45
  document.addEventListener("turbo:morph", this.boundHandleMorph)
44
46
 
45
47
  this.updateStateImmediate()
46
48
  }
47
49
 
48
50
  disconnect() {
49
- window.removeEventListener("resize", this.resizeHandler)
51
+ document.removeEventListener("turbo:visit", this.boundTrackVisit)
52
+ document.removeEventListener("turbo:load", this.boundClearVisit)
50
53
  document.removeEventListener("turbo:before-cache", this.boundTeardown)
51
- document.removeEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
54
+ document.removeEventListener("turbo:before-morph-attribute", this.boundPreserveAttribute)
52
55
  document.removeEventListener("turbo:morph", this.boundHandleMorph)
53
56
  }
54
57
 
55
58
  // ============================================================================
56
- // Turbo Cache Teardown
59
+ // Turbo Integration (cache teardown and page-refresh morphs)
57
60
  // ============================================================================
58
61
 
62
+ // A page refresh is a same-path "replace" visit -- Turbo's own isPageRefresh
63
+ // test. It covers Turbo.visit(location.href, { action: "replace" }), refresh
64
+ // broadcasts and same-URL form redirects. An open drawer must survive it.
65
+ trackVisit(event) {
66
+ const { url, action } = event.detail
67
+ this._refreshing = action === "replace" &&
68
+ new URL(url, window.location.href).pathname === window.location.pathname
69
+ }
70
+
71
+ clearVisit() {
72
+ this._refreshing = false
73
+ }
74
+
75
+ // Runs before Turbo snapshots the page: real navigation closes the drawer so
76
+ // the cached snapshot starts closed; a page refresh leaves it open. Either
77
+ // way the scroll lock is released so no snapshot is cached locked --
78
+ // handleMorph() re-locks after a refresh.
59
79
  teardown() {
80
+ if (this._refreshing) {
81
+ this.unlockScroll()
82
+ return
83
+ }
84
+
60
85
  this.openValue = false
61
86
 
62
87
  if (this.hasBackdropTarget) {
@@ -67,25 +92,34 @@ export default class extends Controller {
67
92
  this.unlockScroll()
68
93
  }
69
94
 
70
- beforeMorphElement(event) {
71
- if (event.target === this.element) {
72
- this._morphing = true
95
+ // The morph must not overwrite the attributes this controller owns; the
96
+ // drawer's contents still morph normally.
97
+ preserveAttribute(event) {
98
+ const owned = this.ownedAttributes(event.target)
99
+ if (owned?.includes(event.detail.attributeName)) {
100
+ event.preventDefault()
73
101
  }
74
102
  }
75
103
 
76
- handleMorph() {
77
- const cookieValue = this.getCookie(this.cookieNameValue)
104
+ ownedAttributes(element) {
105
+ if (element === this.element) return ["data-drawer-open-value"]
106
+ if (this.hasDrawerTarget && element === this.drawerTarget) return ["data-state"]
107
+ if (this.hasBackdropTarget && element === this.backdropTarget) return ["data-state", "class"]
108
+ if (this.hasPanelTarget && element === this.panelTarget) return ["aria-hidden", "inert"]
109
+ return null
110
+ }
78
111
 
79
- if (cookieValue !== null) {
80
- this.openValue = cookieValue === "true"
81
- }
112
+ // After a morph, keep the live state and re-apply what the morph can still
113
+ // reset: the body scroll lock (<body> is morphed) and the trigger's aria.
114
+ handleMorph() {
115
+ this._refreshing = false
82
116
 
83
117
  if (this.hasDrawerTarget) {
84
118
  this.drawerTarget.classList.remove("drawer-loading")
85
119
  }
86
120
 
87
121
  this.updateState()
88
- this._morphing = false
122
+ this.dispatchStateChange()
89
123
  }
90
124
 
91
125
  // ============================================================================
@@ -108,7 +142,6 @@ export default class extends Controller {
108
142
 
109
143
  openValueChanged(new_value, old_value) {
110
144
  if (new_value === old_value) return
111
- if (this._morphing) return
112
145
 
113
146
  this.updateState()
114
147
  this.persistState()
@@ -238,18 +271,26 @@ export default class extends Controller {
238
271
 
239
272
  // Styling lives in drawer.css (body[data-maquina-scroll-locked]); the
240
273
  // controller only records the scroll offset and flips the attribute
274
+ // Idempotent and owned: the offset is recorded once per lock (re-locking
275
+ // after a morph reset <body> must not record the locked page's offset of 0),
276
+ // and a closed drawer never releases a lock it didn't take -- the sidebar
277
+ // shares the same body attribute.
241
278
  lockScroll() {
242
- this.scrollPosition = window.pageYOffset
279
+ if (!this._scrollLocked) {
280
+ this.scrollPosition = window.scrollY
281
+ this._scrollLocked = true
282
+ }
243
283
  document.body.style.setProperty("--maquina-scroll-lock-top", `-${this.scrollPosition}px`)
244
284
  document.body.setAttribute("data-maquina-scroll-locked", "")
245
285
  }
246
286
 
247
287
  unlockScroll() {
288
+ if (!this._scrollLocked) return
289
+
290
+ this._scrollLocked = false
248
291
  document.body.removeAttribute("data-maquina-scroll-locked")
249
292
  document.body.style.removeProperty("--maquina-scroll-lock-top")
250
- if (this.scrollPosition !== undefined) {
251
- window.scrollTo(0, this.scrollPosition)
252
- }
293
+ window.scrollTo(0, this.scrollPosition)
253
294
  }
254
295
 
255
296
  // ============================================================================
@@ -265,23 +306,4 @@ export default class extends Controller {
265
306
  bubbles: true
266
307
  })
267
308
  }
268
-
269
- // ============================================================================
270
- // Utilities
271
- // ============================================================================
272
-
273
- checkScreenSize() {
274
- }
275
-
276
- debounce(func, wait) {
277
- let timeout
278
- return function executedFunction(...args) {
279
- const later = () => {
280
- clearTimeout(timeout)
281
- func(...args)
282
- }
283
- clearTimeout(timeout)
284
- timeout = setTimeout(later, wait)
285
- }
286
- }
287
- }
309
+ }
@@ -26,11 +26,24 @@ export default class extends Controller {
26
26
 
27
27
  connect() {
28
28
  this.handleStateChanged = this.handleStateChanged.bind(this)
29
+ this.preserveAttribute = this.preserveAttribute.bind(this)
29
30
  window.addEventListener("drawer:stateChanged", this.handleStateChanged)
31
+ this.element.addEventListener("turbo:before-morph-attribute", this.preserveAttribute)
30
32
  }
31
33
 
32
34
  disconnect() {
33
35
  window.removeEventListener("drawer:stateChanged", this.handleStateChanged)
36
+ this.element.removeEventListener("turbo:before-morph-attribute", this.preserveAttribute)
37
+ }
38
+
39
+ // The server never renders these (only the client knows the live state), so
40
+ // without this every page morph strips them from the trigger
41
+ preserveAttribute(event) {
42
+ if (event.target !== this.element) return
43
+
44
+ if (["aria-expanded", "aria-controls", "aria-haspopup"].includes(event.detail.attributeName)) {
45
+ event.preventDefault()
46
+ }
34
47
  }
35
48
 
36
49
  drawerOutletConnected(outlet) {
@@ -34,8 +34,8 @@ export default class extends Controller {
34
34
  // Track if we're on mobile
35
35
  this._isMobile = null
36
36
 
37
- // Guard flag for morph-triggered attribute changes
38
- this._morphing = false
37
+ // Set by a same-URL "replace" visit (a page refresh) until it renders
38
+ this._refreshing = false
39
39
  }
40
40
 
41
41
  connect() {
@@ -46,11 +46,15 @@ export default class extends Controller {
46
46
  this.resizeHandler = this.debounce(this.checkScreenSize.bind(this), 150)
47
47
  window.addEventListener("resize", this.resizeHandler)
48
48
 
49
+ this.boundTrackVisit = this.trackVisit.bind(this)
50
+ this.boundClearVisit = this.clearVisit.bind(this)
49
51
  this.boundTeardown = this.teardown.bind(this)
50
- this.boundBeforeMorphElement = this.beforeMorphElement.bind(this)
52
+ this.boundPreserveAttribute = this.preserveAttribute.bind(this)
51
53
  this.boundHandleMorph = this.handleMorph.bind(this)
54
+ document.addEventListener("turbo:visit", this.boundTrackVisit)
55
+ document.addEventListener("turbo:load", this.boundClearVisit)
52
56
  document.addEventListener("turbo:before-cache", this.boundTeardown)
53
- document.addEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
57
+ document.addEventListener("turbo:before-morph-attribute", this.boundPreserveAttribute)
54
58
  document.addEventListener("turbo:morph", this.boundHandleMorph)
55
59
 
56
60
  // Apply initial state without animation
@@ -59,16 +63,40 @@ export default class extends Controller {
59
63
 
60
64
  disconnect() {
61
65
  window.removeEventListener("resize", this.resizeHandler)
66
+ document.removeEventListener("turbo:visit", this.boundTrackVisit)
67
+ document.removeEventListener("turbo:load", this.boundClearVisit)
62
68
  document.removeEventListener("turbo:before-cache", this.boundTeardown)
63
- document.removeEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
69
+ document.removeEventListener("turbo:before-morph-attribute", this.boundPreserveAttribute)
64
70
  document.removeEventListener("turbo:morph", this.boundHandleMorph)
65
71
  }
66
72
 
67
73
  // ============================================================================
68
- // Turbo Cache Teardown
74
+ // Turbo Integration (cache teardown and page-refresh morphs)
69
75
  // ============================================================================
70
76
 
77
+ // A page refresh is a same-path "replace" visit -- Turbo's own isPageRefresh
78
+ // test. It covers Turbo.visit(location.href, { action: "replace" }), refresh
79
+ // broadcasts and same-URL form redirects. The open state must survive it.
80
+ trackVisit(event) {
81
+ const { url, action } = event.detail
82
+ this._refreshing = action === "replace" &&
83
+ new URL(url, window.location.href).pathname === window.location.pathname
84
+ }
85
+
86
+ clearVisit() {
87
+ this._refreshing = false
88
+ }
89
+
90
+ // Runs before Turbo snapshots the page. Real navigation parks the mobile
91
+ // drawer so the cached snapshot (and the next page) starts closed; a page
92
+ // refresh leaves it open. Either way the scroll lock is released so no
93
+ // snapshot is cached locked -- handleMorph() re-locks after a refresh.
71
94
  teardown() {
95
+ if (this._refreshing) {
96
+ this.unlockScroll()
97
+ return
98
+ }
99
+
72
100
  if (this.isMobile()) {
73
101
  this.openValue = false
74
102
  }
@@ -81,19 +109,35 @@ export default class extends Controller {
81
109
  this.unlockScroll()
82
110
  }
83
111
 
84
- beforeMorphElement(event) {
85
- if (event.target === this.element) {
86
- this._morphing = true
112
+ // The server renders open state from the desktop cookie, which mobile never
113
+ // writes, so a morph must not overwrite the attributes this controller owns.
114
+ // The menu contents below them still morph normally.
115
+ preserveAttribute(event) {
116
+ const owned = this.ownedAttributes(event.target)
117
+ if (owned?.includes(event.detail.attributeName)) {
118
+ event.preventDefault()
87
119
  }
88
120
  }
89
121
 
122
+ ownedAttributes(element) {
123
+ if (element === this.element) return ["data-sidebar-open-value"]
124
+ if (this.hasSidebarTarget && element === this.sidebarTarget) return ["data-state", "data-collapsible"]
125
+ if (this.hasBackdropTarget && element === this.backdropTarget) return ["data-state", "class"]
126
+ if (this.hasContainerTarget && element === this.containerTarget) return ["inert"]
127
+ return null
128
+ }
129
+
130
+ // After a morph: keep the live state on mobile, re-read the cookie on
131
+ // desktop, then re-apply everything the morph can still reset -- the body
132
+ // scroll lock (<body> is morphed) and the trigger's aria-expanded.
90
133
  handleMorph() {
91
- const cookieValue = this.getCookie(this.cookieNameValue)
134
+ this._refreshing = false
92
135
 
93
- if (this.isMobile()) {
94
- this.openValue = false
95
- } else if (cookieValue !== null) {
96
- this.openValue = cookieValue === "true"
136
+ if (!this.isMobile()) {
137
+ const cookieValue = this.getCookie(this.cookieNameValue)
138
+ if (cookieValue !== null) {
139
+ this.openValue = cookieValue === "true"
140
+ }
97
141
  }
98
142
 
99
143
  if (this.hasSidebarTarget) {
@@ -101,7 +145,7 @@ export default class extends Controller {
101
145
  }
102
146
 
103
147
  this.updateState()
104
- this._morphing = false
148
+ this.dispatchStateChange()
105
149
  }
106
150
 
107
151
  // ============================================================================
@@ -123,7 +167,6 @@ export default class extends Controller {
123
167
 
124
168
  openValueChanged(new_value, old_value) {
125
169
  if (new_value === old_value) return
126
- if (this._morphing) return
127
170
 
128
171
  this.updateState()
129
172
  this.persistState()
@@ -278,31 +321,23 @@ export default class extends Controller {
278
321
  }
279
322
  }
280
323
 
324
+ // data-collapsible describes the current state ("none" just means open), so
325
+ // only the direction of the crossing matters: entering mobile parks the
326
+ // drawer closed, leaving it restores the saved desktop state.
281
327
  handleScreenSizeChange(wasMobile, isMobile) {
282
- if (this.sidebarTarget.getAttribute("data-collapsible") === "offcanvas") {
283
- // Mobile to desktop: restore saved state
284
- if (wasMobile && !isMobile) {
285
- const cookieValue = this.getCookie(this.cookieNameValue)
286
- this.openValue = cookieValue !== null
287
- ? cookieValue === "true"
288
- : this.defaultOpenValue
289
- }
290
- // Desktop to mobile: close sidebar
291
- else if (!wasMobile && isMobile) {
292
- this.openValue = false
293
- }
294
- } else if (this.sidebarTarget.getAttribute("data-collapsible") === "icon") {
295
- // Icon mode is always visible, just changes width
296
- // Desktop to mobile: might want to close
297
- if (!wasMobile && isMobile) {
298
- this.openValue = false
299
- }
300
- } else if (this.sidebarTarget.getAttribute("data-collapsible") === "none") {
301
- // Non-collapsible sidebars stay open
302
- this.openValue = true
328
+ if (wasMobile && !isMobile) {
329
+ const cookieValue = this.getCookie(this.cookieNameValue)
330
+ this.openValue = cookieValue !== null
331
+ ? cookieValue === "true"
332
+ : this.defaultOpenValue
333
+ } else if (!wasMobile && isMobile) {
334
+ this.openValue = false
303
335
  }
304
336
 
337
+ // Crossing the breakpoint without an open-state change still moves the
338
+ // drawer between offcanvas and icon rail, and drops a desktop scroll lock
305
339
  this.updateState()
340
+ if (!isMobile) this.unlockScroll()
306
341
  }
307
342
 
308
343
  isMobile() {
@@ -342,19 +377,26 @@ export default class extends Controller {
342
377
  // ============================================================================
343
378
 
344
379
  // Styling lives in drawer.css (body[data-maquina-scroll-locked]); the
345
- // controller only records the scroll offset and flips the attribute
380
+ // controller only records the scroll offset and flips the attribute.
381
+ //
382
+ // Idempotent: the offset is recorded once per lock. Re-locking after a morph
383
+ // reset <body> must not record the locked page's offset of 0.
346
384
  lockScroll() {
347
- this.scrollPosition = window.pageYOffset
385
+ if (!this._scrollLocked) {
386
+ this.scrollPosition = window.scrollY
387
+ this._scrollLocked = true
388
+ }
348
389
  document.body.style.setProperty("--maquina-scroll-lock-top", `-${this.scrollPosition}px`)
349
390
  document.body.setAttribute("data-maquina-scroll-locked", "")
350
391
  }
351
392
 
352
393
  unlockScroll() {
394
+ if (!this._scrollLocked) return
395
+
396
+ this._scrollLocked = false
353
397
  document.body.removeAttribute("data-maquina-scroll-locked")
354
398
  document.body.style.removeProperty("--maquina-scroll-lock-top")
355
- if (this.scrollPosition !== undefined) {
356
- window.scrollTo(0, this.scrollPosition)
357
- }
399
+ window.scrollTo(0, this.scrollPosition)
358
400
  }
359
401
 
360
402
  // ============================================================================
@@ -21,11 +21,24 @@ export default class extends Controller {
21
21
 
22
22
  connect() {
23
23
  this.handleStateChanged = this.handleStateChanged.bind(this)
24
+ this.preserveAttribute = this.preserveAttribute.bind(this)
24
25
  window.addEventListener("sidebar:stateChanged", this.handleStateChanged)
26
+ this.element.addEventListener("turbo:before-morph-attribute", this.preserveAttribute)
25
27
  }
26
28
 
27
29
  disconnect() {
28
30
  window.removeEventListener("sidebar:stateChanged", this.handleStateChanged)
31
+ this.element.removeEventListener("turbo:before-morph-attribute", this.preserveAttribute)
32
+ }
33
+
34
+ // The server never renders these (only the client knows the live state), so
35
+ // without this every page morph strips them from the trigger
36
+ preserveAttribute(event) {
37
+ if (event.target !== this.element) return
38
+
39
+ if (["aria-expanded", "aria-controls"].includes(event.detail.attributeName)) {
40
+ event.preventDefault()
41
+ }
29
42
  }
30
43
 
31
44
  sidebarOutletConnected(outlet) {
@@ -101,9 +101,8 @@ export default class extends Controller {
101
101
  }
102
102
 
103
103
  handleAction(event) {
104
- // If this toast was created via JS API with an action callback,
105
- // the callback is stored on the toaster controller or needs to be
106
- // dispatched as an event for the parent to handle
104
+ // Bubbles to the toaster, which runs the action.onClick given to the
105
+ // JS API, and on to any document-level `toast:action` listeners
107
106
  this.dispatch("action", {
108
107
  detail: { toastId: this.element.id },
109
108
  bubbles: true
@@ -12,6 +12,7 @@ import { Controller } from "@hotwired/stimulus"
12
12
  * Toast.info("New update available", { duration: 10000 })
13
13
  * Toast.warning("Session expiring soon")
14
14
  * Toast.show("Custom message", { variant: "default" })
15
+ * Toast.show("Event created", { action: { label: "Undo", onClick: (event) => undo() } })
15
16
  * Toast.dismiss(toastId)
16
17
  * Toast.dismissAll()
17
18
  */
@@ -23,11 +24,18 @@ export default class extends Controller {
23
24
  }
24
25
 
25
26
  connect() {
27
+ // action.onClick callbacks, keyed by toast element
28
+ this.actionCallbacks = new WeakMap()
29
+ this.runActionCallback = this.runActionCallback.bind(this)
30
+ this.element.addEventListener("toast:action", this.runActionCallback)
31
+
26
32
  // Expose global Toast API
27
33
  this.setupGlobalApi()
28
34
  }
29
35
 
30
36
  disconnect() {
37
+ this.element.removeEventListener("toast:action", this.runActionCallback)
38
+
31
39
  // Clean up global API
32
40
  if (window.Toast && window.Toast._controller === this) {
33
41
  delete window.Toast
@@ -112,6 +120,10 @@ export default class extends Controller {
112
120
  // Insert into container
113
121
  this.element.insertAdjacentHTML("beforeend", toastHtml)
114
122
 
123
+ if (typeof action?.onClick === "function") {
124
+ this.actionCallbacks.set(document.getElementById(toastId), action.onClick)
125
+ }
126
+
115
127
  // Enforce max visible
116
128
  this.enforceMaxVisible()
117
129
 
@@ -163,7 +175,7 @@ export default class extends Controller {
163
175
  data-variant="${variant}"
164
176
  data-toast-duration-value="${duration}"
165
177
  data-toast-dismissible-value="${dismissible}"
166
- data-toast-action-callback="${action ? "true" : "false"}"
178
+ data-toast-action-callback-value="${action ? "true" : "false"}"
167
179
  data-state="entering">
168
180
  ${iconHtml}
169
181
  <div data-toast-part="content">
@@ -176,6 +188,16 @@ export default class extends Controller {
176
188
  `.trim()
177
189
  }
178
190
 
191
+ /**
192
+ * Runs the action.onClick passed to the JS API when the toast's action
193
+ * button is clicked. The toast controller dispatches `toast:action`
194
+ * (bubbling) from the toast element; it still reaches document listeners.
195
+ */
196
+ runActionCallback(event) {
197
+ const callback = this.actionCallbacks.get(event.target)
198
+ if (callback) callback(event)
199
+ }
200
+
179
201
  getIconSvg(name) {
180
202
  const icons = {
181
203
  circle_check: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',