actual-css 0.12.0 → 0.14.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 +102 -0
- package/README.md +7 -8
- package/components.json +124 -12
- package/dist/actual.full.css +1152 -309
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/design-notes/architecture.md +67 -0
- package/docs/design-notes/steps.md +45 -28
- package/docs/navigation.json +2 -0
- package/docs/pages/components/alert.md +12 -2
- package/docs/pages/components/avatar.md +17 -11
- package/docs/pages/components/breadcrumb.md +16 -2
- package/docs/pages/components/button.md +3 -2
- package/docs/pages/components/list.md +15 -16
- package/docs/pages/components/navbar.md +42 -22
- package/docs/pages/components/pagination.md +19 -0
- package/docs/pages/components/segmented.md +83 -0
- package/docs/pages/components/steps.md +28 -22
- package/docs/pages/enhancements/flyout.md +5 -0
- package/docs/pages/enhancements/scrollspy.md +6 -6
- package/docs/pages/enhancements/tabs.md +79 -3
- package/docs/pages/examples/overview.md +3 -1
- package/docs/pages/forms/color-swatches.md +131 -0
- package/docs/pages/forms/combobox.md +2 -2
- package/docs/pages/forms/controls.md +6 -3
- package/docs/pages/forms/floating-field.md +21 -7
- package/docs/pages/forms/form-actions.md +3 -6
- package/docs/pages/forms/joined-controls.md +6 -2
- package/docs/pages/forms/overview.md +1 -0
- package/docs/pages/forms/states-and-sizes.md +15 -15
- package/docs/pages/forms/switch.md +4 -2
- package/docs/pages/foundations/philosophy.md +97 -1
- package/docs/pages/foundations/theming.md +1 -1
- package/docs/pages/foundations/tokens.md +94 -27
- package/docs/pages/guides/design-tools.md +14 -8
- package/docs/pages/guides/integrating-actual-css.md +74 -8
- package/docs/pages/guides/introduction.md +3 -2
- package/docs/pages/guides/modular-import.md +1 -1
- package/docs/pages/guides/progressive-enhancement.md +25 -7
- package/docs/pages/guides/upgrading.md +86 -9
- package/docs/pages/layout/center.md +2 -1
- package/docs/pages/layout/cluster.md +11 -4
- package/docs/pages/layout/grid.md +2 -0
- package/docs/pages/layout/scroll-snap.md +4 -2
- package/docs/pages/layout/sidebar-layout.md +3 -0
- package/docs/pages/layout/switcher.md +2 -0
- package/docs/pages/layout/topbar.md +6 -4
- package/docs/pages/patterns/icon-slot.md +17 -1
- package/docs/pages/utilities/flex.md +1 -1
- package/docs/pages/utilities/sizing-wrapping.md +51 -6
- package/package.json +6 -6
- package/reserved-classes.json +3 -0
- package/src/cli/actual-css.js +4 -4
- package/src/css/components/alert.css +99 -48
- package/src/css/components/app-nav.css +17 -9
- package/src/css/components/avatar.css +2 -18
- package/src/css/components/badge.css +23 -10
- package/src/css/components/breadcrumb.css +74 -8
- package/src/css/components/button.css +17 -2
- package/src/css/components/close.css +6 -3
- package/src/css/components/drawer.css +23 -42
- package/src/css/components/flyout.css +0 -1
- package/src/css/components/index.css +1 -0
- package/src/css/components/join.css +2 -2
- package/src/css/components/list.css +28 -13
- package/src/css/components/modal.css +17 -10
- package/src/css/components/navbar.css +15 -10
- package/src/css/components/pagination.css +5 -0
- package/src/css/components/scrollspy.css +1 -1
- package/src/css/components/segmented.css +186 -0
- package/src/css/components/steps.css +72 -15
- package/src/css/components/tab.css +135 -5
- package/src/css/core/focus.css +11 -6
- package/src/css/core/theme.css +6 -1
- package/src/css/core/tokens.css +24 -6
- package/src/css/core/variants.css +16 -5
- package/src/css/forms/choice-card.css +1 -1
- package/src/css/forms/choice.css +1 -1
- package/src/css/forms/color-swatch.css +159 -0
- package/src/css/forms/control.css +1 -1
- package/src/css/forms/floating-field.css +20 -18
- package/src/css/forms/form-actions.css +14 -8
- package/src/css/forms/index.css +1 -0
- package/src/css/forms/native.css +51 -21
- package/src/css/forms/otp.css +4 -5
- package/src/css/forms/switch.css +6 -3
- package/src/css/forms/validation.css +23 -30
- package/src/css/layout/center.css +2 -0
- package/src/css/layout/cluster.css +14 -0
- package/src/css/layout/grid.css +7 -0
- package/src/css/layout/scroll-snap.css +30 -0
- package/src/css/layout/scroller.css +19 -3
- package/src/css/layout/sidebar-layout.css +3 -0
- package/src/css/layout/switcher.css +3 -0
- package/src/css/layout/topbar.css +10 -0
- package/src/css/utilities/extra.css +3 -3
- package/src/js/focus.js +16 -1
- package/src/js/full.js +4 -0
- package/src/js/inline-visible.js +54 -0
- package/src/js/parsed.js +21 -0
- package/src/js/surface.js +10 -0
- package/src/js/tab.js +33 -7
- package/src/tooling/color.js +14 -1
- package/src/tooling/css-bundle.js +0 -5
- package/src/tooling/design-components.js +9 -5
- package/src/tooling/design-export.js +9 -1
- package/src/tooling/design-tokens.js +207 -76
- package/src/tooling/theme-contrast.js +182 -45
|
@@ -12,8 +12,18 @@
|
|
|
12
12
|
* author class can then adjust a vertical strip without restating the
|
|
13
13
|
* attribute to reach its specificity.
|
|
14
14
|
*
|
|
15
|
+
* A horizontal strip is one axis of navigation, so it never wraps: a second
|
|
16
|
+
* line no longer matches the Left/Right model and reads as a nested level.
|
|
17
|
+
* When the tabs do not fit, the strip scrolls without a scrollbar; focus
|
|
18
|
+
* scrolls the active tab into view.
|
|
19
|
+
*
|
|
15
20
|
* Public hooks:
|
|
21
|
+
* --tab-flex
|
|
16
22
|
* --tab-gap
|
|
23
|
+
*
|
|
24
|
+
* Internal:
|
|
25
|
+
* --tab-edge
|
|
26
|
+
* --tab-fade-dir (read inside @keyframes, so it resolves per element)
|
|
17
27
|
*/
|
|
18
28
|
/* Intent boundary — inherited intents do not tint this component.
|
|
19
29
|
Local intent classes still win because :where() has zero specificity. */
|
|
@@ -24,27 +34,129 @@
|
|
|
24
34
|
.tabs {
|
|
25
35
|
display: flex;
|
|
26
36
|
flex-direction: row;
|
|
27
|
-
flex-wrap:
|
|
37
|
+
flex-wrap: nowrap;
|
|
28
38
|
gap: var(--space-10);
|
|
29
39
|
margin: 0;
|
|
30
40
|
padding: 0;
|
|
41
|
+
/* Block overflow is hidden, not auto: overflow-x: auto alone computes the
|
|
42
|
+
block axis to auto, so a sub-pixel line box would add a vertical
|
|
43
|
+
scrollbar inside the strip. Nothing paints outside it: the focus outline
|
|
44
|
+
is inset and the indicator sits inside the tab. */
|
|
45
|
+
overflow: auto hidden;
|
|
46
|
+
overscroll-behavior-inline: contain;
|
|
47
|
+
/* why: a visible bar sits on the indicator and the hairline and reads as a
|
|
48
|
+
second underline. The strip has its own scroll controls: selecting the
|
|
49
|
+
clipped tab, or focusing it, brings it into view with the next one
|
|
50
|
+
hinted by scroll-padding; arrow keys and swipes do the same. */
|
|
51
|
+
scrollbar-width: none;
|
|
52
|
+
/* Focus scrolls a tab in with room to spare, hinting at the next one; the
|
|
53
|
+
edge fade below spans the same inset, so a revealed tab stays clear of it. */
|
|
54
|
+
--tab-edge: var(--space-60);
|
|
55
|
+
|
|
56
|
+
scroll-padding-inline: var(--tab-edge);
|
|
31
57
|
border-block-end: var(--border-width) solid var(--border);
|
|
32
58
|
list-style: none;
|
|
33
59
|
}
|
|
34
60
|
|
|
61
|
+
/* The landmark around link tabs is not a scroll container, so as a flex or
|
|
62
|
+
grid item it would keep the strip's full width and push the page into
|
|
63
|
+
scrolling. Letting it shrink hands the overflow back to the strip.
|
|
64
|
+
Without :has() the landmark keeps its width; min-inline-size: 0 on the
|
|
65
|
+
wrapper is the fallback. */
|
|
66
|
+
:where(nav:has(> .tabs)) {
|
|
67
|
+
min-inline-size: 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Items keep their size and the strip scrolls instead. Covers both
|
|
71
|
+
anatomies: .tab directly in a tablist, or <li> in a navigation list.
|
|
72
|
+
--tab-flex: 1 1 0 shares the width equally; an item still never shrinks
|
|
73
|
+
below its label (flex items keep min-content), so a strip that cannot fit
|
|
74
|
+
scrolls as before. */
|
|
75
|
+
.tabs > * {
|
|
76
|
+
flex: var(--tab-flex, none);
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
.tabs:where([aria-orientation="vertical"]) {
|
|
36
80
|
display: grid;
|
|
81
|
+
overflow: visible;
|
|
37
82
|
align-content: start;
|
|
38
83
|
justify-items: stretch;
|
|
39
84
|
border-block-end: 0;
|
|
40
85
|
border-inline-end: var(--border-width) solid var(--border);
|
|
41
86
|
}
|
|
42
87
|
|
|
88
|
+
/* Edge fade: with no scrollbar, the strip fades the edges it can still scroll
|
|
89
|
+
toward. It runs on the strip's own inline timeline, which is inactive on a
|
|
90
|
+
strip that fits or a vertical rail, so those stay unmasked. Keep the
|
|
91
|
+
@supports: without scroll timelines the animation would end at once and pin
|
|
92
|
+
its last mask. It follows scroll, not time, so reduced motion keeps it. */
|
|
93
|
+
@supports (animation-timeline: scroll()) {
|
|
94
|
+
.tabs {
|
|
95
|
+
--tab-fade-dir: to right;
|
|
96
|
+
|
|
97
|
+
animation: actual-tabs-fade linear both;
|
|
98
|
+
animation-timeline: scroll(self inline);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Gradient keywords are physical; :dir() follows the strip's own
|
|
102
|
+
direction, so an LTR island in an RTL page keeps its fade on the right. */
|
|
103
|
+
.tabs:dir(rtl) {
|
|
104
|
+
--tab-fade-dir: to left;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* A strip that loads on a current link tab starts scrolled to it. Horizontal
|
|
109
|
+
only: the strip is then its nearest scroll container, while a vertical
|
|
110
|
+
rail would hand the target to the page. Engines without the property start
|
|
111
|
+
at the first tab, and the fade says there is more. */
|
|
112
|
+
:where(.tabs:not([aria-orientation="vertical"]))
|
|
113
|
+
.tab:is([aria-current="page"], [aria-current="true"]) {
|
|
114
|
+
/* biome-ignore lint/correctness/noUnknownProperty: CSS Scroll Snap 2 property that Biome's list predates; an engine without it drops the declaration. */
|
|
115
|
+
scroll-initial-target: nearest;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Forced colors keep every label at full contrast. */
|
|
119
|
+
@media (forced-colors: active) {
|
|
120
|
+
.tabs {
|
|
121
|
+
animation: none;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* mask-image does not interpolate, so each mask holds until halfway to the
|
|
126
|
+
next keyframe: the start fade appears within the first pixels of scroll and
|
|
127
|
+
the end fade drops at the very end. */
|
|
128
|
+
@keyframes actual-tabs-fade {
|
|
129
|
+
0% {
|
|
130
|
+
mask-image: linear-gradient(
|
|
131
|
+
var(--tab-fade-dir),
|
|
132
|
+
#000 calc(100% - var(--tab-edge)),
|
|
133
|
+
transparent
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
1%,
|
|
138
|
+
99% {
|
|
139
|
+
mask-image: linear-gradient(
|
|
140
|
+
var(--tab-fade-dir),
|
|
141
|
+
transparent,
|
|
142
|
+
#000 var(--tab-edge) calc(100% - var(--tab-edge)),
|
|
143
|
+
transparent
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
100% {
|
|
148
|
+
mask-image: linear-gradient(var(--tab-fade-dir), transparent, #000 var(--tab-edge));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
43
152
|
.tab {
|
|
44
153
|
/* Positioning context for the selected indicator pseudo. */
|
|
45
154
|
position: relative;
|
|
46
155
|
display: inline-flex;
|
|
47
156
|
align-items: center;
|
|
157
|
+
/* A tab wider than its label (--tab-flex) centers it; at natural width this
|
|
158
|
+
changes nothing, and a vertical rail resets it to the start below. */
|
|
159
|
+
justify-content: center;
|
|
48
160
|
gap: var(--tab-gap, 0.375em);
|
|
49
161
|
min-block-size: var(--control-size);
|
|
50
162
|
padding-inline: var(--space-30);
|
|
@@ -58,12 +170,18 @@
|
|
|
58
170
|
the strip. The active state is carried by color and the indicator line. */
|
|
59
171
|
font-weight: var(--font-weight-semibold);
|
|
60
172
|
text-decoration: none;
|
|
173
|
+
/* A label breaking inside a one-line strip is the same defect as the strip
|
|
174
|
+
wrapping. */
|
|
175
|
+
white-space: nowrap;
|
|
61
176
|
cursor: pointer;
|
|
62
177
|
}
|
|
63
178
|
|
|
179
|
+
/* A vertical strip has no inline axis to protect: long labels wrap within the
|
|
180
|
+
rail instead of overflowing it. */
|
|
64
181
|
.tabs:where([aria-orientation="vertical"]) .tab {
|
|
65
182
|
justify-content: flex-start;
|
|
66
183
|
padding-inline-end: var(--space-40);
|
|
184
|
+
white-space: normal;
|
|
67
185
|
}
|
|
68
186
|
|
|
69
187
|
.tab:is(:disabled, [aria-disabled="true"]) {
|
|
@@ -94,9 +212,14 @@
|
|
|
94
212
|
border-end-end-radius: 0;
|
|
95
213
|
}
|
|
96
214
|
|
|
215
|
+
/* A link tab is current on aria-current="page". "true" is for the one place
|
|
216
|
+
where no page token fits: a navigation flyout trigger whose panel holds the
|
|
217
|
+
current page. State selectors name their values, never the attribute's
|
|
218
|
+
presence, so "false" (what React and Vue serialize for a false binding)
|
|
219
|
+
stays inert. */
|
|
97
220
|
.tab[aria-selected="true"],
|
|
98
|
-
.tab[aria-current="page"] {
|
|
99
|
-
color: var(--intent, var(--state-selected));
|
|
221
|
+
.tab:is([aria-current="page"], [aria-current="true"]) {
|
|
222
|
+
color: var(--intent, var(--state-selected-text, var(--state-selected)));
|
|
100
223
|
}
|
|
101
224
|
|
|
102
225
|
/* The selected indicator lives on a positioned pseudo so that only the active
|
|
@@ -104,7 +227,7 @@
|
|
|
104
227
|
forced colors and erase the distinction. currentColor follows the palette the
|
|
105
228
|
UA forces, so no forced-colors block is needed. */
|
|
106
229
|
.tab[aria-selected="true"]::after,
|
|
107
|
-
.tab[aria-current="page"]::after {
|
|
230
|
+
.tab:is([aria-current="page"], [aria-current="true"])::after {
|
|
108
231
|
content: "";
|
|
109
232
|
position: absolute;
|
|
110
233
|
inset-inline: 0;
|
|
@@ -113,9 +236,16 @@
|
|
|
113
236
|
}
|
|
114
237
|
|
|
115
238
|
.tabs:where([aria-orientation="vertical"]) .tab[aria-selected="true"]::after,
|
|
116
|
-
.tabs:where([aria-orientation="vertical"])
|
|
239
|
+
.tabs:where([aria-orientation="vertical"])
|
|
240
|
+
.tab:is([aria-current="page"], [aria-current="true"])::after {
|
|
117
241
|
inset-block: 0;
|
|
118
242
|
inset-inline: auto 0;
|
|
119
243
|
border-block-end: 0;
|
|
120
244
|
border-inline-end: 2px solid currentColor;
|
|
121
245
|
}
|
|
246
|
+
|
|
247
|
+
/* A shared width (--tab-flex) lands on the <li> of a link tab; the tab fills
|
|
248
|
+
it, so the indicator and the hit area span the whole share. */
|
|
249
|
+
.tabs > li > .tab {
|
|
250
|
+
inline-size: 100%;
|
|
251
|
+
}
|
package/src/css/core/focus.css
CHANGED
|
@@ -18,17 +18,22 @@
|
|
|
18
18
|
--surface-solid, so one token reads on the page, on a solid band and on
|
|
19
19
|
any nested surface.
|
|
20
20
|
|
|
21
|
+
A .segment is an action but takes the inset ring: its track's padding is
|
|
22
|
+
narrower than an offset ring, which would cover the neighbouring segment.
|
|
23
|
+
|
|
21
24
|
Documented exception: an invalid field focuses in --form-invalid-border
|
|
22
25
|
(danger), so the error stays visible at the point of correction. The border
|
|
23
26
|
carries the same hook; report:theme-contrast measures that line at 3:1
|
|
24
|
-
against the field's background alongside --focus.
|
|
27
|
+
against the field's background alongside --focus.
|
|
28
|
+
|
|
29
|
+
Public hooks:
|
|
30
|
+
--focus-outline (fallback-only: replaces the whole generic line)
|
|
31
|
+
*/
|
|
25
32
|
:focus {
|
|
26
|
-
outline: var(--focus-outline);
|
|
33
|
+
outline: var(--focus-outline, var(--focus-ring-width) solid var(--focus-outline-color));
|
|
27
34
|
outline-offset: var(--focus-outline-offset);
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
:
|
|
32
|
-
outline: none;
|
|
33
|
-
}
|
|
37
|
+
:focus:not(:focus-visible) {
|
|
38
|
+
outline: none;
|
|
34
39
|
}
|
package/src/css/core/theme.css
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
|
|
9
9
|
* other tokens; hard-coded overrides on a custom theme will break the intent.
|
|
10
10
|
* Exception: --surface-opaque and --surface-solid-fg are public; a theme with
|
|
11
|
-
* translucent surfaces overrides them with opaque values.
|
|
11
|
+
* translucent surfaces overrides them with opaque values. --border-control is
|
|
12
|
+
* public too: the resting edge of a neutral control, apart from structural
|
|
13
|
+
* separators.
|
|
12
14
|
* Internal: --hgroup-gap.
|
|
13
15
|
*/
|
|
14
16
|
:root {
|
|
@@ -110,6 +112,9 @@
|
|
|
110
112
|
never a fill. --surface-solid-fg is the ink on --surface-solid. */
|
|
111
113
|
--surface-opaque: var(--surface);
|
|
112
114
|
--surface-solid-fg: var(--surface);
|
|
115
|
+
/* The edge that tells a control apart from the surface, where --border only
|
|
116
|
+
separates surfaces: fields, choices, switch, OTP cells, choice cards. */
|
|
117
|
+
--border-control: var(--border);
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
/* Soft foreground hooks — the soft ink is the intent rebated toward the local
|
package/src/css/core/tokens.css
CHANGED
|
@@ -81,9 +81,11 @@
|
|
|
81
81
|
without a component ring (links, summary, plain items). Components draw
|
|
82
82
|
the themed ring instead: --focus-ring-width solid --focus. See focus.css
|
|
83
83
|
for the two contracts. Both share --focus-ring-width: they differ in
|
|
84
|
-
color only.
|
|
84
|
+
color only. No --focus-outline alias here: declared on :root it would be
|
|
85
|
+
inherited already computed, so a theme island or a region setting
|
|
86
|
+
--focus-ring-width would not reach it. focus.css composes the line on the
|
|
87
|
+
focused element instead. */
|
|
85
88
|
--focus-outline-color: currentColor;
|
|
86
|
-
--focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
|
|
87
89
|
--focus-outline-offset: 2px;
|
|
88
90
|
--focus-ring-width: 2px;
|
|
89
91
|
|
|
@@ -160,9 +162,20 @@
|
|
|
160
162
|
[data-theme] {
|
|
161
163
|
/* State tokens — semantic colors for selected/pressed/current and disabled.
|
|
162
164
|
Components consume these instead of hard-wiring --primary/--text-subtle
|
|
163
|
-
so forced-colors can override them in one place.
|
|
165
|
+
so forced-colors can override them in one place. --state-selected is a
|
|
166
|
+
selected fill or accent, --state-selected-fg the ink on that fill.
|
|
167
|
+
--state-selected-text is selected ink on a regular surface (current nav
|
|
168
|
+
link, selected tab, current step number). It is reset to the
|
|
169
|
+
guaranteed-invalid value on every theme boundary, so a consumer reads its
|
|
170
|
+
local --state-selected through the fallback var(--state-selected-text,
|
|
171
|
+
var(--state-selected)): a theme that resolves it to var(--link) bakes that
|
|
172
|
+
reference where it is declared, so a nested island that does not set it
|
|
173
|
+
inherits an already-resolved value the local fallback can never reach. A
|
|
174
|
+
theme sets it when one color cannot be both a fill under its -fg and text
|
|
175
|
+
on the page, typically in a dark scheme. */
|
|
164
176
|
--state-selected: var(--primary);
|
|
165
177
|
--state-selected-fg: var(--primary-fg);
|
|
178
|
+
--state-selected-text: initial;
|
|
166
179
|
--state-disabled: var(--text-subtle);
|
|
167
180
|
|
|
168
181
|
/* Status dot ring — cuts the dot out of what it overlaps. */
|
|
@@ -171,10 +184,15 @@
|
|
|
171
184
|
|
|
172
185
|
:root {
|
|
173
186
|
/* Variant tuning — themes may adjust generated soft surfaces without
|
|
174
|
-
replacing the component rules.
|
|
187
|
+
replacing the component rules. --soft-bg-mix is the share of surface in a
|
|
188
|
+
soft fill: an opaque mix on the shared .soft, an opacity (100% minus it)
|
|
189
|
+
on the translucent badge, alert and soft-button tint. --soft-hover-alpha
|
|
190
|
+
is the extra intent share on hover. --soft-border-mix has no default: it
|
|
191
|
+
is a fallback-only hook whose absence lets each recipe keep its own rim
|
|
192
|
+
(a tinted rim on .soft, none on the tint), and a theme that sets it gives
|
|
193
|
+
both a rim of that weight. */
|
|
175
194
|
--soft-bg-mix: 88%;
|
|
176
|
-
--soft-
|
|
177
|
-
--soft-hover-alpha: 12%;
|
|
195
|
+
--soft-hover-alpha: 6%;
|
|
178
196
|
/* Soft foreground: the share of raw intent in soft text. 100% keeps the
|
|
179
197
|
pure intent color; lower values rebate it toward --text, which vivid or
|
|
180
198
|
light intent palettes need to stay legible on their own soft surface.
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
*
|
|
35
35
|
* Intent list source of truth: intents.css. Components consume --intent* tokens
|
|
36
36
|
* instead of re-listing intent classes.
|
|
37
|
+
*
|
|
38
|
+
* Public hooks:
|
|
39
|
+
* --soft-border-mix (fallback-only: a soft rim of that weight on every recipe)
|
|
37
40
|
*/
|
|
38
41
|
.solid {
|
|
39
42
|
--ui-bg: var(--intent, var(--neutral));
|
|
@@ -97,7 +100,7 @@
|
|
|
97
100
|
|
|
98
101
|
/* Shared control scale. Wrappers are listed when their descendants form one
|
|
99
102
|
component (field, choice-card, floating-field, input-icon, join, pagination,
|
|
100
|
-
tabs, flyout). A local size declaration beats inherited density because it
|
|
103
|
+
tabs, segmented, flyout). A local size declaration beats inherited density because it
|
|
101
104
|
is declared on the sized component itself. Guarded by check:sync
|
|
102
105
|
(size-scale-participants): .sm and .lg must list the same components. */
|
|
103
106
|
:where(
|
|
@@ -110,6 +113,7 @@
|
|
|
110
113
|
.field,
|
|
111
114
|
.choice,
|
|
112
115
|
.choice-card,
|
|
116
|
+
.color-swatch,
|
|
113
117
|
.input-icon,
|
|
114
118
|
.floating-field,
|
|
115
119
|
.otp,
|
|
@@ -117,6 +121,7 @@
|
|
|
117
121
|
.pagination,
|
|
118
122
|
.tabs,
|
|
119
123
|
.tab,
|
|
124
|
+
.segmented,
|
|
120
125
|
.flyout
|
|
121
126
|
).sm {
|
|
122
127
|
--control-size: var(--control-size-sm);
|
|
@@ -137,6 +142,7 @@
|
|
|
137
142
|
.field,
|
|
138
143
|
.choice,
|
|
139
144
|
.choice-card,
|
|
145
|
+
.color-swatch,
|
|
140
146
|
.input-icon,
|
|
141
147
|
.floating-field,
|
|
142
148
|
.otp,
|
|
@@ -144,6 +150,7 @@
|
|
|
144
150
|
.pagination,
|
|
145
151
|
.tabs,
|
|
146
152
|
.tab,
|
|
153
|
+
.segmented,
|
|
147
154
|
.flyout
|
|
148
155
|
).lg {
|
|
149
156
|
--control-size: var(--control-size-lg);
|
|
@@ -155,21 +162,25 @@
|
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
/* Modern color enhancement. Baseline variant values above remain usable
|
|
158
|
-
everywhere. Interactive states live in the components that consume them.
|
|
165
|
+
everywhere. Interactive states live in the components that consume them.
|
|
166
|
+
The shared .soft is opaque: it paints zones (.card, .navbar, .app-nav,
|
|
167
|
+
.chat-bubble) an author may lay over an image, where a translucent fill
|
|
168
|
+
would hand the text's contrast to whatever is behind it. The badge, alert
|
|
169
|
+
and soft button own a translucent tint instead (soft-tint). */
|
|
159
170
|
@supports (color: color-mix(in oklch, red, white)) {
|
|
160
171
|
.soft {
|
|
161
|
-
/* @sync soft-recipe */
|
|
162
172
|
--ui-bg: color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
|
|
163
173
|
--ui-border: color-mix(
|
|
164
174
|
in oklab,
|
|
165
|
-
var(--surface) var(--soft-border-mix),
|
|
175
|
+
var(--surface) var(--soft-border-mix, 65%),
|
|
166
176
|
var(--intent, var(--neutral))
|
|
167
177
|
);
|
|
178
|
+
/* @sync soft-ink */
|
|
168
179
|
--ui-fg: var(
|
|
169
180
|
--intent-soft-fg,
|
|
170
181
|
color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
|
|
171
182
|
);
|
|
172
|
-
/* @sync soft-
|
|
183
|
+
/* @sync soft-ink */
|
|
173
184
|
}
|
|
174
185
|
}
|
|
175
186
|
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
--choice-card-pad: var(--density-space, var(--space-40));
|
|
39
39
|
--choice-card-gap: var(--space-30);
|
|
40
40
|
--choice-card-radius: var(--radius);
|
|
41
|
-
--choice-card-border: var(--border);
|
|
41
|
+
--choice-card-border: var(--border-control);
|
|
42
42
|
--choice-card-bg: var(--surface);
|
|
43
43
|
--choice-card-check-size: 1.25em;
|
|
44
44
|
|
package/src/css/forms/choice.css
CHANGED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Color swatch — a color chosen from a palette the application defines.
|
|
3
|
+
*
|
|
4
|
+
* Docs: docs/pages/forms/color-swatches.md
|
|
5
|
+
*
|
|
6
|
+
* A <label class="color-swatch"> wraps a native radio (one color) or checkbox
|
|
7
|
+
* (several) and paints --color-swatch-value. The same class on a <button> is
|
|
8
|
+
* the trigger of a palette flyout. The application owns the colors; Actual
|
|
9
|
+
* owns the shape, the selection mark and the focus ring.
|
|
10
|
+
*
|
|
11
|
+
* Two sizes: the visible swatch (::before) is the height of a .switch track, so
|
|
12
|
+
* a swatch and a switch in neighbouring rows match; the element is the hit
|
|
13
|
+
* area around it. The hit area reaches half the inherited --gap past the
|
|
14
|
+
* swatch, up to --control-size, and overflows its layout box: neighbours in a
|
|
15
|
+
* .cluster or .grid meet in the middle of the gap instead of overlapping, and
|
|
16
|
+
* rows sit one gap apart like columns. Edge, selection ring and focus ring all
|
|
17
|
+
* belong to the visible swatch.
|
|
18
|
+
*
|
|
19
|
+
* The color is arbitrary, so nothing drawn on it may assume a contrast: the
|
|
20
|
+
* selected mark is a double ring (--text outside, --surface-opaque inside) that
|
|
21
|
+
* reads on any fill, and a hairline edge keeps a near-white color apart from
|
|
22
|
+
* the page. ::before opts out of forced colors to keep the color, so it takes
|
|
23
|
+
* system colors itself there.
|
|
24
|
+
*
|
|
25
|
+
* Without :has() the native radio stays visible in the swatch and carries the
|
|
26
|
+
* state; the ring needs :has() to mirror it.
|
|
27
|
+
*
|
|
28
|
+
* Public hooks:
|
|
29
|
+
* --color-swatch-value (fallback-only: the color, set per swatch)
|
|
30
|
+
* --color-swatch-size
|
|
31
|
+
* --color-swatch-radius
|
|
32
|
+
*/
|
|
33
|
+
.color-swatch {
|
|
34
|
+
/* The .switch track height (switch.css); a browser test holds them equal. */
|
|
35
|
+
--color-swatch-size: calc(var(--choice-control-size) + 0.125em);
|
|
36
|
+
--color-swatch-radius: var(--radius-full);
|
|
37
|
+
/* Internal: what the hit area adds to the swatch, half on each side. One gap
|
|
38
|
+
at most, so two neighbours never claim the same point — a wider reach let
|
|
39
|
+
the next swatch take a click on this one's center. */
|
|
40
|
+
--color-swatch-reach: clamp(0px, var(--control-size) - var(--color-swatch-size), var(--gap));
|
|
41
|
+
|
|
42
|
+
position: relative;
|
|
43
|
+
display: inline-grid;
|
|
44
|
+
place-items: center;
|
|
45
|
+
flex: none;
|
|
46
|
+
inline-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
|
|
47
|
+
block-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
|
|
48
|
+
/* Lay out at the visible size: gaps are measured from the swatch, not from
|
|
49
|
+
its invisible reach. */
|
|
50
|
+
margin: calc(var(--color-swatch-reach) / -2);
|
|
51
|
+
padding: 0;
|
|
52
|
+
border: 0;
|
|
53
|
+
background: transparent;
|
|
54
|
+
color: inherit;
|
|
55
|
+
/* Consumed so .sm/.lg reach the em-sized swatch, as on .choice-card. */
|
|
56
|
+
font: inherit;
|
|
57
|
+
font-size: var(--control-font-size);
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.color-swatch::before {
|
|
62
|
+
content: "";
|
|
63
|
+
grid-area: 1 / 1;
|
|
64
|
+
inline-size: var(--color-swatch-size);
|
|
65
|
+
block-size: var(--color-swatch-size);
|
|
66
|
+
box-sizing: border-box;
|
|
67
|
+
border: var(--border-width) solid var(--border-control);
|
|
68
|
+
border-radius: var(--color-swatch-radius);
|
|
69
|
+
background: var(--color-swatch-value, transparent) padding-box;
|
|
70
|
+
/* The color is the content, as on .color (native.css). */
|
|
71
|
+
forced-color-adjust: none;
|
|
72
|
+
transition:
|
|
73
|
+
border-color var(--duration),
|
|
74
|
+
box-shadow var(--duration);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Shares the swatch's cell and paints after ::before, so a native radio left
|
|
78
|
+
visible sits on the color. */
|
|
79
|
+
.color-swatch > input {
|
|
80
|
+
grid-area: 1 / 1;
|
|
81
|
+
position: relative;
|
|
82
|
+
margin: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Visually hidden but focusable, submitted and arrow-key navigable (sr-only
|
|
86
|
+
pattern). Guarded: without :has() the swatch cannot mirror :checked, so the
|
|
87
|
+
visible native radio is the fallback. */
|
|
88
|
+
@supports selector(:has(*)) {
|
|
89
|
+
.color-swatch > input {
|
|
90
|
+
position: absolute;
|
|
91
|
+
inline-size: 1px;
|
|
92
|
+
block-size: 1px;
|
|
93
|
+
padding: 0;
|
|
94
|
+
margin: -1px;
|
|
95
|
+
overflow: hidden;
|
|
96
|
+
clip: rect(0, 0, 0, 0);
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
border: 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.color-swatch:hover::before {
|
|
103
|
+
border-color: var(--text-subtle);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.color-swatch:has(> input:checked)::before {
|
|
107
|
+
box-shadow:
|
|
108
|
+
inset 0 0 0 2px var(--text),
|
|
109
|
+
inset 0 0 0 4px var(--surface-opaque);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* The ring goes around the visible swatch, not the larger hit area. */
|
|
113
|
+
.color-swatch:focus-visible {
|
|
114
|
+
outline: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.color-swatch:focus-visible::before {
|
|
118
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
119
|
+
outline-offset: var(--focus-outline-offset);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.color-swatch:has(> input:focus-visible)::before {
|
|
123
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
124
|
+
outline-offset: var(--focus-outline-offset);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.color-swatch:disabled {
|
|
128
|
+
opacity: var(--disabled-opacity);
|
|
129
|
+
cursor: not-allowed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.color-swatch:has(> input:disabled) {
|
|
133
|
+
opacity: var(--disabled-opacity);
|
|
134
|
+
cursor: not-allowed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* A disabled swatch keeps its color: --disabled-opacity is 1 here
|
|
138
|
+
(tokens.css), and the edge carries the state. */
|
|
139
|
+
@media (forced-colors: active) {
|
|
140
|
+
.color-swatch::before {
|
|
141
|
+
--focus: CanvasText;
|
|
142
|
+
|
|
143
|
+
border-color: CanvasText;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.color-swatch:has(> input:checked)::before {
|
|
147
|
+
box-shadow:
|
|
148
|
+
inset 0 0 0 2px CanvasText,
|
|
149
|
+
inset 0 0 0 4px Canvas;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.color-swatch:disabled::before {
|
|
153
|
+
border-color: GrayText;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.color-swatch:has(> input:disabled)::before {
|
|
157
|
+
border-color: GrayText;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
* messages stay in the surrounding .field, not inside the cell, so the
|
|
10
10
|
* resting label can center on the control alone.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* The cell reads its control's state through :has(), so the label may come
|
|
13
|
+
* before or after the control (a form theme rendering label then widget needs
|
|
14
|
+
* no reordering). Injected sibling nodes are tolerated (password managers,
|
|
15
|
+
* extension wrappers) as long as the control stays a direct child; the cell
|
|
16
|
+
* contract stays a single control plus one label, since a second control
|
|
17
|
+
* would float the label on either one's state.
|
|
18
|
+
* Every .input/.textarea needs
|
|
16
19
|
* placeholder=" " to expose a reliable empty state — without it the label
|
|
17
20
|
* only reacts to focus.
|
|
18
21
|
*
|
|
@@ -62,23 +65,22 @@
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
/* Single-line controls rest in the middle until they receive a value. */
|
|
65
|
-
.floating-field
|
|
68
|
+
.floating-field:has(> :is(.input, .select)) > .field-label {
|
|
66
69
|
inset-block-start: 50%;
|
|
67
70
|
transform: translateY(-50%);
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/* An empty textarea label follows the control's normal first-line position;
|
|
71
74
|
the control itself keeps the extra headroom reserved for its floated state. */
|
|
72
|
-
.floating-field
|
|
75
|
+
.floating-field:has(> .textarea) > .field-label {
|
|
73
76
|
inset-block-start: calc(1em + var(--border-width));
|
|
74
77
|
transform: none;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/* :autofill covers prefilled values before interaction, when the browser may
|
|
78
81
|
not have propagated the value to the placeholder state yet. */
|
|
79
|
-
.floating-field
|
|
80
|
-
|
|
81
|
-
~ .field-label {
|
|
82
|
+
.floating-field:has(> :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown)))
|
|
83
|
+
> .field-label {
|
|
82
84
|
inset-block-start: var(--floating-label-top);
|
|
83
85
|
transform: none;
|
|
84
86
|
font-size: var(--font-size-xs);
|
|
@@ -88,16 +90,16 @@
|
|
|
88
90
|
/* Controls without a dependable :placeholder-shown empty state stay floated:
|
|
89
91
|
select always, and date/time inputs whose placeholder rendering is
|
|
90
92
|
inconsistent across engines (Firefox shows none). */
|
|
91
|
-
.floating-field
|
|
93
|
+
.floating-field:has(
|
|
92
94
|
> :is(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
.select,
|
|
96
|
+
.input[type="date"],
|
|
97
|
+
.input[type="time"],
|
|
98
|
+
.input[type="datetime-local"],
|
|
99
|
+
.input[type="month"],
|
|
100
|
+
.input[type="week"]
|
|
101
|
+
)
|
|
102
|
+
) > .field-label {
|
|
101
103
|
inset-block-start: var(--floating-label-top);
|
|
102
104
|
transform: none;
|
|
103
105
|
font-size: var(--font-size-xs);
|