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
|
@@ -21,15 +21,12 @@
|
|
|
21
21
|
* --form-actions-sticky-padding (owned by .form-actions.sticky)
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
/* The policy
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
forms load after layout in the official bundle (tests/css-audit.test.js
|
|
28
|
-
guards the order); utilities load later and keep the author's last word. On
|
|
29
|
-
this row --cluster-justify/--cluster-align have no effect. */
|
|
24
|
+
/* The role relays its policy into the row's cluster hooks. Cluster hooks stop
|
|
25
|
+
at every nested .cluster, so the relay cannot leak, and this class beats the
|
|
26
|
+
cluster's zero-specificity reset in any import order. */
|
|
30
27
|
.form-actions {
|
|
31
|
-
align
|
|
32
|
-
justify
|
|
28
|
+
--cluster-align: var(--form-actions-align, center);
|
|
29
|
+
--cluster-justify: var(--form-actions-justify, flex-start);
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
/* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
|
|
@@ -60,3 +57,12 @@
|
|
|
60
57
|
background: color-mix(in oklch, var(--surface) 88%, transparent);
|
|
61
58
|
}
|
|
62
59
|
}
|
|
60
|
+
|
|
61
|
+
/* Same reduce override as .topbar (src/css/layout/topbar.css). */
|
|
62
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
63
|
+
.form-actions.sticky {
|
|
64
|
+
background: var(--surface);
|
|
65
|
+
-webkit-backdrop-filter: none;
|
|
66
|
+
backdrop-filter: none;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/css/forms/index.css
CHANGED
package/src/css/forms/native.css
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Range has its own custom-styled file (range.css) so the focus ring can
|
|
9
9
|
* target the thumb pseudo-element rather than the rectangular CSS box.
|
|
10
|
+
*
|
|
11
|
+
* Public hooks:
|
|
12
|
+
* --color-size
|
|
10
13
|
*/
|
|
11
14
|
.file {
|
|
12
15
|
color: var(--text-muted);
|
|
@@ -25,7 +28,7 @@
|
|
|
25
28
|
calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
|
|
26
29
|
);
|
|
27
30
|
padding-inline: var(--space-20);
|
|
28
|
-
border: var(--border-width) solid var(--border);
|
|
31
|
+
border: var(--border-width) solid var(--border-control);
|
|
29
32
|
border-radius: var(--radius-sm);
|
|
30
33
|
background: var(--surface-subtle);
|
|
31
34
|
color: var(--text);
|
|
@@ -52,16 +55,24 @@
|
|
|
52
55
|
outline-offset: var(--focus-outline-offset);
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
/* Native color picker — keep the browser's dialog and selected-color UI.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
/* Native color picker — keep the browser's dialog and selected-color UI. Same
|
|
59
|
+
geometry as .color-swatch: a --control-size hit area around a round swatch
|
|
60
|
+
the height of a .switch track, so a picker, a palette and a switch compose.
|
|
61
|
+
The input is the hit area; its padding leaves the swatch, which carries the
|
|
62
|
+
edge and the focus ring. Vendor pseudos cannot share a rule. */
|
|
58
63
|
.color {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
--color-size: calc(var(--choice-control-size) + 0.125em);
|
|
65
|
+
|
|
66
|
+
inline-size: max(var(--control-size), var(--color-size));
|
|
67
|
+
block-size: max(var(--control-size), var(--color-size));
|
|
68
|
+
padding: calc((max(var(--control-size), var(--color-size)) - var(--color-size)) / 2);
|
|
69
|
+
/* Lay out at the visible width, as .color-swatch does: the hit area
|
|
70
|
+
overflows on both sides so a label gap is measured from the swatch. */
|
|
71
|
+
margin: 0 calc((var(--color-size) - max(var(--control-size), var(--color-size))) / 2);
|
|
72
|
+
border: 0;
|
|
73
|
+
background: transparent;
|
|
74
|
+
/* Consumed so .sm/.lg reach the em-sized swatch. */
|
|
75
|
+
font-size: var(--control-font-size);
|
|
65
76
|
cursor: pointer;
|
|
66
77
|
}
|
|
67
78
|
|
|
@@ -70,13 +81,21 @@
|
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
.color::-webkit-color-swatch {
|
|
73
|
-
border:
|
|
74
|
-
border-radius:
|
|
84
|
+
border: var(--border-width) solid var(--border-control);
|
|
85
|
+
border-radius: var(--radius-full);
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
.color::-moz-color-swatch {
|
|
78
|
-
border:
|
|
79
|
-
border-radius:
|
|
89
|
+
border: var(--border-width) solid var(--border-control);
|
|
90
|
+
border-radius: var(--radius-full);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.color:hover::-webkit-color-swatch {
|
|
94
|
+
border-color: var(--text-subtle);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.color:hover::-moz-color-swatch {
|
|
98
|
+
border-color: var(--text-subtle);
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
.color:disabled {
|
|
@@ -84,24 +103,35 @@
|
|
|
84
103
|
opacity: var(--disabled-opacity);
|
|
85
104
|
}
|
|
86
105
|
|
|
106
|
+
/* The ring goes around the visible swatch, not the larger hit area. */
|
|
87
107
|
.color:focus-visible {
|
|
108
|
+
outline: none;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.color:focus-visible::-webkit-color-swatch {
|
|
88
112
|
outline: var(--focus-ring-width) solid var(--focus);
|
|
89
113
|
outline-offset: var(--focus-outline-offset);
|
|
90
114
|
}
|
|
91
115
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
116
|
+
.color:focus-visible::-moz-color-swatch {
|
|
117
|
+
outline: var(--focus-ring-width) solid var(--focus);
|
|
118
|
+
outline-offset: var(--focus-outline-offset);
|
|
119
|
+
}
|
|
97
120
|
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
@media (forced-colors: active) {
|
|
122
|
+
/* The swatch color is content, so the swatch opts out of forcing and takes
|
|
123
|
+
system colors for its edge and ring itself. */
|
|
100
124
|
.color::-webkit-color-swatch {
|
|
125
|
+
--focus: CanvasText;
|
|
126
|
+
|
|
101
127
|
forced-color-adjust: none;
|
|
128
|
+
border-color: CanvasText;
|
|
102
129
|
}
|
|
103
130
|
|
|
104
131
|
.color::-moz-color-swatch {
|
|
132
|
+
--focus: CanvasText;
|
|
133
|
+
|
|
105
134
|
forced-color-adjust: none;
|
|
135
|
+
border-color: CanvasText;
|
|
106
136
|
}
|
|
107
137
|
}
|
package/src/css/forms/otp.css
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
--otp-cell-size: var(--control-size);
|
|
22
22
|
--otp-gap: var(--space-10);
|
|
23
23
|
--otp-font-size: 1.5em;
|
|
24
|
-
--otp-cell-border: var(--border);
|
|
24
|
+
--otp-cell-border: var(--border-control);
|
|
25
25
|
--otp-cell-bg: var(--surface);
|
|
26
26
|
--otp-caret-space: calc((var(--otp-cell-size) - 1ch) / 2 + var(--otp-gap) + 1px);
|
|
27
27
|
|
|
@@ -107,10 +107,9 @@
|
|
|
107
107
|
--otp-cell-border: var(--danger);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
110
|
+
/* biome-ignore lint/style/noDescendingSpecificity: all invalid routes write the same border, so their order cannot change the state. Keep :user-invalid separate so unsupported engines retain the other routes. */
|
|
111
|
+
.needs-validation .otp > input:user-invalid ~ span {
|
|
112
|
+
--otp-cell-border: var(--danger);
|
|
114
113
|
}
|
|
115
114
|
|
|
116
115
|
.otp > input:disabled {
|
package/src/css/forms/switch.css
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* --switch-block-size
|
|
15
15
|
* --switch-knob-margin
|
|
16
16
|
* --switch-knob-size
|
|
17
|
+
* --switch-knob-inline-size
|
|
17
18
|
* --switch-travel
|
|
18
19
|
*
|
|
19
20
|
* The track is sized from --choice-control-size (tokens.css), not
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
|
|
30
31
|
.switch {
|
|
31
32
|
--switch-bg: var(--border);
|
|
32
|
-
--switch-border: var(--border);
|
|
33
|
+
--switch-border: var(--border-control);
|
|
33
34
|
--switch-knob: var(--surface-raised);
|
|
34
35
|
--switch-block-size: calc(var(--choice-control-size) + 0.125em);
|
|
35
36
|
--switch-width: calc(var(--switch-block-size) * 1.8);
|
|
@@ -47,9 +48,11 @@
|
|
|
47
48
|
2 *
|
|
48
49
|
var(--switch-knob-margin)
|
|
49
50
|
);
|
|
51
|
+
/* A wider knob is a capsule; the travel follows its width. */
|
|
52
|
+
--switch-knob-inline-size: var(--switch-knob-size);
|
|
50
53
|
--switch-travel: calc(
|
|
51
54
|
var(--switch-width) -
|
|
52
|
-
var(--switch-knob-size) -
|
|
55
|
+
var(--switch-knob-inline-size) -
|
|
53
56
|
2 *
|
|
54
57
|
var(--border-width) -
|
|
55
58
|
2 *
|
|
@@ -85,7 +88,7 @@
|
|
|
85
88
|
position: absolute;
|
|
86
89
|
inset-block-start: var(--switch-knob-margin);
|
|
87
90
|
inset-inline-start: var(--switch-knob-margin);
|
|
88
|
-
inline-size: var(--switch-knob-size);
|
|
91
|
+
inline-size: var(--switch-knob-inline-size);
|
|
89
92
|
block-size: var(--switch-knob-size);
|
|
90
93
|
border-radius: var(--radius-full);
|
|
91
94
|
background: var(--switch-knob);
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Imported by default through forms/index.css. Custom builds may omit this
|
|
7
7
|
* file when an app wants to own validation styling completely.
|
|
8
|
+
* Without :has(), automatic error display and choice-card borders drop;
|
|
9
|
+
* native control states and the explicit .field.danger route stay available.
|
|
8
10
|
*
|
|
9
11
|
* State coverage (which states each component ships) is audited in
|
|
10
12
|
* tests/validation.test.js and summarized in docs/pages/forms/validation.md —
|
|
@@ -26,11 +28,14 @@
|
|
|
26
28
|
display: block;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
.field:has([aria-invalid="true"]) > .field-error,
|
|
32
|
+
.needs-validation.was-validated .field:has(:invalid) > .field-error {
|
|
33
|
+
display: block;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* biome-ignore lint/style/noDescendingSpecificity: every invalid route reveals the error; keep :user-invalid separate so unsupported engines retain the other routes. */
|
|
37
|
+
.needs-validation .field:has(:user-invalid) > .field-error {
|
|
38
|
+
display: block;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
:where(.input, .textarea, .select, .check, .radio, .switch)[aria-invalid="true"],
|
|
@@ -54,39 +59,27 @@
|
|
|
54
59
|
--switch-bg: var(--danger);
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
+
.needs-validation .check:is(:checked, :indeterminate):user-invalid,
|
|
63
|
+
.needs-validation .radio:checked:user-invalid {
|
|
64
|
+
--choice-bg: var(--danger);
|
|
65
|
+
}
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
67
|
+
.needs-validation .switch:checked:user-invalid {
|
|
68
|
+
--switch-bg: var(--danger);
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/* Choice cards hide their native input, so invalid state must surface on the
|
|
69
72
|
card itself. Sets the same --form-invalid-border hook the card's border
|
|
70
73
|
consumes. */
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
--form-invalid-border: var(--danger);
|
|
75
|
-
}
|
|
74
|
+
.choice-card:has([aria-invalid="true"]),
|
|
75
|
+
.needs-validation.was-validated .choice-card:has(:invalid) {
|
|
76
|
+
--form-invalid-border: var(--danger);
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
--form-invalid-border: var(--danger);
|
|
81
|
-
}
|
|
79
|
+
.needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
|
|
80
|
+
--form-invalid-border: var(--danger);
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
display: block;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
.needs-validation .choice-card:has(:user-invalid) {
|
|
90
|
-
--form-invalid-border: var(--danger);
|
|
91
|
-
}
|
|
83
|
+
.needs-validation .choice-card:has(:user-invalid) {
|
|
84
|
+
--form-invalid-border: var(--danger);
|
|
92
85
|
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* --center-size
|
|
8
8
|
* --center-pad
|
|
9
9
|
*/
|
|
10
|
+
/* The hooks inherit on purpose: a site's content width is page policy, so a
|
|
11
|
+
theme sets them once on :root for every .center. */
|
|
10
12
|
.center {
|
|
11
13
|
box-sizing: content-box;
|
|
12
14
|
inline-size: calc(100% - var(--center-pad, 1rem) * 2);
|
|
@@ -10,7 +10,21 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Child contract: direct-child margins are reset, spacing comes from --gap.
|
|
12
12
|
* A ul/ol/menu keeps its semantics but loses list chrome (see below).
|
|
13
|
+
*
|
|
14
|
+
* The hooks describe this row's own geometry, so they stop at each cluster:
|
|
15
|
+
* set them on the cluster itself, or target nested rows with a selector. The
|
|
16
|
+
* gap token is context rhythm and keeps inheriting.
|
|
13
17
|
*/
|
|
18
|
+
|
|
19
|
+
/* Instance boundary: an ancestor's value never reaches a nested cluster.
|
|
20
|
+
Zero specificity, so any class or inline style on the cluster wins without
|
|
21
|
+
depending on import order; `initial` makes the var() fallbacks apply. */
|
|
22
|
+
:where(.cluster) {
|
|
23
|
+
--cluster-align: initial;
|
|
24
|
+
--cluster-justify: initial;
|
|
25
|
+
--cluster-wrap: initial;
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
.cluster {
|
|
15
29
|
display: flex;
|
|
16
30
|
flex-direction: row;
|
package/src/css/layout/grid.css
CHANGED
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* the thresholds were arrived at: docs/design-notes/grid.md.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
/* Instance boundary: a nested grid never takes its parent's track template.
|
|
26
|
+
Zero specificity, so a class or inline style on the grid itself wins. */
|
|
27
|
+
:where(.grid) {
|
|
28
|
+
--grid-columns: initial;
|
|
29
|
+
--grid-min: initial;
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
/* --grid-columns replaces the intrinsic auto-fit template: once set, the grid
|
|
26
33
|
no longer collapses on its own and the author owns narrow-container
|
|
27
34
|
collapse. */
|
|
@@ -13,16 +13,36 @@
|
|
|
13
13
|
* --scroll-snap-align
|
|
14
14
|
* --scroll-snap-item-size
|
|
15
15
|
* --scroll-snap-padding
|
|
16
|
+
*
|
|
17
|
+
* Internal:
|
|
18
|
+
* --scroll-snap-focus-bleed
|
|
16
19
|
*/
|
|
17
20
|
.scroll-snap {
|
|
18
21
|
--scroll-snap-gap: var(--gap);
|
|
19
22
|
--scroll-snap-align: start;
|
|
20
23
|
--scroll-snap-item-size: auto;
|
|
24
|
+
/* Declared here, not as a :root alias: an inherited alias arrives already
|
|
25
|
+
computed, so a --focus-ring-width override on the rail would not reach it. */
|
|
26
|
+
--scroll-snap-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
|
|
21
27
|
|
|
22
28
|
display: flex;
|
|
23
29
|
flex-direction: row;
|
|
24
30
|
gap: var(--scroll-snap-gap);
|
|
31
|
+
/* Both axes clip: `auto` on one computes the other to `auto`, so a ring on
|
|
32
|
+
the top or bottom item would be lost too. Every side reserves the ring's
|
|
33
|
+
own width; the block reserve returns to the flow through a negative
|
|
34
|
+
margin, while the inline reserve stays as the rail's focus gutter.
|
|
35
|
+
Bleeding the inline reserve outward instead would make the border box
|
|
36
|
+
`containing block + 2 * reserve` wide, which at the viewport edge grows
|
|
37
|
+
the page: a rail cannot be flush with arbitrary surrounding content and
|
|
38
|
+
free of page overflow at once. */
|
|
39
|
+
margin-block: calc(var(--scroll-snap-focus-bleed) * -1);
|
|
40
|
+
padding-block: var(--scroll-snap-focus-bleed);
|
|
41
|
+
padding-inline: var(--scroll-snap-focus-bleed);
|
|
25
42
|
overflow-x: auto;
|
|
43
|
+
/* One scroll axis, stated: a fractional content height could otherwise raise
|
|
44
|
+
a phantom vertical scrollbar under one font or zoom step. */
|
|
45
|
+
overflow-y: hidden;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
.scroll-snap > * {
|
|
@@ -32,7 +52,17 @@
|
|
|
32
52
|
@supports (scroll-snap-type: x mandatory) {
|
|
33
53
|
.scroll-snap {
|
|
34
54
|
overscroll-behavior-inline: contain;
|
|
55
|
+
/* Fallback only, never the ring's protector: padding-inline above already
|
|
56
|
+
guarantees paint. A unitless `0` drops the clamp below but keeps this
|
|
57
|
+
declaration, so the worst case is a snapped item flush with the gutter,
|
|
58
|
+
not a clipped ring. */
|
|
35
59
|
scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
|
|
60
|
+
/* A conforming <length> stays at least the focus reserve, so scroll and
|
|
61
|
+
snap stop with the gutter's air. */
|
|
62
|
+
scroll-padding-inline: max(
|
|
63
|
+
var(--scroll-snap-padding, var(--scroll-snap-gap)),
|
|
64
|
+
var(--scroll-snap-focus-bleed)
|
|
65
|
+
);
|
|
36
66
|
/* x then inline: an engine that lacks the logical keyword keeps x.
|
|
37
67
|
proximity is the default, so a short rail cannot trap the pointer. */
|
|
38
68
|
scroll-snap-type: x proximity;
|
|
@@ -13,7 +13,16 @@
|
|
|
13
13
|
* draw their native scrollbar — the intended fallback, not a broken state.
|
|
14
14
|
*
|
|
15
15
|
* Styles the scrollbar only; it does not create the overflow. Pair it with
|
|
16
|
-
* .overflow-auto or a component that already scrolls
|
|
16
|
+
* .overflow-auto or a component that already scrolls (horizontal steps, a
|
|
17
|
+
* table wrap; tabs hide their bar and fade their own edges). No edge fade
|
|
18
|
+
* here: a mask would dictate the strip's scroll-padding and focus geometry for
|
|
19
|
+
* a decorative cue, and an item clipped at the edge already says the strip
|
|
20
|
+
* continues.
|
|
21
|
+
*
|
|
22
|
+
* Not on touch: Chromium trades the overlay scrollbar of a touch screen,
|
|
23
|
+
* hidden at rest, for a classic always-visible one as soon as scrollbar-color
|
|
24
|
+
* or scrollbar-width is set. On a phone that painted a permanent bar under
|
|
25
|
+
* every strip; the native overlay is the better cue there.
|
|
17
26
|
*
|
|
18
27
|
* Public hooks:
|
|
19
28
|
* --scroller-track
|
|
@@ -22,9 +31,16 @@
|
|
|
22
31
|
.scroller {
|
|
23
32
|
--scroller-track: transparent;
|
|
24
33
|
--scroller-thumb: var(--border);
|
|
34
|
+
}
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
/* Any primary pointer but touch: a keyboard-only or pointerless device
|
|
37
|
+
(pointer: none) draws classic scrollbars too. `all and` keeps the negation
|
|
38
|
+
valid in Media Queries 3 parsers. */
|
|
39
|
+
@media not all and (pointer: coarse) {
|
|
40
|
+
.scroller {
|
|
41
|
+
scrollbar-color: var(--scroller-thumb) var(--scroller-track);
|
|
42
|
+
scrollbar-width: thin;
|
|
43
|
+
}
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
/* Opt-in gutter: reserves space so the layout does not shift when
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
* visual, reading and keyboard order all agree — the role rules swap so the
|
|
18
18
|
* aside still takes its preferred width and the main still reclaims the row.
|
|
19
19
|
*/
|
|
20
|
+
/* No instance reset, unlike .cluster and .grid: the regions read the hooks,
|
|
21
|
+
and a layout nested as a region takes its own size from its parent's
|
|
22
|
+
values, so the hooks must reach it. */
|
|
20
23
|
.sidebar-layout {
|
|
21
24
|
display: flex;
|
|
22
25
|
flex-direction: row;
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Child contract: every direct child is a peer; the flex-basis applies to all.
|
|
14
14
|
*/
|
|
15
|
+
/* No instance reset, unlike .cluster and .grid: the children read the hook,
|
|
16
|
+
and a switcher nested as a direct child takes its own flex-basis from its
|
|
17
|
+
parent's value, so the hook must reach it. */
|
|
15
18
|
.switcher {
|
|
16
19
|
display: flex;
|
|
17
20
|
flex-direction: row;
|
|
@@ -45,3 +45,13 @@
|
|
|
45
45
|
-webkit-backdrop-filter: blur(0.75rem);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/* A reduce override, not a no-preference gate: a browser without the feature
|
|
50
|
+
evaluates it false and keeps the frosted bar instead of losing it. */
|
|
51
|
+
@media (prefers-reduced-transparency: reduce) {
|
|
52
|
+
.topbar {
|
|
53
|
+
background: var(--surface);
|
|
54
|
+
backdrop-filter: none;
|
|
55
|
+
-webkit-backdrop-filter: none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -84,9 +84,9 @@
|
|
|
84
84
|
flex-wrap: nowrap;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/* The property is written directly,
|
|
88
|
-
a
|
|
89
|
-
|
|
87
|
+
/* The property is written directly, so the utility works on any flex row, not
|
|
88
|
+
only a .cluster. Utilities load last, so the direct declaration beats
|
|
89
|
+
.cluster at equal specificity. */
|
|
90
90
|
.justify-content-start {
|
|
91
91
|
justify-content: flex-start;
|
|
92
92
|
}
|
package/src/js/focus.js
CHANGED
|
@@ -44,8 +44,23 @@ export function getFocusable(root) {
|
|
|
44
44
|
return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/* Tab reaches a radio group at its checked radio, or at its first one when none
|
|
48
|
+
is checked; the other radios are arrow-key stops only. Entering a panel on
|
|
49
|
+
an unchecked radio put focus on a value the user had not picked. */
|
|
50
|
+
function isRadioTabStop(el) {
|
|
51
|
+
if (el.type !== "radio" || el.checked || !el.name) return true;
|
|
52
|
+
// form.elements also holds controls associated through form="…".
|
|
53
|
+
const candidates = el.form
|
|
54
|
+
? [...el.form.elements]
|
|
55
|
+
: [...el.getRootNode().querySelectorAll('input[type="radio"]')];
|
|
56
|
+
return !candidates.some(
|
|
57
|
+
(radio) =>
|
|
58
|
+
radio.type === "radio" && radio.name === el.name && radio.form === el.form && radio.checked,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
export function focusFirstDescendant(root) {
|
|
48
|
-
const target = getFocusable(root)[0];
|
|
63
|
+
const target = getFocusable(root).filter(isRadioTabStop)[0];
|
|
49
64
|
target?.focus();
|
|
50
65
|
return !!target;
|
|
51
66
|
}
|
package/src/js/full.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Each module self-registers via observer: importing this bundle enables
|
|
5
5
|
* common behaviors. No DOMContentLoaded ceremony, no init calls.
|
|
6
6
|
* Injected content wires automatically; removal cleans up automatically.
|
|
7
|
+
* The entry also exposes the public enhancement API from the same module
|
|
8
|
+
* graph, so adopters extend the runtime they already run.
|
|
7
9
|
*
|
|
8
10
|
* A plain `<script src="actual.full.js"></script>` (no bundler, no import) still
|
|
9
11
|
* gets the full runtime, including declarative triggers like status's
|
|
@@ -25,3 +27,5 @@ import "./range.js";
|
|
|
25
27
|
import "./validation.js";
|
|
26
28
|
import "./status.js";
|
|
27
29
|
import "./index.js";
|
|
30
|
+
|
|
31
|
+
export { applyEnhancement, default as enhance, registerEnhancement } from "./enhance.js";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Brings a target into view inside one horizontal scrollport — internal, not
|
|
3
|
+
* a package export. Tabs call it at connect, so a selected tab rendered
|
|
4
|
+
* off-screen starts visible, and on every selection.
|
|
5
|
+
*
|
|
6
|
+
* Contract:
|
|
7
|
+
* - Scrolls `container` only. scrollIntoView() would also scroll ancestors and
|
|
8
|
+
* the page, so a strip below the fold would yank the reader down to it.
|
|
9
|
+
* - Horizontal, nearest only: a target already visible stays put; one that
|
|
10
|
+
* overflows a single edge moves the minimum to show that edge; one wider
|
|
11
|
+
* than the visible area that covers it on both sides stays put too.
|
|
12
|
+
* - The visible area is the scrollport minus its computed scroll-padding, the
|
|
13
|
+
* native vocabulary also honoured by focus scrolling: a component that
|
|
14
|
+
* keeps an edge clear (a strip's focus line) declares it there, not here.
|
|
15
|
+
* - The delta comes from physical rects and goes through scrollBy(), so RTL
|
|
16
|
+
* needs no scrollLeft sign convention.
|
|
17
|
+
* - Rects are in transformed pixels; clientWidth, scroll-padding and scrollBy()
|
|
18
|
+
* are in layout pixels. The strip's own scale converts between them, so a
|
|
19
|
+
* strip under transform: scale() reveals the same item. Rotation is out of
|
|
20
|
+
* scope.
|
|
21
|
+
* - Inline axis only: the block scroll position is left as it is. Callers are
|
|
22
|
+
* one-row strips whose block axis does not scroll.
|
|
23
|
+
* - Requires a laid-out, visible container and target. A strip hidden at
|
|
24
|
+
* connect (inside a closed dialog or drawer) or a target without a box (a
|
|
25
|
+
* current link inside a closed flyout) gets a no-op, not a deferred retry.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Sub-pixel layout must not read as overflow and nudge an aligned strip.
|
|
29
|
+
const TOLERANCE = 1;
|
|
30
|
+
|
|
31
|
+
// scroll-padding computes to `auto`, a length, or a percentage of the
|
|
32
|
+
// scrollport; auto adds no inset. A calc() mixing the two reads as none.
|
|
33
|
+
const inset = (value, scrollport) =>
|
|
34
|
+
value.endsWith("%")
|
|
35
|
+
? (Number.parseFloat(value) * scrollport) / 100
|
|
36
|
+
: Number.parseFloat(value) || 0;
|
|
37
|
+
|
|
38
|
+
export function ensureInlineVisible(container, target) {
|
|
39
|
+
const width = container?.clientWidth;
|
|
40
|
+
if (!width || !target?.getClientRects().length) return;
|
|
41
|
+
const style = getComputedStyle(container);
|
|
42
|
+
const box = container.getBoundingClientRect();
|
|
43
|
+
const scale = box.width / container.offsetWidth || 1;
|
|
44
|
+
const start = box.left + (container.clientLeft + inset(style.scrollPaddingLeft, width)) * scale;
|
|
45
|
+
const end =
|
|
46
|
+
box.left + (container.clientLeft + width - inset(style.scrollPaddingRight, width)) * scale;
|
|
47
|
+
const rect = target.getBoundingClientRect();
|
|
48
|
+
const before = rect.left < start - TOLERANCE;
|
|
49
|
+
const after = rect.right > end + TOLERANCE;
|
|
50
|
+
if (before === after) return;
|
|
51
|
+
const delta = before ? rect.left - start : rect.right - end;
|
|
52
|
+
// A placement, not a transition: it must not animate under scroll-behavior: smooth.
|
|
53
|
+
container.scrollBy({ left: delta / scale, behavior: "instant" });
|
|
54
|
+
}
|
package/src/js/parsed.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Runs a callback once the document has finished parsing — internal, not a
|
|
3
|
+
* package export.
|
|
4
|
+
*
|
|
5
|
+
* The runtime connects an element as soon as the parser inserts it. With a
|
|
6
|
+
* classic script in <head>, a <script> inside the element hands control back
|
|
7
|
+
* mid-parse, so an enhancer that reads its children at connect can find them
|
|
8
|
+
* missing. Deferring that one read to DOMContentLoaded needs no observer; after
|
|
9
|
+
* parsing the callback runs at once. Returns a cancel for the enhancer's
|
|
10
|
+
* cleanup, so an element removed before the document is parsed does nothing.
|
|
11
|
+
*/
|
|
12
|
+
export function afterParse(node, callback) {
|
|
13
|
+
const doc = node.ownerDocument;
|
|
14
|
+
if (doc.readyState !== "loading") {
|
|
15
|
+
callback();
|
|
16
|
+
return () => {};
|
|
17
|
+
}
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
doc.addEventListener("DOMContentLoaded", callback, { once: true, signal: controller.signal });
|
|
20
|
+
return () => controller.abort();
|
|
21
|
+
}
|
package/src/js/surface.js
CHANGED
|
@@ -386,8 +386,17 @@ export function disconnectSurface(menu) {
|
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
+
/* Arrow keys in a radio group check the next radio by dispatching a click
|
|
390
|
+
(detail 0, no pointer). That click is navigation, not a pick: closing on it
|
|
391
|
+
shut a palette on the first arrow press, before the user could reach the
|
|
392
|
+
color they wanted. Space dispatches the same click and is left alone too. */
|
|
393
|
+
function isKeyboardRadioMove(e) {
|
|
394
|
+
return e.detail === 0 && e.target?.matches?.('input[type="radio"]') === true;
|
|
395
|
+
}
|
|
396
|
+
|
|
389
397
|
function onDocumentClick(e) {
|
|
390
398
|
const path = e.composedPath();
|
|
399
|
+
const radioMove = isKeyboardRadioMove(e);
|
|
391
400
|
|
|
392
401
|
for (const menu of openSurfaces) {
|
|
393
402
|
if (menu.ownerDocument !== e.currentTarget) continue;
|
|
@@ -396,6 +405,7 @@ function onDocumentClick(e) {
|
|
|
396
405
|
|
|
397
406
|
const menuIndex = path.indexOf(menu);
|
|
398
407
|
const isInside = menuIndex >= 0;
|
|
408
|
+
if (isInside && radioMove) continue;
|
|
399
409
|
const hasCloseTrigger =
|
|
400
410
|
isInside &&
|
|
401
411
|
path
|