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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/calendar.css +48 -1
- data/app/assets/stylesheets/date_picker.css +13 -1
- 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 +69 -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 +114 -28
- 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 +39 -30
- data/app/views/components/_combobox.html.erb +2 -0
- data/app/views/components/_date_picker.html.erb +21 -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/_header.html.erb +28 -4
- 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
|
@@ -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>',
|
|
@@ -32,13 +32,26 @@ export default class extends Controller {
|
|
|
32
32
|
this.syncItemStates()
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A group rendered with disabled: true (aria-disabled="true") disables
|
|
37
|
+
* every item, including items rendered directly from partials and items
|
|
38
|
+
* added later, so they leave the tab order and ignore keyboard input.
|
|
39
|
+
*/
|
|
40
|
+
itemTargetConnected(item) {
|
|
41
|
+
if (this.disabled) item.disabled = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get disabled() {
|
|
45
|
+
return this.element.getAttribute("aria-disabled") === "true"
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
/**
|
|
36
49
|
* Toggle an item's state
|
|
37
50
|
* @param {Event} event - Click event from toggle item
|
|
38
51
|
*/
|
|
39
52
|
toggle(event) {
|
|
40
53
|
const item = event.currentTarget
|
|
41
|
-
if (item.disabled) return
|
|
54
|
+
if (item.disabled || this.disabled) return
|
|
42
55
|
|
|
43
56
|
const value = item.dataset.value
|
|
44
57
|
const isPressed = item.dataset.state === "on"
|
|
@@ -66,6 +79,8 @@ export default class extends Controller {
|
|
|
66
79
|
* @param {KeyboardEvent} event
|
|
67
80
|
*/
|
|
68
81
|
handleKeydown(event) {
|
|
82
|
+
if (this.disabled) return
|
|
83
|
+
|
|
69
84
|
const item = event.currentTarget
|
|
70
85
|
const items = this.itemTargets.filter(i => !i.disabled)
|
|
71
86
|
const currentIndex = items.indexOf(item)
|
|
@@ -125,12 +140,8 @@ export default class extends Controller {
|
|
|
125
140
|
: this.selectedValue
|
|
126
141
|
}
|
|
127
142
|
|
|
143
|
+
// Emits a single bubbling `toggle-group:change` from the group element
|
|
128
144
|
this.dispatch("change", { detail })
|
|
129
|
-
|
|
130
|
-
this.element.dispatchEvent(new CustomEvent("toggle-group:change", {
|
|
131
|
-
bubbles: true,
|
|
132
|
-
detail
|
|
133
|
-
}))
|
|
134
145
|
}
|
|
135
146
|
|
|
136
147
|
/**
|
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
component: :breadcrumbs
|
|
5
5
|
)
|
|
6
6
|
|
|
7
|
-
merged_data[:controller] = "breadcrumb" if responsive
|
|
7
|
+
merged_data[:controller] = "breadcrumb" if responsive
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
aria
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# A caller's aria-label (either spelling) replaces the default one.
|
|
10
|
+
aria = (html_options.delete(:aria) || {}).symbolize_keys
|
|
11
|
+
aria[:label] = html_options.delete(:"aria-label") || aria[:label] || "Breadcrumb" %>
|
|
12
|
+
|
|
13
|
+
<%= content_tag :nav, class: css_classes.presence, data: merged_data, aria: aria, **html_options do %>
|
|
14
14
|
<%= yield %>
|
|
15
|
-
|
|
15
|
+
<% end %>
|
|
@@ -1,30 +1,11 @@
|
|
|
1
|
-
<%# locals: (selected: nil, selected_end: nil, month: nil, year: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, cell_size: nil, input_name: nil, input_name_end: nil, css_classes: "", **html_options) %>
|
|
1
|
+
<%# locals: (selected: nil, selected_end: nil, month: nil, year: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, caption_layout: :label, years: nil, cell_size: nil, input_name: nil, input_name_end: nil, css_classes: "", **html_options) %>
|
|
2
2
|
<%
|
|
3
|
-
#
|
|
4
|
-
selected_date =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
selected_end_date = case selected_end
|
|
11
|
-
when String then Date.parse(selected_end) rescue nil
|
|
12
|
-
when Date, Time, DateTime then selected_end.to_date
|
|
13
|
-
else nil
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Parse min/max dates
|
|
17
|
-
min_date_parsed = case min_date
|
|
18
|
-
when String then Date.parse(min_date) rescue nil
|
|
19
|
-
when Date, Time, DateTime then min_date.to_date
|
|
20
|
-
else nil
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
max_date_parsed = case max_date
|
|
24
|
-
when String then Date.parse(max_date) rescue nil
|
|
25
|
-
when Date, Time, DateTime then max_date.to_date
|
|
26
|
-
else nil
|
|
27
|
-
end
|
|
3
|
+
# Dates may arrive as Date, Time or ISO strings
|
|
4
|
+
selected_date = calendar_to_date(selected)
|
|
5
|
+
selected_end_date = calendar_to_date(selected_end)
|
|
6
|
+
min_date_parsed = calendar_to_date(min_date)
|
|
7
|
+
max_date_parsed = calendar_to_date(max_date)
|
|
8
|
+
disabled_dates_parsed = Array(disabled_dates).filter_map { |date| calendar_to_date(date) }
|
|
28
9
|
|
|
29
10
|
# Determine display month/year
|
|
30
11
|
display_date = selected_date || Date.current
|
|
@@ -55,6 +36,18 @@
|
|
|
55
36
|
abbr_day_names.rotate(1) :
|
|
56
37
|
abbr_day_names
|
|
57
38
|
|
|
39
|
+
# caption_layout: :dropdown swaps the caption for month and year selects.
|
|
40
|
+
# Their locals reach the header only then, so an app's own calendar/header
|
|
41
|
+
# override written for the caption keeps working.
|
|
42
|
+
dropdown = caption_layout.to_sym == :dropdown
|
|
43
|
+
year_range = calendar_year_range(display_year, min_date: min_date_parsed, max_date: max_date_parsed, years: years) if dropdown
|
|
44
|
+
header_locals = if dropdown
|
|
45
|
+
{caption_layout: :dropdown, years: year_range,
|
|
46
|
+
month_options: calendar_month_options(display_year, min_date: min_date_parsed, max_date: max_date_parsed)}
|
|
47
|
+
else
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
|
|
58
51
|
merged_data = merge_component_data(html_options,
|
|
59
52
|
controller: "calendar",
|
|
60
53
|
component: "calendar",
|
|
@@ -65,10 +58,16 @@
|
|
|
65
58
|
"calendar-selected-end-value": selected_end_date&.iso8601,
|
|
66
59
|
"calendar-min-date-value": min_date_parsed&.iso8601,
|
|
67
60
|
"calendar-max-date-value": max_date_parsed&.iso8601,
|
|
68
|
-
"calendar-week-starts-on-value": week_starts_on
|
|
61
|
+
"calendar-week-starts-on-value": week_starts_on,
|
|
62
|
+
"calendar-disabled-dates-value": disabled_dates_parsed.map(&:iso8601).presence,
|
|
63
|
+
"calendar-show-outside-days-value": show_outside_days,
|
|
64
|
+
"calendar-start-year-value": year_range&.first,
|
|
65
|
+
"calendar-end-year-value": year_range&.last
|
|
69
66
|
)
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
# cell_size and a caller's style: both write the style attribute, so join them
|
|
69
|
+
style_attr = [("--cell-size: #{cell_size};" if cell_size), html_options.delete(:style).presence]
|
|
70
|
+
.compact.join(" ").presence
|
|
72
71
|
%>
|
|
73
72
|
|
|
74
73
|
<%= content_tag :div,
|
|
@@ -95,7 +94,8 @@
|
|
|
95
94
|
<%= render "components/calendar/header",
|
|
96
95
|
month: display_month,
|
|
97
96
|
year: display_year,
|
|
98
|
-
month_name: I18n.l(first_of_month, format: "%B %Y")
|
|
97
|
+
month_name: I18n.l(first_of_month, format: "%B %Y"),
|
|
98
|
+
**header_locals %>
|
|
99
99
|
|
|
100
100
|
<%# Weekday headers %>
|
|
101
101
|
<div data-calendar-part="weekdays">
|
|
@@ -115,8 +115,17 @@
|
|
|
115
115
|
mode: mode,
|
|
116
116
|
min_date: min_date_parsed,
|
|
117
117
|
max_date: max_date_parsed,
|
|
118
|
-
disabled_dates:
|
|
118
|
+
disabled_dates: disabled_dates_parsed,
|
|
119
119
|
show_outside_days: show_outside_days %>
|
|
120
120
|
<% end %>
|
|
121
121
|
</div>
|
|
122
|
+
|
|
123
|
+
<%# Optional footer (actions such as Clear/Done); stays between the grid and
|
|
124
|
+
a header moved to the bottom when the calendar's popover opens above.
|
|
125
|
+
Without a caller block a partial's yield returns content_for(:layout),
|
|
126
|
+
which is the whole page when rendered from a layout, so that is no footer. %>
|
|
127
|
+
<% footer = capture { yield }.presence %>
|
|
128
|
+
<% if footer && footer != content_for(:layout) %>
|
|
129
|
+
<div data-calendar-part="footer"><%= footer %></div>
|
|
130
|
+
<% end %>
|
|
122
131
|
<% end %>
|
|
@@ -10,5 +10,7 @@
|
|
|
10
10
|
) %>
|
|
11
11
|
|
|
12
12
|
<%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
|
|
13
|
+
<%# Submits the selection with the surrounding form; the controller keeps it in sync %>
|
|
14
|
+
<%= hidden_field_tag name, value, id: nil, data: {combobox_target: "hiddenInput"} if name.present? %>
|
|
13
15
|
<%= yield combobox_id %>
|
|
14
16
|
<% end %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (selected: nil, selected_end: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, placeholder: nil, input_name: nil, input_name_end: nil, id: nil, disabled: false, required: false, css_classes: "", **html_options) %>
|
|
1
|
+
<%# locals: (selected: nil, selected_end: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, caption_layout: :label, years: nil, placeholder: nil, input_name: nil, input_name_end: nil, id: nil, disabled: false, required: false, size: :default, full_width: false, css_classes: "", **html_options) %>
|
|
2
2
|
<%
|
|
3
3
|
# Deterministic ID for popover targeting: random ids break idiomorph
|
|
4
4
|
# matching across Turbo morphs
|
|
@@ -6,46 +6,34 @@
|
|
|
6
6
|
popover_id = "#{component_id}-popover"
|
|
7
7
|
|
|
8
8
|
# Parse selected dates for display
|
|
9
|
-
selected_date =
|
|
10
|
-
|
|
11
|
-
when Date, Time, DateTime then selected.to_date
|
|
12
|
-
else nil
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
selected_end_date = case selected_end
|
|
16
|
-
when String then Date.parse(selected_end) rescue nil
|
|
17
|
-
when Date, Time, DateTime then selected_end.to_date
|
|
18
|
-
else nil
|
|
19
|
-
end
|
|
9
|
+
selected_date = calendar_to_date(selected)
|
|
10
|
+
selected_end_date = calendar_to_date(selected_end)
|
|
20
11
|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
I18n.l(selected_date, format: :long)
|
|
12
|
+
# Same helpers the controller mirrors client-side, so connect() never
|
|
13
|
+
# rewrites the server-rendered text
|
|
14
|
+
display_value = if mode == :range
|
|
15
|
+
date_picker_format_range(selected_date, selected_end_date)
|
|
16
|
+
else
|
|
17
|
+
date_picker_format(selected_date)
|
|
28
18
|
end
|
|
29
19
|
|
|
30
|
-
|
|
20
|
+
placeholder_text = placeholder.presence || (mode == :range ? "Select date range" : "Select date")
|
|
31
21
|
|
|
32
22
|
merged_data = merge_component_data(html_options,
|
|
33
23
|
controller: "date-picker",
|
|
34
24
|
component: "date-picker",
|
|
25
|
+
size: size,
|
|
26
|
+
full_width: (full_width ? true : nil),
|
|
35
27
|
"date-picker-mode-value": mode,
|
|
36
28
|
"date-picker-selected-value": selected_date&.iso8601,
|
|
37
|
-
"date-picker-selected-end-value": selected_end_date&.iso8601
|
|
29
|
+
"date-picker-selected-end-value": selected_end_date&.iso8601,
|
|
30
|
+
"date-picker-placeholder-value": placeholder_text,
|
|
31
|
+
"date-picker-placeholder-range-value": (placeholder_text if mode == :range),
|
|
32
|
+
"date-picker-locale-value": date_picker_locale_data
|
|
38
33
|
)
|
|
39
34
|
%>
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
class="<%= css_classes.presence %>"
|
|
43
|
-
data-controller="date-picker"
|
|
44
|
-
data-component="date-picker"
|
|
45
|
-
data-date-picker-mode-value="<%= mode %>"
|
|
46
|
-
data-date-picker-selected-value="<%= selected_date&.iso8601 %>"
|
|
47
|
-
data-date-picker-selected-end-value="<%= selected_end_date&.iso8601 %>">
|
|
48
|
-
|
|
36
|
+
<%= content_tag :div, id: component_id, class: css_classes.presence, data: merged_data, **html_options do %>
|
|
49
37
|
<%# Hidden inputs for form submission %>
|
|
50
38
|
<% if input_name %>
|
|
51
39
|
<input type="hidden"
|
|
@@ -70,9 +58,7 @@
|
|
|
70
58
|
aria-haspopup="dialog"
|
|
71
59
|
aria-expanded="false">
|
|
72
60
|
<%= builtin_icon_for :calendar, class: "size-4" %>
|
|
73
|
-
<span data-date-picker-target="display">
|
|
74
|
-
<%= display_value || placeholder || default_placeholder %>
|
|
75
|
-
</span>
|
|
61
|
+
<span data-date-picker-target="display"><%= display_value || placeholder_text %></span>
|
|
76
62
|
<% unless display_value %>
|
|
77
63
|
<span data-date-picker-part="placeholder-indicator"></span>
|
|
78
64
|
<% end %>
|
|
@@ -95,9 +81,11 @@
|
|
|
95
81
|
disabled_dates: disabled_dates,
|
|
96
82
|
show_outside_days: show_outside_days,
|
|
97
83
|
week_starts_on: week_starts_on,
|
|
84
|
+
caption_layout: caption_layout,
|
|
85
|
+
years: years,
|
|
98
86
|
data: {
|
|
99
87
|
"date-picker-target": "calendar",
|
|
100
88
|
action: "calendar:change->date-picker#handleChange calendar:navigate->date-picker#handleNavigate"
|
|
101
89
|
} %>
|
|
102
90
|
</div>
|
|
103
|
-
|
|
91
|
+
<% end %>
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<%= image_tag icon, alt: title.presence || "", class: icon_classes.presence %>
|
|
21
21
|
<% elsif text_icon.present? %>
|
|
22
22
|
<span data-menu-button-part="avatar">
|
|
23
|
-
|
|
23
|
+
<%= tag.span text_icon, class: icon_classes.presence %>
|
|
24
24
|
</span>
|
|
25
25
|
<% end %>
|
|
26
26
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
<aside
|
|
14
14
|
id="<%= stable_id %>"
|
|
15
|
-
class="group peer sidebar-loading
|
|
15
|
+
class="<%= class_names("group peer sidebar-loading", css_classes) %>"
|
|
16
16
|
<%= tag.attributes(data: merged_data, **html_options) %>
|
|
17
17
|
>
|
|
18
18
|
<%# Sidebar gap (creates space for sidebar on desktop) %>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
breadcrumb_part: :ellipsis
|
|
4
4
|
) %>
|
|
5
5
|
|
|
6
|
-
<%= content_tag :span, role: "presentation", class: css_classes, data: merged_data, **html_options do %>
|
|
6
|
+
<%= content_tag :span, role: "presentation", class: css_classes.presence, data: merged_data, **html_options do %>
|
|
7
7
|
<%= builtin_icon_for(:ellipsis) %>
|
|
8
8
|
<span class="sr-only">More</span>
|
|
9
9
|
<% end %>
|