maquina-components 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/drawer.css +8 -8
  3. data/app/assets/stylesheets/form.css +58 -4
  4. data/app/assets/stylesheets/separator.css +6 -1
  5. data/app/assets/tailwind/maquina_components_engine/tokens.css +1 -0
  6. data/app/helpers/maquina_components/calendar_helper.rb +32 -0
  7. data/app/helpers/maquina_components/drawer_helper.rb +3 -5
  8. data/app/helpers/maquina_components/dropdown_menu_helper.rb +22 -5
  9. data/app/helpers/maquina_components/empty_helper.rb +3 -3
  10. data/app/helpers/maquina_components/icons_helper.rb +9 -5
  11. data/app/helpers/maquina_components/pagination_helper.rb +27 -5
  12. data/app/javascript/controllers/calendar_controller.js +28 -25
  13. data/app/javascript/controllers/combobox_controller.js +17 -2
  14. data/app/javascript/controllers/date_picker_controller.js +57 -21
  15. data/app/javascript/controllers/drawer_controller.js +66 -44
  16. data/app/javascript/controllers/drawer_trigger_controller.js +13 -0
  17. data/app/javascript/controllers/sidebar_controller.js +84 -42
  18. data/app/javascript/controllers/sidebar_trigger_controller.js +13 -0
  19. data/app/javascript/controllers/toast_controller.js +2 -3
  20. data/app/javascript/controllers/toaster_controller.js +23 -1
  21. data/app/javascript/controllers/toggle_group_controller.js +17 -6
  22. data/app/views/components/_badge.html.erb +1 -1
  23. data/app/views/components/_breadcrumbs.html.erb +7 -7
  24. data/app/views/components/_calendar.html.erb +13 -28
  25. data/app/views/components/_combobox.html.erb +2 -0
  26. data/app/views/components/_date_picker.html.erb +19 -33
  27. data/app/views/components/_drawer.html.erb +1 -1
  28. data/app/views/components/_menu_button.html.erb +1 -1
  29. data/app/views/components/_sidebar.html.erb +1 -1
  30. data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
  31. data/app/views/components/breadcrumbs/_item.html.erb +1 -1
  32. data/app/views/components/breadcrumbs/_link.html.erb +1 -1
  33. data/app/views/components/breadcrumbs/_list.html.erb +1 -1
  34. data/app/views/components/breadcrumbs/_page.html.erb +1 -1
  35. data/app/views/components/breadcrumbs/_separator.html.erb +1 -2
  36. data/app/views/components/calendar/_week.html.erb +7 -4
  37. data/app/views/components/card/_content.html.erb +1 -1
  38. data/app/views/components/card/_footer.html.erb +3 -10
  39. data/app/views/components/card/_header.html.erb +1 -1
  40. data/app/views/components/card/_title.html.erb +1 -1
  41. data/app/views/components/combobox/_option.html.erb +2 -2
  42. data/app/views/components/drawer/_close.html.erb +11 -5
  43. data/app/views/components/drawer/_content.html.erb +1 -1
  44. data/app/views/components/drawer/_footer.html.erb +1 -1
  45. data/app/views/components/drawer/_header.html.erb +1 -1
  46. data/app/views/components/drawer/_provider.html.erb +1 -1
  47. data/app/views/components/drawer/_trigger.html.erb +4 -2
  48. data/app/views/components/pagination/_ellipsis.html.erb +5 -8
  49. data/app/views/components/pagination/_link.html.erb +1 -1
  50. data/app/views/components/pagination/_next.html.erb +6 -7
  51. data/app/views/components/pagination/_previous.html.erb +6 -7
  52. data/app/views/components/sidebar/_content.html.erb +1 -1
  53. data/app/views/components/sidebar/_footer.html.erb +1 -1
  54. data/app/views/components/sidebar/_group.html.erb +1 -1
  55. data/app/views/components/sidebar/_header.html.erb +1 -1
  56. data/app/views/components/sidebar/_inset.html.erb +1 -1
  57. data/app/views/components/sidebar/_menu.html.erb +1 -1
  58. data/app/views/components/sidebar/_menu_button.html.erb +1 -1
  59. data/app/views/components/sidebar/_menu_item.html.erb +1 -1
  60. data/app/views/components/sidebar/_menu_link.html.erb +3 -3
  61. data/app/views/components/sidebar/_provider.html.erb +1 -1
  62. data/app/views/components/sidebar/_trigger.html.erb +1 -1
  63. data/app/views/components/stats/_stats_card.html.erb +7 -6
  64. data/app/views/components/toast/_action.html.erb +2 -1
  65. data/lib/generators/maquina_components/install/templates/theme.css.tt +11 -0
  66. data/lib/maquina_components/version.rb +1 -1
  67. 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: f12b51321ecf3ce0e3c1372bab8dec0770be3fc69f66ac68384f8980994d1018
4
+ data.tar.gz: 6b24d430d5014890dc45a78cf2b13e5ef1d4b8991732b59b0002c40d8f7f598d
5
5
  SHA512:
6
- metadata.gz: 903deaa1e5cdb313f8ae2b71a7e59d1c0608926d2951fc66aba2eb787d0e94763379dcfbe2e828c21e001e344f8dff474f5961e31e8c3f06592d13d558f6f61c
7
- data.tar.gz: 9b62919d2c5bbabeba0842af06a2b3d24d576f3f307dd198e43119790b0bce36c0b0cc3108006c3f10cb5e6e02340d426baa01ca76a567f0800b805b748cd8d4
6
+ metadata.gz: c64b09b67ffc2f10d85e7870edfa538c11171e210abf0381b1ac8f7e481771e83f24327317bb2208795fdd675dd8133ae58557c7eba17913f5d6f7fcd5cd176c
7
+ data.tar.gz: 2d66b6f096c3cfc5854e4fff1daea50f4f7f2dfe9b640db6f849cda507ae449f6ccf7a37b9b0f2a2724d3a72e5f8859575072a4ce35284a3f93a82c5f13b7fea
@@ -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
@@ -174,6 +187,25 @@ module MaquinaComponents
174
187
  nil
175
188
  end
176
189
 
190
+ # The I18n date data date_picker_format reads, for the date picker
191
+ # controller to format client-side exactly as the server does. Formats a
192
+ # locale defines as a Proc are left out; the controller then falls back
193
+ # to Intl.DateTimeFormat for them.
194
+ #
195
+ # @return [Hash] strftime patterns and localized month/day names
196
+ def date_picker_locale_data
197
+ {
198
+ formats: {
199
+ long: I18n.t("date.formats.long", default: nil),
200
+ short: I18n.t("date.formats.short", default: nil)
201
+ }.select { |_, pattern| pattern.is_a?(String) },
202
+ month_names: I18n.t("date.month_names"),
203
+ abbr_month_names: I18n.t("date.abbr_month_names"),
204
+ day_names: I18n.t("date.day_names"),
205
+ abbr_day_names: I18n.t("date.abbr_day_names")
206
+ }
207
+ end
208
+
177
209
  # Format date range for display
178
210
  #
179
211
  # @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
@@ -11,7 +11,9 @@ export default class extends Controller {
11
11
  minDate: String,
12
12
  maxDate: String,
13
13
  mode: { type: String, default: "single" },
14
- weekStartsOn: { type: String, default: "sunday" }
14
+ weekStartsOn: { type: String, default: "sunday" },
15
+ disabledDates: Array,
16
+ showOutsideDays: { type: Boolean, default: true }
15
17
  }
16
18
 
17
19
  connect() {
@@ -98,30 +100,35 @@ export default class extends Controller {
98
100
  weeks.push(week)
99
101
  }
100
102
 
101
- // Parse dates for comparison
102
- const selectedDate = this.selectedValue ? new Date(this.selectedValue) : null
103
- const selectedEndDate = this.selectedEndValue ? new Date(this.selectedEndValue) : null
104
- const minDate = this.minDateValue ? new Date(this.minDateValue) : null
105
- const maxDate = this.maxDateValue ? new Date(this.maxDateValue) : null
106
-
107
103
  // Rebuild grid HTML
108
- grid.innerHTML = weeks.map(week => this.buildWeekHTML(week, month, selectedDate, selectedEndDate, minDate, maxDate)).join('')
104
+ grid.innerHTML = weeks.map(week => this.buildWeekHTML(week, month)).join('')
109
105
  }
110
106
 
111
- buildWeekHTML(days, displayMonth, selectedDate, selectedEndDate, minDate, maxDate) {
112
- // Use string comparisons for range to avoid timezone issues
107
+ // Mirrors calendar/_week.html.erb. Dates compare as ISO strings, which sort
108
+ // lexicographically and avoid the UTC shift of new Date("YYYY-MM-DD").
109
+ buildWeekHTML(days, displayMonth) {
113
110
  const selectedStr = this.selectedValue || ""
114
111
  const selectedEndStr = this.selectedEndValue || ""
112
+ const minStr = this.minDateValue || ""
113
+ const maxStr = this.maxDateValue || ""
114
+ const disabledDates = new Set(this.disabledDatesValue)
115
115
 
116
116
  const daysHTML = days.map(day => {
117
117
  const dateStr = this.formatDate(day)
118
118
  const isOutside = day.getMonth() + 1 !== displayMonth
119
+
120
+ // A hidden outside day keeps its grid column
121
+ if (isOutside && !this.showOutsideDaysValue) {
122
+ return '<span aria-hidden="true" data-calendar-part="outside-day"></span>'
123
+ }
119
124
  const isToday = this.isSameDate(day, new Date())
120
125
  const isSelected = selectedStr && dateStr === selectedStr
121
126
  const isRangeEnd = selectedEndStr && dateStr === selectedEndStr
122
127
  // ISO date strings are lexicographically sortable
123
128
  const isRangeMiddle = selectedStr && selectedEndStr && dateStr > selectedStr && dateStr < selectedEndStr
124
- const isDisabled = (minDate && day < minDate) || (maxDate && day > maxDate)
129
+ const isDisabled = (minStr && dateStr < minStr) ||
130
+ (maxStr && dateStr > maxStr) ||
131
+ disabledDates.has(dateStr)
125
132
 
126
133
  let dayState = null
127
134
  if (isSelected && this.modeValue === "range" && selectedEndStr) {
@@ -221,7 +228,8 @@ export default class extends Controller {
221
228
 
222
229
  handleKeydown(event) {
223
230
  const currentButton = event.currentTarget
224
- const currentDate = new Date(currentButton.dataset.date)
231
+ // Local midnight: a bare "YYYY-MM-DD" parses as UTC and shifts a day west of it
232
+ const currentDate = new Date(currentButton.dataset.date + "T00:00:00")
225
233
  let targetDate = null
226
234
 
227
235
  switch (event.key) {
@@ -269,8 +277,9 @@ export default class extends Controller {
269
277
  const dateStr = this.formatDate(date)
270
278
  const targetButton = this.dayTargets.find(btn => btn.dataset.date === dateStr)
271
279
 
272
- if (targetButton && !targetButton.disabled) {
273
- targetButton.focus()
280
+ if (targetButton) {
281
+ // A disabled day in the visible month is not focusable; stay put
282
+ if (!targetButton.disabled) targetButton.focus()
274
283
  } else {
275
284
  this.navigateToMonth(date.getMonth() + 1, date.getFullYear())
276
285
  requestAnimationFrame(() => {
@@ -329,15 +338,13 @@ export default class extends Controller {
329
338
 
330
339
  updateNavigationState() {
331
340
  if (this.minDateValue && this.hasPrevButtonTarget) {
332
- const minDate = new Date(this.minDateValue)
333
- const firstOfMonth = new Date(this.yearValue, this.monthValue - 1, 1)
334
- this.prevButtonTarget.disabled = firstOfMonth <= minDate
341
+ const firstOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue - 1, 1))
342
+ this.prevButtonTarget.disabled = firstOfMonth <= this.minDateValue
335
343
  }
336
344
 
337
345
  if (this.maxDateValue && this.hasNextButtonTarget) {
338
- const maxDate = new Date(this.maxDateValue)
339
- const lastOfMonth = new Date(this.yearValue, this.monthValue, 0)
340
- this.nextButtonTarget.disabled = lastOfMonth >= maxDate
346
+ const lastOfMonth = this.formatDate(new Date(this.yearValue, this.monthValue, 0))
347
+ this.nextButtonTarget.disabled = lastOfMonth >= this.maxDateValue
341
348
  }
342
349
  }
343
350
 
@@ -348,12 +355,8 @@ export default class extends Controller {
348
355
  selectedEnd: this.selectedEndValue || null
349
356
  }
350
357
 
358
+ // Stimulus dispatches a bubbling "calendar:change" — once
351
359
  this.dispatch("change", { detail })
352
-
353
- this.element.dispatchEvent(new CustomEvent("calendar:change", {
354
- bubbles: true,
355
- detail
356
- }))
357
360
  }
358
361
 
359
362
  formatDate(date) {
@@ -9,9 +9,10 @@ import { Controller } from "@hotwired/stimulus"
9
9
  * - Single selection with toggle
10
10
  * - Keyboard navigation
11
11
  * - Focus management
12
+ * - Form submission through a hidden input (rendered when `name:` is given)
12
13
  */
13
14
  export default class extends Controller {
14
- static targets = ["trigger", "content", "input", "option", "empty", "label"]
15
+ static targets = ["trigger", "content", "input", "option", "empty", "label", "hiddenInput"]
15
16
 
16
17
  static values = {
17
18
  value: { type: String, default: "" },
@@ -171,6 +172,9 @@ export default class extends Controller {
171
172
 
172
173
  this.updateSelection()
173
174
  this.updateTriggerState()
175
+ // valueValueChanged runs later (attribute observer); write the input now so
176
+ // it is current when combobox:change fires
177
+ this.updateHiddenInput()
174
178
 
175
179
  // Close popover
176
180
  if (this.hasContentTarget) {
@@ -326,9 +330,20 @@ export default class extends Controller {
326
330
  }
327
331
  }
328
332
 
329
- // Value change callback
333
+ // Value change callback: also covers a programmatic
334
+ // data-combobox-value-value write
330
335
  valueValueChanged() {
331
336
  this.updateSelection()
332
337
  this.updateTriggerState()
338
+ this.updateHiddenInput()
339
+ }
340
+
341
+ updateHiddenInput() {
342
+ if (!this.hasHiddenInputTarget) return
343
+ if (this.hiddenInputTarget.value === this.valueValue) return
344
+
345
+ this.hiddenInputTarget.value = this.valueValue
346
+ // Let form listeners (autosave, validation) see the new value
347
+ this.hiddenInputTarget.dispatchEvent(new Event("change", { bubbles: true }))
333
348
  }
334
349
  }