maquina-components 0.7.3 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/drawer.css +8 -8
- data/app/assets/stylesheets/form.css +58 -4
- data/app/assets/stylesheets/separator.css +6 -1
- data/app/assets/tailwind/maquina_components_engine/tokens.css +1 -0
- data/app/helpers/maquina_components/calendar_helper.rb +32 -0
- data/app/helpers/maquina_components/drawer_helper.rb +3 -5
- data/app/helpers/maquina_components/dropdown_menu_helper.rb +22 -5
- data/app/helpers/maquina_components/empty_helper.rb +3 -3
- data/app/helpers/maquina_components/icons_helper.rb +9 -5
- data/app/helpers/maquina_components/pagination_helper.rb +27 -5
- data/app/javascript/controllers/calendar_controller.js +28 -25
- data/app/javascript/controllers/combobox_controller.js +17 -2
- data/app/javascript/controllers/date_picker_controller.js +57 -21
- data/app/javascript/controllers/drawer_controller.js +66 -44
- data/app/javascript/controllers/drawer_trigger_controller.js +13 -0
- data/app/javascript/controllers/sidebar_controller.js +84 -42
- data/app/javascript/controllers/sidebar_trigger_controller.js +13 -0
- data/app/javascript/controllers/toast_controller.js +2 -3
- data/app/javascript/controllers/toaster_controller.js +23 -1
- data/app/javascript/controllers/toggle_group_controller.js +17 -6
- data/app/views/components/_badge.html.erb +1 -1
- data/app/views/components/_breadcrumbs.html.erb +7 -7
- data/app/views/components/_calendar.html.erb +13 -28
- data/app/views/components/_combobox.html.erb +2 -0
- data/app/views/components/_date_picker.html.erb +19 -33
- data/app/views/components/_drawer.html.erb +1 -1
- data/app/views/components/_menu_button.html.erb +1 -1
- data/app/views/components/_sidebar.html.erb +1 -1
- data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
- data/app/views/components/breadcrumbs/_item.html.erb +1 -1
- data/app/views/components/breadcrumbs/_link.html.erb +1 -1
- data/app/views/components/breadcrumbs/_list.html.erb +1 -1
- data/app/views/components/breadcrumbs/_page.html.erb +1 -1
- data/app/views/components/breadcrumbs/_separator.html.erb +1 -2
- data/app/views/components/calendar/_week.html.erb +7 -4
- data/app/views/components/card/_content.html.erb +1 -1
- data/app/views/components/card/_footer.html.erb +3 -10
- data/app/views/components/card/_header.html.erb +1 -1
- data/app/views/components/card/_title.html.erb +1 -1
- data/app/views/components/combobox/_option.html.erb +2 -2
- data/app/views/components/drawer/_close.html.erb +11 -5
- data/app/views/components/drawer/_content.html.erb +1 -1
- data/app/views/components/drawer/_footer.html.erb +1 -1
- data/app/views/components/drawer/_header.html.erb +1 -1
- data/app/views/components/drawer/_provider.html.erb +1 -1
- data/app/views/components/drawer/_trigger.html.erb +4 -2
- data/app/views/components/pagination/_ellipsis.html.erb +5 -8
- data/app/views/components/pagination/_link.html.erb +1 -1
- data/app/views/components/pagination/_next.html.erb +6 -7
- data/app/views/components/pagination/_previous.html.erb +6 -7
- data/app/views/components/sidebar/_content.html.erb +1 -1
- data/app/views/components/sidebar/_footer.html.erb +1 -1
- data/app/views/components/sidebar/_group.html.erb +1 -1
- data/app/views/components/sidebar/_header.html.erb +1 -1
- data/app/views/components/sidebar/_inset.html.erb +1 -1
- data/app/views/components/sidebar/_menu.html.erb +1 -1
- data/app/views/components/sidebar/_menu_button.html.erb +1 -1
- data/app/views/components/sidebar/_menu_item.html.erb +1 -1
- data/app/views/components/sidebar/_menu_link.html.erb +3 -3
- data/app/views/components/sidebar/_provider.html.erb +1 -1
- data/app/views/components/sidebar/_trigger.html.erb +1 -1
- data/app/views/components/stats/_stats_card.html.erb +7 -6
- data/app/views/components/toast/_action.html.erb +2 -1
- data/lib/generators/maquina_components/install/templates/theme.css.tt +11 -0
- data/lib/maquina_components/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f12b51321ecf3ce0e3c1372bab8dec0770be3fc69f66ac68384f8980994d1018
|
|
4
|
+
data.tar.gz: 6b24d430d5014890dc45a78cf2b13e5ef1d4b8991732b59b0002c40d8f7f598d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c64b09b67ffc2f10d85e7870edfa538c11171e210abf0381b1ac8f7e481771e83f24327317bb2208795fdd675dd8133ae58557c7eba17913f5d6f7fcd5cd176c
|
|
7
|
+
data.tar.gz: 2d66b6f096c3cfc5854e4fff1daea50f4f7f2dfe9b640db6f849cda507ae449f6ccf7a37b9b0f2a2724d3a72e5f8859575072a4ce35284a3f93a82c5f13b7fea
|
|
@@ -96,10 +96,8 @@
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/* ===== Drawer Trigger ===== */
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
case dropdown_menu_simple_entry_type(item)
|
|
75
|
+
when :separator
|
|
68
76
|
content.separator
|
|
69
|
-
|
|
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:
|
|
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: :
|
|
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: :
|
|
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: :
|
|
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
|
-
|
|
66
|
-
|
|
65
|
+
options ||= {}
|
|
67
66
|
css_classes = options[:class]
|
|
68
|
-
|
|
67
|
+
|
|
68
|
+
svg = if css_classes.is_a?(String) && css_classes.present?
|
|
69
69
|
escaped_css_classes = ERB::Util.html_escape(css_classes)
|
|
70
|
-
|
|
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
|
|
80
|
-
query_params = request.query_parameters.except(page_param
|
|
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
|
|
112
|
+
if (previous_page = pagy_previous(pagy))
|
|
91
113
|
render "components/pagination/previous",
|
|
92
|
-
href: paginated_path(route_helper, pagy,
|
|
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.
|
|
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
|
|
104
|
+
grid.innerHTML = weeks.map(week => this.buildWeekHTML(week, month)).join('')
|
|
109
105
|
}
|
|
110
106
|
|
|
111
|
-
|
|
112
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
273
|
-
|
|
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
|
|
333
|
-
|
|
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
|
|
339
|
-
|
|
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
|
}
|