@react-aria/selection 3.14.0 → 3.16.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.
@@ -10,23 +10,57 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {Collection, KeyboardDelegate, Node} from '@react-types/shared';
13
+ import {Collection, Direction, KeyboardDelegate, Node, Orientation} from '@react-types/shared';
14
+ import {isScrollable} from '@react-aria/utils';
14
15
  import {Key, RefObject} from 'react';
15
16
 
17
+ interface ListKeyboardDelegateOptions<T> {
18
+ collection: Collection<Node<T>>,
19
+ ref: RefObject<HTMLElement>,
20
+ layout?: 'stack' | 'grid',
21
+ orientation?: Orientation,
22
+ direction?: Direction,
23
+ disabledKeys?: Set<Key>
24
+ }
25
+
16
26
  export class ListKeyboardDelegate<T> implements KeyboardDelegate {
17
27
  private collection: Collection<Node<T>>;
18
28
  private disabledKeys: Set<Key>;
19
29
  private ref: RefObject<HTMLElement>;
20
30
  private collator: Intl.Collator;
31
+ private layout: 'stack' | 'grid';
32
+ private orientation?: Orientation;
33
+ private direction?: Direction;
34
+
35
+ constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement>, collator?: Intl.Collator);
36
+ constructor(options: ListKeyboardDelegateOptions<T>);
37
+ constructor(...args: any[]) {
38
+ if (args.length === 1) {
39
+ let opts = args[0] as ListKeyboardDelegateOptions<T>;
40
+ this.collection = opts.collection;
41
+ this.ref = opts.ref;
42
+ this.disabledKeys = opts.disabledKeys || new Set();
43
+ this.orientation = opts.orientation;
44
+ this.direction = opts.direction;
45
+ this.layout = opts.layout || 'stack';
46
+ } else {
47
+ this.collection = args[0];
48
+ this.disabledKeys = args[1];
49
+ this.ref = args[2];
50
+ this.collator = args[3];
51
+ this.layout = 'stack';
52
+ this.orientation = 'vertical';
53
+ }
21
54
 
22
- constructor(collection: Collection<Node<T>>, disabledKeys: Set<Key>, ref: RefObject<HTMLElement>, collator?: Intl.Collator) {
23
- this.collection = collection;
24
- this.disabledKeys = disabledKeys;
25
- this.ref = ref;
26
- this.collator = collator;
55
+ // If this is a vertical stack, remove the left/right methods completely
56
+ // so they aren't called by useDroppableCollection.
57
+ if (this.layout === 'stack' && this.orientation === 'vertical') {
58
+ this.getKeyLeftOf = undefined;
59
+ this.getKeyRightOf = undefined;
60
+ }
27
61
  }
28
62
 
29
- getKeyBelow(key: Key) {
63
+ getNextKey(key: Key) {
30
64
  key = this.collection.getKeyAfter(key);
31
65
  while (key != null) {
32
66
  let item = this.collection.getItem(key);
@@ -40,7 +74,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
40
74
  return null;
41
75
  }
42
76
 
43
- getKeyAbove(key: Key) {
77
+ getPreviousKey(key: Key) {
44
78
  key = this.collection.getKeyBefore(key);
45
79
  while (key != null) {
46
80
  let item = this.collection.getItem(key);
@@ -54,6 +88,82 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
54
88
  return null;
55
89
  }
56
90
 
91
+ private findKey(
92
+ key: Key,
93
+ nextKey: (key: Key) => Key,
94
+ shouldSkip: (prevRect: DOMRect, itemRect: DOMRect) => boolean
95
+ ) {
96
+ let item = this.getItem(key);
97
+ if (!item) {
98
+ return null;
99
+ }
100
+
101
+ // Find the item above or below in the same column.
102
+ let prevRect = item.getBoundingClientRect();
103
+ do {
104
+ key = nextKey(key);
105
+ item = this.getItem(key);
106
+ } while (item && shouldSkip(prevRect, item.getBoundingClientRect()));
107
+
108
+ return key;
109
+ }
110
+
111
+ private isSameRow(prevRect: DOMRect, itemRect: DOMRect) {
112
+ return prevRect.top === itemRect.top || prevRect.left !== itemRect.left;
113
+ }
114
+
115
+ private isSameColumn(prevRect: DOMRect, itemRect: DOMRect) {
116
+ return prevRect.left === itemRect.left || prevRect.top !== itemRect.top;
117
+ }
118
+
119
+ getKeyBelow(key: Key) {
120
+ if (this.layout === 'grid' && this.orientation === 'vertical') {
121
+ return this.findKey(key, (key) => this.getNextKey(key), this.isSameRow);
122
+ } else {
123
+ return this.getNextKey(key);
124
+ }
125
+ }
126
+
127
+ getKeyAbove(key: Key) {
128
+ if (this.layout === 'grid' && this.orientation === 'vertical') {
129
+ return this.findKey(key, (key) => this.getPreviousKey(key), this.isSameRow);
130
+ } else {
131
+ return this.getPreviousKey(key);
132
+ }
133
+ }
134
+
135
+ private getNextColumn(key: Key, right: boolean) {
136
+ return right ? this.getPreviousKey(key) : this.getNextKey(key);
137
+ }
138
+
139
+ getKeyRightOf(key: Key) {
140
+ if (this.layout === 'grid') {
141
+ if (this.orientation === 'vertical') {
142
+ return this.getNextColumn(key, this.direction === 'rtl');
143
+ } else {
144
+ return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'rtl'), this.isSameColumn);
145
+ }
146
+ } else if (this.orientation === 'horizontal') {
147
+ return this.getNextColumn(key, this.direction === 'rtl');
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ getKeyLeftOf(key: Key) {
154
+ if (this.layout === 'grid') {
155
+ if (this.orientation === 'vertical') {
156
+ return this.getNextColumn(key, this.direction === 'ltr');
157
+ } else {
158
+ return this.findKey(key, (key) => this.getNextColumn(key, this.direction === 'ltr'), this.isSameColumn);
159
+ }
160
+ } else if (this.orientation === 'horizontal') {
161
+ return this.getNextColumn(key, this.direction === 'ltr');
162
+ }
163
+
164
+ return null;
165
+ }
166
+
57
167
  getFirstKey() {
58
168
  let key = this.collection.getFirstKey();
59
169
  while (key != null) {
@@ -93,14 +203,33 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
93
203
  return null;
94
204
  }
95
205
 
96
- let pageY = Math.max(0, item.offsetTop + item.offsetHeight - menu.offsetHeight);
206
+ if (!isScrollable(menu)) {
207
+ return this.getFirstKey();
208
+ }
209
+
210
+ let containerRect = menu.getBoundingClientRect();
211
+ let itemRect = item.getBoundingClientRect();
212
+ if (this.orientation === 'horizontal') {
213
+ let containerX = containerRect.x - menu.scrollLeft;
214
+ let pageX = Math.max(0, (itemRect.x - containerX) + itemRect.width - containerRect.width);
215
+
216
+ while (item && (itemRect.x - containerX) > pageX) {
217
+ key = this.getKeyAbove(key);
218
+ item = key == null ? null : this.getItem(key);
219
+ itemRect = item?.getBoundingClientRect();
220
+ }
221
+ } else {
222
+ let containerY = containerRect.y - menu.scrollTop;
223
+ let pageY = Math.max(0, (itemRect.y - containerY) + itemRect.height - containerRect.height);
97
224
 
98
- while (item && item.offsetTop > pageY) {
99
- key = this.getKeyAbove(key);
100
- item = key == null ? null : this.getItem(key);
225
+ while (item && (itemRect.y - containerY) > pageY) {
226
+ key = this.getKeyAbove(key);
227
+ item = key == null ? null : this.getItem(key);
228
+ itemRect = item?.getBoundingClientRect();
229
+ }
101
230
  }
102
231
 
103
- return key;
232
+ return key ?? this.getFirstKey();
104
233
  }
105
234
 
106
235
  getKeyPageBelow(key: Key) {
@@ -110,14 +239,33 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
110
239
  return null;
111
240
  }
112
241
 
113
- let pageY = Math.min(menu.scrollHeight, item.offsetTop - item.offsetHeight + menu.offsetHeight);
242
+ if (!isScrollable(menu)) {
243
+ return this.getLastKey();
244
+ }
114
245
 
115
- while (item && item.offsetTop < pageY) {
116
- key = this.getKeyBelow(key);
117
- item = key == null ? null : this.getItem(key);
246
+ let containerRect = menu.getBoundingClientRect();
247
+ let itemRect = item.getBoundingClientRect();
248
+ if (this.orientation === 'horizontal') {
249
+ let containerX = containerRect.x - menu.scrollLeft;
250
+ let pageX = Math.min(menu.scrollWidth, (itemRect.x - containerX) - itemRect.width + containerRect.width);
251
+
252
+ while (item && (itemRect.x - containerX) < pageX) {
253
+ key = this.getKeyBelow(key);
254
+ item = key == null ? null : this.getItem(key);
255
+ itemRect = item?.getBoundingClientRect();
256
+ }
257
+ } else {
258
+ let containerY = containerRect.y - menu.scrollTop;
259
+ let pageY = Math.min(menu.scrollHeight, (itemRect.y - containerY) - itemRect.height + containerRect.height);
260
+
261
+ while (item && (itemRect.y - containerY) < pageY) {
262
+ key = this.getKeyBelow(key);
263
+ item = key == null ? null : this.getItem(key);
264
+ itemRect = item?.getBoundingClientRect();
265
+ }
118
266
  }
119
267
 
120
- return key;
268
+ return key ?? this.getLastKey();
121
269
  }
122
270
 
123
271
  getKeyForSearch(search: string, fromKey?: Key) {
@@ -165,6 +165,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
165
165
  if (delegate.getKeyLeftOf) {
166
166
  e.preventDefault();
167
167
  let nextKey = delegate.getKeyLeftOf(manager.focusedKey);
168
+ if (nextKey == null && shouldFocusWrap) {
169
+ nextKey = direction === 'rtl' ? delegate.getFirstKey?.(manager.focusedKey) : delegate.getLastKey?.(manager.focusedKey);
170
+ }
168
171
  navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last');
169
172
  }
170
173
  break;
@@ -173,6 +176,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
173
176
  if (delegate.getKeyRightOf) {
174
177
  e.preventDefault();
175
178
  let nextKey = delegate.getKeyRightOf(manager.focusedKey);
179
+ if (nextKey == null && shouldFocusWrap) {
180
+ nextKey = direction === 'rtl' ? delegate.getLastKey?.(manager.focusedKey) : delegate.getFirstKey?.(manager.focusedKey);
181
+ }
176
182
  navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first');
177
183
  }
178
184
  break;
@@ -314,7 +320,11 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
314
320
  if (element) {
315
321
  // This prevents a flash of focus on the first/last element in the collection, or the collection itself.
316
322
  focusWithoutScrolling(element);
317
- scrollIntoView(scrollRef.current, element);
323
+
324
+ let modality = getInteractionModality();
325
+ if (modality === 'keyboard') {
326
+ scrollIntoViewport(element, {containingElement: ref.current});
327
+ }
318
328
  }
319
329
  }
320
330
  };
@@ -358,17 +368,25 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
358
368
 
359
369
  // If not virtualized, scroll the focused element into view when the focusedKey changes.
360
370
  // When virtualized, Virtualizer handles this internally.
371
+ let lastFocusedKey = useRef(manager.focusedKey);
361
372
  useEffect(() => {
362
373
  let modality = getInteractionModality();
363
- if (!isVirtualized && manager.isFocused && manager.focusedKey != null && scrollRef?.current) {
374
+ if (manager.isFocused && manager.focusedKey != null && scrollRef?.current) {
364
375
  let element = scrollRef.current.querySelector(`[data-key="${manager.focusedKey}"]`) as HTMLElement;
365
- if (element) {
366
- scrollIntoView(scrollRef.current, element);
367
- if (modality === 'keyboard') {
368
- scrollIntoViewport(element, {containingElement: ref.current});
376
+ if (element && modality === 'keyboard') {
377
+ if (!isVirtualized) {
378
+ scrollIntoView(scrollRef.current, element);
369
379
  }
380
+ scrollIntoViewport(element, {containingElement: ref.current});
370
381
  }
371
382
  }
383
+
384
+ // If the focused key becomes null (e.g. the last item is deleted), focus the whole collection.
385
+ if (manager.isFocused && manager.focusedKey == null && lastFocusedKey.current != null) {
386
+ focusSafely(ref.current);
387
+ }
388
+
389
+ lastFocusedKey.current = manager.focusedKey;
372
390
  }, [isVirtualized, scrollRef, manager.focusedKey, manager.isFocused, ref]);
373
391
 
374
392
  let handlers = {
@@ -103,7 +103,6 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
103
103
  key,
104
104
  ref,
105
105
  shouldSelectOnPressUp,
106
- isVirtualized,
107
106
  shouldUseVirtualFocus,
108
107
  focus,
109
108
  isDisabled,
@@ -266,10 +265,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
266
265
  };
267
266
  }
268
267
 
269
- if (!isVirtualized) {
270
- itemProps['data-key'] = key;
271
- }
272
-
268
+ itemProps['data-key'] = key;
273
269
  itemPressProps.preventFocusOnPress = shouldUseVirtualFocus;
274
270
  let {pressProps, isPressed} = usePress(itemPressProps);
275
271
 
@@ -53,7 +53,7 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
53
53
 
54
54
  let onKeyDown = (e: KeyboardEvent) => {
55
55
  let character = getStringForKey(e.key);
56
- if (!character || e.ctrlKey || e.metaKey) {
56
+ if (!character || e.ctrlKey || e.metaKey || !e.currentTarget.contains(e.target as HTMLElement)) {
57
57
  return;
58
58
  }
59
59
 
package/src/utils.ts CHANGED
@@ -10,8 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {isAppleDevice} from '@react-aria/utils';
14
- import {isMac} from '@react-aria/utils';
13
+ import {isAppleDevice, isMac} from '@react-aria/utils';
15
14
 
16
15
  interface Event {
17
16
  altKey: boolean,