Skip to content

Commit

Permalink
Merge 12c5383 into c2b069c
Browse files Browse the repository at this point in the history
  • Loading branch information
mperrotti committed Jan 12, 2024
2 parents c2b069c + 12c5383 commit 6e00d36
Show file tree
Hide file tree
Showing 11 changed files with 2,824 additions and 1,762 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 src/Spinner/Spinner.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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
91 changes: 91 additions & 0 deletions src/Spinner/Spinner.examples.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React from 'react'
import {ComponentMeta} from '@storybook/react'
import Spinner from './Spinner'
import {Box, Button} from '..'
import {VisuallyHidden} from '../internal/components/VisuallyHidden'

export default {
title: 'Components/Spinner/Examples',
component: Spinner,
} as ComponentMeta<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 role="status">{state === 'done' && 'Content finished loading'}</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} />}
<span role="status">{state === 'loading' ? 'Content is loading...' : ''}</span>
</Box>
)}
<p>{loadedContent}</p>
<VisuallyHidden role="status">{state === 'done' && 'Content finished loading'}</VisuallyHidden>
</Box>
)
}
8 changes: 8 additions & 0 deletions src/Spinner/Spinner.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import {ComponentMeta} from '@storybook/react'
import Spinner from './Spinner'
import {Box} from '..'

export default {
title: 'Components/Spinner/Features',
Expand All @@ -10,3 +11,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} />
<span role="status">Loading...</span>
</Box>
)
59 changes: 35 additions & 24 deletions src/Spinner/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,57 @@
import React from 'react'
import styled from 'styled-components'
import sx, {SxProp} from '../sx'
import {ComponentProps} from '../utils/types'
import {VisuallyHidden} from '../internal/components/VisuallyHidden'
import {HTMLDataAttributes} from '../internal/internal-types'
import {Box} from '../'

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)

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'}} role={hasSrAnnouncement ? 'status' : undefined}>
<svg height={size} width={size} viewBox="0 0 16 16" fill="none" aria-hidden {...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>{srText || ariaLabel}</VisuallyHidden> : null}
</Box>
)
}

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

StyledSpinner.displayName = 'Spinner'

export type SpinnerProps = ComponentProps<typeof StyledSpinner>
export default StyledSpinner
18 changes: 18 additions & 0 deletions src/__tests__/Spinner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ describe('Spinner', () => {
default: Spinner,
})

it('should render an ARIA live region with default loading text', async () => {
const {getByRole} = HTMLRender(<Spinner />)

expect(getByRole('status').textContent).toBe('Loading')
})

it('should render an ARIA live region with custom loading text', async () => {
const {getByRole} = HTMLRender(<Spinner srText="Custom loading text" />)

expect(getByRole('status').textContent).toBe('Custom loading text')
})

it('should not render an ARIA live region with loading text when `srText` is set to `null`', async () => {
const {queryByRole} = HTMLRender(<Spinner srText={null} />)

expect(queryByRole('status')).not.toBeInTheDocument()
})

it('should have no axe violations', async () => {
const {container} = HTMLRender(<Spinner />)
const results = await axe(container)
Expand Down
74 changes: 51 additions & 23 deletions src/__tests__/__snapshots__/Autocomplete.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,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 @@ -324,7 +331,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 @@ -340,30 +357,41 @@ 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"
role="status"
>
<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}
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"
>
Loading
</div>
</div>
</div>
</span>,
]
Expand Down
Loading

0 comments on commit 6e00d36

Please sign in to comment.