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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a908d12ac4b955850c1c9cf41cab32bbc12d4687d68fad543b43c74fc4f678d6
4
- data.tar.gz: 83fa5e89747cd635883e93e6e47a034b64f59633f3b2207a9e9ae7e3aa5aad35
3
+ metadata.gz: 95885780d4ac7c947e4561e821d4c3cb2f985a3a363738f7cdc7fc01a858fc11
4
+ data.tar.gz: 35100ad72833ef5be786a40444f5af15c37866d0dc8dce4dee34232f04bfe0a5
5
5
  SHA512:
6
- metadata.gz: 903deaa1e5cdb313f8ae2b71a7e59d1c0608926d2951fc66aba2eb787d0e94763379dcfbe2e828c21e001e344f8dff474f5961e31e8c3f06592d13d558f6f61c
7
- data.tar.gz: 9b62919d2c5bbabeba0842af06a2b3d24d576f3f307dd198e43119790b0bce36c0b0cc3108006c3f10cb5e6e02340d426baa01ca76a567f0800b805b748cd8d4
6
+ metadata.gz: b1e7d220b352af1cd2e9fabf11902165c2d7d958608ea504f0b6a896fef336c3c8cd1e556090ab2db7dfa54c8812249fc5611c1a19820701c37c015c15b82a10
7
+ data.tar.gz: 347641b6a36dac6f72f9344e8e6af0a81f0c80bf29dd1ce022e20db9a295db2141d540eb16d41cea92fc19f4a59345438712f9b958c802b3cc1f109bd2d5abbb
@@ -7,15 +7,24 @@
7
7
  * Structure:
8
8
  * - calendar (root container)
9
9
  * - header (navigation)
10
+ * - caption, or with caption_layout: :dropdown
11
+ * dropdowns > month-select, year-select (native [data-component="select"])
10
12
  * - weekdays (day name headers)
11
13
  * - grid (day buttons grid)
12
14
  * - week (row)
13
15
  * - day (button)
16
+ * - footer (optional, the partial's block)
17
+ *
18
+ * Inside a popover the controller sets data-side="top|bottom" on the root.
19
+ * Flipped above its trigger, a popover is pinned at its bottom edge while
20
+ * months change height (4-6 weeks), so [data-side="top"] moves the header
21
+ * last — next to the trigger, where the arrows stay put.
14
22
  *
15
23
  * Cascade order in this file (full contract documented in form.css):
16
24
  * 1. base [data-component="calendar"], [data-calendar-part="…"]
17
25
  * 2. sizes — none (cell metrics come from --cell-size)
18
- * 3. variants [data-calendar-part="day"]:where([data-today], [data-state="…"], [data-outside])
26
+ * 3. variants [data-calendar-part="day"]:where([data-today], [data-state="…"], [data-outside]),
27
+ * [data-component="calendar"]:where([data-side="top"])
19
28
  * 4. states […]:where(:hover, :focus-visible, :disabled)
20
29
  * 5. dark — none; dark-mode differences are tokens (see tokens.css)
21
30
  * Every rule is flattened to 0,1,0 with :where() and lives in
@@ -94,6 +103,26 @@
94
103
  color: var(--foreground);
95
104
  }
96
105
 
106
+ /* ===== Caption dropdowns =====
107
+ Month and year are native selects styled by form.css (colors, radius,
108
+ focus, chevron, dark mode). Here they only shrink to fit beside the
109
+ arrows in a 7-cell header: calendar.css loads after form.css, so these
110
+ 0,1,0 rules win on source order. */
111
+ [data-calendar-part="dropdowns"] {
112
+ display: flex;
113
+ align-items: center;
114
+ @apply gap-1;
115
+ }
116
+
117
+ :where([data-calendar-part="dropdowns"]) [data-component="select"] {
118
+ width: auto;
119
+ height: var(--cell-size);
120
+ @apply pl-2 pr-6 py-0 text-sm;
121
+ font-weight: var(--label-weight, 500);
122
+ background-position: right 0.25rem center;
123
+ background-size: 1rem 1rem;
124
+ }
125
+
97
126
  /* ===== Weekday Headers ===== */
98
127
  [data-calendar-part="weekdays"] {
99
128
  display: grid;
@@ -123,6 +152,11 @@
123
152
  grid-template-columns: repeat(7, var(--cell-size));
124
153
  }
125
154
 
155
+ /* ===== Footer ===== */
156
+ [data-calendar-part="footer"] {
157
+ @apply mt-4;
158
+ }
159
+
126
160
  /* ===== Day Button Base ===== */
127
161
  [data-calendar-part="day"] {
128
162
  display: inline-flex;
@@ -205,6 +239,19 @@
205
239
  @apply opacity-30;
206
240
  }
207
241
 
242
+ /* ===== Popover side ===== */
243
+
244
+ /* Opened above the trigger: weekdays, grid, footer, then the header */
245
+ [data-component="calendar"]:where([data-side="top"]) {
246
+ display: flex;
247
+ flex-direction: column;
248
+ }
249
+
250
+ :where([data-component="calendar"][data-side="top"]) [data-calendar-part="header"] {
251
+ order: 1;
252
+ @apply mt-4 mb-0;
253
+ }
254
+
208
255
  /* ===== Day States ===== */
209
256
 
210
257
  /* Hover — only for cells with no selection state of their own */
@@ -14,7 +14,8 @@
14
14
  * 2. sizes :where([data-size="sm"|"lg"]) [data-date-picker-part="trigger"]
15
15
  * 3. variants :where([data-full-width]) …
16
16
  * 4. states […]:where(:hover, :focus-visible, :disabled, [aria-expanded],
17
- * :has(input:invalid))
17
+ * :has(input:invalid)); popover open/closed, and
18
+ * :has(calendar[data-side="top"]) for the animation
18
19
  * 5. dark — none; dark-mode differences are tokens (see tokens.css)
19
20
  * Every rule is flattened to 0,1,0 with :where() and lives in
20
21
  * @layer components, so a caller's utility class wins and source order is
@@ -160,6 +161,17 @@
160
161
  }
161
162
  }
162
163
 
164
+ /* Opened above the trigger (the calendar's data-side): grow upward out of it */
165
+ [data-date-picker-part="popover"]:where(:not(:popover-open):has([data-component="calendar"][data-side="top"])) {
166
+ transform: translateY(0.5rem) scale(0.95);
167
+ }
168
+
169
+ @starting-style {
170
+ [data-date-picker-part="popover"]:where(:popover-open:has([data-component="calendar"][data-side="top"])) {
171
+ transform: translateY(0.5rem) scale(0.95);
172
+ }
173
+ }
174
+
163
175
  /* Backdrop */
164
176
  [data-date-picker-part="popover"]::backdrop {
165
177
  background-color: transparent;
@@ -96,10 +96,8 @@
96
96
  }
97
97
 
98
98
  /* ===== Drawer Trigger ===== */
99
- [data-drawer-part="trigger"]:where(:hover) {
100
- background-color: var(--accent-hover, var(--accent));
101
- color: var(--accent-foreground);
102
- }
99
+ /* The trigger is a data-component="button"; its variant: and size: locals
100
+ style it through form.css, hover included — no drawer-specific rule. */
103
101
 
104
102
  /* ===== Drawer Title ===== */
105
103
  [data-drawer-part="title"] {
@@ -112,24 +110,26 @@
112
110
  }
113
111
 
114
112
  /* ===== Drawer Close Button ===== */
115
- [data-drawer-part="close"] {
113
+ /* An icon button by default. Given data-component="button" (a footer
114
+ "Cancel"), the button styles in form.css own it instead. */
115
+ [data-drawer-part="close"]:where(:not([data-component="button"])) {
116
116
  @apply inline-flex items-center justify-center p-1;
117
117
  @apply transition-[color,background-color,border-color,text-decoration-color];
118
118
  border-radius: var(--drawer-close-radius, var(--control-radius, 0.375rem));
119
119
  color: var(--foreground);
120
120
  }
121
121
 
122
- [data-drawer-part="close"] svg {
122
+ [data-drawer-part="close"]:where(:not([data-component="button"])) svg {
123
123
  @apply size-4;
124
124
  }
125
125
 
126
- [data-drawer-part="close"]:where(:hover) {
126
+ [data-drawer-part="close"]:where(:not([data-component="button"]):hover) {
127
127
  background-color: var(--accent-hover, var(--accent));
128
128
  color: var(--accent-foreground);
129
129
  }
130
130
 
131
131
  /* outline, not a ring: the drawer panel clips box-shadow rings */
132
- [data-drawer-part="close"]:where(:focus-visible) {
132
+ [data-drawer-part="close"]:where(:not([data-component="button"]):focus-visible) {
133
133
  outline-width: var(--focus-ring-width, 3px);
134
134
  outline-style: var(--focus-ring-style, solid);
135
135
  outline-color: var(--focus-ring-color, var(--ring));
@@ -283,11 +283,21 @@
283
283
  @apply pointer-events-none cursor-not-allowed opacity-50;
284
284
  }
285
285
 
286
- [data-component="select"]:where([aria-invalid="true"]) {
286
+ [data-component="select"]:where([aria-invalid="true"], :user-invalid) {
287
287
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
288
288
  border-color: var(--destructive-border, var(--destructive));
289
289
  }
290
290
 
291
+ /* The destructive halo is a focus affordance, not a resting one: painting an
292
+ outline on an unfocused field doubles the border and reads as a second
293
+ error. Gated to match the :focus-visible rule above. */
294
+ [data-component="select"]:where([aria-invalid="true"], :user-invalid):where(:focus-visible) {
295
+ outline-width: var(--focus-ring-width, 3px);
296
+ outline-style: var(--focus-ring-style, solid);
297
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
298
+ outline-offset: var(--focus-ring-offset, 0px);
299
+ }
300
+
291
301
  /* ===================================================================
292
302
  CHECKBOX
293
303
  Usage: <%= f.check_box :terms, data: { component: "checkbox" } %>
@@ -365,9 +375,15 @@
365
375
  @apply cursor-not-allowed opacity-50;
366
376
  }
367
377
 
368
- [data-component="checkbox"]:where([aria-invalid="true"]) {
378
+ [data-component="checkbox"]:where([aria-invalid="true"], :user-invalid) {
369
379
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
370
380
  border-color: var(--destructive-border, var(--destructive));
381
+ }
382
+
383
+ /* The destructive halo is a focus affordance, not a resting one: painting an
384
+ outline on an unfocused field doubles the border and reads as a second
385
+ error. Gated to match the :focus-visible rule above. */
386
+ [data-component="checkbox"]:where([aria-invalid="true"], :user-invalid):where(:focus-visible) {
371
387
  outline-width: var(--focus-ring-width, 3px);
372
388
  outline-style: var(--focus-ring-style, solid);
373
389
  outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
@@ -421,6 +437,21 @@
421
437
  @apply cursor-not-allowed opacity-50;
422
438
  }
423
439
 
440
+ [data-component="radio"]:where([aria-invalid="true"], :user-invalid) {
441
+ --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
442
+ border-color: var(--destructive-border, var(--destructive));
443
+ }
444
+
445
+ /* The destructive halo is a focus affordance, not a resting one: painting an
446
+ outline on an unfocused field doubles the border and reads as a second
447
+ error. Gated to match the :focus-visible rule above. */
448
+ [data-component="radio"]:where([aria-invalid="true"], :user-invalid):where(:focus-visible) {
449
+ outline-width: var(--focus-ring-width, 3px);
450
+ outline-style: var(--focus-ring-style, solid);
451
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
452
+ outline-offset: var(--focus-ring-offset, 0px);
453
+ }
454
+
424
455
  /* ===================================================================
425
456
  SWITCH (Toggle)
426
457
  Usage: <%= f.check_box :notifications, data: { component: "switch" } %>
@@ -465,6 +496,23 @@
465
496
  @apply cursor-not-allowed opacity-50;
466
497
  }
467
498
 
499
+ /* The track's border is transparent at rest, so an invalid switch gains a
500
+ destructive outline of the track rather than a recolored one */
501
+ [data-component="switch"]:where([aria-invalid="true"], :user-invalid) {
502
+ --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
503
+ border-color: var(--destructive-border, var(--destructive));
504
+ }
505
+
506
+ /* The destructive halo is a focus affordance, not a resting one: painting an
507
+ outline on an unfocused field doubles the border and reads as a second
508
+ error. Gated to match the :focus-visible rule above. */
509
+ [data-component="switch"]:where([aria-invalid="true"], :user-invalid):where(:focus-visible) {
510
+ outline-width: var(--focus-ring-width, 3px);
511
+ outline-style: var(--focus-ring-style, solid);
512
+ outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
513
+ outline-offset: var(--focus-ring-offset, 0px);
514
+ }
515
+
468
516
  /* ===================================================================
469
517
  BUTTON
470
518
  Usage:
@@ -679,14 +727,20 @@
679
727
 
680
728
  :where(.field_with_errors) [data-component="input"],
681
729
  :where(.field_with_errors) [data-component="textarea"],
682
- :where(.field_with_errors) [data-component="select"] {
730
+ :where(.field_with_errors) [data-component="select"],
731
+ :where(.field_with_errors) [data-component="checkbox"],
732
+ :where(.field_with_errors) [data-component="radio"],
733
+ :where(.field_with_errors) [data-component="switch"] {
683
734
  --focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
684
735
  border-color: var(--destructive-border, var(--destructive));
685
736
  }
686
737
 
687
738
  :where(.field_with_errors) [data-component="input"]:where(:focus-visible),
688
739
  :where(.field_with_errors) [data-component="textarea"]:where(:focus-visible),
689
- :where(.field_with_errors) [data-component="select"]:where(:focus-visible) {
740
+ :where(.field_with_errors) [data-component="select"]:where(:focus-visible),
741
+ :where(.field_with_errors) [data-component="checkbox"]:where(:focus-visible),
742
+ :where(.field_with_errors) [data-component="radio"]:where(:focus-visible),
743
+ :where(.field_with_errors) [data-component="switch"]:where(:focus-visible) {
690
744
  outline-width: var(--focus-ring-width, 3px);
691
745
  outline-style: var(--focus-ring-style, solid);
692
746
  outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
@@ -16,16 +16,21 @@
16
16
 
17
17
  @layer components {
18
18
  [data-component="separator"] {
19
- @apply mx-2 shrink-0;
19
+ @apply shrink-0;
20
20
  background-color: var(--border);
21
21
  }
22
22
 
23
+ /* Full width and no horizontal margin: a margin on top of width: 100%
24
+ overflows the container. Parents that want an inset (sidebar, drawer)
25
+ set their own margin with width: auto. */
23
26
  [data-component="separator"]:where([data-orientation="horizontal"]) {
24
27
  height: 1px;
25
28
  width: 100%;
26
29
  }
27
30
 
31
+ /* The inline gap between the items a vertical separator divides. */
28
32
  [data-component="separator"]:where([data-orientation="vertical"]) {
33
+ @apply mx-2;
29
34
  width: 1px;
30
35
  height: 1rem;
31
36
  }
@@ -54,6 +54,7 @@
54
54
  --elevation-control: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));
55
55
  --elevation-raised: var(--shadow-sm, 0 1px 2px 0 rgb(0 0 0 / 0.05));
56
56
  --elevation-overlay: var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1));
57
+ --elevation-none: none; /* ghost/link buttons, inset sidebar */
57
58
 
58
59
  /* Weight */
59
60
  --label-weight: 500;
@@ -12,6 +12,19 @@ module MaquinaComponents
12
12
  # calendar_date_in_range?(date, start_date, end_date)
13
13
  #
14
14
  module CalendarHelper
15
+ # Coerce a Date, Time, DateTime or ISO string to a Date
16
+ #
17
+ # @param value [Date, Time, DateTime, String, nil]
18
+ # @return [Date, nil] nil for blank or unparseable input
19
+ def calendar_to_date(value)
20
+ case value
21
+ when Date, Time, DateTime then value.to_date
22
+ when String then Date.parse(value)
23
+ end
24
+ rescue ArgumentError
25
+ nil
26
+ end
27
+
15
28
  # Generate calendar month data
16
29
  #
17
30
  # @param date [Date] Any date within the target month
@@ -59,6 +72,43 @@ module MaquinaComponents
59
72
  end
60
73
  end
61
74
 
75
+ # Years offered by the caption's year select (caption_layout: :dropdown)
76
+ #
77
+ # min_date/max_date set the bounds when present; otherwise years applies,
78
+ # 100 years back to 10 ahead by default. Given both, the narrower bound
79
+ # wins. The displayed year is always included.
80
+ #
81
+ # @param display_year [Integer] Year the calendar shows
82
+ # @param min_date [Date, nil] Minimum selectable date
83
+ # @param max_date [Date, nil] Maximum selectable date
84
+ # @param years [Range, nil] Explicit span of years
85
+ # @return [Range]
86
+ def calendar_year_range(display_year, min_date: nil, max_date: nil, years: nil)
87
+ first, last = (years || ((Date.current.year - 100)..(Date.current.year + 10))).minmax
88
+ first = min_date.year if min_date && (years.nil? || min_date.year > first)
89
+ last = max_date.year if max_date && (years.nil? || max_date.year < last)
90
+
91
+ [first, display_year].min..[last, display_year].max
92
+ end
93
+
94
+ # Options for the caption's month select: abbreviated localized names
95
+ # (the full ones do not fit beside the year in a 7-cell header), with the
96
+ # months wholly outside min_date/max_date disabled for that year
97
+ #
98
+ # @param year [Integer] Year the months belong to
99
+ # @param min_date [Date, nil] Minimum selectable date
100
+ # @param max_date [Date, nil] Maximum selectable date
101
+ # @return [Array<Array(String, Integer, Boolean)>] [name, month, disabled]
102
+ def calendar_month_options(year, min_date: nil, max_date: nil)
103
+ I18n.t("date.abbr_month_names").drop(1).each_with_index.map do |name, index|
104
+ first_of_month = Date.new(year, index + 1, 1)
105
+ disabled = (min_date && first_of_month.end_of_month < min_date) ||
106
+ (max_date && first_of_month > max_date)
107
+
108
+ [name, index + 1, !!disabled]
109
+ end
110
+ end
111
+
62
112
  # Check if a date falls within a range
63
113
  #
64
114
  # @param date [Date] The date to check
@@ -174,6 +224,25 @@ module MaquinaComponents
174
224
  nil
175
225
  end
176
226
 
227
+ # The I18n date data date_picker_format reads, for the date picker
228
+ # controller to format client-side exactly as the server does. Formats a
229
+ # locale defines as a Proc are left out; the controller then falls back
230
+ # to Intl.DateTimeFormat for them.
231
+ #
232
+ # @return [Hash] strftime patterns and localized month/day names
233
+ def date_picker_locale_data
234
+ {
235
+ formats: {
236
+ long: I18n.t("date.formats.long", default: nil),
237
+ short: I18n.t("date.formats.short", default: nil)
238
+ }.select { |_, pattern| pattern.is_a?(String) },
239
+ month_names: I18n.t("date.month_names"),
240
+ abbr_month_names: I18n.t("date.abbr_month_names"),
241
+ day_names: I18n.t("date.day_names"),
242
+ abbr_day_names: I18n.t("date.abbr_day_names")
243
+ }
244
+ end
245
+
177
246
  # Format date range for display
178
247
  #
179
248
  # @param start_date [Date, String, nil] Start date
@@ -1,11 +1,9 @@
1
1
  module MaquinaComponents
2
2
  module DrawerHelper
3
+ # The drawer's persisted state. A missing cookie means :closed, matching the
4
+ # provider's default_open: false and the controller's default.
3
5
  def drawer_state(cookie_name = "drawer_state")
4
- cookie_value = cookies[cookie_name]
5
-
6
- return :open if cookie_value.nil?
7
-
8
- (cookie_value == "true") ? :open : :closed
6
+ (cookies[cookie_name] == "true") ? :open : :closed
9
7
  end
10
8
 
11
9
  def drawer_open?(cookie_name = "drawer_state")
@@ -53,6 +53,13 @@ module MaquinaComponents
53
53
 
54
54
  # Renders a simple dropdown menu from data
55
55
  #
56
+ # Each item hash renders as one of:
57
+ # - a separator: `separator: true` or `type: :separator`
58
+ # - a label (heading): `type: :label`, or — for backwards compatibility — a
59
+ # hash with neither `href` nor `action` and no `type`
60
+ # - an item: `type: :item`, or any hash with `href` or `action`. Without an
61
+ # `href` it renders as a `<button>`; `action` is its Stimulus data-action
62
+ #
56
63
  # @param trigger_text [String] Text for the trigger button
57
64
  # @param items [Array<Hash>] Array of item configurations
58
65
  # @param trigger_options [Hash] Options for the trigger button
@@ -64,19 +71,20 @@ module MaquinaComponents
64
71
 
65
72
  menu.content(**content_options) do |content|
66
73
  items.each do |item|
67
- if item[:separator]
74
+ case dropdown_menu_simple_entry_type(item)
75
+ when :separator
68
76
  content.separator
69
- elsif item[:label] && item[:href].nil? && item[:action].nil?
77
+ when :label
70
78
  content.label item[:label]
71
79
  else
72
- variant = item[:destructive] ? :destructive : :default
73
80
  content.item(
74
81
  item[:label],
75
82
  href: item[:href],
76
83
  method: item[:method],
77
84
  icon: item[:icon],
78
- variant: variant,
79
- disabled: item[:disabled]
85
+ variant: item[:destructive] ? :destructive : :default,
86
+ disabled: item[:disabled],
87
+ data: {action: item[:action], **item.fetch(:data, {})}.compact
80
88
  )
81
89
  end
82
90
  end
@@ -84,6 +92,15 @@ module MaquinaComponents
84
92
  end
85
93
  end
86
94
 
95
+ private
96
+
97
+ def dropdown_menu_simple_entry_type(item)
98
+ return :separator if item[:separator]
99
+ return item[:type].to_sym if item[:type]
100
+
101
+ (item[:href].nil? && item[:action].nil?) ? :label : :item
102
+ end
103
+
87
104
  # Builder class for constructing dropdown menus
88
105
  class DropdownMenuBuilder
89
106
  def initialize(view_context)
@@ -6,10 +6,10 @@ module MaquinaComponents
6
6
  # Provides convenient methods for creating empty state components.
7
7
  #
8
8
  # @example Simple empty state
9
- # <%= empty_state title: "No projects", description: "Get started by creating one.", icon: :folder_open %>
9
+ # <%= empty_state title: "No projects", description: "Get started by creating one.", icon: :folder %>
10
10
  #
11
11
  # @example With action button
12
- # <%= empty_state title: "No projects", icon: :folder_open do %>
12
+ # <%= empty_state title: "No projects", icon: :folder do %>
13
13
  # <%= link_to "Create Project", new_project_path, data: { component: "button", variant: "primary" } %>
14
14
  # <% end %>
15
15
  #
@@ -86,7 +86,7 @@ module MaquinaComponents
86
86
  # @param icon [Symbol] Icon to display
87
87
  # @param size [Symbol] Size variant
88
88
  # @return [String] Rendered HTML
89
- def empty_list_state(resource_name:, new_path: nil, icon: :folder_open, size: :default)
89
+ def empty_list_state(resource_name:, new_path: nil, icon: :folder, size: :default)
90
90
  empty_state(
91
91
  title: "No #{resource_name} yet",
92
92
  description: "Get started by creating your first #{resource_name.singularize}.",
@@ -62,16 +62,20 @@ module MaquinaComponents
62
62
  end
63
63
 
64
64
  def apply_icon_options(svg, options)
65
- return svg.html_safe unless options&.any?
66
-
65
+ options ||= {}
67
66
  css_classes = options[:class]
68
- if css_classes&.is_a?(String)
67
+
68
+ svg = if css_classes.is_a?(String) && css_classes.present?
69
69
  escaped_css_classes = ERB::Util.html_escape(css_classes)
70
- svg = if /\bclass\s*=\s*(['"])(.*?)\1/.match?(svg)
70
+ if /\bclass\s*=\s*(['"])(.*?)\1/.match?(svg)
71
71
  svg.sub(/\bclass\s*=\s*(['"])(.*?)\1/, "class=\\1#{escaped_css_classes}\\1")
72
72
  else
73
73
  svg.sub(/<svg(?=(\s|>))/, "<svg class=\"#{escaped_css_classes}\"")
74
74
  end
75
+ else
76
+ # The SVG sources carry a class="" placeholder; with no classes to
77
+ # fill it, drop it rather than render an empty attribute.
78
+ svg.sub(/\s+class\s*=\s*(['"])\1/, "")
75
79
  end
76
80
 
77
81
  if options[:stroke_width]
@@ -221,7 +225,7 @@ module MaquinaComponents
221
225
  <path d="m15 18-6-6 6-6"/>
222
226
  </svg>
223
227
  SVG
224
- when :left_panel
228
+ when :left_panel, :panel_left # :panel_left is lucide's name (PanelLeft)
225
229
  <<~SVG.freeze
226
230
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
227
231
  <rect width="18" height="18" x="3" y="3" rx="2"></rect>
@@ -76,8 +76,8 @@ module MaquinaComponents
76
76
  # @param extra_params [Hash] Additional params
77
77
  # @return [String] URL path
78
78
  def paginated_path(route_helper, pagy, page, extra_params = {})
79
- page_param = pagy.vars[:page_param] || Pagy::DEFAULT[:page_param]
80
- query_params = request.query_parameters.except(page_param.to_s).merge(extra_params)
79
+ page_param = pagy_page_param(pagy)
80
+ query_params = request.query_parameters.except(page_param).merge(extra_params)
81
81
  query_params[page_param] = page
82
82
 
83
83
  send(route_helper, query_params)
@@ -85,11 +85,33 @@ module MaquinaComponents
85
85
 
86
86
  private
87
87
 
88
+ # Pagy 43 renamed the API this helper reads; these keep both working.
89
+ #
90
+ # Pagy <= 9: pagy.vars[:page_param] (Symbol), Pagy::DEFAULT, pagy.prev,
91
+ # public pagy.series
92
+ # Pagy >= 43: pagy.options[:page_key] (String), pagy.previous, protected
93
+ # pagy.series
94
+ def pagy_page_param(pagy)
95
+ options = pagy.respond_to?(:options) ? pagy.options : pagy.vars
96
+ key = options[:page_key] || options[:page_param]
97
+ key ||= Pagy::DEFAULT[:page_key] || Pagy::DEFAULT[:page_param] if defined?(Pagy::DEFAULT)
98
+
99
+ (key || :page).to_s
100
+ end
101
+
102
+ def pagy_previous(pagy)
103
+ pagy.respond_to?(:previous) ? pagy.previous : pagy.prev
104
+ end
105
+
106
+ def pagy_series(pagy)
107
+ pagy.send(:series)
108
+ end
109
+
88
110
  def pagination_previous_item(pagy, route_helper, params, turbo, show_label)
89
111
  render "components/pagination/item" do
90
- if pagy.prev
112
+ if (previous_page = pagy_previous(pagy))
91
113
  render "components/pagination/previous",
92
- href: paginated_path(route_helper, pagy, pagy.prev, params),
114
+ href: paginated_path(route_helper, pagy, previous_page, params),
93
115
  show_label: show_label,
94
116
  data: turbo_data(turbo)
95
117
  else
@@ -116,7 +138,7 @@ module MaquinaComponents
116
138
  end
117
139
 
118
140
  def pagination_page_items(pagy, route_helper, params, turbo)
119
- pagy.series.map do |item|
141
+ pagy_series(pagy).map do |item|
120
142
  render "components/pagination/item" do
121
143
  case item
122
144
  when Integer