maquina-components 0.6.1 → 0.7.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/badge.css +3 -2
- data/app/assets/stylesheets/breadcrumbs.css +42 -10
- data/app/assets/stylesheets/calendar.css +8 -6
- data/app/assets/stylesheets/card.css +3 -2
- data/app/assets/stylesheets/combobox.css +10 -1
- data/app/assets/stylesheets/date_picker.css +4 -3
- data/app/assets/stylesheets/drawer.css +4 -3
- data/app/assets/stylesheets/dropdown_menu.css +1 -1
- data/app/assets/stylesheets/form.css +54 -26
- data/app/assets/stylesheets/menu_button.css +3 -2
- data/app/assets/stylesheets/pagination.css +4 -3
- data/app/assets/stylesheets/sidebar.css +42 -18
- data/app/assets/stylesheets/toast.css +7 -5
- data/app/assets/stylesheets/toggle_group.css +4 -3
- data/app/helpers/maquina_components/breadcrumbs_helper.rb +10 -2
- data/app/helpers/maquina_components/icons_helper.rb +35 -3
- data/app/javascript/controllers/breadcrumb_controller.js +97 -40
- data/app/javascript/controllers/sidebar_controller.js +23 -0
- data/app/views/components/_breadcrumbs.html.erb +3 -7
- data/app/views/components/_sidebar.html.erb +4 -0
- data/app/views/components/combobox/_trigger.html.erb +1 -1
- 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: ce8aead4098ab0b2b83aed60c45ffbcca78f03f8e800f43259a6a51f32f36e5d
|
|
4
|
+
data.tar.gz: e58baf4a3a6ce7dcd8c0b8cce1166b5afe9d21ced6751574195ab29d76329625
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '03008f285333b7b4f7cc066a10f55366629b683c7518458ac3187ffff2e6e1fb93a8850f9225b16d98fc0e14bedcc8ccef5759ee628acc4865964b90daf54661'
|
|
7
|
+
data.tar.gz: 9ad4bb8c6d2b9ed0d0595d9571bb7d471e1fe6aaa4c3c294e0b58b6e2550a5d6f42fdbc5b3a077e44fb10ead2394e75cf07350edb884dd229663c15961b75201
|
|
@@ -133,8 +133,9 @@
|
|
|
133
133
|
|
|
134
134
|
/* Focus state for keyboard navigation */
|
|
135
135
|
[data-component="badge"]:where(a:focus-visible, button:focus-visible) {
|
|
136
|
-
outline: var(--focus-ring-width, 3px)
|
|
137
|
-
|
|
136
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
137
|
+
outline-style: var(--focus-ring-style, solid);
|
|
138
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
138
139
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
139
140
|
}
|
|
140
141
|
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
@apply text-sm;
|
|
56
56
|
font-weight: var(--label-weight, 500);
|
|
57
57
|
@apply underline-offset-4;
|
|
58
|
-
@apply transition-
|
|
58
|
+
@apply transition-[color,background-color,border-color,text-decoration-color];
|
|
59
59
|
color: var(--muted-foreground);
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -73,9 +73,16 @@
|
|
|
73
73
|
@apply underline;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/* The underline is kept, but it cannot be the *only* signal: on a page whose
|
|
77
|
+
breadcrumbs are the sole keyboard stops, a text-decoration change reads as
|
|
78
|
+
nothing happening. Ring first, underline as reinforcement. */
|
|
76
79
|
[data-breadcrumb-part="link"]:where(:focus-visible) {
|
|
77
|
-
@apply
|
|
80
|
+
@apply underline;
|
|
78
81
|
color: var(--foreground);
|
|
82
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
83
|
+
outline-style: var(--focus-ring-style, solid);
|
|
84
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
85
|
+
outline-offset: var(--focus-ring-offset, 0px);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
/* ===== Breadcrumb Page (current) ===== */
|
|
@@ -126,6 +133,15 @@
|
|
|
126
133
|
color: var(--foreground);
|
|
127
134
|
}
|
|
128
135
|
|
|
136
|
+
/* It is a trigger, so it is a tab stop, so it rings. */
|
|
137
|
+
[data-breadcrumb-part="ellipsis"]:where(:focus-visible) {
|
|
138
|
+
color: var(--foreground);
|
|
139
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
140
|
+
outline-style: var(--focus-ring-style, solid);
|
|
141
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
142
|
+
outline-offset: var(--focus-ring-offset, 0px);
|
|
143
|
+
}
|
|
144
|
+
|
|
129
145
|
/* ===== Overflow Dropdown (top-layer popover) ===== */
|
|
130
146
|
/* Visual styling comes from dropdown_menu.css via data-dropdown-menu-part.
|
|
131
147
|
Anchor positioning places the popover in modern browsers;
|
|
@@ -156,17 +172,38 @@
|
|
|
156
172
|
@apply shrink-0 whitespace-nowrap;
|
|
157
173
|
}
|
|
158
174
|
|
|
159
|
-
/*
|
|
175
|
+
/* The last item (current page) is the overflow sponge of last resort: when a
|
|
176
|
+
single long title cannot be helped by collapsing anything, it truncates
|
|
177
|
+
rather than pushing the bar out of its box. */
|
|
160
178
|
:where([data-controller="breadcrumb"]) [data-breadcrumb-part="item"]:where(:last-child) {
|
|
161
179
|
@apply shrink min-w-0 overflow-hidden;
|
|
162
|
-
text-overflow: ellipsis;
|
|
163
180
|
}
|
|
164
181
|
|
|
182
|
+
/* display:block, not the inline-flex the other parts get. text-overflow has
|
|
183
|
+
no effect on a flex container's own box, so this rule used to hard-clip
|
|
184
|
+
mid-word -- "A Very Long Current Pag" -- while claiming to truncate
|
|
185
|
+
gracefully. A block box renders the ellipsis. An icon inside still flows
|
|
186
|
+
inline and keeps its margin. */
|
|
165
187
|
:where([data-controller="breadcrumb"] [data-breadcrumb-part="item"]:last-child) :is([data-breadcrumb-part="page"], [data-breadcrumb-part="link"]) {
|
|
166
|
-
@apply overflow-hidden;
|
|
188
|
+
@apply block overflow-hidden whitespace-nowrap;
|
|
167
189
|
text-overflow: ellipsis;
|
|
168
190
|
}
|
|
169
191
|
|
|
192
|
+
/* Measuring mode, toggled by breadcrumb_controller.js for the length of one
|
|
193
|
+
measurement. The sponge above is exactly what makes scrollWidth useless:
|
|
194
|
+
flex resolves a deficit by shrinking a shrinkable item *before* it lets the
|
|
195
|
+
line overflow, so the label absorbs everything down to 0px and
|
|
196
|
+
scrollWidth === clientWidth at every width. Measured: content wanting 430px
|
|
197
|
+
in a 300px box reported no overflow at all, which is why the width-based
|
|
198
|
+
collapse never fired and collapse_after had to be invented to fake it.
|
|
199
|
+
Pinning the last item to its natural width for the measurement makes the
|
|
200
|
+
list report the space it actually wants. */
|
|
201
|
+
:where([data-controller="breadcrumb"]) [data-breadcrumb-part="list"].breadcrumb-measuring [data-breadcrumb-part="item"]:where(:last-child) {
|
|
202
|
+
flex-shrink: 0;
|
|
203
|
+
min-width: max-content;
|
|
204
|
+
overflow: visible;
|
|
205
|
+
}
|
|
206
|
+
|
|
170
207
|
/* Items hidden by Stimulus controller */
|
|
171
208
|
:where([data-controller="breadcrumb"]) [data-breadcrumb-target="item"]:where(.hidden),
|
|
172
209
|
:where([data-controller="breadcrumb"]) [data-breadcrumb-target="ellipsisSeparator"]:where(.hidden) {
|
|
@@ -183,11 +220,6 @@
|
|
|
183
220
|
[data-breadcrumb-part="list"] {
|
|
184
221
|
@apply gap-1;
|
|
185
222
|
}
|
|
186
|
-
|
|
187
|
-
/* Hide middle items on very small screens by default */
|
|
188
|
-
:where([data-component="breadcrumbs"][data-auto-collapse]) [data-breadcrumb-part="item"]:where(:not(:first-child):not(:last-child)) {
|
|
189
|
-
@apply hidden sm:inline-flex;
|
|
190
|
-
}
|
|
191
223
|
}
|
|
192
224
|
|
|
193
225
|
/* ===== Separator Variants ===== */
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
cursor: pointer;
|
|
66
66
|
background-color: transparent;
|
|
67
67
|
color: var(--foreground);
|
|
68
|
-
@apply transition-
|
|
68
|
+
@apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
[data-calendar-part="header"] button svg {
|
|
@@ -78,8 +78,9 @@
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
[data-calendar-part="header"] button:where(:focus-visible) {
|
|
81
|
-
outline: var(--focus-ring-width, 3px)
|
|
82
|
-
|
|
81
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
82
|
+
outline-style: var(--focus-ring-style, solid);
|
|
83
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
83
84
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
84
85
|
}
|
|
85
86
|
|
|
@@ -135,7 +136,7 @@
|
|
|
135
136
|
cursor: pointer;
|
|
136
137
|
background-color: transparent;
|
|
137
138
|
color: var(--foreground);
|
|
138
|
-
@apply transition-
|
|
139
|
+
@apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
/* ===== Day Variants ===== */
|
|
@@ -228,8 +229,9 @@
|
|
|
228
229
|
[data-calendar-part="day"]:where(:focus-visible) {
|
|
229
230
|
position: relative;
|
|
230
231
|
z-index: 10;
|
|
231
|
-
outline: var(--focus-ring-width, 3px)
|
|
232
|
-
|
|
232
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
233
|
+
outline-style: var(--focus-ring-style, solid);
|
|
234
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
233
235
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
234
236
|
}
|
|
235
237
|
}
|
|
@@ -127,8 +127,9 @@
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
[data-component="card"]:where(a:focus-visible, button:focus-visible) {
|
|
130
|
-
outline: var(--focus-ring-width, 3px)
|
|
131
|
-
|
|
130
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
131
|
+
outline-style: var(--focus-ring-style, solid);
|
|
132
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
132
133
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
133
134
|
}
|
|
134
135
|
}
|
|
@@ -159,6 +159,15 @@
|
|
|
159
159
|
@apply disabled:cursor-not-allowed disabled:opacity-50;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/* The search field is a real tab stop -- the popover opens onto it -- so the
|
|
163
|
+
`outline-none` above has to be paid back here, like every other field. */
|
|
164
|
+
[data-combobox-part="input"]:where(:focus-visible) {
|
|
165
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
166
|
+
outline-style: var(--focus-ring-style, solid);
|
|
167
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
168
|
+
outline-offset: var(--focus-ring-offset, 0px);
|
|
169
|
+
}
|
|
170
|
+
|
|
162
171
|
/* ===== Options List ===== */
|
|
163
172
|
[data-combobox-part="list"] {
|
|
164
173
|
@apply max-h-[300px] overflow-y-auto overflow-x-hidden p-1;
|
|
@@ -172,7 +181,7 @@
|
|
|
172
181
|
cursor: default;
|
|
173
182
|
user-select: none;
|
|
174
183
|
color: var(--popover-foreground, var(--foreground));
|
|
175
|
-
@apply gap-2 py-1.5 text-sm outline-none transition-
|
|
184
|
+
@apply gap-2 py-1.5 text-sm outline-none transition-[color,background-color,border-color,text-decoration-color];
|
|
176
185
|
border-radius: var(--combobox-item-radius, var(--control-radius, 0.375rem));
|
|
177
186
|
@apply pl-8 pr-2; /* Space for check icon on left */
|
|
178
187
|
}
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
color: var(--foreground);
|
|
49
49
|
cursor: pointer;
|
|
50
50
|
text-align: left;
|
|
51
|
-
@apply transition-
|
|
51
|
+
@apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
[data-date-picker-part="trigger"] svg {
|
|
@@ -102,8 +102,9 @@
|
|
|
102
102
|
|
|
103
103
|
[data-date-picker-part="trigger"]:where(:focus-visible) {
|
|
104
104
|
border-color: var(--ring);
|
|
105
|
-
outline: var(--focus-ring-width, 3px)
|
|
106
|
-
|
|
105
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
106
|
+
outline-style: var(--focus-ring-style, solid);
|
|
107
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
107
108
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
108
109
|
}
|
|
109
110
|
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
/* ===== Drawer Close Button ===== */
|
|
115
115
|
[data-drawer-part="close"] {
|
|
116
116
|
@apply inline-flex items-center justify-center p-1;
|
|
117
|
-
@apply transition-
|
|
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
|
}
|
|
@@ -130,8 +130,9 @@
|
|
|
130
130
|
|
|
131
131
|
/* outline, not a ring: the drawer panel clips box-shadow rings */
|
|
132
132
|
[data-drawer-part="close"]:where(:focus-visible) {
|
|
133
|
-
outline: var(--focus-ring-width, 3px)
|
|
134
|
-
|
|
133
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
134
|
+
outline-style: var(--focus-ring-style, solid);
|
|
135
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
135
136
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
cursor: default;
|
|
150
150
|
user-select: none;
|
|
151
151
|
color: var(--popover-foreground, var(--foreground));
|
|
152
|
-
@apply gap-2 px-2 py-1.5 text-sm outline-none transition-
|
|
152
|
+
@apply gap-2 px-2 py-1.5 text-sm outline-none transition-[color,background-color,border-color,text-decoration-color];
|
|
153
153
|
border-radius: var(--dropdown-menu-item-radius, var(--control-radius, 0.375rem));
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
@apply border px-3 py-1;
|
|
96
96
|
@apply text-base md:text-sm;
|
|
97
97
|
@apply outline-none;
|
|
98
|
-
@apply transition-[color,box-shadow
|
|
98
|
+
@apply transition-[color,box-shadow];
|
|
99
99
|
|
|
100
100
|
border-radius: var(--input-radius, var(--control-radius, 0.375rem));
|
|
101
101
|
background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
|
|
@@ -141,8 +141,9 @@
|
|
|
141
141
|
/* Focus state — keyboard only. The bare :focus used to ring on mouse click. */
|
|
142
142
|
[data-component="input"]:where(:focus-visible) {
|
|
143
143
|
border-color: var(--ring);
|
|
144
|
-
outline: var(--focus-ring-width, 3px)
|
|
145
|
-
|
|
144
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
145
|
+
outline-style: var(--focus-ring-style, solid);
|
|
146
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
146
147
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -156,8 +157,9 @@
|
|
|
156
157
|
[data-component="input"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
|
|
157
158
|
--focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
|
|
158
159
|
border-color: var(--destructive);
|
|
159
|
-
outline: var(--focus-ring-width, 3px)
|
|
160
|
-
|
|
160
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
161
|
+
outline-style: var(--focus-ring-style, solid);
|
|
162
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
|
|
161
163
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
162
164
|
}
|
|
163
165
|
|
|
@@ -178,7 +180,7 @@
|
|
|
178
180
|
@apply border px-3 py-2;
|
|
179
181
|
@apply text-base md:text-sm;
|
|
180
182
|
@apply outline-none resize-y;
|
|
181
|
-
@apply transition-[color,box-shadow
|
|
183
|
+
@apply transition-[color,box-shadow];
|
|
182
184
|
|
|
183
185
|
border-radius: var(--textarea-radius, var(--control-radius, 0.375rem));
|
|
184
186
|
background-color: var(--control-fill, var(--maquina-control-fill-default, transparent));
|
|
@@ -194,8 +196,9 @@
|
|
|
194
196
|
/* ----- States ----- */
|
|
195
197
|
[data-component="textarea"]:where(:focus-visible) {
|
|
196
198
|
border-color: var(--ring);
|
|
197
|
-
outline: var(--focus-ring-width, 3px)
|
|
198
|
-
|
|
199
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
200
|
+
outline-style: var(--focus-ring-style, solid);
|
|
201
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
199
202
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
200
203
|
}
|
|
201
204
|
|
|
@@ -206,8 +209,9 @@
|
|
|
206
209
|
[data-component="textarea"]:where([aria-invalid="true"], :invalid:not(:placeholder-shown)) {
|
|
207
210
|
--focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
|
|
208
211
|
border-color: var(--destructive);
|
|
209
|
-
outline: var(--focus-ring-width, 3px)
|
|
210
|
-
|
|
212
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
213
|
+
outline-style: var(--focus-ring-style, solid);
|
|
214
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
|
|
211
215
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
212
216
|
}
|
|
213
217
|
|
|
@@ -220,7 +224,7 @@
|
|
|
220
224
|
@apply border px-3 py-1 pr-8;
|
|
221
225
|
@apply text-base md:text-sm;
|
|
222
226
|
@apply outline-none appearance-none cursor-pointer;
|
|
223
|
-
@apply transition-[color,box-shadow
|
|
227
|
+
@apply transition-[color,box-shadow];
|
|
224
228
|
|
|
225
229
|
border-radius: var(--select-radius, var(--control-radius, 0.375rem));
|
|
226
230
|
background-color: var(--control-fill, var(--maquina-select-fill-default, var(--background)));
|
|
@@ -257,8 +261,9 @@
|
|
|
257
261
|
/* ----- States ----- */
|
|
258
262
|
[data-component="select"]:where(:focus-visible) {
|
|
259
263
|
border-color: var(--ring);
|
|
260
|
-
outline: var(--focus-ring-width, 3px)
|
|
261
|
-
|
|
264
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
265
|
+
outline-style: var(--focus-ring-style, solid);
|
|
266
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
262
267
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
263
268
|
}
|
|
264
269
|
|
|
@@ -338,8 +343,9 @@
|
|
|
338
343
|
|
|
339
344
|
[data-component="checkbox"]:where(:focus-visible) {
|
|
340
345
|
border-color: var(--ring);
|
|
341
|
-
outline: var(--focus-ring-width, 3px)
|
|
342
|
-
|
|
346
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
347
|
+
outline-style: var(--focus-ring-style, solid);
|
|
348
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
343
349
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
344
350
|
}
|
|
345
351
|
|
|
@@ -350,8 +356,9 @@
|
|
|
350
356
|
[data-component="checkbox"]:where([aria-invalid="true"]) {
|
|
351
357
|
--focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
|
|
352
358
|
border-color: var(--destructive);
|
|
353
|
-
outline: var(--focus-ring-width, 3px)
|
|
354
|
-
|
|
359
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
360
|
+
outline-style: var(--focus-ring-style, solid);
|
|
361
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
|
|
355
362
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
356
363
|
}
|
|
357
364
|
|
|
@@ -392,8 +399,9 @@
|
|
|
392
399
|
|
|
393
400
|
[data-component="radio"]:where(:focus-visible) {
|
|
394
401
|
border-color: var(--ring);
|
|
395
|
-
outline: var(--focus-ring-width, 3px)
|
|
396
|
-
|
|
402
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
403
|
+
outline-style: var(--focus-ring-style, solid);
|
|
404
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
397
405
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
398
406
|
}
|
|
399
407
|
|
|
@@ -435,8 +443,9 @@
|
|
|
435
443
|
}
|
|
436
444
|
|
|
437
445
|
[data-component="switch"]:where(:focus-visible) {
|
|
438
|
-
outline: var(--focus-ring-width, 3px)
|
|
439
|
-
|
|
446
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
447
|
+
outline-style: var(--focus-ring-style, solid);
|
|
448
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
440
449
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
441
450
|
}
|
|
442
451
|
|
|
@@ -474,7 +483,15 @@
|
|
|
474
483
|
@apply h-9 px-4 py-2;
|
|
475
484
|
@apply cursor-default;
|
|
476
485
|
@apply outline-none;
|
|
477
|
-
|
|
486
|
+
/* NOT `transition-colors`. Tailwind v4 folds outline-color into that
|
|
487
|
+
utility, so the focus ring would animate from its pre-focus value -- the
|
|
488
|
+
initial `currentColor`, i.e. the control's own text color -- over 150ms.
|
|
489
|
+
On a filled variant that is a near-white ring for the first frames, which
|
|
490
|
+
is no focus indicator at all; it also makes every getComputedStyle read
|
|
491
|
+
taken right after a Tab press report the previous color, which is how
|
|
492
|
+
this was mis-reported as an inert --focus-ring-color token. Keep
|
|
493
|
+
outline-color out of every transition in this engine. */
|
|
494
|
+
@apply transition-[color,background-color,border-color,text-decoration-color];
|
|
478
495
|
@apply select-none;
|
|
479
496
|
|
|
480
497
|
/* SVG icons inside buttons */
|
|
@@ -591,10 +608,20 @@
|
|
|
591
608
|
* variant declares, so a single selector suffices and no variant can clobber
|
|
592
609
|
* it; it also survives forced-colors mode and overflow-hidden ancestors, and
|
|
593
610
|
* needs no knowledge of the backdrop color.
|
|
611
|
+
*
|
|
612
|
+
* Longhands, not the `outline` shorthand. The shorthand is invalid at
|
|
613
|
+
* computed-value time as a unit: if any one var() fails to resolve, all three
|
|
614
|
+
* longhands reset to their initial values and the ring vanishes entirely --
|
|
615
|
+
* and `outline-color: currentColor` looks plausible on a dark-text control,
|
|
616
|
+
* which is how a missing ring stays unreported. Longhands isolate a failure
|
|
617
|
+
* to the property that actually failed.
|
|
618
|
+
*
|
|
619
|
+
* The base rule must never transition outline-color -- see the note there.
|
|
594
620
|
*/
|
|
595
621
|
[data-component="button"]:where(:focus-visible) {
|
|
596
|
-
outline: var(--focus-ring-width, 3px)
|
|
597
|
-
|
|
622
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
623
|
+
outline-style: var(--focus-ring-style, solid);
|
|
624
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
598
625
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
599
626
|
}
|
|
600
627
|
|
|
@@ -639,8 +666,9 @@
|
|
|
639
666
|
:where(.field_with_errors) [data-component="select"] {
|
|
640
667
|
--focus-ring-color: color-mix(in oklch, var(--destructive) 20%, transparent);
|
|
641
668
|
border-color: var(--destructive);
|
|
642
|
-
outline: var(--focus-ring-width, 3px)
|
|
643
|
-
|
|
669
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
670
|
+
outline-style: var(--focus-ring-style, solid);
|
|
671
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
|
|
644
672
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
645
673
|
}
|
|
646
674
|
|
|
@@ -77,8 +77,9 @@
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
[data-menu-button-part="trigger"]:where(:focus-visible) {
|
|
80
|
-
outline: var(--focus-ring-width, 3px)
|
|
81
|
-
|
|
80
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
81
|
+
outline-style: var(--focus-ring-style, solid);
|
|
82
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
82
83
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
@apply text-sm;
|
|
59
59
|
border-radius: var(--pagination-radius, var(--control-radius, 0.375rem));
|
|
60
60
|
font-weight: var(--label-weight, 500);
|
|
61
|
-
@apply transition-
|
|
61
|
+
@apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
|
|
62
62
|
|
|
63
63
|
/* Size */
|
|
64
64
|
@apply h-9 min-w-9 px-3;
|
|
@@ -136,8 +136,9 @@
|
|
|
136
136
|
[data-pagination-part="link"]:where(:focus-visible),
|
|
137
137
|
[data-pagination-part="previous"]:where(:focus-visible),
|
|
138
138
|
[data-pagination-part="next"]:where(:focus-visible) {
|
|
139
|
-
outline: var(--focus-ring-width, 3px)
|
|
140
|
-
|
|
139
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
140
|
+
outline-style: var(--focus-ring-style, solid);
|
|
141
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
141
142
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
142
143
|
}
|
|
143
144
|
|
|
@@ -71,6 +71,22 @@
|
|
|
71
71
|
width: calc(var(--sidebar-width-icon) + 1rem);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/* Below 768px the container is a fixed overlay with a backdrop, so there is no
|
|
75
|
+
state in which the gap should also reserve flow. The comment above claims JS
|
|
76
|
+
keeps this at 0 on mobile, but updateState() computes
|
|
77
|
+
`isOpen ? "none" : (isMobile ? "offcanvas" : "icon")` -- isOpen wins over
|
|
78
|
+
isMobile -- so an expanded sidebar_state cookie reserves a full 288px on
|
|
79
|
+
every phone load until the mobile check runs, and a sidebar opened on a
|
|
80
|
+
phone reserves it for as long as it is open. Structural on purpose: no JS
|
|
81
|
+
has to have run for this to hold. Declared last so source order carries it
|
|
82
|
+
over the width rules above, which are all 0,1,0 like everything else here. */
|
|
83
|
+
@media (width < 768px) {
|
|
84
|
+
:where([data-sidebar-part="root"]) [data-sidebar-part="gap"] {
|
|
85
|
+
width: 0;
|
|
86
|
+
transition: none;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
74
90
|
/* ===== Sidebar Backdrop (mobile overlay) ===== */
|
|
75
91
|
[data-sidebar-part="backdrop"] {
|
|
76
92
|
@apply fixed inset-0 z-40 hidden bg-black opacity-0;
|
|
@@ -180,8 +196,9 @@
|
|
|
180
196
|
}
|
|
181
197
|
|
|
182
198
|
[data-sidebar-part="group-label"]:where(:focus-visible) {
|
|
183
|
-
outline: var(--focus-ring-width, 3px)
|
|
184
|
-
|
|
199
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
200
|
+
outline-style: var(--focus-ring-style, solid);
|
|
201
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
185
202
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
186
203
|
}
|
|
187
204
|
|
|
@@ -212,8 +229,9 @@
|
|
|
212
229
|
}
|
|
213
230
|
|
|
214
231
|
[data-sidebar-part="group-action"]:where(:focus-visible) {
|
|
215
|
-
outline: var(--focus-ring-width, 3px)
|
|
216
|
-
|
|
232
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
233
|
+
outline-style: var(--focus-ring-style, solid);
|
|
234
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
217
235
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
218
236
|
}
|
|
219
237
|
|
|
@@ -278,8 +296,9 @@
|
|
|
278
296
|
/* Menu Button States */
|
|
279
297
|
/* outline, not a ring: the sidebar container clips box-shadow rings */
|
|
280
298
|
[data-sidebar-part="menu-button"]:where(:focus-visible) {
|
|
281
|
-
outline: var(--focus-ring-width, 3px)
|
|
282
|
-
|
|
299
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
300
|
+
outline-style: var(--focus-ring-style, solid);
|
|
301
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
283
302
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
284
303
|
}
|
|
285
304
|
|
|
@@ -337,8 +356,9 @@
|
|
|
337
356
|
|
|
338
357
|
/* Menu Link States */
|
|
339
358
|
[data-sidebar-part="menu-link"]:where(:focus-visible) {
|
|
340
|
-
outline: var(--focus-ring-width, 3px)
|
|
341
|
-
|
|
359
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
360
|
+
outline-style: var(--focus-ring-style, solid);
|
|
361
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
342
362
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
343
363
|
}
|
|
344
364
|
|
|
@@ -407,8 +427,9 @@
|
|
|
407
427
|
}
|
|
408
428
|
|
|
409
429
|
[data-sidebar-part="menu-action"]:where(:focus-visible) {
|
|
410
|
-
outline: var(--focus-ring-width, 3px)
|
|
411
|
-
|
|
430
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
431
|
+
outline-style: var(--focus-ring-style, solid);
|
|
432
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
412
433
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
413
434
|
}
|
|
414
435
|
|
|
@@ -435,8 +456,9 @@
|
|
|
435
456
|
|
|
436
457
|
[data-sidebar-part="trigger"]:where(:focus-visible) {
|
|
437
458
|
border-color: var(--ring);
|
|
438
|
-
outline: var(--focus-ring-width, 3px)
|
|
439
|
-
|
|
459
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
460
|
+
outline-style: var(--focus-ring-style, solid);
|
|
461
|
+
outline-color: var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
|
|
440
462
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
441
463
|
}
|
|
442
464
|
|
|
@@ -499,7 +521,7 @@
|
|
|
499
521
|
/* ===== Sidebar Primary Button (Quick Create style) ===== */
|
|
500
522
|
.sidebar-primary-button {
|
|
501
523
|
@apply flex flex-1 items-center gap-2 overflow-hidden p-2 text-left text-sm;
|
|
502
|
-
@apply transition-
|
|
524
|
+
@apply transition-[color,background-color,border-color,text-decoration-color];
|
|
503
525
|
border-radius: var(--sidebar-item-radius, var(--control-radius, 0.375rem));
|
|
504
526
|
font-weight: var(--label-weight, 500);
|
|
505
527
|
background-color: var(--sidebar-primary);
|
|
@@ -515,15 +537,16 @@
|
|
|
515
537
|
}
|
|
516
538
|
|
|
517
539
|
.sidebar-primary-button:where(:focus-visible) {
|
|
518
|
-
outline: var(--focus-ring-width, 3px)
|
|
519
|
-
|
|
540
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
541
|
+
outline-style: var(--focus-ring-style, solid);
|
|
542
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
520
543
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
521
544
|
}
|
|
522
545
|
|
|
523
546
|
/* ===== Sidebar Outline Button ===== */
|
|
524
547
|
.sidebar-outline-button {
|
|
525
548
|
@apply flex items-center justify-center p-2;
|
|
526
|
-
@apply transition-
|
|
549
|
+
@apply transition-[color,background-color,border-color,text-decoration-color];
|
|
527
550
|
@apply border;
|
|
528
551
|
border-radius: var(--sidebar-item-radius, var(--control-radius, 0.375rem));
|
|
529
552
|
border-color: var(--sidebar-border);
|
|
@@ -540,8 +563,9 @@
|
|
|
540
563
|
}
|
|
541
564
|
|
|
542
565
|
.sidebar-outline-button:where(:focus-visible) {
|
|
543
|
-
outline: var(--focus-ring-width, 3px)
|
|
544
|
-
|
|
566
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
567
|
+
outline-style: var(--focus-ring-style, solid);
|
|
568
|
+
outline-color: var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
|
|
545
569
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
546
570
|
}
|
|
547
571
|
}
|
|
@@ -154,8 +154,9 @@
|
|
|
154
154
|
|
|
155
155
|
[data-toast-part="close"]:where(:focus-visible) {
|
|
156
156
|
opacity: 1;
|
|
157
|
-
outline: var(--focus-ring-width, 3px)
|
|
158
|
-
|
|
157
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
158
|
+
outline-style: var(--focus-ring-style, solid);
|
|
159
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
159
160
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -169,7 +170,7 @@
|
|
|
169
170
|
color: var(--foreground);
|
|
170
171
|
cursor: pointer;
|
|
171
172
|
text-decoration: none;
|
|
172
|
-
@apply mt-2 h-8 px-3 text-xs transition-
|
|
173
|
+
@apply mt-2 h-8 px-3 text-xs transition-[color,background-color,border-color,text-decoration-color];
|
|
173
174
|
border-radius: var(--toast-action-radius, var(--control-radius, 0.375rem));
|
|
174
175
|
font-weight: var(--label-weight, 500);
|
|
175
176
|
}
|
|
@@ -180,8 +181,9 @@
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
[data-toast-part="action"]:where(:focus-visible) {
|
|
183
|
-
outline: var(--focus-ring-width, 3px)
|
|
184
|
-
|
|
184
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
185
|
+
outline-style: var(--focus-ring-style, solid);
|
|
186
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
185
187
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
186
188
|
}
|
|
187
189
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
@apply gap-2 text-sm;
|
|
38
38
|
border-radius: var(--toggle-group-radius, var(--control-radius, 0.375rem));
|
|
39
39
|
font-weight: var(--label-weight, 500);
|
|
40
|
-
@apply transition-
|
|
40
|
+
@apply transition-[color,background-color,border-color,text-decoration-color] duration-150;
|
|
41
41
|
|
|
42
42
|
/* Default size */
|
|
43
43
|
@apply h-9 px-3;
|
|
@@ -132,8 +132,9 @@
|
|
|
132
132
|
[data-toggle-group-part="item"]:where(:focus-visible) {
|
|
133
133
|
z-index: 1;
|
|
134
134
|
position: relative;
|
|
135
|
-
outline: var(--focus-ring-width, 3px)
|
|
136
|
-
|
|
135
|
+
outline-width: var(--focus-ring-width, 3px);
|
|
136
|
+
outline-style: var(--focus-ring-style, solid);
|
|
137
|
+
outline-color: var(--focus-ring-color, var(--ring));
|
|
137
138
|
outline-offset: var(--focus-ring-offset, 0px);
|
|
138
139
|
}
|
|
139
140
|
|
|
@@ -36,6 +36,7 @@ module MaquinaComponents
|
|
|
36
36
|
# @param links [Hash] Hash of text => path pairs
|
|
37
37
|
# @param current_page [String, nil] Text for current page (no link)
|
|
38
38
|
# @param css_classes [String] Additional CSS classes for nav element
|
|
39
|
+
# @param collapse_after [Integer] Deprecated, ignored. Removed in 0.8.0.
|
|
39
40
|
# @return [String] HTML string
|
|
40
41
|
#
|
|
41
42
|
# @example
|
|
@@ -44,8 +45,15 @@ module MaquinaComponents
|
|
|
44
45
|
# "Button"
|
|
45
46
|
# )
|
|
46
47
|
#
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
# collapse_after is accepted and ignored. It existed only because the
|
|
49
|
+
# width-based collapse never fired -- the last item's flex-shrink absorbed
|
|
50
|
+
# the overflow, so scrollWidth never exceeded clientWidth -- and it
|
|
51
|
+
# compensated by collapsing on item count alone, with no idea how much room
|
|
52
|
+
# there was. Space-based collapsing works now, so a count threshold can only
|
|
53
|
+
# collapse a bar that fits. Still in the signature so existing callers do
|
|
54
|
+
# not raise; drop it in 0.8.0.
|
|
55
|
+
def responsive_breadcrumbs(links = {}, current_page = nil, css_classes: "", collapse_after: nil)
|
|
56
|
+
render "components/breadcrumbs", css_classes: css_classes, responsive: true do
|
|
49
57
|
render "components/breadcrumbs/list" do
|
|
50
58
|
build_breadcrumb_items(links, current_page, responsive: true)
|
|
51
59
|
end
|
|
@@ -21,11 +21,19 @@ module MaquinaComponents
|
|
|
21
21
|
|
|
22
22
|
# Internal icon helper for engine components. Always uses built-in SVGs
|
|
23
23
|
# so components work reliably regardless of the app's icon configuration.
|
|
24
|
+
#
|
|
25
|
+
# It raises under strict_icons for the same reason icon_for does, and the
|
|
26
|
+
# reason is sharper here: a component asking for an icon the engine does not
|
|
27
|
+
# ship renders nothing, and no host-app override can rescue it. That is how
|
|
28
|
+
# the dropdown and combobox triggers shipped without their chevrons.
|
|
24
29
|
def builtin_icon_for(name, options = {})
|
|
25
30
|
return nil unless name
|
|
26
31
|
|
|
27
32
|
svg = icon_svg_for(name.to_sym)
|
|
28
|
-
|
|
33
|
+
unless svg
|
|
34
|
+
raise MaquinaComponents::UnknownIconError, unknown_builtin_icon_message(name) if MaquinaComponents.strict_icons?
|
|
35
|
+
return nil
|
|
36
|
+
end
|
|
29
37
|
|
|
30
38
|
apply_icon_options(svg, options)
|
|
31
39
|
end
|
|
@@ -35,6 +43,17 @@ module MaquinaComponents
|
|
|
35
43
|
|
|
36
44
|
private
|
|
37
45
|
|
|
46
|
+
def unknown_builtin_icon_message(name)
|
|
47
|
+
"Unknown built-in icon #{name.to_sym.inspect}. An engine component asked " \
|
|
48
|
+
"for an icon the engine does not ship. Defining it in your app's " \
|
|
49
|
+
"main_icon_svg_for override will NOT help: builtin_icon_for deliberately " \
|
|
50
|
+
"never consults it, so that components render the same in every app. Add " \
|
|
51
|
+
"the icon to icon_svg_for in " \
|
|
52
|
+
"app/helpers/maquina_components/icons_helper.rb and report it upstream. " \
|
|
53
|
+
"Set MaquinaComponents.strict_icons = false to fall back to rendering " \
|
|
54
|
+
"nothing."
|
|
55
|
+
end
|
|
56
|
+
|
|
38
57
|
def unknown_icon_message(name)
|
|
39
58
|
"Unknown icon #{name.to_sym.inspect}. Define it in your app's " \
|
|
40
59
|
"main_icon_svg_for override, or use one of the engine's built-in icons " \
|
|
@@ -59,6 +78,19 @@ module MaquinaComponents
|
|
|
59
78
|
svg = svg.gsub('stroke-width="2"', "stroke-width=\"#{options[:stroke_width]}\"")
|
|
60
79
|
end
|
|
61
80
|
|
|
81
|
+
# Without this, options[:data] was accepted and silently dropped, which
|
|
82
|
+
# left dropdown_menu.css's chevron rotation targeting an attribute that
|
|
83
|
+
# never reached the DOM.
|
|
84
|
+
if options[:data].is_a?(Hash)
|
|
85
|
+
attributes = options[:data].filter_map do |key, value|
|
|
86
|
+
next if value.nil? || value == false
|
|
87
|
+
|
|
88
|
+
name = ERB::Util.html_escape("data-#{key.to_s.tr("_", "-")}")
|
|
89
|
+
%(#{name}="#{ERB::Util.html_escape(value.to_s)}")
|
|
90
|
+
end
|
|
91
|
+
svg = svg.sub(/<svg(?=(\s|>))/, "<svg #{attributes.join(" ")}") if attributes.any?
|
|
92
|
+
end
|
|
93
|
+
|
|
62
94
|
svg.html_safe
|
|
63
95
|
end
|
|
64
96
|
|
|
@@ -142,7 +174,7 @@ module MaquinaComponents
|
|
|
142
174
|
<polyline points="12 19 5 12 12 5"></polyline>
|
|
143
175
|
</svg>
|
|
144
176
|
SVG
|
|
145
|
-
when :select_chevron
|
|
177
|
+
when :select_chevron, :chevron_down
|
|
146
178
|
<<~SVG.freeze
|
|
147
179
|
<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="" aria-hidden="true">
|
|
148
180
|
<path d="m6 9 6 6 6-6"></path>
|
|
@@ -170,7 +202,7 @@ module MaquinaComponents
|
|
|
170
202
|
<line x1="21" x2="9" y1="12" y2="12"></line>
|
|
171
203
|
</svg>
|
|
172
204
|
SVG
|
|
173
|
-
when :chevron_up_down
|
|
205
|
+
when :chevron_up_down, :chevrons_up_down
|
|
174
206
|
<<~SVG.freeze
|
|
175
207
|
<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="">
|
|
176
208
|
<path d="m7 15 5 5 5-5"></path>
|
|
@@ -2,7 +2,16 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
4
|
static targets = ["item", "ellipsis", "ellipsisSeparator"]
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
// Shared with breadcrumbs.css. While it is on the list, the last item is
|
|
7
|
+
// pinned to its natural width; see _fits() for why that is the whole trick.
|
|
8
|
+
// test/stylesheets/css_conventions_test.rb asserts the two agree.
|
|
9
|
+
static MEASURING_CLASS = "breadcrumb-measuring"
|
|
10
|
+
|
|
11
|
+
// Sub-pixel slack. scrollWidth and clientWidth are integer-rounded, so
|
|
12
|
+
// content of 500.4px in a 500px box reports 501 > 500 and would collapse a
|
|
13
|
+
// bar that visually fits.
|
|
14
|
+
static FIT_TOLERANCE = 1
|
|
6
15
|
|
|
7
16
|
// True when CSS anchor positioning (breadcrumbs.css) places the popover
|
|
8
17
|
static supportsAnchorPositioning =
|
|
@@ -12,20 +21,56 @@ export default class extends Controller {
|
|
|
12
21
|
|
|
13
22
|
connect() {
|
|
14
23
|
this._dropdown = null
|
|
24
|
+
this._fitting = false
|
|
25
|
+
this._frame = null
|
|
15
26
|
this._popoverToggleHandler = this._handlePopoverToggle.bind(this)
|
|
16
27
|
this._teardownHandler = this._teardown.bind(this)
|
|
28
|
+
this._morphHandler = this.fit.bind(this)
|
|
17
29
|
|
|
18
30
|
document.addEventListener("turbo:before-cache", this._teardownHandler)
|
|
31
|
+
// Morphing does not replace the element, so connect() never re-runs and the
|
|
32
|
+
// bar would keep whatever collapsed state it had for different content.
|
|
33
|
+
document.addEventListener("turbo:morph", this._morphHandler)
|
|
34
|
+
|
|
35
|
+
// A ResizeObserver on the list, not a window resize listener. The container
|
|
36
|
+
// changes width without the window doing anything -- most obviously when
|
|
37
|
+
// this engine's own sidebar collapses -- and a window listener never sees it.
|
|
38
|
+
const list = this._list()
|
|
39
|
+
if (list && typeof ResizeObserver !== "undefined") {
|
|
40
|
+
this._observer = new ResizeObserver(() => this._scheduleFit())
|
|
41
|
+
this._observer.observe(list)
|
|
42
|
+
}
|
|
19
43
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
44
|
+
// Fallback metrics are wider than the real face often enough to matter, and
|
|
45
|
+
// nothing else would re-measure once the swap lands.
|
|
46
|
+
if (document.fonts && document.fonts.ready) {
|
|
47
|
+
document.fonts.ready.then(() => this.fit())
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.fit()
|
|
23
51
|
}
|
|
24
52
|
|
|
25
53
|
disconnect() {
|
|
26
|
-
|
|
54
|
+
if (this._observer) {
|
|
55
|
+
this._observer.disconnect()
|
|
56
|
+
this._observer = null
|
|
57
|
+
}
|
|
58
|
+
if (this._frame) cancelAnimationFrame(this._frame)
|
|
27
59
|
this._teardown()
|
|
28
60
|
document.removeEventListener("turbo:before-cache", this._teardownHandler)
|
|
61
|
+
document.removeEventListener("turbo:morph", this._morphHandler)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// fit() writes classes, which resizes the list, which re-notifies the
|
|
65
|
+
// observer. Coalescing into one frame and guarding re-entry keeps that from
|
|
66
|
+
// becoming a "ResizeObserver loop completed with undelivered notifications".
|
|
67
|
+
_scheduleFit() {
|
|
68
|
+
if (this._fitting) return
|
|
69
|
+
if (this._frame) cancelAnimationFrame(this._frame)
|
|
70
|
+
this._frame = requestAnimationFrame(() => {
|
|
71
|
+
this._frame = null
|
|
72
|
+
this.fit()
|
|
73
|
+
})
|
|
29
74
|
}
|
|
30
75
|
|
|
31
76
|
ellipsisTargetConnected(element) {
|
|
@@ -45,8 +90,11 @@ export default class extends Controller {
|
|
|
45
90
|
}
|
|
46
91
|
}
|
|
47
92
|
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
// Collapse only when the bar genuinely does not fit, and re-expand when it
|
|
94
|
+
// does. Both halves are the same pass: every run starts fully expanded and
|
|
95
|
+
// re-derives the answer, so nothing is one-way.
|
|
96
|
+
fit() {
|
|
97
|
+
const list = this._list()
|
|
50
98
|
if (!list) return
|
|
51
99
|
|
|
52
100
|
const items = this.itemTargets
|
|
@@ -55,50 +103,59 @@ export default class extends Controller {
|
|
|
55
103
|
|
|
56
104
|
if (items.length < 1 || !ellipsis) return
|
|
57
105
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (sep) sep.classList.remove('hidden')
|
|
65
|
-
})
|
|
106
|
+
// A zero-width container means "not laid out yet" -- inside a collapsing
|
|
107
|
+
// sidebar, a Turbo Frame mid-swap, an unshown tab panel. Measuring there
|
|
108
|
+
// reads as infinite overflow and hides every middle item, permanently,
|
|
109
|
+
// because nothing used to schedule a re-measure. The observer will call us
|
|
110
|
+
// back once it has a width.
|
|
111
|
+
if (list.clientWidth === 0) return
|
|
66
112
|
|
|
67
|
-
|
|
68
|
-
// items are middle targets only; total visible = middle targets + first + last (2)
|
|
69
|
-
const totalItems = items.length + 2
|
|
70
|
-
if (this.collapseAfterValue > 0 && totalItems > this.collapseAfterValue) {
|
|
71
|
-
ellipsis.classList.remove('hidden')
|
|
72
|
-
if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
|
|
113
|
+
this._fitting = true
|
|
73
114
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
115
|
+
try {
|
|
116
|
+
// Reset to fully expanded before deciding anything.
|
|
117
|
+
ellipsis.classList.add('hidden')
|
|
118
|
+
if (ellipsisSeparator) ellipsisSeparator.classList.add('hidden')
|
|
119
|
+
items.forEach(item => {
|
|
120
|
+
item.classList.remove('hidden')
|
|
121
|
+
const sep = this._adjacentSeparator(item)
|
|
122
|
+
if (sep) sep.classList.remove('hidden')
|
|
123
|
+
})
|
|
77
124
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (sep) sep.classList.add('hidden')
|
|
82
|
-
visibleMiddle--
|
|
83
|
-
}
|
|
84
|
-
}
|
|
125
|
+
list.classList.add(this.constructor.MEASURING_CLASS)
|
|
126
|
+
|
|
127
|
+
if (this._fits(list)) return
|
|
85
128
|
|
|
86
|
-
// Check overflow using scrollWidth vs clientWidth
|
|
87
|
-
if (list.scrollWidth > list.clientWidth) {
|
|
88
129
|
ellipsis.classList.remove('hidden')
|
|
89
130
|
if (ellipsisSeparator) ellipsisSeparator.classList.remove('hidden')
|
|
90
131
|
|
|
91
|
-
// Hide
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
132
|
+
// Hide from the FRONT. The ellipsis is rendered immediately after the
|
|
133
|
+
// first item, so it can only honestly stand for the items that follow it.
|
|
134
|
+
// Hiding from the back put the "..." in front of items it did not
|
|
135
|
+
// represent and listed the tail in its dropdown.
|
|
136
|
+
for (let i = 0; i < items.length; i++) {
|
|
137
|
+
items[i].classList.add('hidden')
|
|
138
|
+
const sep = this._adjacentSeparator(items[i])
|
|
96
139
|
if (sep) sep.classList.add('hidden')
|
|
97
|
-
if (
|
|
140
|
+
if (this._fits(list)) break
|
|
98
141
|
}
|
|
142
|
+
} finally {
|
|
143
|
+
list.classList.remove(this.constructor.MEASURING_CLASS)
|
|
144
|
+
this._fitting = false
|
|
145
|
+
this._updateDropdown()
|
|
99
146
|
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Only meaningful inside measuring mode. Outside it the last item is
|
|
150
|
+
// flex-shrinkable, so the flex algorithm absorbs the whole deficit into the
|
|
151
|
+
// current-page label rather than overflowing the line, and scrollWidth equals
|
|
152
|
+
// clientWidth at every width -- which is why the old check never fired.
|
|
153
|
+
_fits(list) {
|
|
154
|
+
return list.scrollWidth <= list.clientWidth + this.constructor.FIT_TOLERANCE
|
|
155
|
+
}
|
|
100
156
|
|
|
101
|
-
|
|
157
|
+
_list() {
|
|
158
|
+
return this.element.querySelector('[data-breadcrumb-part="list"]')
|
|
102
159
|
}
|
|
103
160
|
|
|
104
161
|
// Find the next sibling separator <li> (not the managed ellipsisSeparator)
|
|
@@ -149,6 +149,8 @@ export default class extends Controller {
|
|
|
149
149
|
// Update sidebar visual state
|
|
150
150
|
this.sidebarTarget.setAttribute("data-collapsible", collapsible)
|
|
151
151
|
|
|
152
|
+
this.updateInert(state, collapsible)
|
|
153
|
+
|
|
152
154
|
// Update backdrop (only visible on mobile when open)
|
|
153
155
|
if (this.hasBackdropTarget) {
|
|
154
156
|
const backdropState = (isOpen && isMobile) ? "visible" : "hidden"
|
|
@@ -177,6 +179,27 @@ export default class extends Controller {
|
|
|
177
179
|
}
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
// An offcanvas-collapsed sidebar is parked at `left: -(--sidebar-width)` with
|
|
183
|
+
// visibility: visible, so without this every link in it stays a tab stop --
|
|
184
|
+
// a keyboard user walks through a screenful of off-screen destinations that
|
|
185
|
+
// no pointer can reach. `inert`, not `aria-hidden`: the latter hides the
|
|
186
|
+
// subtree from the accessibility tree while leaving it focusable, which is
|
|
187
|
+
// the worse outcome of a focused element the screen reader will not announce.
|
|
188
|
+
//
|
|
189
|
+
// Only the offcanvas-collapsed case. A `collapsible: "icon"` sidebar is a
|
|
190
|
+
// visible rail and must stay reachable, and an open one obviously must.
|
|
191
|
+
updateInert(state, collapsible) {
|
|
192
|
+
if (!this.hasContainerTarget) return
|
|
193
|
+
|
|
194
|
+
const offscreen = state === "collapsed" && collapsible === "offcanvas"
|
|
195
|
+
|
|
196
|
+
if (offscreen) {
|
|
197
|
+
this.containerTarget.setAttribute("inert", "")
|
|
198
|
+
} else {
|
|
199
|
+
this.containerTarget.removeAttribute("inert")
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
updateStateImmediate() {
|
|
181
204
|
// Update state without transitions (for initial render)
|
|
182
205
|
if (!this.hasSidebarTarget) return
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
<%# locals: (css_classes: "", responsive: false, collapse_after:
|
|
1
|
+
<%# locals: (css_classes: "", responsive: false, collapse_after: nil, **html_options) %>
|
|
2
|
+
<%# collapse_after is deprecated and ignored; see BreadcrumbsHelper. %>
|
|
2
3
|
<% merged_data = merge_component_data(html_options,
|
|
3
4
|
component: :breadcrumbs
|
|
4
5
|
)
|
|
5
6
|
|
|
6
|
-
if responsive
|
|
7
|
-
merged_data[:controller] = "breadcrumb"
|
|
8
|
-
if collapse_after > 0
|
|
9
|
-
merged_data[:breadcrumb_collapse_after_value] = collapse_after
|
|
10
|
-
end
|
|
11
|
-
end %>
|
|
7
|
+
merged_data[:controller] = "breadcrumb" if responsive %>
|
|
12
8
|
|
|
13
9
|
<nav
|
|
14
10
|
aria-label="Breadcrumb"
|
|
@@ -27,10 +27,14 @@
|
|
|
27
27
|
></div>
|
|
28
28
|
|
|
29
29
|
<%# Sidebar container (fixed positioned) %>
|
|
30
|
+
<%# inert for the offcanvas-collapsed case, server-side, so the sidebar is out
|
|
31
|
+
of the tab order before Stimulus connects rather than after. The controller
|
|
32
|
+
keeps it in sync from updateInert(); see sidebar_controller.js. %>
|
|
30
33
|
<div
|
|
31
34
|
id="<%= stable_id %>-container"
|
|
32
35
|
data-sidebar-part="container"
|
|
33
36
|
data-sidebar-target="container"
|
|
37
|
+
<%= tag.attributes(inert: true) if state.to_s == "collapsed" && collapsible.to_s == "offcanvas" %>
|
|
34
38
|
>
|
|
35
39
|
<%# Inner sidebar content wrapper %>
|
|
36
40
|
<div id="<%= stable_id %>-inner" data-sidebar-part="inner">
|
|
@@ -18,5 +18,5 @@
|
|
|
18
18
|
data: merged_data,
|
|
19
19
|
**html_options do %>
|
|
20
20
|
<span data-combobox-target="label"><%= placeholder %></span>
|
|
21
|
-
<%= builtin_icon_for :
|
|
21
|
+
<%= builtin_icon_for :chevron_up_down, class: "size-4 shrink-0 opacity-50" %>
|
|
22
22
|
<% end %>
|