actual-css 0.5.0 → 0.6.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.
- package/CHANGELOG.md +17 -1
- package/dist/actual.css +1 -0
- package/dist/actual.full.css +480 -117
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/dist/actual.min.css +1 -1
- package/docs/design-notes/browser-support.md +14 -14
- package/docs/design-notes/enhancement-contract.md +30 -13
- package/docs/design-notes/forced-colors.md +11 -7
- package/docs/design-notes/grid.md +4 -4
- package/docs/design-notes/naming.md +15 -9
- package/docs/design-notes/platform-alignment.md +9 -0
- package/docs/design-notes/references.md +4 -0
- package/docs/design-notes/steps.md +748 -0
- package/docs/design-notes/widget-primitives.md +32 -15
- package/docs/navigation.json +1 -0
- package/docs/pages/components/alert.md +13 -8
- package/docs/pages/components/aura.md +102 -6
- package/docs/pages/components/avatar.md +9 -6
- package/docs/pages/components/badge.md +11 -7
- package/docs/pages/components/button.md +17 -17
- package/docs/pages/components/card.md +15 -8
- package/docs/pages/components/chat.md +1 -1
- package/docs/pages/components/dialog.md +10 -7
- package/docs/pages/components/drawer.md +1 -1
- package/docs/pages/components/fab.md +12 -10
- package/docs/pages/components/indicator.md +6 -6
- package/docs/pages/components/list.md +11 -2
- package/docs/pages/components/navbar.md +1 -1
- package/docs/pages/components/pagination.md +1 -1
- package/docs/pages/components/rating.md +4 -4
- package/docs/pages/components/skeleton.md +1 -1
- package/docs/pages/components/spinner.md +1 -1
- package/docs/pages/components/steps.md +376 -52
- package/docs/pages/components/table.md +5 -5
- package/docs/pages/enhancements/flyout.md +12 -10
- package/docs/pages/enhancements/overview.md +2 -2
- package/docs/pages/enhancements/scrollspy.md +1 -1
- package/docs/pages/enhancements/tabs.md +1 -1
- package/docs/pages/examples/overview.md +2 -0
- package/docs/pages/forms/choice-cards.md +4 -4
- package/docs/pages/forms/combobox.md +87 -0
- package/docs/pages/forms/floating-field.md +5 -3
- package/docs/pages/forms/joined-controls.md +4 -4
- package/docs/pages/forms/labels.md +8 -5
- package/docs/pages/forms/overview.md +25 -18
- package/docs/pages/forms/password.md +3 -2
- package/docs/pages/forms/range.md +13 -2
- package/docs/pages/forms/states-and-sizes.md +6 -4
- package/docs/pages/forms/validation.md +13 -9
- package/docs/pages/foundations/tokens.md +9 -6
- package/docs/pages/foundations/typography.md +1 -1
- package/docs/pages/guides/bootstrap.md +3 -3
- package/docs/pages/guides/introduction.md +1 -1
- package/docs/pages/guides/modular-import.md +21 -10
- package/docs/pages/guides/progressive-enhancement.md +75 -54
- package/docs/pages/guides/tailwind.md +2 -2
- package/docs/pages/layout/app-shell.md +4 -4
- package/docs/pages/layout/choosing.md +10 -6
- package/docs/pages/layout/grid.md +11 -10
- package/docs/pages/layout/stack.md +10 -6
- package/docs/pages/patterns/structured-lists.md +7 -7
- package/docs/pages/utilities/accessibility.md +1 -1
- package/docs/pages/utilities/flex.md +20 -13
- package/docs/pages/utilities/links.md +1 -1
- package/docs/pages/utilities/list.md +1 -1
- package/docs/pages/utilities/overflow.md +14 -7
- package/docs/pages/utilities/overview.md +22 -0
- package/docs/pages/utilities/shape.md +1 -1
- package/docs/pages/utilities/sizing-wrapping.md +10 -8
- package/docs/pages/utilities/spacing.md +26 -19
- package/docs/pages/utilities/text.md +1 -1
- package/llms.txt +27 -4
- package/package.json +3 -2
- package/scripts/reserved-classes.json +4 -1
- package/src/css/components/fab.css +44 -8
- package/src/css/components/list.css +3 -1
- package/src/css/components/steps.css +277 -69
- package/src/css/core/tokens.css +1 -0
- package/src/css/effects/aura.css +101 -11
- package/src/css/forms/custom-select.css +1 -1
- package/src/css/forms/floating-field.css +8 -6
- package/src/css/layout/container-query.css +37 -13
- package/src/css/layout/grid.css +7 -7
- package/src/css/layout/index.css +1 -1
- package/src/js/password.js +1 -1
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
# Steps: representations, thresholds, and selector shapes
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
This document holds the reasoning behind `steps.css`: why the component has the
|
|
6
|
+
representations it has, why the threshold sits where it sits, and which
|
|
7
|
+
plausible alternatives are wrong and by how much.
|
|
8
|
+
|
|
9
|
+
Its job is to keep the constraints legible, so a change that would reintroduce
|
|
10
|
+
a shape this component measures as worse can be recognised as such. Everything
|
|
11
|
+
here is stated as the design is, with the measurement that settles it — not as
|
|
12
|
+
a record of what changed when. That belongs in the changelog.
|
|
13
|
+
|
|
14
|
+
## The markup contract
|
|
15
|
+
|
|
16
|
+
One primitive, two explicit compositions. Both classes are required:
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<ol class="steps steps-horizontal">…</ol> <!-- 2 to 5 stages -->
|
|
20
|
+
<ol class="steps steps-vertical">…</ol> <!-- no count limit -->
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`.steps` is what a step *is*; the second class is what the sequence *looks
|
|
24
|
+
like*. A step is therefore the same thing in both.
|
|
25
|
+
|
|
26
|
+
`.step-label` is then the other decision, and it is **independent of the
|
|
27
|
+
first**: it says whether the sequence carries its own step names. Optional in
|
|
28
|
+
either orientation; when a sequence carries names, every step carries one.
|
|
29
|
+
|
|
30
|
+
```html
|
|
31
|
+
<!-- the page carries the name; the sequence draws the position -->
|
|
32
|
+
<ol class="steps steps-horizontal" aria-hidden="true">
|
|
33
|
+
<li class="step-complete"></li>
|
|
34
|
+
<li aria-current="step"></li>
|
|
35
|
+
<li></li>
|
|
36
|
+
</ol>
|
|
37
|
+
|
|
38
|
+
<!-- the sequence carries the names -->
|
|
39
|
+
<ol class="steps steps-horizontal">
|
|
40
|
+
<li class="step-complete"><a class="step-label" href="/account">Account</a></li>
|
|
41
|
+
<li aria-current="step"><span class="step-label">Validation</span></li>
|
|
42
|
+
<li><span class="step-label">Payment</span></li>
|
|
43
|
+
</ol>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Two dimensions, four compositions:
|
|
47
|
+
|
|
48
|
+
| | empty steps | `.step-label` on every step |
|
|
49
|
+
| -------------- | ------------------- | ------------------------------------- |
|
|
50
|
+
| **horizontal** | compact progression | named sequence, stacked or inline |
|
|
51
|
+
| **vertical** | compact rail | named sequence, labels beside markers |
|
|
52
|
+
|
|
53
|
+
The two forms are not two densities of the same thing. They show different
|
|
54
|
+
information, which is why the contract splits on content rather than on a
|
|
55
|
+
width. And the split is not the component's invention: a step with nothing in
|
|
56
|
+
it *is* just a marker. "Markers only" is not a variant — it is what a step
|
|
57
|
+
naturally is when it has no content.
|
|
58
|
+
|
|
59
|
+
A label may be an `a[href]` where the workflow allows navigation. That is the
|
|
60
|
+
whole interactive surface and it belongs to labels alone, which is the same
|
|
61
|
+
rule read from the other end: navigating a flow means naming its destinations —
|
|
62
|
+
see "Navigation is a labelled affair".
|
|
63
|
+
|
|
64
|
+
### Why `.step-complete` is prefixed
|
|
65
|
+
|
|
66
|
+
The framework writes modifiers bare — `<button class="btn primary outline lg">`
|
|
67
|
+
— and a bare `.complete` would look like it belongs to that grammar. It does
|
|
68
|
+
not. Those modifiers qualify a component on the component's own element; this
|
|
69
|
+
one marks one item *inside* a component, which is the position `.step-label`
|
|
70
|
+
occupies. Prefixing keeps the component's public vocabulary in one namespace:
|
|
71
|
+
`steps`, `steps-horizontal`, `steps-vertical`, `step-label`, `step-complete`,
|
|
72
|
+
beside `aria-current="step"`.
|
|
73
|
+
|
|
74
|
+
It also keeps the framework from claiming `complete` — an ordinary English word
|
|
75
|
+
an application is likely to want for a task, a download or a form — as a
|
|
76
|
+
reserved global class, for one component's one state.
|
|
77
|
+
|
|
78
|
+
Complete is the only state with a class at all. Current is `aria-current="step"`
|
|
79
|
+
and upcoming is the absence of both, because the platform already spells those.
|
|
80
|
+
|
|
81
|
+
### Bare text in an `<li>`
|
|
82
|
+
|
|
83
|
+
Out of contract — a step name belongs in a `.step-label` — and it degrades in
|
|
84
|
+
the right direction. `:empty` reads an item's own content, so `<li>Account</li>`
|
|
85
|
+
is not empty: it takes the reading width a wrapped label would, and only the
|
|
86
|
+
placing and aligning that `.step-label` rules provide are missing. It lays out
|
|
87
|
+
and does not fall apart, which is all an out-of-contract markup is owed.
|
|
88
|
+
|
|
89
|
+
This is the case that makes bare text expensive to support as a first-class
|
|
90
|
+
form, and it is worth stating precisely, because a component that sizes steps
|
|
91
|
+
*per label* pays for it twice.
|
|
92
|
+
|
|
93
|
+
In behaviour: a row mixing wrapped and unwrapped steps compacts only the
|
|
94
|
+
wrapped ones. Measured at five steps in a 559px region, the unwrapped step
|
|
95
|
+
holds 201px while its four peers shrink to 89px. That skew is what
|
|
96
|
+
`> li:has(.step-label)` produces — bare text has no label, so it takes a
|
|
97
|
+
marker-wide floor while its wrapped peers keep `--step-min`.
|
|
98
|
+
|
|
99
|
+
In bytes: the only valid way to express "every step is wrapped" is to enumerate
|
|
100
|
+
positions (see "`:has()` cannot be nested"), which grows the selector for each
|
|
101
|
+
supported count.
|
|
102
|
+
|
|
103
|
+
`:empty` avoids both by asking the question that actually decides the layout —
|
|
104
|
+
*is there anything in this step?* — and answering it the same way for a label,
|
|
105
|
+
a link and bare text.
|
|
106
|
+
|
|
107
|
+
The row-level alternative, `.steps-horizontal:not(:has(.step-label)) > li`,
|
|
108
|
+
avoids the skew differently: it makes every step reading-width, so an empty
|
|
109
|
+
step in a mixed row becomes a wide gap rather than a marker. It also puts the
|
|
110
|
+
compact form itself behind `:has()`, above the Degraded tier, for a shape that
|
|
111
|
+
needs no modern selector at all.
|
|
112
|
+
|
|
113
|
+
### `:empty` means empty
|
|
114
|
+
|
|
115
|
+
No children, no text — not even whitespace. `<li></li>` is a marker;
|
|
116
|
+
`<li>\n</li>` is not. Comments are allowed.
|
|
117
|
+
|
|
118
|
+
That is a sharp edge, and it is the framework's existing one: `.badge:empty`
|
|
119
|
+
draws the same line, so it is not a new convention for an author to learn. It
|
|
120
|
+
is documented next to the markup that relies on it.
|
|
121
|
+
|
|
122
|
+
## Why there is one threshold
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
>= 60rem marker beside label
|
|
126
|
+
otherwise stacked
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Only labelled rows see it. An unlabelled row is already the shape the query
|
|
130
|
+
produces, so it stays out — one representation at every width, which is what
|
|
131
|
+
lets it be documented as unconditional.
|
|
132
|
+
|
|
133
|
+
One threshold, and no per-count variants of it: see "Nothing counts the steps".
|
|
134
|
+
|
|
135
|
+
**60rem is where the inline connector survives on five steps.** Inline is a
|
|
136
|
+
bonus for generous room, not a rescue, so a late threshold costs nothing and one
|
|
137
|
+
threshold can serve every count. But it cannot be arbitrary: the connector
|
|
138
|
+
absorbing the free space between marker + label groups is the representation's
|
|
139
|
+
defining feature, and the region a theme replaces with a chevron.
|
|
140
|
+
|
|
141
|
+
Measured with five steps and realistic labels ("Personal details", "Delivery
|
|
142
|
+
address", "Shipping method", …), the connector width is:
|
|
143
|
+
|
|
144
|
+
| Container | 768px | 832px | 896px | 960px | 1024px |
|
|
145
|
+
| --------- | ----- | ----- | ----- | ----- | ------ |
|
|
146
|
+
| connector | 15px | 24px | 33px | 49px | 65px |
|
|
147
|
+
|
|
148
|
+
At 48rem (768px) the row renders inline with a 15px connector — the
|
|
149
|
+
representation applies, but its point has collapsed to a stub. 60rem is the
|
|
150
|
+
first threshold where five steps still clear the connector's own `2rem`
|
|
151
|
+
flex-basis. Four steps reach 58px already at 768px, and two or three steps have
|
|
152
|
+
room to spare, so calibrating on five costs the smaller counts only a longer
|
|
153
|
+
stay in the stacked layout.
|
|
154
|
+
|
|
155
|
+
**The threshold is literal rem.** `@container` cannot resolve a custom
|
|
156
|
+
property, so overriding `--step-min` moves the scroll budget but not this
|
|
157
|
+
query. Same trade as `--grid-min` in `grid.css`.
|
|
158
|
+
|
|
159
|
+
## The compact form
|
|
160
|
+
|
|
161
|
+
The whole unlabelled contract, in both orientations:
|
|
162
|
+
|
|
163
|
+
```css
|
|
164
|
+
.steps-horizontal > li:empty {
|
|
165
|
+
flex: 1 1 0;
|
|
166
|
+
justify-items: start;
|
|
167
|
+
min-inline-size: var(--step-size);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.steps-horizontal > li:empty::after {
|
|
171
|
+
inset-inline-start: var(--step-size);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.steps-horizontal > li:empty:last-child {
|
|
175
|
+
flex: 0 0 auto;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.steps-vertical > li:empty {
|
|
179
|
+
grid-template-columns: var(--step-size);
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The CSS then reads almost as the public contract does:
|
|
184
|
+
|
|
185
|
+
```css
|
|
186
|
+
.steps-horizontal > li { /* marker over label */ }
|
|
187
|
+
.steps-horizontal > li:empty { /* marker only */ }
|
|
188
|
+
.steps-vertical > li { /* marker beside label */ }
|
|
189
|
+
.steps-vertical > li:empty { /* marker only */ }
|
|
190
|
+
|
|
191
|
+
@container actual-container (inline-size >= 60rem) {
|
|
192
|
+
.steps-horizontal:has(.step-label) > li { /* optional wide enhancement */ }
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Why the row's rule is two declarations and the column's is one
|
|
197
|
+
|
|
198
|
+
The row's base rule describes a step, not a stacked step:
|
|
199
|
+
|
|
200
|
+
```css
|
|
201
|
+
.steps-horizontal > li {
|
|
202
|
+
display: grid;
|
|
203
|
+
grid-template-rows: var(--step-size); /* the marker's row, and only it */
|
|
204
|
+
gap: var(--step-gap);
|
|
205
|
+
…
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The template declares one track. A `.step-label` is auto-placed into an
|
|
210
|
+
implicit second row, and `gap` only means something once there are two tracks —
|
|
211
|
+
so an empty step is exactly `--step-size` tall, with no empty track and no gap
|
|
212
|
+
for `:empty` to take back. `grid-template-rows: var(--step-size) auto` builds
|
|
213
|
+
that second row whether or not a label ever arrives, and the compact rule then
|
|
214
|
+
has to remove the track *and* the gap. What is left to say is only the sizing:
|
|
215
|
+
|
|
216
|
+
```text
|
|
217
|
+
li:empty only the marker marker floor equal share of the line
|
|
218
|
+
li with .step-label a second row --step-min a readable share
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The column cannot use that mechanism, and the reason is worth recording so it
|
|
222
|
+
is not "simplified" later: **`grid-auto-flow` is `row`**, so an auto-placed
|
|
223
|
+
label lands *under* its marker rather than beside it. Its second track is
|
|
224
|
+
therefore explicit, and `:empty` takes it back — one declaration, since a
|
|
225
|
+
single track has nothing for `gap` to sit beside.
|
|
226
|
+
|
|
227
|
+
### Why the track runs edge to edge
|
|
228
|
+
|
|
229
|
+
`justify-items: start` on an empty step is not an override of the row's
|
|
230
|
+
centring, and the distinction matters because it looks like one. A marker is
|
|
231
|
+
centred in its share because a label sits under it and the two must share a
|
|
232
|
+
centre. With no label there is nothing to centre against, so the marker takes
|
|
233
|
+
the start of its share, its connector fills the rest, and the final step —
|
|
234
|
+
having no share to fill — is only as wide as its own marker.
|
|
235
|
+
|
|
236
|
+
The result is a track flush with both ends of the row, which is what lets it
|
|
237
|
+
line up with the heading or paragraph that names the step. Keeping the centred
|
|
238
|
+
geometry leaves half-cell margins at both ends: measured on five empty steps in
|
|
239
|
+
a 736px row, 60px of dead space on each side, with the first marker floating
|
|
240
|
+
inward of a left-aligned heading.
|
|
241
|
+
|
|
242
|
+
It also matches the inline representation, whose `:last-child { flex: 0 0 auto }`
|
|
243
|
+
is the same declaration for the same reason.
|
|
244
|
+
|
|
245
|
+
The connector follows from the alignment rather than being tuned to it. The
|
|
246
|
+
base geometry starts a connector at `50% + --step-size / 2` — the right edge of
|
|
247
|
+
a centred marker — and runs it `100% - --step-size`, to the next marker's left
|
|
248
|
+
edge. Move the marker to the start of the share and that origin becomes
|
|
249
|
+
`--step-size`; the width is unchanged, and the connector still lands exactly on
|
|
250
|
+
the next marker. Measured at 736px and at 200px: zero gap either side.
|
|
251
|
+
|
|
252
|
+
### What the column's rule buys
|
|
253
|
+
|
|
254
|
+
Nothing visible in a stretched column: a flex item in a column container fills
|
|
255
|
+
its line whatever its tracks say, so the marker, the connector and the item's
|
|
256
|
+
box all measure identically either way. Only `grid-template-columns` changes.
|
|
257
|
+
|
|
258
|
+
It earns its place in a **shrink-to-fit box** — which is what a sticky progress
|
|
259
|
+
rail usually is. Measured at three empty steps in an `inline-size: max-content`
|
|
260
|
+
wrapper: **36px without the rule, 28px with it.** The 8px is `--step-inline-gap`,
|
|
261
|
+
the gap to a label track holding nothing. A rail eight pixels wider than the
|
|
262
|
+
markers it draws is not a bug anyone would file, and also not something to
|
|
263
|
+
leave in a component whose whole claim is that sizing follows content.
|
|
264
|
+
|
|
265
|
+
### Why `:empty` rather than `:has()`
|
|
266
|
+
|
|
267
|
+
It says what the markup says, and it reads the item's own content, which is
|
|
268
|
+
what keeps bare text off a marker-wide floor (see "Bare text in an `<li>`").
|
|
269
|
+
|
|
270
|
+
It also predates `:has()` by a decade, so the compact form reaches the Degraded
|
|
271
|
+
tier. `:has(.step-label)` appears only in the wide container query, where
|
|
272
|
+
losing it costs the inline bonus and nothing structural. So the support story
|
|
273
|
+
has no hole in it:
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
no :has(), no container queries both compact forms work; labelled stays stacked
|
|
277
|
+
:has() + container queries labelled rows also go inline past 60rem
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Selector shapes
|
|
281
|
+
|
|
282
|
+
### `:has()` cannot be nested
|
|
283
|
+
|
|
284
|
+
`:has()` is not valid inside `:has()`, including indirectly through `:not()` or
|
|
285
|
+
`:is()`. The whole selector is discarded — silently, so the rule simply never
|
|
286
|
+
applies. Chrome throws on `document.querySelector` for it, which is the cheapest
|
|
287
|
+
way to check.
|
|
288
|
+
|
|
289
|
+
This rules out the natural spelling of "every step is labelled":
|
|
290
|
+
|
|
291
|
+
```css
|
|
292
|
+
/* INVALID — the rule never applies */
|
|
293
|
+
.steps:not(:has(> li:not(:has(> .step-label))))
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
When a count is already known, the workaround is to enumerate positions, each in
|
|
297
|
+
its own flat clause, folding the last into the count check:
|
|
298
|
+
|
|
299
|
+
```css
|
|
300
|
+
.steps:has(> :nth-child(1) > .step-label):has(> :nth-child(2):last-child > .step-label)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
That is the strict all-or-nothing label check, and it costs one clause per
|
|
304
|
+
position per rule.
|
|
305
|
+
|
|
306
|
+
Nothing needs it, because nothing asks that question. Sizing is decided per
|
|
307
|
+
item, by `:empty`, so no rule needs to know what the *sequence* contains; only
|
|
308
|
+
the wide enhancement asks about labels at all, and it asks the loose version:
|
|
309
|
+
|
|
310
|
+
```css
|
|
311
|
+
.steps-horizontal:has(.step-label) > li /* some step is labelled */
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
One flat clause at any count. It cannot express "every step is labelled", so
|
|
315
|
+
all-or-nothing is enforced by documentation rather than by the selector — and
|
|
316
|
+
the strict version would cost the enumeration above on every rule, to improve
|
|
317
|
+
markup that is already wrong. What the loose clause gives a mixed row is the
|
|
318
|
+
inline treatment for all of its steps, which is the harmless direction: each
|
|
319
|
+
item is still sized on its own content.
|
|
320
|
+
|
|
321
|
+
### Nothing counts the steps
|
|
322
|
+
|
|
323
|
+
No selector in the component reads `:nth-child`, and none needs to. Whether a
|
|
324
|
+
step is compact is decided by that step's content, at every width.
|
|
325
|
+
|
|
326
|
+
A count check is the natural instinct for a stepper, so it is worth stating
|
|
327
|
+
what it would be for and why the component declines it. A representation that
|
|
328
|
+
*hides* labels below a width only helps at four or five steps — two or three
|
|
329
|
+
stay readable stacked far below any width worth designing for — so it needs a
|
|
330
|
+
lower bound:
|
|
331
|
+
|
|
332
|
+
```css
|
|
333
|
+
/* four steps or more */
|
|
334
|
+
:has(> :nth-child(4))
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
and, to keep a 6+ row on the base layout, one of two upper bounds as well:
|
|
338
|
+
|
|
339
|
+
```css
|
|
340
|
+
/* exactly 4 or 5 */
|
|
341
|
+
:has(> :nth-child(4):last-child, > :nth-child(5):last-child)
|
|
342
|
+
|
|
343
|
+
/* at least 4, fewer than 6 */
|
|
344
|
+
:has(> :nth-child(4)):not(:has(> :nth-child(6)))
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Both upper bounds are defensible as "the enumeration *is* the contract" — the
|
|
348
|
+
selector stating the supported counts. That argument holds only if 6+
|
|
349
|
+
horizontal is a case worth serving carefully. It is not. It is out of contract,
|
|
350
|
+
and the enumeration buys it a curated fallback at the cost of four
|
|
351
|
+
`:nth-child` clauses on every rule of the block, on every row that *is*
|
|
352
|
+
supported.
|
|
353
|
+
|
|
354
|
+
So a 6+ row goes inline at wide widths, calibrated for a count it does not
|
|
355
|
+
have. That is what out of contract means: the rules apply as written. The docs
|
|
356
|
+
say 2 to 5 and point longer sequences at `.steps-vertical` rather than promising
|
|
357
|
+
a graceful degradation nobody asked for.
|
|
358
|
+
|
|
359
|
+
The tests hold the same line: there is no six-step fixture. Pinning what such a
|
|
360
|
+
row does — "no rule fires", or "it compacts" — turns an accident of the
|
|
361
|
+
selectors into an API nobody promised, and would have to be revisited every
|
|
362
|
+
time a threshold moved. What is out of contract is not tested for behaviour.
|
|
363
|
+
|
|
364
|
+
Vertical is the other thing a rule might need to exclude, and it needs no guard
|
|
365
|
+
either: the container query addresses `.steps-horizontal` by name, so there is
|
|
366
|
+
nothing to exclude. See "Two compositions, one primitive".
|
|
367
|
+
|
|
368
|
+
### The containment grant
|
|
369
|
+
|
|
370
|
+
The inline representation reads the nearest `actual-container` size context,
|
|
371
|
+
which the author grants — with the `.container-query` class or by declaring the
|
|
372
|
+
name in their own CSS — on the row or on a region around it. It is not granted
|
|
373
|
+
by the component, because `container-type: inline-size` collapses `.steps` to
|
|
374
|
+
roughly zero in any shrink-to-fit box: a float, an inline-block, a table cell, a
|
|
375
|
+
grid auto track, an absolutely positioned box, a `max-content` parent. Block flow
|
|
376
|
+
and `.cluster` are safe.
|
|
377
|
+
|
|
378
|
+
Containment is also what makes the condition stable: content cannot perturb the
|
|
379
|
+
container's width, so the query cannot be flipped by its own effect.
|
|
380
|
+
|
|
381
|
+
The name is shared rather than steps-specific, so one grant serves every
|
|
382
|
+
size-aware component in a region.
|
|
383
|
+
|
|
384
|
+
The grant is an enhancement in both directions. An unlabelled sequence needs
|
|
385
|
+
none — its shape is intrinsic, in either orientation. A labelled one needs none
|
|
386
|
+
either: stacked is the baseline, and the grant only buys the wide composition.
|
|
387
|
+
**The container does not rescue the component; it improves how it composes.**
|
|
388
|
+
|
|
389
|
+
### Navigation is a labelled affair
|
|
390
|
+
|
|
391
|
+
Only a `.step-label` can be a link, so navigation and labels are one decision.
|
|
392
|
+
A marker is never interactive, which rules out the doubtful case directly: five
|
|
393
|
+
clickable circles with no visible name.
|
|
394
|
+
|
|
395
|
+
That single rule is also what keeps a focus guard out of the layout. `.sr-only`
|
|
396
|
+
leaves an element in the tab order at 1×1 under `clip-path: inset(50%)`, so a
|
|
397
|
+
focus ring lands on nothing — and a component that hides labels to fit needs to
|
|
398
|
+
refuse any row whose labels are navigable:
|
|
399
|
+
|
|
400
|
+
```css
|
|
401
|
+
:not(:has(a[href]))
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Such a guard has to read structure rather than state, because `aria-current`
|
|
405
|
+
moves as the workflow advances and the same link goes future → current →
|
|
406
|
+
complete: a state-keyed guard flickers under the user. It also has to be
|
|
407
|
+
deliberately loose — `:has(a[href])` reads the whole subtree — since refusing on
|
|
408
|
+
more than you promise is the safe direction. None of that is needed here:
|
|
409
|
+
nothing hides a label, so no rule has to ask whether hiding one would be safe.
|
|
410
|
+
|
|
411
|
+
The contract is one sentence. A step label may be a link, a marker never is, and
|
|
412
|
+
anything else focusable in a step is out of contract.
|
|
413
|
+
|
|
414
|
+
The one `:has(a[href])` in the file is unrelated: a navigable row reserves
|
|
415
|
+
`padding-block` for its focus ring, because the scroll container clips at the
|
|
416
|
+
padding edge. See "Scrolling on one axis".
|
|
417
|
+
|
|
418
|
+
A link needs no styling rule of its own to go with it. `color: inherit` sits in
|
|
419
|
+
the `.steps .step-label` rule: a span inherits its ink anyway, so only a link
|
|
420
|
+
has a `--link` colour to take back, and taking it back for every label costs
|
|
421
|
+
nothing and one rule less. That rule stays scoped to `.steps` — unlike
|
|
422
|
+
`.menu-item-text` and the framework's other sub-part classes — because a bare
|
|
423
|
+
`.step-label` weighs (0,1,0), exactly `.prose :where(a)`, so a stepper inside
|
|
424
|
+
prose would be decided by the order the bundle concatenates two files in.
|
|
425
|
+
|
|
426
|
+
## Why nothing hides a label
|
|
427
|
+
|
|
428
|
+
**The framework does not silently remove content to solve a layout problem.**
|
|
429
|
+
That is the sentence the whole component turns on, and it is worth defending in
|
|
430
|
+
detail, because hiding labels below a width is the obvious design for a
|
|
431
|
+
stepper and it is a well-measured trap.
|
|
432
|
+
|
|
433
|
+
### The shape it rules out
|
|
434
|
+
|
|
435
|
+
A container query hiding every label of a four- or five-step row below 35rem:
|
|
436
|
+
|
|
437
|
+
```text
|
|
438
|
+
< 35rem + 4 steps or more markers only, unless a label is a link
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
with `.sr-only` on `.step-label`, so the text stays in the accessibility tree
|
|
442
|
+
while its box disappears. Or the same rules behind a public class, so the author
|
|
443
|
+
picks the representation instead of a width picking it.
|
|
444
|
+
|
|
445
|
+
The threshold is not the weak part. Items hold at `flex: 1 0 var(--step-min)`,
|
|
446
|
+
so a row's intrinsic width is `count × --step-min` — 35rem for five steps — and
|
|
447
|
+
the query fires exactly where a five-step stacked row starts to scroll. Four
|
|
448
|
+
steps share that threshold rather than getting their own at 28rem, so between
|
|
449
|
+
448px and 560px they compact although stacked would still fit; splitting the
|
|
450
|
+
difference at 32rem is worse, handing five-step rows a scrolling band instead
|
|
451
|
+
(40px of overflow at 520px, 20px at 540px).
|
|
452
|
+
|
|
453
|
+
### Why the premise fails
|
|
454
|
+
|
|
455
|
+
It asks the author to write five step names and then takes them away. Made
|
|
456
|
+
explicit as a class, that is a public API whose entire meaning is *hide the
|
|
457
|
+
content I just made you author* — plus a `.sr-only` clone in the component,
|
|
458
|
+
plus a link guard so the hiding cannot clip a focus target, plus documentation
|
|
459
|
+
explaining why four steps behave differently from three. The hidden markup ends
|
|
460
|
+
up richer than the visible interface: a second, invisible version of the
|
|
461
|
+
component.
|
|
462
|
+
|
|
463
|
+
And the accessibility it buys is thinner than it looks. A CSS-numbered `<li>`
|
|
464
|
+
sequence with five hidden names is not a better announcement than the sentence
|
|
465
|
+
the page already contains. In practice the page says **Step 3 of 5 —
|
|
466
|
+
Validation**, and the sequence is a picture of it.
|
|
467
|
+
|
|
468
|
+
So the question is not "how narrow before labels must go?" but "does this
|
|
469
|
+
sequence carry names at all?", and that one is the author's to answer in
|
|
470
|
+
markup. An unlabelled sequence is compact because it has nothing to show; a
|
|
471
|
+
labelled one keeps everything it was given. When a labelled row does not fit,
|
|
472
|
+
the honest answers are scrolling, more space, or `.steps-vertical`.
|
|
473
|
+
|
|
474
|
+
### What the rule is worth, concretely
|
|
475
|
+
|
|
476
|
+
Holding that line is what keeps all of the following out of the component:
|
|
477
|
+
|
|
478
|
+
* a second container query, and the `< 35rem` threshold in it;
|
|
479
|
+
* `:has(> :nth-child(4))`, and any other rule that counts steps;
|
|
480
|
+
* `:not(:has(a[href]))` on a layout rule — a rule asking whether hiding would
|
|
481
|
+
be safe;
|
|
482
|
+
* a `.sr-only` clone inside the component, and `clip-path` anywhere in the file;
|
|
483
|
+
* a marker-only variant class, and the `:not(...)` guard the wide query would
|
|
484
|
+
need to leave it alone;
|
|
485
|
+
* two of three `grid-template-rows` declarations and a `gap: 0`, since the base
|
|
486
|
+
template need not build a label row for a step that has no label;
|
|
487
|
+
* a browser test proving the focus guard reads structure rather than state, and
|
|
488
|
+
the four- versus five-step threshold fixtures;
|
|
489
|
+
* a documentation section explaining why three steps behave differently from
|
|
490
|
+
four.
|
|
491
|
+
|
|
492
|
+
It also generalises for free. Once "markers only" is *a step with no content*
|
|
493
|
+
rather than *a representation of a labelled step*, the same sentence describes
|
|
494
|
+
the column, and the contract is two independent dimensions rather than one
|
|
495
|
+
orientation with a mode:
|
|
496
|
+
|
|
497
|
+
```text
|
|
498
|
+
orientation x labels = four compositions, no variant classes
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### The cheap versions of hiding, for the record
|
|
502
|
+
|
|
503
|
+
Hiding a label is declarations, not a value, so it cannot ride on a custom
|
|
504
|
+
property the way a `--step-min` override can. The tempting shortcuts each fail
|
|
505
|
+
on their own terms: zero block-size and `font-size: 0` lose the accessible
|
|
506
|
+
name, and style queries sit above the support floor.
|
|
507
|
+
|
|
508
|
+
Relatedly, the wide block's declarations are written out rather than
|
|
509
|
+
abstracted. One container query with an explicit body is easier to read, and to
|
|
510
|
+
change, than any clever indirection.
|
|
511
|
+
|
|
512
|
+
## States
|
|
513
|
+
|
|
514
|
+
Three states must be readable without a check glyph: future is neutral, complete
|
|
515
|
+
is a filled marker, current is an outlined one with a thicker ring. Filled reads
|
|
516
|
+
as "done", outlined as "you are here", and the distinction survives a numbered
|
|
517
|
+
stepper that never sets `--step-complete-mark`.
|
|
518
|
+
|
|
519
|
+
Current is declared after complete at equal specificity, so it wins on every
|
|
520
|
+
channel — including the connector. The accented line means "already walked", so
|
|
521
|
+
the segment *leaving* the current step is neutral; the segment *reaching* it is
|
|
522
|
+
owned by the previous step, which is complete.
|
|
523
|
+
|
|
524
|
+
The thicker ring is structural, so it survives forced colors, and it never
|
|
525
|
+
competes with the outline channel that belongs to focus.
|
|
526
|
+
|
|
527
|
+
### Forced colors, and why there is no override
|
|
528
|
+
|
|
529
|
+
Forced colors drops the accent fill, so in a numbered flow a complete marker and
|
|
530
|
+
an upcoming one both render as a ringed disc with a number in it. Current still
|
|
531
|
+
reads, on its thicker ring and its bolder label; complete and upcoming do not
|
|
532
|
+
separate.
|
|
533
|
+
|
|
534
|
+
The obvious remedy is a `@media (forced-colors: active)` block giving complete a
|
|
535
|
+
system `Highlight` fill and `HighlightText` text. Captured with forced colors
|
|
536
|
+
emulated through the DevTools protocol, that fill renders the marker's own
|
|
537
|
+
content — the counter, or a `--step-complete-mark` checkmark — unreadable: a
|
|
538
|
+
sliver of `HighlightText` inside a solid disc. It trades an ambiguity for an
|
|
539
|
+
illegible glyph, in the exact mode it exists to help.
|
|
540
|
+
|
|
541
|
+
So there is no such block, and the honest answer is documented instead:
|
|
542
|
+
`--step-complete-mark` distinguishes the two states in forced colors as well as
|
|
543
|
+
anywhere else, because a different glyph survives a colour system that flattens
|
|
544
|
+
fills. An author whose flow needs that distinction sets it; the component does
|
|
545
|
+
not fake it with a fill that cannot carry its own content.
|
|
546
|
+
|
|
547
|
+
The general principle is the one this file keeps arriving at. A remedy that is
|
|
548
|
+
worse than the problem in its own target mode is not a remedy, and shipping it
|
|
549
|
+
"for accessibility" only makes the failure harder to notice.
|
|
550
|
+
|
|
551
|
+
## Connectors
|
|
552
|
+
|
|
553
|
+
The connector pseudo-element is marker-height rather than hairline-height so
|
|
554
|
+
`--step-connector` can be any complete background value — a gradient, an image,
|
|
555
|
+
or `none`. The default paints the hairline as a gradient sized
|
|
556
|
+
`100% var(--step-line-size)`.
|
|
557
|
+
|
|
558
|
+
The colour rides inside that gradient as `var(--step-line)` rather than being a
|
|
559
|
+
`--step-connector` default on `.steps`. A custom property resolves its `var()`
|
|
560
|
+
before inheritance, so a parent-level default would freeze the parent's colour
|
|
561
|
+
and a `.step-complete` child's connector would never follow its own state.
|
|
562
|
+
|
|
563
|
+
`--step-inline-connector` exists because the inline representation gives the
|
|
564
|
+
connector a genuinely different job: it absorbs the free space between
|
|
565
|
+
marker + label groups, which is the region a theme replaces with a chevron. It
|
|
566
|
+
falls back to `--step-connector`, then to the default line.
|
|
567
|
+
|
|
568
|
+
There is deliberately no `--step-vertical-connector`. The vertical geometry is
|
|
569
|
+
different, so a horizontal value cannot transpose onto it — but no real design
|
|
570
|
+
has asked to repaint it, and `.steps-vertical > li::after` is a reachable
|
|
571
|
+
override. Unrequested API is API not worth publishing.
|
|
572
|
+
|
|
573
|
+
## Scrolling on one axis
|
|
574
|
+
|
|
575
|
+
The row scrolls horizontally as its last resort, and never vertically. Saying
|
|
576
|
+
only `overflow-x: auto` does not express that: the spec computes the other axis
|
|
577
|
+
to `auto` too whenever one axis is a scroll container, so `overflow-y` becomes
|
|
578
|
+
`auto` by omission.
|
|
579
|
+
|
|
580
|
+
That would be harmless if the row had any vertical slack, and it has none. A
|
|
581
|
+
stacked step is a 28px marker, an 8px gap and a 17.5px label line — 53.5px, a
|
|
582
|
+
fractional height the box hugs exactly. Whether `scrollHeight` rounds to the
|
|
583
|
+
same integer as `clientHeight` then depends on the font in use, the device pixel
|
|
584
|
+
ratio and the zoom level, so a phantom vertical scrollbar appears on one machine
|
|
585
|
+
and not another — the shape of bug a fixed-width fixture cannot catch, which is
|
|
586
|
+
why the browser test sweeps fractional widths instead of sampling a few.
|
|
587
|
+
|
|
588
|
+
`overflow-y: hidden` settles it: there is genuinely nothing to scroll on that
|
|
589
|
+
axis, in any representation.
|
|
590
|
+
|
|
591
|
+
On the axis that does scroll, `overscroll-behavior-inline: contain` keeps a
|
|
592
|
+
swipe that reaches the end of the row from chaining to the page behind it.
|
|
593
|
+
|
|
594
|
+
The scrollbar itself is left to `.scroller`, composed on the row rather than
|
|
595
|
+
baked into it. A stepper is short — a stacked step is 53.5px — so an OS-default
|
|
596
|
+
scrollbar reads heavy under one, and the temptation is to give the component a
|
|
597
|
+
`scrollbar-width: thin` of its own.
|
|
598
|
+
|
|
599
|
+
The reason not to is consistency, not modularity. Every scroll container the
|
|
600
|
+
framework owns — `.table`, `.flyout`, a scrolling `dialog`, `.app-layout`'s
|
|
601
|
+
main — leaves the scrollbar to the engine. A stepper that thinned its own would
|
|
602
|
+
be the only one, so a page with a scrolling table above a scrolling stepper
|
|
603
|
+
would show two kinds of scrollbar. Scrollbar treatment is a decision an author
|
|
604
|
+
makes once, for everything that scrolls, which is exactly the shape `.scroller`
|
|
605
|
+
has: the scroller docs name this case — "pair it with `.overflow-auto` or a
|
|
606
|
+
component that already creates overflow" — and `.nav-list scroller` is the same
|
|
607
|
+
composition elsewhere.
|
|
608
|
+
|
|
609
|
+
Baking the declarations without the hooks would also strand the author: the
|
|
610
|
+
retuning surface is `--scroller-thumb` and `--scroller-track`, which belong to
|
|
611
|
+
`.scroller`. Copying those into Steps duplicates the primitive; omitting them
|
|
612
|
+
leaves a scrollbar no documented hook reaches.
|
|
613
|
+
|
|
614
|
+
Measured on a five-step row in a 380px region, the pairing takes the scrollbar
|
|
615
|
+
from 15px to 10px and the row from 69px to 64px, with the thumb on
|
|
616
|
+
`--text-muted` instead of the OS grey.
|
|
617
|
+
|
|
618
|
+
Its `.stable-gutter` variant, on the other hand, is a trap worth naming.
|
|
619
|
+
`scrollbar-gutter` reserves space on the *inline* axis, for a vertical
|
|
620
|
+
scrollbar — and this row sets `overflow-y: hidden`, so there is never one to
|
|
621
|
+
reserve for. It does not steady the row's height across the appearance of the
|
|
622
|
+
horizontal scrollbar, which is what an author would reach for it to do:
|
|
623
|
+
measured, the row is 54px when it fits and 64px when it scrolls either way,
|
|
624
|
+
while the reserved inline gutter takes 10px off the row's width and pushes
|
|
625
|
+
overflow from 36px to 46px. Strictly worse in both directions.
|
|
626
|
+
|
|
627
|
+
What makes the row scroll rather than squash is the item floor:
|
|
628
|
+
`min-inline-size: max(var(--step-size), var(--step-min))`. The `max()` is there
|
|
629
|
+
so an author who lowers `--step-min` below `--step-size` narrows the scroll
|
|
630
|
+
budget without collapsing items behind their own markers.
|
|
631
|
+
|
|
632
|
+
The cost of scrolling is that clipping happens at the padding edge, and a focus
|
|
633
|
+
ring is drawn outside its element's border box — `--focus-outline` is two
|
|
634
|
+
border-widths wide at `--focus-outline-offset`, so 4px past a label that sits
|
|
635
|
+
flush with the bottom of a stacked row. A navigable row therefore reserves
|
|
636
|
+
exactly that much `padding-block`, gated on `.steps-horizontal:has(a[href])`. An
|
|
637
|
+
informational stepper — the common case, with no ring to protect — keeps its
|
|
638
|
+
height, and so does a navigable column: the padding is compensation for the
|
|
639
|
+
row's clip, and the column is not a scroll container at all.
|
|
640
|
+
|
|
641
|
+
The two cheaper answers each cost something worse. Reserving the space
|
|
642
|
+
unconditionally is one rule shorter and 8px taller on every stepper in the
|
|
643
|
+
framework. Clipping, and letting focus scroll the ring into view, keeps the
|
|
644
|
+
height and moves the row under the reader instead.
|
|
645
|
+
|
|
646
|
+
## Two compositions, one primitive
|
|
647
|
+
|
|
648
|
+
`.steps` is the component. `.steps-horizontal` and `.steps-vertical` are peers,
|
|
649
|
+
both required, neither the default. Vertical is an explicit orientation, never a
|
|
650
|
+
responsive fallback, and it has no step-count limit: its practical limit is the
|
|
651
|
+
surrounding layout rather than a row's width budget.
|
|
652
|
+
|
|
653
|
+
`.steps` holds what a step *is*, under its own name. `.steps-horizontal` and
|
|
654
|
+
`.steps-vertical` hold what is genuinely per-orientation. Nothing excludes
|
|
655
|
+
anything, nothing undoes anything, and the rules read as what they are:
|
|
656
|
+
|
|
657
|
+
```text
|
|
658
|
+
.steps what a step is
|
|
659
|
+
.steps-horizontal a row, plus its container query
|
|
660
|
+
.steps-vertical a column
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
There is no third class, and no third *composition* either. A compact
|
|
664
|
+
progression is the same `.steps-horizontal` with empty items, and a compact
|
|
665
|
+
rail is the same `.steps-vertical` with empty items — which is why neither
|
|
666
|
+
needs a name of its own. See "Why nothing hides a label".
|
|
667
|
+
|
|
668
|
+
The cost is one class on the horizontal markup, where the orientation could be
|
|
669
|
+
implicit. That is the right direction: if vertical is an explicit orientation
|
|
670
|
+
rather than a fallback, horizontal being explicit too is the same claim stated
|
|
671
|
+
once more.
|
|
672
|
+
|
|
673
|
+
`.steps` on its own is incomplete markup. That is a real footgun — it renders a
|
|
674
|
+
flex row of unpositioned markers rather than nothing — and it is the price of
|
|
675
|
+
having neither orientation be the default. Documented, not defended.
|
|
676
|
+
|
|
677
|
+
### Why the primitive needs a class of its own
|
|
678
|
+
|
|
679
|
+
Both alternatives cost more, and each in a way that is easy to mistake for
|
|
680
|
+
economy.
|
|
681
|
+
|
|
682
|
+
**Vertical as a modifier** — `.steps` meaning both the component and the
|
|
683
|
+
horizontal layout — makes the column inherit a row and then undo it. Six
|
|
684
|
+
declarations exist for no other reason:
|
|
685
|
+
|
|
686
|
+
| Undone | Because the row sets |
|
|
687
|
+
| --------------------------- | ----------------------------------------------- |
|
|
688
|
+
| `overflow: visible` | `overflow-x: auto` |
|
|
689
|
+
| `overscroll-behavior: auto` | `overscroll-behavior-inline: contain` |
|
|
690
|
+
| `flex: none` | `flex: 1 0 var(--step-min)` |
|
|
691
|
+
| `grid-template-rows: auto` | `grid-template-rows: var(--step-size)` |
|
|
692
|
+
| `min-inline-size: 0` | `min-inline-size: max(--step-size, --step-min)` |
|
|
693
|
+
| `text-align: start` | `text-align: center` |
|
|
694
|
+
|
|
695
|
+
And it forces the inverse on the horizontal side: every rule that must not
|
|
696
|
+
reach a column carries `:not(.steps-vertical)` — the focus-ring rule plus every
|
|
697
|
+
rule in the container query.
|
|
698
|
+
|
|
699
|
+
**Two independent roots** — `.steps` and `.steps-vertical` sharing no class —
|
|
700
|
+
zeroes both of those lists. But then the shared half of the component (the
|
|
701
|
+
counter, the marker, `.step-label`, `.step-complete`, `aria-current="step"`) has
|
|
702
|
+
no name, and has to be spelled `:is(.steps, .steps-vertical)` on twelve rules.
|
|
703
|
+
Twelve rules needing the same two-class prefix is what a shared primitive looks
|
|
704
|
+
like when it has not been given a class of its own.
|
|
705
|
+
|
|
706
|
+
### `justify-items: start` is not a reset
|
|
707
|
+
|
|
708
|
+
It looks like one, sitting in `.steps-vertical > li` next to properties the row
|
|
709
|
+
also sets. It is the column's own alignment, and it is load-bearing: grid items
|
|
710
|
+
blockify, so a label left at the default `normal` stretches across the whole
|
|
711
|
+
`1fr` track — measured, an `a.step-label` goes from 75px to 264px. That is a
|
|
712
|
+
full-width hover target and a full-width focus ring in a column, where the same
|
|
713
|
+
link in a row gets a box that hugs its text.
|
|
714
|
+
|
|
715
|
+
### The connector's terminator
|
|
716
|
+
|
|
717
|
+
The connector's geometry belongs to each composition, so only the question of
|
|
718
|
+
*which* steps get one is shared. One rule states the condition:
|
|
719
|
+
`.steps > li:not(:last-child)::after`. A pseudo-element with no content
|
|
720
|
+
generates no box, so both compositions can address `> li::after` freely and
|
|
721
|
+
neither can grow a line past the final step. Nothing to order, nothing to weigh.
|
|
722
|
+
|
|
723
|
+
The alternative is two rules pulling against each other —
|
|
724
|
+
`> li::after { content: "" }` and `> li:last-child::after { content: none }` —
|
|
725
|
+
which makes "does a line trail the last step" a specificity question, and makes
|
|
726
|
+
a stray `content` in a geometry rule enough to break it.
|
|
727
|
+
|
|
728
|
+
Its row is aligned to `start` so a label that wraps begins level with the top of
|
|
729
|
+
its marker. A single line is shorter than the marker and would float above its
|
|
730
|
+
centre, so the label takes `align-self: center` — which only bites in that case,
|
|
731
|
+
since a label tall enough to fill the row has nothing left to centre in.
|
|
732
|
+
Measured, a single line sits 0.25px off the marker's cap-height centre with it,
|
|
733
|
+
and 5px above without.
|
|
734
|
+
|
|
735
|
+
## Label typography
|
|
736
|
+
|
|
737
|
+
The marker carries the state, so the label reads a notch below it: one size
|
|
738
|
+
down, tight leading, neutral weight, with extra weight only on
|
|
739
|
+
`aria-current="step"`. A completed step gets none — its filled disc already
|
|
740
|
+
reads as strongly as text could.
|
|
741
|
+
|
|
742
|
+
The size is set on the component, never inside a container block. A font-size
|
|
743
|
+
that changed with the representation would resize the text under a reader
|
|
744
|
+
dragging a window, on top of the layout move the threshold already makes.
|
|
745
|
+
|
|
746
|
+
A navigable label keeps its underline, hover and focus ring but takes
|
|
747
|
+
`color: inherit`, because `--link` is a theme hook and a link-coloured label
|
|
748
|
+
would read ahead of the marker states it sits next to.
|