maquina-components 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/calendar.css +48 -1
- data/app/assets/stylesheets/date_picker.css +13 -1
- data/app/assets/stylesheets/drawer.css +8 -8
- data/app/assets/stylesheets/form.css +58 -4
- data/app/assets/stylesheets/separator.css +6 -1
- data/app/assets/tailwind/maquina_components_engine/tokens.css +1 -0
- data/app/helpers/maquina_components/calendar_helper.rb +69 -0
- data/app/helpers/maquina_components/drawer_helper.rb +3 -5
- data/app/helpers/maquina_components/dropdown_menu_helper.rb +22 -5
- data/app/helpers/maquina_components/empty_helper.rb +3 -3
- data/app/helpers/maquina_components/icons_helper.rb +9 -5
- data/app/helpers/maquina_components/pagination_helper.rb +27 -5
- data/app/javascript/controllers/calendar_controller.js +114 -28
- data/app/javascript/controllers/combobox_controller.js +17 -2
- data/app/javascript/controllers/date_picker_controller.js +57 -21
- data/app/javascript/controllers/drawer_controller.js +66 -44
- data/app/javascript/controllers/drawer_trigger_controller.js +13 -0
- data/app/javascript/controllers/sidebar_controller.js +84 -42
- data/app/javascript/controllers/sidebar_trigger_controller.js +13 -0
- data/app/javascript/controllers/toast_controller.js +2 -3
- data/app/javascript/controllers/toaster_controller.js +23 -1
- data/app/javascript/controllers/toggle_group_controller.js +17 -6
- data/app/views/components/_badge.html.erb +1 -1
- data/app/views/components/_breadcrumbs.html.erb +7 -7
- data/app/views/components/_calendar.html.erb +39 -30
- data/app/views/components/_combobox.html.erb +2 -0
- data/app/views/components/_date_picker.html.erb +21 -33
- data/app/views/components/_drawer.html.erb +1 -1
- data/app/views/components/_menu_button.html.erb +1 -1
- data/app/views/components/_sidebar.html.erb +1 -1
- data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
- data/app/views/components/breadcrumbs/_item.html.erb +1 -1
- data/app/views/components/breadcrumbs/_link.html.erb +1 -1
- data/app/views/components/breadcrumbs/_list.html.erb +1 -1
- data/app/views/components/breadcrumbs/_page.html.erb +1 -1
- data/app/views/components/breadcrumbs/_separator.html.erb +1 -2
- data/app/views/components/calendar/_header.html.erb +28 -4
- data/app/views/components/calendar/_week.html.erb +7 -4
- data/app/views/components/card/_content.html.erb +1 -1
- data/app/views/components/card/_footer.html.erb +3 -10
- data/app/views/components/card/_header.html.erb +1 -1
- data/app/views/components/card/_title.html.erb +1 -1
- data/app/views/components/combobox/_option.html.erb +2 -2
- data/app/views/components/drawer/_close.html.erb +11 -5
- data/app/views/components/drawer/_content.html.erb +1 -1
- data/app/views/components/drawer/_footer.html.erb +1 -1
- data/app/views/components/drawer/_header.html.erb +1 -1
- data/app/views/components/drawer/_provider.html.erb +1 -1
- data/app/views/components/drawer/_trigger.html.erb +4 -2
- data/app/views/components/pagination/_ellipsis.html.erb +5 -8
- data/app/views/components/pagination/_link.html.erb +1 -1
- data/app/views/components/pagination/_next.html.erb +6 -7
- data/app/views/components/pagination/_previous.html.erb +6 -7
- data/app/views/components/sidebar/_content.html.erb +1 -1
- data/app/views/components/sidebar/_footer.html.erb +1 -1
- data/app/views/components/sidebar/_group.html.erb +1 -1
- data/app/views/components/sidebar/_header.html.erb +1 -1
- data/app/views/components/sidebar/_inset.html.erb +1 -1
- data/app/views/components/sidebar/_menu.html.erb +1 -1
- data/app/views/components/sidebar/_menu_button.html.erb +1 -1
- data/app/views/components/sidebar/_menu_item.html.erb +1 -1
- data/app/views/components/sidebar/_menu_link.html.erb +3 -3
- data/app/views/components/sidebar/_provider.html.erb +1 -1
- data/app/views/components/sidebar/_trigger.html.erb +1 -1
- data/app/views/components/stats/_stats_card.html.erb +7 -6
- data/app/views/components/toast/_action.html.erb +2 -1
- data/lib/generators/maquina_components/install/templates/theme.css.tt +11 -0
- data/lib/maquina_components/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 95885780d4ac7c947e4561e821d4c3cb2f985a3a363738f7cdc7fc01a858fc11
|
|
4
|
+
data.tar.gz: 35100ad72833ef5be786a40444f5af15c37866d0dc8dce4dee34232f04bfe0a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|