Skip to content

Commit 3c9ebc9

Browse files
Landmarks followup (#2920)
* don't check labels in production * Log elements that violate missing label check * Log elements that violate duplicate labels check * fix to log element * fix tests that check warnings * change addLandmark to use binary search * cleanup binary search * add blur handler * add test for blur and refocus landmark * remove skip * Fix focus lost to body * remove environment check (not working) Co-authored-by: Rob Snow <[email protected]>
1 parent 2fabc1a commit 3c9ebc9

File tree

2 files changed

+89
-23
lines changed

2 files changed

+89
-23
lines changed

packages/@react-aria/landmark/src/useLandmark.ts

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class LandmarkManager {
4141
private constructor() {
4242
this.f6Handler = this.f6Handler.bind(this);
4343
this.focusinHandler = this.focusinHandler.bind(this);
44+
this.focusoutHandler = this.focusoutHandler.bind(this);
4445
}
4546

4647
public static getInstance(): LandmarkManager {
@@ -54,12 +55,14 @@ class LandmarkManager {
5455
private setup() {
5556
document.addEventListener('keydown', this.f6Handler, {capture: true});
5657
document.addEventListener('focusin', this.focusinHandler, {capture: true});
58+
document.addEventListener('focusout', this.focusoutHandler, {capture: true});
5759
this.isListening = true;
5860
}
5961

6062
private teardown() {
6163
document.removeEventListener('keydown', this.f6Handler, {capture: true});
6264
document.removeEventListener('focusin', this.focusinHandler, {capture: true});
65+
document.removeEventListener('focusout', this.focusoutHandler, {capture: true});
6366
this.isListening = false;
6467
}
6568

@@ -98,22 +101,24 @@ class LandmarkManager {
98101
return;
99102
}
100103

101-
let insertPosition = 0;
102-
let comparedPosition = newLandmark.ref.current.compareDocumentPosition(this.landmarks[insertPosition].ref.current as Node);
103-
// Compare position of landmark being added with existing landmarks.
104-
// Iterate through landmarks (which are sorted in document order),
105-
// and insert when a landmark is found that is positioned before the newly added element,
106-
// or is contained by the newly added element (for nested landmarks).
107-
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
108-
while (
109-
insertPosition < this.landmarks.length &&
110-
((comparedPosition & Node.DOCUMENT_POSITION_PRECEDING) ||
111-
(comparedPosition & Node.DOCUMENT_POSITION_CONTAINS))
112-
) {
113-
comparedPosition = newLandmark.ref.current.compareDocumentPosition(this.landmarks[insertPosition].ref.current as Node);
114-
insertPosition++;
104+
105+
// Binary search to insert new landmark based on position in document relative to existing landmarks.
106+
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
107+
let start = 0;
108+
let end = this.landmarks.length - 1;
109+
while (start <= end) {
110+
let mid = Math.floor((start + end) / 2);
111+
let comparedPosition = newLandmark.ref.current.compareDocumentPosition(this.landmarks[mid].ref.current as Node);
112+
let isNewAfterExisting = Boolean((comparedPosition & Node.DOCUMENT_POSITION_PRECEDING) || (comparedPosition & Node.DOCUMENT_POSITION_CONTAINS));
113+
114+
if (isNewAfterExisting) {
115+
start = mid + 1;
116+
} else {
117+
end = mid - 1;
118+
}
115119
}
116-
this.landmarks.splice(insertPosition, 0, newLandmark);
120+
121+
this.landmarks.splice(start, 0, newLandmark);
117122
}
118123

119124
public updateLandmark(landmark: Pick<Landmark, 'ref'> & Partial<Landmark>) {
@@ -140,14 +145,21 @@ class LandmarkManager {
140145
private checkLabels(role: AriaLandmarkRole) {
141146
let landmarksWithRole = this.getLandmarksByRole(role);
142147
if (landmarksWithRole.size > 1) {
143-
if ([...landmarksWithRole].some(landmark => !landmark.label)) {
144-
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.`);
148+
let duplicatesWithoutLabel = [...landmarksWithRole].filter(landmark => !landmark.label);
149+
if (duplicatesWithoutLabel.length > 0) {
150+
console.warn(
151+
`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: `,
152+
duplicatesWithoutLabel.map(landmark => landmark.ref.current)
153+
);
145154
} else {
146155
let labels = [...landmarksWithRole].map(landmark => landmark.label);
147156
let duplicateLabels = labels.filter((item, index) => labels.indexOf(item) !== index);
148157

149158
duplicateLabels.forEach((label) => {
150-
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.`);
159+
console.warn(
160+
`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: `,
161+
[...landmarksWithRole].filter(landmark => landmark.label === label).map(landmark => landmark.ref.current)
162+
);
151163
});
152164
}
153165
}
@@ -253,6 +265,22 @@ class LandmarkManager {
253265
}
254266
}
255267
}
268+
269+
/**
270+
* Track if the focus is lost to the body. If it is, do cleanup on the landmark that last had focus.
271+
*/
272+
public focusoutHandler(e: FocusEvent) {
273+
let previousFocusedElement = e.target as HTMLElement;
274+
let nextFocusedElement = e.relatedTarget;
275+
// the === document seems to be a jest thing for focus to go there on generic blur event such as landmark.blur();
276+
// browsers appear to send focus instead to document.body and the relatedTarget is null when that happens
277+
if (!nextFocusedElement || nextFocusedElement === document) {
278+
let closestPreviousLandmark = this.closestLandmark(previousFocusedElement);
279+
if (closestPreviousLandmark && closestPreviousLandmark.ref.current === previousFocusedElement) {
280+
closestPreviousLandmark.blur();
281+
}
282+
}
283+
}
256284
}
257285

258286
/**

packages/@react-aria/landmark/test/useLandmark.test.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ describe('LandmarkManager', function () {
569569
it('Should warn if 2+ landmarks with same role are used but not labelled.', function () {
570570
let spyWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
571571

572-
render(
572+
let tree = render(
573573
<div>
574574
<Navigation>
575575
<ul>
@@ -591,13 +591,15 @@ describe('LandmarkManager', function () {
591591
</div>
592592
);
593593

594-
expect(spyWarn).toHaveBeenCalledWith('Page contains more than one landmark with the \'navigation\' 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.');
594+
let navs = tree.getAllByRole('navigation');
595+
596+
expect(spyWarn).toHaveBeenCalledWith('Page contains more than one landmark with the \'navigation\' 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: ', navs);
595597
});
596598

597599
it('Should warn if 2+ landmarks with same role and same label', function () {
598600
let spyWarn = jest.spyOn(console, 'warn').mockImplementation(() => {});
599601

600-
render(
602+
let tree = render(
601603
<div>
602604
<Navigation aria-label="First nav">
603605
<ul>
@@ -618,8 +620,9 @@ describe('LandmarkManager', function () {
618620
</Main>
619621
</div>
620622
);
623+
let navs = tree.getAllByRole('navigation');
621624

622-
expect(spyWarn).toHaveBeenCalledWith('Page contains more than one landmark with the \'navigation\' role and \'First nav\' label. If two or more landmarks on a page share the same role, they must have unique labels.');
625+
expect(spyWarn).toHaveBeenCalledWith('Page contains more than one landmark with the \'navigation\' role and \'First nav\' label. If two or more landmarks on a page share the same role, they must have unique labels: ', navs);
623626
});
624627

625628
it('Should not navigate to a landmark that has been removed from the DOM', function () {
@@ -1094,6 +1097,7 @@ describe('LandmarkManager', function () {
10941097
</Main>
10951098
</div>
10961099
);
1100+
let navs = tree.getAllByRole('navigation');
10971101

10981102
expect(spyWarn).not.toHaveBeenCalled();
10991103
tree.rerender(
@@ -1116,6 +1120,40 @@ describe('LandmarkManager', function () {
11161120
</Main>
11171121
</div>
11181122
);
1119-
expect(spyWarn).toHaveBeenCalledWith('Page contains more than one landmark with the \'navigation\' role and \'nav label 1\' label. If two or more landmarks on a page share the same role, they must have unique labels.');
1123+
expect(spyWarn).toHaveBeenCalledWith('Page contains more than one landmark with the \'navigation\' role and \'nav label 1\' label. If two or more landmarks on a page share the same role, they must have unique labels: ', navs);
1124+
});
1125+
1126+
it('focus restores to previously focused landmark after blur and F6.', function () {
1127+
let {getByRole} = render(
1128+
<div>
1129+
<Navigation>
1130+
<ul>
1131+
<li><a href="/home">Home</a></li>
1132+
<li><a href="/about">About</a></li>
1133+
<li><a href="/contact">Contact</a></li>
1134+
</ul>
1135+
</Navigation>
1136+
<Main>
1137+
<TextField label="First Name" />
1138+
</Main>
1139+
</div>
1140+
);
1141+
let nav = getByRole('navigation');
1142+
1143+
fireEvent.keyDown(document.activeElement, {key: 'F6'});
1144+
fireEvent.keyUp(document.activeElement, {key: 'F6'});
1145+
expect(nav).toHaveAttribute('tabIndex', '-1');
1146+
expect(document.activeElement).toBe(nav);
1147+
1148+
act(() => {
1149+
(document.activeElement as HTMLElement).blur();
1150+
});
1151+
expect(nav).not.toHaveAttribute('tabIndex', '-1');
1152+
expect(document.activeElement).toBe(document.body);
1153+
1154+
fireEvent.keyDown(document.activeElement, {key: 'F6'});
1155+
fireEvent.keyUp(document.activeElement, {key: 'F6'});
1156+
expect(nav).toHaveAttribute('tabIndex', '-1');
1157+
expect(document.activeElement).toBe(nav);
11201158
});
11211159
});

0 commit comments

Comments
 (0)