@react-aria/landmark 3.0.0-alpha.5 → 3.0.0-alpha.7

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.
@@ -13,81 +13,153 @@
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/index.js';
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 | null {
89
+ if (typeof document === 'undefined') {
90
+ return null;
91
+ }
92
+
93
+ // Reuse an existing instance if it has the same or greater version.
94
+ let instance = document[landmarkSymbol];
95
+ if (instance && instance.version >= LANDMARK_API_VERSION) {
96
+ return instance;
97
+ }
98
+
99
+ // Otherwise, create a new instance and dispatch an event so anything using the existing
100
+ // instance updates and re-registers their landmarks with the new one.
101
+ document[landmarkSymbol] = new LandmarkManager();
102
+ document.dispatchEvent(new CustomEvent('react-aria-landmark-manager-change'));
103
+ return document[landmarkSymbol];
104
+ }
35
105
 
36
- class LandmarkManager {
106
+ // Subscribes a React component to the current landmark manager instance.
107
+ function useLandmarkManager(): LandmarkManagerApi | null {
108
+ return useSyncExternalStore(subscribe, getLandmarkManager, getLandmarkManager);
109
+ }
110
+
111
+ class LandmarkManager implements LandmarkManagerApi {
37
112
  private landmarks: Array<Landmark> = [];
38
- private static instance: LandmarkManager;
39
113
  private isListening = false;
114
+ private refCount = 0;
115
+ public version = LANDMARK_API_VERSION;
40
116
 
41
- private constructor() {
117
+ constructor() {
42
118
  this.f6Handler = this.f6Handler.bind(this);
43
119
  this.focusinHandler = this.focusinHandler.bind(this);
44
120
  this.focusoutHandler = this.focusoutHandler.bind(this);
45
121
  }
46
122
 
47
- public static getInstance(): LandmarkManager {
48
- if (!LandmarkManager.instance) {
49
- LandmarkManager.instance = new LandmarkManager();
123
+ private setupIfNeeded() {
124
+ if (this.isListening) {
125
+ return;
50
126
  }
51
-
52
- return LandmarkManager.instance;
53
- }
54
-
55
- private setup() {
56
127
  document.addEventListener('keydown', this.f6Handler, {capture: true});
57
128
  document.addEventListener('focusin', this.focusinHandler, {capture: true});
58
129
  document.addEventListener('focusout', this.focusoutHandler, {capture: true});
59
130
  this.isListening = true;
60
131
  }
61
132
 
62
- private teardown() {
133
+ private teardownIfNeeded() {
134
+ if (!this.isListening || this.landmarks.length > 0 || this.refCount > 0) {
135
+ return;
136
+ }
63
137
  document.removeEventListener('keydown', this.f6Handler, {capture: true});
64
138
  document.removeEventListener('focusin', this.focusinHandler, {capture: true});
65
139
  document.removeEventListener('focusout', this.focusoutHandler, {capture: true});
66
140
  this.isListening = false;
67
141
  }
68
142
 
69
- private focusLandmark(landmark: Element) {
70
- this.landmarks.find(l => l.ref.current === landmark)?.focus();
143
+ private focusLandmark(landmark: Element, direction: 'forward' | 'backward') {
144
+ this.landmarks.find(l => l.ref.current === landmark)?.focus(direction);
71
145
  }
72
146
 
73
147
  /**
74
148
  * Return set of landmarks with a specific role.
75
149
  */
76
- public getLandmarksByRole(role: AriaLandmarkRole) {
150
+ private getLandmarksByRole(role: AriaLandmarkRole) {
77
151
  return new Set(this.landmarks.filter(l => l.role === role));
78
152
  }
79
153
 
80
154
  /**
81
155
  * Return first landmark with a specific role.
82
156
  */
83
- public getLandmarkByRole(role: AriaLandmarkRole) {
157
+ private getLandmarkByRole(role: AriaLandmarkRole) {
84
158
  return this.landmarks.find(l => l.role === role);
85
159
  }
86
160
 
87
- public addLandmark(newLandmark: Landmark) {
88
- if (!this.isListening) {
89
- this.setup();
90
- }
161
+ private addLandmark(newLandmark: Landmark) {
162
+ this.setupIfNeeded();
91
163
  if (this.landmarks.find(landmark => landmark.ref === newLandmark.ref)) {
92
164
  return;
93
165
  }
@@ -98,6 +170,7 @@ class LandmarkManager {
98
170
 
99
171
  if (this.landmarks.length === 0) {
100
172
  this.landmarks = [newLandmark];
173
+ this.checkLabels(newLandmark.role);
101
174
  return;
102
175
  }
103
176
 
@@ -119,9 +192,10 @@ class LandmarkManager {
119
192
  }
120
193
 
121
194
  this.landmarks.splice(start, 0, newLandmark);
195
+ this.checkLabels(newLandmark.role);
122
196
  }
123
197
 
124
- public updateLandmark(landmark: Pick<Landmark, 'ref'> & Partial<Landmark>) {
198
+ private updateLandmark(landmark: Pick<Landmark, 'ref'> & Partial<Landmark>) {
125
199
  let index = this.landmarks.findIndex(l => l.ref === landmark.ref);
126
200
  if (index >= 0) {
127
201
  this.landmarks[index] = {...this.landmarks[index], ...landmark};
@@ -129,11 +203,9 @@ class LandmarkManager {
129
203
  }
130
204
  }
131
205
 
132
- public removeLandmark(ref: MutableRefObject<Element>) {
206
+ private removeLandmark(ref: MutableRefObject<Element>) {
133
207
  this.landmarks = this.landmarks.filter(landmark => landmark.ref !== ref);
134
- if (this.landmarks.length === 0) {
135
- this.teardown();
136
- }
208
+ this.teardownIfNeeded();
137
209
  }
138
210
 
139
211
  /**
@@ -172,7 +244,7 @@ class LandmarkManager {
172
244
  private closestLandmark(element: Element) {
173
245
  let landmarkMap = new Map(this.landmarks.map(l => [l.ref.current, l]));
174
246
  let currentElement = element;
175
- while (!landmarkMap.has(currentElement) && currentElement !== document.body) {
247
+ while (currentElement && !landmarkMap.has(currentElement) && currentElement !== document.body) {
176
248
  currentElement = currentElement.parentElement;
177
249
  }
178
250
  return landmarkMap.get(currentElement);
@@ -184,22 +256,52 @@ class LandmarkManager {
184
256
  * If not inside a landmark, will return first landmark.
185
257
  * Returns undefined if there are no landmarks.
186
258
  */
187
- public getNextLandmark(element: Element, {backward}: {backward?: boolean }) {
188
- if (this.landmarks.length === 0) {
189
- return undefined;
190
- }
191
-
259
+ private getNextLandmark(element: Element, {backward}: {backward?: boolean }) {
192
260
  let currentLandmark = this.closestLandmark(element);
193
- let nextLandmarkIndex = backward ? -1 : 0;
261
+ let nextLandmarkIndex = backward ? this.landmarks.length - 1 : 0;
194
262
  if (currentLandmark) {
195
- nextLandmarkIndex = this.landmarks.findIndex(landmark => landmark === currentLandmark) + (backward ? -1 : 1);
263
+ nextLandmarkIndex = this.landmarks.indexOf(currentLandmark) + (backward ? -1 : 1);
196
264
  }
197
265
 
198
- // Wrap if necessary
199
- if (nextLandmarkIndex < 0) {
200
- nextLandmarkIndex = this.landmarks.length - 1;
201
- } else if (nextLandmarkIndex >= this.landmarks.length) {
202
- nextLandmarkIndex = 0;
266
+ let wrapIfNeeded = () => {
267
+ // When we reach the end of the landmark sequence, fire a custom event that can be listened for by applications.
268
+ // If this event is canceled, we return immediately. This can be used to implement landmark navigation across iframes.
269
+ if (nextLandmarkIndex < 0) {
270
+ if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', {detail: {direction: 'backward'}, bubbles: true, cancelable: true}))) {
271
+ return true;
272
+ }
273
+
274
+ nextLandmarkIndex = this.landmarks.length - 1;
275
+ } else if (nextLandmarkIndex >= this.landmarks.length) {
276
+ if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', {detail: {direction: 'forward'}, bubbles: true, cancelable: true}))) {
277
+ return true;
278
+ }
279
+
280
+ nextLandmarkIndex = 0;
281
+ }
282
+
283
+ if (nextLandmarkIndex < 0 || nextLandmarkIndex >= this.landmarks.length) {
284
+ return true;
285
+ }
286
+
287
+ return false;
288
+ };
289
+
290
+ if (wrapIfNeeded()) {
291
+ return undefined;
292
+ }
293
+
294
+ // Skip over hidden landmarks.
295
+ let i = nextLandmarkIndex;
296
+ while (this.landmarks[nextLandmarkIndex].ref.current.closest('[aria-hidden=true]')) {
297
+ nextLandmarkIndex += backward ? -1 : 1;
298
+ if (wrapIfNeeded()) {
299
+ return undefined;
300
+ }
301
+
302
+ if (nextLandmarkIndex === i) {
303
+ break;
304
+ }
203
305
  }
204
306
 
205
307
  return this.landmarks[nextLandmarkIndex];
@@ -210,49 +312,59 @@ class LandmarkManager {
210
312
  * If not, focus the landmark itself.
211
313
  * If no landmarks at all, or none with focusable elements, don't move focus.
212
314
  */
213
- public f6Handler(e: KeyboardEvent) {
315
+ private f6Handler(e: KeyboardEvent) {
214
316
  if (e.key === 'F6') {
215
- e.preventDefault();
216
- e.stopPropagation();
317
+ // If alt key pressed, focus main landmark, otherwise navigate forward or backward based on shift key.
318
+ let handled = e.altKey ? this.focusMain() : this.navigate(e.target as Element, e.shiftKey);
319
+ if (handled) {
320
+ e.preventDefault();
321
+ e.stopPropagation();
322
+ }
323
+ }
324
+ }
217
325
 
218
- let backward = e.shiftKey;
219
- let nextLandmark = this.getNextLandmark(e.target as Element, {backward});
326
+ private focusMain() {
327
+ let main = this.getLandmarkByRole('main');
328
+ if (main && document.contains(main.ref.current)) {
329
+ this.focusLandmark(main.ref.current, 'forward');
330
+ return true;
331
+ }
220
332
 
221
- // If no landmarks, return
222
- if (!nextLandmark) {
223
- return;
224
- }
333
+ return false;
334
+ }
225
335
 
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
- }
336
+ private navigate(from: Element, backward: boolean) {
337
+ let nextLandmark = this.getNextLandmark(from, {
338
+ backward
339
+ });
234
340
 
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
- }
341
+ if (!nextLandmark) {
342
+ return false;
343
+ }
243
344
 
244
- // Otherwise, focus the landmark itself
245
- if (document.contains(nextLandmark.ref.current)) {
246
- this.focusLandmark(nextLandmark.ref.current);
345
+ // If something was previously focused in the next landmark, then return focus to it
346
+ if (nextLandmark.lastFocused) {
347
+ let lastFocused = nextLandmark.lastFocused;
348
+ if (document.body.contains(lastFocused)) {
349
+ lastFocused.focus();
350
+ return true;
247
351
  }
248
352
  }
353
+
354
+ // Otherwise, focus the landmark itself
355
+ if (document.contains(nextLandmark.ref.current)) {
356
+ this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward');
357
+ return true;
358
+ }
359
+
360
+ return false;
249
361
  }
250
362
 
251
363
  /**
252
364
  * Sets lastFocused for a landmark, if focus is moved within that landmark.
253
365
  * Lets the last focused landmark know it was blurred if something else is focused.
254
366
  */
255
- public focusinHandler(e: FocusEvent) {
367
+ private focusinHandler(e: FocusEvent) {
256
368
  let currentLandmark = this.closestLandmark(e.target as Element);
257
369
  if (currentLandmark && currentLandmark.ref.current !== e.target) {
258
370
  this.updateLandmark({ref: currentLandmark.ref, lastFocused: e.target as FocusableElement});
@@ -269,7 +381,7 @@ class LandmarkManager {
269
381
  /**
270
382
  * Track if the focus is lost to the body. If it is, do cleanup on the landmark that last had focus.
271
383
  */
272
- public focusoutHandler(e: FocusEvent) {
384
+ private focusoutHandler(e: FocusEvent) {
273
385
  let previousFocusedElement = e.target as Element;
274
386
  let nextFocusedElement = e.relatedTarget;
275
387
  // the === document seems to be a jest thing for focus to go there on generic blur event such as landmark.blur();
@@ -281,6 +393,78 @@ class LandmarkManager {
281
393
  }
282
394
  }
283
395
  }
396
+
397
+ public createLandmarkController(): LandmarkController {
398
+ let instance = this;
399
+ instance.refCount++;
400
+ instance.setupIfNeeded();
401
+ return {
402
+ navigate(direction, opts) {
403
+ return instance.navigate(opts?.from || document.activeElement, direction === 'backward');
404
+ },
405
+ focusNext(opts) {
406
+ return instance.navigate(opts?.from || document.activeElement, false);
407
+ },
408
+ focusPrevious(opts) {
409
+ return instance.navigate(opts?.from || document.activeElement, true);
410
+ },
411
+ focusMain() {
412
+ return instance.focusMain();
413
+ },
414
+ dispose() {
415
+ instance.refCount--;
416
+ instance.teardownIfNeeded();
417
+ instance = null;
418
+ }
419
+ };
420
+ }
421
+
422
+ public registerLandmark(landmark: Landmark): () => void {
423
+ if (this.landmarks.find(l => l.ref === landmark.ref)) {
424
+ this.updateLandmark(landmark);
425
+ } else {
426
+ this.addLandmark(landmark);
427
+ }
428
+
429
+ return () => this.removeLandmark(landmark.ref);
430
+ }
431
+ }
432
+
433
+ /** Creates a LandmarkController, which allows programmatic navigation of landmarks. */
434
+ export function createLandmarkController(): LandmarkController {
435
+ // Get the current landmark manager and create a controller using it.
436
+ let instance = getLandmarkManager();
437
+ let controller = instance.createLandmarkController();
438
+
439
+ let unsubscribe = subscribe(() => {
440
+ // If the landmark manager changes, dispose the old
441
+ // controller and create a new one.
442
+ controller.dispose();
443
+ instance = getLandmarkManager();
444
+ controller = instance.createLandmarkController();
445
+ });
446
+
447
+ // Return a wrapper that proxies requests to the current controller instance.
448
+ return {
449
+ navigate(direction, opts) {
450
+ return controller.navigate(direction, opts);
451
+ },
452
+ focusNext(opts) {
453
+ return controller.focusNext(opts);
454
+ },
455
+ focusPrevious(opts) {
456
+ return controller.focusPrevious(opts);
457
+ },
458
+ focusMain() {
459
+ return controller.focusMain();
460
+ },
461
+ dispose() {
462
+ controller.dispose();
463
+ unsubscribe();
464
+ controller = null;
465
+ instance = null;
466
+ }
467
+ };
284
468
  }
285
469
 
286
470
  /**
@@ -292,13 +476,14 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
292
476
  const {
293
477
  role,
294
478
  'aria-label': ariaLabel,
295
- 'aria-labelledby': ariaLabelledby
479
+ 'aria-labelledby': ariaLabelledby,
480
+ focus
296
481
  } = props;
297
- let manager = LandmarkManager.getInstance();
482
+ let manager = useLandmarkManager();
298
483
  let label = ariaLabel || ariaLabelledby;
299
484
  let [isLandmarkFocused, setIsLandmarkFocused] = useState(false);
300
485
 
301
- let focus = useCallback(() => {
486
+ let defaultFocus = useCallback(() => {
302
487
  setIsLandmarkFocused(true);
303
488
  }, [setIsLandmarkFocused]);
304
489
 
@@ -307,18 +492,8 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
307
492
  }, [setIsLandmarkFocused]);
308
493
 
309
494
  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]);
495
+ return manager.registerLandmark({ref, label, role, focus: focus || defaultFocus, blur});
496
+ }, [manager, label, ref, role, focus, defaultFocus, blur]);
322
497
 
323
498
  useEffect(() => {
324
499
  if (isLandmarkFocused) {
@@ -329,7 +504,9 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<Focu
329
504
  return {
330
505
  landmarkProps: {
331
506
  role,
332
- tabIndex: isLandmarkFocused ? -1 : undefined
507
+ tabIndex: isLandmarkFocused ? -1 : undefined,
508
+ 'aria-label': ariaLabel,
509
+ 'aria-labelledby': ariaLabelledby
333
510
  }
334
511
  };
335
512
  }