Skip to content

Commit

Permalink
Convey Spinner to assistive technologies (#4140)
Browse files Browse the repository at this point in the history
* updates Spinner to accept screen reader text

* restricts props, updates prop docs

deprecates aria-label prop

* adds stories and tests

* adds changeset

* updates prop docs

* fixes visual regressions

* improve Storybook examples for announcing compoleted loading

* uses live regions, adapts SelectPanel.Loading

* attempt to resolve circular dependency

* updates Spinner example stories to use live-region-element via Status component

* updates Spinner example stories to use live-region-element via Status component

* removes live region

* updates Storybook imports
  • Loading branch information
mperrotti authored Jun 17, 2024
1 parent 685e103 commit c093411
Show file tree
Hide file tree
Showing 11 changed files with 2,834 additions and 1,744 deletions.
7 changes: 7 additions & 0 deletions .changeset/dry-fans-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/react': minor
---

Adds a prop, `srText`, to the Spinner component to convey a loading message to assistive technologies such as screen readers.

<!-- Changed components: Spinner -->
20 changes: 20 additions & 0 deletions packages/react/src/Spinner/Spinner.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@
"name": "size",
"type": "'small' | 'medium' | 'large'",
"description": "Sets the width and height of the spinner."
},
{
"name": "srText",
"type": "string | null",
"defaultValue": "Loading",
"description": "Sets the text conveyed by assistive technologies such as screen readers. Set to `null` if the loading state is displayed in a text node somewhere else on the page."
},
{
"name": "aria-label",
"type": "string | null",
"description": "Sets the text conveyed by assistive technologies such as screen readers.",
"deprecated": true
},
{
"name": "data-*",
"type": "string"
},
{
"name": "sx",
"type": "SystemStyleObject"
}
],
"subcomponents": []
Expand Down
96 changes: 96 additions & 0 deletions packages/react/src/Spinner/Spinner.examples.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React from 'react'
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'

export default {
title: 'Components/Spinner/Examples',
component: Spinner,
} as Meta<typeof Spinner>

type LoadingState = 'initial' | 'loading' | 'done'

async function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}

// There should be an announcement when loading is completed or if there was an error loading
export const FullLifecycle = () => {
const [isLoading, setIsLoading] = React.useState(false)
const [loadedContent, setLoadedContent] = React.useState('')
let state: LoadingState = 'initial'

if (isLoading) {
state = 'loading'
} else if (loadedContent) {
state = 'done'
}

const initiateLoading = async () => {
if (state === 'done') {
return
}

setIsLoading(true)
await wait(1000)
setLoadedContent('Some content that had to be loaded.')
setIsLoading(false)
}

return (
<>
<Button onClick={initiateLoading} sx={{mb: '1em'}}>
Load content
</Button>
{state === 'loading' && <Spinner />}
<p>{loadedContent}</p>
<VisuallyHidden>
<Status>{state === 'done' && 'Content finished loading'}</Status>
</VisuallyHidden>
</>
)
}

// We should avoid duplicate loading announcements
export const FullLifecycleVisibleLoadingText = () => {
const [isLoading, setIsLoading] = React.useState(false)
const [loadedContent, setLoadedContent] = React.useState('')
let state: LoadingState = 'initial'

if (isLoading) {
state = 'loading'
} else if (loadedContent) {
state = 'done'
}

const initiateLoading = async () => {
if (state === 'done') {
return
}

setIsLoading(true)
await wait(1000)
setLoadedContent('Some content that had to be loaded.')
setIsLoading(false)
}

return (
<Box sx={{display: 'flex', alignItems: 'flex-start', flexDirection: 'column', gap: '0.5em'}}>
<Button onClick={initiateLoading} sx={{mb: '1em'}}>
Load content
</Button>
{state !== 'done' && (
<Box sx={{alignItems: 'center', display: 'flex', gap: '0.25rem'}}>
{state === 'loading' && <Spinner size="small" srText={null} />}
<Status>{state === 'loading' ? 'Content is loading...' : ''}</Status>
</Box>
)}
<p>{loadedContent}</p>
<VisuallyHidden>
<Status>{state === 'done' && 'Content finished loading'}</Status>
</VisuallyHidden>
</Box>
)
}
9 changes: 9 additions & 0 deletions packages/react/src/Spinner/Spinner.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react'
import type {Meta} from '@storybook/react'
import Spinner from './Spinner'
import {Box} from '..'
import {Status} from '../internal/components/Status'

export default {
title: 'Components/Spinner/Features',
Expand All @@ -10,3 +12,10 @@ export default {
export const Small = () => <Spinner size="small" />

export const Large = () => <Spinner size="large" />

export const SuppressScreenReaderText = () => (
<Box sx={{alignItems: 'center', display: 'flex', gap: '0.25rem'}}>
<Spinner size="small" srText={null} />
<Status>Loading...</Status>
</Box>
)
72 changes: 46 additions & 26 deletions packages/react/src/Spinner/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,67 @@
import React from 'react'
import styled from 'styled-components'
import type {SxProp} from '../sx'
import sx from '../sx'
import type {ComponentProps} from '../utils/types'
import sx, {type SxProp} from '../sx'
import {VisuallyHidden} from '../internal/components/VisuallyHidden'
import type {HTMLDataAttributes} from '../internal/internal-types'
import Box from '../Box'
import {useId} from '../hooks'

const sizeMap = {
small: '16px',
medium: '32px',
large: '64px',
}

export interface SpinnerInternalProps {
export type SpinnerProps = {
/** Sets the width and height of the spinner. */
size?: keyof typeof sizeMap
}
/** Sets the text conveyed by assistive technologies such as screen readers. Set to `null` if the loading state is displayed in a text node somewhere else on the page. */
srText?: string | null
/** @deprecated Use `srText` instead. */
'aria-label'?: string | null
} & HTMLDataAttributes &
SxProp

function Spinner({size: sizeKey = 'medium', ...props}: SpinnerInternalProps) {
function Spinner({size: sizeKey = 'medium', srText = 'Loading', 'aria-label': ariaLabel, ...props}: SpinnerProps) {
const size = sizeMap[sizeKey]
const hasSrAnnouncement = Boolean(srText || ariaLabel)
const ariaLabelId = useId()

return (
<svg height={size} width={size} viewBox="0 0 16 16" fill="none" {...props}>
<circle
cx="8"
cy="8"
r="7"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<path
d="M15 8a7.002 7.002 0 00-7-7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
/* inline-flex removes the extra line height */
<Box sx={{display: 'inline-flex'}}>
<svg
height={size}
width={size}
viewBox="0 0 16 16"
fill="none"
aria-hidden
aria-labelledby={ariaLabelId}
{...props}
>
<circle
cx="8"
cy="8"
r="7"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<path
d="M15 8a7.002 7.002 0 00-7-7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
{hasSrAnnouncement ? <VisuallyHidden id={ariaLabelId}>{srText || ariaLabel}</VisuallyHidden> : null}
</Box>
)
}

const StyledSpinner = styled(Spinner)<SxProp>`
const StyledSpinner = styled(Spinner)`
@keyframes rotate-keyframes {
100% {
transform: rotate(360deg);
Expand All @@ -54,5 +75,4 @@ const StyledSpinner = styled(Spinner)<SxProp>`

StyledSpinner.displayName = 'Spinner'

export type SpinnerProps = ComponentProps<typeof StyledSpinner>
export default StyledSpinner
20 changes: 19 additions & 1 deletion packages/react/src/__tests__/Spinner.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react'
import axe from 'axe-core'
import type {SpinnerProps} from '..'
import {Spinner} from '..'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {render as HTMLRender} from '@testing-library/react'
import axe from 'axe-core'

describe('Spinner', () => {
behavesAsComponent({
Expand All @@ -14,6 +14,24 @@ describe('Spinner', () => {
default: Spinner,
})

it('should label the spinner with default loading text', async () => {
const {getByLabelText} = HTMLRender(<Spinner />)

expect(getByLabelText('Loading')).toBeInTheDocument()
})

it('should label the spinner with with custom loading text', async () => {
const {getByLabelText} = HTMLRender(<Spinner srText="Custom loading text" />)

expect(getByLabelText('Custom loading text')).toBeInTheDocument()
})

it('should not label the spinner with with loading text when `srText` is set to `null`', async () => {
const {getByLabelText} = HTMLRender(<Spinner srText={null} />)

expect(() => getByLabelText('Loading')).toThrow()
})

it('should have no axe violations', async () => {
const {container} = HTMLRender(<Spinner />)
const results = await axe.run(container)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,13 @@ exports[`snapshots renders a loading state 1`] = `
justify-content: center;
}
.c2 {
display: -webkit-inline-box;
display: -webkit-inline-flex;
display: -ms-inline-flexbox;
display: inline-flex;
}
.c0 {
position: absolute;
width: 1px;
Expand All @@ -340,7 +347,17 @@ exports[`snapshots renders a loading state 1`] = `
border-width: 0;
}
.c2 {
.c4:not(:focus):not(:active):not(:focus-within) {
-webkit-clip-path: inset(50%);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
.c3 {
-webkit-animation: rotate-keyframes 1s linear infinite;
animation: rotate-keyframes 1s linear infinite;
}
Expand All @@ -356,30 +373,42 @@ exports[`snapshots renders a loading state 1`] = `
className="c1"
display="flex"
>
<svg
<div
className="c2"
fill="none"
height="32px"
viewBox="0 0 16 16"
width="32px"
>
<circle
cx="8"
cy="8"
r="7"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<path
d="M15 8a7.002 7.002 0 00-7-7"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
<svg
aria-hidden={true}
aria-labelledby=":r1v:"
className="c3"
fill="none"
height="32px"
viewBox="0 0 16 16"
width="32px"
>
<circle
cx="8"
cy="8"
r="7"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<path
d="M15 8a7.002 7.002 0 00-7-7"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
<div
className="c4"
id=":r1v:"
>
Loading
</div>
</div>
</div>
</span>,
]
Expand Down
Loading

0 comments on commit c093411

Please sign in to comment.