Skip to content

Commit 3555336

Browse files
authored
RAC Pending Button (#6435)
* RAC Pending Button
1 parent 553ef94 commit 3555336

File tree

13 files changed

+535
-61
lines changed

13 files changed

+535
-61
lines changed

packages/@react-aria/live-announcer/src/LiveAnnouncer.tsx

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,20 @@ type Assertiveness = 'assertive' | 'polite';
1515
/* Inspired by https://github.com/AlmeroSteyn/react-aria-live */
1616
const LIVEREGION_TIMEOUT_DELAY = 7000;
1717

18-
let liveAnnouncer: LiveAnnouncer | null = null;
19-
2018
/**
2119
* Announces the message using screen reader technology.
2220
*/
2321
export function announce(
2422
message: string,
2523
assertiveness: Assertiveness = 'assertive',
26-
timeout = LIVEREGION_TIMEOUT_DELAY
24+
timeout = LIVEREGION_TIMEOUT_DELAY,
25+
mode: 'message' | 'ids' = 'message'
2726
) {
2827
if (!liveAnnouncer) {
2928
liveAnnouncer = new LiveAnnouncer();
3029
}
3130

32-
liveAnnouncer.announce(message, assertiveness, timeout);
31+
liveAnnouncer.announce(message, assertiveness, timeout, mode);
3332
}
3433

3534
/**
@@ -58,34 +57,36 @@ export function destroyAnnouncer() {
5857
// is simple enough to implement without React, so that's what we do here.
5958
// See this discussion for more details: https://github.com/reactwg/react-18/discussions/125#discussioncomment-2382638
6059
class LiveAnnouncer {
61-
node: HTMLElement | null;
62-
assertiveLog: HTMLElement;
63-
politeLog: HTMLElement;
60+
node: HTMLElement | null = null;
61+
assertiveLog: HTMLElement | null = null;
62+
politeLog: HTMLElement | null = null;
6463

6564
constructor() {
66-
this.node = document.createElement('div');
67-
this.node.dataset.liveAnnouncer = 'true';
68-
// copied from VisuallyHidden
69-
Object.assign(this.node.style, {
70-
border: 0,
71-
clip: 'rect(0 0 0 0)',
72-
clipPath: 'inset(50%)',
73-
height: '1px',
74-
margin: '-1px',
75-
overflow: 'hidden',
76-
padding: 0,
77-
position: 'absolute',
78-
width: '1px',
79-
whiteSpace: 'nowrap'
80-
});
81-
82-
this.assertiveLog = this.createLog('assertive');
83-
this.node.appendChild(this.assertiveLog);
84-
85-
this.politeLog = this.createLog('polite');
86-
this.node.appendChild(this.politeLog);
87-
88-
document.body.prepend(this.node);
65+
if (typeof document !== 'undefined') {
66+
this.node = document.createElement('div');
67+
this.node.dataset.liveAnnouncer = 'true';
68+
// copied from VisuallyHidden
69+
Object.assign(this.node.style, {
70+
border: 0,
71+
clip: 'rect(0 0 0 0)',
72+
clipPath: 'inset(50%)',
73+
height: '1px',
74+
margin: '-1px',
75+
overflow: 'hidden',
76+
padding: 0,
77+
position: 'absolute',
78+
width: '1px',
79+
whiteSpace: 'nowrap'
80+
});
81+
82+
this.assertiveLog = this.createLog('assertive');
83+
this.node.appendChild(this.assertiveLog);
84+
85+
this.politeLog = this.createLog('polite');
86+
this.node.appendChild(this.politeLog);
87+
88+
document.body.prepend(this.node);
89+
}
8990
}
9091

9192
createLog(ariaLive: string) {
@@ -105,18 +106,24 @@ class LiveAnnouncer {
105106
this.node = null;
106107
}
107108

108-
announce(message: string, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY) {
109+
announce(message: string, assertiveness = 'assertive', timeout = LIVEREGION_TIMEOUT_DELAY, mode: 'message' | 'ids' = 'message') {
109110
if (!this.node) {
110111
return;
111112
}
112113

113114
let node = document.createElement('div');
114-
node.textContent = message;
115+
if (mode === 'message') {
116+
node.textContent = message;
117+
} else {
118+
// To read an aria-labelledby, the element must have an appropriate role, such as img.
119+
node.setAttribute('role', 'img');
120+
node.setAttribute('aria-labelledby', message);
121+
}
115122

116123
if (assertiveness === 'assertive') {
117-
this.assertiveLog.appendChild(node);
124+
this.assertiveLog?.appendChild(node);
118125
} else {
119-
this.politeLog.appendChild(node);
126+
this.politeLog?.appendChild(node);
120127
}
121128

122129
if (message !== '') {
@@ -131,12 +138,16 @@ class LiveAnnouncer {
131138
return;
132139
}
133140

134-
if (!assertiveness || assertiveness === 'assertive') {
141+
if ((!assertiveness || assertiveness === 'assertive') && this.assertiveLog) {
135142
this.assertiveLog.innerHTML = '';
136143
}
137144

138-
if (!assertiveness || assertiveness === 'polite') {
145+
if ((!assertiveness || assertiveness === 'polite') && this.politeLog) {
139146
this.politeLog.innerHTML = '';
140147
}
141148
}
142149
}
150+
151+
// singleton, setup immediately so that the DOM is primed for the first announcement as soon as possible
152+
// Safari has a race condition where the first announcement is not read if we wait until the first announce call
153+
let liveAnnouncer: LiveAnnouncer | null = new LiveAnnouncer();

packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
jest.mock('@react-aria/live-announcer');
14-
import {act, fireEvent, pointerMap, render, screen, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal';
14+
import {act, fireEvent, pointerMap, render, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal';
1515
import {announce} from '@react-aria/live-announcer';
1616
import {Button} from '@react-spectrum/button';
1717
import Filter from '@spectrum-icons/workflow/Filter';
@@ -3228,7 +3228,9 @@ describe('SearchAutocomplete', function () {
32283228

32293229
let listbox = getByRole('listbox');
32303230
expect(listbox).toBeVisible();
3231-
expect(screen.getAllByRole('log')).toHaveLength(2);
3231+
expect(announce).toHaveBeenCalledTimes(2);
3232+
expect(announce).toHaveBeenNthCalledWith(1, '3 options available.');
3233+
expect(announce).toHaveBeenNthCalledWith(2, 'One');
32323234
platformMock.mockRestore();
32333235
});
32343236

packages/@react-spectrum/color/test/ColorPicker.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('ColorPicker', function () {
4040

4141
let button = getByRole('button');
4242
expect(button).toHaveTextContent('Fill');
43-
expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'vibrant red');
43+
expect(within(button).getByLabelText('vibrant red')).toBeInTheDocument();
4444

4545
await user.click(button);
4646

@@ -67,7 +67,7 @@ describe('ColorPicker', function () {
6767
act(() => dialog.focus());
6868
await user.keyboard('{Escape}');
6969
act(() => {jest.runAllTimers();});
70-
expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'dark vibrant blue');
70+
expect(within(button).getByLabelText('dark vibrant blue')).toBeInTheDocument();
7171
});
7272

7373
it('should have default value of black', async function () {
@@ -81,7 +81,7 @@ describe('ColorPicker', function () {
8181

8282
let button = getByRole('button');
8383
expect(button).toHaveTextContent('Fill');
84-
expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'black');
84+
expect(within(button).getByLabelText('black')).toBeInTheDocument();
8585

8686
await user.click(button);
8787

@@ -132,6 +132,6 @@ describe('ColorPicker', function () {
132132
act(() => getByRole('dialog').focus());
133133
await user.keyboard('{Escape}');
134134
act(() => {jest.runAllTimers();});
135-
expect(within(button).getByRole('img')).toHaveAttribute('aria-label', 'vibrant orange');
135+
expect(within(button).getByLabelText('vibrant orange')).toBeInTheDocument();
136136
});
137137
});

packages/@react-spectrum/combobox/test/ComboBox.test.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
jest.mock('@react-aria/live-announcer');
14-
import {act, fireEvent, pointerMap, render, screen, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal';
14+
import {act, fireEvent, pointerMap, render, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal';
1515
import {announce} from '@react-aria/live-announcer';
1616
import {Button} from '@react-spectrum/button';
1717
import {chain} from '@react-aria/utils';
@@ -5149,7 +5149,9 @@ describe('ComboBox', function () {
51495149

51505150
let listbox = getByRole('listbox');
51515151
expect(listbox).toBeVisible();
5152-
expect(screen.getAllByRole('log')).toHaveLength(2);
5152+
expect(announce).toHaveBeenCalledTimes(2);
5153+
expect(announce).toHaveBeenNthCalledWith(1, '3 options available.');
5154+
expect(announce).toHaveBeenNthCalledWith(2, 'One');
51535155
platformMock.mockRestore();
51545156
});
51555157

packages/@react-spectrum/provider/test/Provider.test.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,11 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
// needs to be imported first
14-
// eslint-disable-next-line
15-
import MatchMediaMock from 'jest-matchmedia-mock';
16-
// eslint-disable-next-line rsp-rules/sort-imports
1713
import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
14+
// eslint-disable-next-line rsp-rules/sort-imports
1815
import {ActionButton, Button} from '@react-spectrum/button';
1916
import {Checkbox} from '@react-spectrum/checkbox';
17+
import MatchMediaMock from 'jest-matchmedia-mock';
2018
import {Provider} from '../';
2119
// eslint-disable-next-line rulesdir/useLayoutEffectRule
2220
import React, {useLayoutEffect, useRef} from 'react';

packages/react-aria-components/docs/Button.mdx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,138 @@ A `Button` can be disabled using the `isDisabled` prop.
159159

160160
</details>
161161

162+
## Pending
163+
164+
A `Button` can be put into the pending state using the `isPending` prop.
165+
Both a `Text` and [ProgressBar](ProgressBar.html) component are required to show the pending state correctly.
166+
Make sure to internationalize the label you pass to the [ProgressBar](ProgressBar.html) component.
167+
168+
```tsx example
169+
import {useEffect, useRef, useState} from 'react';
170+
import {ProgressBar, Text} from 'react-aria-components';
171+
172+
function PendingButton(props) {
173+
let [isPending, setPending] = useState(false);
174+
175+
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
176+
let handlePress = (e) => {
177+
setPending(true);
178+
timeout.current = setTimeout(() => {
179+
setPending(false);
180+
timeout.current = undefined;
181+
}, 5000);
182+
};
183+
184+
useEffect(() => {
185+
return () => {
186+
clearTimeout(timeout.current);
187+
};
188+
}, []);
189+
190+
return (
191+
<Button
192+
{...props}
193+
isPending={isPending}
194+
onPress={handlePress}>
195+
{({isPending}) => (
196+
<>
197+
<Text className={isPending ? 'pending' : undefined}>Click me</Text>
198+
<ProgressBar
199+
aria-label="loading"
200+
isIndeterminate
201+
className={['spinner', (isPending ? 'spinner-pending' : '')].join(' ')}>
202+
<span className={'loader'} />
203+
</ProgressBar>
204+
</>
205+
)}
206+
</Button>
207+
);
208+
}
209+
<PendingButton />
210+
```
211+
212+
<details>
213+
<summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Show CSS</summary>
214+
215+
```css
216+
@keyframes load {
217+
99% {
218+
visibility: hidden;
219+
}
220+
221+
100% {
222+
visibility: visible;
223+
}
224+
}
225+
226+
@keyframes hidden {
227+
99% {
228+
visibility: visible;
229+
}
230+
231+
100% {
232+
visibility: hidden;
233+
}
234+
}
235+
236+
.react-aria-Button {
237+
position: relative;
238+
}
239+
240+
.spinner {
241+
position: absolute;
242+
top: 50%;
243+
left: 50%;
244+
transform: translate(-50%, -50%);
245+
visibility: hidden;
246+
}
247+
.spinner-pending {
248+
animation: 1s load;
249+
animation-fill-mode: forwards;
250+
}
251+
252+
.pending {
253+
animation: 1s hidden;
254+
animation-fill-mode: forwards;
255+
visibility: visible;
256+
}
257+
258+
.loader {
259+
width: 20px;
260+
height: 20px;
261+
border: 3px solid var(--background-color);
262+
border-radius: 50%;
263+
display: inline-block;
264+
position: relative;
265+
box-sizing: border-box;
266+
animation: rotation 1s linear infinite;
267+
}
268+
.loader::after {
269+
content: '';
270+
box-sizing: border-box;
271+
position: absolute;
272+
left: 50%;
273+
top: 50%;
274+
transform: translate(-50%, -50%);
275+
width: 20px;
276+
height: 20px;
277+
border-radius: 50%;
278+
border: 3px solid;
279+
border-color: var(--purple-400) transparent;
280+
}
281+
282+
@keyframes rotation {
283+
0% {
284+
transform: rotate(0deg);
285+
}
286+
100% {
287+
transform: rotate(360deg);
288+
}
289+
}
290+
```
291+
292+
</details>
293+
162294
## Link buttons
163295

164296
The `Button` component always represents a button semantically. To create a link that visually looks like a button, use the [Link](Link.html) component instead. You can reuse the same styles you apply to the `Button` component on the `Link`.

packages/react-aria-components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@react-aria/dnd": "^3.7.2",
4545
"@react-aria/focus": "^3.18.2",
4646
"@react-aria/interactions": "^3.22.2",
47+
"@react-aria/live-announcer": "^3.3.4",
4748
"@react-aria/menu": "^3.15.3",
4849
"@react-aria/toolbar": "3.0.0-beta.8",
4950
"@react-aria/tree": "3.0.0-alpha.5",

0 commit comments

Comments
 (0)