Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(live-region): add support for <live-region> element #4174

Closed
wants to merge 17 commits into from

Conversation

joshblack
Copy link
Member

@joshblack joshblack commented Jan 23, 2024

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 Spinner component. This change is illustrative of the Status API 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(): LiveRegionElement provides access to the live-region in context
    • Note: this hook will dynamically find, or create, a live-region element if one does not exist in context
  • useAnnounce() provides an announce() 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
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?

Copy link

changeset-bot bot commented Jan 23, 2024

⚠️ No Changeset found

Latest commit: 7815b52

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

Copy link
Contributor

github-actions bot commented Jan 23, 2024

size-limit report 📦

Path Size
dist/browser.esm.js 106.98 KB (+0.95% 🔺)
dist/browser.umd.js 107.61 KB (+0.9% 🔺)

@joshblack
Copy link
Member Author

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 😅

@joshblack joshblack marked this pull request as ready for review January 23, 2024 20:02
@joshblack joshblack requested review from a team and langermank January 23, 2024 20:02
Copy link
Contributor

@mperrotti mperrotti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, excellent work! I want to "approve", but I (unfortunately) don't have much experience with custom elements yet, and would love to get feedback from folks who do.

@mperrotti
Copy link
Contributor

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 the useAnnounce() hook?
  • Do I need to render a <LiveRegion> in order to use the useAnnounce() 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.

@joshblack
Copy link
Member Author

joshblack commented Jan 24, 2024

@mperrotti thanks so much for the review! Appreciate it a ton. Will respond to some of the questions below 👇

When would I use ?

Hopefully never 😅 This is in place just so DataTable keeps working.

Why don't we use dot notation for 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 does and why a 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 instead of ?

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 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 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 🤞

@mperrotti
Copy link
Contributor

Totally get that this is confusing naming-wise and we can definitely change that, maybe to LiveRegionProvider and LiveRegion to make it more obvious?

Yea, that name change makes things a lot clearer 🙂

@joshblack
Copy link
Member Author

@mperrotti awesome, done! ✨

@joshblack
Copy link
Member Author

joshblack commented Jan 25, 2024

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.

@owenniblock
Copy link
Contributor

@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 🤔

@joshblack
Copy link
Member Author

Closing as this work has moved out into:

https://github.com/primer/live-region-element
#4313

@joshblack joshblack closed this Mar 8, 2024
@joshblack joshblack deleted the feat/add-live-region-custom-element branch March 8, 2024 15:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants