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

[Alert]: Make the Alert itself aria-live to dedupe screen reader announcement #906

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 15 additions & 154 deletions packages/alert/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* readers, and in some operating systems, they may trigger an alert sound.
*
* The approach here is to allow developers to render a visual <Alert> and then
* we mirror that to a couple of aria-live regions behind the scenes. This way,
* we treat it as an aria-live region behind the scenes. This way,
* most of the time, developers don't have to think about visual vs. aria
* alerts.
*
Expand All @@ -23,38 +23,11 @@
* @see WAI-ARIA https://www.w3.org/TR/wai-aria-practices-1.2/#alert
*/
import * as React from "react";
import * as ReactDOM from "react-dom";
import { VisuallyHidden } from "@reach/visually-hidden";
import { usePrevious } from "@reach/utils/use-previous";
import { getOwnerDocument } from "@reach/utils/owner-document";
import { useComposedRefs } from "@reach/utils/compose-refs";
import PropTypes from "prop-types";

import type * as Polymorphic from "@reach/utils/polymorphic";

/*
* Singleton state is fine because you don't server render
* an alert (SRs don't read them on first load anyway)
*/
let keys: RegionKeys = {
polite: -1,
assertive: -1,
};

let elements: ElementTypes = {
polite: {},
assertive: {},
};

let liveRegions: RegionElements = {
polite: null,
assertive: null,
};

let renderTimer: number | null;

////////////////////////////////////////////////////////////////////////////////

/**
* Alert
*
Expand All @@ -72,14 +45,26 @@ const Alert = React.forwardRef(function Alert(
const ref = useComposedRefs(forwardedRef, ownRef);
const child = React.useMemo(
() => (
<Comp {...props} ref={ref} data-reach-alert>
<Comp
{...props}
ref={ref}
data-reach-alert
// The status role is a type of live region and a container whose
// content is advisory information for the user that is not
// important enough to justify an alert, and is often presented as
// a status bar. When the role is added to an element, the browser
// will send out an accessible status event to assistive
// technology products which can then notify the user about it.
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_status_role
role={regionType === "assertive" ? "alert" : "status"}
aria-live={regionType}
>
{children}
</Comp>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[children, props]
);
useMirrorEffects(regionType, child, ownRef);

return child;
}) as Polymorphic.ForwardRefComponent<"div", AlertProps>;
Expand All @@ -106,130 +91,6 @@ if (__DEV__) {
};
}

////////////////////////////////////////////////////////////////////////////////

function createMirror(type: "polite" | "assertive", doc: Document): Mirror {
let key = ++keys[type];

let mount = (element: JSX.Element) => {
if (liveRegions[type]) {
elements[type][key] = element;
renderAlerts();
} else {
let node = doc.createElement("div");
node.setAttribute(`data-reach-live-${type}`, "true");
liveRegions[type] = node;
doc.body.appendChild(liveRegions[type]!);
mount(element);
}
};

let update = (element: JSX.Element) => {
elements[type][key] = element;
renderAlerts();
};

let unmount = () => {
delete elements[type][key];
renderAlerts();
};

return { mount, update, unmount };
}

function renderAlerts() {
if (renderTimer != null) {
window.clearTimeout(renderTimer);
}
renderTimer = window.setTimeout(() => {
Object.keys(elements).forEach((elementType) => {
let regionType: RegionTypes = elementType as RegionTypes;
let container = liveRegions[regionType]!;
if (container) {
ReactDOM.render(
<VisuallyHidden as="div">
<div
// The status role is a type of live region and a container whose
// content is advisory information for the user that is not
// important enough to justify an alert, and is often presented as
// a status bar. When the role is added to an element, the browser
// will send out an accessible status event to assistive
// technology products which can then notify the user about it.
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_status_role
role={regionType === "assertive" ? "alert" : "status"}
aria-live={regionType}
>
{Object.keys(elements[regionType]).map((key) =>
React.cloneElement(elements[regionType][key], {
key,
ref: null,
})
)}
</div>
</VisuallyHidden>,
liveRegions[regionType]
);
}
});
}, 500);
}

function useMirrorEffects(
regionType: RegionTypes,
element: JSX.Element,
ref: React.RefObject<Element>
) {
const prevType = usePrevious<RegionTypes>(regionType);
const mirror = React.useRef<Mirror | null>(null);
const mounted = React.useRef(false);
React.useEffect(() => {
const ownerDocument = getOwnerDocument(ref.current)!;

if (!mounted.current) {
mounted.current = true;
mirror.current = createMirror(regionType, ownerDocument);
mirror.current.mount(element);
} else if (prevType !== regionType) {
mirror.current && mirror.current.unmount();
mirror.current = createMirror(regionType, ownerDocument);
mirror.current.mount(element);
} else {
mirror.current && mirror.current.update(element);
}
}, [element, regionType, prevType, ref]);

React.useEffect(() => {
return () => {
mirror.current && mirror.current.unmount();
};
}, []);
}

////////////////////////////////////////////////////////////////////////////////
// Types

type Mirror = {
mount: (element: JSX.Element) => void;
update: (element: JSX.Element) => void;
unmount: () => void;
};

type RegionTypes = "polite" | "assertive";

type ElementTypes = {
[key in RegionTypes]: {
[key: string]: JSX.Element;
};
};

type RegionElements<T extends HTMLElement = HTMLDivElement> = {
[key in RegionTypes]: T | null;
};

type RegionKeys = {
[key in RegionTypes]: number;
};

////////////////////////////////////////////////////////////////////////////////
// Exports

Expand Down