@react-aria/selection 3.18.1 → 3.19.1

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 (36) hide show
  1. package/dist/DOMLayoutDelegate.main.js +53 -0
  2. package/dist/DOMLayoutDelegate.main.js.map +1 -0
  3. package/dist/DOMLayoutDelegate.mjs +48 -0
  4. package/dist/DOMLayoutDelegate.module.js +48 -0
  5. package/dist/DOMLayoutDelegate.module.js.map +1 -0
  6. package/dist/ListKeyboardDelegate.main.js +28 -39
  7. package/dist/ListKeyboardDelegate.main.js.map +1 -1
  8. package/dist/ListKeyboardDelegate.mjs +28 -39
  9. package/dist/ListKeyboardDelegate.module.js +28 -39
  10. package/dist/ListKeyboardDelegate.module.js.map +1 -1
  11. package/dist/import.mjs +3 -1
  12. package/dist/main.js +3 -0
  13. package/dist/main.js.map +1 -1
  14. package/dist/module.js +3 -1
  15. package/dist/module.js.map +1 -1
  16. package/dist/types.d.ts +19 -7
  17. package/dist/types.d.ts.map +1 -1
  18. package/dist/useSelectableCollection.main.js +17 -15
  19. package/dist/useSelectableCollection.main.js.map +1 -1
  20. package/dist/useSelectableCollection.mjs +17 -15
  21. package/dist/useSelectableCollection.module.js +17 -15
  22. package/dist/useSelectableCollection.module.js.map +1 -1
  23. package/dist/useSelectableItem.main.js.map +1 -1
  24. package/dist/useSelectableItem.module.js.map +1 -1
  25. package/dist/useSelectableList.main.js +4 -2
  26. package/dist/useSelectableList.main.js.map +1 -1
  27. package/dist/useSelectableList.mjs +4 -2
  28. package/dist/useSelectableList.module.js +4 -2
  29. package/dist/useSelectableList.module.js.map +1 -1
  30. package/package.json +10 -10
  31. package/src/DOMLayoutDelegate.ts +57 -0
  32. package/src/ListKeyboardDelegate.ts +37 -49
  33. package/src/index.ts +1 -0
  34. package/src/useSelectableCollection.ts +26 -15
  35. package/src/useSelectableItem.ts +3 -3
  36. package/src/useSelectableList.ts +12 -4
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Copyright 2024 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import {Key, LayoutDelegate, Rect, RefObject, Size} from '@react-types/shared';
14
+
15
+ export class DOMLayoutDelegate implements LayoutDelegate {
16
+ private ref: RefObject<HTMLElement>;
17
+
18
+ constructor(ref: RefObject<HTMLElement>) {
19
+ this.ref = ref;
20
+ }
21
+
22
+ getItemRect(key: Key): Rect | null {
23
+ let container = this.ref.current;
24
+ let item = key != null ? container.querySelector(`[data-key="${CSS.escape(key.toString())}"]`) : null;
25
+ if (!item) {
26
+ return null;
27
+ }
28
+
29
+ let containerRect = container.getBoundingClientRect();
30
+ let itemRect = item.getBoundingClientRect();
31
+
32
+ return {
33
+ x: itemRect.left - containerRect.left + container.scrollLeft,
34
+ y: itemRect.top - containerRect.top + container.scrollTop,
35
+ width: itemRect.width,
36
+ height: itemRect.height
37
+ };
38
+ }
39
+
40
+ getContentSize(): Size {
41
+ let container = this.ref.current;
42
+ return {
43
+ width: container.scrollWidth,
44
+ height: container.scrollHeight
45
+ };
46
+ }
47
+
48
+ getVisibleRect(): Rect {
49
+ let container = this.ref.current;
50
+ return {
51
+ x: container.scrollLeft,
52
+ y: container.scrollTop,
53
+ width: container.offsetWidth,
54
+ height: container.offsetHeight
55
+ };
56
+ }
57
+ }
@@ -10,32 +10,34 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {Collection, Direction, DisabledBehavior, Key, KeyboardDelegate, Node, Orientation} from '@react-types/shared';
13
+ import {Collection, Direction, DisabledBehavior, Key, KeyboardDelegate, LayoutDelegate, Node, Orientation, Rect, RefObject} from '@react-types/shared';
14
+ import {DOMLayoutDelegate} from './DOMLayoutDelegate';
14
15
  import {isScrollable} from '@react-aria/utils';
15
- import {RefObject} from 'react';
16
16
 
17
17
  interface ListKeyboardDelegateOptions<T> {
18
18
  collection: Collection<Node<T>>,
19
- ref: RefObject<HTMLElement>,
19
+ ref: RefObject<HTMLElement | null>,
20
20
  collator?: Intl.Collator,
21
21
  layout?: 'stack' | 'grid',
22
22
  orientation?: Orientation,
23
23
  direction?: Direction,
24
24
  disabledKeys?: Set<Key>,
25
- disabledBehavior?: DisabledBehavior
25
+ disabledBehavior?: DisabledBehavior,
26
+ layoutDelegate?: LayoutDelegate
26
27
  }
27
28
 
28
29
  export class ListKeyboardDelegate<T> implements KeyboardDelegate {
29
30
  private collection: Collection<Node<T>>;
30
31
  private disabledKeys: Set<Key>;
31
32
  private disabledBehavior: DisabledBehavior;
32
- private ref: RefObject<HTMLElement>;
33
+ private ref: RefObject<HTMLElement | null>;
33
34
  private collator: Intl.Collator | undefined;
34
35
  private layout: 'stack' | 'grid';
35
36
  private orientation?: Orientation;
36
37
  private direction?: Direction;
38
+ private layoutDelegate: LayoutDelegate;
37
39
 
38
- constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement>, collator?: Intl.Collator);
40
+ constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement | null>, collator?: Intl.Collator);
39
41
  constructor(options: ListKeyboardDelegateOptions<T>);
40
42
  constructor(...args: any[]) {
41
43
  if (args.length === 1) {
@@ -45,9 +47,10 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
45
47
  this.collator = opts.collator;
46
48
  this.disabledKeys = opts.disabledKeys || new Set();
47
49
  this.disabledBehavior = opts.disabledBehavior || 'all';
48
- this.orientation = opts.orientation;
50
+ this.orientation = opts.orientation || 'vertical';
49
51
  this.direction = opts.direction;
50
52
  this.layout = opts.layout || 'stack';
53
+ this.layoutDelegate = opts.layoutDelegate || new DOMLayoutDelegate(opts.ref);
51
54
  } else {
52
55
  this.collection = args[0];
53
56
  this.disabledKeys = args[1];
@@ -56,6 +59,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
56
59
  this.layout = 'stack';
57
60
  this.orientation = 'vertical';
58
61
  this.disabledBehavior = 'all';
62
+ this.layoutDelegate = new DOMLayoutDelegate(this.ref);
59
63
  }
60
64
 
61
65
  // If this is a vertical stack, remove the left/right methods completely
@@ -101,29 +105,29 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
101
105
  private findKey(
102
106
  key: Key,
103
107
  nextKey: (key: Key) => Key,
104
- shouldSkip: (prevRect: DOMRect, itemRect: DOMRect) => boolean
108
+ shouldSkip: (prevRect: Rect, itemRect: Rect) => boolean
105
109
  ) {
106
- let item = this.getItem(key);
107
- if (!item) {
110
+ let itemRect = this.layoutDelegate.getItemRect(key);
111
+ if (!itemRect) {
108
112
  return null;
109
113
  }
110
114
 
111
115
  // Find the item above or below in the same column.
112
- let prevRect = item.getBoundingClientRect();
116
+ let prevRect = itemRect;
113
117
  do {
114
118
  key = nextKey(key);
115
- item = this.getItem(key);
116
- } while (item && shouldSkip(prevRect, item.getBoundingClientRect()));
119
+ itemRect = this.layoutDelegate.getItemRect(key);
120
+ } while (itemRect && shouldSkip(prevRect, itemRect));
117
121
 
118
122
  return key;
119
123
  }
120
124
 
121
- private isSameRow(prevRect: DOMRect, itemRect: DOMRect) {
122
- return prevRect.top === itemRect.top || prevRect.left !== itemRect.left;
125
+ private isSameRow(prevRect: Rect, itemRect: Rect) {
126
+ return prevRect.y === itemRect.y || prevRect.x !== itemRect.x;
123
127
  }
124
128
 
125
- private isSameColumn(prevRect: DOMRect, itemRect: DOMRect) {
126
- return prevRect.left === itemRect.left || prevRect.top !== itemRect.top;
129
+ private isSameColumn(prevRect: Rect, itemRect: Rect) {
130
+ return prevRect.x === itemRect.x || prevRect.y !== itemRect.y;
127
131
  }
128
132
 
129
133
  getKeyBelow(key: Key) {
@@ -202,14 +206,10 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
202
206
  return null;
203
207
  }
204
208
 
205
- private getItem(key: Key): HTMLElement {
206
- return key !== null ? this.ref.current.querySelector(`[data-key="${CSS.escape(key.toString())}"]`) : null;
207
- }
208
-
209
209
  getKeyPageAbove(key: Key) {
210
210
  let menu = this.ref.current;
211
- let item = this.getItem(key);
212
- if (!item) {
211
+ let itemRect = this.layoutDelegate.getItemRect(key);
212
+ if (!itemRect) {
213
213
  return null;
214
214
  }
215
215
 
@@ -217,25 +217,19 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
217
217
  return this.getFirstKey();
218
218
  }
219
219
 
220
- let containerRect = menu.getBoundingClientRect();
221
- let itemRect = item.getBoundingClientRect();
222
220
  if (this.orientation === 'horizontal') {
223
- let containerX = containerRect.x - menu.scrollLeft;
224
- let pageX = Math.max(0, (itemRect.x - containerX) + itemRect.width - containerRect.width);
221
+ let pageX = Math.max(0, itemRect.x + itemRect.width - this.layoutDelegate.getVisibleRect().width);
225
222
 
226
- while (item && (itemRect.x - containerX) > pageX) {
223
+ while (itemRect && itemRect.x > pageX) {
227
224
  key = this.getKeyAbove(key);
228
- item = key == null ? null : this.getItem(key);
229
- itemRect = item?.getBoundingClientRect();
225
+ itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
230
226
  }
231
227
  } else {
232
- let containerY = containerRect.y - menu.scrollTop;
233
- let pageY = Math.max(0, (itemRect.y - containerY) + itemRect.height - containerRect.height);
228
+ let pageY = Math.max(0, itemRect.y + itemRect.height - this.layoutDelegate.getVisibleRect().height);
234
229
 
235
- while (item && (itemRect.y - containerY) > pageY) {
230
+ while (itemRect && itemRect.y > pageY) {
236
231
  key = this.getKeyAbove(key);
237
- item = key == null ? null : this.getItem(key);
238
- itemRect = item?.getBoundingClientRect();
232
+ itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
239
233
  }
240
234
  }
241
235
 
@@ -244,8 +238,8 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
244
238
 
245
239
  getKeyPageBelow(key: Key) {
246
240
  let menu = this.ref.current;
247
- let item = this.getItem(key);
248
- if (!item) {
241
+ let itemRect = this.layoutDelegate.getItemRect(key);
242
+ if (!itemRect) {
249
243
  return null;
250
244
  }
251
245
 
@@ -253,25 +247,19 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
253
247
  return this.getLastKey();
254
248
  }
255
249
 
256
- let containerRect = menu.getBoundingClientRect();
257
- let itemRect = item.getBoundingClientRect();
258
250
  if (this.orientation === 'horizontal') {
259
- let containerX = containerRect.x - menu.scrollLeft;
260
- let pageX = Math.min(menu.scrollWidth, (itemRect.x - containerX) - itemRect.width + containerRect.width);
251
+ let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.y - itemRect.width + this.layoutDelegate.getVisibleRect().width);
261
252
 
262
- while (item && (itemRect.x - containerX) < pageX) {
253
+ while (itemRect && itemRect.x < pageX) {
263
254
  key = this.getKeyBelow(key);
264
- item = key == null ? null : this.getItem(key);
265
- itemRect = item?.getBoundingClientRect();
255
+ itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
266
256
  }
267
257
  } else {
268
- let containerY = containerRect.y - menu.scrollTop;
269
- let pageY = Math.min(menu.scrollHeight, (itemRect.y - containerY) - itemRect.height + containerRect.height);
258
+ let pageY = Math.min(this.layoutDelegate.getContentSize().height, itemRect.y - itemRect.height + this.layoutDelegate.getVisibleRect().height);
270
259
 
271
- while (item && (itemRect.y - containerY) < pageY) {
260
+ while (itemRect && itemRect.y < pageY) {
272
261
  key = this.getKeyBelow(key);
273
- item = key == null ? null : this.getItem(key);
274
- itemRect = item?.getBoundingClientRect();
262
+ itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
275
263
  }
276
264
  }
277
265
 
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {useSelectableCollection} from './useSelectableCollection';
14
14
  export {useSelectableItem} from './useSelectableItem';
15
15
  export {useSelectableList} from './useSelectableList';
16
16
  export {ListKeyboardDelegate} from './ListKeyboardDelegate';
17
+ export {DOMLayoutDelegate} from './DOMLayoutDelegate';
17
18
  export {useTypeSelect} from './useTypeSelect';
18
19
 
19
20
  export type {AriaSelectableCollectionOptions, SelectableCollectionAria} from './useSelectableCollection';
@@ -10,9 +10,9 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate} from '@react-types/shared';
13
+ import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
14
14
  import {flushSync} from 'react-dom';
15
- import {FocusEvent, KeyboardEvent, RefObject, useEffect, useRef} from 'react';
15
+ import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react';
16
16
  import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
17
17
  import {focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter} from '@react-aria/utils';
18
18
  import {getInteractionModality} from '@react-aria/interactions';
@@ -33,7 +33,7 @@ export interface AriaSelectableCollectionOptions {
33
33
  /**
34
34
  * The ref attached to the element representing the collection.
35
35
  */
36
- ref: RefObject<HTMLElement>,
36
+ ref: RefObject<HTMLElement | null>,
37
37
  /**
38
38
  * Whether the collection or one of its items should be automatically focused upon render.
39
39
  * @default false
@@ -80,7 +80,7 @@ export interface AriaSelectableCollectionOptions {
80
80
  * The ref attached to the scrollable body. Used to provide automatic scrolling on item focus for non-virtualized collections.
81
81
  * If not provided, defaults to the collection ref.
82
82
  */
83
- scrollRef?: RefObject<HTMLElement>,
83
+ scrollRef?: RefObject<HTMLElement | null>,
84
84
  /**
85
85
  * The behavior of links in the collection.
86
86
  * - 'action': link behaves like onAction.
@@ -293,6 +293,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
293
293
  };
294
294
 
295
295
  // Store the scroll position so we can restore it later.
296
+ /// TODO: should this happen all the time??
296
297
  let scrollPos = useRef({top: 0, left: 0});
297
298
  useEvent(scrollRef, 'scroll', isVirtualized ? null : () => {
298
299
  scrollPos.current = {
@@ -342,7 +343,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
342
343
  scrollRef.current.scrollLeft = scrollPos.current.left;
343
344
  }
344
345
 
345
- if (!isVirtualized && manager.focusedKey != null) {
346
+ if (manager.focusedKey != null) {
346
347
  // Refocus and scroll the focused item into view if it exists within the scrollable region.
347
348
  let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
348
349
  if (element) {
@@ -400,17 +401,21 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
400
401
  // eslint-disable-next-line react-hooks/exhaustive-deps
401
402
  }, []);
402
403
 
403
- // If not virtualized, scroll the focused element into view when the focusedKey changes.
404
- // When virtualized, Virtualizer handles this internally.
404
+ // Scroll the focused element into view when the focusedKey changes.
405
405
  let lastFocusedKey = useRef(manager.focusedKey);
406
406
  useEffect(() => {
407
- let modality = getInteractionModality();
408
- if (manager.isFocused && manager.focusedKey != null && scrollRef?.current) {
409
- let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
410
- if (element && (modality === 'keyboard' || autoFocusRef.current)) {
411
- if (!isVirtualized) {
412
- scrollIntoView(scrollRef.current, element);
413
- }
407
+ if (manager.isFocused && manager.focusedKey != null && (manager.focusedKey !== lastFocusedKey.current || autoFocusRef.current) && scrollRef?.current) {
408
+ let modality = getInteractionModality();
409
+ let element = ref.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
410
+ if (!element) {
411
+ // If item element wasn't found, return early (don't update autoFocusRef and lastFocusedKey).
412
+ // The collection may initially be empty (e.g. virtualizer), so wait until the element exists.
413
+ return;
414
+ }
415
+
416
+ if (modality === 'keyboard' || autoFocusRef.current) {
417
+ scrollIntoView(scrollRef.current, element);
418
+
414
419
  // Avoid scroll in iOS VO, since it may cause overlay to close (i.e. RAC submenu)
415
420
  if (modality !== 'virtual') {
416
421
  scrollIntoViewport(element, {containingElement: ref.current});
@@ -425,7 +430,13 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
425
430
 
426
431
  lastFocusedKey.current = manager.focusedKey;
427
432
  autoFocusRef.current = false;
428
- }, [isVirtualized, scrollRef, manager.focusedKey, manager.isFocused, ref]);
433
+ });
434
+
435
+ // Intercept FocusScope restoration since virtualized collections can reuse DOM nodes.
436
+ useEvent(ref, 'react-aria-focus-scope-restore', e => {
437
+ e.preventDefault();
438
+ manager.setFocused(true);
439
+ });
429
440
 
430
441
  let handlers = {
431
442
  onKeyDown,
@@ -10,13 +10,13 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {DOMAttributes, FocusableElement, Key, LongPressEvent, PressEvent} from '@react-types/shared';
13
+ import {DOMAttributes, FocusableElement, Key, LongPressEvent, PressEvent, RefObject} from '@react-types/shared';
14
14
  import {focusSafely} from '@react-aria/focus';
15
15
  import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
16
16
  import {mergeProps, openLink, useRouter} from '@react-aria/utils';
17
17
  import {MultipleSelectionManager} from '@react-stately/selection';
18
18
  import {PressProps, useLongPress, usePress} from '@react-aria/interactions';
19
- import {RefObject, useEffect, useRef} from 'react';
19
+ import {useEffect, useRef} from 'react';
20
20
 
21
21
  export interface SelectableItemOptions {
22
22
  /**
@@ -30,7 +30,7 @@ export interface SelectableItemOptions {
30
30
  /**
31
31
  * Ref to the item.
32
32
  */
33
- ref: RefObject<FocusableElement>,
33
+ ref: RefObject<FocusableElement | null>,
34
34
  /**
35
35
  * By default, selection occurs on pointer down. This can be strange if selecting an
36
36
  * item causes the UI to disappear immediately (e.g. menus).
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import {AriaSelectableCollectionOptions, useSelectableCollection} from './useSelectableCollection';
14
- import {Collection, DOMAttributes, Key, KeyboardDelegate, Node} from '@react-types/shared';
14
+ import {Collection, DOMAttributes, Key, KeyboardDelegate, LayoutDelegate, Node} from '@react-types/shared';
15
15
  import {ListKeyboardDelegate} from './ListKeyboardDelegate';
16
16
  import {useCollator} from '@react-aria/i18n';
17
17
  import {useMemo} from 'react';
@@ -25,6 +25,12 @@ export interface AriaSelectableListOptions extends Omit<AriaSelectableCollection
25
25
  * A delegate object that implements behavior for keyboard focus movement.
26
26
  */
27
27
  keyboardDelegate?: KeyboardDelegate,
28
+ /**
29
+ * A delegate object that provides layout information for items in the collection.
30
+ * By default this uses the DOM, but this can be overridden to implement things like
31
+ * virtualized scrolling.
32
+ */
33
+ layoutDelegate?: LayoutDelegate,
28
34
  /**
29
35
  * The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with.
30
36
  */
@@ -47,7 +53,8 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL
47
53
  collection,
48
54
  disabledKeys,
49
55
  ref,
50
- keyboardDelegate
56
+ keyboardDelegate,
57
+ layoutDelegate
51
58
  } = props;
52
59
 
53
60
  // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down).
@@ -60,9 +67,10 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL
60
67
  disabledKeys,
61
68
  disabledBehavior,
62
69
  ref,
63
- collator
70
+ collator,
71
+ layoutDelegate
64
72
  })
65
- ), [keyboardDelegate, collection, disabledKeys, ref, collator, disabledBehavior]);
73
+ ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, ref, collator, disabledBehavior]);
66
74
 
67
75
  let {collectionProps} = useSelectableCollection({
68
76
  ...props,