@react-aria/selection 3.21.0 → 3.23.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.
package/dist/utils.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import {isAppleDevice as $jUnAJ$isAppleDevice, isMac as $jUnAJ$isMac} from "@react-aria/utils";
1
+ import {isAppleDevice as $jUnAJ$isAppleDevice, useId as $jUnAJ$useId} from "@react-aria/utils";
2
2
 
3
3
  /*
4
4
  * Copyright 2020 Adobe. All rights reserved.
@@ -16,11 +16,23 @@ function $feb5ffebff200149$export$d3e3bd3e26688c04(e) {
16
16
  // On Windows and Ubuntu, Alt + Space has a system wide meaning.
17
17
  return (0, $jUnAJ$isAppleDevice)() ? e.altKey : e.ctrlKey;
18
18
  }
19
- function $feb5ffebff200149$export$16792effe837dba3(e) {
20
- if ((0, $jUnAJ$isMac)()) return e.metaKey;
21
- return e.ctrlKey;
19
+ function $feb5ffebff200149$export$c3d8340acf92597f(collectionRef, key) {
20
+ var _collectionRef_current, _collectionRef_current1;
21
+ let selector = `[data-key="${CSS.escape(String(key))}"]`;
22
+ let collection = (_collectionRef_current = collectionRef.current) === null || _collectionRef_current === void 0 ? void 0 : _collectionRef_current.dataset.collection;
23
+ if (collection) selector = `[data-collection="${CSS.escape(collection)}"]${selector}`;
24
+ return (_collectionRef_current1 = collectionRef.current) === null || _collectionRef_current1 === void 0 ? void 0 : _collectionRef_current1.querySelector(selector);
25
+ }
26
+ const $feb5ffebff200149$var$collectionMap = new WeakMap();
27
+ function $feb5ffebff200149$export$881eb0d9f3605d9d(collection) {
28
+ let id = (0, $jUnAJ$useId)();
29
+ $feb5ffebff200149$var$collectionMap.set(collection, id);
30
+ return id;
31
+ }
32
+ function $feb5ffebff200149$export$6aeb1680a0ae8741(collection) {
33
+ return $feb5ffebff200149$var$collectionMap.get(collection);
22
34
  }
23
35
 
24
36
 
25
- export {$feb5ffebff200149$export$d3e3bd3e26688c04 as isNonContiguousSelectionModifier, $feb5ffebff200149$export$16792effe837dba3 as isCtrlKeyPressed};
37
+ export {$feb5ffebff200149$export$d3e3bd3e26688c04 as isNonContiguousSelectionModifier, $feb5ffebff200149$export$c3d8340acf92597f as getItemElement, $feb5ffebff200149$export$881eb0d9f3605d9d as useCollectionId, $feb5ffebff200149$export$6aeb1680a0ae8741 as getCollectionId};
26
38
  //# sourceMappingURL=utils.module.js.map
@@ -1,4 +1,4 @@
1
- import {isAppleDevice as $jUnAJ$isAppleDevice, isMac as $jUnAJ$isMac} from "@react-aria/utils";
1
+ import {isAppleDevice as $jUnAJ$isAppleDevice, useId as $jUnAJ$useId} from "@react-aria/utils";
2
2
 
3
3
  /*
4
4
  * Copyright 2020 Adobe. All rights reserved.
@@ -16,11 +16,23 @@ function $feb5ffebff200149$export$d3e3bd3e26688c04(e) {
16
16
  // On Windows and Ubuntu, Alt + Space has a system wide meaning.
17
17
  return (0, $jUnAJ$isAppleDevice)() ? e.altKey : e.ctrlKey;
18
18
  }
19
- function $feb5ffebff200149$export$16792effe837dba3(e) {
20
- if ((0, $jUnAJ$isMac)()) return e.metaKey;
21
- return e.ctrlKey;
19
+ function $feb5ffebff200149$export$c3d8340acf92597f(collectionRef, key) {
20
+ var _collectionRef_current, _collectionRef_current1;
21
+ let selector = `[data-key="${CSS.escape(String(key))}"]`;
22
+ let collection = (_collectionRef_current = collectionRef.current) === null || _collectionRef_current === void 0 ? void 0 : _collectionRef_current.dataset.collection;
23
+ if (collection) selector = `[data-collection="${CSS.escape(collection)}"]${selector}`;
24
+ return (_collectionRef_current1 = collectionRef.current) === null || _collectionRef_current1 === void 0 ? void 0 : _collectionRef_current1.querySelector(selector);
25
+ }
26
+ const $feb5ffebff200149$var$collectionMap = new WeakMap();
27
+ function $feb5ffebff200149$export$881eb0d9f3605d9d(collection) {
28
+ let id = (0, $jUnAJ$useId)();
29
+ $feb5ffebff200149$var$collectionMap.set(collection, id);
30
+ return id;
31
+ }
32
+ function $feb5ffebff200149$export$6aeb1680a0ae8741(collection) {
33
+ return $feb5ffebff200149$var$collectionMap.get(collection);
22
34
  }
23
35
 
24
36
 
25
- export {$feb5ffebff200149$export$d3e3bd3e26688c04 as isNonContiguousSelectionModifier, $feb5ffebff200149$export$16792effe837dba3 as isCtrlKeyPressed};
37
+ export {$feb5ffebff200149$export$d3e3bd3e26688c04 as isNonContiguousSelectionModifier, $feb5ffebff200149$export$c3d8340acf92597f as getItemElement, $feb5ffebff200149$export$881eb0d9f3605d9d as useCollectionId, $feb5ffebff200149$export$6aeb1680a0ae8741 as getCollectionId};
26
38
  //# sourceMappingURL=utils.module.js.map
@@ -1 +1 @@
1
- {"mappings":";;AAAA;;;;;;;;;;CAUC;AAUM,SAAS,0CAAiC,CAAQ;IACvD,qFAAqF;IACrF,gEAAgE;IAChE,OAAO,CAAA,GAAA,oBAAY,MAAM,EAAE,MAAM,GAAG,EAAE,OAAO;AAC/C;AAEO,SAAS,0CAAiB,CAAQ;IACvC,IAAI,CAAA,GAAA,YAAI,KACN,OAAO,EAAE,OAAO;IAGlB,OAAO,EAAE,OAAO;AAClB","sources":["packages/@react-aria/selection/src/utils.ts"],"sourcesContent":["/*\n * Copyright 2020 Adobe. All rights reserved.\n * This file is licensed to you under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License. You may obtain a copy\n * of the License at http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software distributed under\n * the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS\n * OF ANY KIND, either express or implied. See the License for the specific language\n * governing permissions and limitations under the License.\n */\n\nimport {isAppleDevice, isMac} from '@react-aria/utils';\n\ninterface Event {\n altKey: boolean,\n ctrlKey: boolean,\n metaKey: boolean\n}\n\nexport function isNonContiguousSelectionModifier(e: Event) {\n // Ctrl + Arrow Up/Arrow Down has a system wide meaning on macOS, so use Alt instead.\n // On Windows and Ubuntu, Alt + Space has a system wide meaning.\n return isAppleDevice() ? e.altKey : e.ctrlKey;\n}\n\nexport function isCtrlKeyPressed(e: Event) {\n if (isMac()) {\n return e.metaKey;\n }\n\n return e.ctrlKey;\n}\n"],"names":[],"version":3,"file":"utils.module.js.map"}
1
+ {"mappings":";;AAAA;;;;;;;;;;CAUC;AAYM,SAAS,0CAAiC,CAAQ;IACvD,qFAAqF;IACrF,gEAAgE;IAChE,OAAO,CAAA,GAAA,oBAAY,MAAM,EAAE,MAAM,GAAG,EAAE,OAAO;AAC/C;AAEO,SAAS,0CAAe,aAA4C,EAAE,GAAQ;QAElE,wBAIV;IALP,IAAI,WAAW,CAAC,WAAW,EAAE,IAAI,MAAM,CAAC,OAAO,MAAM,EAAE,CAAC;IACxD,IAAI,cAAa,yBAAA,cAAc,OAAO,cAArB,6CAAA,uBAAuB,OAAO,CAAC,UAAU;IAC1D,IAAI,YACF,WAAW,CAAC,kBAAkB,EAAE,IAAI,MAAM,CAAC,YAAY,EAAE,EAAE,UAAU;IAEvE,QAAO,0BAAA,cAAc,OAAO,cAArB,8CAAA,wBAAuB,aAAa,CAAC;AAC9C;AAEA,MAAM,sCAAgB,IAAI;AACnB,SAAS,0CAAgB,UAA2B;IACzD,IAAI,KAAK,CAAA,GAAA,YAAI;IACb,oCAAc,GAAG,CAAC,YAAY;IAC9B,OAAO;AACT;AAEO,SAAS,0CAAgB,UAA2B;IACzD,OAAO,oCAAc,GAAG,CAAC;AAC3B","sources":["packages/@react-aria/selection/src/utils.ts"],"sourcesContent":["/*\n * Copyright 2020 Adobe. All rights reserved.\n * This file is licensed to you under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License. You may obtain a copy\n * of the License at http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software distributed under\n * the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS\n * OF ANY KIND, either express or implied. See the License for the specific language\n * governing permissions and limitations under the License.\n */\n\nimport {Collection, Key} from '@react-types/shared';\nimport {isAppleDevice, useId} from '@react-aria/utils';\nimport {RefObject} from 'react';\n\ninterface Event {\n altKey: boolean,\n ctrlKey: boolean,\n metaKey: boolean\n}\n\nexport function isNonContiguousSelectionModifier(e: Event) {\n // Ctrl + Arrow Up/Arrow Down has a system wide meaning on macOS, so use Alt instead.\n // On Windows and Ubuntu, Alt + Space has a system wide meaning.\n return isAppleDevice() ? e.altKey : e.ctrlKey;\n}\n\nexport function getItemElement(collectionRef: RefObject<HTMLElement | null>, key: Key) {\n let selector = `[data-key=\"${CSS.escape(String(key))}\"]`;\n let collection = collectionRef.current?.dataset.collection;\n if (collection) {\n selector = `[data-collection=\"${CSS.escape(collection)}\"]${selector}`;\n }\n return collectionRef.current?.querySelector(selector);\n}\n\nconst collectionMap = new WeakMap();\nexport function useCollectionId(collection: Collection<any>) {\n let id = useId();\n collectionMap.set(collection, id);\n return id;\n}\n\nexport function getCollectionId(collection: Collection<any>) {\n return collectionMap.get(collection)!;\n}\n"],"names":[],"version":3,"file":"utils.module.js.map"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-aria/selection",
3
- "version": "3.21.0",
3
+ "version": "3.23.0",
4
4
  "description": "Spectrum UI components in React",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/main.js",
@@ -22,12 +22,12 @@
22
22
  "url": "https://github.com/adobe/react-spectrum"
23
23
  },
24
24
  "dependencies": {
25
- "@react-aria/focus": "^3.19.0",
26
- "@react-aria/i18n": "^3.12.4",
27
- "@react-aria/interactions": "^3.22.5",
28
- "@react-aria/utils": "^3.26.0",
29
- "@react-stately/selection": "^3.18.0",
30
- "@react-types/shared": "^3.26.0",
25
+ "@react-aria/focus": "^3.20.0",
26
+ "@react-aria/i18n": "^3.12.6",
27
+ "@react-aria/interactions": "^3.24.0",
28
+ "@react-aria/utils": "^3.28.0",
29
+ "@react-stately/selection": "^3.20.0",
30
+ "@react-types/shared": "^3.28.0",
31
31
  "@swc/helpers": "^0.5.0"
32
32
  },
33
33
  "peerDependencies": {
@@ -37,5 +37,5 @@
37
37
  "publishConfig": {
38
38
  "access": "public"
39
39
  },
40
- "gitHead": "71f0ef23053f9e03ee7e97df736e8b083e006849"
40
+ "gitHead": "4d3c72c94eea2d72eb3a0e7d56000c6ef7e39726"
41
41
  }
@@ -10,6 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import {getItemElement} from './utils';
13
14
  import {Key, LayoutDelegate, Rect, RefObject, Size} from '@react-types/shared';
14
15
 
15
16
  export class DOMLayoutDelegate implements LayoutDelegate {
@@ -24,7 +25,7 @@ export class DOMLayoutDelegate implements LayoutDelegate {
24
25
  if (!container) {
25
26
  return null;
26
27
  }
27
- let item = key != null ? container.querySelector(`[data-key="${CSS.escape(key.toString())}"]`) : null;
28
+ let item = key != null ? getItemElement(this.ref, key) : null;
28
29
  if (!item) {
29
30
  return null;
30
31
  }
@@ -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, 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
- import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
17
- import {focusWithoutScrolling, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter} from '@react-aria/utils';
18
- import {getInteractionModality} from '@react-aria/interactions';
19
- import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
17
+ import {focusSafely, getInteractionModality} from '@react-aria/interactions';
18
+ import {getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus';
19
+ import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils';
20
20
  import {MultipleSelectionManager} from '@react-stately/selection';
21
21
  import {useLocale} from '@react-aria/i18n';
22
22
  import {useTypeSelect} from './useTypeSelect';
@@ -140,7 +140,7 @@ 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 = getItemElement(ref, key);
144
144
  let itemProps = manager.getItemProps(key);
145
145
  if (item) {
146
146
  router.open(item, e, itemProps.href, itemProps.routerOptions);
@@ -222,6 +222,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
222
222
  }
223
223
  case 'Home':
224
224
  if (delegate.getFirstKey) {
225
+ if (manager.focusedKey === null && e.shiftKey) {
226
+ return;
227
+ }
225
228
  e.preventDefault();
226
229
  let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e));
227
230
  manager.setFocusedKey(firstKey);
@@ -236,6 +239,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
236
239
  break;
237
240
  case 'End':
238
241
  if (delegate.getLastKey) {
242
+ if (manager.focusedKey === null && e.shiftKey) {
243
+ return;
244
+ }
239
245
  e.preventDefault();
240
246
  let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e));
241
247
  manager.setFocusedKey(lastKey);
@@ -336,12 +342,11 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
336
342
  }
337
343
 
338
344
  manager.setFocused(true);
339
-
340
345
  if (manager.focusedKey == null) {
341
- let navigateToFirstKey = (key: Key | undefined | null) => {
346
+ let navigateToKey = (key: Key | undefined | null) => {
342
347
  if (key != null) {
343
348
  manager.setFocusedKey(key);
344
- if (selectOnFocus) {
349
+ if (selectOnFocus && !manager.isSelected(key)) {
345
350
  manager.replaceSelection(key);
346
351
  }
347
352
  }
@@ -351,9 +356,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
351
356
  // and either focus the first or last item accordingly.
352
357
  let relatedTarget = e.relatedTarget as Element;
353
358
  if (relatedTarget && (e.currentTarget.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING)) {
354
- navigateToFirstKey(manager.lastSelectedKey ?? delegate.getLastKey?.());
359
+ navigateToKey(manager.lastSelectedKey ?? delegate.getLastKey?.());
355
360
  } else {
356
- navigateToFirstKey(manager.firstSelectedKey ?? delegate.getFirstKey?.());
361
+ navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.());
357
362
  }
358
363
  } else if (!isVirtualized && scrollRef.current) {
359
364
  // Restore the scroll position to what it was before.
@@ -363,10 +368,10 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
363
368
 
364
369
  if (manager.focusedKey != null && scrollRef.current) {
365
370
  // Refocus and scroll the focused item into view if it exists within the scrollable region.
366
- let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
367
- if (element) {
371
+ let element = getItemElement(ref, manager.focusedKey);
372
+ if (element instanceof HTMLElement) {
368
373
  // This prevents a flash of focus on the first/last element in the collection, or the collection itself.
369
- if (!element.contains(document.activeElement)) {
374
+ if (!element.contains(document.activeElement) && !shouldUseVirtualFocus) {
370
375
  focusWithoutScrolling(element);
371
376
  }
372
377
 
@@ -385,7 +390,75 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
385
390
  }
386
391
  };
387
392
 
393
+ // Ref to track whether the first item in the collection should be automatically focused. Specifically used for autocomplete when user types
394
+ // to focus the first key AFTER the collection updates.
395
+ // TODO: potentially expand the usage of this
396
+ let shouldVirtualFocusFirst = useRef(false);
397
+ // Add event listeners for custom virtual events. These handle updating the focused key in response to various keyboard events
398
+ // at the autocomplete level
399
+ // TODO: fix type later
400
+ useEvent(ref, FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e: any) => {
401
+ let {detail} = e;
402
+ e.stopPropagation();
403
+ manager.setFocused(true);
404
+
405
+ // If the user is typing forwards, autofocus the first option in the list.
406
+ if (detail?.focusStrategy === 'first') {
407
+ shouldVirtualFocusFirst.current = true;
408
+ }
409
+ });
410
+
411
+ let updateActiveDescendant = useEffectEvent(() => {
412
+ let keyToFocus = delegate.getFirstKey?.() ?? null;
413
+
414
+ // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist
415
+ if (keyToFocus == null) {
416
+ moveVirtualFocus(ref.current);
417
+
418
+ // 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.
419
+ // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again.
420
+ if (manager.collection.size > 0) {
421
+ shouldVirtualFocusFirst.current = false;
422
+ }
423
+ } else {
424
+ manager.setFocusedKey(keyToFocus);
425
+ // Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key
426
+ // 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
427
+ // after the collection updates after load
428
+ shouldVirtualFocusFirst.current = false;
429
+ }
430
+ });
431
+
432
+ useUpdateLayoutEffect(() => {
433
+ if (shouldVirtualFocusFirst.current) {
434
+ updateActiveDescendant();
435
+ }
436
+
437
+ }, [manager.collection, updateActiveDescendant]);
438
+
439
+ let resetFocusFirstFlag = useEffectEvent(() => {
440
+ // If user causes the focused key to change in any other way, clear shouldVirtualFocusFirst so we don't
441
+ // accidentally move focus from under them. Skip this if the collection was empty because we might be in a load
442
+ // state and will still want to focus the first item after load
443
+ if (manager.collection.size > 0) {
444
+ shouldVirtualFocusFirst.current = false;
445
+ }
446
+ });
447
+
448
+ useUpdateLayoutEffect(() => {
449
+ resetFocusFirstFlag();
450
+ }, [manager.focusedKey, resetFocusFirstFlag]);
451
+
452
+ useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e: any) => {
453
+ e.stopPropagation();
454
+ manager.setFocused(false);
455
+ if (e.detail?.clearFocusKey) {
456
+ manager.setFocusedKey(null);
457
+ }
458
+ });
459
+
388
460
  const autoFocusRef = useRef(autoFocus);
461
+ const didAutoFocusRef = useRef(false);
389
462
  useEffect(() => {
390
463
  if (autoFocusRef.current) {
391
464
  let focusedKey: Key | null = null;
@@ -415,23 +488,28 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
415
488
  if (focusedKey == null && !shouldUseVirtualFocus && ref.current) {
416
489
  focusSafely(ref.current);
417
490
  }
491
+
492
+ // Wait until the collection has items to autofocus.
493
+ if (manager.collection.size > 0) {
494
+ autoFocusRef.current = false;
495
+ didAutoFocusRef.current = true;
496
+ }
418
497
  }
419
- // eslint-disable-next-line react-hooks/exhaustive-deps
420
- }, []);
498
+ });
421
499
 
422
500
  // Scroll the focused element into view when the focusedKey changes.
423
501
  let lastFocusedKey = useRef(manager.focusedKey);
424
502
  useEffect(() => {
425
- if (manager.isFocused && manager.focusedKey != null && (manager.focusedKey !== lastFocusedKey.current || autoFocusRef.current) && scrollRef.current && ref.current) {
503
+ if (manager.isFocused && manager.focusedKey != null && (manager.focusedKey !== lastFocusedKey.current || didAutoFocusRef.current) && scrollRef.current && ref.current) {
426
504
  let modality = getInteractionModality();
427
- let element = ref.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
428
- if (!element) {
505
+ let element = getItemElement(ref, manager.focusedKey);
506
+ if (!(element instanceof HTMLElement)) {
429
507
  // If item element wasn't found, return early (don't update autoFocusRef and lastFocusedKey).
430
508
  // The collection may initially be empty (e.g. virtualizer), so wait until the element exists.
431
509
  return;
432
510
  }
433
511
 
434
- if (modality === 'keyboard' || autoFocusRef.current) {
512
+ if (modality === 'keyboard' || didAutoFocusRef.current) {
435
513
  scrollIntoView(scrollRef.current, element);
436
514
 
437
515
  // Avoid scroll in iOS VO, since it may cause overlay to close (i.e. RAC submenu)
@@ -447,7 +525,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
447
525
  }
448
526
 
449
527
  lastFocusedKey.current = manager.focusedKey;
450
- autoFocusRef.current = false;
528
+ didAutoFocusRef.current = false;
451
529
  });
452
530
 
453
531
  // Intercept FocusScope restoration since virtualized collections can reuse DOM nodes.
@@ -480,17 +558,16 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
480
558
 
481
559
  // If nothing is focused within the collection, make the collection itself tabbable.
482
560
  // This will be marshalled to either the first or last item depending on where focus came from.
483
- // If using virtual focus, don't set a tabIndex at all so that VoiceOver on iOS 14 doesn't try
484
- // to move real DOM focus to the element anyway.
485
561
  let tabIndex: number | undefined = undefined;
486
562
  if (!shouldUseVirtualFocus) {
487
563
  tabIndex = manager.focusedKey == null ? 0 : -1;
488
564
  }
489
565
 
566
+ let collectionId = useCollectionId(manager.collection);
490
567
  return {
491
- collectionProps: {
492
- ...handlers,
493
- tabIndex
494
- }
568
+ collectionProps: mergeProps(handlers, {
569
+ tabIndex,
570
+ 'data-collection': collectionId
571
+ })
495
572
  };
496
573
  }
@@ -10,15 +10,15 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {DOMAttributes, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared';
14
- import {focusSafely} from '@react-aria/focus';
15
- import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
16
- import {mergeProps, openLink, useRouter} from '@react-aria/utils';
13
+ import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared';
14
+ import {focusSafely, PressProps, useLongPress, usePress} from '@react-aria/interactions';
15
+ import {getCollectionId, isNonContiguousSelectionModifier} from './utils';
16
+ import {isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils';
17
+ import {moveVirtualFocus} from '@react-aria/focus';
17
18
  import {MultipleSelectionManager} from '@react-stately/selection';
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);
@@ -159,13 +160,20 @@ 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 blur events 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 && 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
+ moveVirtualFocus(ref.current);
169
177
  }
170
178
  }
171
179
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -241,7 +249,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
241
249
  }
242
250
  };
243
251
 
244
- // If allowsDifferentPressOrigin, make selection happen on pressUp (e.g. open menu on press down, selection on menu item happens on press up.)
252
+ // 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
253
  // Otherwise, have selection happen onPress (prevents listview row selection when clicking on interactable elements in the row)
246
254
  if (!allowsDifferentPressOrigin) {
247
255
  itemPressProps.onPress = (e) => {
@@ -257,12 +265,16 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
257
265
  };
258
266
  } else {
259
267
  itemPressProps.onPressUp = hasPrimaryAction ? undefined : (e) => {
260
- if (e.pointerType !== 'keyboard' && allowsSelection) {
268
+ if (e.pointerType === 'mouse' && allowsSelection) {
261
269
  onSelect(e);
262
270
  }
263
271
  };
264
272
 
265
- itemPressProps.onPress = hasPrimaryAction ? performAction : undefined;
273
+ itemPressProps.onPress = hasPrimaryAction ? performAction : (e) => {
274
+ if (e.pointerType !== 'keyboard' && e.pointerType !== 'mouse' && allowsSelection) {
275
+ onSelect(e);
276
+ }
277
+ };
266
278
  }
267
279
  } else {
268
280
  itemPressProps.onPressStart = (e) => {
@@ -303,8 +315,28 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
303
315
  };
304
316
  }
305
317
 
318
+ itemProps['data-collection'] = getCollectionId(manager.collection);
306
319
  itemProps['data-key'] = key;
307
320
  itemPressProps.preventFocusOnPress = shouldUseVirtualFocus;
321
+
322
+ // When using virtual focus, make sure the focused key gets updated on press.
323
+ if (shouldUseVirtualFocus) {
324
+ itemPressProps = mergeProps(itemPressProps, {
325
+ onPressStart(e) {
326
+ if (e.pointerType !== 'touch') {
327
+ manager.setFocused(true);
328
+ manager.setFocusedKey(key);
329
+ }
330
+ },
331
+ onPress(e) {
332
+ if (e.pointerType === 'touch') {
333
+ manager.setFocused(true);
334
+ manager.setFocusedKey(key);
335
+ }
336
+ }
337
+ });
338
+ }
339
+
308
340
  let {pressProps, isPressed} = usePress(itemPressProps);
309
341
 
310
342
  // Double clicking with a mouse with selectionBehavior = 'replace' performs an action.
@@ -350,9 +382,11 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
350
382
  return {
351
383
  itemProps: mergeProps(
352
384
  itemProps,
353
- allowsSelection || hasPrimaryAction ? pressProps : {},
385
+ allowsSelection || hasPrimaryAction || shouldUseVirtualFocus ? pressProps : {},
354
386
  longPressEnabled ? longPressProps : {},
355
- {onDoubleClick, onDragStartCapture, onClick}
387
+ {onDoubleClick, onDragStartCapture, onClick, id},
388
+ // Prevent DOM focus from moving on mouse down when using virtual focus
389
+ shouldUseVirtualFocus ? {onMouseDown: e => e.preventDefault()} : undefined
356
390
  ),
357
391
  isPressed,
358
392
  isSelected: manager.isSelected(key),
package/src/utils.ts CHANGED
@@ -10,7 +10,9 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {isAppleDevice, isMac} from '@react-aria/utils';
13
+ import {Collection, Key} from '@react-types/shared';
14
+ import {isAppleDevice, useId} from '@react-aria/utils';
15
+ import {RefObject} from 'react';
14
16
 
15
17
  interface Event {
16
18
  altKey: boolean,
@@ -24,10 +26,22 @@ export function isNonContiguousSelectionModifier(e: Event) {
24
26
  return isAppleDevice() ? e.altKey : e.ctrlKey;
25
27
  }
26
28
 
27
- export function isCtrlKeyPressed(e: Event) {
28
- if (isMac()) {
29
- return e.metaKey;
29
+ export function getItemElement(collectionRef: RefObject<HTMLElement | null>, key: Key) {
30
+ let selector = `[data-key="${CSS.escape(String(key))}"]`;
31
+ let collection = collectionRef.current?.dataset.collection;
32
+ if (collection) {
33
+ selector = `[data-collection="${CSS.escape(collection)}"]${selector}`;
30
34
  }
35
+ return collectionRef.current?.querySelector(selector);
36
+ }
37
+
38
+ const collectionMap = new WeakMap();
39
+ export function useCollectionId(collection: Collection<any>) {
40
+ let id = useId();
41
+ collectionMap.set(collection, id);
42
+ return id;
43
+ }
31
44
 
32
- return e.ctrlKey;
45
+ export function getCollectionId(collection: Collection<any>) {
46
+ return collectionMap.get(collection)!;
33
47
  }