@octanejs/aria 0.0.46 → 0.0.48

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 (201) hide show
  1. package/README.md +7 -6
  2. package/UPSTREAM.md +58 -69
  3. package/package.json +11 -11
  4. package/src/calendar/useCalendarGrid.tsx +64 -62
  5. package/src/collections/Document.ts +3 -0
  6. package/src/color/useColorArea.tsx +95 -69
  7. package/src/color/useColorWheel.tsx +38 -37
  8. package/src/combobox/{useComboBox.ts → useComboBox.tsx} +243 -258
  9. package/src/components/ComboBox.ts +35 -2
  10. package/src/components/Dialog.ts +12 -1
  11. package/src/components/DropZone.tsx +3 -2
  12. package/src/components/FileTrigger.tsx +1 -0
  13. package/src/components/GridList.ts +11 -2
  14. package/src/components/ListBox.ts +1 -1
  15. package/src/components/Menu.ts +1 -0
  16. package/src/components/Meter.ts +3 -1
  17. package/src/components/Popover.ts +20 -8
  18. package/src/components/PreviewTrigger.tsx +101 -0
  19. package/src/components/ProgressBar.ts +10 -2
  20. package/src/components/Select.ts +1 -1
  21. package/src/components/Table.ts +26 -15
  22. package/src/components/TokenField.tsx +395 -0
  23. package/src/components/Tooltip.ts +4 -6
  24. package/src/components/Tree.ts +14 -7
  25. package/src/components/Virtualizer.tsx +30 -14
  26. package/src/components/index.ts +22 -0
  27. package/src/components/utils.ts +1 -1
  28. package/src/datepicker/useDateField.tsx +2 -11
  29. package/src/datepicker/useDatePickerGroup.tsx +44 -33
  30. package/src/datepicker/useDateSegment.tsx +31 -30
  31. package/src/dialog/useDialog.ts +8 -1
  32. package/src/dnd/DragManager.tsx +5 -1
  33. package/src/dnd/useDrag.tsx +2 -1
  34. package/src/focus/FocusScope.ts +2 -2
  35. package/src/grid/useGrid.ts +3 -0
  36. package/src/grid/{useGridCell.ts → useGridCell.tsx} +128 -66
  37. package/src/grid/utils.ts +1 -0
  38. package/src/gridlist/{useGridListItem.ts → useGridListItem.tsx} +154 -112
  39. package/src/index.ts +22 -0
  40. package/src/interactions/createEventHandler.ts +11 -8
  41. package/src/interactions/createKeyboardShortcutHandler.tsx +218 -0
  42. package/src/interactions/textSelection.ts +3 -3
  43. package/src/interactions/useContextMenu.tsx +125 -0
  44. package/src/interactions/useFocusVisible.ts +4 -4
  45. package/src/interactions/useKeyboard.ts +50 -2
  46. package/src/interactions/useLongPress.ts +33 -22
  47. package/src/interactions/useMove.ts +6 -6
  48. package/src/interactions/usePress.ts +8 -0
  49. package/src/interactions/utils.ts +10 -10
  50. package/src/intl/combobox/fi-FI.json +2 -2
  51. package/src/intl/previewtrigger/ar-AE.json +3 -0
  52. package/src/intl/previewtrigger/bg-BG.json +3 -0
  53. package/src/intl/previewtrigger/cs-CZ.json +3 -0
  54. package/src/intl/previewtrigger/da-DK.json +3 -0
  55. package/src/intl/previewtrigger/de-DE.json +3 -0
  56. package/src/intl/previewtrigger/el-GR.json +3 -0
  57. package/src/intl/previewtrigger/en-US.json +3 -0
  58. package/src/intl/previewtrigger/es-ES.json +3 -0
  59. package/src/intl/previewtrigger/et-EE.json +3 -0
  60. package/src/intl/previewtrigger/fi-FI.json +3 -0
  61. package/src/intl/previewtrigger/fr-FR.json +3 -0
  62. package/src/intl/previewtrigger/he-IL.json +3 -0
  63. package/src/intl/previewtrigger/hr-HR.json +3 -0
  64. package/src/intl/previewtrigger/hu-HU.json +3 -0
  65. package/src/intl/previewtrigger/index.ts +73 -0
  66. package/src/intl/previewtrigger/it-IT.json +3 -0
  67. package/src/intl/previewtrigger/ja-JP.json +3 -0
  68. package/src/intl/previewtrigger/ko-KR.json +3 -0
  69. package/src/intl/previewtrigger/lt-LT.json +3 -0
  70. package/src/intl/previewtrigger/lv-LV.json +3 -0
  71. package/src/intl/previewtrigger/nb-NO.json +3 -0
  72. package/src/intl/previewtrigger/nl-NL.json +3 -0
  73. package/src/intl/previewtrigger/pl-PL.json +3 -0
  74. package/src/intl/previewtrigger/pt-BR.json +3 -0
  75. package/src/intl/previewtrigger/pt-PT.json +3 -0
  76. package/src/intl/previewtrigger/ro-RO.json +3 -0
  77. package/src/intl/previewtrigger/ru-RU.json +3 -0
  78. package/src/intl/previewtrigger/sk-SK.json +3 -0
  79. package/src/intl/previewtrigger/sl-SI.json +3 -0
  80. package/src/intl/previewtrigger/sr-SP.json +3 -0
  81. package/src/intl/previewtrigger/sv-SE.json +3 -0
  82. package/src/intl/previewtrigger/tr-TR.json +3 -0
  83. package/src/intl/previewtrigger/uk-UA.json +3 -0
  84. package/src/intl/previewtrigger/zh-CN.json +3 -0
  85. package/src/intl/previewtrigger/zh-TW.json +3 -0
  86. package/src/intl/table/ar-AE.json +3 -3
  87. package/src/intl/table/bg-BG.json +3 -3
  88. package/src/intl/table/cs-CZ.json +3 -3
  89. package/src/intl/table/da-DK.json +3 -3
  90. package/src/intl/table/de-DE.json +3 -3
  91. package/src/intl/table/el-GR.json +3 -3
  92. package/src/intl/table/es-ES.json +3 -3
  93. package/src/intl/table/et-EE.json +3 -3
  94. package/src/intl/table/fi-FI.json +3 -3
  95. package/src/intl/table/fr-FR.json +3 -3
  96. package/src/intl/table/he-IL.json +3 -3
  97. package/src/intl/table/hr-HR.json +3 -3
  98. package/src/intl/table/hu-HU.json +3 -3
  99. package/src/intl/table/it-IT.json +3 -3
  100. package/src/intl/table/ja-JP.json +3 -3
  101. package/src/intl/table/ko-KR.json +3 -3
  102. package/src/intl/table/lt-LT.json +3 -3
  103. package/src/intl/table/lv-LV.json +3 -3
  104. package/src/intl/table/nb-NO.json +3 -3
  105. package/src/intl/table/nl-NL.json +3 -3
  106. package/src/intl/table/pl-PL.json +3 -3
  107. package/src/intl/table/pt-BR.json +3 -3
  108. package/src/intl/table/pt-PT.json +3 -3
  109. package/src/intl/table/ro-RO.json +3 -3
  110. package/src/intl/table/ru-RU.json +3 -3
  111. package/src/intl/table/sk-SK.json +3 -3
  112. package/src/intl/table/sl-SI.json +3 -3
  113. package/src/intl/table/sr-SP.json +3 -3
  114. package/src/intl/table/sv-SE.json +3 -3
  115. package/src/intl/table/tr-TR.json +3 -3
  116. package/src/intl/table/uk-UA.json +3 -3
  117. package/src/intl/table/zh-CN.json +4 -4
  118. package/src/intl/table/zh-TW.json +3 -3
  119. package/src/intl/tree/es-ES.json +1 -1
  120. package/src/link/useLink.ts +2 -0
  121. package/src/menu/useMenuItem.ts +33 -36
  122. package/src/menu/useMenuTrigger.tsx +222 -0
  123. package/src/menu/{useSubmenuTrigger.ts → useSubmenuTrigger.tsx} +116 -151
  124. package/src/numberfield/{useNumberField.ts → useNumberField.tsx} +188 -238
  125. package/src/overlays/Overlay.ts +5 -1
  126. package/src/overlays/ariaHideOutside.ts +6 -8
  127. package/src/overlays/useCloseOnScroll.ts +7 -5
  128. package/src/overlays/{useOverlay.ts → useOverlay.tsx} +76 -83
  129. package/src/overlays/usePopover.ts +10 -5
  130. package/src/overlays/usePreventScroll.ts +4 -4
  131. package/src/progress/useProgressBar.ts +2 -1
  132. package/src/radio/{useRadioGroup.ts → useRadioGroup.tsx} +87 -79
  133. package/src/searchfield/useSearchField.tsx +152 -0
  134. package/src/select/useSelect.ts +20 -20
  135. package/src/selection/useSelectableCollection.tsx +755 -0
  136. package/src/selection/useSelectableItem.ts +48 -11
  137. package/src/slider/{useSliderThumb.ts → useSliderThumb.tsx} +120 -158
  138. package/src/spinbutton/useSpinButton.tsx +335 -0
  139. package/src/stately/combobox/useComboBoxState.ts +1 -1
  140. package/src/stately/grid/useGridState.ts +9 -10
  141. package/src/stately/index.ts +11 -0
  142. package/src/stately/menu/useMenuTriggerState.ts +1 -1
  143. package/src/stately/menu/useSubmenuTriggerState.ts +2 -0
  144. package/src/stately/overlays/useOverlayTriggerState.ts +12 -1
  145. package/src/stately/radio/useRadioGroupState.ts +5 -1
  146. package/src/stately/table/TableUtils.ts +15 -1
  147. package/src/stately/table/useTableState.ts +7 -0
  148. package/src/stately/tokenfield/TokenFieldValue.tsx +440 -0
  149. package/src/stately/tokenfield/useTokenFieldState.tsx +57 -0
  150. package/src/stately/tooltip/useTooltipTriggerState.ts +16 -7
  151. package/src/stately/virtualizer/Layout.tsx +2 -0
  152. package/src/stately/virtualizer/ScrollAnchor.tsx +338 -0
  153. package/src/stately/virtualizer/Virtualizer.tsx +52 -1
  154. package/src/stately/virtualizer/types.tsx +2 -0
  155. package/src/table/useTableCell.ts +12 -0
  156. package/src/table/useTableColumnHeader.ts +8 -1
  157. package/src/table/useTableColumnResize.tsx +359 -0
  158. package/src/tag/useTag.ts +22 -15
  159. package/src/textfield/useTextField.ts +2 -0
  160. package/src/tokenfield/useToken.tsx +64 -0
  161. package/src/tokenfield/useTokenField.tsx +849 -0
  162. package/src/tooltip/usePreviewTrigger.tsx +266 -0
  163. package/src/tooltip/useSafeArea.tsx +171 -0
  164. package/src/upstream-exports/react-aria/mergeRefs.tsx +3 -0
  165. package/src/upstream-exports/react-aria/usePreviewTrigger.tsx +18 -0
  166. package/src/upstream-exports/react-aria/useTokenField.tsx +22 -0
  167. package/src/upstream-exports/react-stately/private/collections/getChildNodes.tsx +8 -0
  168. package/src/upstream-exports/react-stately/private/collections/getItemCount.tsx +2 -0
  169. package/src/upstream-exports/react-stately/private/grid/GridCollection.tsx +7 -0
  170. package/src/upstream-exports/react-stately/private/grid/useGridState.tsx +6 -0
  171. package/src/upstream-exports/react-stately/private/utils/number.tsx +2 -0
  172. package/src/upstream-exports/react-stately/useComboBoxState.tsx +23 -0
  173. package/src/upstream-exports/react-stately/useListState.tsx +15 -0
  174. package/src/upstream-exports/react-stately/useMenuTriggerState.tsx +26 -0
  175. package/src/upstream-exports/react-stately/useMultipleSelectionState.tsx +21 -0
  176. package/src/upstream-exports/react-stately/useNumberFieldState.tsx +20 -0
  177. package/src/upstream-exports/react-stately/useOverlayTriggerState.tsx +20 -0
  178. package/src/upstream-exports/react-stately/useRadioGroupState.tsx +16 -0
  179. package/src/upstream-exports/react-stately/useSearchFieldState.tsx +19 -0
  180. package/src/upstream-exports/react-stately/useSliderState.tsx +20 -0
  181. package/src/upstream-exports/react-stately/useTableState.tsx +45 -0
  182. package/src/upstream-exports/react-stately/useTokenFieldState.tsx +25 -0
  183. package/src/upstream-exports/react-stately/useTooltipTriggerState.tsx +20 -0
  184. package/src/upstream-exports/react-stately/useTreeState.tsx +14 -0
  185. package/src/utils/domHelpers.ts +74 -17
  186. package/src/utils/isVirtualEvent.ts +2 -1
  187. package/src/utils/openLink.ts +8 -3
  188. package/src/utils/platform.ts +7 -3
  189. package/src/utils/scrollIntoView.ts +4 -4
  190. package/src/utils/shadowdom/DOMFunctions.ts +42 -2
  191. package/src/utils/useId.ts +11 -2
  192. package/src/utils/useLoadMoreSentinel.ts +8 -3
  193. package/src/utils/useViewportSize.ts +3 -3
  194. package/src/virtualizer/ScrollView.tsx +13 -4
  195. package/src/virtualizer/VirtualizerItem.tsx +4 -1
  196. package/src/virtualizer/useVirtualizerItem.tsx +43 -3
  197. package/src/menu/useMenuTrigger.ts +0 -189
  198. package/src/searchfield/useSearchField.ts +0 -156
  199. package/src/selection/useSelectableCollection.ts +0 -750
  200. package/src/spinbutton/useSpinButton.ts +0 -380
  201. package/src/table/useTableColumnResize.ts +0 -374
@@ -0,0 +1,338 @@
1
+ /** @jsxImportSource octane */
2
+ // Ported from adobe/react-spectrum@5ecb3333001313e83898cd07644227897e3bae1f (packages/react-stately/src/virtualizer/ScrollAnchor.ts).
3
+ /*
4
+ * Copyright 2026 Adobe. All rights reserved.
5
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License. You may obtain a copy
7
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software distributed under
10
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
11
+ * OF ANY KIND, either express or implied. See the License for the specific language
12
+ * governing permissions and limitations under the License.
13
+ */
14
+
15
+ import { Key } from '@react-types/shared';
16
+ import { LayoutInfo } from './LayoutInfo';
17
+ import { Rect, RectCorner } from './Rect';
18
+ import { Size } from './Size';
19
+
20
+ export type ScrollAnchorAxis = 'x' | 'y';
21
+ export type ScrollAnchorEdge = 'start' | 'end';
22
+ export interface ScrollAnchor {
23
+ key: Key;
24
+ corner: RectCorner;
25
+ offset: number;
26
+ }
27
+
28
+ export interface ScrollAnchorInfo {
29
+ /** Which edge of the content the viewport should stay anchored to. */
30
+ edge: 'start' | 'end';
31
+ /** Which axis `edge` refers to — 'y' for vertical lists, 'x' for horizontal. */
32
+ axis: 'x' | 'y';
33
+ /** Distance (px) from `edge` within which the viewport is considered "following" it. */
34
+ threshold: number;
35
+ /**
36
+ * Optional classifier excluding structural/ephemeral layout infos (e.g. loaders) from being
37
+ * selected as the anchor. Defaults to allowing any layoutInfo.
38
+ */
39
+ isAnchorable?: (layoutInfo: LayoutInfo) => boolean;
40
+ }
41
+
42
+ /**
43
+ * Minimum overlap an item must have with the viewport, along the scroll axis,
44
+ * to be eligible as a scroll anchor. Without this, an item that only overlaps the viewport
45
+ * by a sliver (e.g. 1px, essentially scrolled out of view) can still "win" the anchor
46
+ * tie-break over a substantially visible item.
47
+ */
48
+ const MIN_ANCHOR_OVERLAP = 4;
49
+
50
+ function dimensionForAxis(axis: ScrollAnchorAxis): 'width' | 'height' {
51
+ return axis === 'x' ? 'width' : 'height';
52
+ }
53
+
54
+ /**
55
+ * Given a previously-captured anchor, computes the new viewport coordinate (along `axis`) needed
56
+ * to keep it at the same offset from the viewport's start.
57
+ */
58
+ export function computeScrollAnchorTarget(
59
+ anchor: ScrollAnchor,
60
+ axis: ScrollAnchorAxis,
61
+ getLayoutInfo: (key: Key) => LayoutInfo | null,
62
+ visibleRect: Rect,
63
+ contentSize: Size,
64
+ ): number | null {
65
+ let finalInfo = getLayoutInfo(anchor.key);
66
+ if (!finalInfo) {
67
+ return null;
68
+ }
69
+ let adjustment = finalInfo.rect[anchor.corner][axis] - visibleRect[axis] - anchor.offset;
70
+ if (adjustment === 0) {
71
+ return null;
72
+ }
73
+ let target = visibleRect[axis] + adjustment;
74
+ let dimension = dimensionForAxis(axis);
75
+ let max = Math.max(0, contentSize[dimension] - visibleRect[dimension]);
76
+ let clamped = Math.max(0, Math.min(max, target));
77
+ return clamped !== visibleRect[axis] ? clamped : null;
78
+ }
79
+
80
+ /**
81
+ * Picks the item to anchor scroll to: the one nearest the top of the viewport when
82
+ * anchoring to 'end', or nearest the bottom when anchoring to 'start'. Callers can
83
+ * exclude certain items (like loaders) with `isAnchorable`.
84
+ */
85
+ export function captureScrollAnchor(
86
+ edge: ScrollAnchorEdge,
87
+ axis: ScrollAnchorAxis,
88
+ visibleRect: Rect,
89
+ visibleLayoutInfos: Iterable<[Key, LayoutInfo]>,
90
+ isAnchorable: (layoutInfo: LayoutInfo) => boolean = () => true,
91
+ ): ScrollAnchor | null {
92
+ let dimension = dimensionForAxis(axis);
93
+ let best: ScrollAnchor | null = null;
94
+ for (let [key, layoutInfo] of visibleLayoutInfos) {
95
+ if (!layoutInfo || !isAnchorable(layoutInfo)) {
96
+ continue;
97
+ }
98
+ let overlap = layoutInfo.rect.intersection(visibleRect)[dimension];
99
+ if (layoutInfo.rect.area > 0 && overlap >= MIN_ANCHOR_OVERLAP) {
100
+ let corner = layoutInfo.rect.getCornerInRect(visibleRect) ?? 'topLeft';
101
+ let offset = layoutInfo.rect[corner][axis] - visibleRect[axis];
102
+ let isBetter = !best || (edge === 'end' ? offset < best.offset : offset > best.offset);
103
+ if (isBetter) {
104
+ best = { key, corner, offset };
105
+ }
106
+ }
107
+ }
108
+ return best;
109
+ }
110
+
111
+ /** Returns the viewport coordinate (along `axis`) that pins the viewport to `edge` of the content. */
112
+ export function getEdgeSnapTarget(
113
+ edge: ScrollAnchorEdge,
114
+ axis: ScrollAnchorAxis,
115
+ contentSize: Size,
116
+ previousVisibleRect: Rect,
117
+ ): number {
118
+ if (edge === 'start') {
119
+ return 0;
120
+ }
121
+ let dimension = dimensionForAxis(axis);
122
+ return Math.max(0, contentSize[dimension] - previousVisibleRect[dimension]);
123
+ }
124
+
125
+ /**
126
+ * Whether the viewport is currently within `threshold` px of the anchored edge — used by
127
+ * Virtualizer to compute wasNearAnchorEdge generically, without any layout-specific state.
128
+ */
129
+ export function isNearEdge(
130
+ visibleRect: Rect,
131
+ contentSize: Size,
132
+ edge: ScrollAnchorEdge,
133
+ axis: ScrollAnchorAxis,
134
+ threshold: number,
135
+ ): boolean {
136
+ if (edge === 'start') {
137
+ return visibleRect[axis] <= threshold;
138
+ }
139
+ let dimension = dimensionForAxis(axis);
140
+ let distanceFromEnd = contentSize[dimension] - (visibleRect[axis] + visibleRect[dimension]);
141
+ return distanceFromEnd <= threshold;
142
+ }
143
+
144
+ /**
145
+ * Works out the new scroll position after content changes. Tries to keep the anchor
146
+ * item where it was. If that doesn't apply, sticks the view to the edge instead, but
147
+ * only if the user was already near the edge and didn't just scroll away on their own.
148
+ */
149
+ export function resolveScrollAdjustment(
150
+ edge: ScrollAnchorEdge,
151
+ axis: ScrollAnchorAxis,
152
+ anchor: ScrollAnchor | null,
153
+ wasNearAnchorEdge: boolean,
154
+ isScrolling: boolean,
155
+ itemSizeChanged: boolean,
156
+ contentSizeDelta: number,
157
+ getLayoutInfo: (key: Key) => LayoutInfo | null,
158
+ previousVisibleRect: Rect,
159
+ contentSize: Size,
160
+ ): Rect | null {
161
+ let withTarget = (target: number): Rect =>
162
+ axis === 'x'
163
+ ? new Rect(
164
+ target,
165
+ previousVisibleRect.y,
166
+ previousVisibleRect.width,
167
+ previousVisibleRect.height,
168
+ )
169
+ : new Rect(
170
+ previousVisibleRect.x,
171
+ target,
172
+ previousVisibleRect.width,
173
+ previousVisibleRect.height,
174
+ );
175
+
176
+ if (anchor) {
177
+ let target = computeScrollAnchorTarget(
178
+ anchor,
179
+ axis,
180
+ getLayoutInfo,
181
+ previousVisibleRect,
182
+ contentSize,
183
+ );
184
+ if (target != null) {
185
+ return withTarget(target);
186
+ }
187
+ }
188
+
189
+ if (wasNearAnchorEdge && !isScrolling && (!itemSizeChanged || contentSizeDelta > 0)) {
190
+ let target = withTarget(getEdgeSnapTarget(edge, axis, contentSize, previousVisibleRect));
191
+ return target.equals(previousVisibleRect) ? null : target;
192
+ }
193
+
194
+ return null;
195
+ }
196
+
197
+ export interface ResolveAfterLayoutOptions {
198
+ anchorInfo: ScrollAnchorInfo | null;
199
+ /** The anchor captured by `captureBeforeLayout` before this pass's `layout.update()` ran. */
200
+ anchor: ScrollAnchor | null;
201
+ /** The full post-layout visible layout infos, i.e. `virtualizer.getVisibleLayoutInfos()`. */
202
+ postLayoutInfos: Map<Key, LayoutInfo>;
203
+ previousVisibleRect: Rect;
204
+ previousContentSize: Size;
205
+ contentSize: Size;
206
+ itemSizeChanged: boolean;
207
+ isScrolling: boolean;
208
+ getLayoutInfo: (key: Key) => LayoutInfo | null;
209
+ }
210
+
211
+ /**
212
+ * Tracks the cross-pass state needed to keep the viewport anchored to a layout's edge across
213
+ * relayouts.
214
+ */
215
+ export class ScrollAnchorTracker {
216
+ private hasSnappedToEdge = false;
217
+ private hadEstimatedVisibleItems = false;
218
+ private wasNearAnchorEdge = false;
219
+
220
+ /** Resets all tracked state, e.g. when the virtualizer's layout instance changes. */
221
+ reset(): void {
222
+ this.hasSnappedToEdge = false;
223
+ this.hadEstimatedVisibleItems = false;
224
+ this.wasNearAnchorEdge = false;
225
+ }
226
+
227
+ /**
228
+ * Captures the anchor from pre-layout view positions.
229
+ */
230
+ captureBeforeLayout(
231
+ anchorInfo: ScrollAnchorInfo | null,
232
+ preLayoutInfos: Iterable<[Key, LayoutInfo]>,
233
+ visibleRect: Rect,
234
+ ): ScrollAnchor | null {
235
+ if (!anchorInfo) {
236
+ return null;
237
+ }
238
+ return captureScrollAnchor(
239
+ anchorInfo.edge,
240
+ anchorInfo.axis,
241
+ visibleRect,
242
+ preLayoutInfos,
243
+ anchorInfo.isAnchorable,
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Runs the full post-layout decision: updates the tracked state for the next pass, and
249
+ * returns the resolved scroll target, or null if nothing should change.
250
+ */
251
+ resolveAfterLayout(options: ResolveAfterLayoutOptions): Rect | null {
252
+ let {
253
+ anchorInfo,
254
+ anchor,
255
+ postLayoutInfos,
256
+ previousVisibleRect,
257
+ previousContentSize,
258
+ contentSize,
259
+ itemSizeChanged,
260
+ isScrolling,
261
+ getLayoutInfo,
262
+ } = options;
263
+
264
+ if (!anchorInfo) {
265
+ return null;
266
+ }
267
+
268
+ // Read the previous pass's state into locals before any writes below overwrite it.
269
+ let wasSettlingLastPass = this.hadEstimatedVisibleItems;
270
+ let wasNearAnchorEdgeLastPass = this.wasNearAnchorEdge;
271
+
272
+ let hasEstimated = false;
273
+ for (let layoutInfo of postLayoutInfos.values()) {
274
+ if (layoutInfo.estimatedSize) {
275
+ hasEstimated = true;
276
+ break;
277
+ }
278
+ }
279
+ this.hadEstimatedVisibleItems = hasEstimated;
280
+ // Don't recheck "near edge?" mid-resize because it could look like a scroll that never happened.
281
+ // Reuse the answer from before the resizing started.
282
+ if (!wasSettlingLastPass) {
283
+ this.wasNearAnchorEdge = isNearEdge(
284
+ previousVisibleRect,
285
+ previousContentSize,
286
+ anchorInfo.edge,
287
+ anchorInfo.axis,
288
+ anchorInfo.threshold,
289
+ );
290
+ }
291
+
292
+ if (previousVisibleRect.area === 0) {
293
+ return null;
294
+ }
295
+
296
+ let dimension = anchorInfo.axis === 'x' ? 'width' : 'height';
297
+ let contentSizeDelta = contentSize[dimension] - previousContentSize[dimension];
298
+ let isFirstAnchoredLayout = !this.hasSnappedToEdge;
299
+ this.hasSnappedToEdge = true;
300
+
301
+ // Only modify scroll when content actually changed (or this is the first layout, which always snaps)
302
+ if (!(isFirstAnchoredLayout || contentSizeDelta !== 0 || itemSizeChanged)) {
303
+ return null;
304
+ }
305
+
306
+ let wasNearAnchorEdge =
307
+ isFirstAnchoredLayout ||
308
+ (wasSettlingLastPass && wasNearAnchorEdgeLastPass) ||
309
+ isNearEdge(
310
+ previousVisibleRect,
311
+ previousContentSize,
312
+ anchorInfo.edge,
313
+ anchorInfo.axis,
314
+ anchorInfo.threshold,
315
+ );
316
+ // A first-ever layout always snaps to the edge, even if the raw distance check says
317
+ // otherwise. Save that real decision here so later passes in this cascade reuse it.
318
+ if (!wasSettlingLastPass) {
319
+ this.wasNearAnchorEdge = wasNearAnchorEdge;
320
+ }
321
+ // Skip restoring to the captured anchor while still resizing because items above it are also still growing,
322
+ // and following it would fall short of the edge.
323
+ let effectiveAnchor =
324
+ isFirstAnchoredLayout || (wasSettlingLastPass && wasNearAnchorEdgeLastPass) ? null : anchor;
325
+ return resolveScrollAdjustment(
326
+ anchorInfo.edge,
327
+ anchorInfo.axis,
328
+ effectiveAnchor,
329
+ wasNearAnchorEdge,
330
+ isScrolling,
331
+ itemSizeChanged,
332
+ contentSizeDelta,
333
+ getLayoutInfo,
334
+ previousVisibleRect,
335
+ contentSize,
336
+ );
337
+ }
338
+ }
@@ -26,6 +26,7 @@ import { LayoutInfo } from './LayoutInfo';
26
26
  import { OverscanManager } from './OverscanManager';
27
27
  import { Point } from './Point';
28
28
  import { Rect } from './Rect';
29
+ import { ScrollAnchor, ScrollAnchorTracker } from './ScrollAnchor';
29
30
  import { Size } from './Size';
30
31
 
31
32
  interface VirtualizerOptions<T extends object, V> {
@@ -78,6 +79,7 @@ export class Virtualizer<T extends object, V> {
78
79
  private _invalidationContext: InvalidationContext;
79
80
  private _overscanManager: OverscanManager;
80
81
 
82
+ private _scrollAnchor: ScrollAnchorTracker;
81
83
  constructor(options: VirtualizerOptions<T, V>) {
82
84
  this.delegate = options.delegate;
83
85
  this.collection = options.collection;
@@ -92,6 +94,7 @@ export class Virtualizer<T extends object, V> {
92
94
  this._isScrolling = false;
93
95
  this._invalidationContext = {};
94
96
  this._overscanManager = new OverscanManager();
97
+ this._scrollAnchor = new ScrollAnchorTracker();
95
98
  }
96
99
 
97
100
  /** Returns whether the given key, or an ancestor, is persisted. */
@@ -175,11 +178,50 @@ export class Virtualizer<T extends object, V> {
175
178
 
176
179
  private relayout(context: InvalidationContext = {}) {
177
180
  // Update the layout
181
+ let anchorInfo = this.layout.UNSTABLE_getScrollAnchorInfo?.(context.layoutOptions) ?? null;
182
+
183
+ // Capture scroll anchor from current (pre-layout) view positions.
184
+ // On first render _visibleViews is empty so no anchor will be found.
185
+ let anchor: ScrollAnchor | null = null;
186
+ if (anchorInfo) {
187
+ let preLayoutInfos: [Key, LayoutInfo][] = [];
188
+ for (let [key, view] of this._visibleViews) {
189
+ let layoutInfo = this.layout.getLayoutInfo(key) ?? view.layoutInfo;
190
+ if (layoutInfo) {
191
+ preLayoutInfos.push([key, layoutInfo]);
192
+ }
193
+ }
194
+ anchor = this._scrollAnchor.captureBeforeLayout(anchorInfo, preLayoutInfos, this.visibleRect);
195
+ }
196
+
197
+ let previousContentSize = this.contentSize;
198
+ let previousVisibleRect = this.visibleRect;
178
199
  this.layout.update(context);
179
- (this as Mutable<this>).contentSize = this.layout.getContentSize();
200
+
201
+ let rawContentSize = this.layout.getContentSize();
202
+ (this as Mutable<this>).contentSize = new Size(rawContentSize.width, rawContentSize.height);
203
+
204
+ let target = this._scrollAnchor.resolveAfterLayout({
205
+ anchorInfo,
206
+ anchor,
207
+ postLayoutInfos: anchorInfo ? this.getVisibleLayoutInfos() : new Map(),
208
+ previousVisibleRect,
209
+ previousContentSize,
210
+ contentSize: this.contentSize,
211
+ itemSizeChanged: context.itemSizeChanged ?? false,
212
+ isScrolling: this._isScrolling,
213
+ getLayoutInfo: (key: Key) => this.layout.getLayoutInfo(key),
214
+ });
180
215
 
181
216
  // Constrain scroll position.
182
217
  // If the content changed, scroll to the top.
218
+ if (target) {
219
+ // Queues a new render cycle. Return early to skip updateSubviews — running it now
220
+ // would position views against the old visibleRect, causing a flash before the
221
+ // incoming relayout corrects them.
222
+ this.delegate.setVisibleRect(target);
223
+ return;
224
+ }
183
225
  let visibleRect = this.visibleRect;
184
226
  let contentOffsetX = context.contentChanged ? 0 : visibleRect.x;
185
227
  let contentOffsetY = context.contentChanged ? 0 : visibleRect.y;
@@ -289,6 +331,8 @@ export class Virtualizer<T extends object, V> {
289
331
  let needsLayout = false;
290
332
  let offsetChanged = false;
291
333
  let sizeChanged = false;
334
+ let widthChanged = false;
335
+ let heightChanged = false;
292
336
  let itemSizeChanged = false;
293
337
  let layoutOptionsChanged = false;
294
338
  let needsUpdate = false;
@@ -305,6 +349,7 @@ export class Virtualizer<T extends object, V> {
305
349
 
306
350
  opts.layout.virtualizer = this;
307
351
  mutableThis.layout = opts.layout;
352
+ this._scrollAnchor.reset();
308
353
  needsLayout = true;
309
354
  }
310
355
 
@@ -335,6 +380,8 @@ export class Virtualizer<T extends object, V> {
335
380
  if (shouldInvalidate) {
336
381
  offsetChanged = !opts.visibleRect.pointEquals(this.visibleRect);
337
382
  sizeChanged = !this.size.equals(opts.size);
383
+ widthChanged = this.size.width !== opts.size.width;
384
+ heightChanged = this.size.height !== opts.size.height;
338
385
  needsLayout = true;
339
386
  } else {
340
387
  needsUpdate = true;
@@ -347,6 +394,8 @@ export class Virtualizer<T extends object, V> {
347
394
  if (opts.invalidationContext !== this._invalidationContext) {
348
395
  if (opts.invalidationContext) {
349
396
  sizeChanged ||= opts.invalidationContext.sizeChanged || false;
397
+ widthChanged ||= opts.invalidationContext.widthChanged || false;
398
+ heightChanged ||= opts.invalidationContext.heightChanged || false;
350
399
  offsetChanged ||= opts.invalidationContext.offsetChanged || false;
351
400
  itemSizeChanged ||= opts.invalidationContext.itemSizeChanged || false;
352
401
  layoutOptionsChanged ||=
@@ -374,6 +423,8 @@ export class Virtualizer<T extends object, V> {
374
423
  this.relayout({
375
424
  offsetChanged,
376
425
  sizeChanged,
426
+ widthChanged,
427
+ heightChanged,
377
428
  itemSizeChanged,
378
429
  layoutOptionsChanged,
379
430
  layoutOptions: this._invalidationContext.layoutOptions,
@@ -21,6 +21,8 @@ export interface InvalidationContext<O = any> {
21
21
  contentChanged?: boolean;
22
22
  offsetChanged?: boolean;
23
23
  sizeChanged?: boolean;
24
+ widthChanged?: boolean;
25
+ heightChanged?: boolean;
24
26
  itemSizeChanged?: boolean;
25
27
  layoutOptionsChanged?: boolean;
26
28
  layoutOptions?: O;
@@ -32,6 +32,18 @@ export interface AriaTableCellProps {
32
32
  * @deprecated
33
33
  */
34
34
  onAction?: () => void;
35
+ /**
36
+ * Whether the cell or its first focusable child element should be focused when the cell is
37
+ * focused. Defaults to 'child' in arrow keyboard navigation mode and 'cell' in
38
+ * tab keyboard navigation mode.
39
+ */
40
+ focusMode?: 'child' | 'cell';
41
+ /**
42
+ * Whether the cell should support arrow key navigation even when the containing table uses
43
+ * tab keyboard navigation.Allows users to navigate between rows and cells with arrow keys while
44
+ * focus is on an interactive child element within the cell.
45
+ */
46
+ allowsArrowNavigation?: boolean;
35
47
  }
36
48
 
37
49
  export interface TableCellAria {
@@ -38,6 +38,13 @@ export interface AriaTableColumnHeaderProps<T> {
38
38
  * virtual scroller.
39
39
  */
40
40
  isVirtualized?: boolean;
41
+ focusMode?: 'child' | 'cell';
42
+ /**
43
+ * Whether the column header should support arrow key navigation even when the containing table
44
+ * uses tab keyboard navigation. Allows users to navigate between columns with arrow keys while
45
+ * focus is on an interactive child element within the cell.
46
+ */
47
+ allowsArrowNavigation?: boolean;
41
48
  }
42
49
 
43
50
  export interface TableColumnHeaderAria {
@@ -77,7 +84,7 @@ export function useTableColumnHeader(...args: any[]): TableColumnHeaderAria {
77
84
  let allowsSorting = node.props.allowsSorting;
78
85
  // if there are no focusable children, the column header will focus the cell
79
86
  let { gridCellProps } = useGridCell(
80
- { ...props, focusMode: 'child' },
87
+ { focusMode: 'child', ...props },
81
88
  state,
82
89
  ref,
83
90
  subSlot(slot, 'gridCell'),