@astryxdesign/core 0.6.1 → 0.6.2-canary.2b2113d

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 (88) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -1
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +3 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts +7 -5
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +56 -19
  8. package/dist/DateRangeInput/DateRangeInput.d.ts +3 -0
  9. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  10. package/dist/DateRangeInput/DateRangeInput.js +16 -9
  11. package/dist/Dialog/DialogHeader.d.ts +1 -1
  12. package/dist/Dialog/DialogHeader.d.ts.map +1 -1
  13. package/dist/Dialog/DialogHeader.js +10 -7
  14. package/dist/FileInput/FileInput.d.ts.map +1 -1
  15. package/dist/FileInput/FileInput.js +9 -3
  16. package/dist/Markdown/Markdown.d.ts +10 -2
  17. package/dist/Markdown/Markdown.d.ts.map +1 -1
  18. package/dist/Markdown/Markdown.js +58 -14
  19. package/dist/Markdown/index.d.ts +1 -1
  20. package/dist/Markdown/index.d.ts.map +1 -1
  21. package/dist/Markdown/parser.d.ts +126 -12
  22. package/dist/Markdown/parser.d.ts.map +1 -1
  23. package/dist/Markdown/parser.js +369 -34
  24. package/dist/Markdown/utils.d.ts +1 -1
  25. package/dist/Markdown/utils.d.ts.map +1 -1
  26. package/dist/Slider/Slider.d.ts.map +1 -1
  27. package/dist/Slider/Slider.js +5 -2
  28. package/dist/Spinner/Spinner.d.ts +1 -1
  29. package/dist/Spinner/Spinner.d.ts.map +1 -1
  30. package/dist/Spinner/Spinner.js +23 -15
  31. package/dist/astryx.css +5 -2
  32. package/dist/hooks/scrollKeyboardDelegation.d.ts +3 -0
  33. package/dist/hooks/scrollKeyboardDelegation.d.ts.map +1 -0
  34. package/dist/hooks/scrollKeyboardDelegation.js +146 -0
  35. package/dist/hooks/useScrollableArea.d.ts +6 -2
  36. package/dist/hooks/useScrollableArea.d.ts.map +1 -1
  37. package/dist/hooks/useScrollableArea.js +17 -5
  38. package/locales/en.json +16 -0
  39. package/locales/pseudo.json +12 -0
  40. package/package.json +7 -5
  41. package/scripts/agent-doc-state.mjs +1 -1
  42. package/src/BottomSheet/BottomSheet.doc.mjs +8 -1
  43. package/src/BottomSheet/BottomSheet.spec.md +46 -20
  44. package/src/BottomSheet/BottomSheet.test.tsx +6 -3
  45. package/src/BottomSheet/BottomSheet.tsx +3 -1
  46. package/src/BottomSheet/BottomSheetKeyboard.test.tsx +195 -0
  47. package/src/BottomSheet/BottomSheetPanel.test.tsx +11 -1
  48. package/src/BottomSheet/BottomSheetPanel.tsx +49 -16
  49. package/src/BottomSheet/__tests__/BottomSheetKeyboard.a11y.browser.spec.ts +344 -0
  50. package/src/DateRangeInput/DateRangeInput.doc.mjs +35 -7
  51. package/src/DateRangeInput/DateRangeInput.spec.md +203 -0
  52. package/src/DateRangeInput/DateRangeInput.test.tsx +100 -4
  53. package/src/DateRangeInput/DateRangeInput.tsx +29 -20
  54. package/src/Dialog/Dialog.doc.mjs +3 -0
  55. package/src/Dialog/Dialog.spec.md +1 -1
  56. package/src/Dialog/DialogHeader.doc.mjs +38 -0
  57. package/src/Dialog/DialogHeader.test.tsx +49 -0
  58. package/src/Dialog/DialogHeader.tsx +23 -4
  59. package/src/Dialog/modules/DialogHeader.spec.md +152 -0
  60. package/src/FileInput/FileInput.doc.mjs +2 -0
  61. package/src/FileInput/FileInput.spec.md +199 -0
  62. package/src/FileInput/FileInput.test.tsx +14 -0
  63. package/src/FileInput/FileInput.tsx +13 -3
  64. package/src/Markdown/Markdown.doc.mjs +167 -42
  65. package/src/Markdown/Markdown.public.test.ts +157 -0
  66. package/src/Markdown/Markdown.spec.md +149 -70
  67. package/src/Markdown/Markdown.test.tsx +107 -3
  68. package/src/Markdown/Markdown.tsx +116 -35
  69. package/src/Markdown/incremental.test.ts +175 -7
  70. package/src/Markdown/index.ts +6 -0
  71. package/src/Markdown/parser.perf.test.ts +3 -1
  72. package/src/Markdown/parser.test.ts +122 -0
  73. package/src/Markdown/parser.ts +609 -81
  74. package/src/Markdown/utils.ts +6 -0
  75. package/src/ScrollableArea/modules/useScrollableArea.spec.md +50 -22
  76. package/src/Slider/Slider.doc.mjs +16 -0
  77. package/src/Slider/Slider.spec.md +61 -47
  78. package/src/Slider/Slider.test.tsx +18 -0
  79. package/src/Slider/Slider.tsx +12 -6
  80. package/src/Spinner/Spinner.doc.mjs +6 -3
  81. package/src/Spinner/Spinner.test.tsx +37 -0
  82. package/src/Spinner/Spinner.tsx +31 -14
  83. package/src/hooks/scrollKeyboardDelegation.test.ts +155 -0
  84. package/src/hooks/scrollKeyboardDelegation.ts +233 -0
  85. package/src/hooks/useScrollableArea.doc.mjs +15 -3
  86. package/src/hooks/useScrollableArea.test.tsx +59 -1
  87. package/src/hooks/useScrollableArea.ts +34 -10
  88. package/src/theme/derivedVarRegistry.test.ts +6 -4
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Spinner.tsx
7
- * @input Uses React, StyleX, SVG rendering
7
+ * @input Uses React, i18n (useTranslator), StyleX, SVG rendering
8
8
  * @output Exports Spinner component, SpinnerProps, SpinnerSize, SpinnerShade types
9
9
  * @position Core implementation of spinner loading indicator
10
10
  *
@@ -22,6 +22,7 @@ import {colorVars, durationVars, spacingVars} from '../theme/tokens.stylex';
22
22
  import type {BaseProps} from '../BaseProps';
23
23
  import {Text} from '../Text/Text';
24
24
  import {mergeProps} from '../utils';
25
+ import {useTranslator} from '../i18n';
25
26
  import {themeProps} from '../utils/themeProps';
26
27
 
27
28
  // =============================================================================
@@ -29,20 +30,17 @@ import {themeProps} from '../utils/themeProps';
29
30
  // =============================================================================
30
31
 
31
32
  /**
32
- * Fraction of the ring the moving arc covers. The canvas ring this replaces
33
- * swept 135deg, not the 270deg its constant's comment claimed.
33
+ * Default fraction of the ring the moving arc covers. The canvas ring this
34
+ * replaces swept 135deg, not the 270deg its constant's comment claimed.
35
+ *
36
+ * Themeable via `--spinner-arc-fraction`, declared alongside the other public
37
+ * vars in `sizeStyles` below. Only the inline `strokeDasharray` attribute
38
+ * (the pre-stylesheet render — see its own comment) still reads this
39
+ * constant directly; the CSS side composes the dash from the live var.
34
40
  */
35
41
  const ARC_FRACTION = 0.375;
36
42
 
37
- /**
38
- * The dash pattern, per unit of diameter: one arc, then the gap that closes
39
- * the circle. The circumference is `pi x diameter`, so multiplying the
40
- * resolved diameter by these two constants gives exactly the lengths the
41
- * default render has always used, and scales them with a themed diameter.
42
- */
43
43
  const PI = 3.141592653589793;
44
- const ARC_DASH = PI * ARC_FRACTION;
45
- const ARC_GAP = PI * (1 - ARC_FRACTION);
46
44
 
47
45
  const SIZES = {
48
46
  sm: {diameter: 10, border: 2},
@@ -322,10 +320,16 @@ const styles = stylex.create({
322
320
  // of the circle — 87.398 against the 87.965 of pi x 28 — which shortens the
323
321
  // default arc by 0.64% and moves the cap by half a pixel. Composing the
324
322
  // lengths keeps the default byte-identical to what it drew before.
323
+ //
324
+ // The fraction itself also rides a public var (`--spinner-arc-fraction`),
325
+ // unregistered like the other three color/geometry public vars — it is
326
+ // read as a bare `<number>` multiplier here, never summed with a unitless
327
+ // `0` the way the registered length pair guards against, so it needs no
328
+ // registration.
325
329
  arc: {
326
330
  stroke: 'var(--spinner-color)',
327
331
  transform: 'rotate(-90deg)',
328
- strokeDasharray: `calc(var(${RESOLVED_DIAMETER}) * ${ARC_DASH}) calc(var(${RESOLVED_DIAMETER}) * ${ARC_GAP})`,
332
+ strokeDasharray: `calc(var(${RESOLVED_DIAMETER}) * ${PI} * var(--spinner-arc-fraction)) calc(var(${RESOLVED_DIAMETER}) * ${PI} * (1 - var(--spinner-arc-fraction)))`,
329
333
  },
330
334
  track: {stroke: 'var(--spinner-track-color)'},
331
335
  });
@@ -348,22 +352,30 @@ const styles = stylex.create({
348
352
  // cost of its own — with nothing declaring the var,
349
353
  // `theme-var-reachability.js` cannot find an element to check, so a documented
350
354
  // var reads as unreachable.
355
+ // Arc fraction is not itself size-dependent, but it declares alongside the
356
+ // two vars that are, on the same per-size condition (`!hasLabel` gate at the
357
+ // call site) — it needs a home on whichever element carries the theme
358
+ // target, and this is the object already wired to be there.
351
359
  const sizeStyles = stylex.create({
352
360
  sm: {
353
361
  '--spinner-diameter': `${SIZES.sm.diameter}px`,
354
362
  '--spinner-stroke-width': `${SIZES.sm.border}px`,
363
+ '--spinner-arc-fraction': `${ARC_FRACTION}`,
355
364
  },
356
365
  md: {
357
366
  '--spinner-diameter': `${SIZES.md.diameter}px`,
358
367
  '--spinner-stroke-width': `${SIZES.md.border}px`,
368
+ '--spinner-arc-fraction': `${ARC_FRACTION}`,
359
369
  },
360
370
  lg: {
361
371
  '--spinner-diameter': `${SIZES.lg.diameter}px`,
362
372
  '--spinner-stroke-width': `${SIZES.lg.border}px`,
373
+ '--spinner-arc-fraction': `${ARC_FRACTION}`,
363
374
  },
364
375
  xl: {
365
376
  '--spinner-diameter': `${SIZES.xl.diameter}px`,
366
377
  '--spinner-stroke-width': `${SIZES.xl.border}px`,
378
+ '--spinner-arc-fraction': `${ARC_FRACTION}`,
367
379
  },
368
380
  });
369
381
 
@@ -484,6 +496,7 @@ export function Spinner({
484
496
  const arcLength = circumference * ARC_FRACTION;
485
497
  const hasLabel = label != null;
486
498
  const labelId = useId();
499
+ const t = useTranslator();
487
500
 
488
501
  // When a visible string label renders (and no explicit aria-label is set),
489
502
  // name the status element from the visible Text via aria-labelledby instead
@@ -492,9 +505,13 @@ export function Spinner({
492
505
  const namedByVisibleLabel =
493
506
  hasLabel && typeof label === 'string' && ariaLabel == null;
494
507
 
495
- // Resolve accessible name: explicit aria-label > string label > "Loading"
508
+ // Resolve accessible name: explicit aria-label > string label > the
509
+ // localized default. The fallback is AT-facing text, so it goes through the
510
+ // translation runtime like visible text does.
496
511
  const resolvedAriaLabel =
497
- ariaLabel ?? (typeof label === 'string' ? label : undefined) ?? 'Loading';
512
+ ariaLabel ??
513
+ (typeof label === 'string' ? label : undefined) ??
514
+ t('@astryx.spinner.loading');
498
515
 
499
516
  const spinner = (
500
517
  <span
@@ -0,0 +1,155 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file scrollKeyboardDelegation.test.ts
5
+ * @input Private delegation behavior, explicit visibility, and focus event sequences
6
+ * @output Eligibility, keyboard intent, observer cost, and cleanup regressions
7
+ * @position DOM-level policy proof; browser tests own native Tab and scroll evidence
8
+ */
9
+
10
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
11
+ import {attachScrollKeyboardDelegation} from './scrollKeyboardDelegation';
12
+ import {FOCUSABLE_SELECTOR} from './focusableSelector';
13
+
14
+ let detach: (() => void) | undefined;
15
+ let before: HTMLButtonElement;
16
+ let viewport: HTMLDivElement;
17
+ let content: HTMLDivElement;
18
+
19
+ beforeEach(() => {
20
+ vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue([
21
+ new DOMRect(0, 0, 20, 20),
22
+ ] as unknown as DOMRectList);
23
+ before = document.createElement('button');
24
+ viewport = document.createElement('div');
25
+ viewport.tabIndex = 0;
26
+ content = document.createElement('div');
27
+ viewport.append(content);
28
+ // The pinned jsdom selector engine orders this comma-separated selector by
29
+ // selector group. Browsers return document order; normalize only the emulator.
30
+ const query = content.querySelectorAll.bind(content);
31
+ vi.spyOn(content, 'querySelectorAll').mockImplementation(selector => {
32
+ const result = query(selector);
33
+ return selector === FOCUSABLE_SELECTOR
34
+ ? ([...result].sort((a, b) =>
35
+ a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING
36
+ ? -1
37
+ : 1,
38
+ ) as unknown as NodeListOf<Element>)
39
+ : result;
40
+ });
41
+ document.body.append(before, viewport);
42
+ detach = attachScrollKeyboardDelegation(viewport, content);
43
+ });
44
+
45
+ afterEach(() => {
46
+ detach?.();
47
+ before.remove();
48
+ viewport.remove();
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ // jsdom supplies no Tab default action. Complete dispatch before focusing to
53
+ // model the browser's event boundary; real traversal is tested in both engines.
54
+ function enter() {
55
+ before.focus();
56
+ before.dispatchEvent(
57
+ new KeyboardEvent('keydown', {key: 'Tab', bubbles: true}),
58
+ );
59
+ viewport.focus();
60
+ }
61
+
62
+ describe('scroll keyboard delegation', () => {
63
+ it('inspects content only on keyboard entry and leaves mutation observation untouched', () => {
64
+ content.innerHTML = '<button>Action</button>';
65
+ const scan = vi.spyOn(content, 'querySelectorAll');
66
+ const observe = vi.spyOn(MutationObserver.prototype, 'observe');
67
+ viewport.focus();
68
+ content.firstElementChild?.setAttribute('data-state', 'changed');
69
+ viewport.dispatchEvent(new Event('scroll'));
70
+ expect(scan).not.toHaveBeenCalled();
71
+ expect(observe).not.toHaveBeenCalled();
72
+ enter();
73
+ expect(scan).toHaveBeenCalledTimes(1);
74
+ expect(document.activeElement).toBe(content.firstElementChild);
75
+ expect(viewport.tabIndex).toBe(0);
76
+ });
77
+
78
+ it.each([
79
+ '<input aria-label="Input"><button>Later action</button>',
80
+ '<button role="radio">Radio</button><button>Later action</button>',
81
+ '<div role="toolbar"><button>Toolbar action</button></div>',
82
+ '<div contenteditable="true"><button>Editor action</button></div>',
83
+ '<button aria-haspopup="menu">Open menu</button>',
84
+ '<button aria-disabled="true">Unavailable</button>',
85
+ '<button aria-hidden="true">Hidden from AT</button><button>Later action</button>',
86
+ '<button>Zero</button><button tabindex="1">Earlier positive stop</button>',
87
+ '<div tabindex="0">Custom target</div><button>Later action</button>',
88
+ ])('does not skip an excluded first target: %s', markup => {
89
+ content.innerHTML = markup;
90
+ enter();
91
+ expect(document.activeElement).toBe(viewport);
92
+ });
93
+
94
+ it.each(['disabled', 'hidden', 'tabindex="-2"', 'style="visibility:hidden"'])(
95
+ 'skips a nonsequential target (%s)',
96
+ attributes => {
97
+ content.innerHTML = `<button ${attributes}>Skipped</button><button>Action</button>`;
98
+ enter();
99
+ expect(document.activeElement).toBe(content.lastElementChild);
100
+ },
101
+ );
102
+
103
+ it('rechecks ancestor eligibility on the next entry', () => {
104
+ content.innerHTML = '<div><button>Action</button></div>';
105
+ enter();
106
+ expect(document.activeElement).toBe(content.querySelector('button'));
107
+ content.firstElementChild?.setAttribute('role', 'menu');
108
+ enter();
109
+ expect(document.activeElement).toBe(viewport);
110
+ });
111
+
112
+ it('retains programmatic focus during a Tab handler even without preventDefault', () => {
113
+ content.innerHTML = '<button>Action</button>';
114
+ before.addEventListener('keydown', () => viewport.focus(), {once: true});
115
+ before.focus();
116
+ before.dispatchEvent(
117
+ new KeyboardEvent('keydown', {key: 'Tab', bubbles: true}),
118
+ );
119
+ expect(document.activeElement).toBe(viewport);
120
+ });
121
+
122
+ it('does not retain canceled keyboard intent', () => {
123
+ content.innerHTML = '<button>Action</button>';
124
+ before.addEventListener('keydown', event => event.preventDefault(), {
125
+ once: true,
126
+ });
127
+ before.focus();
128
+ before.dispatchEvent(
129
+ new KeyboardEvent('keydown', {
130
+ key: 'Tab',
131
+ bubbles: true,
132
+ cancelable: true,
133
+ }),
134
+ );
135
+ viewport.focus();
136
+ expect(document.activeElement).toBe(viewport);
137
+ });
138
+
139
+ it('restores the skipped viewport on cancellation and cleans up pending work', () => {
140
+ content.innerHTML = '<button>Action</button><button>Last</button>';
141
+ enter();
142
+ const first = content.firstElementChild as HTMLElement;
143
+ (content.lastElementChild as HTMLElement).focus();
144
+ first.focus();
145
+ first.dispatchEvent(
146
+ new KeyboardEvent('keydown', {key: 'Tab', shiftKey: true, bubbles: true}),
147
+ );
148
+ expect(viewport.tabIndex).toBe(-1);
149
+ detach?.();
150
+ detach = undefined;
151
+ expect(viewport.tabIndex).toBe(0);
152
+ enter();
153
+ expect(document.activeElement).toBe(viewport);
154
+ });
155
+ });
@@ -0,0 +1,233 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file scrollKeyboardDelegation.ts
5
+ * @input A named, overflowing viewport and its real content box
6
+ * @output Focus-time delegation with native forward and reverse Tab traversal
7
+ * @position Private useScrollableArea keyboard behavior; no DOM observers or scrolling
8
+ */
9
+
10
+ import {FOCUSABLE_SELECTOR} from './focusableSelector';
11
+ import {getRegisteredScrollOwnerState} from './scrollOwnerRegistry';
12
+
13
+ // Structural roles may contain ordinary links/buttons. Unknown or interactive
14
+ // roles are deliberately excluded until their navigation contract is proven.
15
+ const PASSIVE_ROLES = new Set([
16
+ 'article',
17
+ 'banner',
18
+ 'complementary',
19
+ 'contentinfo',
20
+ 'definition',
21
+ 'directory',
22
+ 'document',
23
+ 'figure',
24
+ 'form',
25
+ 'generic',
26
+ 'group',
27
+ 'heading',
28
+ 'list',
29
+ 'listitem',
30
+ 'main',
31
+ 'navigation',
32
+ 'none',
33
+ 'note',
34
+ 'paragraph',
35
+ 'presentation',
36
+ 'region',
37
+ 'section',
38
+ 'status',
39
+ 'table',
40
+ 'term',
41
+ ]);
42
+
43
+ function isSequential(element: HTMLElement): boolean {
44
+ if (
45
+ element.tabIndex < 0 ||
46
+ element.matches(':disabled') ||
47
+ element.closest('[inert], [hidden]') != null ||
48
+ element.getClientRects().length === 0
49
+ ) {
50
+ return false;
51
+ }
52
+ const visibility = getComputedStyle(element).visibility;
53
+ return visibility !== 'hidden' && visibility !== 'collapse';
54
+ }
55
+
56
+ function firstDelegationTarget(
57
+ viewport: HTMLElement,
58
+ content: HTMLElement,
59
+ ): HTMLElement | null {
60
+ const candidates = content.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
61
+ let first =
62
+ content.matches(FOCUSABLE_SELECTOR) && isSequential(content)
63
+ ? content
64
+ : null;
65
+ for (const candidate of candidates) {
66
+ // After finding the first target, only positive tabindex can precede it.
67
+ // Ordinary trailing controls need no visibility or layout reads.
68
+ if (first != null && candidate.tabIndex <= 0) {
69
+ continue;
70
+ }
71
+ if (!isSequential(candidate)) {
72
+ continue;
73
+ }
74
+ // Positive tabindex participates before the zero-index viewport. Do not
75
+ // revisit it or skip its ordering by delegating to a later zero-index child.
76
+ if (candidate.tabIndex > 0) {
77
+ return null;
78
+ }
79
+ first ??= candidate;
80
+ }
81
+ if (
82
+ first == null ||
83
+ first.tabIndex !== 0 ||
84
+ !first.matches('a[href], button') ||
85
+ first.closest('[aria-hidden="true"]') != null ||
86
+ first.matches(
87
+ '[aria-disabled="true"], [aria-haspopup]:not([aria-haspopup="false"])',
88
+ )
89
+ ) {
90
+ return null;
91
+ }
92
+
93
+ let ancestor: HTMLElement | null = first;
94
+ while (ancestor != null && ancestor !== viewport) {
95
+ const role: string | undefined = ancestor
96
+ .getAttribute('role')
97
+ ?.trim()
98
+ .toLowerCase();
99
+ const nativeRole: boolean =
100
+ ancestor === first &&
101
+ ((role === 'button' && first.localName === 'button') ||
102
+ (role === 'link' && first.localName === 'a'));
103
+ if (
104
+ (role != null &&
105
+ role !== '' &&
106
+ !nativeRole &&
107
+ !PASSIVE_ROLES.has(role)) ||
108
+ (ancestor === first && role != null && role !== '' && !nativeRole) ||
109
+ ancestor.hasAttribute('aria-activedescendant') ||
110
+ ancestor.isContentEditable ||
111
+ ancestor.matches('[contenteditable]:not([contenteditable="false"])') ||
112
+ getRegisteredScrollOwnerState(ancestor) != null
113
+ ) {
114
+ return null;
115
+ }
116
+ // Also respect native scroll owners that have not adopted the shared hook.
117
+ const style = getComputedStyle(ancestor);
118
+ if (
119
+ (/^(auto|scroll|overlay)$/.test(style.overflowX) &&
120
+ ancestor.scrollWidth > ancestor.clientWidth + 1) ||
121
+ (/^(auto|scroll|overlay)$/.test(style.overflowY) &&
122
+ ancestor.scrollHeight > ancestor.clientHeight + 1)
123
+ ) {
124
+ return null;
125
+ }
126
+ ancestor = ancestor.parentElement;
127
+ }
128
+ return ancestor === viewport ? first : null;
129
+ }
130
+
131
+ /** Attach only while automatic ownership has an effective scroll axis. */
132
+ export function attachScrollKeyboardDelegation(
133
+ viewport: HTMLElement,
134
+ content: HTMLElement,
135
+ ): () => void {
136
+ const document = viewport.ownerDocument;
137
+ const window = document.defaultView;
138
+ if (window == null) {
139
+ return () => {};
140
+ }
141
+ let entry: KeyboardEvent | null = null;
142
+ let origin: EventTarget | null = null;
143
+ let delegated: HTMLElement | null = null;
144
+ let timer: number | undefined;
145
+ let restoreTabStop: (() => void) | undefined;
146
+
147
+ const reset = () => {
148
+ entry = null;
149
+ origin = null;
150
+ window.clearTimeout(timer);
151
+ restoreTabStop?.();
152
+ restoreTabStop = undefined;
153
+ };
154
+
155
+ const onKeyDown = (event: KeyboardEvent) => {
156
+ reset();
157
+ if (event.key !== 'Tab' || event.ctrlKey || event.metaKey) {
158
+ return;
159
+ }
160
+ // Keep the actual event: focus() during keydown is programmatic (nonzero
161
+ // eventPhase), whereas native Tab focus follows completed event dispatch.
162
+ entry = event;
163
+ origin = event.target;
164
+ if (
165
+ event.shiftKey &&
166
+ delegated != null &&
167
+ document.activeElement === delegated &&
168
+ viewport.contains(delegated)
169
+ ) {
170
+ const tabIndex = viewport.getAttribute('tabindex');
171
+ viewport.tabIndex = -1;
172
+ restoreTabStop = () => {
173
+ if (viewport.getAttribute('tabindex') === '-1') {
174
+ if (tabIndex == null) {
175
+ viewport.removeAttribute('tabindex');
176
+ } else {
177
+ viewport.setAttribute('tabindex', tabIndex);
178
+ }
179
+ }
180
+ };
181
+ }
182
+ // A canceled Tab, focus leaving the document, or a held key must not leave
183
+ // stale keyboard intent for a later pointer/programmatic focus operation.
184
+ timer = window.setTimeout(reset, 0);
185
+ };
186
+
187
+ const onFocusIn = (event: FocusEvent) => {
188
+ const keyboardEntry = entry;
189
+ const previous = origin;
190
+ reset();
191
+ if (event.target !== viewport) {
192
+ if (!viewport.contains(event.target as Node | null)) {
193
+ delegated = null;
194
+ }
195
+ return;
196
+ }
197
+ delegated = null;
198
+ if (
199
+ keyboardEntry == null ||
200
+ keyboardEntry.shiftKey ||
201
+ keyboardEntry.defaultPrevented ||
202
+ keyboardEntry.eventPhase !== 0 ||
203
+ (event.relatedTarget != null && event.relatedTarget !== previous)
204
+ ) {
205
+ return;
206
+ }
207
+ const target = firstDelegationTarget(viewport, content);
208
+ if (target == null) {
209
+ return;
210
+ }
211
+ delegated = target;
212
+ // Let native focus reveal the action. Arrow/Page scrolling remains entirely
213
+ // browser-owned, including scroll chaining and the full viewport range.
214
+ target.focus();
215
+ if (document.activeElement !== target) {
216
+ delegated = null;
217
+ }
218
+ };
219
+
220
+ document.addEventListener('keydown', onKeyDown, true);
221
+ document.addEventListener('keyup', reset, true);
222
+ document.addEventListener('pointerdown', reset, true);
223
+ document.addEventListener('focusin', onFocusIn, true);
224
+ window.addEventListener('blur', reset);
225
+ return () => {
226
+ reset();
227
+ document.removeEventListener('keydown', onKeyDown, true);
228
+ document.removeEventListener('keyup', reset, true);
229
+ document.removeEventListener('pointerdown', reset, true);
230
+ document.removeEventListener('focusin', onFocusIn, true);
231
+ window.removeEventListener('blur', reset);
232
+ };
233
+ }
@@ -1,5 +1,12 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
+ /**
4
+ * @file useScrollableArea.doc.mjs
5
+ * @input Shared scroll hook's fixed and entry-time keyboard policies
6
+ * @output Consumer guidance for named viewports and safe focus delegation
7
+ * @position Hook documentation consumed by the CLI and docsite
8
+ */
9
+
3
10
  /** @type {import('@astryxdesign/cli/authoring').HookDoc} */
4
11
  export const docs = {
5
12
  name: 'useScrollableArea',
@@ -18,7 +25,7 @@ export const docs = {
18
25
  name: 'options',
19
26
  type: 'UseScrollableAreaOptions',
20
27
  description:
21
- 'Logical scroll intent, keyboard owner, overscroll policy, and fitting Sticky containment.',
28
+ 'Logical scroll intent, fixed or automatic keyboard owner, overscroll policy, and fitting Sticky containment.',
22
29
  required: true,
23
30
  },
24
31
  ],
@@ -59,7 +66,12 @@ export const docs = {
59
66
  {
60
67
  guidance: true,
61
68
  description:
62
- 'Use content keyboard ownership when an existing focusable descendant gives keyboard users access to all overflowed content.',
69
+ 'Use contentOrViewport to delegate forward Tab entry to the first sequential native link or button when it preserves native scroll keys. Inputs, composite widgets, and nested scroll areas retain the named viewport stop. Shift+Tab from the delegated first child skips the viewport; pointer and programmatic focus stay on it.',
70
+ },
71
+ {
72
+ guidance: true,
73
+ description:
74
+ 'Use content keyboard ownership when your integration already supplies keyboard access to the full scroll range. Automatic delegation checks current content at each keyboard entry without continuously tracking its focusability.',
63
75
  },
64
76
  {
65
77
  guidance: true,
@@ -85,7 +97,7 @@ export const docsDense = {
85
97
  'Composes logical-axis scrolling into caller-owned viewport/content elements with stable effective-axis and edge state.',
86
98
  paramDescriptions: {
87
99
  options:
88
- 'axis, keyboard owner, allow/contain overscroll policy, and fitting Sticky containment.',
100
+ 'axis, fixed/automatic keyboard owner, allow/contain overscroll policy, and fitting Sticky containment.',
89
101
  },
90
102
  returnDescriptions: {
91
103
  getViewportProps:
@@ -1,8 +1,15 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
+ /**
4
+ * @file useScrollableArea.test.tsx
5
+ * @input Shared scroll hook, mocked geometry, and observed DOM changes
6
+ * @output Regression coverage for measurement, composition, and stable viewport access
7
+ * @position DOM-level hook contract; native traversal is verified in browser tests
8
+ */
9
+
3
10
  import {act, render, screen} from '@testing-library/react';
4
11
  import * as stylex from '@stylexjs/stylex';
5
- import {useRef, type Ref} from 'react';
12
+ import {useRef, type ReactNode, type Ref} from 'react';
6
13
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
7
14
  import {
8
15
  useScrollableArea,
@@ -194,6 +201,57 @@ describe('useScrollableArea', () => {
194
201
  expect(viewport.getAttribute('style')).toContain('--x-overflowY: hidden');
195
202
  });
196
203
 
204
+ it('keeps the named viewport available while content eligibility changes', () => {
205
+ function AdaptiveFixture({children}: {children?: ReactNode}) {
206
+ const area = useScrollableArea({
207
+ axis: 'block',
208
+ keyboardAccess: {
209
+ owner: 'contentOrViewport',
210
+ label: 'Adaptive results',
211
+ role: 'region',
212
+ },
213
+ });
214
+ return (
215
+ <div data-testid="adaptive-viewport" {...area.getViewportProps()}>
216
+ <div data-testid="adaptive-content" {...area.getContentProps()}>
217
+ {children}
218
+ </div>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue([
224
+ new DOMRect(0, 0, 20, 20),
225
+ ] as unknown as DOMRectList);
226
+ const {rerender} = render(<AdaptiveFixture>Plain text</AdaptiveFixture>);
227
+ const viewport = screen.getByTestId('adaptive-viewport');
228
+ const content = screen.getByTestId('adaptive-content');
229
+ makeMeasurable(viewport);
230
+ setGeometry(viewport, {scrollHeight: 180});
231
+
232
+ void act(() => viewport.dispatchEvent(new Event('scroll')));
233
+ flushFrame();
234
+ expect(viewport).toHaveAttribute('role', 'region');
235
+ expect(viewport).toHaveAccessibleName('Adaptive results');
236
+ expect(viewport).toHaveAttribute('tabindex', '0');
237
+
238
+ viewport.focus();
239
+ rerender(
240
+ <AdaptiveFixture>
241
+ <button type="button">Use existing action</button>
242
+ </AdaptiveFixture>,
243
+ );
244
+ void act(() => content.dispatchEvent(new Event('transitionend')));
245
+ flushFrame();
246
+ expect(viewport).toHaveAttribute('tabindex', '0');
247
+ expect(viewport).toHaveFocus();
248
+
249
+ rerender(<AdaptiveFixture>Plain text again</AdaptiveFixture>);
250
+ void act(() => content.dispatchEvent(new Event('transitionend')));
251
+ flushFrame();
252
+ expect(viewport).toHaveAttribute('tabindex', '0');
253
+ });
254
+
197
255
  it('requires scroll-capable computed overflow and more than 1px excess geometry', () => {
198
256
  render(<Fixture />);
199
257
  const viewport = screen.getByTestId('viewport');