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
@@ -34,8 +34,8 @@ export default class extends Controller {
34
34
  // Track if we're on mobile
35
35
  this._isMobile = null
36
36
 
37
- // Guard flag for morph-triggered attribute changes
38
- this._morphing = false
37
+ // Set by a same-URL "replace" visit (a page refresh) until it renders
38
+ this._refreshing = false
39
39
  }
40
40
 
41
41
  connect() {
@@ -46,11 +46,15 @@ export default class extends Controller {
46
46
  this.resizeHandler = this.debounce(this.checkScreenSize.bind(this), 150)
47
47
  window.addEventListener("resize", this.resizeHandler)
48
48
 
49
+ this.boundTrackVisit = this.trackVisit.bind(this)
50
+ this.boundClearVisit = this.clearVisit.bind(this)
49
51
  this.boundTeardown = this.teardown.bind(this)
50
- this.boundBeforeMorphElement = this.beforeMorphElement.bind(this)
52
+ this.boundPreserveAttribute = this.preserveAttribute.bind(this)
51
53
  this.boundHandleMorph = this.handleMorph.bind(this)
54
+ document.addEventListener("turbo:visit", this.boundTrackVisit)
55
+ document.addEventListener("turbo:load", this.boundClearVisit)
52
56
  document.addEventListener("turbo:before-cache", this.boundTeardown)
53
- document.addEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
57
+ document.addEventListener("turbo:before-morph-attribute", this.boundPreserveAttribute)
54
58
  document.addEventListener("turbo:morph", this.boundHandleMorph)
55
59
 
56
60
  // Apply initial state without animation
@@ -59,16 +63,40 @@ export default class extends Controller {
59
63
 
60
64
  disconnect() {
61
65
  window.removeEventListener("resize", this.resizeHandler)
66
+ document.removeEventListener("turbo:visit", this.boundTrackVisit)
67
+ document.removeEventListener("turbo:load", this.boundClearVisit)
62
68
  document.removeEventListener("turbo:before-cache", this.boundTeardown)
63
- document.removeEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
69
+ document.removeEventListener("turbo:before-morph-attribute", this.boundPreserveAttribute)
64
70
  document.removeEventListener("turbo:morph", this.boundHandleMorph)
65
71
  }
66
72
 
67
73
  // ============================================================================
68
- // Turbo Cache Teardown
74
+ // Turbo Integration (cache teardown and page-refresh morphs)
69
75
  // ============================================================================
70
76
 
77
+ // A page refresh is a same-path "replace" visit -- Turbo's own isPageRefresh
78
+ // test. It covers Turbo.visit(location.href, { action: "replace" }), refresh
79
+ // broadcasts and same-URL form redirects. The open state must survive it.
80
+ trackVisit(event) {
81
+ const { url, action } = event.detail
82
+ this._refreshing = action === "replace" &&
83
+ new URL(url, window.location.href).pathname === window.location.pathname
84
+ }
85
+
86
+ clearVisit() {
87
+ this._refreshing = false
88
+ }
89
+
90
+ // Runs before Turbo snapshots the page. Real navigation parks the mobile
91
+ // drawer so the cached snapshot (and the next page) starts closed; a page
92
+ // refresh leaves it open. Either way the scroll lock is released so no
93
+ // snapshot is cached locked -- handleMorph() re-locks after a refresh.
71
94
  teardown() {
95
+ if (this._refreshing) {
96
+ this.unlockScroll()
97
+ return
98
+ }
99
+
72
100
  if (this.isMobile()) {
73
101
  this.openValue = false
74
102
  }
@@ -81,19 +109,35 @@ export default class extends Controller {
81
109
  this.unlockScroll()
82
110
  }
83
111
 
84
- beforeMorphElement(event) {
85
- if (event.target === this.element) {
86
- this._morphing = true
112
+ // The server renders open state from the desktop cookie, which mobile never
113
+ // writes, so a morph must not overwrite the attributes this controller owns.
114
+ // The menu contents below them still morph normally.
115
+ preserveAttribute(event) {
116
+ const owned = this.ownedAttributes(event.target)
117
+ if (owned?.includes(event.detail.attributeName)) {
118
+ event.preventDefault()
87
119
  }
88
120
  }
89
121
 
122
+ ownedAttributes(element) {
123
+ if (element === this.element) return ["data-sidebar-open-value"]
124
+ if (this.hasSidebarTarget && element === this.sidebarTarget) return ["data-state", "data-collapsible"]
125
+ if (this.hasBackdropTarget && element === this.backdropTarget) return ["data-state", "class"]
126
+ if (this.hasContainerTarget && element === this.containerTarget) return ["inert"]
127
+ return null
128
+ }
129
+
130
+ // After a morph: keep the live state on mobile, re-read the cookie on
131
+ // desktop, then re-apply everything the morph can still reset -- the body
132
+ // scroll lock (<body> is morphed) and the trigger's aria-expanded.
90
133
  handleMorph() {
91
- const cookieValue = this.getCookie(this.cookieNameValue)
134
+ this._refreshing = false
92
135
 
93
- if (this.isMobile()) {
94
- this.openValue = false
95
- } else if (cookieValue !== null) {
96
- this.openValue = cookieValue === "true"
136
+ if (!this.isMobile()) {
137
+ const cookieValue = this.getCookie(this.cookieNameValue)
138
+ if (cookieValue !== null) {
139
+ this.openValue = cookieValue === "true"
140
+ }
97
141
  }
98
142
 
99
143
  if (this.hasSidebarTarget) {
@@ -101,7 +145,7 @@ export default class extends Controller {
101
145
  }
102
146
 
103
147
  this.updateState()
104
- this._morphing = false
148
+ this.dispatchStateChange()
105
149
  }
106
150
 
107
151
  // ============================================================================
@@ -123,7 +167,6 @@ export default class extends Controller {
123
167
 
124
168
  openValueChanged(new_value, old_value) {
125
169
  if (new_value === old_value) return
126
- if (this._morphing) return
127
170
 
128
171
  this.updateState()
129
172
  this.persistState()
@@ -278,31 +321,23 @@ export default class extends Controller {
278
321
  }
279
322
  }
280
323
 
324
+ // data-collapsible describes the current state ("none" just means open), so
325
+ // only the direction of the crossing matters: entering mobile parks the
326
+ // drawer closed, leaving it restores the saved desktop state.
281
327
  handleScreenSizeChange(wasMobile, isMobile) {
282
- if (this.sidebarTarget.getAttribute("data-collapsible") === "offcanvas") {
283
- // Mobile to desktop: restore saved state
284
- if (wasMobile && !isMobile) {
285
- const cookieValue = this.getCookie(this.cookieNameValue)
286
- this.openValue = cookieValue !== null
287
- ? cookieValue === "true"
288
- : this.defaultOpenValue
289
- }
290
- // Desktop to mobile: close sidebar
291
- else if (!wasMobile && isMobile) {
292
- this.openValue = false
293
- }
294
- } else if (this.sidebarTarget.getAttribute("data-collapsible") === "icon") {
295
- // Icon mode is always visible, just changes width
296
- // Desktop to mobile: might want to close
297
- if (!wasMobile && isMobile) {
298
- this.openValue = false
299
- }
300
- } else if (this.sidebarTarget.getAttribute("data-collapsible") === "none") {
301
- // Non-collapsible sidebars stay open
302
- this.openValue = true
328
+ if (wasMobile && !isMobile) {
329
+ const cookieValue = this.getCookie(this.cookieNameValue)
330
+ this.openValue = cookieValue !== null
331
+ ? cookieValue === "true"
332
+ : this.defaultOpenValue
333
+ } else if (!wasMobile && isMobile) {
334
+ this.openValue = false
303
335
  }
304
336
 
337
+ // Crossing the breakpoint without an open-state change still moves the
338
+ // drawer between offcanvas and icon rail, and drops a desktop scroll lock
305
339
  this.updateState()
340
+ if (!isMobile) this.unlockScroll()
306
341
  }
307
342
 
308
343
  isMobile() {
@@ -342,19 +377,26 @@ export default class extends Controller {
342
377
  // ============================================================================
343
378
 
344
379
  // Styling lives in drawer.css (body[data-maquina-scroll-locked]); the
345
- // controller only records the scroll offset and flips the attribute
380
+ // controller only records the scroll offset and flips the attribute.
381
+ //
382
+ // Idempotent: the offset is recorded once per lock. Re-locking after a morph
383
+ // reset <body> must not record the locked page's offset of 0.
346
384
  lockScroll() {
347
- this.scrollPosition = window.pageYOffset
385
+ if (!this._scrollLocked) {
386
+ this.scrollPosition = window.scrollY
387
+ this._scrollLocked = true
388
+ }
348
389
  document.body.style.setProperty("--maquina-scroll-lock-top", `-${this.scrollPosition}px`)
349
390
  document.body.setAttribute("data-maquina-scroll-locked", "")
350
391
  }
351
392
 
352
393
  unlockScroll() {
394
+ if (!this._scrollLocked) return
395
+
396
+ this._scrollLocked = false
353
397
  document.body.removeAttribute("data-maquina-scroll-locked")
354
398
  document.body.style.removeProperty("--maquina-scroll-lock-top")
355
- if (this.scrollPosition !== undefined) {
356
- window.scrollTo(0, this.scrollPosition)
357
- }
399
+ window.scrollTo(0, this.scrollPosition)
358
400
  }
359
401
 
360
402
  // ============================================================================
@@ -21,11 +21,24 @@ export default class extends Controller {
21
21
 
22
22
  connect() {
23
23
  this.handleStateChanged = this.handleStateChanged.bind(this)
24
+ this.preserveAttribute = this.preserveAttribute.bind(this)
24
25
  window.addEventListener("sidebar:stateChanged", this.handleStateChanged)
26
+ this.element.addEventListener("turbo:before-morph-attribute", this.preserveAttribute)
25
27
  }
26
28
 
27
29
  disconnect() {
28
30
  window.removeEventListener("sidebar:stateChanged", this.handleStateChanged)
31
+ this.element.removeEventListener("turbo:before-morph-attribute", this.preserveAttribute)
32
+ }
33
+
34
+ // The server never renders these (only the client knows the live state), so
35
+ // without this every page morph strips them from the trigger
36
+ preserveAttribute(event) {
37
+ if (event.target !== this.element) return
38
+
39
+ if (["aria-expanded", "aria-controls"].includes(event.detail.attributeName)) {
40
+ event.preventDefault()
41
+ }
29
42
  }
30
43
 
31
44
  sidebarOutletConnected(outlet) {
@@ -101,9 +101,8 @@ export default class extends Controller {
101
101
  }
102
102
 
103
103
  handleAction(event) {
104
- // If this toast was created via JS API with an action callback,
105
- // the callback is stored on the toaster controller or needs to be
106
- // dispatched as an event for the parent to handle
104
+ // Bubbles to the toaster, which runs the action.onClick given to the
105
+ // JS API, and on to any document-level `toast:action` listeners
107
106
  this.dispatch("action", {
108
107
  detail: { toastId: this.element.id },
109
108
  bubbles: true
@@ -12,6 +12,7 @@ import { Controller } from "@hotwired/stimulus"
12
12
  * Toast.info("New update available", { duration: 10000 })
13
13
  * Toast.warning("Session expiring soon")
14
14
  * Toast.show("Custom message", { variant: "default" })
15
+ * Toast.show("Event created", { action: { label: "Undo", onClick: (event) => undo() } })
15
16
  * Toast.dismiss(toastId)
16
17
  * Toast.dismissAll()
17
18
  */
@@ -23,11 +24,18 @@ export default class extends Controller {
23
24
  }
24
25
 
25
26
  connect() {
27
+ // action.onClick callbacks, keyed by toast element
28
+ this.actionCallbacks = new WeakMap()
29
+ this.runActionCallback = this.runActionCallback.bind(this)
30
+ this.element.addEventListener("toast:action", this.runActionCallback)
31
+
26
32
  // Expose global Toast API
27
33
  this.setupGlobalApi()
28
34
  }
29
35
 
30
36
  disconnect() {
37
+ this.element.removeEventListener("toast:action", this.runActionCallback)
38
+
31
39
  // Clean up global API
32
40
  if (window.Toast && window.Toast._controller === this) {
33
41
  delete window.Toast
@@ -112,6 +120,10 @@ export default class extends Controller {
112
120
  // Insert into container
113
121
  this.element.insertAdjacentHTML("beforeend", toastHtml)
114
122
 
123
+ if (typeof action?.onClick === "function") {
124
+ this.actionCallbacks.set(document.getElementById(toastId), action.onClick)
125
+ }
126
+
115
127
  // Enforce max visible
116
128
  this.enforceMaxVisible()
117
129
 
@@ -163,7 +175,7 @@ export default class extends Controller {
163
175
  data-variant="${variant}"
164
176
  data-toast-duration-value="${duration}"
165
177
  data-toast-dismissible-value="${dismissible}"
166
- data-toast-action-callback="${action ? "true" : "false"}"
178
+ data-toast-action-callback-value="${action ? "true" : "false"}"
167
179
  data-state="entering">
168
180
  ${iconHtml}
169
181
  <div data-toast-part="content">
@@ -176,6 +188,16 @@ export default class extends Controller {
176
188
  `.trim()
177
189
  }
178
190
 
191
+ /**
192
+ * Runs the action.onClick passed to the JS API when the toast's action
193
+ * button is clicked. The toast controller dispatches `toast:action`
194
+ * (bubbling) from the toast element; it still reaches document listeners.
195
+ */
196
+ runActionCallback(event) {
197
+ const callback = this.actionCallbacks.get(event.target)
198
+ if (callback) callback(event)
199
+ }
200
+
179
201
  getIconSvg(name) {
180
202
  const icons = {
181
203
  circle_check: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
@@ -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
  /**
@@ -11,6 +11,6 @@
11
11
  size: size
12
12
  ) %>
13
13
 
14
- <%= content_tag :span, class: css_classes, data: merged_data, **html_options do %>
14
+ <%= content_tag :span, class: css_classes.presence, data: merged_data, **html_options do %>
15
15
  <%= yield %>
16
16
  <% end %>
@@ -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
- <nav
10
- aria-label="Breadcrumb"
11
- class="<%= css_classes %>"
12
- <%= tag.attributes(data: merged_data, **html_options) %>
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
- </nav>
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
- # Parse selected dates
4
- selected_date = case selected
5
- when String then Date.parse(selected) rescue nil
6
- when Date, Time, DateTime then selected.to_date
7
- else nil
8
- end
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
- style_attr = cell_size ? "--cell-size: #{cell_size};" : nil
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: 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 = case selected
10
- when String then Date.parse(selected) rescue nil
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
- # Format display value
22
- display_value = if mode == :range && selected_date && selected_end_date
23
- "#{I18n.l(selected_date, format: :short)} - #{I18n.l(selected_end_date, format: :short)}"
24
- elsif mode == :range && selected_date
25
- "#{I18n.l(selected_date, format: :short)} - ..."
26
- elsif selected_date
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
- default_placeholder = mode == :range ? "Select date range" : "Select date"
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
- <div id="<%= component_id %>"
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
- </div>
91
+ <% end %>
@@ -8,7 +8,7 @@
8
8
  ) %>
9
9
  <aside
10
10
  id="<%= stable_id %>"
11
- class="group peer drawer-loading <%= css_classes %>"
11
+ class="<%= class_names("group peer drawer-loading", css_classes) %>"
12
12
  <%= tag.attributes(data: merged_data, **html_options) %>
13
13
  >
14
14
  <%# Backdrop overlay %>
@@ -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
- <span class="<%= icon_classes %>"><%= text_icon %></span>
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 <%= css_classes %>"
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 %>
@@ -3,6 +3,6 @@
3
3
  breadcrumb_part: :item
4
4
  ) %>
5
5
 
6
- <%= content_tag :li, class: css_classes, data: merged_data, **html_options do %>
6
+ <%= content_tag :li, class: css_classes.presence, data: merged_data, **html_options do %>
7
7
  <%= yield %>
8
8
  <% end %>
@@ -3,6 +3,6 @@
3
3
  breadcrumb_part: :link
4
4
  ) %>
5
5
 
6
- <%= link_to href, class: css_classes, data: merged_data, **html_options do %>
6
+ <%= link_to href, class: css_classes.presence, data: merged_data, **html_options do %>
7
7
  <%= yield %>
8
8
  <% end %>