actual-css 0.11.1 → 0.13.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -6
  3. package/components.json +12 -18
  4. package/dist/actual.full.css +780 -479
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/brand/favicon.svg +5 -0
  8. package/docs/brand/lockup.svg +12 -0
  9. package/docs/brand/logo-dark.svg +9 -0
  10. package/docs/brand/logo-light.svg +9 -0
  11. package/docs/brand/logo.svg +11 -0
  12. package/docs/brand/mark.svg +11 -0
  13. package/docs/design-notes/architecture.md +67 -0
  14. package/docs/design-notes/steps.md +45 -28
  15. package/docs/navigation.json +3 -2
  16. package/docs/pages/components/alert.md +2 -10
  17. package/docs/pages/components/avatar.md +17 -11
  18. package/docs/pages/components/badge.md +3 -9
  19. package/docs/pages/components/breadcrumb.md +16 -2
  20. package/docs/pages/components/card.md +31 -2
  21. package/docs/pages/components/dialog.md +121 -33
  22. package/docs/pages/components/drawer.md +2 -2
  23. package/docs/pages/components/list.md +9 -13
  24. package/docs/pages/components/navbar.md +49 -30
  25. package/docs/pages/components/pagination.md +19 -0
  26. package/docs/pages/components/steps.md +24 -20
  27. package/docs/pages/components/table.md +3 -3
  28. package/docs/pages/enhancements/flyout.md +2 -2
  29. package/docs/pages/enhancements/scrollspy.md +7 -7
  30. package/docs/pages/enhancements/status-bar.md +2 -2
  31. package/docs/pages/enhancements/tabs.md +74 -3
  32. package/docs/pages/examples/overview.md +2 -1
  33. package/docs/pages/forms/date-picker.md +90 -0
  34. package/docs/pages/forms/detached-actions.md +37 -30
  35. package/docs/pages/forms/floating-field.md +21 -7
  36. package/docs/pages/forms/form-actions.md +58 -28
  37. package/docs/pages/forms/input-policy.md +1 -1
  38. package/docs/pages/forms/overview.md +6 -5
  39. package/docs/pages/forms/states-and-sizes.md +15 -15
  40. package/docs/pages/forms/sticky-actions.md +85 -83
  41. package/docs/pages/forms/validation.md +5 -5
  42. package/docs/pages/foundations/philosophy.md +102 -4
  43. package/docs/pages/foundations/theming.md +15 -4
  44. package/docs/pages/foundations/tokens.md +89 -36
  45. package/docs/pages/foundations/typography.md +4 -7
  46. package/docs/pages/guides/bootstrap.md +2 -2
  47. package/docs/pages/guides/design-tools.md +2 -2
  48. package/docs/pages/guides/integrating-actual-css.md +79 -9
  49. package/docs/pages/guides/introduction.md +5 -5
  50. package/docs/pages/guides/modular-import.md +3 -0
  51. package/docs/pages/guides/progressive-enhancement.md +27 -9
  52. package/docs/pages/guides/tailwind.md +70 -0
  53. package/docs/pages/guides/upgrading.md +151 -0
  54. package/docs/pages/layout/center.md +2 -1
  55. package/docs/pages/layout/cluster.md +37 -4
  56. package/docs/pages/layout/grid.md +2 -0
  57. package/docs/pages/layout/overview.md +1 -1
  58. package/docs/pages/layout/sidebar-layout.md +3 -0
  59. package/docs/pages/layout/stack.md +21 -16
  60. package/docs/pages/layout/switcher.md +2 -0
  61. package/docs/pages/layout/topbar.md +7 -5
  62. package/docs/pages/patterns/icon-slot.md +17 -1
  63. package/docs/pages/patterns/navigation-list.md +16 -46
  64. package/docs/pages/patterns/overview.md +3 -4
  65. package/docs/pages/utilities/flex.md +30 -20
  66. package/docs/pages/utilities/sizing-wrapping.md +52 -7
  67. package/docs/pages/utilities/spacing.md +4 -3
  68. package/package.json +6 -6
  69. package/reserved-classes.json +3 -3
  70. package/skills/actual-css/references/enhancements.md +10 -7
  71. package/src/cli/actual-css.js +39 -13
  72. package/src/css/components/alert.css +34 -34
  73. package/src/css/components/avatar.css +8 -17
  74. package/src/css/components/badge.css +1 -6
  75. package/src/css/components/breadcrumb.css +74 -8
  76. package/src/css/components/button.css +3 -9
  77. package/src/css/components/card.css +18 -10
  78. package/src/css/components/chat.css +4 -5
  79. package/src/css/components/drawer.css +42 -45
  80. package/src/css/components/flyout.css +3 -1
  81. package/src/css/components/index.css +0 -1
  82. package/src/css/components/join.css +2 -2
  83. package/src/css/components/list.css +10 -10
  84. package/src/css/components/modal.css +113 -134
  85. package/src/css/components/navbar.css +22 -31
  86. package/src/css/components/pagination.css +5 -0
  87. package/src/css/components/scrollspy.css +1 -1
  88. package/src/css/components/steps.css +66 -12
  89. package/src/css/components/tab.css +134 -4
  90. package/src/css/core/base.css +1 -1
  91. package/src/css/core/focus.css +8 -6
  92. package/src/css/core/reset.css +2 -1
  93. package/src/css/core/theme.css +10 -1
  94. package/src/css/core/tokens.css +32 -12
  95. package/src/css/core/variants.css +41 -14
  96. package/src/css/forms/choice-card.css +1 -1
  97. package/src/css/forms/choice.css +1 -1
  98. package/src/css/forms/control.css +1 -1
  99. package/src/css/forms/floating-field.css +20 -18
  100. package/src/css/forms/form-actions.css +14 -12
  101. package/src/css/forms/native.css +2 -2
  102. package/src/css/forms/otp.css +4 -5
  103. package/src/css/forms/switch.css +1 -1
  104. package/src/css/forms/validation.css +23 -30
  105. package/src/css/layout/center.css +2 -0
  106. package/src/css/layout/cluster.css +25 -0
  107. package/src/css/layout/grid.css +7 -0
  108. package/src/css/layout/scroller.css +19 -3
  109. package/src/css/layout/sidebar-layout.css +3 -0
  110. package/src/css/layout/switcher.css +3 -0
  111. package/src/css/layout/topbar.css +12 -6
  112. package/src/css/utilities/base.css +5 -0
  113. package/src/css/utilities/extra.css +3 -7
  114. package/src/js/full.js +4 -0
  115. package/src/js/inline-visible.js +54 -0
  116. package/src/js/parsed.js +21 -0
  117. package/src/js/tab.js +33 -7
  118. package/src/tooling/color.js +65 -0
  119. package/src/tooling/design-export.js +1 -1
  120. package/src/tooling/design-tokens.js +53 -34
  121. package/src/tooling/theme-contrast.js +217 -0
  122. package/docs/pages/patterns/actions.md +0 -52
  123. package/src/css/components/actions.css +0 -27
@@ -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 control must precede the label: the float is driven by the general
13
- * sibling combinator and :placeholder-shown. Injected nodes may appear between
14
- * the control and the label (password managers, extension wrappers); the cell
15
- * contract stays a single control plus one label. Every .input/.textarea needs
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 > :is(.input, .select) ~ .field-label {
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 > .textarea ~ .field-label {
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
- > :is(.input, .textarea):is(:focus, :autofill, :not(:placeholder-shown))
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
- .select,
94
- .input[type="date"],
95
- .input[type="time"],
96
- .input[type="datetime-local"],
97
- .input[type="month"],
98
- .input[type="week"]
99
- )
100
- ~ .field-label {
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);
@@ -1,30 +1,32 @@
1
1
  /*
2
- * Form actions — bottom form bar with sticky variant.
2
+ * Form actions — the role of a .cluster action row that closes a form.
3
3
  *
4
4
  * Docs: docs/pages/forms/form-actions.md
5
5
  *
6
- * A flex cluster of buttons/actions that closes a form. The sticky variant
7
- * docks to the viewport bottom with a translucent blur where supported; at
8
- * rest it stays visually neutral so it does not double as a separator.
6
+ * Markup: `<div class="cluster form-actions">`. The cluster owns the row
7
+ * (flex, wrap, gap); this role owns the form-specific alignment policy and the
8
+ * opt-in sticky variant. The distance to the fields above belongs to the
9
+ * parent layout (.stack on the form), never to a margin here: a margin would
10
+ * add to a stack's gap. The sticky variant docks to the viewport bottom with a
11
+ * translucent blur where supported; at rest it stays visually neutral so it
12
+ * does not double as a separator.
9
13
  *
10
14
  * Public hooks:
11
15
  * --form-actions-align
12
16
  * --form-actions-justify
13
- * --form-actions-margin-block-start
14
17
  *
15
18
  * Internal:
16
19
  * --form-actions-sticky-block-offset (owned by .form-actions.sticky)
17
20
  * --form-actions-sticky-inline-offset (owned by .form-actions.sticky)
18
21
  * --form-actions-sticky-padding (owned by .form-actions.sticky)
19
22
  */
23
+
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. */
20
27
  .form-actions {
21
- display: flex;
22
- flex-direction: row;
23
- flex-wrap: wrap;
24
- gap: var(--gap);
25
- align-items: var(--form-actions-align, center);
26
- justify-content: var(--form-actions-justify, flex-start);
27
- margin-block-start: var(--form-actions-margin-block-start, var(--space-50));
28
+ --cluster-align: var(--form-actions-align, center);
29
+ --cluster-justify: var(--form-actions-justify, flex-start);
28
30
  }
29
31
 
30
32
  /* Opt-in sticky variant. When the bar is not stuck (at rest) the translucent
@@ -25,7 +25,7 @@
25
25
  calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
26
26
  );
27
27
  padding-inline: var(--space-20);
28
- border: var(--border-width) solid var(--border);
28
+ border: var(--border-width) solid var(--border-control);
29
29
  border-radius: var(--radius-sm);
30
30
  background: var(--surface-subtle);
31
31
  color: var(--text);
@@ -59,7 +59,7 @@
59
59
  inline-size: var(--control-size);
60
60
  block-size: var(--control-size);
61
61
  padding: var(--border-width);
62
- border: var(--border-width) solid var(--border);
62
+ border: var(--border-width) solid var(--border-control);
63
63
  border-radius: var(--radius);
64
64
  background: var(--surface);
65
65
  cursor: pointer;
@@ -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
- @supports selector(:user-invalid) {
111
- .needs-validation .otp > input:user-invalid ~ span {
112
- --otp-cell-border: var(--danger);
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 {
@@ -29,7 +29,7 @@
29
29
 
30
30
  .switch {
31
31
  --switch-bg: var(--border);
32
- --switch-border: var(--border);
32
+ --switch-border: var(--border-control);
33
33
  --switch-knob: var(--surface-raised);
34
34
  --switch-block-size: calc(var(--choice-control-size) + 0.125em);
35
35
  --switch-width: calc(var(--switch-block-size) * 1.8);
@@ -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
- @supports selector(:has(*)) {
30
- .field:has([aria-invalid="true"]) > .field-error,
31
- .needs-validation.was-validated .field:has(:invalid) > .field-error {
32
- display: block;
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
- @supports selector(:user-invalid) {
58
- .needs-validation .check:is(:checked, :indeterminate):user-invalid,
59
- .needs-validation .radio:checked:user-invalid {
60
- --choice-bg: var(--danger);
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
- .needs-validation .switch:checked:user-invalid {
64
- --switch-bg: var(--danger);
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
- @supports selector(:has(*)) {
72
- .choice-card:has([aria-invalid="true"]),
73
- .needs-validation.was-validated .choice-card:has(:invalid) {
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
- @supports selector(:user-invalid) {
79
- .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
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
- @supports selector(:has(:user-invalid)) {
85
- .needs-validation .field:has(:user-invalid) > .field-error {
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);
@@ -9,7 +9,22 @@
9
9
  * --cluster-wrap
10
10
  *
11
11
  * Child contract: direct-child margins are reset, spacing comes from --gap.
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.
12
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
+
13
28
  .cluster {
14
29
  display: flex;
15
30
  flex-direction: row;
@@ -22,3 +37,13 @@
22
37
  .cluster > * {
23
38
  margin: 0;
24
39
  }
40
+
41
+ /* Horizontal, wrapping layout is incompatible with list markers and native
42
+ indentation: a ul/ol/menu applied with .cluster stays a list in the DOM, but
43
+ its visual chrome would be accidental. .stack intentionally leaves this
44
+ alone — a vertical list can keep its numbers. */
45
+ :where(ul, ol, menu).cluster {
46
+ margin: 0;
47
+ padding: 0;
48
+ list-style: none;
49
+ }
@@ -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,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
- scrollbar-color: var(--scroller-thumb) var(--scroller-track);
27
- scrollbar-width: thin;
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;
@@ -1,8 +1,9 @@
1
1
  /*
2
2
  * Sticky, frosted top bar shell for app/dashboard shells (persistent
3
3
  * sidebar + topbar layouts). Owns positioning, stacking, and the frosted
4
- * background only — row content (search, breadcrumb, actions) stays local
5
- * to the page.
4
+ * background; it also lays its children out as a default row that a layout
5
+ * primitive on the same element (such as .cluster) replaces. Row content
6
+ * (search, breadcrumb, actions) stays local to the page.
6
7
  *
7
8
  * Docs: docs/pages/layout/topbar.md
8
9
  *
@@ -16,16 +17,21 @@
16
17
  inset-block-start: 0;
17
18
  z-index: var(--z-sticky);
18
19
  isolation: isolate;
19
- display: flex;
20
- flex-direction: row;
21
- align-items: center;
22
- gap: var(--gap);
23
20
  padding-block: var(--space-30);
24
21
  padding-inline: var(--space-40);
25
22
  border-block-end: var(--border-width) solid var(--border);
26
23
  background: var(--surface);
27
24
  }
28
25
 
26
+ /* The row is a default at zero specificity: .topbar and the primitives share
27
+ layout/, so at class weight their file order alone would pick the winner. */
28
+ :where(.topbar) {
29
+ display: flex;
30
+ flex-direction: row;
31
+ align-items: center;
32
+ gap: var(--gap);
33
+ }
34
+
29
35
  /* Both conditions are required: the translucent background contains var(),
30
36
  so a browser with backdrop-filter but not color-mix (e.g. Safari < 16.2)
31
37
  would accept the declaration at parse time, fail it at computed-value
@@ -74,6 +74,11 @@
74
74
  align-items: end;
75
75
  }
76
76
 
77
+ .items-baseline {
78
+ --items-align: baseline;
79
+ align-items: baseline;
80
+ }
81
+
77
82
  .muted {
78
83
  color: var(--text-muted);
79
84
  }
@@ -10,9 +10,6 @@
10
10
  * - utilities consume the current context instead of defining their own -sm/-lg scale.
11
11
  * - optional utilities extend core utilities, they do not re-express them.
12
12
  * - follow verbose .{property}-{value} naming.
13
- *
14
- * Internal:
15
- * --cluster-justify (local relay consumed by .cluster)
16
13
  */
17
14
 
18
15
  /* Display */
@@ -87,23 +84,22 @@
87
84
  flex-wrap: nowrap;
88
85
  }
89
86
 
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
- --cluster-justify: flex-start;
92
91
  justify-content: flex-start;
93
92
  }
94
93
 
95
94
  .justify-content-center {
96
- --cluster-justify: center;
97
95
  justify-content: center;
98
96
  }
99
97
 
100
98
  .justify-content-end {
101
- --cluster-justify: flex-end;
102
99
  justify-content: flex-end;
103
100
  }
104
101
 
105
102
  .justify-content-space-between {
106
- --cluster-justify: space-between;
107
103
  justify-content: space-between;
108
104
  }
109
105
 
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
+ }
@@ -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/tab.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * ArrowUp/Down for aria-orientation="vertical"
10
10
  * ArrowDown (focus selected panel)
11
11
  * [hidden], disabled and panel-less tabs are skipped
12
+ * Overflow: a horizontal strip shows its whole selected tab, at connect and
13
+ * on every selection
12
14
  *
13
15
  * Self-registers via registerEnhancement: injected tablists wire automatically.
14
16
  * The tab→panel map is rebuilt from the live tablist on each interaction,
@@ -17,9 +19,13 @@
17
19
  */
18
20
 
19
21
  import { registerEnhancement } from "./enhance.js";
22
+ import { ensureInlineVisible } from "./inline-visible.js";
20
23
  import { itemForKey, shouldIgnoreKey } from "./keys.js";
24
+ import { afterParse } from "./parsed.js";
21
25
 
22
- const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
26
+ // Ownership follows the ARIA structure, not the activation token: a tab
27
+ // belongs to its closest role="tablist", enhanced or not.
28
+ const TABLIST_SELECTOR = '[role="tablist"]';
23
29
 
24
30
  // A tablist owns only the tabs directly beneath it, not those of a nested
25
31
  // tablist. Filtering by closest owner keeps the outer list from operating on
@@ -85,10 +91,19 @@ function activate(tab) {
85
91
  }
86
92
  }
87
93
 
88
- function activateAndFocus(tab) {
94
+ /* An overflowing horizontal strip shows the whole tab. At connect a
95
+ server-selected tab may render off-screen; on arrow keys and clicks the
96
+ native focus scroll is not enough: Chrome skips a tab already partly inside
97
+ the scrollport, so one showing a sliver at the edge stayed clipped. */
98
+ function reveal(list, tab) {
99
+ if (list.getAttribute("aria-orientation") !== "vertical") ensureInlineVisible(list, tab);
100
+ }
101
+
102
+ function activateAndFocus(list, tab) {
89
103
  if (!tab) return;
90
104
  activate(tab);
91
105
  tab.focus();
106
+ reveal(list, tab);
92
107
  }
93
108
 
94
109
  function initialize(list) {
@@ -96,6 +111,7 @@ function initialize(list) {
96
111
  if (!tabs.length) return;
97
112
  const selected = tabs.find((tab) => tab.getAttribute("aria-selected") === "true") || tabs[0];
98
113
  activate(selected);
114
+ reveal(list, selected);
99
115
  }
100
116
 
101
117
  function onKeydown(e) {
@@ -133,7 +149,7 @@ function onKeydown(e) {
133
149
  if (!target) return;
134
150
 
135
151
  e.preventDefault();
136
- activateAndFocus(target);
152
+ activateAndFocus(list, target);
137
153
  }
138
154
 
139
155
  function onClick(e) {
@@ -146,14 +162,24 @@ function onClick(e) {
146
162
  }
147
163
  e.preventDefault();
148
164
  if (tab.getAttribute("aria-selected") === "true") return;
149
- activate(tab);
150
- tab.focus();
165
+ activateAndFocus(list, tab);
151
166
  }
152
167
 
153
168
  registerEnhancement("tabs", (list) => {
169
+ // Tabs find their list through role="tablist"; without it nothing would
170
+ // respond, so say why instead of wiring a dead strip.
171
+ if (list.getAttribute("role") !== "tablist") {
172
+ console.warn('data-enhance="tabs" needs role="tablist" on the same element', list);
173
+ return;
174
+ }
154
175
  const controller = new AbortController();
155
- initialize(list);
176
+ // Once parsed: a tablist connected mid-parse had no tabs yet, so the roving
177
+ // tabindex and the selected tab's placement were skipped for good.
178
+ const cancelInit = afterParse(list, () => initialize(list));
156
179
  list.addEventListener("click", onClick, { signal: controller.signal });
157
180
  list.addEventListener("keydown", onKeydown, { signal: controller.signal });
158
- return () => controller.abort();
181
+ return () => {
182
+ cancelInit();
183
+ controller.abort();
184
+ };
159
185
  });