Skip to content

Commit 5f83e9f

Browse files
authored
Landmarks (#2750)
1 parent 3dc96e5 commit 5f83e9f

File tree

8 files changed

+1750
-0
lines changed

8 files changed

+1750
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @react-aria/landmark
2+
3+
This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright 2022 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+
export * from './src';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@react-aria/landmark",
3+
"version": "3.0.0-alpha.1",
4+
"private": true,
5+
"description": "Spectrum UI components in React",
6+
"license": "Apache-2.0",
7+
"main": "dist/main.js",
8+
"module": "dist/module.js",
9+
"types": "dist/types.d.ts",
10+
"source": "src/index.ts",
11+
"files": [
12+
"dist",
13+
"src"
14+
],
15+
"sideEffects": false,
16+
"repository": {
17+
"type": "git",
18+
"url": "https://github.com/adobe/react-spectrum"
19+
},
20+
"dependencies": {
21+
"@babel/runtime": "^7.6.2",
22+
"@react-aria/focus": "^3.5.0",
23+
"@react-aria/utils": "^3.11.0",
24+
"@react-types/shared": "^3.10.1"
25+
},
26+
"peerDependencies": {
27+
"react": "^16.8.0 || ^17.0.0-rc.1"
28+
},
29+
"publishConfig": {
30+
"access": "public"
31+
}
32+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright 2022 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+
export * from './useLandmark';
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/*
2+
* Copyright 2022 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 {AriaLabelingProps} from '@react-types/shared';
14+
import {HTMLAttributes, MutableRefObject, useCallback, useEffect, useState} from 'react';
15+
import {useLayoutEffect} from '@react-aria/utils';
16+
17+
export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary';
18+
19+
export interface AriaLandmarkProps extends AriaLabelingProps {
20+
role: AriaLandmarkRole
21+
}
22+
23+
interface LandmarkAria {
24+
landmarkProps: HTMLAttributes<HTMLElement>
25+
}
26+
27+
type Landmark = {
28+
ref: MutableRefObject<HTMLElement>,
29+
role: AriaLandmarkRole,
30+
label?: string,
31+
lastFocused?: HTMLElement,
32+
focus: () => void,
33+
blur: () => void
34+
};
35+
36+
class LandmarkManager {
37+
private landmarks: Array<Landmark> = [];
38+
private static instance: LandmarkManager;
39+
40+
private constructor() {}
41+
42+
public static getInstance(): LandmarkManager {
43+
if (!LandmarkManager.instance) {
44+
LandmarkManager.instance = new LandmarkManager();
45+
LandmarkManager.instance.setup();
46+
}
47+
48+
return LandmarkManager.instance;
49+
}
50+
51+
private setup() {
52+
document.addEventListener('keydown', LandmarkManager.getInstance().f6Handler.bind(LandmarkManager.getInstance()), {capture: true});
53+
document.addEventListener('focusin', LandmarkManager.getInstance().focusinHandler.bind(LandmarkManager.getInstance()), {capture: true});
54+
}
55+
56+
private focusLandmark(landmark: HTMLElement) {
57+
this.landmarks.find(l => l.ref.current === landmark)?.focus();
58+
}
59+
60+
/**
61+
* Return set of landmarks with a specific role.
62+
*/
63+
public getLandmarksByRole(role: AriaLandmarkRole) {
64+
return new Set(this.landmarks.filter(l => l.role === role));
65+
}
66+
67+
/**
68+
* Return first landmark with a specific role.
69+
*/
70+
public getLandmarkByRole(role: AriaLandmarkRole) {
71+
return this.landmarks.find(l => l.role === role);
72+
}
73+
74+
public addLandmark(newLandmark: Landmark) {
75+
if (this.landmarks.find(landmark => landmark.ref === newLandmark.ref)) {
76+
return;
77+
}
78+
79+
if (this.landmarks.filter(landmark => landmark.role === 'main').length > 1) {
80+
console.error('Page can contain no more than one landmark with the role "main".');
81+
}
82+
83+
if (this.landmarks.length === 0) {
84+
this.landmarks = [newLandmark];
85+
return;
86+
}
87+
88+
let insertPosition = 0;
89+
let comparedPosition = newLandmark.ref.current.compareDocumentPosition(this.landmarks[insertPosition].ref.current as Node);
90+
// Compare position of landmark being added with existing landmarks.
91+
// Iterate through landmarks (which are sorted in document order),
92+
// and insert when a landmark is found that is positioned before the newly added element,
93+
// or is contained by the newly added element (for nested landmarks).
94+
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
95+
while (
96+
insertPosition < this.landmarks.length &&
97+
((comparedPosition & Node.DOCUMENT_POSITION_PRECEDING) ||
98+
(comparedPosition & Node.DOCUMENT_POSITION_CONTAINS))
99+
) {
100+
comparedPosition = newLandmark.ref.current.compareDocumentPosition(this.landmarks[insertPosition].ref.current as Node);
101+
insertPosition++;
102+
}
103+
this.landmarks.splice(insertPosition, 0, newLandmark);
104+
}
105+
106+
public updateLandmark(landmark: Landmark) {
107+
this.landmarks = this.landmarks.map((prevLandmark) => prevLandmark.ref.current === landmark.ref.current ? {...prevLandmark, ...landmark} : prevLandmark);
108+
this.checkLabels(landmark.role);
109+
}
110+
111+
public removeLandmark(ref: MutableRefObject<HTMLElement>) {
112+
this.landmarks = this.landmarks.filter(landmark => landmark.ref !== ref);
113+
}
114+
115+
/**
116+
* Warn if there are 2+ landmarks with the same role but no label.
117+
* Labels for landmarks with the same role must also be unique.
118+
*
119+
* See https://www.w3.org/TR/wai-aria-practices/examples/landmarks/navigation.html.
120+
*/
121+
private checkLabels(role: AriaLandmarkRole) {
122+
let landmarksWithRole = this.getLandmarksByRole(role);
123+
if (landmarksWithRole.size > 1) {
124+
if ([...landmarksWithRole].some(landmark => !landmark.label)) {
125+
console.warn(`Page contains more than one landmark with the '${role}' role. If two or more landmarks on a page share the same role, all must be labeled with an aria-label or aria-labelledby attribute.`);
126+
} else {
127+
let labels = [...landmarksWithRole].map(landmark => landmark.label);
128+
let duplicateLabels = labels.filter((item, index) => labels.indexOf(item) !== index);
129+
130+
duplicateLabels.forEach((label) => {
131+
console.warn(`Page contains more than one landmark with the '${role}' role and '${label}' label. If two or more landmarks on a page share the same role, they must have unique labels.`);
132+
});
133+
}
134+
}
135+
}
136+
137+
/**
138+
* Get the landmark that is the closest parent in the DOM.
139+
* Returns undefined if no parent is a landmark.
140+
*/
141+
private closestLandmark(element: HTMLElement) {
142+
let landmarkMap = new Map(this.landmarks.map(l => [l.ref.current, l]));
143+
let currentElement = element;
144+
while (!landmarkMap.has(currentElement) && currentElement !== document.body) {
145+
currentElement = currentElement.parentElement;
146+
}
147+
return landmarkMap.get(currentElement);
148+
}
149+
150+
/**
151+
* Gets the next landmark, in DOM focus order, or previous if backwards is specified.
152+
* If nested, next should be the child landmark.
153+
* If last landmark, next should be the first landmark.
154+
* If not inside a landmark, will return first landmark.
155+
* Returns undefined if there are no landmarks.
156+
*/
157+
public getNextLandmark(element: HTMLElement, {backward}: {backward?: boolean }) {
158+
if (this.landmarks.length === 0) {
159+
return undefined;
160+
}
161+
162+
let currentLandmark = this.closestLandmark(element);
163+
let nextLandmarkIndex = backward ? -1 : 0;
164+
if (currentLandmark) {
165+
nextLandmarkIndex = this.landmarks.findIndex(landmark => landmark === currentLandmark) + (backward ? -1 : 1);
166+
}
167+
168+
// Wrap if necessary
169+
if (nextLandmarkIndex < 0) {
170+
nextLandmarkIndex = this.landmarks.length - 1;
171+
} else if (nextLandmarkIndex >= this.landmarks.length) {
172+
nextLandmarkIndex = 0;
173+
}
174+
175+
return this.landmarks[nextLandmarkIndex];
176+
}
177+
178+
/**
179+
* Look at next landmark. If an element was previously focused inside, restore focus there.
180+
* If not, focus the first focusable element inside the lanemark.
181+
* If no focusable elements inside, go to the next landmark.
182+
* If no landmarks at all, or none with focusable elements, don't move focus.
183+
*/
184+
public f6Handler(e: KeyboardEvent) {
185+
if (e.key === 'F6') {
186+
e.preventDefault();
187+
e.stopPropagation();
188+
189+
let backward = e.shiftKey;
190+
let nextLandmark = this.getNextLandmark(e.target as HTMLElement, {backward});
191+
192+
// If no landmarks, return
193+
if (!nextLandmark) {
194+
return;
195+
}
196+
197+
// If alt key pressed, focus main landmark
198+
if (e.altKey) {
199+
let main = this.getLandmarkByRole('main');
200+
if (main && document.contains(main.ref.current)) {
201+
this.focusLandmark(main.ref.current);
202+
}
203+
return;
204+
}
205+
206+
// If something was previously focused in the next landmark, then return focus to it
207+
if (nextLandmark.lastFocused) {
208+
let lastFocused = nextLandmark.lastFocused;
209+
if (document.body.contains(lastFocused)) {
210+
lastFocused.focus();
211+
return;
212+
}
213+
}
214+
215+
// Otherwise, focus the landmark itself
216+
if (document.contains(nextLandmark.ref.current)) {
217+
this.focusLandmark(nextLandmark.ref.current);
218+
}
219+
}
220+
}
221+
222+
/**
223+
* Sets lastFocused for a landmark, if focus is moved within that landmark.
224+
* Lets the last focused landmark know it was blurred if something else is focused.
225+
*/
226+
public focusinHandler(e: FocusEvent) {
227+
let currentLandmark = this.closestLandmark(e.target as HTMLElement);
228+
if (currentLandmark && currentLandmark.ref.current !== e.target) {
229+
this.updateLandmark({...currentLandmark, lastFocused: e.target as HTMLElement});
230+
}
231+
let previousFocusedElment = e.relatedTarget as HTMLElement;
232+
if (previousFocusedElment) {
233+
let closestPreviousLandmark = this.closestLandmark(previousFocusedElment);
234+
if (closestPreviousLandmark && closestPreviousLandmark.ref.current === previousFocusedElment) {
235+
closestPreviousLandmark.blur();
236+
}
237+
}
238+
}
239+
}
240+
241+
/**
242+
* Provides landmark navigation in an application. Call this with a role and label to register a landmark navigable with F6.
243+
* @param props - Props for the landmark.
244+
* @param ref - Ref to the landmark.
245+
*/
246+
export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject<HTMLElement>): LandmarkAria {
247+
const {
248+
role,
249+
'aria-label': ariaLabel,
250+
'aria-labelledby': ariaLabelledby
251+
} = props;
252+
let manager = LandmarkManager.getInstance();
253+
let label = ariaLabel || ariaLabelledby;
254+
let [isLandmarkFocused, setIsLandmarkFocused] = useState(false);
255+
256+
let focus = useCallback(() => {
257+
setIsLandmarkFocused(true);
258+
}, [setIsLandmarkFocused]);
259+
260+
let blur = useCallback(() => {
261+
setIsLandmarkFocused(false);
262+
}, [setIsLandmarkFocused]);
263+
264+
useLayoutEffect(() => {
265+
manager.addLandmark({ref, role, label, focus, blur});
266+
267+
return () => {
268+
manager.removeLandmark(ref);
269+
};
270+
// eslint-disable-next-line react-hooks/exhaustive-deps
271+
}, []);
272+
273+
useEffect(() => {
274+
manager.updateLandmark({ref, label, role, focus, blur});
275+
// eslint-disable-next-line react-hooks/exhaustive-deps
276+
}, [label, ref, role]);
277+
278+
useEffect(() => {
279+
if (isLandmarkFocused) {
280+
ref.current.focus();
281+
}
282+
}, [isLandmarkFocused, ref]);
283+
284+
return {
285+
landmarkProps: {
286+
role,
287+
tabIndex: isLandmarkFocused ? -1 : undefined
288+
}
289+
};
290+
}

0 commit comments

Comments
 (0)