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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a445c4fe008c07069fd7d0e9fd4249c9a169410e9a53fa6cdaa6c2455351b92
4
- data.tar.gz: c0964a27ecfdbd92797ee8c03a24d7c9e6db2b5e41147d2d81bf18ff2e38540d
3
+ metadata.gz: ce8aead4098ab0b2b83aed60c45ffbcca78f03f8e800f43259a6a51f32f36e5d
4
+ data.tar.gz: e58baf4a3a6ce7dcd8c0b8cce1166b5afe9d21ced6751574195ab29d76329625
5
5
  SHA512:
6
- metadata.gz: 7c59d1819376a7242cbac12758ceffdb6e221184a29150b6f70364b275545f84d8da7c3235a5d9bdc3a1044e08622dba5b379615e2bc7053e8647506f83b6b7c
7
- data.tar.gz: 340d7fcd15e476d17e2244cb7e7b29563b27e7fff360c09f9427d2ccc0491379b4dc5c412e4b8be9447159bbb75fd9d47bade573fbfb9497284d3d9ada629931
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) var(--focus-ring-style, solid)
137
- var(--focus-ring-color, var(--ring));
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-colors;
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 outline-none underline;
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
- /* Allow the last item (current page) to truncate gracefully instead of hard-clipping */
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-colors duration-150;
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) var(--focus-ring-style, solid)
82
- var(--focus-ring-color, var(--ring));
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-colors duration-150;
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) var(--focus-ring-style, solid)
232
- var(--focus-ring-color, var(--ring));
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) var(--focus-ring-style, solid)
131
- var(--focus-ring-color, var(--ring));
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-colors;
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-colors duration-150;
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) var(--focus-ring-style, solid)
106
- var(--focus-ring-color, var(--ring));
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-colors;
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) var(--focus-ring-style, solid)
134
- var(--focus-ring-color, var(--ring));
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-colors;
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,outline-color];
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) var(--focus-ring-style, solid)
145
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
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) var(--focus-ring-style, solid)
160
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
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,outline-color];
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) var(--focus-ring-style, solid)
198
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
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) var(--focus-ring-style, solid)
210
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
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,outline-color];
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) var(--focus-ring-style, solid)
261
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
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) var(--focus-ring-style, solid)
342
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
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) var(--focus-ring-style, solid)
354
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
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) var(--focus-ring-style, solid)
396
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
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) var(--focus-ring-style, solid)
439
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
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
- @apply transition-colors;
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) var(--focus-ring-style, solid)
597
- var(--focus-ring-color, var(--ring));
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) var(--focus-ring-style, solid)
643
- var(--focus-ring-color, color-mix(in oklch, var(--destructive) 20%, transparent));
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) var(--focus-ring-style, solid)
81
- var(--focus-ring-color, var(--sidebar-ring));
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-colors duration-150;
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) var(--focus-ring-style, solid)
140
- var(--focus-ring-color, var(--ring));
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) var(--focus-ring-style, solid)
184
- var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
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) var(--focus-ring-style, solid)
216
- var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
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) var(--focus-ring-style, solid)
282
- var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
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) var(--focus-ring-style, solid)
341
- var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
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) var(--focus-ring-style, solid)
411
- var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
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) var(--focus-ring-style, solid)
439
- var(--focus-ring-color, color-mix(in oklch, var(--ring) 50%, transparent));
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-colors;
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) var(--focus-ring-style, solid)
519
- var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
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-colors;
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) var(--focus-ring-style, solid)
544
- var(--focus-ring-color, var(--sidebar-ring, var(--ring)));
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) var(--focus-ring-style, solid)
158
- var(--focus-ring-color, var(--ring));
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-colors;
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) var(--focus-ring-style, solid)
184
- var(--focus-ring-color, var(--ring));
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-colors duration-150;
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) var(--focus-ring-style, solid)
136
- var(--focus-ring-color, var(--ring));
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
- def responsive_breadcrumbs(links = {}, current_page = nil, css_classes: "", collapse_after: 0)
48
- render "components/breadcrumbs", css_classes: css_classes, responsive: true, collapse_after: collapse_after do
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
- return nil unless svg
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
- static values = { collapseAfter: { type: Number, default: 0 } }
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
- this.windowResizeHandler = this.handleResize.bind(this)
21
- window.addEventListener('resize', this.windowResizeHandler)
22
- this.handleResize()
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
- window.removeEventListener('resize', this.windowResizeHandler)
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
- handleResize() {
49
- const list = this.element.querySelector('[data-breadcrumb-part="list"]')
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
- // Reset all items and their adjacent separators to visible
59
- ellipsis.classList.add('hidden')
60
- if (ellipsisSeparator) ellipsisSeparator.classList.add('hidden')
61
- items.forEach(item => {
62
- item.classList.remove('hidden')
63
- const sep = this._adjacentSeparator(item)
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
- // Count-based collapsing: force-collapse when total items exceed threshold
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
- // collapseAfterValue includes first + last, so middle budget = collapseAfterValue - 2
75
- const maxMiddleVisible = Math.max(this.collapseAfterValue - 2, 0)
76
- let visibleMiddle = items.length
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
- for (let i = items.length - 1; i >= 0 && visibleMiddle > maxMiddleVisible; i--) {
79
- items[i].classList.add('hidden')
80
- const sep = this._adjacentSeparator(items[i])
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 middle items one at a time until it fits
92
- const visibleItems = items.filter(item => !item.classList.contains('hidden'))
93
- for (let i = visibleItems.length - 1; i >= 0; i--) {
94
- visibleItems[i].classList.add('hidden')
95
- const sep = this._adjacentSeparator(visibleItems[i])
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 (list.scrollWidth <= list.clientWidth) break
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
- this._updateDropdown()
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: 0, **html_options) %>
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 :chevrons_up_down, class: "size-4 shrink-0 opacity-50" %>
21
+ <%= builtin_icon_for :chevron_up_down, class: "size-4 shrink-0 opacity-50" %>
22
22
  <% end %>
@@ -1,3 +1,3 @@
1
1
  module MaquinaComponents
2
- VERSION = "0.6.1"
2
+ VERSION = "0.7.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: maquina-components
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez