@neuravision/construct 2.1.1 → 2.1.3
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 +55 -0
- package/README.md +13 -0
- package/behaviors/combobox.js +43 -4
- package/behaviors/dropdown.js +16 -2
- package/behaviors/index.d.ts +2 -0
- package/behaviors/internal/dialog-controller.js +79 -9
- package/behaviors/internal/dom.js +38 -7
- package/behaviors/internal/overlay.js +4 -4
- package/behaviors/internal/popup-layer.js +2 -0
- package/behaviors/popover.js +21 -12
- package/behaviors/select-menu.js +20 -2
- package/behaviors/tabs.js +13 -2
- package/behaviors/toggle-group.js +37 -5
- package/behaviors/tooltip.js +26 -2
- package/components/README.md +40 -0
- package/components/_shared.css +11 -0
- package/components/accordion.css +8 -0
- package/components/app-shell-v2.css +7 -0
- package/components/app-shell.css +7 -0
- package/components/banner.css +32 -1
- package/components/card.css +23 -9
- package/components/chart.css +26 -0
- package/components/checkbox.css +32 -0
- package/components/chip.css +41 -1
- package/components/combobox.css +21 -0
- package/components/datepicker.css +55 -8
- package/components/datum.css +84 -11
- package/components/dropdown.css +23 -3
- package/components/field.css +6 -4
- package/components/file-upload.css +1 -1
- package/components/list.css +28 -2
- package/components/navbar.css +14 -1
- package/components/pagination.css +18 -0
- package/components/progress-bar.css +22 -1
- package/components/select-menu.css +8 -0
- package/components/select.css +8 -0
- package/components/sidebar.css +15 -2
- package/components/skip-link.css +6 -2
- package/components/slider.css +5 -0
- package/components/switch.css +62 -2
- package/components/table.css +53 -4
- package/components/tabs.css +55 -3
- package/components/toast.css +10 -1
- package/components/toggle-group.css +16 -5
- package/components/toolbar.css +17 -1
- package/components/tree.css +30 -0
- package/docs/behaviors.md +26 -5
- package/docs/guidelines.md +30 -2
- package/foundations.css +6 -0
- package/package.json +7 -3
- package/schemas/components.schema.json +16 -1
- package/schemas/semantic.schema.json +2 -1
- package/schemas/theme.schema.json +1 -1
- package/scripts/build-tokens.mjs +87 -5
- package/themes/README.md +37 -0
- package/themes/walnut.theme.json +106 -0
- package/tokens/README.md +10 -2
- package/tokens/components.json +10 -0
- package/tokens/semantic.dark.json +4 -3
- package/tokens/semantic.high-contrast-dark.json +94 -0
- package/tokens/semantic.high-contrast.json +3 -2
- package/tokens/semantic.light.json +2 -1
- package/tokens/tokens.css +431 -33
- package/tokens/tokens.js +432 -133
- package/tokens/tokens.json +432 -133
- package/tokens/tokens.ts +432 -133
package/components/tabs.css
CHANGED
|
@@ -30,19 +30,39 @@
|
|
|
30
30
|
font-size: var(--ct-tabs-trigger-font-size);
|
|
31
31
|
line-height: var(--ct-tabs-trigger-line-height);
|
|
32
32
|
font-weight: var(--font-weight-medium);
|
|
33
|
+
text-decoration: none;
|
|
33
34
|
cursor: pointer;
|
|
34
35
|
transition: color var(--duration-fast) var(--easing-standard),
|
|
35
36
|
background var(--duration-fast) var(--easing-standard);
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/* Triggers may be rendered as <a> for router-driven tabs. Neutralize the
|
|
40
|
+
global link styling (underline + link colors) so anchor triggers read
|
|
41
|
+
identically to buttons — the active-tab signal is the indicator, never
|
|
42
|
+
an underline. Deliberately excludes :hover so the shared trigger hover
|
|
43
|
+
rule above keeps winning. */
|
|
44
|
+
a.ct-tabs__trigger,
|
|
45
|
+
a.ct-tabs__trigger:active {
|
|
46
|
+
color: var(--color-text-secondary);
|
|
47
|
+
text-decoration: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
.ct-tabs__trigger::after {
|
|
39
51
|
content: '';
|
|
40
52
|
position: absolute;
|
|
41
53
|
inset-inline: 0;
|
|
42
54
|
inset-block-end: 0;
|
|
43
55
|
height: var(--ct-tabs-indicator-size);
|
|
44
|
-
background:
|
|
45
|
-
|
|
56
|
+
background: var(--ct-tabs-indicator-color);
|
|
57
|
+
/* The datum draws itself: the indicator grows from its origin on
|
|
58
|
+
activation instead of popping in. Collapsed to zero at rest. */
|
|
59
|
+
transform: scaleX(0);
|
|
60
|
+
transform-origin: 0 50%;
|
|
61
|
+
transition: transform var(--duration-fast) var(--easing-standard);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ct-tabs__trigger:is(:dir(rtl))::after {
|
|
65
|
+
transform-origin: 100% 50%;
|
|
46
66
|
}
|
|
47
67
|
|
|
48
68
|
@media (hover: hover) {
|
|
@@ -65,7 +85,7 @@
|
|
|
65
85
|
|
|
66
86
|
.ct-tabs__trigger[aria-selected='true']::after,
|
|
67
87
|
.ct-tabs__trigger[data-state='active']::after {
|
|
68
|
-
|
|
88
|
+
transform: scale(1);
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
.ct-tabs__trigger:disabled,
|
|
@@ -114,6 +134,13 @@
|
|
|
114
134
|
inset-block: 0;
|
|
115
135
|
width: var(--ct-tabs-indicator-size);
|
|
116
136
|
height: auto;
|
|
137
|
+
/* Vertical tabs draw the indicator from the block start. */
|
|
138
|
+
transform: scaleY(0);
|
|
139
|
+
transform-origin: 50% 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.ct-tabs--vertical .ct-tabs__trigger:is([aria-selected='true'], [data-state='active'])::after {
|
|
143
|
+
transform: scale(1);
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
/* Pill variant */
|
|
@@ -139,9 +166,34 @@
|
|
|
139
166
|
box-shadow: var(--shadow-sm);
|
|
140
167
|
}
|
|
141
168
|
|
|
169
|
+
/* Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) */
|
|
170
|
+
|
|
171
|
+
@media (pointer: coarse) {
|
|
172
|
+
.ct-tabs__trigger {
|
|
173
|
+
min-block-size: 44px;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
142
177
|
@media (prefers-reduced-motion: reduce) {
|
|
143
178
|
.ct-tabs__trigger,
|
|
144
179
|
.ct-tabs__trigger::after {
|
|
145
180
|
transition: none;
|
|
146
181
|
}
|
|
147
182
|
}
|
|
183
|
+
|
|
184
|
+
@media (forced-colors: active) {
|
|
185
|
+
/* The active indicator is background-drawn and must survive forced
|
|
186
|
+
backgrounds. */
|
|
187
|
+
.ct-tabs__trigger[aria-selected='true']::after,
|
|
188
|
+
.ct-tabs__trigger[data-state='active']::after {
|
|
189
|
+
background: SelectedItem;
|
|
190
|
+
forced-color-adjust: none;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.ct-tabs--pill .ct-tabs__trigger[aria-selected='true'],
|
|
194
|
+
.ct-tabs--pill .ct-tabs__trigger[data-state='active'] {
|
|
195
|
+
background: SelectedItem;
|
|
196
|
+
color: SelectedItemText;
|
|
197
|
+
forced-color-adjust: none;
|
|
198
|
+
}
|
|
199
|
+
}
|
package/components/toast.css
CHANGED
|
@@ -23,12 +23,21 @@
|
|
|
23
23
|
box-shadow: var(--shadow-md);
|
|
24
24
|
color: var(--color-text-primary);
|
|
25
25
|
transition: opacity var(--duration-fast) var(--easing-standard),
|
|
26
|
-
transform var(--duration-fast) var(--easing-standard)
|
|
26
|
+
transform var(--duration-fast) var(--easing-standard),
|
|
27
|
+
visibility 0s linear 0s;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/* Delayed visibility (drawer.css pattern) removes the faded-out toast
|
|
31
|
+
from the accessibility tree and tab order once the fade completes;
|
|
32
|
+
reopening restores it instantly via the base transition. */
|
|
29
33
|
.ct-toast[data-state='closed'] {
|
|
30
34
|
opacity: 0;
|
|
31
35
|
transform: translateY(8px);
|
|
36
|
+
visibility: hidden;
|
|
37
|
+
pointer-events: none;
|
|
38
|
+
transition: opacity var(--duration-fast) var(--easing-standard),
|
|
39
|
+
transform var(--duration-fast) var(--easing-standard),
|
|
40
|
+
visibility 0s linear var(--duration-fast);
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
.ct-toast__title {
|
|
@@ -24,11 +24,7 @@
|
|
|
24
24
|
overflow-x: auto;
|
|
25
25
|
overflow-y: hidden;
|
|
26
26
|
-webkit-overflow-scrolling: touch;
|
|
27
|
-
scrollbar-width:
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
.ct-toggle-group::-webkit-scrollbar {
|
|
31
|
-
display: none;
|
|
27
|
+
scrollbar-width: thin;
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
/* ── Item ── */
|
|
@@ -194,3 +190,18 @@
|
|
|
194
190
|
transition: none;
|
|
195
191
|
}
|
|
196
192
|
}
|
|
193
|
+
|
|
194
|
+
/* ── Forced colors ── */
|
|
195
|
+
|
|
196
|
+
@media (forced-colors: active) {
|
|
197
|
+
/* The pressed fill must survive forced backgrounds; the outlined
|
|
198
|
+
selectors match the specificity of that variant's override. */
|
|
199
|
+
.ct-toggle-group__item[aria-pressed='true'],
|
|
200
|
+
.ct-toggle-group__item[aria-checked='true'],
|
|
201
|
+
.ct-toggle-group--outlined .ct-toggle-group__item[aria-pressed='true'],
|
|
202
|
+
.ct-toggle-group--outlined .ct-toggle-group__item[aria-checked='true'] {
|
|
203
|
+
background: SelectedItem;
|
|
204
|
+
color: SelectedItemText;
|
|
205
|
+
forced-color-adjust: none;
|
|
206
|
+
}
|
|
207
|
+
}
|
package/components/toolbar.css
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
display: flex;
|
|
16
16
|
align-items: center;
|
|
17
17
|
gap: var(--ct-toolbar-gap);
|
|
18
|
-
height:
|
|
18
|
+
/* min-height, not height: the bar must grow with text scaling. */
|
|
19
|
+
min-height: var(--ct-toolbar-height);
|
|
19
20
|
padding: 0 var(--ct-toolbar-padding-x);
|
|
20
21
|
background: var(--color-bg-elevated);
|
|
21
22
|
border-bottom: var(--border-thin) solid var(--color-border-subtle);
|
|
@@ -153,8 +154,15 @@
|
|
|
153
154
|
--ct-toolbar-actions-gap: var(--space-2);
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
/* WCAG 2.2 SC 2.5.8: dense padding alone cannot guarantee 24px links. */
|
|
158
|
+
.ct-toolbar--dense .ct-toolbar__nav-link {
|
|
159
|
+
min-height: 24px;
|
|
160
|
+
}
|
|
161
|
+
|
|
156
162
|
/* ── Sticky ── */
|
|
157
163
|
|
|
164
|
+
/* Consumers: set --ct-scroll-offset-top on :root (see foundations.css) so
|
|
165
|
+
scrolled-to content is not obscured behind the bar (WCAG 2.4.11). */
|
|
158
166
|
.ct-toolbar--sticky {
|
|
159
167
|
position: sticky;
|
|
160
168
|
inset-block-start: 0;
|
|
@@ -213,6 +221,14 @@
|
|
|
213
221
|
}
|
|
214
222
|
}
|
|
215
223
|
|
|
224
|
+
/* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ── */
|
|
225
|
+
|
|
226
|
+
@media (pointer: coarse) {
|
|
227
|
+
.ct-toolbar__nav-link {
|
|
228
|
+
min-height: 44px;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
216
232
|
/* ── Reduced Motion ── */
|
|
217
233
|
|
|
218
234
|
@media (prefers-reduced-motion: reduce) {
|
package/components/tree.css
CHANGED
|
@@ -118,10 +118,25 @@
|
|
|
118
118
|
border-radius: var(--ct-tree-toggle-radius);
|
|
119
119
|
color: var(--ct-tree-toggle-color);
|
|
120
120
|
cursor: pointer;
|
|
121
|
+
position: relative;
|
|
121
122
|
transition: color var(--duration-fast) var(--easing-standard),
|
|
122
123
|
background var(--duration-fast) var(--easing-standard);
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/* WCAG 2.2 SC 2.5.8: the visual chevron stays 1rem; the pointer target
|
|
127
|
+
does not. The hit area overlaps the row, mirroring checkbox.css. */
|
|
128
|
+
.ct-tree__toggle::before {
|
|
129
|
+
content: '';
|
|
130
|
+
position: absolute;
|
|
131
|
+
inset: calc((var(--ct-tree-toggle-size) - 24px) / 2);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@media (pointer: coarse) {
|
|
135
|
+
.ct-tree__toggle::before {
|
|
136
|
+
inset: calc((var(--ct-tree-toggle-size) - 44px) / 2);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
125
140
|
@media (hover: hover) {
|
|
126
141
|
.ct-tree__toggle:hover {
|
|
127
142
|
color: var(--ct-tree-toggle-color-hover);
|
|
@@ -194,6 +209,13 @@
|
|
|
194
209
|
--ct-tree-actions-opacity: 1;
|
|
195
210
|
}
|
|
196
211
|
|
|
212
|
+
/* Touch surfaces have no hover to reveal row actions — keep them visible. */
|
|
213
|
+
@media (hover: none) {
|
|
214
|
+
.ct-tree {
|
|
215
|
+
--ct-tree-actions-opacity: 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
197
219
|
/* ── Selection ── */
|
|
198
220
|
|
|
199
221
|
.ct-tree__node[aria-selected='true'] > .ct-tree__row,
|
|
@@ -277,6 +299,14 @@
|
|
|
277
299
|
background: var(--color-bg-elevated);
|
|
278
300
|
}
|
|
279
301
|
|
|
302
|
+
/* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ── */
|
|
303
|
+
|
|
304
|
+
@media (pointer: coarse) {
|
|
305
|
+
.ct-tree__row {
|
|
306
|
+
min-height: 44px;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
280
310
|
/* ── Reduced motion ── */
|
|
281
311
|
|
|
282
312
|
@media (prefers-reduced-motion: reduce) {
|
package/docs/behaviors.md
CHANGED
|
@@ -15,18 +15,37 @@ Every factory validates its DOM contract, returns the existing controller when i
|
|
|
15
15
|
|
|
16
16
|
| Factory | Behavior contract |
|
|
17
17
|
| --- | --- |
|
|
18
|
-
| `createModalController` | Dialog semantics, initial focus, focus trap, Escape/backdrop dismissal, nested overlay ordering, scroll lock, focus return |
|
|
18
|
+
| `createModalController` | Dialog semantics, initial focus, focus trap, Escape/backdrop dismissal, nested overlay ordering, scroll lock, focus return, optional `inertBackground` |
|
|
19
19
|
| `createDrawerController` | Modal drawer behavior with the same focus/dismissal invariants and toggle trigger support |
|
|
20
20
|
| `createTabsController` | Roving tabindex, horizontal/vertical arrows, Home/End, automatic or manual activation |
|
|
21
|
-
| `createToggleGroupController` | Single/multiple pressed state, roving tabindex, orientation-aware arrows, controlled programmatic value |
|
|
22
|
-
| `createDropdownController` | ARIA menu button, first/last focus, arrows, Home/End, typeahead, outside/Escape dismissal, menu item selection |
|
|
23
|
-
| `createSelectMenuController` | Button/listbox selection, active option, typeahead, keyboard opening and dismissal, value synchronization |
|
|
24
|
-
| `createComboboxController` | Editable combobox, virtual listbox focus, optional local filtering, active descendant, announcements, selection |
|
|
21
|
+
| `createToggleGroupController` | Single/multiple pressed state (`aria-pressed`, or `aria-checked` radio semantics for single groups authored with `role="radiogroup"`/`role="radio"`), roving tabindex, orientation-aware arrows, controlled programmatic value |
|
|
22
|
+
| `createDropdownController` | ARIA menu button, first/last focus, arrows, Home/End, typeahead (open and closed), outside/Escape dismissal, menu item selection |
|
|
23
|
+
| `createSelectMenuController` | Button/listbox selection, active option, typeahead, PageUp/PageDown, Tab commits the active option, keyboard opening and dismissal, value synchronization |
|
|
24
|
+
| `createComboboxController` | Editable combobox, virtual listbox focus, optional local filtering, active descendant, Alt+Arrow open/close, IME-safe key handling, announcements, selection |
|
|
25
25
|
| `createTooltipController` | Focus and hover timing, touch toggle, Escape/outside dismissal, described-by wiring |
|
|
26
26
|
| `createPopoverController` | Non-modal popup disclosure, optional focus entry, hover/focus modes, outside/Escape dismissal, focus return |
|
|
27
27
|
|
|
28
28
|
The package includes TypeScript declarations for options, callbacks, controller methods, and event-detail types.
|
|
29
29
|
|
|
30
|
+
### What the caller still owns (accessible names and live regions)
|
|
31
|
+
|
|
32
|
+
Controllers wire state attributes, but accessible names and announcement targets come from your markup. Missing ones are reported as console warnings in the browser:
|
|
33
|
+
|
|
34
|
+
| Controller | You must provide |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| Modal / Drawer | `aria-labelledby` or `aria-label` on the dialog element |
|
|
37
|
+
| Dropdown | `aria-label` or `aria-labelledby` on the `role="menu"` element |
|
|
38
|
+
| Toggle group | `aria-label` or `aria-labelledby` on the group/radiogroup/toolbar root |
|
|
39
|
+
| Select menu | A visible label or `aria-labelledby` for the trigger, and labels on options |
|
|
40
|
+
| Combobox | A `<label>` for the input, an `aria-label` on the listbox, and a visually hidden live-region element passed as `options.status` — without it filter results are never announced |
|
|
41
|
+
| Tooltip | A keyboard-focusable trigger (natively focusable or `tabindex="0"`) and text content in the tooltip element |
|
|
42
|
+
| Popover | An accessible name when using `role="dialog"` content |
|
|
43
|
+
|
|
44
|
+
### Documented deviations from APG
|
|
45
|
+
|
|
46
|
+
- Select menu and dropdown arrow navigation wraps at the ends (APG select-only combobox specifies no wrap); wrapping matches Construct's menu behavior and is intentional.
|
|
47
|
+
- Typing on a closed select menu commits the matching value immediately, mirroring native `<select>` on Windows, instead of opening the listbox first.
|
|
48
|
+
|
|
30
49
|
## Modal example
|
|
31
50
|
|
|
32
51
|
```html
|
|
@@ -64,6 +83,8 @@ controller.destroy();
|
|
|
64
83
|
|
|
65
84
|
Buttons with `data-ct-dismiss` are discovered automatically. The caller still owns the accessible name through `aria-labelledby` or `aria-label` and owns the action performed after confirmation.
|
|
66
85
|
|
|
86
|
+
Pass `inertBackground: true` to additionally set `inert` on the container's DOM siblings while the dialog is open — a belt-and-suspenders layer on top of `aria-modal` for legacy screen-reader virtual cursors. Content portaled into the DOM after opening (for example popup layers) is unaffected.
|
|
87
|
+
|
|
67
88
|
## Tabs example
|
|
68
89
|
|
|
69
90
|
```js
|
package/docs/guidelines.md
CHANGED
|
@@ -20,6 +20,7 @@ A consistent, accessible, professional design system for modern web applications
|
|
|
20
20
|
### Don't
|
|
21
21
|
|
|
22
22
|
- Don't rely on color alone for status information without text or icons
|
|
23
|
+
- Don't rely on hue alone to distinguish chart series — use direct labels, ordered legends, and the always-present data-table fallback; several series pairs are near-identical in luminance for color-blind users
|
|
23
24
|
- Don't create custom controls without ARIA roles and keyboard support
|
|
24
25
|
- Don't use fixed pixel layouts for content that can grow
|
|
25
26
|
- Don't use low-contrast text or disabled states that are unreadable
|
|
@@ -28,7 +29,12 @@ A consistent, accessible, professional design system for modern web applications
|
|
|
28
29
|
## Accessibility Rules (Minimum)
|
|
29
30
|
|
|
30
31
|
- **Visible Focus**: All interactive elements must have visible focus indication
|
|
31
|
-
- **Labels**: Every form input must have a label
|
|
32
|
+
- **Labels**: Every form input must have a label. Prefer a visible `<label>`; use `aria-label` only when a visible label is impossible, and never let it differ from visible text (2.5.3 Label in Name — note that `aria-label` overrides an associated `<label>` when both are present)
|
|
33
|
+
- **Headings**: Keep `h1`–`h6` in order without skipping levels, including inside cards, modals, and shells
|
|
34
|
+
- **Landmarks**: Wrap navigation lists in `<nav>` (also inside `<aside>` sidebars), label repeated landmarks (`aria-label`), and provide a skip link to `main` (see `skip-link.css`)
|
|
35
|
+
- **Truncation**: `nowrap` + ellipsis on essential text (titles, values, labels) needs a way to reach the full text — a tooltip, a wrapping variant, or full text elsewhere on the page
|
|
36
|
+
- **Status colors**: Semantic color (toast, banner, alert, badge) must be paired with an icon or explicit wording; the tinted border alone is decorative and intentionally not contrast-gated
|
|
37
|
+
- **Sticky chrome**: Products using `--sticky`/`--fixed` navbar, toolbar, or banner variants must set `--ct-scroll-offset-top`/`--ct-scroll-offset-bottom` on `:root` so focused elements are never scrolled underneath (2.4.11); the app shells set their own scroll padding automatically
|
|
32
38
|
- **Keyboard Navigation**: Use the tested headless controller for supported composite widgets; framework adapters must implement the documented pattern for others such as Datepicker and Tree
|
|
33
39
|
- **ARIA**: Use ARIA attributes only where semantic HTML isn't sufficient
|
|
34
40
|
- **Links**: Links must be distinguishable without color alone (e.g., underline)
|
|
@@ -86,9 +92,10 @@ framework adapter with equivalent tested semantics; CSS state selectors alone do
|
|
|
86
92
|
- **Backdrop**: Click closes drawer
|
|
87
93
|
|
|
88
94
|
### Tooltip
|
|
89
|
-
- **Open**: On hover and focus
|
|
95
|
+
- **Open**: On hover and keyboard focus (the trigger must be focusable)
|
|
90
96
|
- **Close**: On blur and Esc
|
|
91
97
|
- **Role**: `role="tooltip"` with `aria-describedby`
|
|
98
|
+
- **Implementation**: The CSS `:hover`/`:focus-within` styles alone do not satisfy WCAG 1.4.13 — Esc dismissal and hover persistence require `createTooltipController` or an equivalent adapter
|
|
92
99
|
|
|
93
100
|
### Tree (`ct-tree`)
|
|
94
101
|
|
|
@@ -164,6 +171,27 @@ Use these attributes for state management:
|
|
|
164
171
|
Never use `aria-disabled="true"` alone to block a native button: it announces state but does not suppress
|
|
165
172
|
activation. Use the native `disabled` attribute whenever the HTML element supports it.
|
|
166
173
|
|
|
174
|
+
## The Datum
|
|
175
|
+
|
|
176
|
+
Construct's signature is one orange reference line — "the datum" — that marks what is focused, active,
|
|
177
|
+
current, or the reference value. The grammar lives in `components/datum.css` (eyebrow, scale, frame,
|
|
178
|
+
metrics), in active leading edges (list, sidebar, card, table rows), in the tab indicator and sorted
|
|
179
|
+
column, and in the chart reference line. Its dimensions are component tokens (`--component-datum-*`).
|
|
180
|
+
|
|
181
|
+
Rules that keep the signature strong and accessible:
|
|
182
|
+
|
|
183
|
+
- **One datum per surface.** Orange marks exactly one thing per view region — the current, active, or
|
|
184
|
+
reference element. Never let two orange edges compete; if everything is highlighted, nothing is.
|
|
185
|
+
- **Orange is never text.** Labels and values stay on `--color-text-*` neutrals; orange is carried by
|
|
186
|
+
non-text ticks, edges, rules, and lines.
|
|
187
|
+
- **Edges are `--color-brand-accent`; meaning-bearing fills are `--color-brand-accent-strong`**
|
|
188
|
+
(validated ≥3:1 against muted/canvas/surface tracks per theme). A decorative rule may use plain
|
|
189
|
+
accent; anything that encodes a value (progress, chart reference) uses strong.
|
|
190
|
+
- **The datum enters, it never pops.** State changes draw the edge in from its origin
|
|
191
|
+
(`--duration-fast` / `--easing-standard`); `prefers-reduced-motion` disables this globally.
|
|
192
|
+
- **Forced colors re-encode edges as borders.** Inset box-shadow edges are dropped by forced colors —
|
|
193
|
+
every new edge needs a `SelectedItem` border fallback.
|
|
194
|
+
|
|
167
195
|
## Breakpoints & Media Queries
|
|
168
196
|
|
|
169
197
|
Construct defines breakpoint tokens in `tokens/primitives.json`:
|
package/foundations.css
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
html {
|
|
19
19
|
height: 100%;
|
|
20
20
|
text-size-adjust: 100%;
|
|
21
|
+
/* WCAG 2.4.11 Focus Not Obscured contract: sticky/fixed chrome (navbar,
|
|
22
|
+
toolbar, banner) does not know the document, so products publish its
|
|
23
|
+
height by setting --ct-scroll-offset-top/-bottom on :root/html. Scroll
|
|
24
|
+
operations then keep focused or anchored content clear of the bars. */
|
|
25
|
+
scroll-padding-block-start: var(--ct-scroll-offset-top, 0px);
|
|
26
|
+
scroll-padding-block-end: var(--ct-scroll-offset-bottom, 0px);
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
body {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neuravision/construct",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"description": "Construct Design System - Accessible, token-based design system for modern web applications",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
"audit": "npm audit --audit-level=high",
|
|
34
34
|
"tokens:validate": "node scripts/validate-tokens.mjs",
|
|
35
35
|
"tokens:test": "node scripts/test-token-pipeline.mjs",
|
|
36
|
+
"themes:validate": "node scripts/construct-theme.mjs --theme themes/walnut.theme.json --validate-only",
|
|
36
37
|
"check": "npm run check:tokens && npm run check:standalone && npm run check:css && npm run check:exports && npm run check:version",
|
|
37
|
-
"check:tokens": "node scripts/build-tokens.mjs --check && npm run tokens:validate && npm run tokens:test",
|
|
38
|
+
"check:tokens": "node scripts/build-tokens.mjs --check && npm run tokens:validate && npm run tokens:test && npm run themes:validate",
|
|
38
39
|
"check:standalone": "node scripts/build-standalone-components.mjs --check",
|
|
39
40
|
"check:css": "node scripts/validate-css-vars.mjs",
|
|
40
41
|
"check:exports": "node scripts/validate-package-exports.mjs",
|
|
@@ -78,7 +79,8 @@
|
|
|
78
79
|
"default": "./tokens/tokens.js"
|
|
79
80
|
},
|
|
80
81
|
"./schemas/*.json": "./schemas/*.json",
|
|
81
|
-
"./schemas/examples/*.json": "./schemas/examples/*.json"
|
|
82
|
+
"./schemas/examples/*.json": "./schemas/examples/*.json",
|
|
83
|
+
"./themes/*.json": "./themes/*.json"
|
|
82
84
|
},
|
|
83
85
|
"files": [
|
|
84
86
|
"foundations.css",
|
|
@@ -89,6 +91,8 @@
|
|
|
89
91
|
"tokens/*.ts",
|
|
90
92
|
"tokens/README.md",
|
|
91
93
|
"schemas/**/*.json",
|
|
94
|
+
"themes/*.json",
|
|
95
|
+
"themes/README.md",
|
|
92
96
|
"components/*.css",
|
|
93
97
|
"components/README.md",
|
|
94
98
|
"standalone/*.css",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://neuravision.dev/construct/schemas/components.schema.json",
|
|
4
4
|
"title": "Construct component token contract",
|
|
5
5
|
"type": "object",
|
|
6
|
-
"required": ["button", "control", "card", "status", "metric", "meter"],
|
|
6
|
+
"required": ["button", "control", "card", "status", "metric", "meter", "datum"],
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
9
|
"button": {
|
|
@@ -87,6 +87,21 @@
|
|
|
87
87
|
"radius": { "$ref": "#/$defs/token" },
|
|
88
88
|
"track": { "$ref": "#/$defs/token" }
|
|
89
89
|
}
|
|
90
|
+
},
|
|
91
|
+
"datum": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"additionalProperties": false,
|
|
94
|
+
"required": ["edgeWidth", "tickWidth", "tickHeight", "originLength", "markSize", "scaleHeight", "scaleMinorStep", "scaleMajorStep"],
|
|
95
|
+
"properties": {
|
|
96
|
+
"edgeWidth": { "$ref": "#/$defs/token" },
|
|
97
|
+
"tickWidth": { "$ref": "#/$defs/token" },
|
|
98
|
+
"tickHeight": { "$ref": "#/$defs/token" },
|
|
99
|
+
"originLength": { "$ref": "#/$defs/token" },
|
|
100
|
+
"markSize": { "$ref": "#/$defs/token" },
|
|
101
|
+
"scaleHeight": { "$ref": "#/$defs/token" },
|
|
102
|
+
"scaleMinorStep": { "$ref": "#/$defs/token" },
|
|
103
|
+
"scaleMajorStep": { "$ref": "#/$defs/token" }
|
|
104
|
+
}
|
|
90
105
|
}
|
|
91
106
|
},
|
|
92
107
|
"$defs": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"brand": {
|
|
28
28
|
"type": "object",
|
|
29
29
|
"additionalProperties": false,
|
|
30
|
-
"required": ["primary", "primaryHover", "primaryActive", "accent", "accentHover", "accentActive", "onAccent"],
|
|
30
|
+
"required": ["primary", "primaryHover", "primaryActive", "accent", "accentHover", "accentActive", "accentStrong", "onAccent"],
|
|
31
31
|
"properties": {
|
|
32
32
|
"primary": { "$ref": "#/$defs/token" },
|
|
33
33
|
"primaryHover": { "$ref": "#/$defs/token" },
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"accent": { "$ref": "#/$defs/token" },
|
|
36
36
|
"accentHover": { "$ref": "#/$defs/token" },
|
|
37
37
|
"accentActive": { "$ref": "#/$defs/token" },
|
|
38
|
+
"accentStrong": { "$ref": "#/$defs/token" },
|
|
38
39
|
"onAccent": { "$ref": "#/$defs/token" }
|
|
39
40
|
}
|
|
40
41
|
},
|
package/scripts/build-tokens.mjs
CHANGED
|
@@ -13,6 +13,7 @@ const sourcePaths = {
|
|
|
13
13
|
semanticLight: path.join(tokensDir, 'semantic.light.json'),
|
|
14
14
|
semanticDark: path.join(tokensDir, 'semantic.dark.json'),
|
|
15
15
|
semanticHighContrast: path.join(tokensDir, 'semantic.high-contrast.json'),
|
|
16
|
+
semanticHighContrastDark: path.join(tokensDir, 'semantic.high-contrast-dark.json'),
|
|
16
17
|
components: path.join(tokensDir, 'components.json'),
|
|
17
18
|
};
|
|
18
19
|
|
|
@@ -23,7 +24,7 @@ const schemaPaths = {
|
|
|
23
24
|
theme: path.join(schemasDir, 'theme.schema.json'),
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
const builtInThemeNames = new Set(['light', 'dark', 'high-contrast']);
|
|
27
|
+
const builtInThemeNames = new Set(['light', 'dark', 'high-contrast', 'high-contrast-dark']);
|
|
27
28
|
|
|
28
29
|
const defaultContrastPairs = [
|
|
29
30
|
...['primary', 'secondary', 'muted'].flatMap((text) =>
|
|
@@ -58,6 +59,40 @@ const defaultContrastPairs = [
|
|
|
58
59
|
background: `semantic.color.stateSurface.${state}`,
|
|
59
60
|
minRatio: 4.5,
|
|
60
61
|
})),
|
|
62
|
+
...['info', 'success', 'warning', 'danger'].flatMap((state) =>
|
|
63
|
+
['canvas', 'surface', 'elevated'].map((background) => ({
|
|
64
|
+
name: `${state} text on ${background}`,
|
|
65
|
+
foreground: `semantic.color.stateText.${state}`,
|
|
66
|
+
background: `semantic.color.background.${background}`,
|
|
67
|
+
minRatio: 4.5,
|
|
68
|
+
}))
|
|
69
|
+
),
|
|
70
|
+
...['primary', 'secondary'].flatMap((text) =>
|
|
71
|
+
['info', 'success', 'warning', 'danger'].map((state) => ({
|
|
72
|
+
name: `${text} text on ${state} surface`,
|
|
73
|
+
foreground: `semantic.color.text.${text}`,
|
|
74
|
+
background: `semantic.color.stateSurface.${state}`,
|
|
75
|
+
minRatio: 4.5,
|
|
76
|
+
}))
|
|
77
|
+
),
|
|
78
|
+
...['info', 'success', 'warning', 'danger'].map((state) => ({
|
|
79
|
+
name: `${state} fill on muted track`,
|
|
80
|
+
foreground: `semantic.color.state.${state}`,
|
|
81
|
+
background: 'semantic.color.background.muted',
|
|
82
|
+
minRatio: 3,
|
|
83
|
+
})),
|
|
84
|
+
...['canvas', 'surface', 'muted'].map((background) => ({
|
|
85
|
+
name: `strong accent on ${background}`,
|
|
86
|
+
foreground: 'semantic.color.brand.accentStrong',
|
|
87
|
+
background: `semantic.color.background.${background}`,
|
|
88
|
+
minRatio: 3,
|
|
89
|
+
})),
|
|
90
|
+
...['canvas', 'surface', 'elevated'].map((background) => ({
|
|
91
|
+
name: `strong border on ${background}`,
|
|
92
|
+
foreground: 'semantic.color.border.strong',
|
|
93
|
+
background: `semantic.color.background.${background}`,
|
|
94
|
+
minRatio: 3,
|
|
95
|
+
})),
|
|
61
96
|
...['info', 'success', 'warning', 'danger'].map((state) => ({
|
|
62
97
|
name: `text on solid ${state}`,
|
|
63
98
|
foreground: 'semantic.color.state.onSolid',
|
|
@@ -96,6 +131,12 @@ const defaultContrastPairs = [
|
|
|
96
131
|
background: 'components.control.background',
|
|
97
132
|
minRatio: 3,
|
|
98
133
|
},
|
|
134
|
+
{
|
|
135
|
+
name: 'placeholder on control background',
|
|
136
|
+
foreground: 'components.control.placeholder',
|
|
137
|
+
background: 'components.control.background',
|
|
138
|
+
minRatio: 4.5,
|
|
139
|
+
},
|
|
99
140
|
];
|
|
100
141
|
|
|
101
142
|
const fontFallbacks = {
|
|
@@ -533,9 +574,10 @@ const componentVarName = (tokenPath) =>
|
|
|
533
574
|
const inferPrimitiveUnit = (tokenPath) => {
|
|
534
575
|
const [group, subgroup] = tokenPath;
|
|
535
576
|
if (group === 'font') {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
577
|
+
/* Type scale in rem so user browser font-size preferences are honored
|
|
578
|
+
(1.4.4); source values stay authored in px and are divided by 16. */
|
|
579
|
+
if (subgroup === 'size' || subgroup === 'lineHeight') return 'rem';
|
|
580
|
+
if (subgroup === 'letterSpacing') return 'px';
|
|
539
581
|
return '';
|
|
540
582
|
}
|
|
541
583
|
if (['space', 'radius', 'border', 'size', 'layout', 'breakpoint'].includes(group)) {
|
|
@@ -548,6 +590,7 @@ const inferPrimitiveUnit = (tokenPath) => {
|
|
|
548
590
|
const formatPrimitiveValue = (tokenPath, value) => {
|
|
549
591
|
if (typeof value === 'number') {
|
|
550
592
|
const unit = inferPrimitiveUnit(tokenPath);
|
|
593
|
+
if (unit === 'rem') return `${Number((value / 16).toFixed(4))}rem`;
|
|
551
594
|
return unit ? `${value}${unit}` : String(value);
|
|
552
595
|
}
|
|
553
596
|
if (tokenPath[0] === 'font' && tokenPath[1] === 'family') {
|
|
@@ -1035,6 +1078,7 @@ const buildTokens = (options) => {
|
|
|
1035
1078
|
const semanticLightRaw = readJson(sourcePaths.semanticLight);
|
|
1036
1079
|
const semanticDarkOverride = readJson(sourcePaths.semanticDark);
|
|
1037
1080
|
const semanticHighContrastOverride = readJson(sourcePaths.semanticHighContrast);
|
|
1081
|
+
const semanticHighContrastDarkOverride = readJson(sourcePaths.semanticHighContrastDark);
|
|
1038
1082
|
const componentsRaw = readJson(sourcePaths.components);
|
|
1039
1083
|
|
|
1040
1084
|
const schemas = Object.fromEntries(
|
|
@@ -1055,11 +1099,17 @@ const buildTokens = (options) => {
|
|
|
1055
1099
|
semanticLightRaw,
|
|
1056
1100
|
'tokens/semantic.high-contrast.json'
|
|
1057
1101
|
);
|
|
1102
|
+
assertKnownOverride(
|
|
1103
|
+
semanticHighContrastDarkOverride,
|
|
1104
|
+
semanticLightRaw,
|
|
1105
|
+
'tokens/semantic.high-contrast-dark.json'
|
|
1106
|
+
);
|
|
1058
1107
|
|
|
1059
1108
|
const semanticBuiltInRaw = {
|
|
1060
1109
|
light: semanticLightRaw,
|
|
1061
1110
|
dark: mergeDeep(semanticLightRaw, semanticDarkOverride),
|
|
1062
1111
|
'high-contrast': mergeDeep(semanticLightRaw, semanticHighContrastOverride),
|
|
1112
|
+
'high-contrast-dark': mergeDeep(semanticLightRaw, semanticHighContrastDarkOverride),
|
|
1063
1113
|
};
|
|
1064
1114
|
|
|
1065
1115
|
for (const [name, semanticRaw] of Object.entries(semanticBuiltInRaw)) {
|
|
@@ -1112,6 +1162,17 @@ const buildTokens = (options) => {
|
|
|
1112
1162
|
componentRaw: componentsRaw,
|
|
1113
1163
|
...buildThemeLayers('high-contrast', semanticBuiltInRaw['high-contrast'], componentsRaw),
|
|
1114
1164
|
},
|
|
1165
|
+
'high-contrast-dark': {
|
|
1166
|
+
name: 'high-contrast-dark',
|
|
1167
|
+
selector: '[data-theme="high-contrast-dark"]',
|
|
1168
|
+
semanticRaw: semanticBuiltInRaw['high-contrast-dark'],
|
|
1169
|
+
componentRaw: componentsRaw,
|
|
1170
|
+
...buildThemeLayers(
|
|
1171
|
+
'high-contrast-dark',
|
|
1172
|
+
semanticBuiltInRaw['high-contrast-dark'],
|
|
1173
|
+
componentsRaw
|
|
1174
|
+
),
|
|
1175
|
+
},
|
|
1115
1176
|
};
|
|
1116
1177
|
|
|
1117
1178
|
const customThemes = options.themes.map((themePath) => {
|
|
@@ -1178,6 +1239,10 @@ const buildTokens = (options) => {
|
|
|
1178
1239
|
const highContrastSemanticEntries =
|
|
1179
1240
|
builtInThemes['high-contrast'].semanticLayer.cssEntries;
|
|
1180
1241
|
const highContrastComponentEntries = builtInThemes['high-contrast'].componentLayer.cssEntries;
|
|
1242
|
+
const highContrastDarkSemanticEntries =
|
|
1243
|
+
builtInThemes['high-contrast-dark'].semanticLayer.cssEntries;
|
|
1244
|
+
const highContrastDarkComponentEntries =
|
|
1245
|
+
builtInThemes['high-contrast-dark'].componentLayer.cssEntries;
|
|
1181
1246
|
|
|
1182
1247
|
const primitiveEntries = primitiveLayer.cssEntries;
|
|
1183
1248
|
const semanticEntries = lightTheme.semanticLayer.cssEntries;
|
|
@@ -1287,6 +1352,12 @@ const buildTokens = (options) => {
|
|
|
1287
1352
|
highContrastSemanticEntries,
|
|
1288
1353
|
highContrastComponentEntries
|
|
1289
1354
|
),
|
|
1355
|
+
...themeBlock(
|
|
1356
|
+
builtInThemes['high-contrast-dark'].selector,
|
|
1357
|
+
'high-contrast-dark',
|
|
1358
|
+
highContrastDarkSemanticEntries,
|
|
1359
|
+
highContrastDarkComponentEntries
|
|
1360
|
+
),
|
|
1290
1361
|
...customThemeBlocks,
|
|
1291
1362
|
...mediaBlock(
|
|
1292
1363
|
'(prefers-color-scheme: dark)',
|
|
@@ -1302,12 +1373,23 @@ const buildTokens = (options) => {
|
|
|
1302
1373
|
highContrastSemanticEntries,
|
|
1303
1374
|
highContrastComponentEntries
|
|
1304
1375
|
),
|
|
1376
|
+
/* Emitted after the light high-contrast block so it wins the cascade
|
|
1377
|
+
when both prefers-contrast: more and a dark color scheme match —
|
|
1378
|
+
dark-mode users asking for more contrast keep a dark UI. */
|
|
1379
|
+
...mediaBlock(
|
|
1380
|
+
'(prefers-contrast: more) and (prefers-color-scheme: dark)',
|
|
1381
|
+
':root:not([data-theme])',
|
|
1382
|
+
'high-contrast-dark (system)',
|
|
1383
|
+
highContrastDarkSemanticEntries,
|
|
1384
|
+
highContrastDarkComponentEntries
|
|
1385
|
+
),
|
|
1305
1386
|
];
|
|
1306
1387
|
|
|
1307
1388
|
const semanticThemes = {};
|
|
1308
1389
|
const componentThemes = {};
|
|
1309
1390
|
for (const theme of allThemes) {
|
|
1310
|
-
const
|
|
1391
|
+
const exportNames = { 'high-contrast': 'highContrast', 'high-contrast-dark': 'highContrastDark' };
|
|
1392
|
+
const exportName = exportNames[theme.name] ?? theme.name;
|
|
1311
1393
|
semanticThemes[exportName] = theme.semanticLayer.resolved;
|
|
1312
1394
|
componentThemes[exportName] = theme.componentLayer.resolved;
|
|
1313
1395
|
}
|