Skip to content

Commit 02b8800

Browse files
LFDanLudevongovett
andauthored
docs: Toast/TabsPicker docs fixes, prevent Autocomplete input focus on mobile (#9243)
* update Toast examples to work when user is on tailwind * hide asterix from screenreaders if required is applied to the field already still needed for cases like Picker since those dont support aria-invalid since they are buttons * add default class name to tab picker so it doesnt pick up react-aria-select styles * only focus autocomplete input field if click or keyboard --------- Co-authored-by: Devon Govett <[email protected]>
1 parent 65e58b5 commit 02b8800

File tree

7 files changed

+208
-78
lines changed

7 files changed

+208
-78
lines changed

packages/@react-aria/autocomplete/src/useAutocomplete.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
9292
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
9393
let delayNextActiveDescendant = useRef(false);
9494
let queuedActiveDescendant = useRef<string | null>(null);
95+
let lastPointerType = useRef<string | null>(null);
9596

9697
// For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually
9798
// moving focus back to the subtriggers
@@ -105,9 +106,23 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
105106
return () => clearTimeout(timeout.current);
106107
}, []);
107108

109+
useEffect(() => {
110+
let handlePointerDown = (e: PointerEvent) => {
111+
lastPointerType.current = e.pointerType;
112+
};
113+
114+
if (typeof PointerEvent !== 'undefined') {
115+
document.addEventListener('pointerdown', handlePointerDown, true);
116+
return () => {
117+
document.removeEventListener('pointerdown', handlePointerDown, true);
118+
};
119+
}
120+
}, []);
121+
108122
let updateActiveDescendantEvent = useEffectEvent((e: Event) => {
109123
// Ensure input is focused if the user clicks on the collection directly.
110-
if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) {
124+
// don't trigger on touch so that mobile keyboard doesnt appear when tapping on options
125+
if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current && lastPointerType.current !== 'touch') {
111126
inputRef.current.focus();
112127
}
113128

packages/@react-spectrum/s2/src/ComboBox.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,6 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps<any
601601
<>
602602
<InternalComboboxContext.Provider value={{size}}>
603603
<FieldLabel
604-
includeNecessityIndicatorInAccessibilityName
605604
isDisabled={isDisabled}
606605
isRequired={isRequired}
607606
size={size}

packages/@react-spectrum/s2/src/Field.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ export const FieldLabel = forwardRef(function FieldLabel(props: FieldLabelProps,
112112
value: 'currentColor'
113113
}
114114
})}
115-
aria-label={includeNecessityIndicatorInAccessibilityName ? stringFormatter.format('label.(required)') : undefined} />
115+
aria-label={includeNecessityIndicatorInAccessibilityName ? stringFormatter.format('label.(required)') : undefined}
116+
aria-hidden={!includeNecessityIndicatorInAccessibilityName} />
116117
}
117118
{necessityIndicator === 'label' &&
118119
/* The necessity label is hidden to screen readers if the field is required because

packages/dev/s2-docs/pages/react-aria/Toast.mdx

Lines changed: 152 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const description = 'Displays brief, temporary notifications of actions,
3333
},
3434
props.timeout ? {timeout: props.timeout} : undefined
3535
)}>
36-
Upload files
36+
Show Toast
3737
</Button>
3838
</div>
3939
);
@@ -56,7 +56,7 @@ export const description = 'Displays brief, temporary notifications of actions,
5656
},
5757
props.timeout ? {timeout: props.timeout} : undefined
5858
)}>
59-
Upload files
59+
Show Toast
6060
</Button>
6161
</div>
6262
);
@@ -71,26 +71,50 @@ export const description = 'Displays brief, temporary notifications of actions,
7171

7272
Use the `"title"` and `"description"` slots within `<ToastContent>` to provide structured content for the toast. The title is required, and description is optional.
7373

74-
```tsx render hideImports
75-
"use client";
76-
import {queue} from 'vanilla-starter/Toast';
77-
import {Button} from 'vanilla-starter/Button';
78-
79-
function Example() {
80-
return (
81-
<Button
82-
///- begin highlight -///
83-
onPress={() => queue.add({
84-
title: 'Update available',
85-
description: 'A new version is ready to install.'
86-
})}
87-
///- end highlight -///
88-
>
89-
Check for updates
90-
</Button>
91-
);
92-
}
93-
```
74+
<ExampleSwitcher>
75+
```tsx render hideImports type="vanilla"
76+
"use client";
77+
import {queue} from 'vanilla-starter/Toast';
78+
import {Button} from 'vanilla-starter/Button';
79+
80+
function Example() {
81+
return (
82+
<Button
83+
///- begin highlight -///
84+
onPress={() => queue.add({
85+
title: 'Update available',
86+
description: 'A new version is ready to install.'
87+
})}
88+
///- end highlight -///
89+
>
90+
Check for updates
91+
</Button>
92+
);
93+
}
94+
```
95+
96+
```tsx render hideImports type="tailwind"
97+
"use client";
98+
import {queue} from 'tailwind-starter/Toast';
99+
import {Button} from 'tailwind-starter/Button';
100+
101+
function Example() {
102+
return (
103+
<Button
104+
///- begin highlight -///
105+
onPress={() => queue.add({
106+
title: 'Update available',
107+
description: 'A new version is ready to install.'
108+
})}
109+
///- end highlight -///
110+
>
111+
Check for updates
112+
</Button>
113+
);
114+
}
115+
```
116+
117+
</ExampleSwitcher>
94118

95119
### Close button
96120

@@ -105,26 +129,50 @@ Include a `<Button slot="close">` to allow users to dismiss the toast manually.
105129

106130
Use the `timeout` option to automatically dismiss toasts after a period of time. For accessibility, toasts should have a minimum timeout of **5 seconds**. Timers automatically pause when the user focuses or hovers over a toast.
107131

108-
```tsx render hideImports
109-
"use client";
110-
import {queue} from 'vanilla-starter/Toast';
111-
import {Button} from 'vanilla-starter/Button';
112-
113-
function Example() {
114-
return (
115-
<Button
116-
///- begin highlight -///
117-
onPress={() => queue.add(
118-
{title: 'File has been saved!'},
119-
{timeout: 5000}
120-
)}
121-
///- end highlight -///
122-
>
123-
Save file
124-
</Button>
125-
);
126-
}
127-
```
132+
<ExampleSwitcher>
133+
```tsx render hideImports type="vanilla"
134+
"use client";
135+
import {queue} from 'vanilla-starter/Toast';
136+
import {Button} from 'vanilla-starter/Button';
137+
138+
function Example() {
139+
return (
140+
<Button
141+
///- begin highlight -///
142+
onPress={() => queue.add(
143+
{title: 'File has been saved!'},
144+
{timeout: 5000}
145+
)}
146+
///- end highlight -///
147+
>
148+
Save file
149+
</Button>
150+
);
151+
}
152+
```
153+
154+
```tsx render hideImports type="tailwind"
155+
"use client";
156+
import {queue} from 'tailwind-starter/Toast';
157+
import {Button} from 'tailwind-starter/Button';
158+
159+
function Example() {
160+
return (
161+
<Button
162+
///- begin highlight -///
163+
onPress={() => queue.add(
164+
{title: 'File has been saved!'},
165+
{timeout: 5000}
166+
)}
167+
///- end highlight -///
168+
>
169+
Save file
170+
</Button>
171+
);
172+
}
173+
```
174+
175+
</ExampleSwitcher>
128176

129177
<InlineAlert variant="notice">
130178
<Heading>Accessibility</Heading>
@@ -135,35 +183,68 @@ function Example() {
135183

136184
Toasts can be programmatically dismissed using the key returned from `queue.add()`. This is useful when a toast becomes irrelevant before the user manually closes it.
137185

138-
```tsx render hideImports
139-
"use client";
140-
import {queue} from 'vanilla-starter/Toast';
141-
import {Button} from 'vanilla-starter/Button';
142-
import {useState} from 'react';
143-
144-
function Example() {
145-
let [toastKey, setToastKey] = useState(null);
146-
147-
return (
148-
<Button
149-
///- begin highlight -///
150-
onPress={() => {
151-
if (!toastKey) {
152-
setToastKey(queue.add(
153-
{title: 'Processing...'},
154-
{onClose: () => setToastKey(null)}
155-
));
156-
} else {
157-
queue.close(toastKey);
158-
}
159-
}}
160-
///- end highlight -///
161-
>
162-
{toastKey ? 'Cancel' : 'Process'}
163-
</Button>
164-
);
165-
}
166-
```
186+
<ExampleSwitcher>
187+
```tsx render hideImports type="vanilla"
188+
"use client";
189+
import {queue} from 'vanilla-starter/Toast';
190+
import {Button} from 'vanilla-starter/Button';
191+
import {useState} from 'react';
192+
193+
function Example() {
194+
let [toastKey, setToastKey] = useState(null);
195+
196+
return (
197+
<Button
198+
///- begin highlight -///
199+
onPress={() => {
200+
if (!toastKey) {
201+
setToastKey(queue.add(
202+
{title: 'Processing...'},
203+
{onClose: () => setToastKey(null)}
204+
));
205+
} else {
206+
queue.close(toastKey);
207+
}
208+
}}
209+
///- end highlight -///
210+
>
211+
{toastKey ? 'Cancel' : 'Process'}
212+
</Button>
213+
);
214+
}
215+
```
216+
217+
```tsx render hideImports type="tailwind"
218+
"use client";
219+
import {queue} from 'tailwind-starter/Toast';
220+
import {Button} from 'tailwind-starter/Button';
221+
import {useState} from 'react';
222+
223+
function Example() {
224+
let [toastKey, setToastKey] = useState(null);
225+
226+
return (
227+
<Button
228+
///- begin highlight -///
229+
onPress={() => {
230+
if (!toastKey) {
231+
setToastKey(queue.add(
232+
{title: 'Processing...'},
233+
{onClose: () => setToastKey(null)}
234+
));
235+
} else {
236+
queue.close(toastKey);
237+
}
238+
}}
239+
///- end highlight -///
240+
>
241+
{toastKey ? 'Cancel' : 'Process'}
242+
</Button>
243+
);
244+
}
245+
```
246+
247+
</ExampleSwitcher>
167248

168249
## Accessibility
169250

packages/react-aria-components/test/Autocomplete.test.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
13+
import {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
1414
import {AriaAutocompleteTests} from './AriaAutocomplete.test-util';
1515
import {Autocomplete, Breadcrumb, Breadcrumbs, Button, Cell, Collection, Column, Dialog, DialogTrigger, GridList, GridListItem, Header, Input, Label, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, Tag, TagGroup, TagList, Text, TextField, Tree, TreeItem, TreeItemContent} from '..';
1616
import React, {ReactNode, useState} from 'react';
@@ -382,6 +382,7 @@ let CustomFiltering = ({autocompleteProps = {}, inputProps = {}, children}: {aut
382382

383383
describe('Autocomplete', () => {
384384
let user;
385+
installPointerEvent();
385386
beforeAll(() => {
386387
user = userEvent.setup({delay: null, pointerMap});
387388
jest.useFakeTimers();
@@ -626,6 +627,38 @@ describe('Autocomplete', () => {
626627
expect(foo).not.toHaveAttribute('data-focus-visible');
627628
});
628629

630+
it('should not move focus to the input field if tapping on a menu item via touch', async function () {
631+
let {getByRole} = render(
632+
<AutocompleteWrapper>
633+
<StaticMenu />
634+
</AutocompleteWrapper>
635+
);
636+
637+
let input = getByRole('searchbox');
638+
let menu = getByRole('menu');
639+
let options = within(menu).getAllByRole('menuitem');
640+
let foo = options[0];
641+
642+
await user.pointer({target: foo, keys: '[TouchA]'});
643+
expect(document.activeElement).not.toBe(input);
644+
});
645+
646+
it('should move focus to the input field if clicking on a menu item via mouse', async function () {
647+
let {getByRole} = render(
648+
<AutocompleteWrapper>
649+
<StaticMenu />
650+
</AutocompleteWrapper>
651+
);
652+
653+
let input = getByRole('searchbox');
654+
let menu = getByRole('menu');
655+
let options = within(menu).getAllByRole('menuitem');
656+
let foo = options[0];
657+
658+
await user.click(foo);
659+
expect(document.activeElement).toBe(input);
660+
});
661+
629662
it('should work inside a Select', async function () {
630663
let {getByRole} = render(
631664
<Select>

starters/docs/src/Toast.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
}
1818

1919
.react-aria-Toast {
20+
width: 230px;
2021
display: flex;
2122
align-items: center;
2223
gap: var(--spacing-4);

starters/tailwind/src/Toast.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ export function MyToastRegion() {
4444
{({toast}) => (
4545
<MyToast toast={toast}>
4646
<ToastContent className="flex flex-col flex-1 min-w-0">
47-
<Text slot="title" className="font-semibold text-white">{toast.content.title}</Text>
47+
<Text slot="title" className="font-semibold text-white text-sm">{toast.content.title}</Text>
4848
{toast.content.description && (
49-
<Text slot="description" className="text-sm text-white">{toast.content.description}</Text>
49+
<Text slot="description" className="text-xs text-white">{toast.content.description}</Text>
5050
)}
5151
</ToastContent>
5252
<Button
@@ -68,7 +68,7 @@ export function MyToast(props: ToastProps<MyToastContent>) {
6868
style={{viewTransitionName: props.toast.key}}
6969
className={composeTailwindRenderProps(
7070
props.className,
71-
"flex items-center gap-4 bg-blue-600 px-4 py-3 rounded-lg outline-none forced-colors:outline focus-visible:outline-2 focus-visible:outline-blue-600 focus-visible:outline-offset-2 [view-transition-class:toast] font-sans"
71+
"flex items-center gap-4 bg-blue-600 px-4 py-3 rounded-lg outline-none forced-colors:outline focus-visible:outline-2 focus-visible:outline-blue-600 focus-visible:outline-offset-2 [view-transition-class:toast] font-sans w-[230px]"
7272
)}
7373
/>
7474
);

0 commit comments

Comments
 (0)