@react-aria/landmark 3.0.0-alpha.4 → 3.0.0-alpha.6

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/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@react-aria/landmark",
3
- "version": "3.0.0-alpha.4",
3
+ "version": "3.0.0-alpha.6",
4
4
  "description": "Spectrum UI components in React",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/main.js",
7
7
  "module": "dist/module.js",
8
+ "exports": {
9
+ "types": "./dist/types.d.ts",
10
+ "import": "./dist/import.mjs",
11
+ "require": "./dist/main.js"
12
+ },
8
13
  "types": "dist/types.d.ts",
9
14
  "source": "src/index.ts",
10
15
  "files": [
@@ -17,10 +22,11 @@
17
22
  "url": "https://github.com/adobe/react-spectrum"
18
23
  },
19
24
  "dependencies": {
20
- "@babel/runtime": "^7.6.2",
21
- "@react-aria/focus": "^3.10.0",
22
- "@react-aria/utils": "^3.14.1",
23
- "@react-types/shared": "^3.16.0"
25
+ "@react-aria/focus": "^3.11.0",
26
+ "@react-aria/utils": "^3.15.0",
27
+ "@react-types/shared": "^3.17.0",
28
+ "@swc/helpers": "^0.4.14",
29
+ "use-sync-external-store": "^1.2.0"
24
30
  },
25
31
  "peerDependencies": {
26
32
  "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
@@ -28,5 +34,5 @@
28
34
  "publishConfig": {
29
35
  "access": "public"
30
36
  },
31
- "gitHead": "2954307ddbefe149241685440c81f80ece6b2c83"
37
+ "gitHead": "a0efee84aa178cb1a202951dfd6d8de02b292307"
32
38
  }
package/src/index.ts CHANGED
@@ -10,5 +10,5 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- export type {AriaLandmarkRole, AriaLandmarkProps, LandmarkAria} from './useLandmark';
14
- export {useLandmark} from './useLandmark';
13
+ export type {AriaLandmarkRole, AriaLandmarkProps, LandmarkAria, LandmarkController} from './useLandmark';
14
+ export {useLandmark, createLandmarkController} from './useLandmark';
@@ -13,81 +13,149 @@
13
13
  import {AriaLabelingProps, DOMAttributes, FocusableElement} from '@react-types/shared';
14
14
  import {MutableRefObject, useCallback, useEffect, useState} from 'react';
15
15
  import {useLayoutEffect} from '@react-aria/utils';
16
+ import {useSyncExternalStore} from 'use-sync-external-store/shim';
16
17
 
17
18
  export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary';
18
19
 
19
20
  export interface AriaLandmarkProps extends AriaLabelingProps {
20
- role: AriaLandmarkRole
21
+ role: AriaLandmarkRole,
22
+ focus?: (direction: 'forward' | 'backward') => void
21
23
  }
22
24
 
23
25
  export interface LandmarkAria {
24
26
  landmarkProps: DOMAttributes
25
27
  }
26
28
 
27
- type Landmark = {
29
+ // Increment this version number whenever the
30
+ // LandmarkManagerApi or Landmark interfaces change.
31
+ const LANDMARK_API_VERSION = 1;
32
+
33
+ // Minimal API for LandmarkManager that must continue to work between versions.
34
+ // Changes to this interface are considered breaking. New methods/properties are
35
+ // safe to add, but changes or removals are not allowed (same as public APIs).
36
+ interface LandmarkManagerApi {
37
+ version: number,
38
+ createLandmarkController(): LandmarkController,
39
+ registerLandmark(landmark: Landmark): () => void
40
+ }
41
+
42
+ // Changes to this interface are considered breaking.
43
+ // New properties MUST be optional so that registering a landmark
44
+ // from an older version of useLandmark against a newer version of
45
+ // LandmarkManager does not crash.
46
+ interface Landmark {
28
47
  ref: MutableRefObject<Element>,
29
48
  role: AriaLandmarkRole,
30
49
  label?: string,
31
50
  lastFocused?: FocusableElement,
32
- focus: () => void,
51
+ focus: (direction: 'forward' | 'backward') => void,
33
52
  blur: () => void
34
- };
53
+ }
54
+
55
+ export interface LandmarkControllerOptions {
56
+ /**
57
+ * The element from which to start navigating.
58
+ * @default document.activeElement
59
+ */
60
+ from?: Element
61
+ }
62
+
63
+ /** A LandmarkController allows programmatic navigation of landmarks. */
64
+ export interface LandmarkController {
65
+ /** Moves focus to the next landmark. */
66
+ focusNext(opts?: LandmarkControllerOptions): boolean,
67
+ /** Moves focus to the previous landmark. */
68
+ focusPrevious(opts?: LandmarkControllerOptions): boolean,
69
+ /** Moves focus to the main landmark. */
70
+ focusMain(): boolean,
71
+ /** Moves focus either forward or backward in the landmark sequence. */
72
+ navigate(direction: 'forward' | 'backward', opts?: LandmarkControllerOptions): boolean,
73
+ /**
74
+ * Disposes the landmark controller. When no landmarks are registered, and no
75
+ * controllers are active, the landmark keyboard listeners are removed from the page.
76
+ */
77
+ dispose(): void
78
+ }
79
+
80
+ // Symbol under which the singleton landmark manager instance is attached to the document.
81
+ const landmarkSymbol = Symbol.for('react-aria-landmark-manager');
82
+
83
+ function subscribe(fn: () => void) {
84
+ document.addEventListener('react-aria-landmark-manager-change', fn);
85
+ return () => document.removeEventListener('react-aria-landmark-manager-change', fn);
86
+ }
87
+
88
+ function getLandmarkManager(): LandmarkManagerApi {
89
+ // Reuse an existing instance if it has the same or greater version.
90
+ let instance = document[landmarkSymbol];
91
+ if (instance && instance.version >= LANDMARK_API_VERSION) {
92
+ return instance;
93
+ }
94
+
95
+ // Otherwise, create a new instance and dispatch an event so anything using the existing
96
+ // instance updates and re-registers their landmarks with the new one.
97
+ document[landmarkSymbol] = new LandmarkManager();
98
+ document.dispatchEvent(new CustomEvent('react-aria-landmark-manager-change'));
99
+ return document[landmarkSymbol];
100
+ }
101
+
102
+ // Subscribes a React component to the current landmark manager instance.
103
+ function useLandmarkManager(): LandmarkManagerApi {
104
+ return useSyncExternalStore(subscribe, getLandmarkManager);
105
+ }
35
106
 
36
- class LandmarkManager {
107
+ class LandmarkManager implements LandmarkManagerApi {
37
108
  private landmarks: Array<Landmark> = [];
38
- private static instance: LandmarkManager;
39
109
  private isListening = false;
110
+ private refCount = 0;
111
+ public version = LANDMARK_API_VERSION;
40
112
 
41
- private constructor() {
113
+ constructor() {
42
114
  this.f6Handler = this.f6Handler.bind(this);
43
115
  this.focusinHandler = this.focusinHandler.bind(this);
44
116
  this.focusoutHandler = this.focusoutHandler.bind(this);
45
117
  }
46
118
 
47
- public static getInstance(): LandmarkManager {
48
- if (!LandmarkManager.instance) {
49
- LandmarkManager.instance = new LandmarkManager();
119
+ private setupIfNeeded() {
120
+ if (this.isListening) {
121
+ return;
50
122
  }
51
-
52
- return LandmarkManager.instance;
53
- }
54
-
55
- private setup() {
56
123
  document.addEventListener('keydown', this.f6Handler, {capture: true});
57
124
  document.addEventListener('focusin', this.focusinHandler, {capture: true});
58
125
  document.addEventListener('focusout', this.focusoutHandler, {capture: true});
59
126
  this.isListening = true;
60
127
  }
61
128
 
62
- private teardown() {
129
+ private teardownIfNeeded() {
130
+ if (!this.isListening || this.landmarks.length > 0 || this.refCount > 0) {
131
+ return;
132
+ }
63
133
  document.removeEventListener('keydown', this.f6Handler, {capture: true});
64
134
  document.removeEventListener('focusin', this.focusinHandler, {capture: true});
65
135
  document.removeEventListener('focusout', this.focusoutHandler, {capture: true});
66
136
  this.isListening = false;
67
137
  }
68
138
 
69
- private focusLandmark(landmark: Element) {
70
- this.landmarks.find(l => l.ref.current === landmark)?.focus();
139
+ private focusLandmark(landmark: Element, direction: 'forward' | 'backward') {
140
+ this.landmarks.find(l => l.ref.current === landmark)?.focus(direction);
71
141
  }
72
142
 
73
143
  /**
74
144
  * Return set of landmarks with a specific role.
75
145
  */
76
- public getLandmarksByRole(role: AriaLandmarkRole) {
146
+ private getLandmarksByRole(role: AriaLandmarkRole) {
77
147
  return new Set(this.landmarks.filter(l => l.role === role));
78
148
  }
79
149
 
80
150
  /**
81
151
  * Return first landmark with a specific role.
82
152
  */
83
- public getLandmarkByRole(role: AriaLandmarkRole) {
153
+ private getLandmarkByRole(role: AriaLandmarkRole) {
84
154
  return this.landmarks.find(l => l.role === role);
85
155
  }
86
156
 
87
- public addLandmark(newLandmark: Landmark) {
88
- if (!this.isListening) {
89
- this.setup();
90
- }
157
+ private addLandmark(newLandmark: Landmark) {
158
+ this.setupIfNeeded();
91
159
  if (this.landmarks.find(landmark => landmark.ref === newLandmark.ref)) {
92
160
  return;
93
161
  }
@@ -98,6 +166,7 @@ class LandmarkManager {
98
166
 
99
167
  if (this.landmarks.length === 0) {
100
168
  this.landmarks = [newLandmark];
169
+ this.checkLabels(newLandmark.role);
101
170
  return;
102
171
  }
103
172
 
@@ -119,9 +188,10 @@ class LandmarkManager {
119
188
  }
120
189
 
121
190
  this.landmarks.splice(start, 0, newLandmark);
191
+ this.checkLabels(newLandmark.role);
122
192
  }
123
193
 
124
- public updateLandmark(landmark: Pick<Landmark, 'ref'> & Partial<Landmark>) {
194
+ private updateLandmark(landmark: Pick<Landmark, 'ref'> & Partial<Landmark>) {
125
195
  let index = this.landmarks.findIndex(l => l.ref === landmark.ref);
126
196
  if (index >= 0) {
127
197
  this.landmarks[index] = {...this.landmarks[index], ...landmark};
@@ -129,11 +199,9 @@ class LandmarkManager {
129
199
  }
130
200
  }
131
201
 
132
- public removeLandmark(ref: MutableRefObject<Element>) {
202
+ private removeLandmark(ref: MutableRefObject<Element>) {
133
203
  this.landmarks = this.landmarks.filter(landmark => landmark.ref !== ref);
134
- if (this.landmarks.length === 0) {
135
- this.teardown();
136
- }
204
+ this.teardownIfNeeded();
137
205
  }
138
206
 
139
207
  /**
@@ -172,7 +240,7 @@ class LandmarkManager {
172
240
  private closestLandmark(element: Element) {
173
241
  let landmarkMap = new Map(this.landmarks.map(l => [l.ref.current, l]));
174
242
  let currentElement = element;
175
- while (!landmarkMap.has(currentElement) && currentElement !== document.body) {
243
+ while (currentElement && !landmarkMap.has(currentElement) && currentElement !== document.body) {
176
244
  currentElement = currentElement.parentElement;
177
245
  }
178
246
  return landmarkMap.get(currentElement);
@@ -184,22 +252,52 @@ class LandmarkManager {
184
252
  * If not inside a landmark, will return first landmark.
185
253
  * Returns undefined if there are no landmarks.
186
254
  */
187
- public getNextLandmark(element: Element, {backward}: {backward?: boolean }) {
188
- if (this.landmarks.length === 0) {
189
- return undefined;
190
- }
191
-
255
+ private getNextLandmark(element: Element, {backward}: {backward?: boolean }) {
192
256
  let currentLandmark = this.closestLandmark(element);
193
- let nextLandmarkIndex = backward ? -1 : 0;
257
+ let nextLandmarkIndex = backward ? this.landmarks.length - 1 : 0;
194
258
  if (currentLandmark) {
195
- nextLandmarkIndex = this.landmarks.findIndex(landmark => landmark === currentLandmark) + (backward ? -1 : 1);
259
+ nextLandmarkIndex = this.landmarks.indexOf(currentLandmark) + (backward ? -1 : 1);
196
260
  }
197
261
 
198
- // Wrap if necessary
199
- if (nextLandmarkIndex < 0) {
200
- nextLandmarkIndex = this.landmarks.length - 1;
201
- } else if (nextLandmarkIndex >= this.landmarks.length) {
202
- nextLandmarkIndex = 0;
262
+ let wrapIfNeeded = () => {
263
+ // When we reach the end of the landmark sequence, fire a custom event that can be listened for by applications.
264
+ // If this event is canceled, we return immediately. This can be used to implement landmark navigation across iframes.
265
+ if (nextLandmarkIndex < 0) {
266
+ if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', {detail: {direction: 'backward'}, bubbles: true, cancelable: true}))) {
267
+ return true;
268
+ }
269
+
270
+ nextLandmarkIndex = this.landmarks.length - 1;
271
+ } else if (nextLandmarkIndex >= this.landmarks.length) {
272
+ if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', {detail: {direction: 'forward'}, bubbles: true, cancelable: true}))) {
273
+ return true;
274
+ }
275
+
276
+ nextLandmarkIndex = 0;
277
+ }
278
+
279
+ if (nextLandmarkIndex < 0 || nextLandmarkIndex >= this.landmarks.length) {
280
+ return true;
281
+ }
282
+
283
+ return false;
284
+ };
285
+
286
+ if (wrapIfNeeded()) {
287
+ return undefined;
288
+ }
289
+
290
+ // Skip over hidden landmarks.
291
+ let i = nextLandmarkIndex;
292
+ while (this.landmarks[nextLandmarkIndex].ref.current.closest('[aria-hidden=true]')) {
293
+ nextLandmarkIndex += backward ? -1 : 1;
294
+ if (wrapIfNeeded()) {
295
+ return undefined;
296
+ }
297
+
298
+ if (nextLandmarkIndex === i) {
299
+ break;
300
+ }
203
301
  }
204
302
 
205
303
  return this.landmarks[nextLandmarkIndex];
@@ -210,49 +308,59 @@ class LandmarkManager {
210
308
  * If not, focus the landmark itself.
211
309
  * If no landmarks at all, or none with focusable elements, don't move focus.
212
310
  */
213
- public f6Handler(e: KeyboardEvent) {
311
+ private f6Handler(e: KeyboardEvent) {
214
312
  if (e.key === 'F6') {
215
- e.preventDefault();
216
- e.stopPropagation();
313
+ // If alt key pressed, focus main landmark, otherwise navigate forward or backward based on shift key.
314
+ let handled = e.altKey ? this.focusMain() : this.navigate(e.target as Element, e.shiftKey);
315
+ if (handled) {
316
+ e.preventDefault();
317
+ e.stopPropagation();
318
+ }
319
+ }
320
+ }
217
321
 
218
- let backward = e.shiftKey;
219
- let nextLandmark = this.getNextLandmark(e.target as Element, {backward});
322
+ private focusMain() {
323
+ let main = this.getLandmarkByRole('main');
324
+ if (main && document.contains(main.ref.current)) {
325
+ this.focusLandmark(main.ref.current, 'forward');
326
+ return true;
327
+ }
220
328
 
221
- // If no landmarks, return
222
- if (!nextLandmark) {
223
- return;
224
- }
329
+ return false;
330
+ }
225
331
 
226
- // If alt key pressed, focus main landmark
227
- if (e.altKey) {
228
- let main = this.getLandmarkByRole('main');
229
- if (main && document.contains(main.ref.current)) {
230
- this.focusLandmark(main.ref.current);
231
- }
232
- return;
233
- }
332
+ private navigate(from: Element, backward: boolean) {
333
+ let nextLandmark = this.getNextLandmark(from, {
334
+ backward
335
+ });
234
336
 
235
- // If something was previously focused in the next landmark, then return focus to it
236
- if (nextLandmark.lastFocused) {
237
- let lastFocused = nextLandmark.lastFocused;
238
- if (document.body.contains(lastFocused)) {
239
- lastFocused.focus();
240
- return;
241
- }
242
- }
337
+ if (!nextLandmark) {
338
+ return false;
339
+ }
243
340
 
244
- // Otherwise, focus the landmark itself
245
- if (document.contains(nextLandmark.ref.current)) {
246
- this.focusLandmark(nextLandmark.ref.current);
341
+ // If something was previously focused in the next landmark, then return focus to it
342
+ if (nextLandmark.lastFocused) {
343
+ let lastFocused = nextLandmark.lastFocused;
344
+ if (document.body.contains(lastFocused)) {
345
+ lastFocused.focus();
346
+ return true;
247
347
  }
248
348
  }
349
+
350
+ // Otherwise, focus the landmark itself
351
+ if (document.contains(nextLandmark.ref.current)) {
352
+ this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward');
353
+ return true;
354
+ }
355
+
356
+ return false;
249
357
  }
250
358
 
251
359
  /**
252
360
  * Sets lastFocused for a landmark, if focus is moved within that landmark.
253
361
  * Lets the last focused landmark know it was blurred if something else is focused.
254
362
  */
255
- public focusinHandler(e: FocusEvent) {
363
+ private focusinHandler(e: FocusEvent) {
256
364
  let currentLandmark = this.closestLandmark(e.target as Element);
257
365
  if (currentLandmark && currentLandmark.ref.current !== e.target) {
258
366
  this.updateLandmark({ref: currentLandmark.ref, lastFocused: e.target as FocusableElement});
@@ -269,7 +377,7 @@ class LandmarkManager {
269
377
  /**
270
378
  * Track if the focus is lost to the body. If it is, do cleanup on the landmark that last had focus.
271
379
  */
272
- public focusoutHandler(e: FocusEvent) {
380
+ private focusoutHandler(e: FocusEvent) {
273
381
  let previousFocusedElement = e.target as Element;
274
382
  let nextFocusedElement = e.relatedTarget;
275
383
  // the === document seems to be a jest thing for focus to go there on generic blur event such as landmark.blur();
@@ -281,6 +389,78 @@ class LandmarkManager {
281
389
  }
282
390
  }
283
391
  }
392
+
393
+ public createLandmarkController(): LandmarkController {
394
+ let instance = this;
395
+ instance.refCount++;
396
+ instance.setupIfNeeded();
397
+ return {
398
+ navigate(direction, opts) {
399
+ return instance.navigate(opts?.from || document.activeElement, direction === 'backward');
400
+ },
401
+ focusNext(opts) {
402
+ return instance.navigate(opts?.from || document.activeElement, false);
403
+ },
404
+ focusPrevious(opts) {
405
+ return instance.navigate(opts?.from || document.activeElement, true);
406
+ },
407
+ focusMain() {
408
+ return instance.focusMain();
409
+ },
410
+ dispose() {
411
+ instance.refCount--;
412
+ instance.teardownIfNeeded();
413
+ instance = null;
414
+ }
415
+ };
416
+ }
417
+
418
+ public registerLandmark(landmark: Landmark): () => void {
419
+ if (this.landmarks.find(l => l.ref === landmark.ref)) {
420
+ this.updateLandmark(landmark);
421
+ } else {
422
+ this.addLandmark(landmark);
423
+ }
424
+
425
+ return () => this.removeLandmark(landmark.ref);
426
+ }
427
+ }
428
+
429
+ /** Creates a LandmarkController, which allows programmatic navigation of landmarks. */
430
+ export function createLandmarkController(): LandmarkController {
431
+ // Get the current landmark manager and create a controller using it.
432
+ let instance = getLandmarkManager();
433
+ let controller = instance.createLandmarkController();
434
+
435
+ let unsubscribe = subscribe(() => {
436
+ // If the landmark manager changes, dispose the old
437
+ // controller and create a new one.
438
+ controller.dispose();
439
+ instance = getLandmarkManager();
440
+ controller = instance.createLandmarkController();
441
+ });
442
+
443
+ // Return a wrapper that proxies requests to the current controller instance.
444
+ return {
445
+ navigate(direction, opts) {
446
+ return controller.navigate(direction, opts);
447
+ },
448
+ focusNext(opts) {
449
+ return controller.focusNext(opts);
450
+ },
451
+ focusPrevious(opts) {
452
+ return controller.focusPrevious(opts);
453
+ },
454
+ focusMain() {
455
+ return controller.focusMain();
456
+ },
457
+ dispose() {
458
+ controller.dispose();
459
+ unsubscribe();
460
+ controller = null;
461
+ instance = null;
462
+ }
463
+ };
284
464
  }
285
465
 
286
466
  /**
@@ -292,13 +472,14 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
292
472
  const {
293
473
  role,
294
474
  'aria-label': ariaLabel,
295
- 'aria-labelledby': ariaLabelledby
475
+ 'aria-labelledby': ariaLabelledby,
476
+ focus
296
477
  } = props;
297
- let manager = LandmarkManager.getInstance();
478
+ let manager = useLandmarkManager();
298
479
  let label = ariaLabel || ariaLabelledby;
299
480
  let [isLandmarkFocused, setIsLandmarkFocused] = useState(false);
300
481
 
301
- let focus = useCallback(() => {
482
+ let defaultFocus = useCallback(() => {
302
483
  setIsLandmarkFocused(true);
303
484
  }, [setIsLandmarkFocused]);
304
485
 
@@ -307,18 +488,8 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
307
488
  }, [setIsLandmarkFocused]);
308
489
 
309
490
  useLayoutEffect(() => {
310
- manager.addLandmark({ref, role, label, focus, blur});
311
-
312
- return () => {
313
- manager.removeLandmark(ref);
314
- };
315
- // eslint-disable-next-line react-hooks/exhaustive-deps
316
- }, []);
317
-
318
- useLayoutEffect(() => {
319
- manager.updateLandmark({ref, label, role, focus, blur});
320
- // eslint-disable-next-line react-hooks/exhaustive-deps
321
- }, [label, ref, role]);
491
+ return manager.registerLandmark({ref, label, role, focus: focus || defaultFocus, blur});
492
+ }, [manager, label, ref, role, focus, defaultFocus, blur]);
322
493
 
323
494
  useEffect(() => {
324
495
  if (isLandmarkFocused) {
@@ -329,7 +500,9 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
329
500
  return {
330
501
  landmarkProps: {
331
502
  role,
332
- tabIndex: isLandmarkFocused ? -1 : undefined
503
+ tabIndex: isLandmarkFocused ? -1 : undefined,
504
+ 'aria-label': ariaLabel,
505
+ 'aria-labelledby': ariaLabelledby
333
506
  }
334
507
  };
335
508
  }