@react-aria/selection 3.20.1 → 3.22.0

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 (39) hide show
  1. package/dist/DOMLayoutDelegate.main.js +9 -6
  2. package/dist/DOMLayoutDelegate.main.js.map +1 -1
  3. package/dist/DOMLayoutDelegate.mjs +9 -6
  4. package/dist/DOMLayoutDelegate.module.js +9 -6
  5. package/dist/DOMLayoutDelegate.module.js.map +1 -1
  6. package/dist/ListKeyboardDelegate.main.js +38 -30
  7. package/dist/ListKeyboardDelegate.main.js.map +1 -1
  8. package/dist/ListKeyboardDelegate.mjs +38 -30
  9. package/dist/ListKeyboardDelegate.module.js +38 -30
  10. package/dist/ListKeyboardDelegate.module.js.map +1 -1
  11. package/dist/types.d.ts +14 -14
  12. package/dist/types.d.ts.map +1 -1
  13. package/dist/useSelectableCollection.main.js +103 -31
  14. package/dist/useSelectableCollection.main.js.map +1 -1
  15. package/dist/useSelectableCollection.mjs +104 -32
  16. package/dist/useSelectableCollection.module.js +104 -32
  17. package/dist/useSelectableCollection.module.js.map +1 -1
  18. package/dist/useSelectableItem.main.js +28 -12
  19. package/dist/useSelectableItem.main.js.map +1 -1
  20. package/dist/useSelectableItem.mjs +30 -14
  21. package/dist/useSelectableItem.module.js +30 -14
  22. package/dist/useSelectableItem.module.js.map +1 -1
  23. package/dist/useTypeSelect.main.js +12 -10
  24. package/dist/useTypeSelect.main.js.map +1 -1
  25. package/dist/useTypeSelect.mjs +12 -10
  26. package/dist/useTypeSelect.module.js +12 -10
  27. package/dist/useTypeSelect.module.js.map +1 -1
  28. package/dist/utils.main.js +0 -5
  29. package/dist/utils.main.js.map +1 -1
  30. package/dist/utils.mjs +2 -6
  31. package/dist/utils.module.js +2 -6
  32. package/dist/utils.module.js.map +1 -1
  33. package/package.json +10 -10
  34. package/src/DOMLayoutDelegate.ts +11 -8
  35. package/src/ListKeyboardDelegate.ts +46 -34
  36. package/src/useSelectableCollection.ts +118 -36
  37. package/src/useSelectableItem.ts +35 -18
  38. package/src/useTypeSelect.ts +16 -14
  39. package/src/utils.ts +1 -9
@@ -74,47 +74,54 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
74
74
  return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key));
75
75
  }
76
76
 
77
- private findNextNonDisabled(key: Key, getNext: (key: Key) => Key | null): Key | null {
78
- while (key != null) {
79
- let item = this.collection.getItem(key);
77
+ private findNextNonDisabled(key: Key | null, getNext: (key: Key) => Key | null): Key | null {
78
+ let nextKey = key;
79
+ while (nextKey != null) {
80
+ let item = this.collection.getItem(nextKey);
80
81
  if (item?.type === 'item' && !this.isDisabled(item)) {
81
- return key;
82
+ return nextKey;
82
83
  }
83
84
 
84
- key = getNext(key);
85
+ nextKey = getNext(nextKey);
85
86
  }
86
87
 
87
88
  return null;
88
89
  }
89
90
 
90
91
  getNextKey(key: Key) {
91
- key = this.collection.getKeyAfter(key);
92
- return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key));
92
+ let nextKey: Key | null = key;
93
+ nextKey = this.collection.getKeyAfter(nextKey);
94
+ return this.findNextNonDisabled(nextKey, key => this.collection.getKeyAfter(key));
93
95
  }
94
96
 
95
97
  getPreviousKey(key: Key) {
96
- key = this.collection.getKeyBefore(key);
97
- return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key));
98
+ let nextKey: Key | null = key;
99
+ nextKey = this.collection.getKeyBefore(nextKey);
100
+ return this.findNextNonDisabled(nextKey, key => this.collection.getKeyBefore(key));
98
101
  }
99
102
 
100
103
  private findKey(
101
104
  key: Key,
102
- nextKey: (key: Key) => Key,
105
+ nextKey: (key: Key) => Key | null,
103
106
  shouldSkip: (prevRect: Rect, itemRect: Rect) => boolean
104
107
  ) {
105
- let itemRect = this.layoutDelegate.getItemRect(key);
106
- if (!itemRect) {
108
+ let tempKey: Key | null = key;
109
+ let itemRect = this.layoutDelegate.getItemRect(tempKey);
110
+ if (!itemRect || tempKey == null) {
107
111
  return null;
108
112
  }
109
113
 
110
114
  // Find the item above or below in the same column.
111
115
  let prevRect = itemRect;
112
116
  do {
113
- key = nextKey(key);
114
- itemRect = this.layoutDelegate.getItemRect(key);
115
- } while (itemRect && shouldSkip(prevRect, itemRect));
117
+ tempKey = nextKey(tempKey);
118
+ if (tempKey == null) {
119
+ break;
120
+ }
121
+ itemRect = this.layoutDelegate.getItemRect(tempKey);
122
+ } while (itemRect && shouldSkip(prevRect, itemRect) && tempKey != null);
116
123
 
117
- return key;
124
+ return tempKey;
118
125
  }
119
126
 
120
127
  private isSameRow(prevRect: Rect, itemRect: Rect) {
@@ -145,7 +152,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
145
152
  return right ? this.getPreviousKey(key) : this.getNextKey(key);
146
153
  }
147
154
 
148
- getKeyRightOf(key: Key) {
155
+ getKeyRightOf?(key: Key) {
149
156
  // This is a temporary solution for CardView until we refactor useSelectableCollection.
150
157
  // https://github.com/orgs/adobe/projects/19/views/32?pane=issue&itemId=77825042
151
158
  let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyRightOf' : 'getKeyLeftOf';
@@ -167,7 +174,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
167
174
  return null;
168
175
  }
169
176
 
170
- getKeyLeftOf(key: Key) {
177
+ getKeyLeftOf?(key: Key) {
171
178
  let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyLeftOf' : 'getKeyRightOf';
172
179
  if (this.layoutDelegate[layoutDelegateMethod]) {
173
180
  key = this.layoutDelegate[layoutDelegateMethod](key);
@@ -204,27 +211,28 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
204
211
  return null;
205
212
  }
206
213
 
207
- if (!isScrollable(menu)) {
214
+ if (menu && !isScrollable(menu)) {
208
215
  return this.getFirstKey();
209
216
  }
210
217
 
218
+ let nextKey: Key | null = key;
211
219
  if (this.orientation === 'horizontal') {
212
220
  let pageX = Math.max(0, itemRect.x + itemRect.width - this.layoutDelegate.getVisibleRect().width);
213
221
 
214
- while (itemRect && itemRect.x > pageX) {
215
- key = this.getKeyAbove(key);
216
- itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
222
+ while (itemRect && itemRect.x > pageX && nextKey != null) {
223
+ nextKey = this.getKeyAbove(nextKey);
224
+ itemRect = nextKey == null ? null : this.layoutDelegate.getItemRect(nextKey);
217
225
  }
218
226
  } else {
219
227
  let pageY = Math.max(0, itemRect.y + itemRect.height - this.layoutDelegate.getVisibleRect().height);
220
228
 
221
- while (itemRect && itemRect.y > pageY) {
222
- key = this.getKeyAbove(key);
223
- itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
229
+ while (itemRect && itemRect.y > pageY && nextKey != null) {
230
+ nextKey = this.getKeyAbove(nextKey);
231
+ itemRect = nextKey == null ? null : this.layoutDelegate.getItemRect(nextKey);
224
232
  }
225
233
  }
226
234
 
227
- return key ?? this.getFirstKey();
235
+ return nextKey ?? this.getFirstKey();
228
236
  }
229
237
 
230
238
  getKeyPageBelow(key: Key) {
@@ -234,27 +242,28 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
234
242
  return null;
235
243
  }
236
244
 
237
- if (!isScrollable(menu)) {
245
+ if (menu && !isScrollable(menu)) {
238
246
  return this.getLastKey();
239
247
  }
240
248
 
249
+ let nextKey: Key | null = key;
241
250
  if (this.orientation === 'horizontal') {
242
251
  let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.y - itemRect.width + this.layoutDelegate.getVisibleRect().width);
243
252
 
244
- while (itemRect && itemRect.x < pageX) {
245
- key = this.getKeyBelow(key);
246
- itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
253
+ while (itemRect && itemRect.x < pageX && nextKey != null) {
254
+ nextKey = this.getKeyBelow(nextKey);
255
+ itemRect = nextKey == null ? null : this.layoutDelegate.getItemRect(nextKey);
247
256
  }
248
257
  } else {
249
258
  let pageY = Math.min(this.layoutDelegate.getContentSize().height, itemRect.y - itemRect.height + this.layoutDelegate.getVisibleRect().height);
250
259
 
251
- while (itemRect && itemRect.y < pageY) {
252
- key = this.getKeyBelow(key);
253
- itemRect = key == null ? null : this.layoutDelegate.getItemRect(key);
260
+ while (itemRect && itemRect.y < pageY && nextKey != null) {
261
+ nextKey = this.getKeyBelow(nextKey);
262
+ itemRect = nextKey == null ? null : this.layoutDelegate.getItemRect(nextKey);
254
263
  }
255
264
  }
256
265
 
257
- return key ?? this.getLastKey();
266
+ return nextKey ?? this.getLastKey();
258
267
  }
259
268
 
260
269
  getKeyForSearch(search: string, fromKey?: Key) {
@@ -266,6 +275,9 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
266
275
  let key = fromKey || this.getFirstKey();
267
276
  while (key != null) {
268
277
  let item = collection.getItem(key);
278
+ if (!item) {
279
+ return null;
280
+ }
269
281
  let substring = item.textValue.slice(0, search.length);
270
282
  if (item.textValue && this.collator.compare(substring, search) === 0) {
271
283
  return key;
@@ -10,13 +10,13 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils';
13
14
  import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared';
14
15
  import {flushSync} from 'react-dom';
15
16
  import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react';
16
17
  import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
17
- import {focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter} from '@react-aria/utils';
18
18
  import {getInteractionModality} from '@react-aria/interactions';
19
- import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
19
+ import {isNonContiguousSelectionModifier} from './utils';
20
20
  import {MultipleSelectionManager} from '@react-stately/selection';
21
21
  import {useLocale} from '@react-aria/i18n';
22
22
  import {useTypeSelect} from './useTypeSelect';
@@ -128,7 +128,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
128
128
 
129
129
  // Keyboard events bubble through portals. Don't handle keyboard events
130
130
  // for elements outside the collection (e.g. menus).
131
- if (!ref.current.contains(e.target as Element)) {
131
+ if (!ref.current?.contains(e.target as Element)) {
132
132
  return;
133
133
  }
134
134
 
@@ -140,9 +140,11 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
140
140
  manager.setFocusedKey(key, childFocus);
141
141
  });
142
142
 
143
- let item = scrollRef.current.querySelector(`[data-key="${CSS.escape(key.toString())}"]`);
143
+ let item = scrollRef.current?.querySelector(`[data-key="${CSS.escape(key.toString())}"]`);
144
144
  let itemProps = manager.getItemProps(key);
145
- router.open(item, e, itemProps.href, itemProps.routerOptions);
145
+ if (item) {
146
+ router.open(item, e, itemProps.href, itemProps.routerOptions);
147
+ }
146
148
 
147
149
  return;
148
150
  }
@@ -194,7 +196,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
194
196
  }
195
197
  case 'ArrowLeft': {
196
198
  if (delegate.getKeyLeftOf) {
197
- let nextKey = delegate.getKeyLeftOf?.(manager.focusedKey);
199
+ let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyLeftOf?.(manager.focusedKey) : null;
198
200
  if (nextKey == null && shouldFocusWrap) {
199
201
  nextKey = direction === 'rtl' ? delegate.getFirstKey?.(manager.focusedKey) : delegate.getLastKey?.(manager.focusedKey);
200
202
  }
@@ -207,7 +209,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
207
209
  }
208
210
  case 'ArrowRight': {
209
211
  if (delegate.getKeyRightOf) {
210
- let nextKey = delegate.getKeyRightOf?.(manager.focusedKey);
212
+ let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyRightOf?.(manager.focusedKey) : null;
211
213
  if (nextKey == null && shouldFocusWrap) {
212
214
  nextKey = direction === 'rtl' ? delegate.getLastKey?.(manager.focusedKey) : delegate.getFirstKey?.(manager.focusedKey);
213
215
  }
@@ -220,30 +222,40 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
220
222
  }
221
223
  case 'Home':
222
224
  if (delegate.getFirstKey) {
225
+ if (manager.focusedKey === null && e.shiftKey) {
226
+ return;
227
+ }
223
228
  e.preventDefault();
224
- let firstKey = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e));
229
+ let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e));
225
230
  manager.setFocusedKey(firstKey);
226
- if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') {
227
- manager.extendSelection(firstKey);
228
- } else if (selectOnFocus) {
229
- manager.replaceSelection(firstKey);
231
+ if (firstKey != null) {
232
+ if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') {
233
+ manager.extendSelection(firstKey);
234
+ } else if (selectOnFocus) {
235
+ manager.replaceSelection(firstKey);
236
+ }
230
237
  }
231
238
  }
232
239
  break;
233
240
  case 'End':
234
241
  if (delegate.getLastKey) {
242
+ if (manager.focusedKey === null && e.shiftKey) {
243
+ return;
244
+ }
235
245
  e.preventDefault();
236
246
  let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e));
237
247
  manager.setFocusedKey(lastKey);
238
- if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') {
239
- manager.extendSelection(lastKey);
240
- } else if (selectOnFocus) {
241
- manager.replaceSelection(lastKey);
248
+ if (lastKey != null) {
249
+ if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') {
250
+ manager.extendSelection(lastKey);
251
+ } else if (selectOnFocus) {
252
+ manager.replaceSelection(lastKey);
253
+ }
242
254
  }
243
255
  }
244
256
  break;
245
257
  case 'PageDown':
246
- if (delegate.getKeyPageBelow) {
258
+ if (delegate.getKeyPageBelow && manager.focusedKey != null) {
247
259
  let nextKey = delegate.getKeyPageBelow(manager.focusedKey);
248
260
  if (nextKey != null) {
249
261
  e.preventDefault();
@@ -252,7 +264,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
252
264
  }
253
265
  break;
254
266
  case 'PageUp':
255
- if (delegate.getKeyPageAbove) {
267
+ if (delegate.getKeyPageAbove && manager.focusedKey != null) {
256
268
  let nextKey = delegate.getKeyPageAbove(manager.focusedKey);
257
269
  if (nextKey != null) {
258
270
  e.preventDefault();
@@ -285,7 +297,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
285
297
  ref.current.focus();
286
298
  } else {
287
299
  let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
288
- let next: FocusableElement;
300
+ let next: FocusableElement | undefined = undefined;
289
301
  let last: FocusableElement;
290
302
  do {
291
303
  last = walker.lastChild() as FocusableElement;
@@ -307,10 +319,10 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
307
319
  // Store the scroll position so we can restore it later.
308
320
  /// TODO: should this happen all the time??
309
321
  let scrollPos = useRef({top: 0, left: 0});
310
- useEvent(scrollRef, 'scroll', isVirtualized ? null : () => {
322
+ useEvent(scrollRef, 'scroll', isVirtualized ? undefined : () => {
311
323
  scrollPos.current = {
312
- top: scrollRef.current.scrollTop,
313
- left: scrollRef.current.scrollLeft
324
+ top: scrollRef.current?.scrollTop ?? 0,
325
+ left: scrollRef.current?.scrollLeft ?? 0
314
326
  };
315
327
  });
316
328
 
@@ -332,7 +344,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
332
344
  manager.setFocused(true);
333
345
 
334
346
  if (manager.focusedKey == null) {
335
- let navigateToFirstKey = (key: Key | undefined) => {
347
+ let navigateToFirstKey = (key: Key | undefined | null) => {
336
348
  if (key != null) {
337
349
  manager.setFocusedKey(key);
338
350
  if (selectOnFocus) {
@@ -345,17 +357,17 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
345
357
  // and either focus the first or last item accordingly.
346
358
  let relatedTarget = e.relatedTarget as Element;
347
359
  if (relatedTarget && (e.currentTarget.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING)) {
348
- navigateToFirstKey(manager.lastSelectedKey ?? delegate.getLastKey());
360
+ navigateToFirstKey(manager.lastSelectedKey ?? delegate.getLastKey?.());
349
361
  } else {
350
- navigateToFirstKey(manager.firstSelectedKey ?? delegate.getFirstKey());
362
+ navigateToFirstKey(manager.firstSelectedKey ?? delegate.getFirstKey?.());
351
363
  }
352
- } else if (!isVirtualized) {
364
+ } else if (!isVirtualized && scrollRef.current) {
353
365
  // Restore the scroll position to what it was before.
354
366
  scrollRef.current.scrollTop = scrollPos.current.top;
355
367
  scrollRef.current.scrollLeft = scrollPos.current.left;
356
368
  }
357
369
 
358
- if (manager.focusedKey != null) {
370
+ if (manager.focusedKey != null && scrollRef.current) {
359
371
  // Refocus and scroll the focused item into view if it exists within the scrollable region.
360
372
  let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
361
373
  if (element) {
@@ -379,16 +391,86 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
379
391
  }
380
392
  };
381
393
 
394
+ // Ref to track whether the first item in the collection should be automatically focused. Specifically used for autocomplete when user types
395
+ // to focus the first key AFTER the collection updates.
396
+ // TODO: potentially expand the usage of this
397
+ let shouldVirtualFocusFirst = useRef(false);
398
+ // Add event listeners for custom virtual events. These handle updating the focused key in response to various keyboard events
399
+ // at the autocomplete level
400
+ // TODO: fix type later
401
+ useEvent(ref, FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e: any) => {
402
+ let {detail} = e;
403
+ e.stopPropagation();
404
+ manager.setFocused(true);
405
+
406
+ // If the user is typing forwards, autofocus the first option in the list.
407
+ if (detail?.focusStrategy === 'first') {
408
+ shouldVirtualFocusFirst.current = true;
409
+ }
410
+ });
411
+
412
+ let updateActiveDescendant = useEffectEvent(() => {
413
+ let keyToFocus = delegate.getFirstKey?.() ?? null;
414
+
415
+ // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist
416
+ if (keyToFocus == null) {
417
+ ref.current?.dispatchEvent(
418
+ new CustomEvent(UPDATE_ACTIVEDESCENDANT, {
419
+ cancelable: true,
420
+ bubbles: true
421
+ })
422
+ );
423
+
424
+ // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled.
425
+ // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again.
426
+ if (manager.collection.size > 0) {
427
+ shouldVirtualFocusFirst.current = false;
428
+ }
429
+ } else {
430
+ manager.setFocusedKey(keyToFocus);
431
+ // Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key
432
+ // If there wasn't a key to focus, we might be in a temporary loading state so we'll want to still focus the first key
433
+ // after the collection updates after load
434
+ shouldVirtualFocusFirst.current = false;
435
+ }
436
+ });
437
+
438
+ useUpdateLayoutEffect(() => {
439
+ if (shouldVirtualFocusFirst.current) {
440
+ updateActiveDescendant();
441
+ }
442
+
443
+ }, [manager.collection, updateActiveDescendant]);
444
+
445
+ let resetFocusFirstFlag = useEffectEvent(() => {
446
+ // If user causes the focused key to change in any other way, clear shouldVirtualFocusFirst so we don't
447
+ // accidentally move focus from under them. Skip this if the collection was empty because we might be in a load
448
+ // state and will still want to focus the first item after load
449
+ if (manager.collection.size > 0) {
450
+ shouldVirtualFocusFirst.current = false;
451
+ }
452
+ });
453
+
454
+ useUpdateLayoutEffect(() => {
455
+ resetFocusFirstFlag();
456
+ }, [manager.focusedKey, resetFocusFirstFlag]);
457
+
458
+ useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e) => {
459
+ e.stopPropagation();
460
+ manager.setFocused(false);
461
+ manager.setFocusedKey(null);
462
+ });
463
+
382
464
  const autoFocusRef = useRef(autoFocus);
383
465
  useEffect(() => {
384
466
  if (autoFocusRef.current) {
385
- let focusedKey = null;
467
+ let focusedKey: Key | null = null;
386
468
 
387
469
  // Check focus strategy to determine which item to focus
388
470
  if (autoFocus === 'first') {
389
- focusedKey = delegate.getFirstKey();
471
+ focusedKey = delegate.getFirstKey?.() ?? null;
390
472
  } if (autoFocus === 'last') {
391
- focusedKey = delegate.getLastKey();
473
+ focusedKey = delegate.getLastKey?.() ?? null;
392
474
  }
393
475
 
394
476
  // If there are any selected keys, make the first one the new focus target
@@ -406,7 +488,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
406
488
  manager.setFocusedKey(focusedKey);
407
489
 
408
490
  // If no default focus key is selected, focus the collection itself.
409
- if (focusedKey == null && !shouldUseVirtualFocus) {
491
+ if (focusedKey == null && !shouldUseVirtualFocus && ref.current) {
410
492
  focusSafely(ref.current);
411
493
  }
412
494
  }
@@ -416,7 +498,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
416
498
  // Scroll the focused element into view when the focusedKey changes.
417
499
  let lastFocusedKey = useRef(manager.focusedKey);
418
500
  useEffect(() => {
419
- if (manager.isFocused && manager.focusedKey != null && (manager.focusedKey !== lastFocusedKey.current || autoFocusRef.current) && scrollRef?.current) {
501
+ if (manager.isFocused && manager.focusedKey != null && (manager.focusedKey !== lastFocusedKey.current || autoFocusRef.current) && scrollRef.current && ref.current) {
420
502
  let modality = getInteractionModality();
421
503
  let element = ref.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
422
504
  if (!element) {
@@ -436,7 +518,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
436
518
  }
437
519
 
438
520
  // If the focused key becomes null (e.g. the last item is deleted), focus the whole collection.
439
- if (!shouldUseVirtualFocus && manager.isFocused && manager.focusedKey == null && lastFocusedKey.current != null) {
521
+ if (!shouldUseVirtualFocus && manager.isFocused && manager.focusedKey == null && lastFocusedKey.current != null && ref.current) {
440
522
  focusSafely(ref.current);
441
523
  }
442
524
 
@@ -474,11 +556,11 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
474
556
 
475
557
  // If nothing is focused within the collection, make the collection itself tabbable.
476
558
  // This will be marshalled to either the first or last item depending on where focus came from.
477
- // If using virtual focus, don't set a tabIndex at all so that VoiceOver on iOS 14 doesn't try
478
- // to move real DOM focus to the element anyway.
479
- let tabIndex: number;
559
+ let tabIndex: number | undefined = undefined;
480
560
  if (!shouldUseVirtualFocus) {
481
561
  tabIndex = manager.focusedKey == null ? 0 : -1;
562
+ } else {
563
+ tabIndex = -1;
482
564
  }
483
565
 
484
566
  return {
@@ -10,15 +10,15 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {DOMAttributes, FocusableElement, Key, LongPressEvent, PressEvent, RefObject} from '@react-types/shared';
13
+ import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared';
14
14
  import {focusSafely} from '@react-aria/focus';
15
- import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
16
- import {mergeProps, openLink, useRouter} from '@react-aria/utils';
15
+ import {isCtrlKeyPressed, mergeProps, openLink, UPDATE_ACTIVEDESCENDANT, useId, useRouter} from '@react-aria/utils';
16
+ import {isNonContiguousSelectionModifier} from './utils';
17
17
  import {MultipleSelectionManager} from '@react-stately/selection';
18
18
  import {PressProps, useLongPress, usePress} from '@react-aria/interactions';
19
19
  import {useEffect, useRef} from 'react';
20
20
 
21
- export interface SelectableItemOptions {
21
+ export interface SelectableItemOptions extends DOMProps {
22
22
  /**
23
23
  * An interface for reading and updating multiple selection state.
24
24
  */
@@ -108,6 +108,7 @@ export interface SelectableItemAria extends SelectableItemStates {
108
108
  */
109
109
  export function useSelectableItem(options: SelectableItemOptions): SelectableItemAria {
110
110
  let {
111
+ id,
111
112
  selectionManager: manager,
112
113
  key,
113
114
  ref,
@@ -120,7 +121,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
120
121
  linkBehavior = 'action'
121
122
  } = options;
122
123
  let router = useRouter();
123
-
124
+ id = useId(id);
124
125
  let onSelect = (e: PressEvent | LongPressEvent | PointerEvent) => {
125
126
  if (e.pointerType === 'keyboard' && isNonContiguousSelectionModifier(e)) {
126
127
  manager.toggleSelection(key);
@@ -130,7 +131,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
130
131
  }
131
132
 
132
133
  if (manager.isLink(key)) {
133
- if (linkBehavior === 'selection') {
134
+ if (linkBehavior === 'selection' && ref.current) {
134
135
  let itemProps = manager.getItemProps(key);
135
136
  router.open(ref.current, e, itemProps.href, itemProps.routerOptions);
136
137
  // Always set selected keys back to what they were so that select and combobox close.
@@ -159,13 +160,25 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
159
160
  };
160
161
 
161
162
  // Focus the associated DOM node when this item becomes the focusedKey
163
+ // TODO: can't make this useLayoutEffect bacause it breaks menus inside dialogs
164
+ // However, if this is a useEffect, it runs twice and dispatches two UPDATE_ACTIVEDESCENDANT and immediately sets
165
+ // aria-activeDescendant in useAutocomplete... I've worked around this for now
162
166
  useEffect(() => {
163
167
  let isFocused = key === manager.focusedKey;
164
- if (isFocused && manager.isFocused && !shouldUseVirtualFocus) {
165
- if (focus) {
166
- focus();
167
- } else if (document.activeElement !== ref.current) {
168
- focusSafely(ref.current);
168
+ if (isFocused && manager.isFocused) {
169
+ if (!shouldUseVirtualFocus) {
170
+ if (focus) {
171
+ focus();
172
+ } else if (document.activeElement !== ref.current && ref.current) {
173
+ focusSafely(ref.current);
174
+ }
175
+ } else {
176
+ let updateActiveDescendant = new CustomEvent(UPDATE_ACTIVEDESCENDANT, {
177
+ cancelable: true,
178
+ bubbles: true
179
+ });
180
+
181
+ ref.current?.dispatchEvent(updateActiveDescendant);
169
182
  }
170
183
  }
171
184
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -207,7 +220,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
207
220
  );
208
221
  let hasSecondaryAction = allowsActions && allowsSelection && manager.selectionBehavior === 'replace';
209
222
  let hasAction = hasPrimaryAction || hasSecondaryAction;
210
- let modality = useRef(null);
223
+ let modality = useRef<PointerType | null>(null);
211
224
 
212
225
  let longPressEnabled = hasAction && allowsSelection;
213
226
  let longPressEnabledOnPressStart = useRef(false);
@@ -218,7 +231,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
218
231
  onAction();
219
232
  }
220
233
 
221
- if (hasLinkAction) {
234
+ if (hasLinkAction && ref.current) {
222
235
  let itemProps = manager.getItemProps(key);
223
236
  router.open(ref.current, e, itemProps.href, itemProps.routerOptions);
224
237
  }
@@ -241,7 +254,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
241
254
  }
242
255
  };
243
256
 
244
- // If allowsDifferentPressOrigin, make selection happen on pressUp (e.g. open menu on press down, selection on menu item happens on press up.)
257
+ // If allowsDifferentPressOrigin and interacting with mouse, make selection happen on pressUp (e.g. open menu on press down, selection on menu item happens on press up.)
245
258
  // Otherwise, have selection happen onPress (prevents listview row selection when clicking on interactable elements in the row)
246
259
  if (!allowsDifferentPressOrigin) {
247
260
  itemPressProps.onPress = (e) => {
@@ -256,13 +269,17 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
256
269
  }
257
270
  };
258
271
  } else {
259
- itemPressProps.onPressUp = hasPrimaryAction ? null : (e) => {
260
- if (e.pointerType !== 'keyboard' && allowsSelection) {
272
+ itemPressProps.onPressUp = hasPrimaryAction ? undefined : (e) => {
273
+ if (e.pointerType === 'mouse' && allowsSelection) {
261
274
  onSelect(e);
262
275
  }
263
276
  };
264
277
 
265
- itemPressProps.onPress = hasPrimaryAction ? performAction : null;
278
+ itemPressProps.onPress = hasPrimaryAction ? performAction : (e) => {
279
+ if (e.pointerType !== 'keyboard' && e.pointerType !== 'mouse' && allowsSelection) {
280
+ onSelect(e);
281
+ }
282
+ };
266
283
  }
267
284
  } else {
268
285
  itemPressProps.onPressStart = (e) => {
@@ -352,7 +369,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
352
369
  itemProps,
353
370
  allowsSelection || hasPrimaryAction ? pressProps : {},
354
371
  longPressEnabled ? longPressProps : {},
355
- {onDoubleClick, onDragStartCapture, onClick}
372
+ {onDoubleClick, onDragStartCapture, onClick, id}
356
373
  ),
357
374
  isPressed,
358
375
  isSelected: manager.isSelected(key),
@@ -46,9 +46,9 @@ export interface TypeSelectAria {
46
46
  */
47
47
  export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
48
48
  let {keyboardDelegate, selectionManager, onTypeSelect} = options;
49
- let state = useRef({
49
+ let state = useRef<{search: string, timeout: ReturnType<typeof setTimeout> | undefined}>({
50
50
  search: '',
51
- timeout: null
51
+ timeout: undefined
52
52
  }).current;
53
53
 
54
54
  let onKeyDown = (e: KeyboardEvent) => {
@@ -70,19 +70,21 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
70
70
 
71
71
  state.search += character;
72
72
 
73
- // Use the delegate to find a key to focus.
74
- // Prioritize items after the currently focused item, falling back to searching the whole list.
75
- let key = keyboardDelegate.getKeyForSearch(state.search, selectionManager.focusedKey);
73
+ if (keyboardDelegate.getKeyForSearch != null) {
74
+ // Use the delegate to find a key to focus.
75
+ // Prioritize items after the currently focused item, falling back to searching the whole list.
76
+ let key = keyboardDelegate.getKeyForSearch(state.search, selectionManager.focusedKey);
76
77
 
77
- // If no key found, search from the top.
78
- if (key == null) {
79
- key = keyboardDelegate.getKeyForSearch(state.search);
80
- }
78
+ // If no key found, search from the top.
79
+ if (key == null) {
80
+ key = keyboardDelegate.getKeyForSearch(state.search);
81
+ }
81
82
 
82
- if (key != null) {
83
- selectionManager.setFocusedKey(key);
84
- if (onTypeSelect) {
85
- onTypeSelect(key);
83
+ if (key != null) {
84
+ selectionManager.setFocusedKey(key);
85
+ if (onTypeSelect) {
86
+ onTypeSelect(key);
87
+ }
86
88
  }
87
89
  }
88
90
 
@@ -96,7 +98,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
96
98
  typeSelectProps: {
97
99
  // Using a capturing listener to catch the keydown event before
98
100
  // other hooks in order to handle the Spacebar event.
99
- onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDown : null
101
+ onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined
100
102
  }
101
103
  };
102
104
  }