@react-aria/selection 3.5.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {PressEvent} from '@react-types/shared';
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> & PressProps
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) => manager.select(key, e);
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
- itemProps.onPressStart = (e) => {
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
- itemProps.onPressUp = (e) => {
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
- itemProps.onPressStart = (e) => {
123
- if (e.pointerType !== 'touch') {
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
- itemProps.onPress = (e) => {
129
- if (e.pointerType === 'touch') {
130
- onSelect(e);
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
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import {Collection, FocusStrategy, KeyboardDelegate, Node} from '@react-types/shared';
14
- import {HTMLAttributes, Key, RefObject, useEffect, useMemo} from 'react';
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,64 +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 = relativeOffset(scrollView, element, 'left');
148
- let offsetY = relativeOffset(scrollView, element, 'top');
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
- }
170
-
171
- /**
172
- * Computes the offset left or top from child to ancestor by accumulating
173
- * offsetLeft or offsetTop through intervening offsetParents.
174
- */
175
- function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|'top') {
176
- const prop = axis === 'left' ? 'offsetLeft' : 'offsetTop';
177
- let sum = 0;
178
- while (child.offsetParent) {
179
- sum += child[prop];
180
- if (child.offsetParent === ancestor) {
181
- // Stop once we have found the ancestor we are interested in.
182
- break;
183
- } else if (child.offsetParent.contains(ancestor)) {
184
- // If the ancestor is not `position:relative`, then we stop at
185
- // _its_ offset parent, and we subtract off _its_ offset, so that
186
- // we end up with the proper offset from child to ancestor.
187
- sum -= ancestor[prop];
188
- break;
189
- }
190
- child = child.offsetParent as HTMLElement;
191
- }
192
- return sum;
193
- }
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
+ }