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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/drawer.css +8 -8
- data/app/assets/stylesheets/form.css +58 -4
- data/app/assets/stylesheets/separator.css +6 -1
- data/app/assets/tailwind/maquina_components_engine/tokens.css +1 -0
- data/app/helpers/maquina_components/calendar_helper.rb +32 -0
- data/app/helpers/maquina_components/drawer_helper.rb +3 -5
- data/app/helpers/maquina_components/dropdown_menu_helper.rb +22 -5
- data/app/helpers/maquina_components/empty_helper.rb +3 -3
- data/app/helpers/maquina_components/icons_helper.rb +9 -5
- data/app/helpers/maquina_components/pagination_helper.rb +27 -5
- data/app/javascript/controllers/calendar_controller.js +28 -25
- data/app/javascript/controllers/combobox_controller.js +17 -2
- data/app/javascript/controllers/date_picker_controller.js +57 -21
- data/app/javascript/controllers/drawer_controller.js +66 -44
- data/app/javascript/controllers/drawer_trigger_controller.js +13 -0
- data/app/javascript/controllers/sidebar_controller.js +84 -42
- data/app/javascript/controllers/sidebar_trigger_controller.js +13 -0
- data/app/javascript/controllers/toast_controller.js +2 -3
- data/app/javascript/controllers/toaster_controller.js +23 -1
- data/app/javascript/controllers/toggle_group_controller.js +17 -6
- data/app/views/components/_badge.html.erb +1 -1
- data/app/views/components/_breadcrumbs.html.erb +7 -7
- data/app/views/components/_calendar.html.erb +13 -28
- data/app/views/components/_combobox.html.erb +2 -0
- data/app/views/components/_date_picker.html.erb +19 -33
- data/app/views/components/_drawer.html.erb +1 -1
- data/app/views/components/_menu_button.html.erb +1 -1
- data/app/views/components/_sidebar.html.erb +1 -1
- data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
- data/app/views/components/breadcrumbs/_item.html.erb +1 -1
- data/app/views/components/breadcrumbs/_link.html.erb +1 -1
- data/app/views/components/breadcrumbs/_list.html.erb +1 -1
- data/app/views/components/breadcrumbs/_page.html.erb +1 -1
- data/app/views/components/breadcrumbs/_separator.html.erb +1 -2
- data/app/views/components/calendar/_week.html.erb +7 -4
- data/app/views/components/card/_content.html.erb +1 -1
- data/app/views/components/card/_footer.html.erb +3 -10
- data/app/views/components/card/_header.html.erb +1 -1
- data/app/views/components/card/_title.html.erb +1 -1
- data/app/views/components/combobox/_option.html.erb +2 -2
- data/app/views/components/drawer/_close.html.erb +11 -5
- data/app/views/components/drawer/_content.html.erb +1 -1
- data/app/views/components/drawer/_footer.html.erb +1 -1
- data/app/views/components/drawer/_header.html.erb +1 -1
- data/app/views/components/drawer/_provider.html.erb +1 -1
- data/app/views/components/drawer/_trigger.html.erb +4 -2
- data/app/views/components/pagination/_ellipsis.html.erb +5 -8
- data/app/views/components/pagination/_link.html.erb +1 -1
- data/app/views/components/pagination/_next.html.erb +6 -7
- data/app/views/components/pagination/_previous.html.erb +6 -7
- data/app/views/components/sidebar/_content.html.erb +1 -1
- data/app/views/components/sidebar/_footer.html.erb +1 -1
- data/app/views/components/sidebar/_group.html.erb +1 -1
- data/app/views/components/sidebar/_header.html.erb +1 -1
- data/app/views/components/sidebar/_inset.html.erb +1 -1
- data/app/views/components/sidebar/_menu.html.erb +1 -1
- data/app/views/components/sidebar/_menu_button.html.erb +1 -1
- data/app/views/components/sidebar/_menu_item.html.erb +1 -1
- data/app/views/components/sidebar/_menu_link.html.erb +3 -3
- data/app/views/components/sidebar/_provider.html.erb +1 -1
- data/app/views/components/sidebar/_trigger.html.erb +1 -1
- data/app/views/components/stats/_stats_card.html.erb +7 -6
- data/app/views/components/toast/_action.html.erb +2 -1
- data/lib/generators/maquina_components/install/templates/theme.css.tt +11 -0
- data/lib/maquina_components/version.rb +1 -1
- 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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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", "
|
|
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
|
-
|
|
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.
|
|
36
|
-
|
|
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.
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
38
|
-
this.
|
|
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.
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
134
|
+
this._refreshing = false
|
|
92
135
|
|
|
93
|
-
if (this.isMobile()) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
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 (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
this.
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
105
|
-
//
|
|
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>',
|