@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +13 -0
  3. package/behaviors/combobox.js +43 -4
  4. package/behaviors/dropdown.js +16 -2
  5. package/behaviors/index.d.ts +2 -0
  6. package/behaviors/internal/dialog-controller.js +79 -9
  7. package/behaviors/internal/dom.js +38 -7
  8. package/behaviors/internal/overlay.js +4 -4
  9. package/behaviors/internal/popup-layer.js +2 -0
  10. package/behaviors/popover.js +21 -12
  11. package/behaviors/select-menu.js +20 -2
  12. package/behaviors/tabs.js +13 -2
  13. package/behaviors/toggle-group.js +37 -5
  14. package/behaviors/tooltip.js +26 -2
  15. package/components/README.md +40 -0
  16. package/components/_shared.css +11 -0
  17. package/components/accordion.css +8 -0
  18. package/components/app-shell-v2.css +7 -0
  19. package/components/app-shell.css +7 -0
  20. package/components/banner.css +32 -1
  21. package/components/card.css +23 -9
  22. package/components/chart.css +26 -0
  23. package/components/checkbox.css +32 -0
  24. package/components/chip.css +41 -1
  25. package/components/combobox.css +21 -0
  26. package/components/datepicker.css +55 -8
  27. package/components/datum.css +84 -11
  28. package/components/dropdown.css +23 -3
  29. package/components/field.css +6 -4
  30. package/components/file-upload.css +1 -1
  31. package/components/list.css +28 -2
  32. package/components/navbar.css +14 -1
  33. package/components/pagination.css +18 -0
  34. package/components/progress-bar.css +22 -1
  35. package/components/select-menu.css +8 -0
  36. package/components/select.css +8 -0
  37. package/components/sidebar.css +15 -2
  38. package/components/skip-link.css +6 -2
  39. package/components/slider.css +5 -0
  40. package/components/switch.css +62 -2
  41. package/components/table.css +53 -4
  42. package/components/tabs.css +55 -3
  43. package/components/toast.css +10 -1
  44. package/components/toggle-group.css +16 -5
  45. package/components/toolbar.css +17 -1
  46. package/components/tree.css +30 -0
  47. package/docs/behaviors.md +26 -5
  48. package/docs/guidelines.md +30 -2
  49. package/foundations.css +6 -0
  50. package/package.json +7 -3
  51. package/schemas/components.schema.json +16 -1
  52. package/schemas/semantic.schema.json +2 -1
  53. package/schemas/theme.schema.json +1 -1
  54. package/scripts/build-tokens.mjs +87 -5
  55. package/themes/README.md +37 -0
  56. package/themes/walnut.theme.json +106 -0
  57. package/tokens/README.md +10 -2
  58. package/tokens/components.json +10 -0
  59. package/tokens/semantic.dark.json +4 -3
  60. package/tokens/semantic.high-contrast-dark.json +94 -0
  61. package/tokens/semantic.high-contrast.json +3 -2
  62. package/tokens/semantic.light.json +2 -1
  63. package/tokens/tokens.css +431 -33
  64. package/tokens/tokens.js +432 -133
  65. package/tokens/tokens.json +432 -133
  66. package/tokens/tokens.ts +432 -133
@@ -10,11 +10,12 @@ import {
10
10
  createControllerLifecycle,
11
11
  createDisposables,
12
12
  dispatch,
13
- focusElement,
13
+ focusIntoView,
14
14
  getItemValue,
15
15
  isDisabled,
16
16
  nextEnabledIndex,
17
17
  orientationDelta,
18
+ warnAccessibility,
18
19
  } from './internal/dom.js';
19
20
 
20
21
  const controllers = new WeakMap();
@@ -91,12 +92,35 @@ export function createToggleGroupController(options) {
91
92
  if (focusIndex < 0) throw new TypeError('Toggle group controller requires at least one enabled item.');
92
93
  if (!allowEmpty && selected.size === 0) selected.add(values[focusIndex]);
93
94
 
94
- const rootRole = root.getAttribute('role') ?? 'group';
95
+ /* Two supported state models:
96
+ - button model (default): aria-pressed toggle buttons in a group/toolbar
97
+ - radio model: role="radiogroup"/role="radio" markup with aria-checked,
98
+ selection following focus per the APG radio-group pattern.
99
+ Radio markup used to be silently rewritten to buttons while its stale
100
+ aria-checked kept announcing the original selection — now it is honored. */
101
+ const radioSemantics =
102
+ type === 'single' &&
103
+ (root.getAttribute('role') === 'radiogroup' ||
104
+ items.some((item) => item.getAttribute('role') === 'radio'));
105
+ if (type === 'multiple' && items.some((item) => item.getAttribute('role') === 'radio')) {
106
+ warnAccessibility(
107
+ 'Toggle group: role="radio" items require type "single"; falling back to aria-pressed buttons.',
108
+ );
109
+ }
110
+
111
+ const rootRole = root.getAttribute('role') ?? (radioSemantics ? 'radiogroup' : 'group');
95
112
  attributes.set(root, 'role', rootRole);
96
113
  attributes.set(root, 'data-orientation', orientation);
97
114
  if (rootRole === 'toolbar') attributes.set(root, 'aria-orientation', orientation);
115
+ if (!root.hasAttribute('aria-label') && !root.hasAttribute('aria-labelledby')) {
116
+ warnAccessibility(
117
+ `Toggle group: the ${rootRole} element has neither aria-labelledby nor aria-label. ` +
118
+ 'Grouping roles must expose an accessible name (WCAG 4.1.2).',
119
+ );
120
+ }
98
121
  items.forEach((item) => {
99
- if (!isNativeButton(item)) attributes.set(item, 'role', 'button');
122
+ if (radioSemantics) attributes.set(item, 'role', 'radio');
123
+ else if (!isNativeButton(item)) attributes.set(item, 'role', 'button');
100
124
  });
101
125
 
102
126
  function currentValue() {
@@ -105,9 +129,15 @@ export function createToggleGroupController(options) {
105
129
  }
106
130
 
107
131
  function sync() {
132
+ /* An item disabled after init would otherwise strand the roving
133
+ tabindex and drop the whole group from the tab order. */
134
+ if (isDisabled(items[focusIndex])) {
135
+ const fallback = boundaryEnabledIndex(items);
136
+ if (fallback >= 0) focusIndex = fallback;
137
+ }
108
138
  items.forEach((item, index) => {
109
139
  const pressed = selected.has(values[index]);
110
- attributes.set(item, 'aria-pressed', String(pressed));
140
+ attributes.set(item, radioSemantics ? 'aria-checked' : 'aria-pressed', String(pressed));
111
141
  attributes.set(item, 'data-state', pressed ? 'on' : 'off');
112
142
  attributes.set(item, 'tabindex', index === focusIndex && !isDisabled(item) ? '0' : '-1');
113
143
  });
@@ -185,7 +215,9 @@ export function createToggleGroupController(options) {
185
215
  event.preventDefault();
186
216
  focusIndex = nextIndex;
187
217
  sync();
188
- focusElement(items[nextIndex]);
218
+ focusIntoView(items[nextIndex]);
219
+ /* APG radio group: selection follows focus. */
220
+ if (radioSemantics && !selected.has(values[nextIndex])) toggle(nextIndex, 'keyboard');
189
221
  return;
190
222
  }
191
223
 
@@ -7,6 +7,8 @@ import {
7
7
  dispatch,
8
8
  ensureId,
9
9
  eventTargetsOutside,
10
+ isComposingEvent,
11
+ warnAccessibility,
10
12
  } from './internal/dom.js';
11
13
  import { consumeTopOverlayEvent, pushOverlay } from './internal/overlay.js';
12
14
 
@@ -52,6 +54,14 @@ export function createTooltipController(options) {
52
54
  let focused = false;
53
55
  let touchOpened = false;
54
56
 
57
+ if (trigger.tabIndex < 0) {
58
+ warnAccessibility(
59
+ 'Tooltip: the trigger is not keyboard-focusable, so keyboard and screen-reader ' +
60
+ 'users can never open this tooltip (WCAG 2.1.1). Use a natively focusable ' +
61
+ 'element or add tabindex="0".',
62
+ );
63
+ }
64
+
55
65
  const tooltipId = ensureId(tooltip, 'ct-tooltip', attributes);
56
66
  const describedBy = new Set((trigger.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean));
57
67
  describedBy.add(tooltipId);
@@ -165,7 +175,16 @@ export function createTooltipController(options) {
165
175
  });
166
176
  disposables.listen(trigger, 'focus', () => {
167
177
  focused = true;
168
- scheduleOpen('focus', 0);
178
+ /* Open instantly for keyboard focus; pointer-driven focus (mouse click
179
+ on the trigger) is already covered by the hover path and would only
180
+ add noise. Engines without :focus-visible fall back to opening. */
181
+ let keyboardFocus = true;
182
+ try {
183
+ keyboardFocus = trigger.matches(':focus-visible');
184
+ } catch {
185
+ keyboardFocus = true;
186
+ }
187
+ if (keyboardFocus) scheduleOpen('focus', 0);
169
188
  });
170
189
  disposables.listen(trigger, 'blur', () => {
171
190
  focused = false;
@@ -182,7 +201,12 @@ export function createTooltipController(options) {
182
201
  }
183
202
  });
184
203
  disposables.listen(document, 'keydown', (event) => {
185
- if (open && event.key === 'Escape' && consumeTopOverlayEvent(event, document, token)) {
204
+ if (
205
+ open &&
206
+ event.key === 'Escape' &&
207
+ !isComposingEvent(event) &&
208
+ consumeTopOverlayEvent(event, document, token)
209
+ ) {
186
210
  event.preventDefault();
187
211
  event.stopPropagation();
188
212
  closeTooltip('escape');
@@ -24,7 +24,9 @@ Example:
24
24
  - `data-loading="true"` as the button's visual loading hook; native buttons also require `disabled`
25
25
  - `aria-invalid="true"` for form errors
26
26
  - `aria-current="page"` for current pagination page
27
+ - `aria-current="true"` for the current table row; `aria-selected="true"`/`data-state="selected"` for selected rows in grid contexts
27
28
  - `aria-selected="true"` for active tabs and selected datepicker days
29
+ - `aria-sort="ascending|descending"` on the actively sorted column header
28
30
  - `aria-disabled="true"` when disabled but not using native `disabled`
29
31
 
30
32
  ARIA communicates state but does not block events. Prefer native `disabled` for form controls. For links and custom controls, suppress pointer and keyboard activation in application code when `aria-disabled="true"` is set. Add live-region attributes only to content that changes dynamically; static status labels do not need `aria-live`.
@@ -212,6 +214,37 @@ Content card with header, body, and footer:
212
214
  </section>
213
215
  ```
214
216
 
217
+ ### Datum Primitives
218
+
219
+ The reference-line grammar from `components/datum.css` — Construct's orange signature as reusable
220
+ structural vocabulary. All dimensions are component tokens (`--component-datum-*`).
221
+
222
+ ```html
223
+ <div class="ct-eyebrow-set">
224
+ <p class="ct-eyebrow ct-eyebrow--numbered">Foundations</p>
225
+ </div>
226
+
227
+ <div class="ct-datum-scale" role="presentation"></div>
228
+
229
+ <div class="ct-datum-scale ct-datum-scale--progress" role="progressbar"
230
+ aria-label="Upload" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100"
231
+ style="--ct-datum-scale-progress: 60%;"></div>
232
+
233
+ <figure class="ct-datum-frame">…</figure>
234
+ ```
235
+
236
+ - `ct-eyebrow` - Mono kicker with a leading datum tick; `--numbered` adds drawing-set sheet numbers
237
+ (scope with `ct-eyebrow-set`)
238
+ - `ct-datum-scale` - Measured ruler line; decorative, mark it `role="presentation"`
239
+ - `ct-datum-scale--progress` - The origin segment reports progress via `--ct-datum-scale-progress`;
240
+ pair with `role="progressbar"` and `aria-value*`
241
+ - `ct-datum-frame` - Corner registration mark for figures, code samples, and media
242
+ - `ct-card--datum`, active leading edges, the tab indicator, sorted table columns, and
243
+ `ct-chart__datum-line` speak the same grammar
244
+
245
+ Orange is never text; edges use `brand-accent`, meaning-bearing fills use `brand-accent-strong`.
246
+ One datum per surface — see `docs/guidelines.md`.
247
+
215
248
  ### Table
216
249
 
217
250
  Basic data table with variants:
@@ -248,6 +281,13 @@ Variants:
248
281
 
249
282
  Rows receive a visual hover affordance automatically on hover-capable pointers; no hover modifier exists.
250
283
 
284
+ States (the datum grammar):
285
+ - `tr[aria-current="true"]`, `tr[aria-selected="true"]`, or `tr[data-state="selected"]` marks the
286
+ current/selected row with a muted background and the orange leading edge. Use `aria-current` on
287
+ native tables; `aria-selected` is only valid on rows inside `role="grid"`/`role="treegrid"`.
288
+ - `th[aria-sort="ascending|descending"]` underlines the actively sorted column header with the datum;
289
+ sort direction stays shape-encoded via the `ct-table__sort-indicator` arrows.
290
+
251
291
  ### Data Table
252
292
 
253
293
  Complex data table with header, toolbar, filters, and actions:
@@ -93,6 +93,9 @@
93
93
  background: var(--ct-control-bg-disabled);
94
94
  border-color: var(--color-border-subtle);
95
95
  color: var(--color-text-muted);
96
+ /* Disabled is fully colour-encoded above; cancel foundations' global
97
+ :disabled opacity so controls are not double-dimmed. */
98
+ opacity: 1;
96
99
  }
97
100
 
98
101
  :where(.ct-input, .ct-select, .ct-textarea, .ct-control)[aria-invalid='true'],
@@ -135,3 +138,11 @@
135
138
  --ct-control-font-size: var(--font-size-lg);
136
139
  --ct-control-line-height: var(--line-height-lg);
137
140
  }
141
+
142
+ /* Per-file kill switch: standalone/core.css imports this file without
143
+ foundations.css and its global reduced-motion rule. */
144
+ @media (prefers-reduced-motion: reduce) {
145
+ :where(.ct-input, .ct-select, .ct-textarea, .ct-control) {
146
+ transition: none;
147
+ }
148
+ }
@@ -116,6 +116,14 @@
116
116
  padding: 0 var(--space-4) var(--space-4);
117
117
  }
118
118
 
119
+ /* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ── */
120
+
121
+ @media (pointer: coarse) {
122
+ .ct-accordion__trigger {
123
+ min-block-size: 44px;
124
+ }
125
+ }
126
+
119
127
  @media (prefers-reduced-motion: reduce) {
120
128
  .ct-accordion__icon,
121
129
  .ct-accordion__item::details-content {
@@ -170,6 +170,13 @@
170
170
  background: var(--ct-v2-surface-bg);
171
171
  }
172
172
 
173
+ /* The shell owns its scroll container, so it can satisfy WCAG 2.4.11
174
+ itself: scrolled-to content clears the sticky toolbar. Override
175
+ --ct-shell-scroll-offset when the toolbar height is customized. */
176
+ .ct-app-shell-v2__main:has(.ct-app-shell-v2__toolbar--sticky) {
177
+ scroll-padding-block-start: var(--ct-shell-scroll-offset, 64px);
178
+ }
179
+
173
180
 
174
181
  /* ── Sidebar States ── */
175
182
 
@@ -230,6 +230,13 @@
230
230
  background: var(--color-bg-canvas);
231
231
  }
232
232
 
233
+ /* The shell owns its scroll container, so it can satisfy WCAG 2.4.11
234
+ itself: scrolled-to content clears the sticky page header. Override
235
+ --ct-shell-scroll-offset when the header height is customized. */
236
+ .ct-app-shell__main:has(.ct-app-shell__page-header--sticky) {
237
+ scroll-padding-block-start: var(--ct-shell-scroll-offset, 64px);
238
+ }
239
+
233
240
 
234
241
 
235
242
  /* ── Backdrop (Mobile Sidebar Overlay) ── */
@@ -26,7 +26,8 @@
26
26
  font-size: var(--font-size-sm);
27
27
  line-height: var(--line-height-sm);
28
28
  transition: opacity var(--duration-normal) var(--easing-standard),
29
- transform var(--duration-normal) var(--easing-standard);
29
+ transform var(--duration-normal) var(--easing-standard),
30
+ visibility 0s linear 0s;
30
31
  }
31
32
 
32
33
  /* ---- Sub-elements ---- */
@@ -159,6 +160,8 @@
159
160
 
160
161
  /* ---- Positions ---- */
161
162
 
163
+ /* Consumers: set --ct-scroll-offset-top on :root (see foundations.css) so
164
+ scrolled-to content is not obscured behind the banner (WCAG 2.4.11). */
162
165
  .ct-banner--fixed-top {
163
166
  position: fixed;
164
167
  inset-block-start: 0;
@@ -167,6 +170,8 @@
167
170
  border-radius: 0;
168
171
  }
169
172
 
173
+ /* Consumers: set --ct-scroll-offset-bottom on :root (see foundations.css) so
174
+ scrolled-to content is not obscured behind the banner (WCAG 2.4.11). */
170
175
  .ct-banner--fixed-bottom {
171
176
  position: fixed;
172
177
  inset-block-end: 0;
@@ -175,6 +180,8 @@
175
180
  border-radius: 0;
176
181
  }
177
182
 
183
+ /* Consumers: set --ct-scroll-offset-top on :root (see foundations.css) so
184
+ scrolled-to content is not obscured behind the banner (WCAG 2.4.11). */
178
185
  .ct-banner--sticky {
179
186
  position: sticky;
180
187
  inset-block-start: 0;
@@ -211,9 +218,16 @@
211
218
 
212
219
  /* ---- State ---- */
213
220
 
221
+ /* Delayed visibility (drawer.css pattern) removes the faded-out banner
222
+ from the accessibility tree and tab order once the fade completes;
223
+ reopening restores it instantly via the base transition. */
214
224
  .ct-banner[data-state='closed'] {
215
225
  opacity: 0;
226
+ visibility: hidden;
216
227
  pointer-events: none;
228
+ transition: opacity var(--duration-normal) var(--easing-standard),
229
+ transform var(--duration-normal) var(--easing-standard),
230
+ visibility 0s linear var(--duration-normal);
217
231
  }
218
232
 
219
233
  .ct-banner--fixed-top[data-state='closed'],
@@ -237,6 +251,23 @@
237
251
  }
238
252
  }
239
253
 
254
+ /* ---- Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ---- */
255
+
256
+ @media (pointer: coarse) {
257
+ /* The default close is --control-height-sm, which _shared.css remaps to
258
+ 44px; only the fixed 24px compact close needs the checkbox.css-style
259
+ hit area. */
260
+ .ct-banner--compact .ct-banner__close {
261
+ position: relative;
262
+ }
263
+
264
+ .ct-banner--compact .ct-banner__close::before {
265
+ content: '';
266
+ position: absolute;
267
+ inset: calc((var(--icon-lg) - 44px) / 2);
268
+ }
269
+ }
270
+
240
271
  /* ---- Reduced Motion ---- */
241
272
 
242
273
  @media (prefers-reduced-motion: reduce) {
@@ -11,6 +11,8 @@
11
11
  border-radius: var(--ct-card-radius);
12
12
  padding: var(--ct-card-padding);
13
13
  box-shadow: var(--ct-card-shadow);
14
+ /* The datum edge slides in on activation instead of popping. */
15
+ transition: box-shadow var(--duration-fast) var(--easing-standard);
14
16
  }
15
17
 
16
18
  .ct-card--sm {
@@ -104,18 +106,30 @@
104
106
  .ct-card--datum::before {
105
107
  content: '';
106
108
  position: absolute;
107
- top: calc(-1 * var(--border-thin));
108
- left: calc(-1 * var(--border-thin));
109
- width: 18px;
110
- height: 18px;
111
- border-top: var(--border-medium) solid var(--color-brand-accent);
112
- border-left: var(--border-medium) solid var(--color-brand-accent);
113
- border-top-left-radius: var(--radius-lg);
109
+ inset-block-start: calc(-1 * var(--border-thin));
110
+ inset-inline-start: calc(-1 * var(--border-thin));
111
+ width: var(--component-datum-mark-size);
112
+ height: var(--component-datum-mark-size);
113
+ border-block-start: var(--border-medium) solid var(--color-brand-accent);
114
+ border-inline-start: var(--border-medium) solid var(--color-brand-accent);
115
+ border-start-start-radius: var(--radius-lg);
116
+ pointer-events: none;
114
117
  }
115
118
 
116
119
  .ct-card--active,
117
120
  .ct-card[data-state='active'] {
118
121
  /* The datum: an orange leading edge marks the active/current card,
119
- consistent with .ct-list__item and .ct-sidebar active states. */
120
- box-shadow: inset var(--border-thick) 0 0 0 var(--color-brand-accent), var(--ct-card-shadow);
122
+ consistent with .ct-list__item and .ct-sidebar active states. The
123
+ ambient shadow stays first so the list interpolates against the
124
+ base card shadow and the edge can animate in. */
125
+ box-shadow: var(--ct-card-shadow), inset var(--component-datum-edge-width) 0 0 0 var(--color-brand-accent);
126
+ }
127
+
128
+ @media (forced-colors: active) {
129
+ /* The datum edge is an inset box-shadow, which forced colors drops —
130
+ re-encode it as a border. */
131
+ .ct-card--active,
132
+ .ct-card[data-state='active'] {
133
+ border-inline-start: var(--component-datum-edge-width) solid SelectedItem;
134
+ }
121
135
  }
@@ -86,6 +86,27 @@
86
86
  fill: var(--color-text-secondary);
87
87
  }
88
88
 
89
+ /* ── Datum reference line ──
90
+ Construct's signature applied to data: an orange reference line marks
91
+ a threshold, target, or "today". The dash pattern separates the
92
+ reference from data series without relying on hue, and the value it
93
+ encodes must also appear as text — in the label and in the chart's
94
+ data-table fallback — so the line itself stays supplementary. */
95
+ .ct-chart__datum-line {
96
+ stroke: var(--color-brand-accent-strong);
97
+ stroke-width: var(--ct-chart-line-width);
98
+ stroke-dasharray: 6 4;
99
+ }
100
+
101
+ .ct-chart__datum-label {
102
+ fill: var(--color-text-secondary);
103
+ font-family: var(--font-family-mono);
104
+ font-size: var(--font-size-xs);
105
+ font-weight: var(--font-weight-semibold);
106
+ letter-spacing: 0.08em;
107
+ text-transform: uppercase;
108
+ }
109
+
89
110
  /* ── Line & area ── */
90
111
  .ct-chart__line {
91
112
  fill: none;
@@ -366,4 +387,9 @@
366
387
  .ct-chart__axis-line {
367
388
  stroke: GrayText;
368
389
  }
390
+
391
+ /* The datum reference line stays distinct from the dimmed chrome. */
392
+ .ct-chart__datum-line {
393
+ stroke: CanvasText;
394
+ }
369
395
  }
@@ -98,6 +98,9 @@
98
98
  background: var(--color-bg-muted);
99
99
  border-color: var(--color-border-subtle);
100
100
  cursor: not-allowed;
101
+ /* Disabled is fully colour-encoded above; cancel foundations' global
102
+ :disabled opacity so the control is not double-dimmed. */
103
+ opacity: 1;
101
104
  }
102
105
 
103
106
  .ct-check__input:focus-visible,
@@ -119,3 +122,32 @@
119
122
  border-inline-end: 0;
120
123
  transform: scale(1);
121
124
  }
125
+
126
+ @media (prefers-reduced-motion: reduce) {
127
+ .ct-check__input,
128
+ .ct-radio__input,
129
+ .ct-check__input::before,
130
+ .ct-radio__input::before {
131
+ transition: none;
132
+ }
133
+ }
134
+
135
+ @media (forced-colors: active) {
136
+ .ct-check__input:checked,
137
+ .ct-check__input:indeterminate,
138
+ .ct-radio__input:checked {
139
+ border-color: ButtonText;
140
+ }
141
+
142
+ /* The tick and indeterminate dash are border-drawn and survive on their
143
+ own; the radio dot is background-drawn and would be forced to Canvas. */
144
+ .ct-radio__input:checked::before {
145
+ background: SelectedItem;
146
+ forced-color-adjust: none;
147
+ }
148
+
149
+ .ct-check__input:disabled,
150
+ .ct-radio__input:disabled {
151
+ border-color: GrayText;
152
+ }
153
+ }
@@ -35,7 +35,9 @@
35
35
  --ct-chip-padding-x: var(--space-2);
36
36
  --ct-chip-font-size: var(--font-size-xs);
37
37
  --ct-chip-line-height: var(--line-height-xs);
38
- --ct-chip-remove-size: var(--icon-sm);
38
+ /* WCAG 2.2 SC 2.5.8: the remove button stays at least 24px in every
39
+ pointer context, so the small chip cannot shrink it below that. */
40
+ --ct-chip-remove-size: var(--icon-lg);
39
41
  --ct-chip-gap: var(--space-1);
40
42
  }
41
43
 
@@ -204,6 +206,18 @@
204
206
  color: var(--ct-chip-accent);
205
207
  }
206
208
 
209
+ /* Composed icon content adopts the slot's size. `.ct-icon` carries a
210
+ fixed `--icon-md` box of its own; inside the 1em chip slot that box
211
+ would overflow onto the label (an intermediate wrapper — e.g. a
212
+ framework component host — keeps `width: 100%` from resolving, so
213
+ the slot size is restated in em). */
214
+ .ct-chip__icon .ct-icon,
215
+ .ct-chip__icon > svg {
216
+ width: 1em;
217
+ height: 1em;
218
+ font-size: 1em;
219
+ }
220
+
207
221
  /* ── Built-in semantic icons via CSS mask-image ──────────────── */
208
222
 
209
223
  .ct-chip__icon--info,
@@ -346,6 +360,17 @@
346
360
  .ct-chip {
347
361
  min-height: 44px;
348
362
  }
363
+
364
+ /* Hit area may extend past the chip edge, mirroring checkbox.css. */
365
+ .ct-chip__remove {
366
+ position: relative;
367
+ }
368
+
369
+ .ct-chip__remove::before {
370
+ content: '';
371
+ position: absolute;
372
+ inset: calc((var(--ct-chip-remove-size) - 44px) / 2);
373
+ }
349
374
  }
350
375
 
351
376
  /* ── Reduced motion ──────────────────────────────────────────── */
@@ -361,3 +386,18 @@
361
386
  transform: none;
362
387
  }
363
388
  }
389
+
390
+ /* ── Forced colors ───────────────────────────────────────────── */
391
+
392
+ @media (forced-colors: active) {
393
+ /* Mask-drawn glyphs and the status dot paint via background-color,
394
+ which forced colors resets — repaint them with system ink. The
395
+ selected checkmark is border-drawn and survives on its own. */
396
+ .ct-chip__icon--info,
397
+ .ct-chip__icon--success,
398
+ .ct-chip__icon--warning,
399
+ .ct-chip__icon--danger,
400
+ .ct-chip__dot {
401
+ background-color: CanvasText;
402
+ }
403
+ }
@@ -374,6 +374,27 @@
374
374
  border-color: transparent;
375
375
  }
376
376
 
377
+ /* Replacement indicator for the removed input outline (mirrors the --multi pattern) */
378
+ .ct-combobox--inline:focus-within {
379
+ outline: 2px solid var(--color-focus-ring);
380
+ outline-offset: 2px;
381
+ }
382
+
383
+ /* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ── */
384
+
385
+ @media (pointer: coarse) {
386
+ .ct-combobox__option {
387
+ min-block-size: 44px;
388
+ }
389
+
390
+ /* Hit area extends past the visual 24px trigger, mirroring checkbox.css. */
391
+ .ct-combobox__trigger::before {
392
+ content: '';
393
+ position: absolute;
394
+ inset: calc((var(--icon-lg) - 44px) / 2);
395
+ }
396
+ }
397
+
377
398
  /* ── Reduced motion ──────────────────────────────────────────── */
378
399
 
379
400
  @media (prefers-reduced-motion: reduce) {
@@ -79,10 +79,22 @@
79
79
  justify-items: center;
80
80
  }
81
81
 
82
+ /* Rows keep grid placement on the shared 7-column grid. display: contents
83
+ once dropped element semantics in older engines; current Chromium/Firefox/
84
+ Safari expose role="row" on display: contents elements correctly. */
82
85
  .ct-datepicker__row {
83
86
  display: contents;
84
87
  }
85
88
 
89
+ /* Optional gridcell wrapper (APG date-picker dialog markup): the cell is the
90
+ grid item and carries role="gridcell" + aria-selected; the day button
91
+ inside keeps its native button role. */
92
+ .ct-datepicker__cell {
93
+ display: flex;
94
+ width: 100%;
95
+ justify-content: center;
96
+ }
97
+
86
98
  .ct-datepicker__weekday {
87
99
  font-size: var(--font-size-xs);
88
100
  color: var(--color-text-muted);
@@ -115,9 +127,10 @@
115
127
  outline-offset: 2px;
116
128
  }
117
129
 
118
- .ct-datepicker__day[aria-selected='true'] {
130
+ .ct-datepicker__day[aria-selected='true'],
131
+ .ct-datepicker__cell[aria-selected='true'] .ct-datepicker__day {
119
132
  background: var(--color-brand-primary);
120
- color: var(--color-text-inverse);
133
+ color: var(--color-text-on-primary);
121
134
  }
122
135
 
123
136
  .ct-datepicker__day[data-outside='true'] {
@@ -158,13 +171,13 @@
158
171
 
159
172
  .ct-datepicker__day[data-range-start] {
160
173
  background: var(--color-brand-primary);
161
- color: var(--color-text-inverse);
174
+ color: var(--color-text-on-primary);
162
175
  border-radius: var(--radius-sm) 0 0 var(--radius-sm);
163
176
  }
164
177
 
165
178
  .ct-datepicker__day[data-range-end] {
166
179
  background: var(--color-brand-primary);
167
- color: var(--color-text-inverse);
180
+ color: var(--color-text-on-primary);
168
181
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
169
182
  }
170
183
 
@@ -172,6 +185,38 @@
172
185
  border-radius: var(--radius-sm);
173
186
  }
174
187
 
188
+ /* ── Coarse pointers ────────────────────────────────────────────── */
189
+
190
+ @media (pointer: coarse) {
191
+ /* Day cells may compress inside the min(90vw, …) popover; tap size
192
+ wins over the shared column width (44×44 policy). */
193
+ .ct-datepicker__day {
194
+ min-width: var(--control-height-calendar-day);
195
+ min-height: var(--control-height-calendar-day);
196
+ }
197
+ }
198
+
199
+ /* ── Forced colors ──────────────────────────────────────────────── */
200
+
201
+ @media (forced-colors: active) {
202
+ /* Selection fills are background-only and would be stripped to Canvas —
203
+ re-encode with system selection colors (data-today's border and the
204
+ highlight outline survive on their own). */
205
+ .ct-datepicker__day[aria-selected='true'],
206
+ .ct-datepicker__cell[aria-selected='true'] .ct-datepicker__day,
207
+ .ct-datepicker__cell[aria-selected='true'] .ct-datepicker__month,
208
+ .ct-datepicker__cell[aria-selected='true'] .ct-datepicker__year,
209
+ .ct-datepicker__month[aria-selected='true'],
210
+ .ct-datepicker__year[aria-selected='true'],
211
+ .ct-datepicker__day[data-in-range],
212
+ .ct-datepicker__day[data-range-start],
213
+ .ct-datepicker__day[data-range-end] {
214
+ background: SelectedItem;
215
+ color: SelectedItemText;
216
+ forced-color-adjust: none;
217
+ }
218
+ }
219
+
175
220
  /* ── Month grid (3×4) ───────────────────────────────────────────── */
176
221
 
177
222
  .ct-datepicker__month-grid {
@@ -205,9 +250,10 @@
205
250
  outline-offset: 2px;
206
251
  }
207
252
 
208
- .ct-datepicker__month[aria-selected='true'] {
253
+ .ct-datepicker__month[aria-selected='true'],
254
+ .ct-datepicker__cell[aria-selected='true'] .ct-datepicker__month {
209
255
  background: var(--color-brand-primary);
210
- color: var(--color-text-inverse);
256
+ color: var(--color-text-on-primary);
211
257
  }
212
258
 
213
259
  .ct-datepicker__month[data-highlighted] {
@@ -254,9 +300,10 @@
254
300
  outline-offset: 2px;
255
301
  }
256
302
 
257
- .ct-datepicker__year[aria-selected='true'] {
303
+ .ct-datepicker__year[aria-selected='true'],
304
+ .ct-datepicker__cell[aria-selected='true'] .ct-datepicker__year {
258
305
  background: var(--color-brand-primary);
259
- color: var(--color-text-inverse);
306
+ color: var(--color-text-on-primary);
260
307
  }
261
308
 
262
309
  .ct-datepicker__year[data-highlighted] {