|
| 1 | +import { Meta } from '@storybook/addon-docs/blocks'; |
| 2 | + |
| 3 | +<Meta title="Concepts/Developer/Accessibility/Notification Best Practices" /> |
| 4 | + |
| 5 | +# Notification Best Practices |
| 6 | + |
| 7 | +## Using DOM-based live regions |
| 8 | + |
| 9 | +There are functionally three ways to create a live region in the DOM: |
| 10 | + |
| 11 | +1. **Use `aria-live="assertive"` or `aria-live="polite"`** |
| 12 | + Using `aria-live` to designate an element as a live region is the most common way to do so. In theory, the difference between `assertive` and `polite` affects how where notification text is inserted in the screen reader's speech queue. However, in practice this is not consistent between operating systems and screen readers. Generally use `aria-live="assertive"` when the notification is likely more important than a user's current interaction, and use `aria-live="polite"` when the notification is less important than what the user is currently doing. |
| 13 | +2. **Use `role="alert"`** |
| 14 | + The `alert` approach is best used for error messages such as form errors or high-importance toasts. Some screen readers say "alert" or a similar word before the text of the live region element. It is the only live region that consistently gets announced when inserted into the DOM, rather than only on subsequent child mutations. |
| 15 | +3. ** Use `role="status"`** |
| 16 | + This is largely equivalent to using `aria-live="polite"`. It is possible that some screen readers may announce it slightly differently, such as saying "status" before the message. This does not currently happen in any screen reader as of writing, however. |
| 17 | + |
| 18 | +All these attributes will designate an element as a live region, and screen reader notifications will be triggered by any change to its children. For this reason, it is extremely important to never use live region attributes on an element that will have frequent mutations. Generally elements that have a lot of child content also should not be live regions. |
| 19 | + |
| 20 | +In general, live regions should be: |
| 21 | + |
| 22 | +- short -- in both text content and number & complexity of child nodes |
| 23 | +- stable -- only mutating infrequently when a user-relevant event occurs |
| 24 | +- avoid conflicts with user events -- live regions should not be designed to fire at the same time as a focus change or user input event, since doing so will conflict with screen reader announcements of those events. |
| 25 | + |
| 26 | +## Using Fluent's `useAnnounce` hook |
| 27 | + |
| 28 | +There are multiple advantages to using the built-in Fluent `AriaLiveAnnouncer` + `useAnnounce` hook to handle live regions instead of building your own in the DOM: |
| 29 | + |
| 30 | +1. We use the new `document.ariaNotify` API in browsers where it is supported, with a fallback to a DOM-based live region in browsers without support. The `ariaNotify` API has several advantages, including better support for announcements when a modal is open, better performance in apps with a very large & complex DOM, better observability and debugging support, and it easier to unit test. |
| 31 | +2. You can call it once at exactly the time the announcement is desired, instead of trying to manage text (and removing text) in a rendered live region node. |
| 32 | +3. You can call `announce()` at any time, even when the component is first mounted. You do not need to manually ensure an empty live region node exists in the DOM first, before inserting the desired announcement text into it. |
| 33 | +4. You sidestep the problem of unintentional other updates to children of a rendered live region node causing the live region to announce again. |
| 34 | + |
| 35 | +While it is possible to directly use `ariaNotify` without using the Fluent Announce utility, we recommend waiting until the API is more stable than at the time of writing. Fluent has been working with both the spec and browser implementors, which is why we have it implemented in its early stages. It would also be necessary to include a polyfill until browser support meets the needs of your site or app. |
| 36 | + |
| 37 | +### Step 1: ensure an `AriaLiveAnnouncer` or custom implementation exists |
| 38 | + |
| 39 | +The `useAnnounce` hook's `announce()` function looks for the closest `AnnounceContext`, which is where the actual live region implementation exists. Ideally the `AriaLiveAnnouncer` or custom `AnnounceContext` should be added once at the root of the application. |
| 40 | + |
| 41 | +There are a couple cases where one might add an additional nested `AriaLiveAnnouncer`: |
| 42 | + |
| 43 | +- Your team's UI may be used in any of several places, and you can't guarantee the wrapping app has its own `AriaLiveAnnouncer` |
| 44 | +- You have UI rendered within an iframe |
| 45 | + |
| 46 | +It would usually look something like this, in the same place other top-level providers are defined: |
| 47 | + |
| 48 | +```tsx |
| 49 | +<FluentProvider theme={webLightTheme}> |
| 50 | + <AriaLiveAnnouncer>{...children}</AriaLiveAnnouncer> |
| 51 | +</FluentProvider> |
| 52 | +``` |
| 53 | + |
| 54 | +### Step 2: Import `useAnnounce` and call `announce()` when desired |
| 55 | + |
| 56 | +At the component level, import and call `useAnnounce` to get the `announce` function, and call `announce` where desired. |
| 57 | + |
| 58 | +For example, this is how you would fire an announcement in response to an attachment uploading: |
| 59 | + |
| 60 | +In the imports section: |
| 61 | + |
| 62 | +```tsx |
| 63 | +import { useAnnounce } from '@fluentui/react-components'; |
| 64 | +``` |
| 65 | + |
| 66 | +And within the component function: |
| 67 | + |
| 68 | +```tsx |
| 69 | +const { announce } = useAnnounce(); |
| 70 | + |
| 71 | +onLoad = attachment => { |
| 72 | + announce(`finished uploading ${attachment.name}`); |
| 73 | + |
| 74 | + // other onLoad logic |
| 75 | +}; |
| 76 | +``` |
| 77 | + |
| 78 | +## Common mistakes |
| 79 | + |
| 80 | +### 1. Localization |
| 81 | + |
| 82 | +Since the text of screen reader announcements is often either not displayed visually, or slightly different than the text displayed visually, it is easy to forget and not catch when it isn't localized. Ensure any strings used in live region messages are pulled from imported localized strings (whether using Fluent's `useAnnounce` or custom live regions). |
| 83 | + |
| 84 | +### 2. Wrapping large regions in a live region node |
| 85 | + |
| 86 | +A common example of this is putting `aria-live` on an element that wraps an entire chat message list, or a table whose cells can frequently update. |
| 87 | + |
| 88 | +Never wrap a large amount of content, and especially complex DOM hierarchy in a live region node. |
| 89 | + |
| 90 | +### 3. Using `aria-relevant` or `aria-atomic` |
| 91 | + |
| 92 | +These attributes do not have consistent cross-browser, cross-screen-reader, and cross-platform support and should not be used. Instead, ensure the text of any live region message is specifically tailored to the update that needs to be conveyed. Never wrap a large amount of content with multiple possible types of DOM updates in a live region and expect `aria-relevant` or `aria-atomic` to prevent all the problems that come with that approach. |
| 93 | + |
| 94 | +### 4. Putting an editable form field or contenteditable element in a live region |
| 95 | + |
| 96 | +User-editable fields like inputs, checkboxes, selects, dropdowns, and contenteditable elements should never be live regions, or be inside live regions. When this happens, every user interaction can cause the live region to fire in some browsers and screen readers, causing the form field or editable region to be effectively unusable for screen reader users. |
| 97 | + |
| 98 | +### 5. Inserting a live region node into the DOM with child content, and expecting that child content to be read |
| 99 | + |
| 100 | +This applies to custom DOM-based live regions, not to the Fluent announce utility. |
| 101 | + |
| 102 | +When making custom live region nodes, any approach other than `role="alert"` _must_ exist in the DOM before text is inserted in order to work as expected. Live region nodes read updates, not text on insertion. In the past, this has worked in Narrator, but not in any other screen reader. |
| 103 | + |
| 104 | +Only `role="alert"` will read its content when it is first inserted into the DOM. However, `role="alert"` should only be used for errors and alerts, since it is sometimes announced differently by screen readers than other live regions (e.g. by playing a sound or saying "alert" before the text of the message). |
| 105 | + |
| 106 | +### 6. Calling `announce()` or updating a live region inside a `useEffect` that runs more than intended |
| 107 | + |
| 108 | +One common cause of screen reader announcements running repeatedly when not intended is triggering them within a `useEffect` that has dependencies that update outside of the intended announcement trigger. |
| 109 | + |
| 110 | +For example, here is a `useEffect` that both calls `announce` and an optional callback function in response to a loading state change, and accidentally triggers announcements even outside of the loading changes: |
| 111 | + |
| 112 | +```tsx |
| 113 | +useEffect(() => { |
| 114 | + if (!loading) { |
| 115 | + announce('loading complete'); |
| 116 | + props.onLoad?.(); |
| 117 | + } |
| 118 | +}, [loading, props.onLoad]); |
| 119 | +``` |
| 120 | + |
| 121 | +The issue is that if the `props.onLoad` function isn't wrapped in something like `useCallback` or `useMemo` (or is, but one of those dependencies changes), the "loading complete" message will fire again even though the loading state did not change. |
| 122 | + |
| 123 | +### 7. Calling `announce` or triggering a live region in response to user text input |
| 124 | + |
| 125 | +The issue with this is that the announcement will conflict with the screen reader's default keyboard echo as the user types. In the worst case, this can make the text input unusable, since the user may not be able to hear themselves typing. Alternatively, they may hear themselves type, but entirely miss the announcement. This applies to both text inputs, textareas, and contenteditable regions. |
| 126 | + |
| 127 | +Instead, use the Fluent `useTypingAnnounce` hook, which will both batch and debounce any `typingAnnounce` calls and fire a single announcement 0.5s after the user ceases typing. |
| 128 | + |
| 129 | +Here is an example of using `useTypingAnnounce` to give the user a warning about approaching or exceeding the character limit on a text field: |
| 130 | + |
| 131 | +```tsx |
| 132 | +const announceId = useId('typing-announce'); |
| 133 | + |
| 134 | +const onChange = event => { |
| 135 | + const charCount = event.target.value.length; |
| 136 | + const isOverlimit = charCount > 20; |
| 137 | + setExceededLimit(isOverlimit); |
| 138 | + |
| 139 | + if (charCount > 15 && charCount <= 20) { |
| 140 | + typingAnnounce(`${20 - charCount} characters remaining`, { |
| 141 | + // setting the same batchId allows multiple messages to be batched, |
| 142 | + // so only the last typingAnnounce call's message is actually announced |
| 143 | + batchId: announceId, |
| 144 | + }); |
| 145 | + } |
| 146 | + |
| 147 | + if (isOverlimit) { |
| 148 | + typingAnnounce('You have reached the maximum character limit', { |
| 149 | + batchId: announceId, |
| 150 | + }); |
| 151 | + } |
| 152 | +}; |
| 153 | +``` |
0 commit comments