Skip to content

Commit 11d6cab

Browse files
authored
Merge 5d49c29 into 685e103
2 parents 685e103 + 5d49c29 commit 11d6cab

File tree

11 files changed

+2834
-1744
lines changed

11 files changed

+2834
-1744
lines changed

.changeset/dry-fans-sort.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Adds a prop, `srText`, to the Spinner component to convey a loading message to assistive technologies such as screen readers.
6+
7+
<!-- Changed components: Spinner -->

packages/react/src/Spinner/Spinner.docs.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@
1010
"name": "size",
1111
"type": "'small' | 'medium' | 'large'",
1212
"description": "Sets the width and height of the spinner."
13+
},
14+
{
15+
"name": "srText",
16+
"type": "string | null",
17+
"defaultValue": "Loading",
18+
"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."
19+
},
20+
{
21+
"name": "aria-label",
22+
"type": "string | null",
23+
"description": "Sets the text conveyed by assistive technologies such as screen readers.",
24+
"deprecated": true
25+
},
26+
{
27+
"name": "data-*",
28+
"type": "string"
29+
},
30+
{
31+
"name": "sx",
32+
"type": "SystemStyleObject"
1333
}
1434
],
1535
"subcomponents": []
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React from 'react'
2+
import type {ComponentMeta} from '@storybook/react'
3+
import Spinner from './Spinner'
4+
import {Box, Button} from '..'
5+
import {VisuallyHidden} from '../internal/components/VisuallyHidden'
6+
import {Status} from '../internal/components/Status'
7+
8+
export default {
9+
title: 'Components/Spinner/Examples',
10+
component: Spinner,
11+
} as ComponentMeta<typeof Spinner>
12+
13+
type LoadingState = 'initial' | 'loading' | 'done'
14+
15+
async function wait(ms: number) {
16+
return new Promise(resolve => setTimeout(resolve, ms))
17+
}
18+
19+
// There should be an announcement when loading is completed or if there was an error loading
20+
export const FullLifecycle = () => {
21+
const [isLoading, setIsLoading] = React.useState(false)
22+
const [loadedContent, setLoadedContent] = React.useState('')
23+
let state: LoadingState = 'initial'
24+
25+
if (isLoading) {
26+
state = 'loading'
27+
} else if (loadedContent) {
28+
state = 'done'
29+
}
30+
31+
const initiateLoading = async () => {
32+
if (state === 'done') {
33+
return
34+
}
35+
36+
setIsLoading(true)
37+
await wait(1000)
38+
setLoadedContent('Some content that had to be loaded.')
39+
setIsLoading(false)
40+
}
41+
42+
return (
43+
<>
44+
<Button onClick={initiateLoading} sx={{mb: '1em'}}>
45+
Load content
46+
</Button>
47+
{state === 'loading' && <Spinner />}
48+
<p>{loadedContent}</p>
49+
<VisuallyHidden>
50+
<Status>{state === 'done' && 'Content finished loading'}</Status>
51+
</VisuallyHidden>
52+
</>
53+
)
54+
}
55+
56+
// We should avoid duplicate loading announcements
57+
export const FullLifecycleVisibleLoadingText = () => {
58+
const [isLoading, setIsLoading] = React.useState(false)
59+
const [loadedContent, setLoadedContent] = React.useState('')
60+
let state: LoadingState = 'initial'
61+
62+
if (isLoading) {
63+
state = 'loading'
64+
} else if (loadedContent) {
65+
state = 'done'
66+
}
67+
68+
const initiateLoading = async () => {
69+
if (state === 'done') {
70+
return
71+
}
72+
73+
setIsLoading(true)
74+
await wait(1000)
75+
setLoadedContent('Some content that had to be loaded.')
76+
setIsLoading(false)
77+
}
78+
79+
return (
80+
<Box sx={{display: 'flex', alignItems: 'flex-start', flexDirection: 'column', gap: '0.5em'}}>
81+
<Button onClick={initiateLoading} sx={{mb: '1em'}}>
82+
Load content
83+
</Button>
84+
{state !== 'done' && (
85+
<Box sx={{alignItems: 'center', display: 'flex', gap: '0.25rem'}}>
86+
{state === 'loading' && <Spinner size="small" srText={null} />}
87+
<Status>{state === 'loading' ? 'Content is loading...' : ''}</Status>
88+
</Box>
89+
)}
90+
<p>{loadedContent}</p>
91+
<VisuallyHidden>
92+
<Status>{state === 'done' && 'Content finished loading'}</Status>
93+
</VisuallyHidden>
94+
</Box>
95+
)
96+
}

packages/react/src/Spinner/Spinner.features.stories.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React from 'react'
22
import type {Meta} from '@storybook/react'
33
import Spinner from './Spinner'
4+
import {Box} from '..'
5+
import {Status} from '../internal/components/Status'
46

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

1214
export const Large = () => <Spinner size="large" />
15+
16+
export const SuppressScreenReaderText = () => (
17+
<Box sx={{alignItems: 'center', display: 'flex', gap: '0.25rem'}}>
18+
<Spinner size="small" srText={null} />
19+
<Status>Loading...</Status>
20+
</Box>
21+
)
Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,67 @@
11
import React from 'react'
22
import styled from 'styled-components'
3-
import type {SxProp} from '../sx'
4-
import sx from '../sx'
5-
import type {ComponentProps} from '../utils/types'
3+
import sx, {type SxProp} from '../sx'
4+
import {VisuallyHidden} from '../internal/components/VisuallyHidden'
5+
import type {HTMLDataAttributes} from '../internal/internal-types'
6+
import Box from '../Box'
7+
import {useId} from '../hooks'
68

79
const sizeMap = {
810
small: '16px',
911
medium: '32px',
1012
large: '64px',
1113
}
1214

13-
export interface SpinnerInternalProps {
15+
export type SpinnerProps = {
1416
/** Sets the width and height of the spinner. */
1517
size?: keyof typeof sizeMap
16-
}
18+
/** 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. */
19+
srText?: string | null
20+
/** @deprecated Use `srText` instead. */
21+
'aria-label'?: string | null
22+
} & HTMLDataAttributes &
23+
SxProp
1724

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

2130
return (
22-
<svg height={size} width={size} viewBox="0 0 16 16" fill="none" {...props}>
23-
<circle
24-
cx="8"
25-
cy="8"
26-
r="7"
27-
stroke="currentColor"
28-
strokeOpacity="0.25"
29-
strokeWidth="2"
30-
vectorEffect="non-scaling-stroke"
31-
/>
32-
<path
33-
d="M15 8a7.002 7.002 0 00-7-7"
34-
stroke="currentColor"
35-
strokeWidth="2"
36-
strokeLinecap="round"
37-
vectorEffect="non-scaling-stroke"
38-
/>
39-
</svg>
31+
/* inline-flex removes the extra line height */
32+
<Box sx={{display: 'inline-flex'}}>
33+
<svg
34+
height={size}
35+
width={size}
36+
viewBox="0 0 16 16"
37+
fill="none"
38+
aria-hidden
39+
aria-labelledby={ariaLabelId}
40+
{...props}
41+
>
42+
<circle
43+
cx="8"
44+
cy="8"
45+
r="7"
46+
stroke="currentColor"
47+
strokeOpacity="0.25"
48+
strokeWidth="2"
49+
vectorEffect="non-scaling-stroke"
50+
/>
51+
<path
52+
d="M15 8a7.002 7.002 0 00-7-7"
53+
stroke="currentColor"
54+
strokeWidth="2"
55+
strokeLinecap="round"
56+
vectorEffect="non-scaling-stroke"
57+
/>
58+
</svg>
59+
{hasSrAnnouncement ? <VisuallyHidden id={ariaLabelId}>{srText || ariaLabel}</VisuallyHidden> : null}
60+
</Box>
4061
)
4162
}
4263

43-
const StyledSpinner = styled(Spinner)<SxProp>`
64+
const StyledSpinner = styled(Spinner)`
4465
@keyframes rotate-keyframes {
4566
100% {
4667
transform: rotate(360deg);
@@ -54,5 +75,4 @@ const StyledSpinner = styled(Spinner)<SxProp>`
5475

5576
StyledSpinner.displayName = 'Spinner'
5677

57-
export type SpinnerProps = ComponentProps<typeof StyledSpinner>
5878
export default StyledSpinner

packages/react/src/__tests__/Spinner.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React from 'react'
2+
import axe from 'axe-core'
23
import type {SpinnerProps} from '..'
34
import {Spinner} from '..'
45
import {behavesAsComponent, checkExports} from '../utils/testing'
56
import {render as HTMLRender} from '@testing-library/react'
6-
import axe from 'axe-core'
77

88
describe('Spinner', () => {
99
behavesAsComponent({
@@ -14,6 +14,24 @@ describe('Spinner', () => {
1414
default: Spinner,
1515
})
1616

17+
it('should label the spinner with default loading text', async () => {
18+
const {getByLabelText} = HTMLRender(<Spinner />)
19+
20+
expect(getByLabelText('Loading')).toBeInTheDocument()
21+
})
22+
23+
it('should label the spinner with with custom loading text', async () => {
24+
const {getByLabelText} = HTMLRender(<Spinner srText="Custom loading text" />)
25+
26+
expect(getByLabelText('Custom loading text')).toBeInTheDocument()
27+
})
28+
29+
it('should not label the spinner with with loading text when `srText` is set to `null`', async () => {
30+
const {getByLabelText} = HTMLRender(<Spinner srText={null} />)
31+
32+
expect(() => getByLabelText('Loading')).toThrow()
33+
})
34+
1735
it('should have no axe violations', async () => {
1836
const {container} = HTMLRender(<Spinner />)
1937
const results = await axe.run(container)

packages/react/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,13 @@ exports[`snapshots renders a loading state 1`] = `
327327
justify-content: center;
328328
}
329329
330+
.c2 {
331+
display: -webkit-inline-box;
332+
display: -webkit-inline-flex;
333+
display: -ms-inline-flexbox;
334+
display: inline-flex;
335+
}
336+
330337
.c0 {
331338
position: absolute;
332339
width: 1px;
@@ -340,7 +347,17 @@ exports[`snapshots renders a loading state 1`] = `
340347
border-width: 0;
341348
}
342349
343-
.c2 {
350+
.c4:not(:focus):not(:active):not(:focus-within) {
351+
-webkit-clip-path: inset(50%);
352+
clip-path: inset(50%);
353+
height: 1px;
354+
overflow: hidden;
355+
position: absolute;
356+
white-space: nowrap;
357+
width: 1px;
358+
}
359+
360+
.c3 {
344361
-webkit-animation: rotate-keyframes 1s linear infinite;
345362
animation: rotate-keyframes 1s linear infinite;
346363
}
@@ -356,30 +373,42 @@ exports[`snapshots renders a loading state 1`] = `
356373
className="c1"
357374
display="flex"
358375
>
359-
<svg
376+
<div
360377
className="c2"
361-
fill="none"
362-
height="32px"
363-
viewBox="0 0 16 16"
364-
width="32px"
365378
>
366-
<circle
367-
cx="8"
368-
cy="8"
369-
r="7"
370-
stroke="currentColor"
371-
strokeOpacity="0.25"
372-
strokeWidth="2"
373-
vectorEffect="non-scaling-stroke"
374-
/>
375-
<path
376-
d="M15 8a7.002 7.002 0 00-7-7"
377-
stroke="currentColor"
378-
strokeLinecap="round"
379-
strokeWidth="2"
380-
vectorEffect="non-scaling-stroke"
381-
/>
382-
</svg>
379+
<svg
380+
aria-hidden={true}
381+
aria-labelledby=":r1v:"
382+
className="c3"
383+
fill="none"
384+
height="32px"
385+
viewBox="0 0 16 16"
386+
width="32px"
387+
>
388+
<circle
389+
cx="8"
390+
cy="8"
391+
r="7"
392+
stroke="currentColor"
393+
strokeOpacity="0.25"
394+
strokeWidth="2"
395+
vectorEffect="non-scaling-stroke"
396+
/>
397+
<path
398+
d="M15 8a7.002 7.002 0 00-7-7"
399+
stroke="currentColor"
400+
strokeLinecap="round"
401+
strokeWidth="2"
402+
vectorEffect="non-scaling-stroke"
403+
/>
404+
</svg>
405+
<div
406+
className="c4"
407+
id=":r1v:"
408+
>
409+
Loading
410+
</div>
411+
</div>
383412
</div>
384413
</span>,
385414
]

0 commit comments

Comments
 (0)