@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
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/).
@@ -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
- const selected = selectedIndex >= 0 && isAvailable(optionElements[selectedIndex])
220
- ? selectedIndex
221
- : optionElements.indexOf(available[0]);
222
- if (selected >= 0) setActive(selected);
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
  }
@@ -11,7 +11,7 @@ import {
11
11
  createDisposables,
12
12
  createTypeahead,
13
13
  dispatch,
14
- focusElement,
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) focusElement(items[index]);
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();
@@ -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 && focusElement(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 (focusElement(target)) return true;
155
- if (target !== trigger && focusElement(trigger)) return true;
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
- disposables.listen(backdrop, 'click', (event) => {
287
- if (
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
- closeDialog('backdrop');
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
- !isDisabled(element) &&
122
- element.getAttribute('aria-hidden') !== 'true' &&
123
- isRendered(element),
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.isComposing
321
+ isComposingEvent(event)
291
322
  ) {
292
323
  return -1;
293
324
  }
@@ -1,4 +1,4 @@
1
- import { focusElement, getFocusableElements, isElement } from './dom.js';
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
- focusElement(scopes[0]);
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
- focusElement(last);
230
+ focusIntoView(last);
231
231
  return true;
232
232
  }
233
233
 
234
234
  if (!event.shiftKey && (!activeInside || active === last)) {
235
235
  event.preventDefault();
236
- focusElement(first);
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();
@@ -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
- pendingFocus = Boolean(options.focusOnOpen);
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
- disposables.listen(root, 'pointerenter', (event) => {
220
- if (event.pointerType !== 'touch') {
221
- hovered = true;
222
- scheduleOpen('hover');
223
- }
224
- });
225
- disposables.listen(root, 'pointerleave', (event) => {
226
- if (event.pointerType !== 'touch') {
227
- hovered = false;
228
- scheduleClose('hover');
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
 
@@ -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
- close('tab', { restoreFocus: false });
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
- return [...scope.querySelectorAll('[role="tabpanel"], [data-ct-tab-panel]')];
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
- focusElement(tabs[index]);
174
+ /* Overflowing tablists scroll; keep the roving focus visible. */
175
+ focusIntoView(tabs[index]);
165
176
  } finally {
166
177
  movingFocus = false;
167
178
  }