@astryxdesign/core 0.1.7-canary.fb9c68c → 0.1.7

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 (137) hide show
  1. package/README.md +15 -24
  2. package/dist/Collapsible/Collapsible.d.ts +1 -11
  3. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  4. package/dist/Collapsible/Collapsible.js +4 -36
  5. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  6. package/dist/ContextMenu/ContextMenu.js +3 -4
  7. package/dist/ContextMenu/index.d.ts +0 -3
  8. package/dist/ContextMenu/index.d.ts.map +1 -1
  9. package/dist/ContextMenu/index.js +1 -7
  10. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  11. package/dist/DropdownMenu/DropdownMenu.js +4 -8
  12. package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -17
  13. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  14. package/dist/DropdownMenu/DropdownMenuContext.js +0 -18
  15. package/dist/DropdownMenu/index.d.ts +1 -5
  16. package/dist/DropdownMenu/index.d.ts.map +1 -1
  17. package/dist/DropdownMenu/index.js +1 -10
  18. package/dist/Icon/Icon.d.ts +12 -17
  19. package/dist/Icon/Icon.d.ts.map +1 -1
  20. package/dist/Icon/Icon.js +20 -28
  21. package/dist/Outline/Outline.d.ts +3 -69
  22. package/dist/Outline/Outline.d.ts.map +1 -1
  23. package/dist/Outline/Outline.js +26 -129
  24. package/dist/Outline/useScrollSpy.d.ts +7 -27
  25. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  26. package/dist/Outline/useScrollSpy.js +70 -202
  27. package/dist/TabList/Tab.d.ts.map +1 -1
  28. package/dist/TabList/Tab.js +2 -2
  29. package/dist/TabList/TabList.d.ts.map +1 -1
  30. package/dist/TabList/TabList.js +0 -2
  31. package/dist/TabList/TabMenu.d.ts.map +1 -1
  32. package/dist/TabList/TabMenu.js +1 -1
  33. package/dist/Table/index.d.ts +0 -2
  34. package/dist/Table/index.d.ts.map +1 -1
  35. package/dist/Table/index.js +0 -1
  36. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  37. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +2 -24
  38. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  39. package/dist/Table/plugins/sortable/useTableSortable.js +7 -18
  40. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  41. package/dist/Table/useBaseTablePlugins.js +3 -6
  42. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  43. package/dist/Toolbar/Toolbar.js +1 -8
  44. package/dist/astryx.css +2 -18
  45. package/dist/astryx.umd.js +48 -48
  46. package/dist/astryx.umd.js.map +4 -4
  47. package/dist/theme/Theme.js +1 -1
  48. package/dist/theme/defineTheme.d.ts +1 -1
  49. package/dist/theme/defineTheme.js +1 -1
  50. package/dist/theme/hct.d.ts +1 -0
  51. package/dist/theme/hct.d.ts.map +1 -1
  52. package/dist/theme/hct.js +3 -0
  53. package/locales/en.json +0 -28
  54. package/locales/pseudo.json +0 -21
  55. package/package.json +1 -3
  56. package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
  57. package/src/Collapsible/Collapsible.doc.mjs +0 -7
  58. package/src/Collapsible/Collapsible.test.tsx +0 -80
  59. package/src/Collapsible/Collapsible.tsx +5 -58
  60. package/src/ContextMenu/ContextMenu.test.tsx +1 -75
  61. package/src/ContextMenu/ContextMenu.tsx +5 -10
  62. package/src/ContextMenu/index.ts +0 -15
  63. package/src/DropdownMenu/DropdownMenu.doc.mjs +0 -23
  64. package/src/DropdownMenu/DropdownMenu.tsx +6 -11
  65. package/src/DropdownMenu/DropdownMenuContext.tsx +1 -29
  66. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +1 -7
  67. package/src/DropdownMenu/index.ts +3 -23
  68. package/src/Field/Field.doc.mjs +1 -1
  69. package/src/Field/FieldLabel.doc.mjs +1 -1
  70. package/src/Icon/Icon.test.tsx +0 -46
  71. package/src/Icon/Icon.tsx +24 -37
  72. package/src/NumberInput/NumberInput.doc.mjs +2 -2
  73. package/src/Outline/Outline.doc.mjs +3 -123
  74. package/src/Outline/Outline.test.tsx +0 -735
  75. package/src/Outline/Outline.tsx +33 -184
  76. package/src/Outline/useScrollSpy.ts +72 -248
  77. package/src/Selector/SelectorOption.doc.mjs +1 -1
  78. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  79. package/src/Switch/Switch.doc.mjs +1 -1
  80. package/src/TabList/Tab.tsx +1 -5
  81. package/src/TabList/TabList.doc.mjs +3 -3
  82. package/src/TabList/TabList.test.tsx +0 -66
  83. package/src/TabList/TabList.tsx +0 -8
  84. package/src/TabList/TabMenu.tsx +1 -4
  85. package/src/Table/Table.doc.mjs +0 -2
  86. package/src/Table/index.ts +0 -7
  87. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +1 -90
  88. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +5 -37
  89. package/src/Table/plugins/sortable/useTableSortable.test.tsx +9 -95
  90. package/src/Table/plugins/sortable/useTableSortable.tsx +7 -23
  91. package/src/Table/useBaseTablePlugins.ts +2 -6
  92. package/src/TextArea/TextArea.doc.mjs +1 -1
  93. package/src/TextInput/TextInput.doc.mjs +1 -1
  94. package/src/Toolbar/Toolbar.tsx +3 -10
  95. package/src/theme/Theme.doc.mjs +4 -4
  96. package/src/theme/Theme.tsx +1 -1
  97. package/src/theme/defineTheme.ts +1 -1
  98. package/src/theme/hct.ts +4 -0
  99. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +0 -89
  100. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +0 -1
  101. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +0 -170
  102. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +0 -69
  103. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +0 -1
  104. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +0 -70
  105. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +0 -69
  106. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +0 -1
  107. package/dist/DropdownMenu/DropdownMenuRadioItem.js +0 -178
  108. package/dist/DropdownMenu/menuItemRoles.d.ts +0 -13
  109. package/dist/DropdownMenu/menuItemRoles.d.ts.map +0 -1
  110. package/dist/DropdownMenu/menuItemRoles.js +0 -15
  111. package/dist/Table/plugins/tree/index.d.ts +0 -5
  112. package/dist/Table/plugins/tree/index.d.ts.map +0 -1
  113. package/dist/Table/plugins/tree/index.js +0 -4
  114. package/dist/Table/plugins/tree/useTableTreeData.d.ts +0 -38
  115. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +0 -1
  116. package/dist/Table/plugins/tree/useTableTreeData.js +0 -358
  117. package/dist/Table/plugins/tree/useTableTreeState.d.ts +0 -51
  118. package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +0 -1
  119. package/dist/Table/plugins/tree/useTableTreeState.js +0 -175
  120. package/scripts/postinstall.mjs +0 -93
  121. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +0 -95
  122. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +0 -237
  123. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +0 -80
  124. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +0 -119
  125. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +0 -80
  126. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +0 -232
  127. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +0 -163
  128. package/src/DropdownMenu/menuItemRoles.ts +0 -22
  129. package/src/Table/plugins/tree/index.ts +0 -12
  130. package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +0 -163
  131. package/src/Table/plugins/tree/useTableTreeData.test.tsx +0 -594
  132. package/src/Table/plugins/tree/useTableTreeData.tsx +0 -495
  133. package/src/Table/plugins/tree/useTableTreeState.test.tsx +0 -636
  134. package/src/Table/plugins/tree/useTableTreeState.tsx +0 -253
  135. package/src/Table/useTableTreeData.doc.mjs +0 -75
  136. package/src/Table/useTableTreeState.doc.mjs +0 -98
  137. package/src/postinstall.test.mjs +0 -71
@@ -10,26 +10,11 @@
10
10
  *
11
11
  * Drives the active outline item from scroll position. On each scroll
12
12
  * (rAF-throttled) it reads live heading positions and marks the last heading
13
- * whose top has passed its activation line. This is stable — it never compares
14
- * stale cached positions — so the indicator moves monotonically instead of
15
- * jumping. Defaults to the first item at the top and the last item at the
16
- * bottom so short final sections still activate.
17
- *
18
- * The activation line and the scroll landing are the same number, computed once
19
- * in `getRestingTop`: scroll-root top + `offset` (a fixed header overlaying the
20
- * root) + the heading's own `scroll-margin-top`. A heading therefore activates
21
- * exactly where navigating to it puts it. `offset` and `scroll-margin-top`
22
- * compose — the header, then the breathing room below it — they do not
23
- * duplicate each other.
24
- *
25
- * It also owns the single navigation path (`scrollTo`), shared by click and
26
- * keyboard activation: it announces the jump, suppresses scroll-spy for the
27
- * duration of the programmatic scroll (so the indicator doesn't chase it), and
28
- * resolves when the scroll settles — via `scrollend` where supported, or a
29
- * timeout fallback where it is not.
30
- *
31
- * The scroll root is the `scrollContainerRef` element when provided, otherwise
32
- * the nearest scrollable ancestor of the outline, otherwise the viewport.
13
+ * whose top has passed its activation line (its own scroll-margin-top, i.e.
14
+ * where it lands when navigated to). This is stable — it never compares stale
15
+ * cached positions — so the indicator moves monotonically instead of jumping.
16
+ * Defaults to the first item at the top and the last item at the bottom so
17
+ * short final sections still activate.
33
18
  *
34
19
  * SYNC: When modified, update /packages/core/src/Outline/Outline.tsx
35
20
  */
@@ -37,14 +22,6 @@
37
22
  import {useCallback, useEffect, useRef, useState} from 'react';
38
23
  import type {OutlineItem} from './types';
39
24
 
40
- /**
41
- * How long to wait for a programmatic smooth scroll to settle when the
42
- * `scrollend` event never arrives — either because the browser does not
43
- * support it (Safari at time of writing) or because the target was already in
44
- * position, so no scroll happened at all and no events were emitted.
45
- */
46
- const SCROLL_SETTLE_TIMEOUT_MS = 1200;
47
-
48
25
  /** Keys that scroll the viewport — used to detect a manual scroll intent. */
49
26
  const SCROLL_KEYS = new Set([
50
27
  'ArrowUp',
@@ -81,82 +58,26 @@ function getScrollableAncestor(
81
58
  return null;
82
59
  }
83
60
 
84
- /** The element's own `scroll-margin-top` in px (0 when unset). */
85
- function getScrollMarginTop(element: HTMLElement): number {
86
- return (
87
- Number.parseFloat(window.getComputedStyle(element).scrollMarginTop) || 0
88
- );
89
- }
90
-
91
- /** The top of the scroll root in viewport coordinates (0 for the viewport). */
92
- function getRootTop(scrollRoot: HTMLElement | null): number {
93
- return scrollRoot != null ? scrollRoot.getBoundingClientRect().top : 0;
94
- }
95
-
96
- /**
97
- * Where a heading comes to rest, in viewport coordinates: below the fixed
98
- * header (`offset`) and below its own `scroll-margin-top` breathing room.
99
- *
100
- * This is the single source of truth shared by the activation line and the
101
- * scroll landing — the two must agree, or a heading activates somewhere other
102
- * than where navigating to it puts it.
103
- */
104
- function getRestingTop(
105
- target: HTMLElement,
106
- scrollRoot: HTMLElement | null,
107
- offset: number,
108
- ): number {
109
- return getRootTop(scrollRoot) + offset + getScrollMarginTop(target);
110
- }
111
-
112
- /**
113
- * Bring `target` to rest at the top of the scroll root, below any fixed header.
114
- *
115
- * With no `offset`, this is the CSS-native path: `scrollIntoView` already
116
- * honors the heading's `scroll-margin-top`, and it walks *every* scrollable
117
- * ancestor, so the browser does it better than we can.
118
- *
119
- * An `offset` describes a fixed header overlaying the top of the scroll root —
120
- * which the browser cannot know about, so `scrollIntoView` would park the
121
- * heading underneath it, hidden. Compute the landing explicitly instead, from
122
- * the same {@link getRestingTop} the activation line uses.
123
- */
124
- function scrollToTarget(
125
- target: HTMLElement,
126
- scrollRoot: HTMLElement | null,
127
- scrollTarget: HTMLElement | Window,
128
- offset: number,
129
- ): void {
130
- if (offset === 0) {
131
- target.scrollIntoView({behavior: 'smooth', block: 'start'});
132
- return;
133
- }
134
-
135
- const delta =
136
- target.getBoundingClientRect().top -
137
- getRestingTop(target, scrollRoot, offset);
138
- scrollTarget.scrollBy({top: delta, behavior: 'smooth'});
139
- }
140
-
141
61
  /**
142
62
  * Resolve the active heading id from current scroll position.
143
63
  *
144
- * A heading is "passed" once its top reaches its activation line — exactly
145
- * where navigating to it would land it ({@link getRestingTop}): the scroll
146
- * root's top, plus `offset` (a fixed header overlaying the scroll root), plus
147
- * the heading's own scroll-margin-top. The active heading is the last passed
148
- * one (headings are in document order). When none have passed (scrolled above
149
- * the first), the first item is active; at the bottom, the last item is active.
64
+ * A heading is "passed" once its top reaches its activation line — the scroll
65
+ * root's top plus the heading's own scroll-margin-top. The active heading is
66
+ * the last passed one (headings are in document order). When none have passed
67
+ * (scrolled above the first), the first item is active; at the bottom, the
68
+ * last item is active.
150
69
  */
151
70
  function resolveActiveId(
152
71
  items: OutlineItem[],
153
72
  scrollRoot: HTMLElement | null,
154
- offset: number,
155
73
  ): string | undefined {
156
74
  if (items.length === 0) {
157
75
  return undefined;
158
76
  }
159
77
 
78
+ const rootTop =
79
+ scrollRoot != null ? scrollRoot.getBoundingClientRect().top : 0;
80
+
160
81
  const atBottom =
161
82
  scrollRoot != null
162
83
  ? scrollRoot.scrollTop + scrollRoot.clientHeight >=
@@ -173,10 +94,11 @@ function resolveActiveId(
173
94
  if (element == null) {
174
95
  continue;
175
96
  }
176
-
177
- // 1px of tolerance absorbs sub-pixel rounding after a scroll lands.
178
97
  const top = element.getBoundingClientRect().top;
179
- if (top <= getRestingTop(element, scrollRoot, offset) + 1) {
98
+ const marginTop =
99
+ Number.parseFloat(window.getComputedStyle(element).scrollMarginTop) || 0;
100
+
101
+ if (top <= rootTop + marginTop + 1) {
180
102
  activeId = item.id;
181
103
  } else {
182
104
  break;
@@ -190,20 +112,6 @@ interface UseScrollSpyOptions {
190
112
  items: OutlineItem[];
191
113
  onActiveIdChange?: (id: string) => void;
192
114
  rootRef: React.RefObject<HTMLElement | null>;
193
- /**
194
- * Height in px of a fixed header overlaying the top of the scroll root.
195
- * Shifts both the activation line and the scroll landing by the same amount,
196
- * on top of each heading's own `scroll-margin-top`.
197
- */
198
- offset?: number;
199
- /** Scroll container to track, instead of the nearest scrollable ancestor. */
200
- scrollContainerRef?: React.RefObject<HTMLElement | null>;
201
- /** Whether {@link UseScrollSpyResult.scrollTo} performs the smooth scroll. */
202
- hasScrollOnClick?: boolean;
203
- /** Called when a navigation begins, before the scroll starts. */
204
- onNavigateStart?: (id: string) => void;
205
- /** Called once per navigation, when the scroll settles or is interrupted. */
206
- onNavigateEnd?: (id: string) => void;
207
115
  }
208
116
 
209
117
  interface UseScrollSpyResult {
@@ -211,19 +119,13 @@ interface UseScrollSpyResult {
211
119
  /** Set the active id (notifies onActiveIdChange). For controlled consumers. */
212
120
  setActiveId: (id: string) => void;
213
121
  /**
214
- * Navigate to the item with id `id` — the single path shared by click and
215
- * keyboard activation.
216
- *
217
- * Fires `onNavigateStart(id)`, scrolls (unless `hasScrollOnClick` is false),
218
- * and fires `onNavigateEnd(id)` exactly once when the scroll settles or the
219
- * user interrupts it. While uncontrolled, scroll-spy is suppressed for the
220
- * duration so the indicator doesn't chase the scroll through intervening
221
- * sections; it lands on the target once the scroll settles, or resumes
222
- * position tracking if the user scrolls away mid-flight.
223
- *
224
- * Returns false (and does nothing) when no element with `id` exists.
122
+ * Handle a click on the outline item with id `id`. Delays moving the
123
+ * indicator: scroll-spy is suppressed during the programmatic smooth scroll
124
+ * so the indicator doesn't chase it, then the indicator moves once to the
125
+ * clicked item when the scroll settles. If the user scrolls manually mid-way,
126
+ * scroll-position tracking resumes immediately instead.
225
127
  */
226
- scrollTo: (id: string) => boolean;
128
+ lockActiveId: (id: string) => void;
227
129
  }
228
130
 
229
131
  export function useScrollSpy({
@@ -231,29 +133,18 @@ export function useScrollSpy({
231
133
  items,
232
134
  onActiveIdChange,
233
135
  rootRef,
234
- offset = 0,
235
- scrollContainerRef,
236
- hasScrollOnClick = true,
237
- onNavigateStart,
238
- onNavigateEnd,
239
136
  }: UseScrollSpyOptions): UseScrollSpyResult {
240
137
  const isControlled = activeId !== undefined;
241
138
  const [uncontrolledActiveId, setUncontrolledActiveId] = useState<
242
139
  string | undefined
243
140
  >(items[0]?.id);
244
141
  const activeIdRef = useRef<string | undefined>(activeId);
245
- // While true, scroll-spy ignores scroll updates because a navigation is
246
- // driving a programmatic scroll. Released when it settles or the user scrolls.
142
+ // While true, scroll-spy ignores scroll updates because a click is driving a
143
+ // programmatic scroll. Released when that scroll settles or the user scrolls.
247
144
  const suppressRef = useRef(false);
248
- // The navigation currently in flight, if any.
249
- const navigationRef = useRef<{
250
- /** End it (without resuming tracking) because a new one is starting. */
251
- supersede: () => void;
252
- /** Drop its listeners without firing onNavigateEnd (unmount). */
253
- teardown: () => void;
254
- } | null>(null);
255
- // Latest scroll-position resolver, so a navigation can resume tracking when
256
- // the user scrolls during the programmatic scroll.
145
+ const releaseSuppressionRef = useRef<(() => void) | null>(null);
146
+ // Latest scroll-position resolver, so the click handler can resume tracking
147
+ // when the user scrolls during a programmatic scroll.
257
148
  const syncRef = useRef<(() => void) | null>(null);
258
149
  // Keep latest items/callback in refs so the scroll listener effect doesn't
259
150
  // re-subscribe on every render (items is a fresh array each render).
@@ -264,23 +155,12 @@ export function useScrollSpy({
264
155
  const itemIds = items.map(item => item.id).join('\n');
265
156
  activeIdRef.current = isControlled ? activeId : uncontrolledActiveId;
266
157
 
267
- /**
268
- * The element whose scroll position drives the outline: the explicit
269
- * container when scoped, else the nearest scrollable ancestor, else null
270
- * (meaning the viewport).
271
- */
272
- const getScrollRoot = useCallback((): HTMLElement | null => {
273
- return (
274
- scrollContainerRef?.current ?? getScrollableAncestor(rootRef.current)
275
- );
276
- }, [rootRef, scrollContainerRef]);
277
-
278
158
  useEffect(() => {
279
159
  if (isControlled || typeof window === 'undefined') {
280
160
  return;
281
161
  }
282
162
 
283
- const scrollRoot = getScrollRoot();
163
+ const scrollRoot = getScrollableAncestor(rootRef.current);
284
164
  const scrollTarget: HTMLElement | Window = scrollRoot ?? window;
285
165
 
286
166
  let frame = 0;
@@ -289,11 +169,7 @@ export function useScrollSpy({
289
169
  if (suppressRef.current) {
290
170
  return;
291
171
  }
292
- const nextActiveId = resolveActiveId(
293
- itemsRef.current,
294
- scrollRoot,
295
- offset,
296
- );
172
+ const nextActiveId = resolveActiveId(itemsRef.current, scrollRoot);
297
173
  if (nextActiveId != null && nextActiveId !== activeIdRef.current) {
298
174
  activeIdRef.current = nextActiveId;
299
175
  setUncontrolledActiveId(nextActiveId);
@@ -319,12 +195,12 @@ export function useScrollSpy({
319
195
  cancelAnimationFrame(frame);
320
196
  }
321
197
  };
322
- }, [isControlled, itemIds, offset, getScrollRoot]);
198
+ }, [isControlled, itemIds, rootRef]);
323
199
 
324
- // Tear down any in-flight navigation's listeners when the Outline unmounts.
200
+ // Tear down any pending suppression listeners when the Outline unmounts.
325
201
  useEffect(() => {
326
202
  return () => {
327
- navigationRef.current?.teardown();
203
+ releaseSuppressionRef.current?.();
328
204
  };
329
205
  }, []);
330
206
 
@@ -335,117 +211,65 @@ export function useScrollSpy({
335
211
  onActiveIdChange?.(nextActiveId);
336
212
  };
337
213
 
338
- // Defined in render (not memoized) so it always closes over the current props
339
- // — it only ever runs from an event handler, never during render.
340
- const scrollTo = (id: string): boolean => {
341
- const target =
342
- typeof document !== 'undefined' ? document.getElementById(id) : null;
343
- if (target == null) {
344
- return false;
345
- }
346
-
347
- // A second navigation replaces the first: end the old one (balancing its
348
- // onNavigateStart) without resuming tracking, since we are about to
349
- // suppress it again anyway.
350
- navigationRef.current?.supersede();
351
-
352
- onNavigateStart?.(id);
353
-
354
- if (isControlled) {
355
- // The consumer owns the active state — notify only.
356
- setActiveId(id);
357
- } else {
358
- // Freeze the indicator during the programmatic scroll instead of moving
359
- // it immediately: it lands on the target once the scroll settles, so it
360
- // doesn't chase the scroll through intervening sections.
361
- suppressRef.current = true;
214
+ const lockActiveId = useCallback((clickedId: string) => {
215
+ if (typeof window === 'undefined') {
216
+ setUncontrolledActiveId(clickedId);
217
+ activeIdRef.current = clickedId;
218
+ onActiveIdChangeRef.current?.(clickedId);
219
+ return;
362
220
  }
363
221
 
364
- const scrollRoot = getScrollRoot();
365
- const scrollTarget: HTMLElement | Window = scrollRoot ?? window;
222
+ // Freeze the indicator during the programmatic smooth scroll instead of
223
+ // moving it immediately — it lands on the clicked item once the scroll
224
+ // settles, so it doesn't chase the scroll through intervening sections.
225
+ suppressRef.current = true;
226
+ // Replace any in-flight handlers from a previous click.
227
+ releaseSuppressionRef.current?.();
366
228
 
367
229
  let settleTimer = 0;
368
- let isSettled = false;
369
-
370
230
  const cleanup = () => {
371
- scrollTarget.removeEventListener('scrollend', onSettle);
372
- scrollTarget.removeEventListener('wheel', onManual);
373
- scrollTarget.removeEventListener('touchmove', onManual);
231
+ window.removeEventListener('scrollend', onSettle);
232
+ window.removeEventListener('wheel', onManual);
233
+ window.removeEventListener('touchmove', onManual);
374
234
  window.removeEventListener('keydown', onKeyDown);
375
235
  if (settleTimer !== 0) {
376
236
  clearTimeout(settleTimer);
377
237
  settleTimer = 0;
378
238
  }
379
- navigationRef.current = null;
239
+ releaseSuppressionRef.current = null;
380
240
  };
381
-
382
- /**
383
- * End the navigation exactly once. `didArrive` is false when the user took
384
- * over with a manual scroll mid-flight. `onNavigateEnd` fires either way,
385
- * so every `onNavigateStart` is balanced and a consumer's "navigating"
386
- * state can never leak.
387
- */
388
- const finish = (didArrive: boolean, shouldResume = true) => {
389
- if (isSettled) {
390
- return;
391
- }
392
- isSettled = true;
241
+ // Programmatic scroll finished: move the indicator to the clicked item.
242
+ const onSettle = () => {
393
243
  cleanup();
394
-
395
- if (!isControlled) {
396
- suppressRef.current = false;
397
- if (didArrive) {
398
- setUncontrolledActiveId(id);
399
- activeIdRef.current = id;
400
- onActiveIdChangeRef.current?.(id);
401
- } else if (shouldResume) {
402
- // Hand control back to scroll-position tracking.
403
- syncRef.current?.();
404
- }
405
- }
406
-
407
- onNavigateEnd?.(id);
244
+ suppressRef.current = false;
245
+ setUncontrolledActiveId(clickedId);
246
+ activeIdRef.current = clickedId;
247
+ onActiveIdChangeRef.current?.(clickedId);
248
+ };
249
+ // User scrolled mid-flight: hand control back to scroll-position tracking.
250
+ const onManual = () => {
251
+ cleanup();
252
+ suppressRef.current = false;
253
+ syncRef.current?.();
408
254
  };
409
-
410
- const onSettle = () => finish(true);
411
- const onManual = () => finish(false);
412
255
  const onKeyDown = (event: KeyboardEvent) => {
413
- // A key the outline itself consumed (arrow roving focus, Space
414
- // activation) is prevented, so the browser will not scroll — it is not a
415
- // manual scroll intent and must not cancel the navigation it just began.
416
- if (!event.defaultPrevented && SCROLL_KEYS.has(event.key)) {
417
- finish(false);
256
+ if (SCROLL_KEYS.has(event.key)) {
257
+ onManual();
418
258
  }
419
259
  };
420
260
 
421
- if (hasScrollOnClick) {
422
- // Arm settle detection *before* scrolling so an instant jump (a target
423
- // already in position, or prefers-reduced-motion collapsing the smooth
424
- // scroll) cannot land before anyone is listening.
425
- scrollTarget.addEventListener('scrollend', onSettle, {once: true});
426
- scrollTarget.addEventListener('wheel', onManual, {passive: true});
427
- scrollTarget.addEventListener('touchmove', onManual, {passive: true});
428
- window.addEventListener('keydown', onKeyDown);
429
- settleTimer = window.setTimeout(onSettle, SCROLL_SETTLE_TIMEOUT_MS);
430
- navigationRef.current = {
431
- supersede: () => finish(false, false),
432
- teardown: cleanup,
433
- };
434
- scrollToTarget(target, scrollRoot, scrollTarget, offset);
435
- } else {
436
- // The consumer owns scrolling — there is no scroll to wait for, so the
437
- // navigation is already at rest. Resolving immediately (instead of
438
- // waiting on the settle timeout) keeps onNavigateEnd usable for arrival
439
- // effects even when hasScrollOnClick is false.
440
- finish(true);
441
- }
442
-
443
- return true;
444
- };
261
+ window.addEventListener('scrollend', onSettle, {once: true});
262
+ window.addEventListener('wheel', onManual, {passive: true});
263
+ window.addEventListener('touchmove', onManual, {passive: true});
264
+ window.addEventListener('keydown', onKeyDown);
265
+ // Fallback when scrollend is unsupported or no scroll is needed.
266
+ settleTimer = window.setTimeout(onSettle, 1200);
267
+ releaseSuppressionRef.current = cleanup;
268
+ }, []);
445
269
 
446
270
  return {
447
271
  activeId: isControlled ? activeId : uncontrolledActiveId,
448
272
  setActiveId,
449
- scrollTo,
273
+ lockActiveId,
450
274
  };
451
275
  }
@@ -20,7 +20,7 @@ export const docs = {
20
20
  name: 'icon',
21
21
  type: 'IconType',
22
22
  description:
23
- 'Icon displayed before the label. See `astryx docs icons` for valid semantic names.',
23
+ 'Icon displayed before the label. See `npx astryx docs icons` for valid semantic names.',
24
24
  },
25
25
  {
26
26
  name: 'description',
@@ -23,12 +23,12 @@ export const docs = {
23
23
  {
24
24
  name: 'icon',
25
25
  type: 'IconType',
26
- description: 'Icon displayed in the outline (unselected) variant. See `astryx docs icons` for valid semantic names.',
26
+ description: 'Icon displayed in the outline (unselected) variant. See `npx astryx docs icons` for valid semantic names.',
27
27
  },
28
28
  {
29
29
  name: 'selectedIcon',
30
30
  type: 'IconType',
31
- description: 'Icon displayed when the item is selected (filled variant). See `astryx docs icons` for valid semantic names.',
31
+ description: 'Icon displayed when the item is selected (filled variant). See `npx astryx docs icons` for valid semantic names.',
32
32
  },
33
33
  {
34
34
  name: 'isSelected',
@@ -107,7 +107,7 @@ export const docs = {
107
107
  {
108
108
  name: 'labelIcon',
109
109
  type: 'IconType',
110
- description: 'Icon displayed before the label text. See `astryx docs icons` for valid semantic names.',
110
+ description: 'Icon displayed before the label text. See `npx astryx docs icons` for valid semantic names.',
111
111
  },
112
112
  {
113
113
  name: 'labelTooltip',
@@ -138,11 +138,7 @@ const styles = stylex.create({
138
138
  },
139
139
  indicator: {
140
140
  position: 'absolute',
141
- // Sits on the tab's bottom edge by default (-1px). When the tab strip
142
- // reserves space for a divider rail — TabList `hasDivider` or a Toolbar
143
- // with a bottom divider — that ancestor sets `--_tab-indicator-bottom`
144
- // to drop the indicator onto the rail beneath the reserved gap.
145
- bottom: 'var(--_tab-indicator-bottom, -1px)',
141
+ bottom: '-1px',
146
142
  left: spacingVars['--spacing-3'],
147
143
  right: spacingVars['--spacing-3'],
148
144
  height: '2px',
@@ -92,7 +92,7 @@ export const docs = {
92
92
  bestPractices: [
93
93
  { guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
94
94
  { guidance: true, description: 'Use TabMenu to group overflow items when horizontal space is limited rather than scrolling tabs off-screen.' },
95
- { guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm) — the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
95
+ { guidance: true, description: 'When using hasDivider with action buttons alongside tabs, use a smaller button size (sm) so the actions don\'t overpower the tab row.' },
96
96
  { guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
97
97
  { guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
98
98
  { guidance: false, description: 'Confuse TabList with SegmentedControl or ToggleButton. TabList is for navigation between views. SegmentedControl and ToggleButton are input controls: SegmentedControl always has exactly one selected option, while ToggleButton can be toggled on or off.' },
@@ -113,7 +113,7 @@ export const docsZh = {
113
113
  bestPractices: [
114
114
  { guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
115
115
  { guidance: true, description: 'Use TabMenu to group overflow items when horizontal space is limited rather than scrolling tabs off-screen.' },
116
- { guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm) — the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
116
+ { guidance: true, description: 'When using hasDivider with action buttons alongside tabs, use a smaller button size (sm) so the actions don\'t overpower the tab row.' },
117
117
  { guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
118
118
  { guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
119
119
  { guidance: false, description: 'Confuse TabList with SegmentedControl or ToggleButton. TabList is for navigation between views. SegmentedControl and ToggleButton are input controls: SegmentedControl always has exactly one selected option, while ToggleButton can be toggled on or off.' },
@@ -135,7 +135,7 @@ export const docsDense = {
135
135
  bestPractices: [
136
136
  { guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
137
137
  { guidance: true, description: 'Use TabMenu to group overflow items when horizontal space is limited rather than scrolling tabs off-screen.' },
138
- { guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm) — the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
138
+ { guidance: true, description: 'When using hasDivider with action buttons alongside tabs, use a smaller button size (sm) so the actions don\'t overpower the tab row.' },
139
139
  { guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
140
140
  { guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
141
141
  { guidance: false, description: 'Confuse TabList with SegmentedControl or ToggleButton. TabList is for navigation between views. SegmentedControl and ToggleButton are input controls: SegmentedControl always has exactly one selected option, while ToggleButton can be toggled on or off.' },
@@ -323,72 +323,6 @@ describe('TabList', () => {
323
323
  });
324
324
  });
325
325
 
326
- describe('TabList divider gap', () => {
327
- // The divider adds the reserved gap + indicator offset via a single class
328
- // (StyleX applies deterministic classes in the test env). Capture that
329
- // class set once so the assertions describe intent, not opaque hashes.
330
- function navClasses(hasDivider: boolean): Set<string> {
331
- const {unmount} = render(
332
- <TabList value="home" onChange={() => {}} hasDivider={hasDivider}>
333
- <Tab value="home" label="Home" />
334
- </TabList>,
335
- );
336
- const nav = screen.getByRole('navigation');
337
- const classes = new Set(nav.className.split(/\s+/).filter(Boolean));
338
- unmount();
339
- return classes;
340
- }
341
-
342
- it('adds divider-only styling classes when hasDivider is set', () => {
343
- const withDivider = navClasses(true);
344
- const withoutDivider = navClasses(false);
345
-
346
- // Every class the plain nav has must still be present when divided: the
347
- // divider only adds styling (border + reserved gap + indicator offset),
348
- // it never removes the base nav styles.
349
- for (const cls of withoutDivider) {
350
- expect(withDivider.has(cls)).toBe(true);
351
- }
352
-
353
- // And it must add at least one class the undivided nav does not have.
354
- const added = [...withDivider].filter(c => !withoutDivider.has(c));
355
- expect(added.length).toBeGreaterThan(0);
356
- });
357
-
358
- it('does not add divider styling to an undivided tab list (default)', () => {
359
- // Default (no hasDivider) and explicit hasDivider={false} are identical:
360
- // the non-divided path is untouched by the divider gap change.
361
- expect(navClasses(false)).toEqual(
362
- (() => {
363
- const {unmount} = render(
364
- <TabList value="home" onChange={() => {}}>
365
- <Tab value="home" label="Home" />
366
- </TabList>,
367
- );
368
- const nav = screen.getByRole('navigation');
369
- const classes = new Set(nav.className.split(/\s+/).filter(Boolean));
370
- unmount();
371
- return classes;
372
- })(),
373
- );
374
- });
375
-
376
- it('keeps the selected indicator rendered under a divider', () => {
377
- render(
378
- <TabList value="home" onChange={() => {}} hasDivider>
379
- <Tab value="home" label="Home" />
380
- <Tab value="away" label="Away" />
381
- </TabList>,
382
- );
383
- const selected = screen.getByRole('button', {name: 'Home'});
384
- // The indicator span carries the selected marker; the divider must not
385
- // drop it (it is repositioned onto the rail, not removed).
386
- expect(
387
- selected.querySelector('[data-selected="selected"]'),
388
- ).toBeInTheDocument();
389
- });
390
- });
391
-
392
326
  describe('TabList keyboard navigation (roving tabindex)', () => {
393
327
  it('exposes the tab strip as a single Tab stop (only selected tab is tabbable)', () => {
394
328
  render(
@@ -96,14 +96,6 @@ const styles = stylex.create({
96
96
  borderBottomWidth: borderVars['--border-width'],
97
97
  borderBottomStyle: 'solid',
98
98
  borderBottomColor: colorVars['--color-border'],
99
- // Reserve a gap between the tabs and the divider rail so the hover pill
100
- // (which fills the tab height) no longer touches the underline, and an
101
- // adjacent same-size Button aligns to the tabs rather than butting the
102
- // rail. The tabs keep their element-size height; this padding grows the
103
- // strip. `--_tab-indicator-bottom` drops the selected indicator through
104
- // the reserved gap (+ the 1px border) so it still sits on the rail.
105
- paddingBlockEnd: spacingVars['--spacing-1'],
106
- '--_tab-indicator-bottom': `calc(-1 * (${spacingVars['--spacing-1']} + ${borderVars['--border-width']}))`,
107
99
  },
108
100
  });
109
101
 
@@ -122,10 +122,7 @@ const styles = stylex.create({
122
122
  },
123
123
  indicator: {
124
124
  position: 'absolute',
125
- // Mirrors Tab's indicator: sits on the bottom edge by default (-1px), or
126
- // drops onto the divider rail when an ancestor (TabList `hasDivider` or a
127
- // Toolbar with a bottom divider) sets `--_tab-indicator-bottom`.
128
- bottom: 'var(--_tab-indicator-bottom, -1px)',
125
+ bottom: '-1px',
129
126
  left: spacingVars['--spacing-3'],
130
127
  right: spacingVars['--spacing-3'],
131
128
  height: '2px',
@@ -111,8 +111,6 @@ export const docs = {
111
111
  {name: 'useTableSelection'},
112
112
  {name: 'useTableSelectionState'},
113
113
  {name: 'useTableSortable'},
114
- {name: 'useTableTreeData'},
115
- {name: 'useTableTreeState'},
116
114
  {name: 'useTablePagination'},
117
115
  {name: 'useTableColumnSettings'},
118
116
  {name: 'useTableFiltering'},
@@ -32,7 +32,6 @@ export {
32
32
  useTableRowExpansion,
33
33
  useTableRowExpansionState,
34
34
  } from './plugins/rowExpansion';
35
- export {useTableTreeData, useTableTreeState} from './plugins/tree';
36
35
  export {resolveContextActions} from './tableContextMenu';
37
36
  export {
38
37
  useTableFiltering,
@@ -110,12 +109,6 @@ export type {
110
109
  UseTableGroupedRowsConfig,
111
110
  UseTableGroupedRowsResult,
112
111
  } from './plugins/groupedRows';
113
- export type {
114
- TableTreeRowMeta,
115
- UseTableTreeDataConfig,
116
- UseTableTreeStateConfig,
117
- UseTableTreeStateResult,
118
- } from './plugins/tree';
119
112
  export type {
120
113
  UseTableFilteringConfig,
121
114
  TableFilterState,