From eedc6b16ba3078025c636a8c1368f38f7f9b798d Mon Sep 17 00:00:00 2001 From: Josh Black Date: Fri, 28 Jun 2024 11:52:09 -0500 Subject: [PATCH] refactor(live-region): update live region helpers to match ADR (#4673) * refactor(live-region): update live region helpers to match ADR * fix: update file paths from moving to live-region folder * feat(live-region): add support for delayMs to announce * feat: add support for delayMs to Announce, AriaStatus, AriaAlert * chore: add changeset * docs: update stories * chore: fix eslint violations * docs: update jsdoc for hook * fix: update signature to include support for "as" * chore: add support for sx to components --------- Co-authored-by: Josh Black --- .changeset/five-humans-retire.md | 5 + .../src/Banner/Banner.examples.stories.tsx | 7 +- .../src/Spinner/Spinner.examples.stories.tsx | 8 +- .../src/Spinner/Spinner.features.stories.tsx | 4 +- .../__snapshots__/exports.test.ts.snap | 12 ++ .../src/drafts/SelectPanel2/SelectPanel.tsx | 7 +- packages/react/src/drafts/index.ts | 3 + .../react/src/internal/components/Alert.tsx | 12 -- .../src/internal/components/Announce.tsx | 71 --------- .../react/src/internal/components/Status.tsx | 12 -- .../components/__tests__/Status.test.tsx | 52 ------- .../src/internal/hooks/useEffectCallback.ts | 20 +++ .../Announce.features.stories.tsx} | 40 ++--- .../src/live-region/Announce.stories.tsx | 29 ++++ packages/react/src/live-region/Announce.tsx | 142 ++++++++++++++++++ .../AriaAlert.features.stories.tsx | 23 +++ .../src/live-region/AriaAlert.stories.tsx | 34 +++++ packages/react/src/live-region/AriaAlert.tsx | 37 +++++ .../AriaStatus.features.stories.tsx | 38 +++++ .../src/live-region/AriaStatus.stories.tsx | 40 +++++ packages/react/src/live-region/AriaStatus.tsx | 42 ++++++ .../__tests__/Announce.test.tsx | 42 ++++++ .../__tests__/AriaAlert.test.tsx} | 21 ++- .../live-region/__tests__/AriaStatus.test.tsx | 94 ++++++++++++ packages/react/src/live-region/index.ts | 8 + 25 files changed, 615 insertions(+), 188 deletions(-) create mode 100644 .changeset/five-humans-retire.md delete mode 100644 packages/react/src/internal/components/Alert.tsx delete mode 100644 packages/react/src/internal/components/Announce.tsx delete mode 100644 packages/react/src/internal/components/Status.tsx delete mode 100644 packages/react/src/internal/components/__tests__/Status.test.tsx create mode 100644 packages/react/src/internal/hooks/useEffectCallback.ts rename packages/react/src/{internal/components/Status.stories.tsx => live-region/Announce.features.stories.tsx} (65%) create mode 100644 packages/react/src/live-region/Announce.stories.tsx create mode 100644 packages/react/src/live-region/Announce.tsx create mode 100644 packages/react/src/live-region/AriaAlert.features.stories.tsx create mode 100644 packages/react/src/live-region/AriaAlert.stories.tsx create mode 100644 packages/react/src/live-region/AriaAlert.tsx create mode 100644 packages/react/src/live-region/AriaStatus.features.stories.tsx create mode 100644 packages/react/src/live-region/AriaStatus.stories.tsx create mode 100644 packages/react/src/live-region/AriaStatus.tsx rename packages/react/src/{internal/components => live-region}/__tests__/Announce.test.tsx (64%) rename packages/react/src/{internal/components/__tests__/Alert.test.tsx => live-region/__tests__/AriaAlert.test.tsx} (72%) create mode 100644 packages/react/src/live-region/__tests__/AriaStatus.test.tsx create mode 100644 packages/react/src/live-region/index.ts diff --git a/.changeset/five-humans-retire.md b/.changeset/five-humans-retire.md new file mode 100644 index 00000000000..8e414902821 --- /dev/null +++ b/.changeset/five-humans-retire.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add experimental support for the AriaStatus, AriaAlert, and Announce components diff --git a/packages/react/src/Banner/Banner.examples.stories.tsx b/packages/react/src/Banner/Banner.examples.stories.tsx index 2dee74f606c..84eea96bb0a 100644 --- a/packages/react/src/Banner/Banner.examples.stories.tsx +++ b/packages/react/src/Banner/Banner.examples.stories.tsx @@ -2,8 +2,7 @@ import {Banner} from '../Banner' import {action} from '@storybook/addon-actions' import Link from '../Link' import type {Meta} from '@storybook/react' -import {Status} from '../internal/components/Status' -import {Alert} from '../internal/components/Alert' +import {AriaAlert, AriaStatus} from '../live-region' import FormControl from '../FormControl' import RadioGroup from '../RadioGroup' import Radio from '../Radio' @@ -30,7 +29,7 @@ export const WithUserAction = () => { Something went wrong. Please try again later.} + description={Something went wrong. Please try again later.} variant="critical" /> ) : null} @@ -60,7 +59,7 @@ export const WithDynamicContent = () => { <> {messages.get(selected)}} + description={{messages.get(selected)}} onDismiss={action('onDismiss')} primaryAction={Button} secondaryAction={Button} diff --git a/packages/react/src/Spinner/Spinner.examples.stories.tsx b/packages/react/src/Spinner/Spinner.examples.stories.tsx index ed0c578942a..a79b8642783 100644 --- a/packages/react/src/Spinner/Spinner.examples.stories.tsx +++ b/packages/react/src/Spinner/Spinner.examples.stories.tsx @@ -3,7 +3,7 @@ import type {Meta} from '@storybook/react' import Spinner from './Spinner' import {Box, Button} from '..' import {VisuallyHidden} from '../internal/components/VisuallyHidden' -import {Status} from '../internal/components/Status' +import {AriaStatus} from '../live-region' export default { title: 'Components/Spinner/Examples', @@ -47,7 +47,7 @@ export const FullLifecycle = () => { {state === 'loading' && }

{loadedContent}

- {state === 'done' && 'Content finished loading'} + {state === 'done' && 'Content finished loading'} ) @@ -84,12 +84,12 @@ export const FullLifecycleVisibleLoadingText = () => { {state !== 'done' && ( {state === 'loading' && } - {state === 'loading' ? 'Content is loading...' : ''} + {state === 'loading' ? 'Content is loading...' : ''} )}

{loadedContent}

- {state === 'done' && 'Content finished loading'} + {state === 'done' && 'Content finished loading'} ) diff --git a/packages/react/src/Spinner/Spinner.features.stories.tsx b/packages/react/src/Spinner/Spinner.features.stories.tsx index 9e4ffa46a57..46410f6f701 100644 --- a/packages/react/src/Spinner/Spinner.features.stories.tsx +++ b/packages/react/src/Spinner/Spinner.features.stories.tsx @@ -2,7 +2,7 @@ import React from 'react' import type {Meta} from '@storybook/react' import Spinner from './Spinner' import {Box} from '..' -import {Status} from '../internal/components/Status' +import {AriaStatus} from '../live-region' export default { title: 'Components/Spinner/Features', @@ -16,6 +16,6 @@ export const Large = () => export const SuppressScreenReaderText = () => ( - Loading... + Loading... ) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 32b4a0a07c2..bab6c95313f 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -242,6 +242,12 @@ exports[`@primer/react/drafts should not update exports without a semver change [ "ActionBar", "type ActionBarProps", + "Announce", + "type AnnounceProps", + "AriaAlert", + "type AriaAlertProps", + "AriaStatus", + "type AriaStatusProps", "Banner", "type BannerProps", "Blankslate", @@ -347,6 +353,12 @@ exports[`@primer/react/experimental should not update exports without a semver c [ "ActionBar", "type ActionBarProps", + "Announce", + "type AnnounceProps", + "AriaAlert", + "type AriaAlertProps", + "AriaStatus", + "type AriaStatusProps", "Banner", "type BannerProps", "Blankslate", diff --git a/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx b/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx index 48853b46e09..2160c1b8c01 100644 --- a/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/packages/react/src/drafts/SelectPanel2/SelectPanel.tsx @@ -23,7 +23,7 @@ import type {OverlayProps} from '../../Overlay/Overlay' import {StyledOverlay, heightMap} from '../../Overlay/Overlay' import InputLabel from '../../internal/components/InputLabel' import {invariant} from '../../utils/invariant' -import {Status} from '../../internal/components/Status' +import {AriaStatus} from '../../live-region' import {useResponsiveValue} from '../../hooks/useResponsiveValue' import type {ResponsiveValue} from '../../hooks/useResponsiveValue' @@ -604,7 +604,8 @@ const SelectPanelSecondaryAction: React.FC = ({ const SelectPanelLoading = ({children = 'Fetching items...'}: React.PropsWithChildren) => { return ( - {children} - + ) } diff --git a/packages/react/src/drafts/index.ts b/packages/react/src/drafts/index.ts index 7a0bf3d8c7c..fca323ac256 100644 --- a/packages/react/src/drafts/index.ts +++ b/packages/react/src/drafts/index.ts @@ -75,6 +75,9 @@ export * from '../ActionBar' export {Stack} from '../Stack' export type {StackProps, StackItemProps} from '../Stack' +export {Announce, AriaStatus, AriaAlert} from '../live-region' +export type {AnnounceProps, AriaStatusProps, AriaAlertProps} from '../live-region' + export * from './UnderlinePanels' export {SkeletonBox, SkeletonText, SkeletonAvatar} from './Skeleton' diff --git a/packages/react/src/internal/components/Alert.tsx b/packages/react/src/internal/components/Alert.tsx deleted file mode 100644 index c9dce3ebe85..00000000000 --- a/packages/react/src/internal/components/Alert.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import {Announce, type AnnounceProps} from './Announce' - -export type AlertProps = AnnounceProps - -export function Alert({children, ...rest}: AlertProps) { - return ( - - {children} - - ) -} diff --git a/packages/react/src/internal/components/Announce.tsx b/packages/react/src/internal/components/Announce.tsx deleted file mode 100644 index ab936dd7aa2..00000000000 --- a/packages/react/src/internal/components/Announce.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import {announceFromElement} from '@primer/live-region-element' -import React, {useEffect, useRef, type ElementRef} from 'react' -import Box from '../../Box' -import {useEffectOnce} from '../hooks/useEffectOnce' - -export type AnnounceProps = React.ComponentPropsWithoutRef & { - /** - * The politeness level to use for the announcement - * @default polite - */ - politeness?: 'polite' | 'assertive' -} - -/** - * `Announce` is a component that will announce the text content of the - * `children` passed in to screen readers using the given politeness level. It - * will also announce any changes to the text content of `children` - */ -export function Announce({children, politeness = 'polite', ...rest}: AnnounceProps) { - const ref = useRef>(null) - const savedPoliteness = useRef(politeness) - - useEffect(() => { - savedPoliteness.current = politeness - }, [politeness]) - - // Announce the initial message, this is wrapped in `useEffectOnce` so that it - // does not announce twice in StrictMode - useEffectOnce(() => { - if (ref.current !== null) { - announceFromElement(ref.current, { - politeness: savedPoliteness.current, - }) - } - }) - - useEffect(() => { - if (ref.current === null) { - return - } - - const {current: container} = ref - - // When the text of the container changes, announce the new text - const observer = new MutationObserver(mutationList => { - for (const mutation of mutationList) { - if (mutation.type === 'characterData') { - announceFromElement(container, { - politeness: savedPoliteness.current, - }) - break - } - } - }) - - observer.observe(container, { - subtree: true, - characterData: true, - }) - - return () => { - observer.disconnect() - } - }, []) - - return ( - - {children} - - ) -} diff --git a/packages/react/src/internal/components/Status.tsx b/packages/react/src/internal/components/Status.tsx deleted file mode 100644 index 076ad8c023c..00000000000 --- a/packages/react/src/internal/components/Status.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import {Announce, type AnnounceProps} from './Announce' - -export type StatusProps = AnnounceProps - -export function Status({children, ...rest}: StatusProps) { - return ( - - {children} - - ) -} diff --git a/packages/react/src/internal/components/__tests__/Status.test.tsx b/packages/react/src/internal/components/__tests__/Status.test.tsx deleted file mode 100644 index 397c073b257..00000000000 --- a/packages/react/src/internal/components/__tests__/Status.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import {render, screen} from '@testing-library/react' -import React from 'react' -import type {LiveRegionElement} from '@primer/live-region-element' -import {Status} from '../Status' - -function getLiveRegion(): LiveRegionElement { - const liveRegion = document.querySelector('live-region') - if (liveRegion) { - return liveRegion as LiveRegionElement - } - throw new Error('No live-region found') -} - -describe('Status', () => { - afterEach(() => { - // Reset the live-region after each test so that we do not have overlapping - // messages from previous tests - const liveRegion = getLiveRegion() - document.body.removeChild(liveRegion) - }) - - it('should have a default politeness of `polite`', () => { - render(test) - - const liveRegion = getLiveRegion() - expect(liveRegion.getMessage('polite')).toBe('test') - }) - - it('should pass additional props to the container element', () => { - const {container} = render(test) - - expect(container.firstChild).toHaveAttribute('data-testid', 'container') - }) - - it('should support styling via the `sx` prop', () => { - render( - - test - , - ) - expect(screen.getByTestId('container')).toHaveStyle('color: blue') - }) - - it('should support customizing the container element with `as`', () => { - render( - - test - , - ) - expect(screen.getByTestId('container').tagName).toBe('SPAN') - }) -}) diff --git a/packages/react/src/internal/hooks/useEffectCallback.ts b/packages/react/src/internal/hooks/useEffectCallback.ts new file mode 100644 index 00000000000..ab2a88c53cc --- /dev/null +++ b/packages/react/src/internal/hooks/useEffectCallback.ts @@ -0,0 +1,20 @@ +import {useCallback, useEffect, useRef} from 'react' + +/** + * Create a callback that can be used within an effect without re-running the + * effect when the values used change. The callback passed to this hook will + * always see the latest snapshot of values that it uses and does not need to + * use a dependency array. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useEffectCallback any>(callback: T) { + const savedCallback = useRef(callback) + + useEffect(() => { + savedCallback.current = callback + }, [callback]) + + return useCallback((...args: Parameters): ReturnType => { + return savedCallback.current(...args) + }, []) +} diff --git a/packages/react/src/internal/components/Status.stories.tsx b/packages/react/src/live-region/Announce.features.stories.tsx similarity index 65% rename from packages/react/src/internal/components/Status.stories.tsx rename to packages/react/src/live-region/Announce.features.stories.tsx index 23a0c26fb3d..072fc108305 100644 --- a/packages/react/src/internal/components/Status.stories.tsx +++ b/packages/react/src/live-region/Announce.features.stories.tsx @@ -1,26 +1,11 @@ import type {StoryObj} from '@storybook/react' import React, {useEffect, useState} from 'react' -import {Status} from './Status' -import {VisuallyHidden} from './VisuallyHidden' +import {Announce} from './Announce' +import {VisuallyHidden} from '../internal/components/VisuallyHidden' export default { - title: 'Private/Components/Status', - component: Status, -} - -export const Default = () => { - const [message, setMessage] = useState('Default message') - - useEffect(() => { - const interval = setInterval(() => { - setMessage(`Last updated at ${new Date().toLocaleTimeString()}`) - }, 5000) - return () => { - clearInterval(interval) - } - }, []) - - return {message} + title: 'Drafts/Components/Announce/Features', + component: Announce, } export const VisuallyHiddenStory: StoryObj = { @@ -30,9 +15,24 @@ export const VisuallyHiddenStory: StoryObj = { <>

This is an example

- A visually hidden message + A visually hidden message ) }, } + +export const WithDelay = () => { + const [message, setMessage] = useState('Default message') + + useEffect(() => { + const interval = setInterval(() => { + setMessage(`Last updated at ${new Date().toLocaleTimeString()}`) + }, 5000) + return () => { + clearInterval(interval) + } + }, []) + + return {message} +} diff --git a/packages/react/src/live-region/Announce.stories.tsx b/packages/react/src/live-region/Announce.stories.tsx new file mode 100644 index 00000000000..2416cb06f51 --- /dev/null +++ b/packages/react/src/live-region/Announce.stories.tsx @@ -0,0 +1,29 @@ +import React, {useEffect, useState} from 'react' +import {Announce} from './Announce' +import type {StoryObj} from '@storybook/react' + +export default { + title: 'Drafts/Components/Announce', + component: Announce, +} + +export const Default = () => { + const [message, setMessage] = useState('Default message') + + useEffect(() => { + const interval = setInterval(() => { + setMessage(`Last updated at ${new Date().toLocaleTimeString()}`) + }, 5000) + return () => { + clearInterval(interval) + } + }, []) + + return {message} +} + +export const Playground: StoryObj = { + render: args => { + return Example message + }, +} diff --git a/packages/react/src/live-region/Announce.tsx b/packages/react/src/live-region/Announce.tsx new file mode 100644 index 00000000000..307a79c47b2 --- /dev/null +++ b/packages/react/src/live-region/Announce.tsx @@ -0,0 +1,142 @@ +import {announceFromElement} from '@primer/live-region-element' +import React, {useEffect, useRef, useState, type ElementRef} from 'react' +import Box from '../Box' +import {useEffectOnce} from '../internal/hooks/useEffectOnce' +import {useEffectCallback} from '../internal/hooks/useEffectCallback' + +export type AnnounceProps = React.ComponentPropsWithoutRef & { + /** + * Specify if the content of the element should be announced when this + * component is rendered and is not hidden + * @default false + */ + announceOnShow?: boolean + + /** + * Specify if the element is hidden + * @default false + */ + hidden?: boolean + + /** + * Provide a delay in milliseconds before the announcement is made. This will + * only work with `polite` announcements + */ + delayMs?: number + + /** + * The politeness level to use for the announcement + * @default 'polite' + */ + politeness?: 'assertive' | 'polite' +} + +/** + * `Announce` is a component that will announce the text content of the + * `children` passed in to screen readers using the given politeness level. It + * will also announce any changes to the text content of `children` + */ +export function Announce({ + announceOnShow = true, + children, + delayMs, + hidden = false, + politeness = 'polite', + ...rest +}: AnnounceProps) { + const ref = useRef>(null) + const [previousAnnouncementText, setPreviousAnnouncementText] = useState(null) + const savedAnnouncement = useRef | null>(null) + const announce = useEffectCallback(() => { + const {current: element} = ref + if (!element) { + return + } + + if (hidden) { + return + } + + const style = window.getComputedStyle(element) + if (style.display === 'none') { + return + } + + if (style.visibility === 'hidden') { + return + } + + const textContent = getTextContent(element) + if (textContent === previousAnnouncementText) { + return + } + + savedAnnouncement.current?.cancel() + savedAnnouncement.current = announceFromElement( + element, + politeness === 'assertive' + ? { + politeness, + } + : { + politeness, + delayMs, + }, + ) + setPreviousAnnouncementText(textContent) + }) + + // Announce the initial message, this is wrapped in `useEffectOnce` so that it + // does not announce twice in StrictMode + useEffectOnce(() => { + if (announceOnShow) { + announce() + } + }) + + useEffect(() => { + const {current: container} = ref + if (container === null) { + return + } + + // When the text of the container changes, announce the new text + const observer = new MutationObserver(() => { + announce() + }) + + observer.observe(container, { + subtree: true, + characterData: true, + }) + + return () => { + observer.disconnect() + } + }, [announce]) + + useEffect(() => { + return () => { + if (savedAnnouncement.current !== null) { + savedAnnouncement.current.cancel() + savedAnnouncement.current = null + } + } + }, []) + + return ( + + {children} + + ) +} + +function getTextContent(element: HTMLElement): string { + let value = '' + if (element.hasAttribute('aria-label')) { + value = element.getAttribute('aria-label')! + } else if (element.textContent) { + value = element.textContent + } + return value ? value.trim() : '' +} diff --git a/packages/react/src/live-region/AriaAlert.features.stories.tsx b/packages/react/src/live-region/AriaAlert.features.stories.tsx new file mode 100644 index 00000000000..1c8068e2db0 --- /dev/null +++ b/packages/react/src/live-region/AriaAlert.features.stories.tsx @@ -0,0 +1,23 @@ +import type {StoryObj} from '@storybook/react' +import React from 'react' +import {AriaAlert} from './AriaAlert' +import {VisuallyHidden} from '../internal/components/VisuallyHidden' + +export default { + title: 'Drafts/Components/AriaAlert/Features', + component: AriaAlert, +} + +export const VisuallyHiddenStory: StoryObj = { + name: 'VisuallyHidden', + render: () => { + return ( + <> +

This is an example

+ + A visually hidden message + + + ) + }, +} diff --git a/packages/react/src/live-region/AriaAlert.stories.tsx b/packages/react/src/live-region/AriaAlert.stories.tsx new file mode 100644 index 00000000000..d43079f9c8a --- /dev/null +++ b/packages/react/src/live-region/AriaAlert.stories.tsx @@ -0,0 +1,34 @@ +import type {StoryObj} from '@storybook/react' +import React, {useEffect, useState} from 'react' +import {AriaAlert} from './AriaAlert' + +export default { + title: 'Drafts/Components/AriaAlert', + component: AriaAlert, +} + +export const Default = () => { + const [message, setMessage] = useState('Default message') + + useEffect(() => { + const interval = setInterval(() => { + setMessage(`Last updated at ${new Date().toLocaleTimeString()}`) + }, 5000) + return () => { + clearInterval(interval) + } + }, []) + + return {message} +} + +export const Playground: StoryObj = { + argTypes: { + announceOnShow: { + control: 'boolean', + }, + }, + render: args => { + return Example message + }, +} diff --git a/packages/react/src/live-region/AriaAlert.tsx b/packages/react/src/live-region/AriaAlert.tsx new file mode 100644 index 00000000000..ac88f8238f3 --- /dev/null +++ b/packages/react/src/live-region/AriaAlert.tsx @@ -0,0 +1,37 @@ +import React, {type ElementType} from 'react' +import {Announce} from './Announce' +import type {SxProp} from '../sx' + +export type AriaAlertProps = React.PropsWithChildren< + { + /** + * Customize the element type of the rendered container + */ + as?: As + + /** + * Specify if the content of the element should be announced when this + * component is rendered and is not hidden + * @default true + */ + announceOnShow?: boolean + + /** + * Specify if the element is hidden + * @default false + */ + hidden?: boolean + } & SxProp +> + +export function AriaAlert({ + announceOnShow = true, + children, + ...rest +}: AriaAlertProps & React.ComponentPropsWithoutRef) { + return ( + + {children} + + ) +} diff --git a/packages/react/src/live-region/AriaStatus.features.stories.tsx b/packages/react/src/live-region/AriaStatus.features.stories.tsx new file mode 100644 index 00000000000..24e1a35e13a --- /dev/null +++ b/packages/react/src/live-region/AriaStatus.features.stories.tsx @@ -0,0 +1,38 @@ +import type {StoryObj} from '@storybook/react' +import React, {useEffect, useState} from 'react' +import {AriaStatus} from './AriaStatus' +import {VisuallyHidden} from '../internal/components/VisuallyHidden' + +export default { + title: 'Drafts/Components/AriaStatus/Features', + component: AriaStatus, +} + +export const VisuallyHiddenStory: StoryObj = { + name: 'VisuallyHidden', + render: () => { + return ( + <> +

This is an example

+ + A visually hidden message + + + ) + }, +} + +export const WithDelay = () => { + const [message, setMessage] = useState('Default message') + + useEffect(() => { + const interval = setInterval(() => { + setMessage(`Last updated at ${new Date().toLocaleTimeString()}`) + }, 5000) + return () => { + clearInterval(interval) + } + }, []) + + return {message} +} diff --git a/packages/react/src/live-region/AriaStatus.stories.tsx b/packages/react/src/live-region/AriaStatus.stories.tsx new file mode 100644 index 00000000000..1848c46935b --- /dev/null +++ b/packages/react/src/live-region/AriaStatus.stories.tsx @@ -0,0 +1,40 @@ +import type {StoryObj} from '@storybook/react' +import React, {useEffect, useState} from 'react' +import {AriaStatus} from './AriaStatus' + +export default { + title: 'Drafts/Components/AriaStatus', + component: AriaStatus, +} + +export const Default = () => { + const [message, setMessage] = useState('Default message') + + useEffect(() => { + const interval = setInterval(() => { + setMessage(`Last updated at ${new Date().toLocaleTimeString()}`) + }, 5000) + return () => { + clearInterval(interval) + } + }, []) + + return {message} +} + +export const Playground: StoryObj = { + argTypes: { + announceOnShow: { + control: 'boolean', + }, + hidden: { + control: 'boolean', + }, + delayMs: { + control: 'number', + }, + }, + render: args => { + return Example message + }, +} diff --git a/packages/react/src/live-region/AriaStatus.tsx b/packages/react/src/live-region/AriaStatus.tsx new file mode 100644 index 00000000000..7cf1ca7c6be --- /dev/null +++ b/packages/react/src/live-region/AriaStatus.tsx @@ -0,0 +1,42 @@ +import React, {type ElementType} from 'react' +import {Announce} from './Announce' +import type {SxProp} from '../sx' + +export type AriaStatusProps = React.PropsWithChildren< + { + /** + * Customize the element type of the rendered container + */ + as?: As + + /** + * Specify if the content of the element should be announced when this + * component is rendered and is not hidden + * @default false + */ + announceOnShow?: boolean + + /** + * Specify if the element is hidden + * @default false + */ + hidden?: boolean + + /** + * Provide a delay in milliseconds before the announcement is made + */ + delayMs?: number + } & SxProp +> + +export function AriaStatus({ + announceOnShow = false, + children, + ...rest +}: AriaStatusProps & React.ComponentPropsWithoutRef) { + return ( + + {children} + + ) +} diff --git a/packages/react/src/internal/components/__tests__/Announce.test.tsx b/packages/react/src/live-region/__tests__/Announce.test.tsx similarity index 64% rename from packages/react/src/internal/components/__tests__/Announce.test.tsx rename to packages/react/src/live-region/__tests__/Announce.test.tsx index c1d265cb9f8..5586297a8dd 100644 --- a/packages/react/src/internal/components/__tests__/Announce.test.tsx +++ b/packages/react/src/live-region/__tests__/Announce.test.tsx @@ -12,6 +12,11 @@ function getLiveRegion(): LiveRegionElement { } describe('Announce', () => { + beforeEach(() => { + const liveRegion = document.createElement('live-region') + document.body.appendChild(liveRegion) + }) + afterEach(() => { // Reset the live-region after each test so that we do not have overlapping // messages from previous tests @@ -57,4 +62,41 @@ describe('Announce', () => { ) expect(screen.getByTestId('container').tagName).toBe('SPAN') }) + + it('should not announce the contents of the container if `hidden={false}`', () => { + render() + + const liveRegion = getLiveRegion() + expect(liveRegion.getMessage('polite')).not.toBe('test') + }) + + it('should not announce the contents of the container if `display: none`', () => { + render( + + test + , + ) + + const liveRegion = getLiveRegion() + expect(liveRegion.getMessage('polite')).not.toBe('test') + }) + + it('should not announce the contents of the container if `visibility: hidden`', () => { + render( + + test + , + ) + + const liveRegion = getLiveRegion() + expect(liveRegion.getMessage('polite')).not.toBe('test') + }) }) diff --git a/packages/react/src/internal/components/__tests__/Alert.test.tsx b/packages/react/src/live-region/__tests__/AriaAlert.test.tsx similarity index 72% rename from packages/react/src/internal/components/__tests__/Alert.test.tsx rename to packages/react/src/live-region/__tests__/AriaAlert.test.tsx index f3e208319a3..e51e4558d44 100644 --- a/packages/react/src/internal/components/__tests__/Alert.test.tsx +++ b/packages/react/src/live-region/__tests__/AriaAlert.test.tsx @@ -1,7 +1,7 @@ import {render, screen} from '@testing-library/react' import React from 'react' import type {LiveRegionElement} from '@primer/live-region-element' -import {Alert} from '../Alert' +import {AriaAlert} from '../AriaAlert' function getLiveRegion(): LiveRegionElement { const liveRegion = document.querySelector('live-region') @@ -11,7 +11,12 @@ function getLiveRegion(): LiveRegionElement { throw new Error('No live-region found') } -describe('Alert', () => { +describe('AriaAlert', () => { + beforeEach(() => { + const liveRegion = document.createElement('live-region') + document.body.appendChild(liveRegion) + }) + afterEach(() => { // Reset the live-region after each test so that we do not have overlapping // messages from previous tests @@ -20,32 +25,32 @@ describe('Alert', () => { }) it('should have a default politeness of `assertive`', () => { - render(test) + render(test) const liveRegion = getLiveRegion() expect(liveRegion.getMessage('assertive')).toBe('test') }) it('should pass additional props to the container element', () => { - const {container} = render(test) + const {container} = render(test) expect(container.firstChild).toHaveAttribute('data-testid', 'container') }) it('should support styling via the `sx` prop', () => { render( - + test - , + , ) expect(screen.getByTestId('container')).toHaveStyle('color: blue') }) it('should support customizing the container element with `as`', () => { render( - + test - , + , ) expect(screen.getByTestId('container').tagName).toBe('SPAN') }) diff --git a/packages/react/src/live-region/__tests__/AriaStatus.test.tsx b/packages/react/src/live-region/__tests__/AriaStatus.test.tsx new file mode 100644 index 00000000000..29bed2c78fb --- /dev/null +++ b/packages/react/src/live-region/__tests__/AriaStatus.test.tsx @@ -0,0 +1,94 @@ +import {render, screen} from '@testing-library/react' +import React from 'react' +import type {LiveRegionElement} from '@primer/live-region-element' +import {AriaStatus} from '../AriaStatus' +import {userEvent} from '@testing-library/user-event' + +function getLiveRegion(): LiveRegionElement { + const liveRegion = document.querySelector('live-region') + if (liveRegion) { + return liveRegion as LiveRegionElement + } + throw new Error('No live-region found') +} + +describe('AriaStatus', () => { + beforeEach(() => { + const liveRegion = document.createElement('live-region') + document.body.appendChild(liveRegion) + }) + + afterEach(() => { + // Reset the live-region after each test so that we do not have overlapping + // messages from previous tests + const liveRegion = getLiveRegion() + document.body.removeChild(liveRegion) + }) + + it('should not announce on show by default', () => { + render(test) + + const liveRegion = getLiveRegion() + expect(liveRegion.getMessage('polite')).not.toBe('test') + }) + + it('should have a default politeness of `polite`', () => { + render(test) + + const liveRegion = getLiveRegion() + expect(liveRegion.getMessage('polite')).toBe('test') + }) + + it('should announce on content change', async () => { + function TestComponent() { + const [message, setMessage] = React.useState('default message') + return ( + <> + {message} + + + ) + } + + const user = userEvent.setup() + + render() + + const liveRegion = getLiveRegion() + expect(liveRegion.getMessage('polite')).not.toBe('test') + + await user.click(screen.getByText('Update message')) + expect(liveRegion.getMessage('polite')).toBe('updated message') + }) + + it('should pass additional props to the container element', () => { + const {container} = render(test) + + expect(container.firstChild).toHaveAttribute('data-testid', 'container') + }) + + it('should support styling via the `sx` prop', () => { + render( + + test + , + ) + expect(screen.getByTestId('container')).toHaveStyle('color: blue') + }) + + it('should support customizing the container element with `as`', () => { + render( + + test + , + ) + expect(screen.getByTestId('container').tagName).toBe('SPAN') + }) +}) diff --git a/packages/react/src/live-region/index.ts b/packages/react/src/live-region/index.ts new file mode 100644 index 00000000000..5b98e55dbbe --- /dev/null +++ b/packages/react/src/live-region/index.ts @@ -0,0 +1,8 @@ +export {Announce} from './Announce' +export type {AnnounceProps} from './Announce' + +export {AriaAlert} from './AriaAlert' +export type {AriaAlertProps} from './AriaAlert' + +export {AriaStatus} from './AriaStatus' +export type {AriaStatusProps} from './AriaStatus'