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

Convey Spinner to assistive technologies #4140

Merged
merged 16 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
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
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
Loading