feat(live-region): add support for <live-region> element
Overview
This is a proof of concept for a live-region custom element that is used in coordination with our components to provide accessible announcements in live regions.
tl;dr
We'd love to make announcements to live regions without them being dropped. This PR adds in components, hooks, and a custom element to make the following API possible:
function SpinnerExample() {
const [loading, setLoading] = React.useState(false)
const buttonRef = React.useRef<HTMLButtonElement>(null)
const announce = useAnnounce()
const setTimeout = useSafeTimeout();
function onClick() {
setLoading(true)
setTimeout(() => {
setLoading(false)
buttonRef.current?.focus()
announce('Spinner example complete')
}, 2500)
}
return (
<>
<button ref={buttonRef} onClick={onClick}>
Start loading
</button>
// When true, `Spinner` makes an announcement to a live region with the
// text from `loadingMessage`
{loading ? <Spinner loadingMessage="Spinner example loading" /> : null}
</>
)
}
Problem
When making announcements through live regions, the container for a live region must exist in the document before the announcement in made. When this is not the case, the announcement will be dropped.
The core challenge for making announcements is when a component is rendered conditionally. In these scenarios, we cannot rely on a stable live region within the component. Instead, we would require one of the following approaches:
- Require a live region to be provided via context at the root level
- Dynamically find, or create, a live region and buffer messages sent to that live region
This PR introduces a direction for both approaches through a combination of components and hooks.
Approach
This PR introduces components, hooks, and a <live-region> custom element in order to solve the problems mentioned above.
[!NOTE] This PR includes a change to the
Spinnercomponent. This change is illustrative of theStatusAPI specifically and is not a proposal for the final API of this component.
Components
LiveRegionProvider and LiveRegion are two internal components that are used to render and provide a <live-region> for a tree of React components. These components can be used at the top-level of an application or can be used within a component.
function MyApp({ children }) {
return (
<LiveRegionProvider>
{children}
<LiveRegion />
</LiveRegion>
);
}
In the example above, LiveRegionProvider acts as a context provider that provides children with access to a live-region element rendered by LiveRegion.
The Status and Alert components correspond the role="status" and role="alert" live regions. However, instead of injecting these containers into the DOM, these components announce their text content as a message in the corresponding live region.
function MyComponent() {
return (
<>
<Alert>Example assertive message</Alert>
<Status>
<VisuallyHidden>Example status message</VisuallyHidden>
</Status>
<ExampleContent />
</>
);
}
Hooks
useLiveRegion and useAnnounce are two internal hooks used to either get or interact with the live-region element in context.
useLiveRegion(): LiveRegionElementprovides access to thelive-regionin context- Note: this hook will dynamically find, or create, a
live-regionelement if one does not exist in context
- Note: this hook will dynamically find, or create, a
useAnnounce()provides anannounce()method to directly announce the message- Note: the value returned by this hook is a constant reference which makes it easier to use within
useEffect()hooks
- Note: the value returned by this hook is a constant reference which makes it easier to use within
function MyComponent() {
const announce = useAnnounce();
return (
<button onClick={() => {
announce('This is an example')
}}>
Test announcement
</button>
);
}
<live-region> custom element
The live-region custom element is an attempt at using a Custom Element to provide a common interop point between Primer and github/github. This custom element mirror methods used upstream in github/github, specifically announce() and announceFromElement(), and provides them from the element class directly.
const liveRegion = document.createElement('live-region');
document.documentElement.appendChild(liveRegion);
liveRegion.announce('Test announcement');
liveRegion.announce('Test announcement with delay', {
delayMs: 500,
});
liveRegion.announce('Test assertive announcement', {
politeness: 'assertive',
});
Detailed design
LiveRegionProvider, LiveRegion
The LiveRegionProvider component is used to provide a live-region custom element to children. The LiveRegion component renders the live-region and sets the context value to that live-region.
Within LiveRegion, we use declarative shadow DOM to make sure that the structure is present in the document before JS hydrates.
Status, Alert
The Status and Alert components are used for one-off messages when a component first renders. They correspond to role="status" and role="alert" regions and will create an announcement based on the text content of the children passed to this component.
At the moment, these components will not create announcements if their contents change.
These components accept some of the options from AnnounceOptions on LiveRegionElement but do not allow changing the politeness setting. By default, politeness will map to the corresponding defaults for the role.
function MyComponent() {
return (
<>
<Status>This is a visible, polite message</Status>
<Alert>This is an visible, assertive message</Alert>
<Status>
<p>This is announced since it is the text content of Status</p>
</Status>
<Status>
<VisuallyHidden>This is announced but is visually hidden</VisuallyHidden>
</Status>
</>
);
}
useLiveRegion
This hook provides a reference to a live-region element. With this hook, components can interact with the live-region directly. However, there is an important practice to follow when using this hook. Specifically, the value of this hook should not be present in useEffect() or similar dependency arrays as these effects should not synchronize with changes to the identity of this element.
function MyComponent() {
const liveRegion = useLiveRegion();
// Note: this is an anti-pattern because the announcement
// would be unintentionally made on changes to `liveRegion`
// from context
useEffect(() => {
liveRegion.announce(message);
}, [liveRegion, message]);
}
Instead, the identity of liveRegion should be saved to a ref using the following pattern:
function MyComponent() {
const liveRegion = useLiveRegion();
const savedLiveRegion = useRef(liveRegion);
useEffect(() => {
savedLiveRegion.current = liveRegion;
}, [liveRegion]);
useEffect(() => {
liveRegion.current.announce(message);
}, [message]);
}
This pattern de-couples the effect firing from the identity of liveRegion changing.
As a result, it is preferable to use hooks like useAnnounce that abstracts this pattern.
useAnnounce
The useAnnounce() hook provides a stable announce() method that mirrors the function from LiveRegionElement#announce. This stable reference makes it reliable within effects and, as a result, should not be listed in dependency arrays.
<live-region> design
live-region is a custom element, LiveRegionElement, that has the following shadow DOM structure:
<style>
:host {
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
</style>
<div id="polite" aria-live="polite" aria-atomic="true"></div>
<div id="assertive" aria-live="assertive" aria-atomic="true"></div>
It provides two public methods, announce() and announceFromElement(), for live region announcements. The idea behind using a custom element is that it could provide a common interop point across Primer and GitHub since the element would be named live-region and would expose consistent methods for announcements.
Buffering
One technical note is that this element implements announcement buffering. Specifically, if announce() or announceFromElement are called before the element is connected (in the DOM) then they will wait until the element is in the DOM before being announced.
This is a technical detail in order to support our conditional rendering challenge above. Specifically, we may call announce() on an element before it is technically in the DOM. Implementing this buffer allows us to make sure these messages do not get dropped.
Bundling
We need to shim certain DOM globals that are used when authoring custom elements in a Node.js environment. As a result, this PR updates our rollup config to add node specific entrypoints that uses @lit-labs/ssr-dom-shim to safely call this code during SSR.
Feedback
This PR is a lot and for that I am sorry 😅 Trying to address this problem lead me to go very deep in this problem space. Let me know if there would be a better way to present this information!
I would love the following feedback on this PR:
- What did you think of the problem statement?
- Is it clear?
- Did the challenges identified resonate with you?
- If not, let me know why!
- What did you think of the approach? Do you feel like there are other approaches worth exploring in this space?
- What did you think of the design? Did it make sense? Anything you would change?
- How do you feel about moving forward with this approach? Anything you're worried about or anything you'd want addressed beforehand?
Also feel free to leave other feedback if there is something missing above 😄
Questions
When should someone use delayMs? What are valid values for it?
⚠️ No Changeset found
Latest commit: 7815b521ee7a762fa20a2d214e12e04bee1661e2
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.
This PR includes no changesets
When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types
Click here to learn what changesets are, and how to add one.
Click here if you're a maintainer who wants to add a changeset to this PR
size-limit report 📦
| Path | Size |
|---|---|
| dist/browser.esm.js | 106.98 KB (+0.95% 🔺) |
| dist/browser.umd.js | 107.61 KB (+0.9% 🔺) |
Note: looking into the deploy preview problem now to see what we can do. Unfortunately we directly wire things up to @primer/react from docs so the node export conditions aren't being picked up 😅
What did you think of the problem statement?
It is clear and I've run into the challenges identified multiple times.
What did you think of the approach? Do you feel like there are other approaches worth exploring in this space?
I love this approach. We could also explore React portals, but I think this is better.
What did you think of the design? Did it make sense? Anything you would change?
Before looking at the source, my impression was that the hook's API design is very straightforward and intuitive. However, I found the following things to be a little confusing about the components:
- I was confused about what
<LiveRegion>does and why a<LiveRegionOutlet>is also needed. Which one creates a live region? - When would I want to use
<Status>instead of<LiveRegion>? - Why would I want to use a
<LiveRegion>instead of just using theuseAnnounce()hook? - Do I need to render a
<LiveRegion>in order to use theuseAnnounce()hook?
After reviewing the PR, I think I can answer all of those questions. However, I still have these questions:
- When would I use
<Message>? - Why don't we use dot notation for
<LiveRegionOutlet>like we do for other components with parent-child relationships? - Should we consider exporting some of these hooks and components for Primer consumers to use in their applications?
How do you feel about moving forward with this approach? Anything you're worried about or anything you'd want addressed beforehand?
I feel good about it assuming that we're going to do some screen reader testing. I really think we should add some usage examples to Storybook.
@mperrotti thanks so much for the review! Appreciate it a ton. Will respond to some of the questions below 👇
When would I use <Message>?
Hopefully never 😅 This is in place just so DataTable keeps working.
Why don't we use dot notation for <LiveRegionOutlet> like we do for other components with parent-child relationships?
We could definitely use this style if we expose it in our public API. Right now it's an internal component which doesn't typically use that convention.
Should we consider exporting some of these hooks and components for Primer consumers to use in their applications?
Definitely! I think it'd be incredibly helpful. Depending on how the feedback goes for this I would love to bring it to accessibility and see if this would be a good interop point for announcements at GitHub 🤞
Some of the other ones that I thought were great questions and will add them to the questions section in the PR 🔥
I was confused about what <LiveRegion> does and why a <LiveRegionOutlet> is also needed. Which one creates a live region?
It can be helpful to think of LiveRegion as the context provider (or even LiveRegionProvider) and LiveRegionOutlet as what is actually rendering the live-region element.
Totally get that this is confusing naming-wise and we can definitely change that, maybe to LiveRegionProvider and LiveRegion to make it more obvious?
When would I want to use <Status> instead of <LiveRegion>?
I think LiveRegion will be used to setup the live region at the top-level of an application, or component, boundary. Status and useAnnounce should be used in components that want to make announcements.
Why would I want to use a <LiveRegion> instead of just using the useAnnounce() hook?
LiveRegion is used to make sure that the live region element exists on the page before making any announcements to it. Otherwise, one will be dynamically created and added to the DOM. A lot of this framing definitely comes from the idea that we want the live region in the DOM before making announcements to it.
Hopefully for most people they just interact with useAnnounce() and never have to think about LiveRegion and that can just sit at the root level of the application. There is a special case here for dialog though in that we probably want all dialogs to have their own live region so that components within that need live regions have one available in a non-inert area when the dialog is open.
Do I need to render a <LiveRegion> in order to use the useAnnounce() hook?
Surprisingly no, it seems like it will work with a dynamic live region plus buffered announcements but will need more testing to make sure 🤞
Totally get that this is confusing naming-wise and we can definitely change that, maybe to
LiveRegionProviderandLiveRegionto make it more obvious?
Yea, that name change makes things a lot clearer 🙂
@mperrotti awesome, done! ✨
One quick update, the Status component has been updated after some feedback. This component, along with a new Alert component, now announce their contents instead of a dedicated message prop.
For more info, checkout the Status, Alert section above.
@joshblack I like this, feels more "React-y" that what we're using already. We should discuss this with the accessibility team if we haven't already since it'll presumably replace the existing aria-live.ts stuff that we use in dotcom at the moment. I wonder if there's any way to bridge the gap between the two to allow Rails developers to use the same <aria-live> element 🤔
Closing as this work has moved out into:
https://github.com/primer/live-region-element https://github.com/primer/react/pull/4313