@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.
- package/dist/DOMLayoutDelegate.main.js +9 -6
- package/dist/DOMLayoutDelegate.main.js.map +1 -1
- package/dist/DOMLayoutDelegate.mjs +9 -6
- package/dist/DOMLayoutDelegate.module.js +9 -6
- package/dist/DOMLayoutDelegate.module.js.map +1 -1
- package/dist/ListKeyboardDelegate.main.js +38 -30
- package/dist/ListKeyboardDelegate.main.js.map +1 -1
- package/dist/ListKeyboardDelegate.mjs +38 -30
- package/dist/ListKeyboardDelegate.module.js +38 -30
- package/dist/ListKeyboardDelegate.module.js.map +1 -1
- package/dist/types.d.ts +14 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/useSelectableCollection.main.js +103 -31
- package/dist/useSelectableCollection.main.js.map +1 -1
- package/dist/useSelectableCollection.mjs +104 -32
- package/dist/useSelectableCollection.module.js +104 -32
- package/dist/useSelectableCollection.module.js.map +1 -1
- package/dist/useSelectableItem.main.js +28 -12
- package/dist/useSelectableItem.main.js.map +1 -1
- package/dist/useSelectableItem.mjs +30 -14
- package/dist/useSelectableItem.module.js +30 -14
- package/dist/useSelectableItem.module.js.map +1 -1
- package/dist/useTypeSelect.main.js +12 -10
- package/dist/useTypeSelect.main.js.map +1 -1
- package/dist/useTypeSelect.mjs +12 -10
- package/dist/useTypeSelect.module.js +12 -10
- package/dist/useTypeSelect.module.js.map +1 -1
- package/dist/utils.main.js +0 -5
- package/dist/utils.main.js.map +1 -1
- package/dist/utils.mjs +2 -6
- package/dist/utils.module.js +2 -6
- package/dist/utils.module.js.map +1 -1
- package/package.json +10 -10
- package/src/DOMLayoutDelegate.ts +11 -8
- package/src/ListKeyboardDelegate.ts +46 -34
- package/src/useSelectableCollection.ts +118 -36
- package/src/useSelectableItem.ts +35 -18
- package/src/useTypeSelect.ts +16 -14
- 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
|
-
|
|
79
|
-
|
|
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
|
|
82
|
+
return nextKey;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
|
|
85
|
+
nextKey = getNext(nextKey);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
return null;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
getNextKey(key: Key) {
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
itemRect =
|
|
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
|
-
|
|
223
|
-
itemRect =
|
|
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
|
|
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
|
-
|
|
246
|
-
itemRect =
|
|
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
|
-
|
|
253
|
-
itemRect =
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
143
|
+
let item = scrollRef.current?.querySelector(`[data-key="${CSS.escape(key.toString())}"]`);
|
|
144
144
|
let itemProps = manager.getItemProps(key);
|
|
145
|
-
|
|
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 (
|
|
227
|
-
manager.
|
|
228
|
-
|
|
229
|
-
|
|
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 (
|
|
239
|
-
manager.
|
|
240
|
-
|
|
241
|
-
|
|
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 ?
|
|
322
|
+
useEvent(scrollRef, 'scroll', isVirtualized ? undefined : () => {
|
|
311
323
|
scrollPos.current = {
|
|
312
|
-
top: scrollRef.current
|
|
313
|
-
left: scrollRef.current
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/useSelectableItem.ts
CHANGED
|
@@ -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,
|
|
16
|
-
import {
|
|
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
|
|
165
|
-
if (
|
|
166
|
-
focus
|
|
167
|
-
|
|
168
|
-
|
|
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 ?
|
|
260
|
-
if (e.pointerType
|
|
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 :
|
|
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),
|
package/src/useTypeSelect.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// If no key found, search from the top.
|
|
79
|
+
if (key == null) {
|
|
80
|
+
key = keyboardDelegate.getKeyForSearch(state.search);
|
|
81
|
+
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 :
|
|
101
|
+
onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined
|
|
100
102
|
}
|
|
101
103
|
};
|
|
102
104
|
}
|