@astryxdesign/core 0.1.7 → 0.1.8-canary.c5fa19b

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 (190) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +30 -19
  3. package/dist/Avatar/Avatar.d.ts +10 -5
  4. package/dist/Avatar/Avatar.d.ts.map +1 -1
  5. package/dist/Avatar/Avatar.js +12 -8
  6. package/dist/Avatar/AvatarSizeContext.d.ts +1 -1
  7. package/dist/Avatar/AvatarSizeContext.js +1 -1
  8. package/dist/Avatar/AvatarStatusDot.d.ts +2 -2
  9. package/dist/Avatar/AvatarStatusDot.js +2 -2
  10. package/dist/AvatarGroup/AvatarGroup.d.ts +2 -2
  11. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  12. package/dist/AvatarGroup/AvatarGroup.js +2 -2
  13. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  14. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  15. package/dist/AvatarGroup/AvatarGroupOverflow.js +16 -14
  16. package/dist/Carousel/Carousel.d.ts +3 -2
  17. package/dist/Carousel/Carousel.d.ts.map +1 -1
  18. package/dist/Carousel/Carousel.js +32 -2
  19. package/dist/Chat/ChatMessage.d.ts +1 -1
  20. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  21. package/dist/Chat/ChatMessage.js +1 -1
  22. package/dist/Citation/Citation.d.ts.map +1 -1
  23. package/dist/Citation/Citation.js +10 -4
  24. package/dist/Collapsible/Collapsible.d.ts +11 -1
  25. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  26. package/dist/Collapsible/Collapsible.js +36 -4
  27. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  28. package/dist/ContextMenu/ContextMenu.js +4 -3
  29. package/dist/ContextMenu/index.d.ts +3 -0
  30. package/dist/ContextMenu/index.d.ts.map +1 -1
  31. package/dist/ContextMenu/index.js +7 -1
  32. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.js +8 -4
  34. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +89 -0
  35. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -0
  36. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +170 -0
  37. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -1
  38. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenuContext.js +18 -0
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +69 -0
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -0
  42. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +70 -0
  43. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +69 -0
  44. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -0
  45. package/dist/DropdownMenu/DropdownMenuRadioItem.js +178 -0
  46. package/dist/DropdownMenu/index.d.ts +5 -1
  47. package/dist/DropdownMenu/index.d.ts.map +1 -1
  48. package/dist/DropdownMenu/index.js +10 -1
  49. package/dist/DropdownMenu/menuItemRoles.d.ts +13 -0
  50. package/dist/DropdownMenu/menuItemRoles.d.ts.map +1 -0
  51. package/dist/DropdownMenu/menuItemRoles.js +15 -0
  52. package/dist/Icon/Icon.d.ts +43 -13
  53. package/dist/Icon/Icon.d.ts.map +1 -1
  54. package/dist/Icon/Icon.js +62 -27
  55. package/dist/Outline/Outline.d.ts +69 -3
  56. package/dist/Outline/Outline.d.ts.map +1 -1
  57. package/dist/Outline/Outline.js +129 -26
  58. package/dist/Outline/useScrollSpy.d.ts +27 -7
  59. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  60. package/dist/Outline/useScrollSpy.js +202 -70
  61. package/dist/TabList/Tab.d.ts.map +1 -1
  62. package/dist/TabList/Tab.js +2 -2
  63. package/dist/TabList/TabList.d.ts.map +1 -1
  64. package/dist/TabList/TabList.js +2 -0
  65. package/dist/TabList/TabMenu.d.ts.map +1 -1
  66. package/dist/TabList/TabMenu.js +1 -1
  67. package/dist/Table/Table.d.ts +1 -1
  68. package/dist/Table/Table.js +1 -1
  69. package/dist/Table/index.d.ts +2 -0
  70. package/dist/Table/index.d.ts.map +1 -1
  71. package/dist/Table/index.js +1 -0
  72. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  73. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +24 -2
  74. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  75. package/dist/Table/plugins/sortable/useTableSortable.js +18 -7
  76. package/dist/Table/plugins/tree/index.d.ts +5 -0
  77. package/dist/Table/plugins/tree/index.d.ts.map +1 -0
  78. package/dist/Table/plugins/tree/index.js +4 -0
  79. package/dist/Table/plugins/tree/useTableTreeData.d.ts +38 -0
  80. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -0
  81. package/dist/Table/plugins/tree/useTableTreeData.js +358 -0
  82. package/dist/Table/plugins/tree/useTableTreeState.d.ts +51 -0
  83. package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +1 -0
  84. package/dist/Table/plugins/tree/useTableTreeState.js +175 -0
  85. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  86. package/dist/Table/useBaseTablePlugins.js +6 -3
  87. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  88. package/dist/Toolbar/Toolbar.js +8 -1
  89. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  90. package/dist/TreeList/TreeListItem.js +5 -14
  91. package/dist/astryx.css +24 -4
  92. package/dist/astryx.umd.js +54 -54
  93. package/dist/astryx.umd.js.map +4 -4
  94. package/dist/theme/Theme.js +1 -1
  95. package/dist/theme/defineTheme.d.ts +1 -1
  96. package/dist/theme/defineTheme.js +1 -1
  97. package/dist/theme/hct.d.ts +0 -1
  98. package/dist/theme/hct.d.ts.map +1 -1
  99. package/dist/theme/hct.js +0 -3
  100. package/docs.mjs +32 -461
  101. package/locales/en.json +200 -172
  102. package/locales/pseudo.json +21 -0
  103. package/package.json +3 -1
  104. package/scripts/postinstall.mjs +93 -0
  105. package/src/Avatar/Avatar.doc.mjs +6 -6
  106. package/src/Avatar/Avatar.tsx +16 -11
  107. package/src/Avatar/AvatarSizeContext.ts +1 -1
  108. package/src/Avatar/AvatarStatusDot.tsx +2 -2
  109. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  110. package/src/AvatarGroup/AvatarGroup.test.tsx +17 -3
  111. package/src/AvatarGroup/AvatarGroup.tsx +3 -3
  112. package/src/AvatarGroup/AvatarGroupOverflow.doc.mjs +4 -4
  113. package/src/AvatarGroup/AvatarGroupOverflow.tsx +21 -5
  114. package/src/Carousel/Carousel.doc.mjs +1 -0
  115. package/src/Carousel/Carousel.test.tsx +96 -1
  116. package/src/Carousel/Carousel.tsx +29 -2
  117. package/src/Chat/Chat.doc.mjs +1 -1
  118. package/src/Chat/ChatMessage.tsx +4 -2
  119. package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
  120. package/src/Citation/Citation.test.tsx +51 -0
  121. package/src/Citation/Citation.tsx +8 -2
  122. package/src/Collapsible/Collapsible.doc.mjs +7 -0
  123. package/src/Collapsible/Collapsible.test.tsx +80 -0
  124. package/src/Collapsible/Collapsible.tsx +58 -5
  125. package/src/ContextMenu/ContextMenu.test.tsx +75 -1
  126. package/src/ContextMenu/ContextMenu.tsx +10 -5
  127. package/src/ContextMenu/index.ts +15 -0
  128. package/src/DropdownMenu/DropdownMenu.doc.mjs +23 -0
  129. package/src/DropdownMenu/DropdownMenu.tsx +11 -6
  130. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +95 -0
  131. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +237 -0
  132. package/src/DropdownMenu/DropdownMenuContext.tsx +29 -1
  133. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +7 -1
  134. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +80 -0
  135. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +119 -0
  136. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +80 -0
  137. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +232 -0
  138. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +163 -0
  139. package/src/DropdownMenu/index.ts +23 -3
  140. package/src/DropdownMenu/menuItemRoles.ts +22 -0
  141. package/src/Field/Field.doc.mjs +1 -1
  142. package/src/Field/FieldLabel.doc.mjs +1 -1
  143. package/src/Icon/Icon.doc.mjs +12 -0
  144. package/src/Icon/Icon.test.tsx +142 -0
  145. package/src/Icon/Icon.tsx +98 -30
  146. package/src/Item/Item.doc.mjs +1 -1
  147. package/src/NumberInput/NumberInput.doc.mjs +2 -2
  148. package/src/Outline/Outline.doc.mjs +123 -3
  149. package/src/Outline/Outline.test.tsx +735 -0
  150. package/src/Outline/Outline.tsx +184 -33
  151. package/src/Outline/useScrollSpy.ts +248 -72
  152. package/src/Selector/SelectorOption.doc.mjs +1 -1
  153. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  154. package/src/Switch/Switch.doc.mjs +1 -1
  155. package/src/TabList/Tab.tsx +5 -1
  156. package/src/TabList/TabList.doc.mjs +3 -3
  157. package/src/TabList/TabList.test.tsx +66 -0
  158. package/src/TabList/TabList.tsx +8 -0
  159. package/src/TabList/TabMenu.tsx +4 -1
  160. package/src/Table/Table.doc.mjs +2 -0
  161. package/src/Table/Table.tsx +1 -1
  162. package/src/Table/index.ts +7 -0
  163. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +90 -1
  164. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +37 -5
  165. package/src/Table/plugins/sortable/useTableSortable.test.tsx +95 -9
  166. package/src/Table/plugins/sortable/useTableSortable.tsx +23 -7
  167. package/src/Table/plugins/tree/index.ts +12 -0
  168. package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +163 -0
  169. package/src/Table/plugins/tree/useTableTreeData.test.tsx +594 -0
  170. package/src/Table/plugins/tree/useTableTreeData.tsx +495 -0
  171. package/src/Table/plugins/tree/useTableTreeState.test.tsx +636 -0
  172. package/src/Table/plugins/tree/useTableTreeState.tsx +253 -0
  173. package/src/Table/useBaseTablePlugins.ts +6 -2
  174. package/src/Table/useTableTreeData.doc.mjs +75 -0
  175. package/src/Table/useTableTreeState.doc.mjs +98 -0
  176. package/src/TextArea/TextArea.doc.mjs +1 -1
  177. package/src/TextInput/TextInput.doc.mjs +1 -1
  178. package/src/Toolbar/Toolbar.tsx +10 -3
  179. package/src/TreeList/TreeList.test.tsx +32 -1
  180. package/src/TreeList/TreeListItem.tsx +19 -10
  181. package/src/docs-script.test.mjs +18 -13
  182. package/src/postinstall.test.mjs +71 -0
  183. package/src/theme/Theme.doc.mjs +4 -4
  184. package/src/theme/Theme.tsx +1 -1
  185. package/src/theme/defineTheme.ts +1 -1
  186. package/src/theme/hct.ts +0 -4
  187. package/dist/TreeList/treeListItem.markers.stylex.d.ts +0 -16
  188. package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +0 -1
  189. package/dist/TreeList/treeListItem.markers.stylex.js +0 -22
  190. package/src/TreeList/treeListItem.markers.stylex.ts +0 -20
@@ -287,6 +287,741 @@ describe('Outline', () => {
287
287
  });
288
288
  });
289
289
 
290
+ /**
291
+ * Override `scroll-margin-top` for specific elements in `getComputedStyle`.
292
+ * jsdom's cssstyle does not resolve `scroll-margin-top`, so the real value can
293
+ * never be read back from an inline style — the property must be faked at the
294
+ * `getComputedStyle` boundary that useScrollSpy actually reads. Every other
295
+ * element (and every other property) falls through to the real implementation,
296
+ * so StyleX and Testing Library are unaffected.
297
+ */
298
+ function mockScrollMarginTop(overrides: Map<Element, string>) {
299
+ const original = window.getComputedStyle.bind(window);
300
+ vi.spyOn(window, 'getComputedStyle').mockImplementation(
301
+ (element, pseudoElement) => {
302
+ const style = original(element, pseudoElement ?? undefined);
303
+ const scrollMarginTop = overrides.get(element);
304
+ if (scrollMarginTop != null) {
305
+ // An own property shadows the prototype's accessor, so the real
306
+ // CSSStyleDeclaration keeps working for every other property.
307
+ Object.defineProperty(style, 'scrollMarginTop', {
308
+ value: scrollMarginTop,
309
+ configurable: true,
310
+ });
311
+ }
312
+ return style;
313
+ },
314
+ );
315
+ }
316
+
317
+ /**
318
+ * Mount real heading elements for `items` so navigation has scroll targets.
319
+ * Returns a cleanup that removes them.
320
+ */
321
+ function mountHeadings(ids: string[] = items.map(item => item.id)) {
322
+ const headings = ids.map(id => {
323
+ const heading = document.createElement('h2');
324
+ heading.id = id;
325
+ document.body.appendChild(heading);
326
+ return heading;
327
+ });
328
+ return () => {
329
+ for (const heading of headings) {
330
+ heading.remove();
331
+ }
332
+ };
333
+ }
334
+
335
+ describe('Outline keyboard navigation', () => {
336
+ beforeEach(() => {
337
+ Element.prototype.scrollIntoView = vi.fn();
338
+ });
339
+
340
+ afterEach(() => {
341
+ vi.restoreAllMocks();
342
+ });
343
+
344
+ it('exposes a single tab stop (roving tabindex) instead of one per heading', () => {
345
+ render(<Outline items={items} />);
346
+ const links = screen.getAllByRole('link');
347
+
348
+ // A 40-heading TOC must not cost 40 Tab presses: exactly one link is in
349
+ // the page tab order, the rest are reachable with arrow keys.
350
+ expect(links.map(link => link.getAttribute('tabindex'))).toEqual([
351
+ '0',
352
+ '-1',
353
+ '-1',
354
+ ]);
355
+ });
356
+
357
+ it('seats the tab stop on the active heading, not always the first', () => {
358
+ // WAI-ARIA roving tabindex: the single tab stop belongs on the *current*
359
+ // item. Tabbing into a TOC while reading section 3 must land on section 3,
360
+ // not send the reader back to section 1.
361
+ render(<Outline items={items} activeId="api" />);
362
+ const links = screen.getAllByRole('link');
363
+
364
+ expect(links.map(link => link.getAttribute('tabindex'))).toEqual([
365
+ '-1',
366
+ '-1',
367
+ '0',
368
+ ]);
369
+ });
370
+
371
+ it('moves the tab stop as the active heading changes', () => {
372
+ const {rerender} = render(<Outline items={items} activeId="intro" />);
373
+ expect(
374
+ screen.getAllByRole('link').map(l => l.getAttribute('tabindex')),
375
+ ).toEqual(['0', '-1', '-1']);
376
+
377
+ // Scroll-spy advances the active section: the tab stop must follow it.
378
+ rerender(<Outline items={items} activeId="install" />);
379
+ expect(
380
+ screen.getAllByRole('link').map(l => l.getAttribute('tabindex')),
381
+ ).toEqual(['-1', '0', '-1']);
382
+ });
383
+
384
+ it('does not yank the tab stop away from the item the user arrowed to', async () => {
385
+ const user = userEvent.setup();
386
+ const {rerender} = render(<Outline items={items} activeId="intro" />);
387
+ const links = screen.getAllByRole('link');
388
+
389
+ // User arrow-keys down to `api` and keeps focus there.
390
+ links[0].focus();
391
+ await user.keyboard('{ArrowDown}{ArrowDown}');
392
+ expect(links[2]).toHaveFocus();
393
+ expect(links[2]).toHaveAttribute('tabindex', '0');
394
+
395
+ // Scroll-spy moves the active section underneath them. Focus is still
396
+ // inside the list, so the tab stop must stay where the user put it.
397
+ rerender(<Outline items={items} activeId="install" />);
398
+ expect(links[2]).toHaveAttribute('tabindex', '0');
399
+ expect(links[1]).toHaveAttribute('tabindex', '-1');
400
+ });
401
+
402
+ it('moves focus with ArrowDown / ArrowUp', async () => {
403
+ const user = userEvent.setup();
404
+ render(<Outline items={items} />);
405
+ const [intro, install] = screen.getAllByRole('link');
406
+
407
+ intro.focus();
408
+ await user.keyboard('{ArrowDown}');
409
+ expect(install).toHaveFocus();
410
+ expect(install).toHaveAttribute('tabindex', '0');
411
+ expect(intro).toHaveAttribute('tabindex', '-1');
412
+
413
+ await user.keyboard('{ArrowUp}');
414
+ expect(intro).toHaveFocus();
415
+ });
416
+
417
+ it('jumps to first / last with Home / End', async () => {
418
+ const user = userEvent.setup();
419
+ render(<Outline items={items} />);
420
+ const links = screen.getAllByRole('link');
421
+
422
+ links[0].focus();
423
+ await user.keyboard('{End}');
424
+ expect(links[2]).toHaveFocus();
425
+
426
+ await user.keyboard('{Home}');
427
+ expect(links[0]).toHaveFocus();
428
+ });
429
+
430
+ it('activates the focused link with Space', async () => {
431
+ const user = userEvent.setup();
432
+ const cleanup = mountHeadings();
433
+ const onNavigateStart = vi.fn();
434
+
435
+ render(<Outline items={items} onNavigateStart={onNavigateStart} />);
436
+ const install = screen.getAllByRole('link')[1];
437
+ install.focus();
438
+ await user.keyboard('[Space]');
439
+
440
+ expect(onNavigateStart).toHaveBeenCalledWith('install');
441
+ expect(document.getElementById('install')?.scrollIntoView).toBeDefined();
442
+ expect(
443
+ (document.getElementById('install') as HTMLElement).scrollIntoView,
444
+ ).toHaveBeenCalledWith({behavior: 'smooth', block: 'start'});
445
+
446
+ cleanup();
447
+ });
448
+
449
+ it('activates the focused link with Enter', async () => {
450
+ const user = userEvent.setup();
451
+ const cleanup = mountHeadings();
452
+ const onNavigateStart = vi.fn();
453
+
454
+ render(<Outline items={items} onNavigateStart={onNavigateStart} />);
455
+ screen.getAllByRole('link')[2].focus();
456
+ await user.keyboard('{Enter}');
457
+
458
+ expect(onNavigateStart).toHaveBeenCalledWith('api');
459
+ cleanup();
460
+ });
461
+
462
+ it('leaves modifier chords to the browser (Cmd/Ctrl + Space is not activation)', async () => {
463
+ const user = userEvent.setup();
464
+ const cleanup = mountHeadings();
465
+ const onNavigateStart = vi.fn();
466
+
467
+ render(<Outline items={items} onNavigateStart={onNavigateStart} />);
468
+ screen.getAllByRole('link')[1].focus();
469
+ await user.keyboard('{Meta>}[Space]{/Meta}');
470
+
471
+ expect(onNavigateStart).not.toHaveBeenCalled();
472
+ cleanup();
473
+ });
474
+
475
+ it('does not treat its own Space activation as a manual scroll', async () => {
476
+ const user = userEvent.setup();
477
+ const cleanup = mountHeadings();
478
+ const onNavigateEnd = vi.fn();
479
+
480
+ render(<Outline items={items} onNavigateEnd={onNavigateEnd} />);
481
+ screen.getAllByRole('link')[1].focus();
482
+ await user.keyboard('[Space]');
483
+
484
+ // Space is one of the keys that normally scroll the page, and the settle
485
+ // watcher listens on window — so the very keydown that started this
486
+ // navigation would bubble up and cancel it. Because we preventDefault it,
487
+ // no scroll can happen and the navigation must survive.
488
+ expect(onNavigateEnd).not.toHaveBeenCalled();
489
+
490
+ act(() => {
491
+ window.dispatchEvent(new Event('scrollend'));
492
+ });
493
+ expect(onNavigateEnd).toHaveBeenCalledTimes(1);
494
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
495
+ 'aria-current',
496
+ 'true',
497
+ );
498
+
499
+ cleanup();
500
+ });
501
+
502
+ it('does not treat arrow-key roving focus as a manual scroll', async () => {
503
+ const user = userEvent.setup();
504
+ const cleanup = mountHeadings();
505
+ const onNavigateEnd = vi.fn();
506
+
507
+ render(<Outline items={items} onNavigateEnd={onNavigateEnd} />);
508
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
509
+
510
+ // Arrow keys inside the outline move focus; they are prevented, so they do
511
+ // not scroll the page and must not cancel the in-flight navigation.
512
+ await user.keyboard('{ArrowDown}');
513
+ expect(onNavigateEnd).not.toHaveBeenCalled();
514
+
515
+ cleanup();
516
+ });
517
+
518
+ it('does not steal Tab from the page', async () => {
519
+ const user = userEvent.setup();
520
+ render(
521
+ <>
522
+ <Outline items={items} />
523
+ <button type="button">After</button>
524
+ </>,
525
+ );
526
+
527
+ screen.getAllByRole('link')[0].focus();
528
+ await user.tab();
529
+
530
+ // The non-tabbable links are skipped, so one Tab leaves the whole outline.
531
+ expect(screen.getByRole('button', {name: 'After'})).toHaveFocus();
532
+ });
533
+
534
+ it('keeps a single tab stop with one item, and none with zero items', () => {
535
+ const {rerender} = render(
536
+ <Outline items={[{id: 'only', label: 'Only', level: 2}]} />,
537
+ );
538
+ expect(screen.getAllByRole('link')[0]).toHaveAttribute('tabindex', '0');
539
+
540
+ rerender(<Outline items={[]} />);
541
+ expect(screen.queryAllByRole('link')).toHaveLength(0);
542
+ });
543
+ });
544
+
545
+ describe('Outline navigate callbacks', () => {
546
+ beforeEach(() => {
547
+ Element.prototype.scrollIntoView = vi.fn();
548
+ });
549
+
550
+ afterEach(() => {
551
+ vi.restoreAllMocks();
552
+ vi.useRealTimers();
553
+ });
554
+
555
+ it('fires onNavigateStart on click and onNavigateEnd when the scroll settles', async () => {
556
+ const user = userEvent.setup();
557
+ const cleanup = mountHeadings();
558
+ const onNavigateStart = vi.fn();
559
+ const onNavigateEnd = vi.fn();
560
+
561
+ render(
562
+ <Outline
563
+ items={items}
564
+ onNavigateStart={onNavigateStart}
565
+ onNavigateEnd={onNavigateEnd}
566
+ />,
567
+ );
568
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
569
+
570
+ expect(onNavigateStart).toHaveBeenCalledWith('install');
571
+ // Still scrolling — arrival has not happened yet.
572
+ expect(onNavigateEnd).not.toHaveBeenCalled();
573
+
574
+ act(() => {
575
+ window.dispatchEvent(new Event('scrollend'));
576
+ });
577
+ expect(onNavigateEnd).toHaveBeenCalledTimes(1);
578
+ expect(onNavigateEnd).toHaveBeenCalledWith('install');
579
+
580
+ cleanup();
581
+ });
582
+
583
+ it('fires the callbacks in controlled mode too', async () => {
584
+ const user = userEvent.setup();
585
+ const cleanup = mountHeadings();
586
+ const onNavigateStart = vi.fn();
587
+ const onNavigateEnd = vi.fn();
588
+
589
+ render(
590
+ <Outline
591
+ items={items}
592
+ activeId="intro"
593
+ onNavigateStart={onNavigateStart}
594
+ onNavigateEnd={onNavigateEnd}
595
+ />,
596
+ );
597
+ await user.click(screen.getByRole('link', {name: 'API'}));
598
+
599
+ expect(onNavigateStart).toHaveBeenCalledWith('api');
600
+ act(() => {
601
+ window.dispatchEvent(new Event('scrollend'));
602
+ });
603
+ expect(onNavigateEnd).toHaveBeenCalledTimes(1);
604
+ expect(onNavigateEnd).toHaveBeenCalledWith('api');
605
+
606
+ cleanup();
607
+ });
608
+
609
+ it('falls back to a settle timeout when scrollend never fires', async () => {
610
+ vi.useFakeTimers({shouldAdvanceTime: true});
611
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
612
+ const cleanup = mountHeadings();
613
+ const onNavigateEnd = vi.fn();
614
+
615
+ render(<Outline items={items} onNavigateEnd={onNavigateEnd} />);
616
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
617
+
618
+ expect(onNavigateEnd).not.toHaveBeenCalled();
619
+ act(() => {
620
+ vi.advanceTimersByTime(2000);
621
+ });
622
+ expect(onNavigateEnd).toHaveBeenCalledTimes(1);
623
+ expect(onNavigateEnd).toHaveBeenCalledWith('install');
624
+
625
+ cleanup();
626
+ });
627
+
628
+ it('fires onNavigateEnd exactly once when scrollend AND the fallback both elapse', async () => {
629
+ vi.useFakeTimers({shouldAdvanceTime: true});
630
+ const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime});
631
+ const cleanup = mountHeadings();
632
+ const onNavigateEnd = vi.fn();
633
+
634
+ render(<Outline items={items} onNavigateEnd={onNavigateEnd} />);
635
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
636
+
637
+ // Reduced motion turns the smooth scroll into an instant jump: scrollend
638
+ // arrives immediately. The fallback timer must not fire a second time.
639
+ act(() => {
640
+ window.dispatchEvent(new Event('scrollend'));
641
+ window.dispatchEvent(new Event('scrollend'));
642
+ vi.advanceTimersByTime(5000);
643
+ });
644
+ expect(onNavigateEnd).toHaveBeenCalledTimes(1);
645
+ expect(onNavigateEnd).toHaveBeenCalledWith('install');
646
+
647
+ cleanup();
648
+ });
649
+
650
+ it('still fires onNavigateEnd once when a manual scroll interrupts the jump', async () => {
651
+ const user = userEvent.setup();
652
+ const cleanup = mountHeadings();
653
+ const onNavigateStart = vi.fn();
654
+ const onNavigateEnd = vi.fn();
655
+
656
+ render(
657
+ <Outline
658
+ items={items}
659
+ onNavigateStart={onNavigateStart}
660
+ onNavigateEnd={onNavigateEnd}
661
+ />,
662
+ );
663
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
664
+ expect(onNavigateStart).toHaveBeenCalledTimes(1);
665
+
666
+ act(() => {
667
+ window.dispatchEvent(new Event('wheel'));
668
+ });
669
+
670
+ // Every onNavigateStart is balanced by exactly one onNavigateEnd, so a
671
+ // consumer's "navigating" state can never leak.
672
+ expect(onNavigateEnd).toHaveBeenCalledTimes(1);
673
+ expect(onNavigateEnd).toHaveBeenCalledWith('install');
674
+
675
+ cleanup();
676
+ });
677
+
678
+ it('does not fire callbacks when the target heading is missing', async () => {
679
+ const user = userEvent.setup();
680
+ const onNavigateStart = vi.fn();
681
+ const onNavigateEnd = vi.fn();
682
+
683
+ render(
684
+ <Outline
685
+ items={items}
686
+ onNavigateStart={onNavigateStart}
687
+ onNavigateEnd={onNavigateEnd}
688
+ />,
689
+ );
690
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
691
+
692
+ expect(onNavigateStart).not.toHaveBeenCalled();
693
+ expect(onNavigateEnd).not.toHaveBeenCalled();
694
+ });
695
+
696
+ it('leaves a missing target to the browser instead of deadening the link', async () => {
697
+ const user = userEvent.setup();
698
+ window.location.hash = '';
699
+
700
+ render(<Outline items={items} />);
701
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
702
+
703
+ // The heading is not in the DOM (lazily-rendered or virtualized content).
704
+ // We own the scroll only when we have something to scroll to; with no
705
+ // target we must NOT preventDefault, or the anchor becomes a total no-op.
706
+ // The browser's native fragment navigation still updates the URL, so a
707
+ // later render / deep link can still resolve it.
708
+ expect(window.location.hash).toBe('#install');
709
+
710
+ window.location.hash = '';
711
+ });
712
+
713
+ it('does not fire callbacks for a modified (Cmd) click', async () => {
714
+ const user = userEvent.setup();
715
+ const cleanup = mountHeadings();
716
+ const onNavigateStart = vi.fn();
717
+
718
+ render(<Outline items={items} onNavigateStart={onNavigateStart} />);
719
+ await user.keyboard('{Meta>}');
720
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
721
+ await user.keyboard('{/Meta}');
722
+
723
+ expect(onNavigateStart).not.toHaveBeenCalled();
724
+ cleanup();
725
+ });
726
+ });
727
+
728
+ describe('Outline scroll scoping', () => {
729
+ beforeEach(() => {
730
+ Element.prototype.scrollIntoView = vi.fn();
731
+ // jsdom implements neither; the offset path drives the scroll through them.
732
+ Element.prototype.scrollBy = vi.fn();
733
+ window.scrollBy = vi.fn();
734
+ });
735
+
736
+ afterEach(() => {
737
+ vi.restoreAllMocks();
738
+ });
739
+
740
+ it('hasScrollOnClick={false} skips the scroll but still navigates', async () => {
741
+ const user = userEvent.setup();
742
+ const cleanup = mountHeadings();
743
+ const onNavigateStart = vi.fn();
744
+ const onNavigateEnd = vi.fn();
745
+
746
+ render(
747
+ <Outline
748
+ items={items}
749
+ hasScrollOnClick={false}
750
+ onNavigateStart={onNavigateStart}
751
+ onNavigateEnd={onNavigateEnd}
752
+ />,
753
+ );
754
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
755
+
756
+ const target = document.getElementById('install') as HTMLElement;
757
+ expect(target.scrollIntoView).not.toHaveBeenCalled();
758
+ // The consumer owns scrolling; they still learn where to go.
759
+ expect(onNavigateStart).toHaveBeenCalledWith('install');
760
+ // There is nothing to scroll, so nothing to wait for: onNavigateEnd must
761
+ // resolve immediately rather than waiting out the settle timeout — an
762
+ // arrival effect paired with onNavigateEnd would otherwise land ~1.2s late
763
+ // for no reason.
764
+ expect(onNavigateEnd).toHaveBeenCalledWith('install');
765
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
766
+ 'aria-current',
767
+ 'true',
768
+ );
769
+
770
+ cleanup();
771
+ });
772
+
773
+ it('offset shifts the activation line for a fixed header', () => {
774
+ const cleanup = mountHeadings();
775
+ const [intro, install, api] = items.map(
776
+ item => document.getElementById(item.id) as HTMLElement,
777
+ );
778
+
779
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
780
+ value: 4000,
781
+ configurable: true,
782
+ });
783
+ vi.spyOn(intro, 'getBoundingClientRect').mockReturnValue({
784
+ top: -200,
785
+ } as DOMRect);
786
+ // `install` sits 40px below the viewport top — under a 64px fixed header,
787
+ // so it should already be active once `offset={64}` is declared.
788
+ vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({
789
+ top: 40,
790
+ } as DOMRect);
791
+ vi.spyOn(api, 'getBoundingClientRect').mockReturnValue({
792
+ top: 400,
793
+ } as DOMRect);
794
+
795
+ const {rerender} = render(<Outline items={items} />);
796
+ // Without offset the activation line is the viewport top: `install` (top:40)
797
+ // has not reached it, so `intro` stays active.
798
+ expect(screen.getByRole('link', {name: 'Introduction'})).toHaveAttribute(
799
+ 'aria-current',
800
+ 'true',
801
+ );
802
+
803
+ rerender(<Outline items={items} offset={64} />);
804
+ act(() => {
805
+ window.dispatchEvent(new Event('resize'));
806
+ });
807
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
808
+ 'aria-current',
809
+ 'true',
810
+ );
811
+
812
+ cleanup();
813
+ });
814
+
815
+ it('lands the heading below a fixed header instead of underneath it', async () => {
816
+ const user = userEvent.setup();
817
+ const cleanup = mountHeadings();
818
+ const install = document.getElementById('install') as HTMLElement;
819
+
820
+ // The heading sits 400px down the page and asks for 8px of breathing room
821
+ // below whatever is above it.
822
+ vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({
823
+ top: 400,
824
+ } as DOMRect);
825
+ mockScrollMarginTop(new Map([[install, '8px']]));
826
+
827
+ render(<Outline items={items} offset={48} />);
828
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
829
+
830
+ // A 48px fixed header covers the top of the scroll root, so the heading
831
+ // must come to rest at 48 (header) + 8 (its own scroll-margin-top) = 56px
832
+ // — NOT at 8px, which would park it underneath the header, invisible.
833
+ // scrollIntoView cannot know about the header, so it must not be used here.
834
+ expect(window.scrollBy).toHaveBeenCalledWith({
835
+ top: 400 - 56,
836
+ behavior: 'smooth',
837
+ });
838
+ expect(install.scrollIntoView).not.toHaveBeenCalled();
839
+
840
+ cleanup();
841
+ });
842
+
843
+ it('lands the heading below a fixed header inside a scoped container', async () => {
844
+ const user = userEvent.setup();
845
+ const cleanup = mountHeadings();
846
+ const install = document.getElementById('install') as HTMLElement;
847
+
848
+ const pane = document.createElement('div');
849
+ document.body.appendChild(pane);
850
+ pane.scrollBy = vi.fn();
851
+ vi.spyOn(pane, 'getBoundingClientRect').mockReturnValue({
852
+ top: 100,
853
+ } as DOMRect);
854
+ vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({
855
+ top: 500,
856
+ } as DOMRect);
857
+
858
+ render(
859
+ <Outline
860
+ items={items}
861
+ scrollContainerRef={{current: pane}}
862
+ offset={48}
863
+ />,
864
+ );
865
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
866
+
867
+ // Measured from the pane's own top (100), not the viewport's.
868
+ expect(pane.scrollBy).toHaveBeenCalledWith({
869
+ top: 500 - (100 + 48),
870
+ behavior: 'smooth',
871
+ });
872
+ expect(window.scrollBy).not.toHaveBeenCalled();
873
+
874
+ cleanup();
875
+ pane.remove();
876
+ });
877
+
878
+ it('keeps the CSS-native scrollIntoView path when offset is 0', async () => {
879
+ const user = userEvent.setup();
880
+ const cleanup = mountHeadings();
881
+
882
+ render(<Outline items={items} />);
883
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
884
+
885
+ // No fixed header to compensate for: let the browser honor
886
+ // scroll-margin-top itself rather than doing the math in JS.
887
+ const install = document.getElementById('install') as HTMLElement;
888
+ expect(install.scrollIntoView).toHaveBeenCalledWith({
889
+ behavior: 'smooth',
890
+ block: 'start',
891
+ });
892
+ expect(window.scrollBy).not.toHaveBeenCalled();
893
+
894
+ cleanup();
895
+ });
896
+
897
+ it('composes offset with the heading own scroll-margin-top for activation', () => {
898
+ const cleanup = mountHeadings();
899
+ const [intro, install, api] = items.map(
900
+ item => document.getElementById(item.id) as HTMLElement,
901
+ );
902
+
903
+ Object.defineProperty(document.documentElement, 'scrollHeight', {
904
+ value: 4000,
905
+ configurable: true,
906
+ });
907
+ // Every heading declares 8px of scroll-margin-top; the header is 48px.
908
+ // The activation line is therefore 48 + 8 = 56px (+1px tolerance).
909
+ mockScrollMarginTop(
910
+ new Map([
911
+ [intro, '8px'],
912
+ [install, '8px'],
913
+ [api, '8px'],
914
+ ]),
915
+ );
916
+ vi.spyOn(intro, 'getBoundingClientRect').mockReturnValue({
917
+ top: -200,
918
+ } as DOMRect);
919
+ // 50 < 56: `install` has crossed the composed line, so it is active.
920
+ vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({
921
+ top: 50,
922
+ } as DOMRect);
923
+ // 60 > 56: `api` has NOT crossed it. If offset and scroll-margin-top were
924
+ // double-counted (96px) this would wrongly activate.
925
+ vi.spyOn(api, 'getBoundingClientRect').mockReturnValue({
926
+ top: 60,
927
+ } as DOMRect);
928
+
929
+ render(<Outline items={items} offset={48} />);
930
+
931
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
932
+ 'aria-current',
933
+ 'true',
934
+ );
935
+ expect(screen.getByRole('link', {name: 'API'})).not.toHaveAttribute(
936
+ 'aria-current',
937
+ );
938
+
939
+ cleanup();
940
+ });
941
+
942
+ it('scrollContainerRef scopes tracking to a custom scroll container', () => {
943
+ const cleanup = mountHeadings();
944
+ const [intro, install, api] = items.map(
945
+ item => document.getElementById(item.id) as HTMLElement,
946
+ );
947
+
948
+ // A split-pane / modal scroll container. It is deliberately NOT a
949
+ // scrollable ancestor of the Outline, so the auto-detect heuristic would
950
+ // fall back to the window — the exact case that leaves a TOC's highlight
951
+ // stuck today. Scoping must use this element's box as the activation line.
952
+ const pane = document.createElement('div');
953
+ document.body.appendChild(pane);
954
+ vi.spyOn(pane, 'getBoundingClientRect').mockReturnValue({
955
+ top: 300,
956
+ } as DOMRect);
957
+ Object.defineProperty(pane, 'scrollTop', {value: 0, configurable: true});
958
+ Object.defineProperty(pane, 'clientHeight', {
959
+ value: 500,
960
+ configurable: true,
961
+ });
962
+ Object.defineProperty(pane, 'scrollHeight', {
963
+ value: 4000,
964
+ configurable: true,
965
+ });
966
+
967
+ // Relative to the pane's top (300), intro and install have passed; api not.
968
+ // Relative to the viewport (0) all three would have passed, which would
969
+ // make `api` active — so this asserts the pane really is the scroll root.
970
+ vi.spyOn(intro, 'getBoundingClientRect').mockReturnValue({
971
+ top: 100,
972
+ } as DOMRect);
973
+ vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({
974
+ top: 250,
975
+ } as DOMRect);
976
+ vi.spyOn(api, 'getBoundingClientRect').mockReturnValue({
977
+ top: 600,
978
+ } as DOMRect);
979
+
980
+ render(<Outline items={items} scrollContainerRef={{current: pane}} />);
981
+
982
+ expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute(
983
+ 'aria-current',
984
+ 'true',
985
+ );
986
+
987
+ pane.remove();
988
+ cleanup();
989
+ });
990
+
991
+ it('settles a scoped navigation on the container scrollend, not the window', async () => {
992
+ const user = userEvent.setup();
993
+ const cleanup = mountHeadings();
994
+ const onNavigateEnd = vi.fn();
995
+
996
+ const pane = document.createElement('div');
997
+ document.body.appendChild(pane);
998
+
999
+ render(
1000
+ <Outline
1001
+ items={items}
1002
+ scrollContainerRef={{current: pane}}
1003
+ onNavigateEnd={onNavigateEnd}
1004
+ />,
1005
+ );
1006
+ await user.click(screen.getByRole('link', {name: 'Installation'}));
1007
+
1008
+ // A window scrollend must NOT settle a container-scoped navigation.
1009
+ act(() => {
1010
+ window.dispatchEvent(new Event('scrollend'));
1011
+ });
1012
+ expect(onNavigateEnd).not.toHaveBeenCalled();
1013
+
1014
+ act(() => {
1015
+ pane.dispatchEvent(new Event('scrollend'));
1016
+ });
1017
+ expect(onNavigateEnd).toHaveBeenCalledTimes(1);
1018
+ expect(onNavigateEnd).toHaveBeenCalledWith('install');
1019
+
1020
+ pane.remove();
1021
+ cleanup();
1022
+ });
1023
+ });
1024
+
290
1025
  describe('useOutlineFromDOM', () => {
291
1026
  it('collects headings from DOM container', () => {
292
1027
  function Demo() {