maquina-components 0.7.3 → 0.8.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/calendar.css +48 -1
  3. data/app/assets/stylesheets/date_picker.css +13 -1
  4. data/app/assets/stylesheets/drawer.css +8 -8
  5. data/app/assets/stylesheets/form.css +58 -4
  6. data/app/assets/stylesheets/separator.css +6 -1
  7. data/app/assets/tailwind/maquina_components_engine/tokens.css +1 -0
  8. data/app/helpers/maquina_components/calendar_helper.rb +69 -0
  9. data/app/helpers/maquina_components/drawer_helper.rb +3 -5
  10. data/app/helpers/maquina_components/dropdown_menu_helper.rb +22 -5
  11. data/app/helpers/maquina_components/empty_helper.rb +3 -3
  12. data/app/helpers/maquina_components/icons_helper.rb +9 -5
  13. data/app/helpers/maquina_components/pagination_helper.rb +27 -5
  14. data/app/javascript/controllers/calendar_controller.js +114 -28
  15. data/app/javascript/controllers/combobox_controller.js +17 -2
  16. data/app/javascript/controllers/date_picker_controller.js +57 -21
  17. data/app/javascript/controllers/drawer_controller.js +66 -44
  18. data/app/javascript/controllers/drawer_trigger_controller.js +13 -0
  19. data/app/javascript/controllers/sidebar_controller.js +84 -42
  20. data/app/javascript/controllers/sidebar_trigger_controller.js +13 -0
  21. data/app/javascript/controllers/toast_controller.js +2 -3
  22. data/app/javascript/controllers/toaster_controller.js +23 -1
  23. data/app/javascript/controllers/toggle_group_controller.js +17 -6
  24. data/app/views/components/_badge.html.erb +1 -1
  25. data/app/views/components/_breadcrumbs.html.erb +7 -7
  26. data/app/views/components/_calendar.html.erb +39 -30
  27. data/app/views/components/_combobox.html.erb +2 -0
  28. data/app/views/components/_date_picker.html.erb +21 -33
  29. data/app/views/components/_drawer.html.erb +1 -1
  30. data/app/views/components/_menu_button.html.erb +1 -1
  31. data/app/views/components/_sidebar.html.erb +1 -1
  32. data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
  33. data/app/views/components/breadcrumbs/_item.html.erb +1 -1
  34. data/app/views/components/breadcrumbs/_link.html.erb +1 -1
  35. data/app/views/components/breadcrumbs/_list.html.erb +1 -1
  36. data/app/views/components/breadcrumbs/_page.html.erb +1 -1
  37. data/app/views/components/breadcrumbs/_separator.html.erb +1 -2
  38. data/app/views/components/calendar/_header.html.erb +28 -4
  39. data/app/views/components/calendar/_week.html.erb +7 -4
  40. data/app/views/components/card/_content.html.erb +1 -1
  41. data/app/views/components/card/_footer.html.erb +3 -10
  42. data/app/views/components/card/_header.html.erb +1 -1
  43. data/app/views/components/card/_title.html.erb +1 -1
  44. data/app/views/components/combobox/_option.html.erb +2 -2
  45. data/app/views/components/drawer/_close.html.erb +11 -5
  46. data/app/views/components/drawer/_content.html.erb +1 -1
  47. data/app/views/components/drawer/_footer.html.erb +1 -1
  48. data/app/views/components/drawer/_header.html.erb +1 -1
  49. data/app/views/components/drawer/_provider.html.erb +1 -1
  50. data/app/views/components/drawer/_trigger.html.erb +4 -2
  51. data/app/views/components/pagination/_ellipsis.html.erb +5 -8
  52. data/app/views/components/pagination/_link.html.erb +1 -1
  53. data/app/views/components/pagination/_next.html.erb +6 -7
  54. data/app/views/components/pagination/_previous.html.erb +6 -7
  55. data/app/views/components/sidebar/_content.html.erb +1 -1
  56. data/app/views/components/sidebar/_footer.html.erb +1 -1
  57. data/app/views/components/sidebar/_group.html.erb +1 -1
  58. data/app/views/components/sidebar/_header.html.erb +1 -1
  59. data/app/views/components/sidebar/_inset.html.erb +1 -1
  60. data/app/views/components/sidebar/_menu.html.erb +1 -1
  61. data/app/views/components/sidebar/_menu_button.html.erb +1 -1
  62. data/app/views/components/sidebar/_menu_item.html.erb +1 -1
  63. data/app/views/components/sidebar/_menu_link.html.erb +3 -3
  64. data/app/views/components/sidebar/_provider.html.erb +1 -1
  65. data/app/views/components/sidebar/_trigger.html.erb +1 -1
  66. data/app/views/components/stats/_stats_card.html.erb +7 -6
  67. data/app/views/components/toast/_action.html.erb +2 -1
  68. data/lib/generators/maquina_components/install/templates/theme.css.tt +11 -0
  69. data/lib/maquina_components/version.rb +1 -1
  70. metadata +1 -1
@@ -1,7 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["day", "input", "inputEnd", "prevButton", "nextButton", "grid", "caption"]
4
+ static targets = ["day", "input", "inputEnd", "prevButton", "nextButton", "grid", "caption", "monthSelect", "yearSelect"]
5
5
 
6
6
  static values = {
7
7
  month: Number,
@@ -11,12 +11,45 @@ export default class extends Controller {
11
11
  minDate: String,
12
12
  maxDate: String,
13
13
  mode: { type: String, default: "single" },
14
- weekStartsOn: { type: String, default: "sunday" }
14
+ weekStartsOn: { type: String, default: "sunday" },
15
+ disabledDates: Array,
16
+ showOutsideDays: { type: Boolean, default: true },
17
+ // Bounds of the caption's year select (caption_layout: :dropdown only)
18
+ startYear: Number,
19
+ endYear: Number
15
20
  }
16
21
 
17
22
  connect() {
18
23
  this.updateNavigationState()
19
24
  this.updateDayStates()
25
+
26
+ // Inside a popover (date picker or a caller's own), track which side of
27
+ // the trigger it opened on, so the header can sit next to the trigger
28
+ this.popover = this.element.closest("[popover]")
29
+ if (this.popover) {
30
+ this.boundUpdateSide = this.updateSide.bind(this)
31
+ this.popover.addEventListener("toggle", this.boundUpdateSide)
32
+ }
33
+ }
34
+
35
+ disconnect() {
36
+ this.popover?.removeEventListener("toggle", this.boundUpdateSide)
37
+ }
38
+
39
+ /**
40
+ * Sets data-side="top|bottom" when the popover is open. A popover flipped
41
+ * above its trigger is pinned at its bottom edge, so CSS moves the header
42
+ * there: month heights differ, and the arrows must not move under the cursor.
43
+ */
44
+ updateSide() {
45
+ if (!this.popover?.matches(":popover-open")) return
46
+
47
+ const trigger = this.popover.id &&
48
+ document.querySelector(`[popovertarget="${CSS.escape(this.popover.id)}"]`)
49
+ if (!trigger) return
50
+
51
+ const above = this.popover.getBoundingClientRect().top < trigger.getBoundingClientRect().top
52
+ this.element.dataset.side = above ? "top" : "bottom"
20
53
  }
21
54
 
22
55
  previousMonth() {
@@ -43,12 +76,38 @@ export default class extends Controller {
43
76
  this.navigateToMonth(newMonth, newYear)
44
77
  }
45
78
 
79
+ // Caption dropdowns (caption_layout: :dropdown)
80
+ selectMonth() {
81
+ this.navigateToMonth(Number(this.monthSelectTarget.value), this.yearValue)
82
+ }
83
+
84
+ selectYear() {
85
+ const year = Number(this.yearSelectTarget.value)
86
+ this.navigateToMonth(this.clampMonth(this.monthValue, year), year)
87
+ }
88
+
89
+ // A year change keeps the month, unless min/max date rule it out that year
90
+ clampMonth(month, year) {
91
+ const [minYear, minMonth] = (this.minDateValue || "").split("-").map(Number)
92
+ const [maxYear, maxMonth] = (this.maxDateValue || "").split("-").map(Number)
93
+
94
+ if (year === minYear && month < minMonth) return minMonth
95
+ if (year === maxYear && month > maxMonth) return maxMonth
96
+ return month
97
+ }
98
+
46
99
  navigateToMonth(month, year) {
100
+ // The year select lists only startYear..endYear; never show a month it can't
101
+ if ((this.hasStartYearValue && year < this.startYearValue) ||
102
+ (this.hasEndYearValue && year > this.endYearValue)) return
103
+
47
104
  this.monthValue = month
48
105
  this.yearValue = year
49
106
 
50
107
  this.rebuildCalendar()
108
+ this.syncDropdowns()
51
109
  this.updateNavigationState()
110
+ this.updateSide()
52
111
 
53
112
  this.dispatch("navigate", {
54
113
  detail: {
@@ -98,30 +157,35 @@ export default class extends Controller {
98
157
  weeks.push(week)
99
158
  }
100
159
 
101
- // Parse dates for comparison
102
- const selectedDate = this.selectedValue ? new Date(this.selectedValue) : null
103
- const selectedEndDate = this.selectedEndValue ? new Date(this.selectedEndValue) : null
104
- const minDate = this.minDateValue ? new Date(this.minDateValue) : null
105
- const maxDate = this.maxDateValue ? new Date(this.maxDateValue) : null
106
-
107
160
  // Rebuild grid HTML
108
- grid.innerHTML = weeks.map(week => this.buildWeekHTML(week, month, selectedDate, selectedEndDate, minDate, maxDate)).join('')
161
+ grid.innerHTML = weeks.map(week => this.buildWeekHTML(week, month)).join('')
109
162
  }
110
163
 
111
- buildWeekHTML(days, displayMonth, selectedDate, selectedEndDate, minDate, maxDate) {
112
- // Use string comparisons for range to avoid timezone issues
164
+ // Mirrors calendar/_week.html.erb. Dates compare as ISO strings, which sort
165
+ // lexicographically and avoid the UTC shift of new Date("YYYY-MM-DD").
166
+ buildWeekHTML(days, displayMonth) {
113
167
  const selectedStr = this.selectedValue || ""
114
168
  const selectedEndStr = this.selectedEndValue || ""
169
+ const minStr = this.minDateValue || ""
170
+ const maxStr = this.maxDateValue || ""
171
+ const disabledDates = new Set(this.disabledDatesValue)
115
172
 
116
173
  const daysHTML = days.map(day => {
117
174
  const dateStr = this.formatDate(day)
118
175
  const isOutside = day.getMonth() + 1 !== displayMonth
176
+
177
+ // A hidden outside day keeps its grid column
178
+ if (isOutside && !this.showOutsideDaysValue) {
179
+ return '<span aria-hidden="true" data-calendar-part="outside-day"></span>'
180
+ }
119
181
  const isToday = this.isSameDate(day, new Date())
120
182
  const isSelected = selectedStr && dateStr === selectedStr
121
183
  const isRangeEnd = selectedEndStr && dateStr === selectedEndStr
122
184
  // ISO date strings are lexicographically sortable
123
185
  const isRangeMiddle = selectedStr && selectedEndStr && dateStr > selectedStr && dateStr < selectedEndStr
124
- const isDisabled = (minDate && day < minDate) || (maxDate && day > maxDate)
186
+ const isDisabled = (minStr && dateStr < minStr) ||
187
+ (maxStr && dateStr > maxStr) ||
188
+ disabledDates.has(dateStr)
125
189
 
126
190
  let dayState = null
127
191
  if (isSelected && this.modeValue === "range" && selectedEndStr) {
@@ -221,7 +285,8 @@ export default class extends Controller {
221
285
 
222
286
  handleKeydown(event) {
223
287
  const currentButton = event.currentTarget
224
- const currentDate = new Date(currentButton.dataset.date)
288
+ // Local midnight: a bare "YYYY-MM-DD" parses as UTC and shifts a day west of it
289
+ const currentDate = new Date(currentButton.dataset.date + "T00:00:00")
225
290
  let targetDate = null
226
291
 
227
292
  switch (event.key) {
@@ -269,8 +334,9 @@ export default class extends Controller {
269
334
  const dateStr = this.formatDate(date)
270
335
  const targetButton = this.dayTargets.find(btn => btn.dataset.date === dateStr)
271
336
 
272
- if (targetButton && !targetButton.disabled) {
273
- targetButton.focus()
337
+ if (targetButton) {
338
+ // A disabled day in the visible month is not focusable; stay put
339
+ if (!targetButton.disabled) targetButton.focus()
274
340
  } else {
275
341
  this.navigateToMonth(date.getMonth() + 1, date.getFullYear())
276
342
  requestAnimationFrame(() => {
@@ -328,16 +394,40 @@ export default class extends Controller {
328
394
  }
329
395
 
330
396
  updateNavigationState() {
331
- if (this.minDateValue && this.hasPrevButtonTarget) {
332
- const minDate = new Date(this.minDateValue)
333
- const firstOfMonth = new Date(this.yearValue, this.monthValue - 1, 1)
334
- this.prevButtonTarget.disabled = firstOfMonth <= minDate
397
+ const firstOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue - 1, 1))
398
+ const lastOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue, 0))
399
+
400
+ if (this.hasPrevButtonTarget) {
401
+ this.prevButtonTarget.disabled = Boolean(
402
+ (this.minDateValue && firstOfMonth <= this.minDateValue) ||
403
+ (this.hasStartYearValue && this.monthValue === 1 && this.yearValue <= this.startYearValue)
404
+ )
335
405
  }
336
406
 
337
- if (this.maxDateValue && this.hasNextButtonTarget) {
338
- const maxDate = new Date(this.maxDateValue)
339
- const lastOfMonth = new Date(this.yearValue, this.monthValue, 0)
340
- this.nextButtonTarget.disabled = lastOfMonth >= maxDate
407
+ if (this.hasNextButtonTarget) {
408
+ this.nextButtonTarget.disabled = Boolean(
409
+ (this.maxDateValue && lastOfMonth >= this.maxDateValue) ||
410
+ (this.hasEndYearValue && this.monthValue === 12 && this.yearValue >= this.endYearValue)
411
+ )
412
+ }
413
+ }
414
+
415
+ // Mirrors calendar_month_options: a month wholly outside min/max is disabled
416
+ syncDropdowns() {
417
+ if (this.hasMonthSelectTarget) {
418
+ for (const option of this.monthSelectTarget.options) {
419
+ const month = Number(option.value)
420
+ const first = this.formatDate(new Date(this.yearValue, month - 1, 1))
421
+ const last = this.formatDate(new Date(this.yearValue, month, 0))
422
+
423
+ option.disabled = Boolean((this.minDateValue && last < this.minDateValue) ||
424
+ (this.maxDateValue && first > this.maxDateValue))
425
+ }
426
+ this.monthSelectTarget.value = String(this.monthValue)
427
+ }
428
+
429
+ if (this.hasYearSelectTarget) {
430
+ this.yearSelectTarget.value = String(this.yearValue)
341
431
  }
342
432
  }
343
433
 
@@ -348,12 +438,8 @@ export default class extends Controller {
348
438
  selectedEnd: this.selectedEndValue || null
349
439
  }
350
440
 
441
+ // Stimulus dispatches a bubbling "calendar:change" — once
351
442
  this.dispatch("change", { detail })
352
-
353
- this.element.dispatchEvent(new CustomEvent("calendar:change", {
354
- bubbles: true,
355
- detail
356
- }))
357
443
  }
358
444
 
359
445
  formatDate(date) {
@@ -9,9 +9,10 @@ import { Controller } from "@hotwired/stimulus"
9
9
  * - Single selection with toggle
10
10
  * - Keyboard navigation
11
11
  * - Focus management
12
+ * - Form submission through a hidden input (rendered when `name:` is given)
12
13
  */
13
14
  export default class extends Controller {
14
- static targets = ["trigger", "content", "input", "option", "empty", "label"]
15
+ static targets = ["trigger", "content", "input", "option", "empty", "label", "hiddenInput"]
15
16
 
16
17
  static values = {
17
18
  value: { type: String, default: "" },
@@ -171,6 +172,9 @@ export default class extends Controller {
171
172
 
172
173
  this.updateSelection()
173
174
  this.updateTriggerState()
175
+ // valueValueChanged runs later (attribute observer); write the input now so
176
+ // it is current when combobox:change fires
177
+ this.updateHiddenInput()
174
178
 
175
179
  // Close popover
176
180
  if (this.hasContentTarget) {
@@ -326,9 +330,20 @@ export default class extends Controller {
326
330
  }
327
331
  }
328
332
 
329
- // Value change callback
333
+ // Value change callback: also covers a programmatic
334
+ // data-combobox-value-value write
330
335
  valueValueChanged() {
331
336
  this.updateSelection()
332
337
  this.updateTriggerState()
338
+ this.updateHiddenInput()
339
+ }
340
+
341
+ updateHiddenInput() {
342
+ if (!this.hasHiddenInputTarget) return
343
+ if (this.hiddenInputTarget.value === this.valueValue) return
344
+
345
+ this.hiddenInputTarget.value = this.valueValue
346
+ // Let form listeners (autosave, validation) see the new value
347
+ this.hiddenInputTarget.dispatchEvent(new Event("change", { bubbles: true }))
333
348
  }
334
349
  }
@@ -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) {