@react-aria/selection 3.4.1 → 3.7.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/main.js +288 -168
- package/dist/main.js.map +1 -1
- package/dist/module.js +279 -166
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/useSelectableCollection.ts +160 -93
- package/src/useSelectableItem.ts +106 -15
- package/src/useSelectableList.ts +4 -43
- package/src/utils.ts +34 -0
|
@@ -10,22 +10,15 @@
|
|
|
10
10
|
* governing permissions and limitations under the License.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {FocusEvent, HTMLAttributes, KeyboardEvent, RefObject, useEffect} from 'react';
|
|
13
|
+
import {FocusEvent, HTMLAttributes, Key, KeyboardEvent, RefObject, useEffect, useRef} from 'react';
|
|
14
14
|
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
|
|
15
15
|
import {FocusStrategy, KeyboardDelegate} from '@react-types/shared';
|
|
16
|
-
import {
|
|
16
|
+
import {focusWithoutScrolling, mergeProps, useEvent} from '@react-aria/utils';
|
|
17
|
+
import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
|
|
17
18
|
import {MultipleSelectionManager} from '@react-stately/selection';
|
|
18
19
|
import {useLocale} from '@react-aria/i18n';
|
|
19
20
|
import {useTypeSelect} from './useTypeSelect';
|
|
20
21
|
|
|
21
|
-
function isCtrlKeyPressed(e: KeyboardEvent) {
|
|
22
|
-
if (isMac()) {
|
|
23
|
-
return e.metaKey;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return e.ctrlKey;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
22
|
interface SelectableCollectionOptions {
|
|
30
23
|
/**
|
|
31
24
|
* An interface for reading and updating multiple selection state.
|
|
@@ -76,7 +69,16 @@ interface SelectableCollectionOptions {
|
|
|
76
69
|
/**
|
|
77
70
|
* Whether navigation through tab key is enabled.
|
|
78
71
|
*/
|
|
79
|
-
allowsTabNavigation?: boolean
|
|
72
|
+
allowsTabNavigation?: boolean,
|
|
73
|
+
/**
|
|
74
|
+
* Whether the collection items are contained in a virtual scroller.
|
|
75
|
+
*/
|
|
76
|
+
isVirtualized?: boolean,
|
|
77
|
+
/**
|
|
78
|
+
* The ref attached to the scrollable body. Used to provide automatic scrolling on item focus for non-virtualized collections.
|
|
79
|
+
* If not provided, defaults to the collection ref.
|
|
80
|
+
*/
|
|
81
|
+
scrollRef?: RefObject<HTMLElement>
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
interface SelectableCollectionAria {
|
|
@@ -96,45 +98,52 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
96
98
|
shouldFocusWrap = false,
|
|
97
99
|
disallowEmptySelection = false,
|
|
98
100
|
disallowSelectAll = false,
|
|
99
|
-
selectOnFocus =
|
|
101
|
+
selectOnFocus = manager.selectionBehavior === 'replace',
|
|
100
102
|
disallowTypeAhead = false,
|
|
101
103
|
shouldUseVirtualFocus,
|
|
102
|
-
allowsTabNavigation = false
|
|
104
|
+
allowsTabNavigation = false,
|
|
105
|
+
isVirtualized,
|
|
106
|
+
// If no scrollRef is provided, assume the collection ref is the scrollable region
|
|
107
|
+
scrollRef = ref
|
|
103
108
|
} = options;
|
|
104
109
|
let {direction} = useLocale();
|
|
105
110
|
|
|
111
|
+
|
|
106
112
|
let onKeyDown = (e: KeyboardEvent) => {
|
|
107
|
-
//
|
|
113
|
+
// Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes
|
|
114
|
+
if (e.altKey && e.key === 'Tab') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
// Keyboard events bubble through portals. Don't handle keyboard events
|
|
109
119
|
// for elements outside the collection (e.g. menus).
|
|
110
|
-
if (
|
|
120
|
+
if (!ref.current.contains(e.target as HTMLElement)) {
|
|
111
121
|
return;
|
|
112
122
|
}
|
|
113
123
|
|
|
124
|
+
const navigateToKey = (key: Key | undefined, childFocus?: FocusStrategy) => {
|
|
125
|
+
if (key != null) {
|
|
126
|
+
manager.setFocusedKey(key, childFocus);
|
|
127
|
+
|
|
128
|
+
if (e.shiftKey && manager.selectionMode === 'multiple') {
|
|
129
|
+
manager.extendSelection(key);
|
|
130
|
+
} else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) {
|
|
131
|
+
manager.replaceSelection(key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
114
136
|
switch (e.key) {
|
|
115
137
|
case 'ArrowDown': {
|
|
116
138
|
if (delegate.getKeyBelow) {
|
|
117
139
|
e.preventDefault();
|
|
118
140
|
let nextKey = manager.focusedKey != null
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
manager.setFocusedKey(nextKey);
|
|
124
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
125
|
-
manager.replaceSelection(nextKey);
|
|
126
|
-
}
|
|
127
|
-
} else if (shouldFocusWrap) {
|
|
128
|
-
let wrapKey = delegate.getFirstKey(manager.focusedKey);
|
|
129
|
-
manager.setFocusedKey(wrapKey);
|
|
130
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
131
|
-
manager.replaceSelection(wrapKey);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (e.shiftKey && manager.selectionMode === 'multiple') {
|
|
136
|
-
manager.extendSelection(nextKey);
|
|
141
|
+
? delegate.getKeyBelow(manager.focusedKey)
|
|
142
|
+
: delegate.getFirstKey?.();
|
|
143
|
+
if (nextKey == null && shouldFocusWrap) {
|
|
144
|
+
nextKey = delegate.getFirstKey?.(manager.focusedKey);
|
|
137
145
|
}
|
|
146
|
+
navigateToKey(nextKey);
|
|
138
147
|
}
|
|
139
148
|
break;
|
|
140
149
|
}
|
|
@@ -142,25 +151,12 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
142
151
|
if (delegate.getKeyAbove) {
|
|
143
152
|
e.preventDefault();
|
|
144
153
|
let nextKey = manager.focusedKey != null
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
manager.setFocusedKey(nextKey);
|
|
150
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
151
|
-
manager.replaceSelection(nextKey);
|
|
152
|
-
}
|
|
153
|
-
} else if (shouldFocusWrap) {
|
|
154
|
-
let wrapKey = delegate.getLastKey(manager.focusedKey);
|
|
155
|
-
manager.setFocusedKey(wrapKey);
|
|
156
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
157
|
-
manager.replaceSelection(wrapKey);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (e.shiftKey && manager.selectionMode === 'multiple') {
|
|
162
|
-
manager.extendSelection(nextKey);
|
|
154
|
+
? delegate.getKeyAbove(manager.focusedKey)
|
|
155
|
+
: delegate.getLastKey?.();
|
|
156
|
+
if (nextKey == null && shouldFocusWrap) {
|
|
157
|
+
nextKey = delegate.getLastKey?.(manager.focusedKey);
|
|
163
158
|
}
|
|
159
|
+
navigateToKey(nextKey);
|
|
164
160
|
}
|
|
165
161
|
break;
|
|
166
162
|
}
|
|
@@ -168,15 +164,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
168
164
|
if (delegate.getKeyLeftOf) {
|
|
169
165
|
e.preventDefault();
|
|
170
166
|
let nextKey = delegate.getKeyLeftOf(manager.focusedKey);
|
|
171
|
-
|
|
172
|
-
manager.setFocusedKey(nextKey, direction === 'rtl' ? 'first' : 'last');
|
|
173
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
174
|
-
manager.replaceSelection(nextKey);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
if (e.shiftKey && manager.selectionMode === 'multiple') {
|
|
178
|
-
manager.extendSelection(nextKey);
|
|
179
|
-
}
|
|
167
|
+
navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last');
|
|
180
168
|
}
|
|
181
169
|
break;
|
|
182
170
|
}
|
|
@@ -184,15 +172,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
184
172
|
if (delegate.getKeyRightOf) {
|
|
185
173
|
e.preventDefault();
|
|
186
174
|
let nextKey = delegate.getKeyRightOf(manager.focusedKey);
|
|
187
|
-
|
|
188
|
-
manager.setFocusedKey(nextKey, direction === 'rtl' ? 'last' : 'first');
|
|
189
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
190
|
-
manager.replaceSelection(nextKey);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (e.shiftKey && manager.selectionMode === 'multiple') {
|
|
194
|
-
manager.extendSelection(nextKey);
|
|
195
|
-
}
|
|
175
|
+
navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first');
|
|
196
176
|
}
|
|
197
177
|
break;
|
|
198
178
|
}
|
|
@@ -201,11 +181,10 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
201
181
|
e.preventDefault();
|
|
202
182
|
let firstKey = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e));
|
|
203
183
|
manager.setFocusedKey(firstKey);
|
|
204
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
205
|
-
manager.replaceSelection(firstKey);
|
|
206
|
-
}
|
|
207
184
|
if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') {
|
|
208
185
|
manager.extendSelection(firstKey);
|
|
186
|
+
} else if (selectOnFocus) {
|
|
187
|
+
manager.replaceSelection(firstKey);
|
|
209
188
|
}
|
|
210
189
|
}
|
|
211
190
|
break;
|
|
@@ -214,11 +193,10 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
214
193
|
e.preventDefault();
|
|
215
194
|
let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e));
|
|
216
195
|
manager.setFocusedKey(lastKey);
|
|
217
|
-
if (manager.selectionMode === 'single' && selectOnFocus) {
|
|
218
|
-
manager.replaceSelection(lastKey);
|
|
219
|
-
}
|
|
220
196
|
if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') {
|
|
221
197
|
manager.extendSelection(lastKey);
|
|
198
|
+
} else if (selectOnFocus) {
|
|
199
|
+
manager.replaceSelection(lastKey);
|
|
222
200
|
}
|
|
223
201
|
}
|
|
224
202
|
break;
|
|
@@ -226,24 +204,14 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
226
204
|
if (delegate.getKeyPageBelow) {
|
|
227
205
|
e.preventDefault();
|
|
228
206
|
let nextKey = delegate.getKeyPageBelow(manager.focusedKey);
|
|
229
|
-
|
|
230
|
-
manager.setFocusedKey(nextKey);
|
|
231
|
-
if (e.shiftKey && manager.selectionMode === 'multiple') {
|
|
232
|
-
manager.extendSelection(nextKey);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
207
|
+
navigateToKey(nextKey);
|
|
235
208
|
}
|
|
236
209
|
break;
|
|
237
210
|
case 'PageUp':
|
|
238
211
|
if (delegate.getKeyPageAbove) {
|
|
239
212
|
e.preventDefault();
|
|
240
213
|
let nextKey = delegate.getKeyPageAbove(manager.focusedKey);
|
|
241
|
-
|
|
242
|
-
manager.setFocusedKey(nextKey);
|
|
243
|
-
if (e.shiftKey && manager.selectionMode === 'multiple') {
|
|
244
|
-
manager.extendSelection(nextKey);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
214
|
+
navigateToKey(nextKey);
|
|
247
215
|
}
|
|
248
216
|
break;
|
|
249
217
|
case 'a':
|
|
@@ -280,7 +248,7 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
280
248
|
} while (last);
|
|
281
249
|
|
|
282
250
|
if (next && !next.contains(document.activeElement)) {
|
|
283
|
-
next
|
|
251
|
+
focusWithoutScrolling(next);
|
|
284
252
|
}
|
|
285
253
|
}
|
|
286
254
|
break;
|
|
@@ -289,6 +257,15 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
289
257
|
}
|
|
290
258
|
};
|
|
291
259
|
|
|
260
|
+
// Store the scroll position so we can restore it later.
|
|
261
|
+
let scrollPos = useRef({top: 0, left: 0});
|
|
262
|
+
useEvent(scrollRef, 'scroll', isVirtualized ? null : () => {
|
|
263
|
+
scrollPos.current = {
|
|
264
|
+
top: scrollRef.current.scrollTop,
|
|
265
|
+
left: scrollRef.current.scrollLeft
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
|
|
292
269
|
let onFocus = (e: FocusEvent) => {
|
|
293
270
|
if (manager.isFocused) {
|
|
294
271
|
// If a focus event bubbled through a portal, reset focus state.
|
|
@@ -307,14 +284,34 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
307
284
|
manager.setFocused(true);
|
|
308
285
|
|
|
309
286
|
if (manager.focusedKey == null) {
|
|
287
|
+
let navigateToFirstKey = (key: Key | undefined) => {
|
|
288
|
+
if (key != null) {
|
|
289
|
+
manager.setFocusedKey(key);
|
|
290
|
+
if (selectOnFocus) {
|
|
291
|
+
manager.replaceSelection(key);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
310
295
|
// If the user hasn't yet interacted with the collection, there will be no focusedKey set.
|
|
311
296
|
// Attempt to detect whether the user is tabbing forward or backward into the collection
|
|
312
297
|
// and either focus the first or last item accordingly.
|
|
313
298
|
let relatedTarget = e.relatedTarget as Element;
|
|
314
299
|
if (relatedTarget && (e.currentTarget.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
315
|
-
|
|
300
|
+
navigateToFirstKey(manager.lastSelectedKey ?? delegate.getLastKey());
|
|
316
301
|
} else {
|
|
317
|
-
|
|
302
|
+
navigateToFirstKey(manager.firstSelectedKey ?? delegate.getFirstKey());
|
|
303
|
+
}
|
|
304
|
+
} else if (!isVirtualized) {
|
|
305
|
+
// Restore the scroll position to what it was before.
|
|
306
|
+
scrollRef.current.scrollTop = scrollPos.current.top;
|
|
307
|
+
scrollRef.current.scrollLeft = scrollPos.current.left;
|
|
308
|
+
|
|
309
|
+
// Refocus and scroll the focused item into view if it exists within the scrollable region.
|
|
310
|
+
let element = scrollRef.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement;
|
|
311
|
+
if (element) {
|
|
312
|
+
// This prevents a flash of focus on the first/last element in the collection
|
|
313
|
+
focusWithoutScrolling(element);
|
|
314
|
+
scrollIntoView(scrollRef.current, element);
|
|
318
315
|
}
|
|
319
316
|
}
|
|
320
317
|
};
|
|
@@ -326,8 +323,9 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
326
323
|
}
|
|
327
324
|
};
|
|
328
325
|
|
|
326
|
+
const autoFocusRef = useRef(autoFocus);
|
|
329
327
|
useEffect(() => {
|
|
330
|
-
if (
|
|
328
|
+
if (autoFocusRef.current) {
|
|
331
329
|
let focusedKey = null;
|
|
332
330
|
|
|
333
331
|
// Check focus strategy to determine which item to focus
|
|
@@ -351,16 +349,31 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
351
349
|
focusSafely(ref.current);
|
|
352
350
|
}
|
|
353
351
|
}
|
|
352
|
+
autoFocusRef.current = false;
|
|
354
353
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
355
354
|
}, []);
|
|
356
355
|
|
|
356
|
+
// If not virtualized, scroll the focused element into view when the focusedKey changes.
|
|
357
|
+
// When virtualized, Virtualizer handles this internally.
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (!isVirtualized && manager.focusedKey && scrollRef?.current) {
|
|
360
|
+
let element = scrollRef.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement;
|
|
361
|
+
if (element) {
|
|
362
|
+
scrollIntoView(scrollRef.current, element);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}, [isVirtualized, scrollRef, manager.focusedKey]);
|
|
366
|
+
|
|
357
367
|
let handlers = {
|
|
358
368
|
onKeyDown,
|
|
359
369
|
onFocus,
|
|
360
370
|
onBlur,
|
|
361
371
|
onMouseDown(e) {
|
|
362
|
-
//
|
|
363
|
-
e.
|
|
372
|
+
// Ignore events that bubbled through portals.
|
|
373
|
+
if (e.currentTarget.contains(e.target)) {
|
|
374
|
+
// Prevent focus going to the collection when clicking on the scrollbar.
|
|
375
|
+
e.preventDefault();
|
|
376
|
+
}
|
|
364
377
|
}
|
|
365
378
|
};
|
|
366
379
|
|
|
@@ -389,3 +402,57 @@ export function useSelectableCollection(options: SelectableCollectionOptions): S
|
|
|
389
402
|
}
|
|
390
403
|
};
|
|
391
404
|
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Scrolls `scrollView` so that `element` is visible.
|
|
408
|
+
* Similar to `element.scrollIntoView({block: 'nearest'})` (not supported in Edge),
|
|
409
|
+
* but doesn't affect parents above `scrollView`.
|
|
410
|
+
*/
|
|
411
|
+
function scrollIntoView(scrollView: HTMLElement, element: HTMLElement) {
|
|
412
|
+
let offsetX = relativeOffset(scrollView, element, 'left');
|
|
413
|
+
let offsetY = relativeOffset(scrollView, element, 'top');
|
|
414
|
+
let width = element.offsetWidth;
|
|
415
|
+
let height = element.offsetHeight;
|
|
416
|
+
let x = scrollView.scrollLeft;
|
|
417
|
+
let y = scrollView.scrollTop;
|
|
418
|
+
let maxX = x + scrollView.offsetWidth;
|
|
419
|
+
let maxY = y + scrollView.offsetHeight;
|
|
420
|
+
|
|
421
|
+
if (offsetX <= x) {
|
|
422
|
+
x = offsetX;
|
|
423
|
+
} else if (offsetX + width > maxX) {
|
|
424
|
+
x += offsetX + width - maxX;
|
|
425
|
+
}
|
|
426
|
+
if (offsetY <= y) {
|
|
427
|
+
y = offsetY;
|
|
428
|
+
} else if (offsetY + height > maxY) {
|
|
429
|
+
y += offsetY + height - maxY;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
scrollView.scrollLeft = x;
|
|
433
|
+
scrollView.scrollTop = y;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Computes the offset left or top from child to ancestor by accumulating
|
|
438
|
+
* offsetLeft or offsetTop through intervening offsetParents.
|
|
439
|
+
*/
|
|
440
|
+
function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|'top') {
|
|
441
|
+
const prop = axis === 'left' ? 'offsetLeft' : 'offsetTop';
|
|
442
|
+
let sum = 0;
|
|
443
|
+
while (child.offsetParent) {
|
|
444
|
+
sum += child[prop];
|
|
445
|
+
if (child.offsetParent === ancestor) {
|
|
446
|
+
// Stop once we have found the ancestor we are interested in.
|
|
447
|
+
break;
|
|
448
|
+
} else if (child.offsetParent.contains(ancestor)) {
|
|
449
|
+
// If the ancestor is not `position:relative`, then we stop at
|
|
450
|
+
// _its_ offset parent, and we subtract off _its_ offset, so that
|
|
451
|
+
// we end up with the proper offset from child to ancestor.
|
|
452
|
+
sum -= ancestor[prop];
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
child = child.offsetParent as HTMLElement;
|
|
456
|
+
}
|
|
457
|
+
return sum;
|
|
458
|
+
}
|
package/src/useSelectableItem.ts
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import {focusSafely} from '@react-aria/focus';
|
|
14
|
-
import {HTMLAttributes, Key, RefObject, useEffect} from 'react';
|
|
14
|
+
import {HTMLAttributes, Key, RefObject, useEffect, useRef} from 'react';
|
|
15
|
+
import {isCtrlKeyPressed, isNonContiguousSelectionModifier} from './utils';
|
|
16
|
+
import {LongPressEvent, PressEvent} from '@react-types/shared';
|
|
17
|
+
import {mergeProps} from '@react-aria/utils';
|
|
15
18
|
import {MultipleSelectionManager} from '@react-stately/selection';
|
|
16
|
-
import {
|
|
17
|
-
import {PressProps} from '@react-aria/interactions';
|
|
19
|
+
import {PressProps, useLongPress, usePress} from '@react-aria/interactions';
|
|
18
20
|
|
|
19
21
|
interface SelectableItemOptions {
|
|
20
22
|
/**
|
|
@@ -45,14 +47,23 @@ interface SelectableItemOptions {
|
|
|
45
47
|
/**
|
|
46
48
|
* Whether the option should use virtual focus instead of being focused directly.
|
|
47
49
|
*/
|
|
48
|
-
shouldUseVirtualFocus?: boolean
|
|
50
|
+
shouldUseVirtualFocus?: boolean,
|
|
51
|
+
/** Whether the item is disabled. */
|
|
52
|
+
isDisabled?: boolean,
|
|
53
|
+
/**
|
|
54
|
+
* Handler that is called when a user performs an action on the cell. The exact user event depends on
|
|
55
|
+
* the collection's `selectionBehavior` prop and the interaction modality.
|
|
56
|
+
*/
|
|
57
|
+
onAction?: () => void
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
interface SelectableItemAria {
|
|
52
61
|
/**
|
|
53
62
|
* Props to be spread on the item root node.
|
|
54
63
|
*/
|
|
55
|
-
itemProps: HTMLAttributes<HTMLElement
|
|
64
|
+
itemProps: HTMLAttributes<HTMLElement>,
|
|
65
|
+
/** Whether the item is currently in a pressed state. */
|
|
66
|
+
isPressed: boolean
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/**
|
|
@@ -66,10 +77,35 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
|
|
|
66
77
|
shouldSelectOnPressUp,
|
|
67
78
|
isVirtualized,
|
|
68
79
|
shouldUseVirtualFocus,
|
|
69
|
-
focus
|
|
80
|
+
focus,
|
|
81
|
+
isDisabled,
|
|
82
|
+
onAction
|
|
70
83
|
} = options;
|
|
71
84
|
|
|
72
|
-
let onSelect = (e: PressEvent | PointerEvent) =>
|
|
85
|
+
let onSelect = (e: PressEvent | LongPressEvent | PointerEvent) => {
|
|
86
|
+
if (e.pointerType === 'keyboard' && isNonContiguousSelectionModifier(e)) {
|
|
87
|
+
manager.toggleSelection(key);
|
|
88
|
+
} else {
|
|
89
|
+
if (manager.selectionMode === 'none') {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (manager.selectionMode === 'single') {
|
|
94
|
+
if (manager.isSelected(key) && !manager.disallowEmptySelection) {
|
|
95
|
+
manager.toggleSelection(key);
|
|
96
|
+
} else {
|
|
97
|
+
manager.replaceSelection(key);
|
|
98
|
+
}
|
|
99
|
+
} else if (e && e.shiftKey) {
|
|
100
|
+
manager.extendSelection(key);
|
|
101
|
+
} else if (manager.selectionBehavior === 'toggle' || (e && (isCtrlKeyPressed(e) || e.pointerType === 'touch' || e.pointerType === 'virtual'))) {
|
|
102
|
+
// if touch or virtual (VO) then we just want to toggle, otherwise it's impossible to multi select because they don't have modifier keys
|
|
103
|
+
manager.toggleSelection(key);
|
|
104
|
+
} else {
|
|
105
|
+
manager.replaceSelection(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
73
109
|
|
|
74
110
|
// Focus the associated DOM node when this item becomes the focusedKey
|
|
75
111
|
let isFocused = key === manager.focusedKey;
|
|
@@ -98,6 +134,11 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
|
|
|
98
134
|
};
|
|
99
135
|
}
|
|
100
136
|
|
|
137
|
+
let modality = useRef(null);
|
|
138
|
+
let hasPrimaryAction = onAction && manager.selectionMode === 'none';
|
|
139
|
+
let hasSecondaryAction = onAction && manager.selectionMode !== 'none' && manager.selectionBehavior === 'replace';
|
|
140
|
+
let allowsSelection = !isDisabled && manager.canSelectItem(key);
|
|
141
|
+
|
|
101
142
|
// By default, selection occurs on pointer down. This can be strange if selecting an
|
|
102
143
|
// item causes the UI to disappear immediately (e.g. menus).
|
|
103
144
|
// If shouldSelectOnPressUp is true, we use onPressUp instead of onPressStart.
|
|
@@ -105,29 +146,40 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
|
|
|
105
146
|
// we want to be able to have the pointer down on the trigger that opens the menu and
|
|
106
147
|
// the pointer up on the menu item rather than requiring a separate press.
|
|
107
148
|
// For keyboard events, selection still occurs on key down.
|
|
149
|
+
let itemPressProps: PressProps = {};
|
|
108
150
|
if (shouldSelectOnPressUp) {
|
|
109
|
-
|
|
151
|
+
itemPressProps.onPressStart = (e) => {
|
|
152
|
+
modality.current = e.pointerType;
|
|
110
153
|
if (e.pointerType === 'keyboard') {
|
|
111
154
|
onSelect(e);
|
|
112
155
|
}
|
|
113
156
|
};
|
|
114
157
|
|
|
115
|
-
|
|
158
|
+
itemPressProps.onPressUp = (e) => {
|
|
116
159
|
if (e.pointerType !== 'keyboard') {
|
|
117
160
|
onSelect(e);
|
|
118
161
|
}
|
|
119
162
|
};
|
|
163
|
+
|
|
164
|
+
itemPressProps.onPress = hasPrimaryAction ? () => onAction() : null;
|
|
120
165
|
} else {
|
|
121
166
|
// On touch, it feels strange to select on touch down, so we special case this.
|
|
122
|
-
|
|
123
|
-
|
|
167
|
+
itemPressProps.onPressStart = (e) => {
|
|
168
|
+
modality.current = e.pointerType;
|
|
169
|
+
if (e.pointerType !== 'touch' && e.pointerType !== 'virtual') {
|
|
124
170
|
onSelect(e);
|
|
125
171
|
}
|
|
126
172
|
};
|
|
127
173
|
|
|
128
|
-
|
|
129
|
-
if (e.pointerType === 'touch') {
|
|
130
|
-
|
|
174
|
+
itemPressProps.onPress = (e) => {
|
|
175
|
+
if (e.pointerType === 'touch' || e.pointerType === 'virtual' || hasPrimaryAction) {
|
|
176
|
+
// Single tap on touch with selectionBehavior = 'replace' performs an action, i.e. navigation.
|
|
177
|
+
// Also perform action on press up when selectionMode = 'none'.
|
|
178
|
+
if (hasPrimaryAction || hasSecondaryAction) {
|
|
179
|
+
onAction();
|
|
180
|
+
} else {
|
|
181
|
+
onSelect(e);
|
|
182
|
+
}
|
|
131
183
|
}
|
|
132
184
|
};
|
|
133
185
|
}
|
|
@@ -136,7 +188,46 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
|
|
|
136
188
|
itemProps['data-key'] = key;
|
|
137
189
|
}
|
|
138
190
|
|
|
191
|
+
itemPressProps.preventFocusOnPress = shouldUseVirtualFocus;
|
|
192
|
+
let {pressProps, isPressed} = usePress(itemPressProps);
|
|
193
|
+
|
|
194
|
+
// Double clicking with a mouse with selectionBehavior = 'replace' performs an action.
|
|
195
|
+
let onDoubleClick = hasSecondaryAction ? (e) => {
|
|
196
|
+
if (modality.current === 'mouse') {
|
|
197
|
+
e.stopPropagation();
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
onAction();
|
|
200
|
+
}
|
|
201
|
+
} : undefined;
|
|
202
|
+
|
|
203
|
+
// Long pressing an item with touch when selectionBehavior = 'replace' switches the selection behavior
|
|
204
|
+
// to 'toggle'. This changes the single tap behavior from performing an action (i.e. navigating) to
|
|
205
|
+
// selecting, and may toggle the appearance of a UI affordance like checkboxes on each item.
|
|
206
|
+
// TODO: what about when drag and drop is also enabled??
|
|
207
|
+
let {longPressProps} = useLongPress({
|
|
208
|
+
isDisabled: !hasSecondaryAction,
|
|
209
|
+
onLongPress(e) {
|
|
210
|
+
if (e.pointerType === 'touch') {
|
|
211
|
+
onSelect(e);
|
|
212
|
+
manager.setSelectionBehavior('toggle');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Pressing the Enter key with selectionBehavior = 'replace' performs an action (i.e. navigation).
|
|
218
|
+
let onKeyUp = hasSecondaryAction ? (e: KeyboardEvent) => {
|
|
219
|
+
if (e.key === 'Enter') {
|
|
220
|
+
onAction();
|
|
221
|
+
}
|
|
222
|
+
} : undefined;
|
|
223
|
+
|
|
139
224
|
return {
|
|
140
|
-
itemProps
|
|
225
|
+
itemProps: mergeProps(
|
|
226
|
+
itemProps,
|
|
227
|
+
allowsSelection || hasPrimaryAction ? pressProps : {},
|
|
228
|
+
hasSecondaryAction ? longPressProps : {},
|
|
229
|
+
{onKeyUp, onDoubleClick}
|
|
230
|
+
),
|
|
231
|
+
isPressed
|
|
141
232
|
};
|
|
142
233
|
}
|
package/src/useSelectableList.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import {Collection, FocusStrategy, KeyboardDelegate, Node} from '@react-types/shared';
|
|
14
|
-
import {HTMLAttributes, Key, RefObject,
|
|
14
|
+
import {HTMLAttributes, Key, RefObject, useMemo} from 'react';
|
|
15
15
|
import {ListKeyboardDelegate} from './ListKeyboardDelegate';
|
|
16
16
|
import {MultipleSelectionManager} from '@react-stately/selection';
|
|
17
17
|
import {useCollator} from '@react-aria/i18n';
|
|
@@ -109,17 +109,6 @@ export function useSelectableList(props: SelectableListOptions): SelectableListA
|
|
|
109
109
|
let collator = useCollator({usage: 'search', sensitivity: 'base'});
|
|
110
110
|
let delegate = useMemo(() => keyboardDelegate || new ListKeyboardDelegate(collection, disabledKeys, ref, collator), [keyboardDelegate, collection, disabledKeys, ref, collator]);
|
|
111
111
|
|
|
112
|
-
// If not virtualized, scroll the focused element into view when the focusedKey changes.
|
|
113
|
-
// When virtualized, Virtualizer handles this internally.
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
if (!isVirtualized && selectionManager.focusedKey && ref?.current) {
|
|
116
|
-
let element = ref.current.querySelector(`[data-key="${selectionManager.focusedKey}"]`) as HTMLElement;
|
|
117
|
-
if (element) {
|
|
118
|
-
scrollIntoView(ref.current, element);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}, [isVirtualized, ref, selectionManager.focusedKey]);
|
|
122
|
-
|
|
123
112
|
let {collectionProps} = useSelectableCollection({
|
|
124
113
|
ref,
|
|
125
114
|
selectionManager,
|
|
@@ -130,40 +119,12 @@ export function useSelectableList(props: SelectableListOptions): SelectableListA
|
|
|
130
119
|
selectOnFocus,
|
|
131
120
|
disallowTypeAhead,
|
|
132
121
|
shouldUseVirtualFocus,
|
|
133
|
-
allowsTabNavigation
|
|
122
|
+
allowsTabNavigation,
|
|
123
|
+
isVirtualized,
|
|
124
|
+
scrollRef: ref
|
|
134
125
|
});
|
|
135
126
|
|
|
136
127
|
return {
|
|
137
128
|
listProps: collectionProps
|
|
138
129
|
};
|
|
139
130
|
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Scrolls `scrollView` so that `element` is visible.
|
|
143
|
-
* Similar to `element.scrollIntoView({block: 'nearest'})` (not supported in Edge),
|
|
144
|
-
* but doesn't affect parents above `scrollView`.
|
|
145
|
-
*/
|
|
146
|
-
function scrollIntoView(scrollView: HTMLElement, element: HTMLElement) {
|
|
147
|
-
let offsetX = element.offsetLeft - scrollView.offsetLeft;
|
|
148
|
-
let offsetY = element.offsetTop - scrollView.offsetTop;
|
|
149
|
-
let width = element.offsetWidth;
|
|
150
|
-
let height = element.offsetHeight;
|
|
151
|
-
let x = scrollView.scrollLeft;
|
|
152
|
-
let y = scrollView.scrollTop;
|
|
153
|
-
let maxX = x + scrollView.offsetWidth;
|
|
154
|
-
let maxY = y + scrollView.offsetHeight;
|
|
155
|
-
|
|
156
|
-
if (offsetX <= x) {
|
|
157
|
-
x = offsetX;
|
|
158
|
-
} else if (offsetX + width > maxX) {
|
|
159
|
-
x += offsetX + width - maxX;
|
|
160
|
-
}
|
|
161
|
-
if (offsetY <= y) {
|
|
162
|
-
y = offsetY;
|
|
163
|
-
} else if (offsetY + height > maxY) {
|
|
164
|
-
y += offsetY + height - maxY;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
scrollView.scrollLeft = x;
|
|
168
|
-
scrollView.scrollTop = y;
|
|
169
|
-
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2020 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {isAppleDevice} from '@react-aria/utils';
|
|
14
|
+
import {isMac} from '@react-aria/utils';
|
|
15
|
+
|
|
16
|
+
interface Event {
|
|
17
|
+
altKey: boolean,
|
|
18
|
+
ctrlKey: boolean,
|
|
19
|
+
metaKey: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isNonContiguousSelectionModifier(e: Event) {
|
|
23
|
+
// Ctrl + Arrow Up/Arrow Down has a system wide meaning on macOS, so use Alt instead.
|
|
24
|
+
// On Windows and Ubuntu, Alt + Space has a system wide meaning.
|
|
25
|
+
return isAppleDevice() ? e.altKey : e.ctrlKey;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isCtrlKeyPressed(e: Event) {
|
|
29
|
+
if (isMac()) {
|
|
30
|
+
return e.metaKey;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return e.ctrlKey;
|
|
34
|
+
}
|