@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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,61 @@ All notable changes to `@neuravision/construct` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.1.3] - 2026-07-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Chip: custom icons composed into the `.ct-chip__icon` slot (a `.ct-icon` — also when wrapped
|
|
13
|
+
by a framework component host — or a bare `<svg>`) now adopt the slot's 1em size. Previously
|
|
14
|
+
`.ct-icon` kept its fixed `--icon-md` box and overflowed the slot onto the chip label
|
|
15
|
+
(visible as a copy icon overlapping the first characters of ID chips).
|
|
16
|
+
|
|
17
|
+
## [2.1.2] - 2026-07-16
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Datum grammar, extended from signature to system-wide vocabulary:
|
|
22
|
+
- `component.datum.*` tokens (edge width, tick, origin, mark, scale steps) — the grammar's
|
|
23
|
+
dimensions are now part of the published token contract and themable per product.
|
|
24
|
+
- `ct-eyebrow--numbered` (+ `ct-eyebrow-set` scope): auto-incremented, zero-padded sheet
|
|
25
|
+
numbers in the eyebrow, like an engineering drawing set.
|
|
26
|
+
- `ct-datum-scale--progress`: the scale's origin segment becomes meaning-bearing and reports
|
|
27
|
+
progress via `--ct-datum-scale-progress`; fill uses `brand-accent-strong` (≥3:1 per theme).
|
|
28
|
+
- `ct-datum-frame`: the corner registration mark generalised beyond cards for figures, code
|
|
29
|
+
samples, and media exhibits.
|
|
30
|
+
- Chart datum reference line (`ct-chart__datum-line` + `ct-chart__datum-label`) for threshold,
|
|
31
|
+
target, and "today" markers — dashed so the reference is shape-encoded, with a forced-colors
|
|
32
|
+
`CanvasText` fallback.
|
|
33
|
+
- Tables speak the grammar: `tr[aria-current]`/`[aria-selected]`/`[data-state="selected"]` rows
|
|
34
|
+
carry the orange leading edge (with forced-colors border fallback), and the actively sorted
|
|
35
|
+
column header (`aria-sort`) carries the datum underline.
|
|
36
|
+
- A "The Datum" section in `docs/guidelines.md` documenting the dosage rule (one datum per
|
|
37
|
+
surface), the text/edge/fill color contract, and the motion rule.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- The datum now enters instead of popping: tab indicators draw in from their origin
|
|
42
|
+
(`transform`-based), and active leading edges on list items, sidebar nav items, and cards
|
|
43
|
+
slide in via a `box-shadow` transition (`--duration-fast`, disabled by
|
|
44
|
+
`prefers-reduced-motion`). The active card's shadow list is reordered
|
|
45
|
+
(`ambient, inset edge`) so it interpolates; consumers overriding `box-shadow` on active
|
|
46
|
+
states should re-check their overrides.
|
|
47
|
+
- The datum scale's origin now anchors to the inline start in RTL writing modes.
|
|
48
|
+
- Table rows using `aria-selected`/`aria-current` markup, previously unstyled, now receive the
|
|
49
|
+
selected-row treatment (muted background + leading edge).
|
|
50
|
+
|
|
51
|
+
- Preset themes: a new `themes/` package entry for validated, opt-in brand skins that compile
|
|
52
|
+
through the `construct-theme` CLI instead of being baked into `tokens.css`. First preset:
|
|
53
|
+
`walnut` — a warm ivory light theme with walnut-brown brand color, AAA body-text contrast
|
|
54
|
+
(≥ 7:1) enforced by `contrast` gates, validated in `npm run check` via `themes:validate`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- Tabs: anchor-based triggers (router-driven tabs rendering `<a class="ct-tabs__trigger">`)
|
|
59
|
+
no longer inherit the global link underline and link `:active` color from `foundations.css`.
|
|
60
|
+
The trigger label showed a text underline stacked above the orange active indicator; the
|
|
61
|
+
indicator is the only active-tab signal now.
|
|
62
|
+
|
|
8
63
|
## [2.1.1] - 2026-07-16
|
|
9
64
|
|
|
10
65
|
### Changed
|
package/README.md
CHANGED
|
@@ -118,6 +118,19 @@ Load the generated token stylesheet after all Construct style entries, because f
|
|
|
118
118
|
|
|
119
119
|
Application-specific overrides may follow it. Keeping the generated theme last among Construct imports ensures its root and scoped values win deterministically.
|
|
120
120
|
|
|
121
|
+
### Preset themes
|
|
122
|
+
|
|
123
|
+
The package ships validated preset themes in [`themes/`](themes/README.md) — brand skins that
|
|
124
|
+
compile through the same CLI instead of being baked into `tokens.css`:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx construct-theme --theme node_modules/@neuravision/construct/themes/walnut.theme.json --out-dir ./src/generated/construct
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| Preset | Character |
|
|
131
|
+
|--------|-----------|
|
|
132
|
+
| `walnut` | Warm ivory light theme with walnut-brown brand color; AAA body-text contrast enforced by built-in gates |
|
|
133
|
+
|
|
121
134
|
## 🧩 Components
|
|
122
135
|
|
|
123
136
|
49 standalone component style entries, documented and accessibility-tested in [Storybook](https://samyssmile.github.io/construct/).
|
package/behaviors/combobox.js
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
ensureId,
|
|
15
15
|
focusElement,
|
|
16
16
|
getTextLabel,
|
|
17
|
+
isComposingEvent,
|
|
17
18
|
isDisabled,
|
|
18
19
|
nextEnabledIndex,
|
|
20
|
+
warnAccessibility,
|
|
19
21
|
} from './internal/dom.js';
|
|
20
22
|
import { createPopupLayer } from './internal/popup-layer.js';
|
|
21
23
|
|
|
@@ -94,6 +96,13 @@ export function createComboboxController(options) {
|
|
|
94
96
|
|
|
95
97
|
const status = options.status == null ? null : assertElement(options.status, 'options.status');
|
|
96
98
|
if (status) assertSameDocument([status], input.ownerDocument, 'options.status');
|
|
99
|
+
if (!status) {
|
|
100
|
+
warnAccessibility(
|
|
101
|
+
'Combobox: no options.status element was provided, so filter results are never ' +
|
|
102
|
+
'announced to screen readers (WCAG 4.1.3). Pass a visually hidden live-region ' +
|
|
103
|
+
'element via options.status.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
97
106
|
const originalStatusText = status?.textContent;
|
|
98
107
|
const originalInputValue = input.value;
|
|
99
108
|
const filter = options.filter === true
|
|
@@ -198,6 +207,12 @@ export function createComboboxController(options) {
|
|
|
198
207
|
return result === false ? availableOptions() : result;
|
|
199
208
|
}
|
|
200
209
|
|
|
210
|
+
/* Which option receives the visual highlight when the listbox opens:
|
|
211
|
+
'default' = selected option or first available, 'last' = last available
|
|
212
|
+
(ArrowUp open per APG), 'none' = open without moving visual focus
|
|
213
|
+
(Alt+ArrowDown per APG). */
|
|
214
|
+
let pendingOpenFocus = 'default';
|
|
215
|
+
|
|
201
216
|
const layer = createPopupLayer({
|
|
202
217
|
additionalControls: toggleButton ? [toggleButton] : [],
|
|
203
218
|
attributes,
|
|
@@ -216,10 +231,16 @@ export function createComboboxController(options) {
|
|
|
216
231
|
trigger: input,
|
|
217
232
|
onOpen(detail, checkpoint) {
|
|
218
233
|
const available = applyFilterRaw();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
234
|
+
let initial = -1;
|
|
235
|
+
if (pendingOpenFocus === 'last') {
|
|
236
|
+
initial = optionElements.indexOf(available.at(-1));
|
|
237
|
+
} else if (pendingOpenFocus === 'default') {
|
|
238
|
+
initial = selectedIndex >= 0 && isAvailable(optionElements[selectedIndex])
|
|
239
|
+
? selectedIndex
|
|
240
|
+
: optionElements.indexOf(available[0]);
|
|
241
|
+
}
|
|
242
|
+
pendingOpenFocus = 'default';
|
|
243
|
+
if (initial >= 0) setActive(initial);
|
|
223
244
|
focusElement(input);
|
|
224
245
|
checkpoint();
|
|
225
246
|
options.onOpen?.(detail);
|
|
@@ -330,9 +351,27 @@ export function createComboboxController(options) {
|
|
|
330
351
|
|
|
331
352
|
function onInputKeyDown(event) {
|
|
332
353
|
if (!isInteractive()) return;
|
|
354
|
+
/* IME composition: Enter commits composed text and arrows navigate
|
|
355
|
+
candidates — the widget must not hijack them. */
|
|
356
|
+
if (isComposingEvent(event)) return;
|
|
333
357
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
334
358
|
event.preventDefault();
|
|
359
|
+
/* APG: Alt+ArrowDown opens without moving visual focus,
|
|
360
|
+
Alt+ArrowUp closes and keeps the current value. */
|
|
361
|
+
if (event.altKey) {
|
|
362
|
+
if (event.key === 'ArrowDown') {
|
|
363
|
+
if (!layer.isOpen) {
|
|
364
|
+
pendingOpenFocus = 'none';
|
|
365
|
+
open('keyboard');
|
|
366
|
+
}
|
|
367
|
+
} else if (layer.isOpen) {
|
|
368
|
+
close('keyboard', { restoreFocus: false });
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
335
372
|
if (!layer.isOpen) {
|
|
373
|
+
/* APG: ArrowUp opens with visual focus on the last option. */
|
|
374
|
+
pendingOpenFocus = event.key === 'ArrowUp' ? 'last' : 'default';
|
|
336
375
|
open('keyboard');
|
|
337
376
|
return;
|
|
338
377
|
}
|
package/behaviors/dropdown.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
createDisposables,
|
|
12
12
|
createTypeahead,
|
|
13
13
|
dispatch,
|
|
14
|
-
|
|
14
|
+
focusIntoView,
|
|
15
15
|
getItemValue,
|
|
16
16
|
isDisabled,
|
|
17
17
|
nextEnabledIndex,
|
|
@@ -82,7 +82,7 @@ export function createDropdownController(options) {
|
|
|
82
82
|
items.forEach((item, itemIndex) => {
|
|
83
83
|
attributes.set(item, 'data-highlighted', itemIndex === index ? '' : null);
|
|
84
84
|
});
|
|
85
|
-
if (focus)
|
|
85
|
+
if (focus) focusIntoView(items[index]);
|
|
86
86
|
return true;
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -239,11 +239,25 @@ export function createDropdownController(options) {
|
|
|
239
239
|
} else if ((event.key === 'Enter' || event.key === ' ') && !layer.isOpen) {
|
|
240
240
|
event.preventDefault();
|
|
241
241
|
open('keyboard');
|
|
242
|
+
} else if (!layer.isOpen) {
|
|
243
|
+
/* APG menu button: a printable character opens the menu and moves
|
|
244
|
+
focus to the first matching item. */
|
|
245
|
+
const match = typeahead.find(event, items);
|
|
246
|
+
if (match >= 0) {
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
open('keyboard');
|
|
249
|
+
setHighlighted(match);
|
|
250
|
+
}
|
|
242
251
|
}
|
|
243
252
|
});
|
|
244
253
|
disposables.listen(menu, 'keydown', onMenuKeyDown);
|
|
245
254
|
items.forEach((item, index) => {
|
|
246
255
|
disposables.listen(item, 'focus', () => setHighlighted(index, { focus: false }));
|
|
256
|
+
/* Focus follows the pointer, as in native menus — otherwise the roving
|
|
257
|
+
focus item and the hovered item can show two competing highlights. */
|
|
258
|
+
disposables.listen(item, 'pointermove', () => {
|
|
259
|
+
if (layer.isOpen && focusIndex !== index && !isDisabled(item)) setHighlighted(index);
|
|
260
|
+
});
|
|
247
261
|
disposables.listen(item, 'click', (event) => {
|
|
248
262
|
if (isDisabled(item)) {
|
|
249
263
|
event.preventDefault();
|
package/behaviors/index.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface DialogControllerOptions {
|
|
|
36
36
|
trapFocus?: boolean;
|
|
37
37
|
returnFocus?: boolean;
|
|
38
38
|
lockScroll?: boolean;
|
|
39
|
+
/** Additionally set `inert` on the container's DOM siblings while open. */
|
|
40
|
+
inertBackground?: boolean;
|
|
39
41
|
onOpen?(detail: OpenCloseDetail<DialogController>): void;
|
|
40
42
|
onClose?(detail: OpenCloseDetail<DialogController>): void;
|
|
41
43
|
}
|
|
@@ -10,9 +10,12 @@ import {
|
|
|
10
10
|
dispatch,
|
|
11
11
|
ensureId,
|
|
12
12
|
focusElement,
|
|
13
|
+
focusIntoView,
|
|
13
14
|
getFocusableElements,
|
|
15
|
+
isComposingEvent,
|
|
14
16
|
isElement,
|
|
15
17
|
isDisabled,
|
|
18
|
+
warnAccessibility,
|
|
16
19
|
} from './dom.js';
|
|
17
20
|
import {
|
|
18
21
|
closeOwnedOverlays,
|
|
@@ -101,10 +104,13 @@ export function createDialogController(options, registry, defaults) {
|
|
|
101
104
|
const trapFocus = options.trapFocus !== false;
|
|
102
105
|
const returnFocus = options.returnFocus !== false;
|
|
103
106
|
const lockScroll = options.lockScroll !== false;
|
|
107
|
+
const inertBackground = options.inertBackground === true;
|
|
104
108
|
let open = options.open ?? container.getAttribute('data-state') === 'open';
|
|
105
109
|
let previouslyFocused = null;
|
|
106
110
|
let releaseBodyLock = null;
|
|
107
111
|
let removeFromStack = null;
|
|
112
|
+
let inertedElements = null;
|
|
113
|
+
let warnedMissingName = false;
|
|
108
114
|
|
|
109
115
|
const dialogId = ensureId(dialog, `${defaults.idPrefix}-dialog`, attributes);
|
|
110
116
|
attributes.set(dialog, 'role', dialog.getAttribute('role') ?? 'dialog');
|
|
@@ -125,6 +131,42 @@ export function createDialogController(options, registry, defaults) {
|
|
|
125
131
|
removeFromStack = null;
|
|
126
132
|
releaseBodyLock?.();
|
|
127
133
|
releaseBodyLock = null;
|
|
134
|
+
releaseBackgroundInert();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* aria-modal hides background content from modern AT, but legacy virtual
|
|
138
|
+
cursors can still wander behind the dialog. Opt-in `inertBackground`
|
|
139
|
+
additionally inerts the container's DOM siblings up to <body>. Nodes
|
|
140
|
+
appended after opening (e.g. portaled popups) are unaffected. */
|
|
141
|
+
function applyBackgroundInert() {
|
|
142
|
+
if (!inertBackground || inertedElements) return;
|
|
143
|
+
inertedElements = [];
|
|
144
|
+
for (let node = container; node.parentElement && node !== document.body; node = node.parentElement) {
|
|
145
|
+
for (const sibling of node.parentElement.children) {
|
|
146
|
+
if (sibling === node || sibling.inert) continue;
|
|
147
|
+
const tag = sibling.tagName;
|
|
148
|
+
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'LINK' || tag === 'TEMPLATE') continue;
|
|
149
|
+
sibling.inert = true;
|
|
150
|
+
inertedElements.push(sibling);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function releaseBackgroundInert() {
|
|
156
|
+
if (!inertedElements) return;
|
|
157
|
+
for (const element of inertedElements) element.inert = false;
|
|
158
|
+
inertedElements = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function warnWhenUnnamed() {
|
|
162
|
+
if (warnedMissingName) return;
|
|
163
|
+
warnedMissingName = true;
|
|
164
|
+
if (!dialog.hasAttribute('aria-label') && !dialog.hasAttribute('aria-labelledby')) {
|
|
165
|
+
warnAccessibility(
|
|
166
|
+
`${defaults.name}: the dialog element has neither aria-labelledby nor aria-label. ` +
|
|
167
|
+
'Dialogs must expose an accessible name (WCAG 4.1.2).',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
128
170
|
}
|
|
129
171
|
|
|
130
172
|
function registerDialogOverlay() {
|
|
@@ -140,7 +182,7 @@ export function createDialogController(options, registry, defaults) {
|
|
|
140
182
|
function focusInside(checkpoint = lifecycle.assertAlive) {
|
|
141
183
|
const target = resolveInitialFocus(options.initialFocus, dialog);
|
|
142
184
|
checkpoint();
|
|
143
|
-
if (target &&
|
|
185
|
+
if (target && focusIntoView(target)) {
|
|
144
186
|
checkpoint();
|
|
145
187
|
return;
|
|
146
188
|
}
|
|
@@ -151,8 +193,8 @@ export function createDialogController(options, registry, defaults) {
|
|
|
151
193
|
}
|
|
152
194
|
|
|
153
195
|
function restoreDialogFocus(target) {
|
|
154
|
-
if (
|
|
155
|
-
if (target !== trigger &&
|
|
196
|
+
if (focusIntoView(target)) return true;
|
|
197
|
+
if (target !== trigger && focusIntoView(trigger)) return true;
|
|
156
198
|
|
|
157
199
|
const active = document.activeElement;
|
|
158
200
|
if (isElement(active) && dialog.contains(active) && typeof active.blur === 'function') {
|
|
@@ -173,8 +215,10 @@ export function createDialogController(options, registry, defaults) {
|
|
|
173
215
|
previouslyFocused = isElement(document.activeElement) ? document.activeElement : null;
|
|
174
216
|
open = true;
|
|
175
217
|
syncState();
|
|
218
|
+
warnWhenUnnamed();
|
|
176
219
|
removeFromStack = registerDialogOverlay();
|
|
177
220
|
if (lockScroll) releaseBodyLock = lockBodyScroll(document);
|
|
221
|
+
applyBackgroundInert();
|
|
178
222
|
try {
|
|
179
223
|
focusInside(checkpoint);
|
|
180
224
|
options.onOpen?.(detail);
|
|
@@ -232,6 +276,9 @@ export function createDialogController(options, registry, defaults) {
|
|
|
232
276
|
|
|
233
277
|
function onDocumentKeyDown(event) {
|
|
234
278
|
if (!open) return;
|
|
279
|
+
/* Never treat keys as dialog shortcuts while an IME composition is
|
|
280
|
+
active — Escape/Tab are cancelling or committing composed text. */
|
|
281
|
+
if (isComposingEvent(event)) return;
|
|
235
282
|
|
|
236
283
|
if (
|
|
237
284
|
event.key === 'Escape' &&
|
|
@@ -283,15 +330,28 @@ export function createDialogController(options, registry, defaults) {
|
|
|
283
330
|
});
|
|
284
331
|
|
|
285
332
|
if (backdrop) {
|
|
286
|
-
|
|
287
|
-
|
|
333
|
+
/* Close only when the pointer both went down AND came up on the backdrop
|
|
334
|
+
itself. A plain click listener also fires when a drag (e.g. selecting
|
|
335
|
+
text in a form field) starts inside the dialog and is released over
|
|
336
|
+
the backdrop — the click retargets to their common ancestor, which is
|
|
337
|
+
the backdrop — and would discard the user's in-progress work. Routing
|
|
338
|
+
the pointerdown through the overlay stack also keeps a single gesture
|
|
339
|
+
from dismissing a popup and this dialog together. */
|
|
340
|
+
let pointerDownOnBackdrop = false;
|
|
341
|
+
disposables.listen(backdrop, 'pointerdown', (event) => {
|
|
342
|
+
pointerDownOnBackdrop =
|
|
288
343
|
open &&
|
|
289
344
|
closeOnBackdrop &&
|
|
290
345
|
event.target === backdrop &&
|
|
291
|
-
consumeTopOverlayEvent(event, document, token)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
346
|
+
consumeTopOverlayEvent(event, document, token);
|
|
347
|
+
});
|
|
348
|
+
disposables.listen(backdrop, 'pointercancel', () => {
|
|
349
|
+
pointerDownOnBackdrop = false;
|
|
350
|
+
});
|
|
351
|
+
disposables.listen(backdrop, 'pointerup', (event) => {
|
|
352
|
+
const shouldClose = pointerDownOnBackdrop && event.target === backdrop;
|
|
353
|
+
pointerDownOnBackdrop = false;
|
|
354
|
+
if (shouldClose && open) closeDialog('backdrop');
|
|
295
355
|
});
|
|
296
356
|
}
|
|
297
357
|
|
|
@@ -320,6 +380,14 @@ export function createDialogController(options, registry, defaults) {
|
|
|
320
380
|
if (!lifecycle.destroy()) return;
|
|
321
381
|
if (open) {
|
|
322
382
|
open = false;
|
|
383
|
+
/* Frameworks often unmount an open dialog without closing it first;
|
|
384
|
+
without a hand-off the browser drops focus to <body> and silently
|
|
385
|
+
teleports keyboard/AT users to the top of the page. */
|
|
386
|
+
const active = document.activeElement;
|
|
387
|
+
if (isElement(active) && dialog.contains(active)) {
|
|
388
|
+
restoreDialogFocus(previouslyFocused?.isConnected ? previouslyFocused : trigger);
|
|
389
|
+
}
|
|
390
|
+
previouslyFocused = null;
|
|
323
391
|
releaseOpenResources();
|
|
324
392
|
}
|
|
325
393
|
disposables.dispose();
|
|
@@ -332,8 +400,10 @@ export function createDialogController(options, registry, defaults) {
|
|
|
332
400
|
syncState();
|
|
333
401
|
if (open) {
|
|
334
402
|
previouslyFocused = isElement(document.activeElement) ? document.activeElement : null;
|
|
403
|
+
warnWhenUnnamed();
|
|
335
404
|
removeFromStack = registerDialogOverlay();
|
|
336
405
|
if (lockScroll) releaseBodyLock = lockBodyScroll(document);
|
|
406
|
+
applyBackgroundInert();
|
|
337
407
|
try {
|
|
338
408
|
focusInside();
|
|
339
409
|
} catch (error) {
|
|
@@ -116,12 +116,14 @@ export function getFocusableElements(container) {
|
|
|
116
116
|
if (container.matches(FOCUSABLE_SELECTOR)) candidates.push(container);
|
|
117
117
|
candidates.push(...container.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
118
118
|
|
|
119
|
-
return candidates.filter(
|
|
120
|
-
(element)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
return candidates.filter((element) => {
|
|
120
|
+
if (isDisabled(element) || element.getAttribute('aria-hidden') === 'true') return false;
|
|
121
|
+
/* The selector only excludes tabindex="-1"; any other negative value
|
|
122
|
+
(e.g. "-2") is equally removed from the tab order. */
|
|
123
|
+
const tabindex = element.getAttribute('tabindex');
|
|
124
|
+
if (tabindex !== null && Number.parseInt(tabindex, 10) < 0) return false;
|
|
125
|
+
return isRendered(element);
|
|
126
|
+
});
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
export function focusElement(element) {
|
|
@@ -133,6 +135,24 @@ export function focusElement(element) {
|
|
|
133
135
|
return element.ownerDocument.activeElement === element;
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Focuses an element and scrolls it minimally into view. focusElement uses
|
|
140
|
+
* preventScroll to avoid browser-default jump scrolling, so controller-driven
|
|
141
|
+
* focus moves (trap wraparound, roving focus, highlight) must restore
|
|
142
|
+
* visibility themselves or the focus indicator can leave the viewport
|
|
143
|
+
* (WCAG 2.4.11 Focus Not Obscured).
|
|
144
|
+
*/
|
|
145
|
+
export function focusIntoView(element) {
|
|
146
|
+
const focused = focusElement(element);
|
|
147
|
+
if (focused) element.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
148
|
+
return focused;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Emits a non-fatal accessibility authoring warning. */
|
|
152
|
+
export function warnAccessibility(message) {
|
|
153
|
+
if (typeof console !== 'undefined') console.warn(`[construct] ${message}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
136
156
|
export function createAttributeStore() {
|
|
137
157
|
const originals = new Map();
|
|
138
158
|
|
|
@@ -266,6 +286,17 @@ export function orientationDelta(key, orientation, element) {
|
|
|
266
286
|
return 0;
|
|
267
287
|
}
|
|
268
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Detects keydown events fired while an IME composition is in progress.
|
|
291
|
+
* Such events must never trigger widget shortcuts (Enter/Escape/arrows):
|
|
292
|
+
* the user is committing or cancelling composed text, not operating the
|
|
293
|
+
* widget. keyCode 229 covers Chromium, which reports real key values
|
|
294
|
+
* during composition; `isComposing` covers the standards path.
|
|
295
|
+
*/
|
|
296
|
+
export function isComposingEvent(event) {
|
|
297
|
+
return event.isComposing === true || event.keyCode === 229;
|
|
298
|
+
}
|
|
299
|
+
|
|
269
300
|
export function createTypeahead({ timeout = 500 } = {}) {
|
|
270
301
|
if (!Number.isFinite(timeout) || timeout < 0) {
|
|
271
302
|
throw new RangeError('Typeahead timeout must be a non-negative finite number.');
|
|
@@ -287,7 +318,7 @@ export function createTypeahead({ timeout = 500 } = {}) {
|
|
|
287
318
|
event.altKey ||
|
|
288
319
|
event.ctrlKey ||
|
|
289
320
|
event.metaKey ||
|
|
290
|
-
event
|
|
321
|
+
isComposingEvent(event)
|
|
291
322
|
) {
|
|
292
323
|
return -1;
|
|
293
324
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { focusIntoView, getFocusableElements, isElement } from './dom.js';
|
|
2
2
|
|
|
3
3
|
const bodyLocks = new WeakMap();
|
|
4
4
|
const overlayStates = new WeakMap();
|
|
@@ -216,7 +216,7 @@ export function trapTabKey(event, containers) {
|
|
|
216
216
|
}
|
|
217
217
|
if (!focusable.length) {
|
|
218
218
|
event.preventDefault();
|
|
219
|
-
|
|
219
|
+
focusIntoView(scopes[0]);
|
|
220
220
|
return true;
|
|
221
221
|
}
|
|
222
222
|
|
|
@@ -227,13 +227,13 @@ export function trapTabKey(event, containers) {
|
|
|
227
227
|
|
|
228
228
|
if (event.shiftKey && (!activeInside || active === first)) {
|
|
229
229
|
event.preventDefault();
|
|
230
|
-
|
|
230
|
+
focusIntoView(last);
|
|
231
231
|
return true;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
234
|
if (!event.shiftKey && (!activeInside || active === last)) {
|
|
235
235
|
event.preventDefault();
|
|
236
|
-
|
|
236
|
+
focusIntoView(first);
|
|
237
237
|
return true;
|
|
238
238
|
}
|
|
239
239
|
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ensureId,
|
|
5
5
|
eventTargetsOutside,
|
|
6
6
|
focusElement,
|
|
7
|
+
isComposingEvent,
|
|
7
8
|
isElement,
|
|
8
9
|
} from './dom.js';
|
|
9
10
|
import { consumeTopOverlayEvent, pushOverlay } from './overlay.js';
|
|
@@ -134,6 +135,7 @@ export function createPopupLayer({
|
|
|
134
135
|
!open ||
|
|
135
136
|
!closeOnEscape ||
|
|
136
137
|
event.key !== 'Escape' ||
|
|
138
|
+
isComposingEvent(event) ||
|
|
137
139
|
!consumeTopOverlayEvent(event, document, token)
|
|
138
140
|
) return;
|
|
139
141
|
event.preventDefault();
|
package/behaviors/popover.js
CHANGED
|
@@ -176,7 +176,10 @@ export function createPopoverController(options) {
|
|
|
176
176
|
function toggle(reason = 'programmatic') {
|
|
177
177
|
return lifecycle.run('toggle', (checkpoint) => {
|
|
178
178
|
if (isDisabled(trigger)) return false;
|
|
179
|
-
|
|
179
|
+
/* Same focus-entry default as open(): configuring initialFocus means
|
|
180
|
+
trigger activation must move focus in too, or keyboard users may
|
|
181
|
+
never reach popover content rendered elsewhere in the DOM. */
|
|
182
|
+
pendingFocus = Boolean(options.initialFocus || options.focusOnOpen);
|
|
180
183
|
try {
|
|
181
184
|
const changed = layer.toggle(reason);
|
|
182
185
|
checkpoint();
|
|
@@ -216,17 +219,23 @@ export function createPopoverController(options) {
|
|
|
216
219
|
}
|
|
217
220
|
|
|
218
221
|
if (options.openOnHover) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
222
|
+
/* When no wrapper root exists, root falls back to the (hidden) popover,
|
|
223
|
+
which can never receive pointerenter — hover must then be driven by
|
|
224
|
+
the trigger as well. */
|
|
225
|
+
const hoverTargets = root.contains(trigger) ? [root] : [root, trigger];
|
|
226
|
+
hoverTargets.forEach((target) => {
|
|
227
|
+
disposables.listen(target, 'pointerenter', (event) => {
|
|
228
|
+
if (event.pointerType !== 'touch') {
|
|
229
|
+
hovered = true;
|
|
230
|
+
scheduleOpen('hover');
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
disposables.listen(target, 'pointerleave', (event) => {
|
|
234
|
+
if (event.pointerType !== 'touch') {
|
|
235
|
+
hovered = false;
|
|
236
|
+
scheduleClose('hover');
|
|
237
|
+
}
|
|
238
|
+
});
|
|
230
239
|
});
|
|
231
240
|
}
|
|
232
241
|
|
package/behaviors/select-menu.js
CHANGED
|
@@ -204,14 +204,18 @@ export function createSelectMenuController(options) {
|
|
|
204
204
|
|
|
205
205
|
function onTriggerKeyDown(event) {
|
|
206
206
|
if (isDisabled(trigger)) {
|
|
207
|
-
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'Enter', ' '].includes(event.key)) {
|
|
207
|
+
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageUp', 'PageDown', 'Enter', ' '].includes(event.key)) {
|
|
208
208
|
event.preventDefault();
|
|
209
209
|
}
|
|
210
210
|
return;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
if (event.key === 'Tab' && layer.isOpen) {
|
|
214
|
-
|
|
214
|
+
/* APG select-only combobox: Tab commits the visually focused option
|
|
215
|
+
before the browser moves focus on — otherwise keyboard users
|
|
216
|
+
silently keep the old value where mouse users' click commits. */
|
|
217
|
+
if (activeIndex >= 0 && activeIndex !== selectedIndex) select(activeIndex, 'keyboard');
|
|
218
|
+
if (layer.isOpen) close('tab', { restoreFocus: false });
|
|
215
219
|
return;
|
|
216
220
|
}
|
|
217
221
|
|
|
@@ -240,6 +244,20 @@ export function createSelectMenuController(options) {
|
|
|
240
244
|
return;
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
if (event.key === 'PageUp' || event.key === 'PageDown') {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
if (!layer.isOpen) open('keyboard');
|
|
250
|
+
const direction = event.key === 'PageDown' ? 1 : -1;
|
|
251
|
+
let index = activeIndex >= 0 ? activeIndex : selectedIndex;
|
|
252
|
+
for (let step = 0; step < 10; step += 1) {
|
|
253
|
+
const next = nextEnabledIndex(optionElements, index, direction, false);
|
|
254
|
+
if (next < 0) break;
|
|
255
|
+
index = next;
|
|
256
|
+
}
|
|
257
|
+
if (index >= 0) setActive(index);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
const match = typeahead.find(event, optionElements, activeIndex >= 0 ? activeIndex : selectedIndex);
|
|
244
262
|
if (match >= 0) {
|
|
245
263
|
event.preventDefault();
|
package/behaviors/tabs.js
CHANGED
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
dispatch,
|
|
14
14
|
ensureId,
|
|
15
15
|
focusElement,
|
|
16
|
+
focusIntoView,
|
|
16
17
|
isDisabled,
|
|
17
18
|
nextEnabledIndex,
|
|
18
19
|
orientationDelta,
|
|
20
|
+
warnAccessibility,
|
|
19
21
|
} from './internal/dom.js';
|
|
20
22
|
|
|
21
23
|
const controllers = new WeakMap();
|
|
@@ -31,7 +33,15 @@ function resolvePanels(tablist, tabs, suppliedPanels) {
|
|
|
31
33
|
if (controlledPanels.every(Boolean)) return controlledPanels;
|
|
32
34
|
|
|
33
35
|
const scope = tablist.parentElement ?? tablist;
|
|
34
|
-
|
|
36
|
+
const positional = [...scope.querySelectorAll('[role="tabpanel"], [data-ct-tab-panel]')];
|
|
37
|
+
if (positional.length > 1) {
|
|
38
|
+
warnAccessibility(
|
|
39
|
+
'Tabs: panels were paired with tabs by DOM position. If panel order differs from ' +
|
|
40
|
+
'tab order, tabs announce the wrong panels — set aria-controls on each tab or ' +
|
|
41
|
+
'pass options.panels explicitly.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return positional;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
/**
|
|
@@ -161,7 +171,8 @@ export function createTabsController(options) {
|
|
|
161
171
|
sync();
|
|
162
172
|
movingFocus = true;
|
|
163
173
|
try {
|
|
164
|
-
|
|
174
|
+
/* Overflowing tablists scroll; keep the roving focus visible. */
|
|
175
|
+
focusIntoView(tabs[index]);
|
|
165
176
|
} finally {
|
|
166
177
|
movingFocus = false;
|
|
167
178
|
}
|