useLandmark
Provides landmark navigation in an application. Call this with a role and label to register a landmark navigable with F6.
| install | yarn add @react-aria/landmark |
|---|---|
| version | 3.0.0-beta.7 |
| usage | import {useLandmark} from '@react-aria/landmark' |
API#
useLandmark(
(props: AriaLandmarkProps,
, ref: RefObject<FocusableElement>
)): LandmarkAria
Features#
Landmarks provide a way to designate important subsections of a page. They allow screen reader users to get an overview of the various sections of the page, and jump to a specific section.
By default, browsers do not provide a consistent way to navigate between landmarks using the keyboard.
The useLandmark hook enables keyboard navigation between landmarks, and provides a consistent experience across browsers.
- F6 and Shift+F6 key navigation between landmarks
- Alt+F6 key navigation to the main landmark
- Support for navigating nested landmarks
- Support for navigating across iframes
Anatomy#
Landmark elements can be registered with the useLandmark hook. The role prop is required.
Pressing F6 will move focus to the next landmark on the page, and pressing Shift+F6 will move focus to the previous landmark. If an element within a landmark was previously focused before leaving that landmark, focus will return to that element when navigating back to that landmark. Alt+F6 will always move focus to the main landmark if it has been registered.
If multiple landmarks are registered with the same role, they should have unique labels which can be provided by aria-label or aria-labelledby.
Example#
import {useLandmark} from '@react-aria/landmark';
import {useRef} from 'react';
function Navigation(props) {
let ref = useRef();
let {landmarkProps} = useLandmark({...props, role: 'navigation'}, ref);
return (
<nav ref={ref} {...props} {...landmarkProps}>
{props.children}
</nav>
);
}
function Region(props) {
let ref = useRef();
let {landmarkProps} = useLandmark({...props, role: 'region'}, ref);
return (
<article ref={ref} {...props} {...landmarkProps}>
{props.children}
</article>
);
}
function Search(props) {
let ref = useRef();
let {landmarkProps} = useLandmark({...props, role: 'search'}, ref);
return (
<form ref={ref} {...props} {...landmarkProps}>
<h2 id="search-header">Search</h2>
<input aria-labelledby="search-header" type="search" />
</form>
);
}
<div>
<Navigation>
<h2>Navigation</h2>
<ul>
<li><a href="#">Link 1</a></li>
<li><a href="#">Link 2</a></li>
</ul>
</Navigation>
<Search />
<Region>
<h2>Region</h2>
<p>Example region with no focusable children.</p>
</Region>
</div>
import {useLandmark} from '@react-aria/landmark';
import {useRef} from 'react';
function Navigation(props) {
let ref = useRef();
let { landmarkProps } = useLandmark({
...props,
role: 'navigation'
}, ref);
return (
<nav ref={ref} {...props} {...landmarkProps}>
{props.children}
</nav>
);
}
function Region(props) {
let ref = useRef();
let { landmarkProps } = useLandmark({
...props,
role: 'region'
}, ref);
return (
<article ref={ref} {...props} {...landmarkProps}>
{props.children}
</article>
);
}
function Search(props) {
let ref = useRef();
let { landmarkProps } = useLandmark({
...props,
role: 'search'
}, ref);
return (
<form ref={ref} {...props} {...landmarkProps}>
<h2 id="search-header">Search</h2>
<input
aria-labelledby="search-header"
type="search"
/>
</form>
);
}
<div>
<Navigation>
<h2>Navigation</h2>
<ul>
<li>
<a href="#">Link 1</a>
</li>
<li>
<a href="#">Link 2</a>
</li>
</ul>
</Navigation>
<Search />
<Region>
<h2>Region</h2>
<p>Example region with no focusable children.</p>
</Region>
</div>
import {useLandmark} from '@react-aria/landmark';
import {useRef} from 'react';
function Navigation(
props
) {
let ref = useRef();
let { landmarkProps } =
useLandmark({
...props,
role: 'navigation'
}, ref);
return (
<nav
ref={ref}
{...props}
{...landmarkProps}
>
{props.children}
</nav>
);
}
function Region(props) {
let ref = useRef();
let { landmarkProps } =
useLandmark({
...props,
role: 'region'
}, ref);
return (
<article
ref={ref}
{...props}
{...landmarkProps}
>
{props.children}
</article>
);
}
function Search(props) {
let ref = useRef();
let { landmarkProps } =
useLandmark({
...props,
role: 'search'
}, ref);
return (
<form
ref={ref}
{...props}
{...landmarkProps}
>
<h2 id="search-header">
Search
</h2>
<input
aria-labelledby="search-header"
type="search"
/>
</form>
);
}
<div>
<Navigation>
<h2>Navigation</h2>
<ul>
<li>
<a href="#">
Link 1
</a>
</li>
<li>
<a href="#">
Link 2
</a>
</li>
</ul>
</Navigation>
<Search />
<Region>
<h2>Region</h2>
<p>
Example region
with no focusable
children.
</p>
</Region>
</div>
Advanced usage#
Across iframes#
In order to support navigating between landmarks across iframes, some additional setup is required.
A custom react-aria-landmark-navigation event is dispatched when navigating between landmarks.
Applications can listen for this custom event and redirect focus to the proper landmark when going into or out of an iframe.
import {createLandmarkController, useLandmark} from '@react-aria/landmark';
import {useEffect, useMemo, useRef} from 'react';
function Example() {
// Create a controller instance to manage focus between landmarks.
let controller = React.useMemo(() => createLandmarkController(), []);
// Cleanup the controller when the component unmounts.
useEffect(() => () => controller.dispose(), [controller]);
// Setup script for the iframe
let onLoad = (e: SyntheticEvent) => {
let iframe = e.target as HTMLIFrameElement;
let window = iframe.contentWindow;
let document = window?.document;
if (!window || !document) {
return;
}
let prevFocusedElement: HTMLElement | null = null;
// Listen for landmark navigation events from the parent page.
window.addEventListener(
'react-aria-landmark-navigation',
((e: CustomEvent) => {
e.preventDefault();
if (!window || !document) {
return;
}
let el = document.activeElement as HTMLElement;
if (el !== document.body) {
prevFocusedElement = el;
}
// Prevent focus scope from stealing focus back when we move focus to the iframe.
document.body.setAttribute('data-react-aria-top-layer', 'true');
// Notify the parent page that we are navigating between landmarks.
window.parent.postMessage({
type: 'landmark-navigation',
direction: e.detail.direction
});
// Wait a bit before removing the attribute so that focus is restored properly.
setTimeout(() => {
document?.body.removeAttribute('data-react-aria-top-layer');
}, 100);
}) as EventListener
);
// When the iframe is re-focused, restore focus back inside where it was before.
window.addEventListener('focus', () => {
if (prevFocusedElement) {
prevFocusedElement.focus();
prevFocusedElement = null;
}
});
// Move focus to first or last landmark when we receive a message from the parent page.
window.addEventListener('message', (e) => {
if (e.data.type === 'landmark-navigation') {
// (Can't use LandmarkController in this example because we need the controller instance inside the iframe)
document?.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'F6',
shiftKey: e.data.direction === 'backward',
bubbles: true
})
);
}
});
};
let ref = useRef<HTMLIFrameElement>(null);
useEffect(() => {
let onMessage = (e: MessageEvent) => {
let iframe = ref.current;
if (e.data.type === 'landmark-navigation') {
// Move focus to the iframe so that when focus is restored there, and we can redirect it back inside (below).
iframe?.focus();
// Now re-dispatch the keyboard event so landmark navigation outside the iframe picks it up.
controller.navigate(e.data.direction);
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [controller]);
let { landmarkProps } = useLandmark({
role: 'main',
focus(direction) {
// When iframe landmark receives focus via landmark navigation, go to first/last landmark inside iframe.
ref.current?.contentWindow?.postMessage({
type: 'landmark-navigation',
direction
});
}
}, ref);
return (
<>
{/* Rest of page */}
<iframe
ref={ref}
{...landmarkProps}
title="iframe"
src="iframe.html"
onLoad={onLoad}
tabIndex={-1}
/>
</>
);
}
import {
createLandmarkController,
useLandmark
} from '@react-aria/landmark';
import {useEffect, useMemo, useRef} from 'react';
function Example() {
// Create a controller instance to manage focus between landmarks.
let controller = React.useMemo(
() => createLandmarkController(),
[]
);
// Cleanup the controller when the component unmounts.
useEffect(() => () => controller.dispose(), [controller]);
// Setup script for the iframe
let onLoad = (e: SyntheticEvent) => {
let iframe = e.target as HTMLIFrameElement;
let window = iframe.contentWindow;
let document = window?.document;
if (!window || !document) {
return;
}
let prevFocusedElement: HTMLElement | null = null;
// Listen for landmark navigation events from the parent page.
window.addEventListener(
'react-aria-landmark-navigation',
((e: CustomEvent) => {
e.preventDefault();
if (!window || !document) {
return;
}
let el = document.activeElement as HTMLElement;
if (el !== document.body) {
prevFocusedElement = el;
}
// Prevent focus scope from stealing focus back when we move focus to the iframe.
document.body.setAttribute(
'data-react-aria-top-layer',
'true'
);
// Notify the parent page that we are navigating between landmarks.
window.parent.postMessage({
type: 'landmark-navigation',
direction: e.detail.direction
});
// Wait a bit before removing the attribute so that focus is restored properly.
setTimeout(() => {
document?.body.removeAttribute(
'data-react-aria-top-layer'
);
}, 100);
}) as EventListener
);
// When the iframe is re-focused, restore focus back inside where it was before.
window.addEventListener('focus', () => {
if (prevFocusedElement) {
prevFocusedElement.focus();
prevFocusedElement = null;
}
});
// Move focus to first or last landmark when we receive a message from the parent page.
window.addEventListener('message', (e) => {
if (e.data.type === 'landmark-navigation') {
// (Can't use LandmarkController in this example because we need the controller instance inside the iframe)
document?.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'F6',
shiftKey: e.data.direction === 'backward',
bubbles: true
})
);
}
});
};
let ref = useRef<HTMLIFrameElement>(null);
useEffect(() => {
let onMessage = (e: MessageEvent) => {
let iframe = ref.current;
if (e.data.type === 'landmark-navigation') {
// Move focus to the iframe so that when focus is restored there, and we can redirect it back inside (below).
iframe?.focus();
// Now re-dispatch the keyboard event so landmark navigation outside the iframe picks it up.
controller.navigate(e.data.direction);
}
};
window.addEventListener('message', onMessage);
return () =>
window.removeEventListener('message', onMessage);
}, [controller]);
let { landmarkProps } = useLandmark({
role: 'main',
focus(direction) {
// When iframe landmark receives focus via landmark navigation, go to first/last landmark inside iframe.
ref.current?.contentWindow?.postMessage({
type: 'landmark-navigation',
direction
});
}
}, ref);
return (
<>
{/* Rest of page */}
<iframe
ref={ref}
{...landmarkProps}
title="iframe"
src="iframe.html"
onLoad={onLoad}
tabIndex={-1}
/>
</>
);
}
import {
createLandmarkController,
useLandmark
} from '@react-aria/landmark';
import {
useEffect,
useMemo,
useRef
} from 'react';
function Example() {
// Create a controller instance to manage focus between landmarks.
let controller = React
.useMemo(
() =>
createLandmarkController(),
[]
);
// Cleanup the controller when the component unmounts.
useEffect(
() => () =>
controller
.dispose(),
[controller]
);
// Setup script for the iframe
let onLoad = (
e: SyntheticEvent
) => {
let iframe = e
.target as HTMLIFrameElement;
let window =
iframe
.contentWindow;
let document = window
?.document;
if (
!window ||
!document
) {
return;
}
let prevFocusedElement:
| HTMLElement
| null = null;
// Listen for landmark navigation events from the parent page.
window
.addEventListener(
'react-aria-landmark-navigation',
((
e: CustomEvent
) => {
e.preventDefault();
if (
!window ||
!document
) {
return;
}
let el =
document
.activeElement as HTMLElement;
if (
el !==
document
.body
) {
prevFocusedElement =
el;
}
// Prevent focus scope from stealing focus back when we move focus to the iframe.
document.body
.setAttribute(
'data-react-aria-top-layer',
'true'
);
// Notify the parent page that we are navigating between landmarks.
window.parent
.postMessage(
{
type:
'landmark-navigation',
direction:
e.detail
.direction
}
);
// Wait a bit before removing the attribute so that focus is restored properly.
setTimeout(
() => {
document
?.body
.removeAttribute(
'data-react-aria-top-layer'
);
},
100
);
}) as EventListener
);
// When the iframe is re-focused, restore focus back inside where it was before.
window
.addEventListener(
'focus',
() => {
if (
prevFocusedElement
) {
prevFocusedElement
.focus();
prevFocusedElement =
null;
}
}
);
// Move focus to first or last landmark when we receive a message from the parent page.
window
.addEventListener(
'message',
(e) => {
if (
e.data
.type ===
'landmark-navigation'
) {
// (Can't use LandmarkController in this example because we need the controller instance inside the iframe)
document
?.body
.dispatchEvent(
new KeyboardEvent(
'keydown',
{
key:
'F6',
shiftKey:
e.data
.direction ===
'backward',
bubbles:
true
}
)
);
}
}
);
};
let ref = useRef<
HTMLIFrameElement
>(null);
useEffect(() => {
let onMessage = (
e: MessageEvent
) => {
let iframe =
ref.current;
if (
e.data.type ===
'landmark-navigation'
) {
// Move focus to the iframe so that when focus is restored there, and we can redirect it back inside (below).
iframe?.focus();
// Now re-dispatch the keyboard event so landmark navigation outside the iframe picks it up.
controller
.navigate(
e.data
.direction
);
}
};
window
.addEventListener(
'message',
onMessage
);
return () =>
window
.removeEventListener(
'message',
onMessage
);
}, [controller]);
let { landmarkProps } =
useLandmark({
role: 'main',
focus(direction) {
// When iframe landmark receives focus via landmark navigation, go to first/last landmark inside iframe.
ref.current
?.contentWindow
?.postMessage({
type:
'landmark-navigation',
direction
});
}
}, ref);
return (
<>
{/* Rest of page */}
<iframe
ref={ref}
{...landmarkProps}
title="iframe"
src="iframe.html"
onLoad={onLoad}
tabIndex={-1}
/>
</>
);
}