Skip to content
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
17 changes: 16 additions & 1 deletion packages/@react-aria/autocomplete/src/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function useAutocomplete<T>(props: AriaAutocompleteOptions<T>, state: Aut
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
let delayNextActiveDescendant = useRef(false);
let queuedActiveDescendant = useRef<string | null>(null);
let lastPointerType = useRef<string | null>(null);

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

useEffect(() => {
let handlePointerDown = (e: PointerEvent) => {
lastPointerType.current = e.pointerType;
};

if (typeof PointerEvent !== 'undefined') {
document.addEventListener('pointerdown', handlePointerDown, true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realized one case this won't handle: if the last pointer event happened before the autocomplete mounted. We may need to track the pointer type globally. useFocusVisible does this already but only stores "pointer" rather than the actual pointer type...

return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
};
}
}, []);

let updateActiveDescendantEvent = useEffectEvent((e: Event) => {
// Ensure input is focused if the user clicks on the collection directly.
if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) {
// don't trigger on touch so that mobile keyboard doesnt appear when tapping on options
if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current && lastPointerType.current !== 'touch') {
inputRef.current.focus();
}

Expand Down
1 change: 0 additions & 1 deletion packages/@react-spectrum/s2/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,6 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps<any
<>
<InternalComboboxContext.Provider value={{size}}>
<FieldLabel
includeNecessityIndicatorInAccessibilityName
isDisabled={isDisabled}
isRequired={isRequired}
size={size}
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-spectrum/s2/src/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export const FieldLabel = forwardRef(function FieldLabel(props: FieldLabelProps,
value: 'currentColor'
}
})}
aria-label={includeNecessityIndicatorInAccessibilityName ? stringFormatter.format('label.(required)') : undefined} />
aria-label={includeNecessityIndicatorInAccessibilityName ? stringFormatter.format('label.(required)') : undefined}
aria-hidden={!includeNecessityIndicatorInAccessibilityName} />
}
{necessityIndicator === 'label' &&
/* The necessity label is hidden to screen readers if the field is required because
Expand Down
223 changes: 152 additions & 71 deletions packages/dev/s2-docs/pages/react-aria/Toast.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const version = 'alpha';
},
props.timeout ? {timeout: props.timeout} : undefined
)}>
Upload files
Show Toast
</Button>
</div>
);
Expand All @@ -54,7 +54,7 @@ export const version = 'alpha';
},
props.timeout ? {timeout: props.timeout} : undefined
)}>
Upload files
Show Toast
</Button>
</div>
);
Expand All @@ -69,26 +69,50 @@ export const version = 'alpha';

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

```tsx render hideImports
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add({
title: 'Update available',
description: 'A new version is ready to install.'
})}
///- end highlight -///
>
Check for updates
</Button>
);
}
```
<ExampleSwitcher>
```tsx render hideImports type="vanilla"
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add({
title: 'Update available',
description: 'A new version is ready to install.'
})}
///- end highlight -///
>
Check for updates
</Button>
);
}
```

```tsx render hideImports type="tailwind"
"use client";
import {queue} from 'tailwind-starter/Toast';
import {Button} from 'tailwind-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add({
title: 'Update available',
description: 'A new version is ready to install.'
})}
///- end highlight -///
>
Check for updates
</Button>
);
}
```

</ExampleSwitcher>

### Close button

Expand All @@ -103,26 +127,50 @@ Include a `<Button slot="close">` to allow users to dismiss the toast manually.

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.

```tsx render hideImports
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add(
{title: 'File has been saved!'},
{timeout: 5000}
)}
///- end highlight -///
>
Save file
</Button>
);
}
```
<ExampleSwitcher>
```tsx render hideImports type="vanilla"
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add(
{title: 'File has been saved!'},
{timeout: 5000}
)}
///- end highlight -///
>
Save file
</Button>
);
}
```

```tsx render hideImports type="tailwind"
"use client";
import {queue} from 'tailwind-starter/Toast';
import {Button} from 'tailwind-starter/Button';

function Example() {
return (
<Button
///- begin highlight -///
onPress={() => queue.add(
{title: 'File has been saved!'},
{timeout: 5000}
)}
///- end highlight -///
>
Save file
</Button>
);
}
```

</ExampleSwitcher>

<InlineAlert variant="notice">
<Heading>Accessibility</Heading>
Expand All @@ -133,35 +181,68 @@ function Example() {

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.

```tsx render hideImports
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';
import {useState} from 'react';

function Example() {
let [toastKey, setToastKey] = useState(null);

return (
<Button
///- begin highlight -///
onPress={() => {
if (!toastKey) {
setToastKey(queue.add(
{title: 'Processing...'},
{onClose: () => setToastKey(null)}
));
} else {
queue.close(toastKey);
}
}}
///- end highlight -///
>
{toastKey ? 'Cancel' : 'Process'}
</Button>
);
}
```
<ExampleSwitcher>
```tsx render hideImports type="vanilla"
"use client";
import {queue} from 'vanilla-starter/Toast';
import {Button} from 'vanilla-starter/Button';
import {useState} from 'react';

function Example() {
let [toastKey, setToastKey] = useState(null);

return (
<Button
///- begin highlight -///
onPress={() => {
if (!toastKey) {
setToastKey(queue.add(
{title: 'Processing...'},
{onClose: () => setToastKey(null)}
));
} else {
queue.close(toastKey);
}
}}
///- end highlight -///
>
{toastKey ? 'Cancel' : 'Process'}
</Button>
);
}
```

```tsx render hideImports type="tailwind"
"use client";
import {queue} from 'tailwind-starter/Toast';
import {Button} from 'tailwind-starter/Button';
import {useState} from 'react';

function Example() {
let [toastKey, setToastKey] = useState(null);

return (
<Button
///- begin highlight -///
onPress={() => {
if (!toastKey) {
setToastKey(queue.add(
{title: 'Processing...'},
{onClose: () => setToastKey(null)}
));
} else {
queue.close(toastKey);
}
}}
///- end highlight -///
>
{toastKey ? 'Cancel' : 'Process'}
</Button>
);
}
```

</ExampleSwitcher>

## Accessibility

Expand Down
35 changes: 34 additions & 1 deletion packages/react-aria-components/test/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
import {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
import {AriaAutocompleteTests} from './AriaAutocomplete.test-util';
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 '..';
import React, {ReactNode, useState} from 'react';
Expand Down Expand Up @@ -382,6 +382,7 @@ let CustomFiltering = ({autocompleteProps = {}, inputProps = {}, children}: {aut

describe('Autocomplete', () => {
let user;
installPointerEvent();
beforeAll(() => {
user = userEvent.setup({delay: null, pointerMap});
jest.useFakeTimers();
Expand Down Expand Up @@ -626,6 +627,38 @@ describe('Autocomplete', () => {
expect(foo).not.toHaveAttribute('data-focus-visible');
});

it('should not move focus to the input field if tapping on a menu item via touch', async function () {
let {getByRole} = render(
<AutocompleteWrapper>
<StaticMenu />
</AutocompleteWrapper>
);

let input = getByRole('searchbox');
let menu = getByRole('menu');
let options = within(menu).getAllByRole('menuitem');
let foo = options[0];

await user.pointer({target: foo, keys: '[TouchA]'});
expect(document.activeElement).not.toBe(input);
});

it('should move focus to the input field if clicking on a menu item via mouse', async function () {
let {getByRole} = render(
<AutocompleteWrapper>
<StaticMenu />
</AutocompleteWrapper>
);

let input = getByRole('searchbox');
let menu = getByRole('menu');
let options = within(menu).getAllByRole('menuitem');
let foo = options[0];

await user.click(foo);
expect(document.activeElement).toBe(input);
});

it('should work inside a Select', async function () {
let {getByRole} = render(
<Select>
Expand Down
1 change: 1 addition & 0 deletions starters/docs/src/Toast.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
}

.react-aria-Toast {
width: 230px;
display: flex;
align-items: center;
gap: var(--spacing-4);
Expand Down
6 changes: 3 additions & 3 deletions starters/tailwind/src/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ export function MyToastRegion() {
{({toast}) => (
<MyToast toast={toast}>
<ToastContent className="flex flex-col flex-1 min-w-0">
<Text slot="title" className="font-semibold text-white">{toast.content.title}</Text>
<Text slot="title" className="font-semibold text-white text-sm">{toast.content.title}</Text>
{toast.content.description && (
<Text slot="description" className="text-sm text-white">{toast.content.description}</Text>
<Text slot="description" className="text-xs text-white">{toast.content.description}</Text>
)}
</ToastContent>
<Button
Expand All @@ -68,7 +68,7 @@ export function MyToast(props: ToastProps<MyToastContent>) {
style={{viewTransitionName: props.toast.key}}
className={composeTailwindRenderProps(
props.className,
"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"
"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]"
)}
/>
);
Expand Down