diff --git a/.storybook/custom-addons/provider/index.js b/.storybook/custom-addons/provider/index.js index 29ea9398a33..5c8ea217863 100644 --- a/.storybook/custom-addons/provider/index.js +++ b/.storybook/custom-addons/provider/index.js @@ -17,7 +17,6 @@ function ProviderUpdater(props) { let [localeValue, setLocale] = useState(providerValuesFromUrl.locale || undefined); let [themeValue, setTheme] = useState(providerValuesFromUrl.theme || undefined); let [scaleValue, setScale] = useState(providerValuesFromUrl.scale || undefined); - let [toastPositionValue, setToastPosition] = useState(providerValuesFromUrl.toastPosition || 'bottom'); let [expressValue, setExpress] = useState(providerValuesFromUrl.express === 'true'); let [storyReady, setStoryReady] = useState(window.parent === window); // reduce content flash because it takes a moment to get the provider details // Typically themes are provided with both light + dark, and both scales. @@ -30,7 +29,6 @@ function ProviderUpdater(props) { setLocale(event.locale); setTheme(event.theme === 'Auto' ? undefined : event.theme); setScale(event.scale === 'Auto' ? undefined : event.scale); - setToastPosition(event.toastPosition); setExpress(event.express); setStoryReady(true); }; @@ -44,7 +42,7 @@ function ProviderUpdater(props) { if (props.options.mainElement == null) { return ( - +
{storyReady && props.children}
@@ -52,7 +50,7 @@ function ProviderUpdater(props) { ); } else { return ( - + {storyReady && props.children} ); diff --git a/.storybook/custom-addons/provider/register.js b/.storybook/custom-addons/provider/register.js index 3d663a4e61b..994fe1c06f1 100644 --- a/.storybook/custom-addons/provider/register.js +++ b/.storybook/custom-addons/provider/register.js @@ -36,7 +36,7 @@ let TOAST_POSITIONS = [ ]; function ProviderFieldSetter({api}) { - let [values, setValues] = useState({locale: providerValuesFromUrl.locale || undefined, theme: providerValuesFromUrl.theme || undefined, scale: providerValuesFromUrl.scale || undefined, toastPosition: providerValuesFromUrl.toastPosition || 'bottom', express: providerValuesFromUrl.express === 'true'}); + let [values, setValues] = useState({locale: providerValuesFromUrl.locale || undefined, theme: providerValuesFromUrl.theme || undefined, scale: providerValuesFromUrl.scale || undefined, express: providerValuesFromUrl.express === 'true'}); let channel = addons.getChannel(); let onLocaleChange = (e) => { let newValue = e.target.value || undefined; @@ -62,14 +62,6 @@ function ProviderFieldSetter({api}) { return next; }); }; - let onToastPositionChange = (e) => { - let newValue = e.target.value; - setValues((old) => { - let next = {...old, toastPosition: newValue}; - channel.emit('provider/updated', next); - return next; - }); - }; let onExpressChange = (e) => { let newValue = e.target.checked; setValues((old) => { @@ -93,7 +85,6 @@ function ProviderFieldSetter({api}) { 'providerSwitcher-locale': values.locale || '', 'providerSwitcher-theme': values.theme || '', 'providerSwitcher-scale': values.scale || '', - 'providerSwitcher-toastPosition': values.toastPosition || '', 'providerSwitcher-express': String(values.express), }); }); @@ -118,12 +109,6 @@ function ProviderFieldSetter({api}) { {SCALES.map(scale => )} -
- - -
diff --git a/packages/@adobe/spectrum-css-temp/components/button/skin.css b/packages/@adobe/spectrum-css-temp/components/button/skin.css index 3b3478f465b..c9594cea2f9 100644 --- a/packages/@adobe/spectrum-css-temp/components/button/skin.css +++ b/packages/@adobe/spectrum-css-temp/components/button/skin.css @@ -55,7 +55,6 @@ governing permissions and limitations under the License. .spectrum-ClearButton { background-color: var(--spectrum-clearbutton-medium-background-color); - color: var(--spectrum-clearbutton-medium-icon-color); .spectrum-Icon { @@ -64,7 +63,6 @@ governing permissions and limitations under the License. &:hover { background-color: var(--spectrum-clearbutton-medium-background-color-hover); - color: var(--spectrum-clearbutton-medium-icon-color-hover); .spectrum-Icon { @@ -74,7 +72,6 @@ governing permissions and limitations under the License. &.is-active { background-color: var(--spectrum-clearbutton-medium-background-color-down); - color: var(--spectrum-clearbutton-medium-icon-color-down); .spectrum-Icon { @@ -84,7 +81,6 @@ governing permissions and limitations under the License. &:focus-ring { background-color: var(--spectrum-clearbutton-medium-background-color-key-focus); - color: var(--spectrum-clearbutton-medium-icon-color-key-focus); .spectrum-Icon { @@ -95,7 +91,6 @@ governing permissions and limitations under the License. &:disabled, &.is-disabled { background-color: var(--spectrum-clearbutton-medium-background-color-disabled); - color: var(--spectrum-clearbutton-medium-icon-color-disabled); .spectrum-Icon { @@ -104,6 +99,20 @@ governing permissions and limitations under the License. } } +.spectrum-ClearButton--overBackground { + --spectrum-clearbutton-medium-background-color: transparent; + --spectrum-clearbutton-medium-background-color-hover: rgba(255, 255, 255, 0.1); + --spectrum-clearbutton-medium-background-color-key-focus: rgba(255, 255, 255, 0.1); + --spectrum-clearbutton-medium-background-color-down: rgba(255, 255, 255, 0.15); + --spectrum-clearbutton-medium-background-color-disabled: transparent; + --spectrum-clearbutton-medium-icon-color: white; + --spectrum-clearbutton-medium-icon-color-hover: white; + --spectrum-clearbutton-medium-icon-color-down: white; + --spectrum-clearbutton-medium-icon-color-key-focus: white; + --spectrum-clearbutton-medium-icon-color-disabled: rgba(255, 255, 255, 0.55); + --spectrum-focus-ring-color: white; +} + .spectrum-Button { &[data-style=fill] { --spectrum-button-text-color: white; diff --git a/packages/@adobe/spectrum-css-temp/components/commons/focus-ring.css b/packages/@adobe/spectrum-css-temp/components/commons/focus-ring.css index 5888c882f5f..4d3947b0899 100644 --- a/packages/@adobe/spectrum-css-temp/components/commons/focus-ring.css +++ b/packages/@adobe/spectrum-css-temp/components/commons/focus-ring.css @@ -1,4 +1,4 @@ -%spectrum-FocusRing { +%spectrum-FocusRing-ring { --spectrum-focus-ring-border-radius: var(--spectrum-textfield-border-radius); --spectrum-focus-ring-gap: var(--spectrum-alias-input-focusring-gap); --spectrum-focus-ring-size: var(--spectrum-alias-input-focusring-size); @@ -20,12 +20,20 @@ transition: box-shadow var(--spectrum-global-animation-duration-100) ease-out, margin var(--spectrum-global-animation-duration-100) ease-out; } +} + +%spectrum-FocusRing-active { + &:after { + margin: calc(var(--spectrum-focus-ring-gap) * -1 - var(--spectrum-focus-ring-border-size)); + box-shadow: 0 0 0 var(--spectrum-focus-ring-size) var(--spectrum-focus-ring-color); + } +} + +%spectrum-FocusRing { + @inherit %spectrum-FocusRing-ring; &:focus-ring { - &:after { - margin: calc(var(--spectrum-focus-ring-gap) * -1 - var(--spectrum-focus-ring-border-size)); - box-shadow: 0 0 0 var(--spectrum-focus-ring-size) var(--spectrum-focus-ring-color); - } + @inherit %spectrum-FocusRing-active; } } diff --git a/packages/@adobe/spectrum-css-temp/components/toast/index.css b/packages/@adobe/spectrum-css-temp/components/toast/index.css index b125b5a8324..1cabe7676bf 100644 --- a/packages/@adobe/spectrum-css-temp/components/toast/index.css +++ b/packages/@adobe/spectrum-css-temp/components/toast/index.css @@ -23,6 +23,7 @@ governing permissions and limitations under the License. display: inline-flex; flex-direction: row; align-items: stretch; + max-width: 500px; /* devon made this up */ border-radius: var(--spectrum-toast-border-radius); @@ -32,7 +33,6 @@ governing permissions and limitations under the License. padding-inline-start: var(--spectrum-toast-padding-left); font-size: var(--spectrum-toast-text-size); - font-weight: var(--spectrum-toast-text-font-weight); -webkit-font-smoothing: antialiased; } @@ -48,10 +48,8 @@ governing permissions and limitations under the License. .spectrum-Toast-content { flex: 1 1 auto; - display: inline-block; box-sizing: border-box; padding-block-start: var(--spectrum-toast-content-padding-top); - padding-inline-end: var(--spectrum-toast-content-padding-right); padding-block-end: var(--spectrum-toast-content-padding-bottom); padding-inline-start: 0; text-align: start; @@ -75,9 +73,12 @@ governing permissions and limitations under the License. flex: 1 1 auto; align-self: center; - .spectrum-Button { - margin-inline-end: var(--spectrum-toast-button-margin-right); - } + /* https://spectrum.adobe.com/page/toast/#Text-overflow */ + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + column-gap: var(--spectrum-toast-content-padding-right); + padding-inline-end: var(--spectrum-toast-content-padding-right); & + .spectrum-Toast-buttons { padding-inline-start: var(--spectrum-toast-padding-right); diff --git a/packages/@adobe/spectrum-css-temp/components/toast/skin.css b/packages/@adobe/spectrum-css-temp/components/toast/skin.css index b2202cd00d6..fa4fe679c1d 100644 --- a/packages/@adobe/spectrum-css-temp/components/toast/skin.css +++ b/packages/@adobe/spectrum-css-temp/components/toast/skin.css @@ -11,8 +11,8 @@ governing permissions and limitations under the License. */ .spectrum-Toast { - background-color: var(--spectrum-toast-background-color); - color: var(--spectrum-toast-background-color); + background-color: var(--spectrum-alias-neutral-background-color); + color: white; } .spectrum-Toast-content { @@ -27,50 +27,16 @@ governing permissions and limitations under the License. border-inline-start-color: rgba(255, 255, 255, 0.2); } -.spectrum-Toast--warning { - background-color: var(--spectrum-toast-warning-background-color); - color: var(--spectrum-toast-warning-background-color); - - .spectrum-Toast-closeButton { - &:focus-ring:not(:active) { - color: var(--spectrum-toast-warning-background-color); - } - } -} - -.spectrum-Toast--negative, -/** @deprecated */.spectrum-Toast--error { - background-color: var(--spectrum-toast-error-background-color); - color: var(--spectrum-toast-error-background-color); - - .spectrum-Toast-closeButton { - &:focus-ring:not(:active) { - color: var(--spectrum-toast-error-background-color); - } - } +.spectrum-Toast--negative { + background-color: var(--spectrum-negative-background-color-default); } .spectrum-Toast--info { - background-color: var(--spectrum-toast-info-background-color); - color: var(--spectrum-toast-info-background-color); - - .spectrum-Toast-closeButton { - &:focus-ring:not(:active) { - color: var(--spectrum-toast-info-background-color); - } - } + background-color: var(--spectrum-informative-background-color-default); } -.spectrum-Toast--positive, -/** @deprecated */.spectrum-Toast--success { - background-color: var(--spectrum-toast-positive-background-color); - color: var(--spectrum-toast-positive-background-color); - - .spectrum-Toast-closeButton { - &:focus-ring:not(:active) { - color: var(--spectrum-toast-positive-background-color); - } - } +.spectrum-Toast--positive { + background-color: var(--spectrum-positive-background-color-default); } @media (forced-colors: active) { diff --git a/packages/@adobe/spectrum-css-temp/components/tooltip/skin.css b/packages/@adobe/spectrum-css-temp/components/tooltip/skin.css index 4eb8639371c..cfda8055ebd 100644 --- a/packages/@adobe/spectrum-css-temp/components/tooltip/skin.css +++ b/packages/@adobe/spectrum-css-temp/components/tooltip/skin.css @@ -11,6 +11,7 @@ governing permissions and limitations under the License. */ .spectrum-Tooltip { + --spectrum-tooltip-background-color: var(--spectrum-alias-neutral-background-color); --spectrum-tooltip-negative-background-color: var(--spectrum-negative-background-color-default); --spectrum-tooltip-positive-background-color: var(--spectrum-positive-background-color-default); --spectrum-tooltip-info-background-color: var(--spectrum-informative-background-color-default); diff --git a/packages/@adobe/spectrum-css-temp/vars/express.css b/packages/@adobe/spectrum-css-temp/vars/express.css index 0b457268694..ce5aef6cbb4 100644 --- a/packages/@adobe/spectrum-css-temp/vars/express.css +++ b/packages/@adobe/spectrum-css-temp/vars/express.css @@ -152,7 +152,7 @@ --spectrum-tooltip-dropshadow: drop-shadow(0 var(--spectrum-alias-dropshadow-offset-y) var(--spectrum-alias-dropshadow-blur) var(--spectrum-alias-dropshadow-color)); --spectrum-selectlist-option-icon-color-selected: var(--spectrum-global-color-indigo-500); - --spectrum-tooltip-background-color: var(--spectrum-neutral-background-color-default); + --spectrum-alias-neutral-background-color: var(--spectrum-neutral-background-color-default); } .express.medium { diff --git a/packages/@adobe/spectrum-css-temp/vars/spectrum-global.css b/packages/@adobe/spectrum-css-temp/vars/spectrum-global.css index 4500c6debfc..6f85db4a3f8 100644 --- a/packages/@adobe/spectrum-css-temp/vars/spectrum-global.css +++ b/packages/@adobe/spectrum-css-temp/vars/spectrum-global.css @@ -654,5 +654,5 @@ --spectrum-actiongroup-compact-button-gap: calc(-1 * var(--spectrum-actionbutton-border-size)); - --spectrum-tooltip-background-color: var(--spectrum-neutral-subdued-background-color-default); + --spectrum-alias-neutral-background-color: var(--spectrum-neutral-subdued-background-color-default); } diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index e3766f5a8aa..1aa4aead842 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -398,6 +398,11 @@ function isElementInScope(element: Element, scope: Element[]) { } function isElementInChildScope(element: Element, scope: ScopeRef = null) { + // If the element is within a top layer element (e.g. toasts), always allow moving focus there. + if (element instanceof Element && element.closest('[data-react-aria-top-layer]')) { + return true; + } + // node.contains in isElementInScope covers child scopes that are also DOM children, // but does not cover child scopes in portals. for (let {scopeRef: s} of focusScopeTree.traverse(focusScopeTree.getTreeNode(scope))) { diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 44d74b895af..8ef0989a1ce 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -110,12 +110,17 @@ function isValidEvent(event, ref) { return false; } - // if the event target is no longer in the document if (event.target) { + // if the event target is no longer in the document, ignore const ownerDocument = event.target.ownerDocument; if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) { return false; } + + // If the target is within a top layer element (e.g. toasts), ignore. + if (event.target.closest('[data-react-aria-top-layer]')) { + return false; + } } return ref.current && !ref.current.contains(event.target); diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index 3f72f023e33..4f835a6a1a4 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -329,7 +329,9 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject(targets); let hiddenNodes = new Set(); + + // Keep live announcer and top layer elements (e.g. toasts) visible. + for (let element of document.querySelectorAll('[data-live-announcer], [data-react-aria-top-layer]')) { + visibleNodes.add(element); + } + let walker = document.createTreeWalker( root, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { - // If this node is a live announcer, add it to the set of nodes to keep visible. - if (((node instanceof HTMLElement || node instanceof SVGElement) && node.dataset.liveAnnouncer === 'true')) { - visibleNodes.add(node); - } - // Skip this node and its children if it is one of the target nodes, or a live announcer. // Also skip children of already hidden nodes, as aria-hidden is recursive. An exception is // made for elements with role="row" since VoiceOver on iOS has issues hiding elements with role="row". @@ -48,8 +49,10 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } // Skip this node but continue to children if one of the targets is inside the node. - if (targets.some(target => node.contains(target))) { - return NodeFilter.FILTER_SKIP; + for (let target of visibleNodes) { + if (node.contains(target)) { + return NodeFilter.FILTER_SKIP; + } } return NodeFilter.FILTER_ACCEPT; @@ -96,7 +99,10 @@ export function ariaHideOutside(targets: Element[], root = document.body) { // and not already inside a hidden node, hide all of the new children. if (![...visibleNodes, ...hiddenNodes].some(node => node.contains(change.target))) { for (let node of change.addedNodes) { - if (((node instanceof HTMLElement || node instanceof SVGElement) && node.dataset.liveAnnouncer === 'true')) { + if ( + (node instanceof HTMLElement || node instanceof SVGElement) && + (node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer === 'true') + ) { visibleNodes.add(node); } else if (node instanceof Element) { hide(node); diff --git a/packages/@react-aria/toast/docs/toast-anatomy.svg b/packages/@react-aria/toast/docs/toast-anatomy.svg new file mode 100644 index 00000000000..7972153248f --- /dev/null +++ b/packages/@react-aria/toast/docs/toast-anatomy.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + Analysis complete + + + + + + + Title + + + + + Close button + + + + + Toast + + + + + Region + + + + diff --git a/packages/@react-aria/toast/docs/useToast.mdx b/packages/@react-aria/toast/docs/useToast.mdx new file mode 100644 index 00000000000..ad1c833ef3b --- /dev/null +++ b/packages/@react-aria/toast/docs/useToast.mdx @@ -0,0 +1,452 @@ +{/* Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +import {Layout} from '@react-spectrum/docs'; +export default Layout; + +import docs from 'docs:@react-aria/toast'; +import statelyDocs from 'docs:@react-stately/toast'; +import {HeaderInfo, FunctionAPI, TypeContext, InterfaceType, PageDescription, TypeLink} from '@react-spectrum/docs'; +import packageData from '@react-aria/toast/package.json'; +import Anatomy from './toast-anatomy.svg'; +import ChevronRight from '@spectrum-icons/workflow/ChevronRight'; +import {Keyboard} from '@react-spectrum/text'; + +```tsx import +import {useToastState} from '@react-stately/toast'; +import {useToastRegion} from '@react-aria/toast'; +``` + +--- +category: Status +keywords: [toast, notifications, alert, aria] +after_version: 3.0.0 +--- + +# useToast + +{docs.exports.useToast.description} + + + +## API + + + + +## Features + +There is no built in way to toast notifications in HTML. and help achieve accessible toasts that can be styled as needed. + +* **Accessible** – Toasts follow the [ARIA alert pattern](https://www.w3.org/WAI/ARIA/apg/patterns/alert/). They are rendered in a [landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/), which keyboard and screen reader users can easily jump to when an alert is announced. +* **Focus management** – When a toast unmounts, focus is moved to the next toast if any. Otherwise, focus is restored to where it was before navigating to the toast region. +* **Priority queue** – Toasts are displayed according to a priority queue, displaying a configurable number of toasts at a time. The queue can either be owned by a provider component, or global. +* **Animations** – Toasts support optional entry and exit animations. + +## Anatomy + + + +A toast region is an ARIA landmark region labeled "Notifications" by default. A toast region contains one or more visible toasts, in priority order. When the limit is reached, additional toasts are queued until the user dismisses one. Each toast is an ARIA alert element, containing the content of the notification and a close button. + +Landmark regions including the toast container can be navigated using the keyboard by pressing the F6 key to move forward, and the Shift + F6 key to move backward. This provides an easy way for keyboard users to jump to the toasts from anywhere in the app. When the last toast is closed, keyboard focus is restored. + +`useToastRegion` returns props that you should spread onto the toast container element: + + + + + +`useToast` returns props that you should spread onto an individual toast and its child elements: + + + + + +## Example + +Toasts consist of three components. The first is a `ToastProvider` component which will manage the state for the toast queue with the hook. Alternatively, you could use a global toast queue ([see below](#global-toast-queue)). + +```tsx +import {useToastState} from '@react-stately/toast'; + +function ToastProvider({children, ...props}) { + let state = useToastState({ + maxVisibleToasts: 5 + }); + + return ( + <> + {children(state)} + {state.visibleToasts.length > 0 && } + + ); +} +``` + +```tsx import +// Actual implementation we use in the docs, using global queue. +function ToastProvider({children}) { + return children(toastQueue); +} +``` + +The `ToastRegion` component will be rendered when there are toasts to display. It uses the hook to create a landmark region, allowing keyboard and screen reader users to easily navigate to it. + +```tsx example export=true render=false +import {useToastRegion} from '@react-aria/toast'; + +function ToastRegion({state, ...props}) { + let ref = React.useRef(); + let {regionProps} = useToastRegion(props, state, ref); + + return ( +
+ {state.visibleToasts.map(toast => ( + + ))} +
+ ); +} +``` + +Finally, we need the `Toast` component to render an individual toast within a `ToastRegion`, built with . + +```tsx example render=false export=true +import {useToast} from '@react-aria/toast'; + +// Reuse the Button from your component library. See below for details. +import {Button} from 'your-component-library'; + +function Toast({state, ...props}) { + let ref = React.useRef(); + let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref); + + return ( +
+
{props.toast.content}
+ +
+ ); +} +``` + +```tsx example + + {state => ( + + )} + +``` + +
+ Show CSS + +```css +.toast-region { + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + display: flex; + align-items: center; + gap: 16px; + background: slateblue; + color: white; + padding: 12px 16px; + border-radius: 8px; +} + +.toast button { + background: none; + border: none; + appearance: none; + border-radius: 50%; + height: 32px; + width: 32px; + font-size: 16px; + border: 1px solid white; + color: white; + padding: 0; +} + +.toast button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px slateblue, 0 0 0 4px white; +} + +.toast button:active { + background: rgba(255, 255, 255, 0.2); +} +``` + +
+ +### Button + +The `Button` component is used in the above example to close a toast. It is built using the [useButton](useButton.html) hook, and can be shared with many other components. + +
+ Show code + +```tsx example export=true render=false +import {useButton} from '@react-aria/button'; + +function Button(props) { + let ref = React.useRef(); + let {buttonProps} = useButton(props, ref); + return ; +} +``` + +
+ +## Usage + +The following examples show how to use the `ToastProvider` component created in the above example. + +### Toast priorities + +Toasts are displayed according to a priority queue. The priority of a toast can be set using the `priority` option, passed to the `state.add` function. Priorities are arbitrary numbers defined by your implementation. + +```tsx example + + {state => (<> + {/*- begin highlight -*/} + + {/*- begin highlight -*/} + + {/*- begin highlight -*/} + + )} + +``` + +### Auto-dismiss + +Toasts support a `timeout` option to automatically hide them after a certain amount of time. For accessibility, toasts should have a minimum timeout of 5 seconds to give users enough time to read them. If a toast includes action buttons or other interactive elements it should not auto dismiss. In addition, timers will automatically pause when the user focuses or hovers over a toast. + +Be sure only to automatically dismiss toasts when the information is not important, or may be found elsewhere. Some users may require additional time to read a toast message, and screen zoom users may miss toasts entirely. + +```tsx example + + {state => ( + ///- begin highlight -/// + + )} + +``` + +### Programmatic dismissal + +Toasts may be programmatically dismissed if they become irrelevant before the user manually closes them. `state.add` returns a key for the toast which may be passed to `state.close` to dismiss the toast. + +```tsx example +function Example() { + let [toastKey, setToastKey] = React.useState(null); + + return ( + + {state => ( + + )} + + ); +} +``` + +## Advanced topics + +### Global toast queue + +In the above examples, each `ToastProvider` has a separate queue. This setup is simple, and fine for most cases where you can wrap the entire app in a single `ToastProvider`. However, in more complex situations, you may want to keep the toast queue outside the React tree so that toasts can be queued from anywhere. This can be done by creating your own and subscribing to it using the hook rather than `useToastState`. + +```tsx example export=true hidden +import {ToastQueue, useToastQueue} from '@react-stately/toast'; + +// Create a global toast queue. +///- begin highlight -/// +const toastQueue = new ToastQueue({ + maxVisibleToasts: 5 +}); +///- end highlight -/// + +function GlobalToastRegion(props) { + // Subscribe to it. + ///- begin highlight -/// + let state = useToastQueue(toastQueue); + ///- end highlight -/// + + // Render toast region. + return state.visibleToasts.length > 0 + ? ReactDOM.createPortal(, document.body) + : null; +} + +// Render it somewhere in your app. + +``` + +Now you can queue a toast from anywhere: + +```tsx example + +``` + +### Animations + +`useToastState` and `ToastQueue` support a `hasExitAnimation` option. When enabled, toasts transition to an "exiting" state when closed rather than immediately being removed. This allows you to trigger an exit animation. When complete, call the `state.remove` function. + +Each includes an `animation` property that indicates the current animation state. There are three possible states: + +* `entering` – The toast is entering immediately after being triggered. +* `queued` – The toast is entering from the queue (out of view). +* `exiting` – The toast is exiting from view. + +```tsx +function ToastRegion() { + let state = useToastState({ + maxVisibleToasts: 5, + /*- begin highlight -*/ + hasExitAnimation: true + /*- end highlight -*/ + }); + + // ... +} + +function Toast({state, ...props}) { + let ref = React.useRef(); + let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref); + + return ( +
{ + // Remove the toast when the exiting animation completes. + if (props.toast.animation === 'exiting') { + state.remove(props.toast.key); + } + }} + /*- end highlight -*/ + > +
{props.toast.content}
+ +
+ ); +} +``` + +In CSS, the data attribute defined above can be used to trigger keyframe animations: + +```css +.toast[data-animation=entering] { + animation-name: slide-in; +} + +.toast[data-animation=queued] { + animation-name: fade-in; +} + +.toast[data-animation=exiting] { + animation-name: slide-out; +} +``` + +### TypeScript + +A `ToastQueue` and `useToastState` use a generic type to represent toast content. The examples so far have used strings, but you can type this however you want to enable passing custom objects or options. This example uses a custom object to support toasts with both a title and description. + +```tsx +import type {QueuedToast} from '@react-stately/toast'; + +/*- begin highlight -*/ +interface MyToast { + title: string, + description: string +} +/*- end highlight -*/ + +function ToastProvider() { + /*- begin highlight -*/ + let state = useToastState(); + /*- end highlight -*/ + + // ... +} + +interface ToastProps { + /*- begin highlight -*/ + toast: QueuedToast + /*- end highlight -*/ +} + +function Toast(props: ToastProps) { + // ... + + let {toastProps, titleProps, descriptionProps, closeButtonProps} = useToast(props, state, ref); + + return ( +
+
+ {/*- begin highlight -*/} +
{props.toast.content.title}
+
{props.toast.content.description}
+ {/*- end highlight -*/} +
+ +
+ ); +} + +// Queuing a toast +/*- begin highlight -*/ +state.add({title: 'Success!', description: 'Toast is done.'}); +/*- end highlight -*/ +``` diff --git a/packages/@react-aria/toast/intl/ar-AE.json b/packages/@react-aria/toast/intl/ar-AE.json index 0a7e372bfad..86447323701 100644 --- a/packages/@react-aria/toast/intl/ar-AE.json +++ b/packages/@react-aria/toast/intl/ar-AE.json @@ -1,6 +1,3 @@ { - "close": "إغلاق", - "info": "معلومات", - "negative": "خطأ", - "positive": "تم بنجاح" + "close": "إغلاق" } diff --git a/packages/@react-aria/toast/intl/bg-BG.json b/packages/@react-aria/toast/intl/bg-BG.json index 6043e01f4f8..106e86379ab 100644 --- a/packages/@react-aria/toast/intl/bg-BG.json +++ b/packages/@react-aria/toast/intl/bg-BG.json @@ -1,6 +1,3 @@ { - "close": "Затвори", - "info": "Инфо", - "negative": "Грешка", - "positive": "Успех" + "close": "Затвори" } diff --git a/packages/@react-aria/toast/intl/cs-CZ.json b/packages/@react-aria/toast/intl/cs-CZ.json index 0aa61866813..97b131a500e 100644 --- a/packages/@react-aria/toast/intl/cs-CZ.json +++ b/packages/@react-aria/toast/intl/cs-CZ.json @@ -1,6 +1,3 @@ { - "close": "Zavřít", - "info": "Informace", - "negative": "Chyba", - "positive": "Úspěch" + "close": "Zavřít" } diff --git a/packages/@react-aria/toast/intl/da-DK.json b/packages/@react-aria/toast/intl/da-DK.json index 57d084b0837..2fd65d6cd22 100644 --- a/packages/@react-aria/toast/intl/da-DK.json +++ b/packages/@react-aria/toast/intl/da-DK.json @@ -1,6 +1,3 @@ { - "close": "Luk", - "info": "Info", - "negative": "Fejl", - "positive": "Fuldført" + "close": "Luk" } diff --git a/packages/@react-aria/toast/intl/de-DE.json b/packages/@react-aria/toast/intl/de-DE.json index 76993b80de8..f04b9650f38 100644 --- a/packages/@react-aria/toast/intl/de-DE.json +++ b/packages/@react-aria/toast/intl/de-DE.json @@ -1,6 +1,3 @@ { - "close": "Schließen", - "info": "Informationen", - "negative": "Fehler", - "positive": "Erfolg" + "close": "Schließen" } diff --git a/packages/@react-aria/toast/intl/el-GR.json b/packages/@react-aria/toast/intl/el-GR.json index bae0b9b386b..a4330b8a476 100644 --- a/packages/@react-aria/toast/intl/el-GR.json +++ b/packages/@react-aria/toast/intl/el-GR.json @@ -1,6 +1,3 @@ { - "close": "Κλείσιμο", - "info": "Πληροφορίες", - "negative": "Σφάλμα", - "positive": "Επιτυχία" + "close": "Κλείσιμο" } diff --git a/packages/@react-aria/toast/intl/en-US.json b/packages/@react-aria/toast/intl/en-US.json index f5a0b62975c..887fa2b147d 100644 --- a/packages/@react-aria/toast/intl/en-US.json +++ b/packages/@react-aria/toast/intl/en-US.json @@ -1,6 +1,4 @@ { "close": "Close", - "info": "Info", - "negative": "Error", - "positive": "Success" + "notifications": "Notifications" } diff --git a/packages/@react-aria/toast/intl/es-ES.json b/packages/@react-aria/toast/intl/es-ES.json index ff4a3c9efd1..32a5e0fa0a5 100644 --- a/packages/@react-aria/toast/intl/es-ES.json +++ b/packages/@react-aria/toast/intl/es-ES.json @@ -1,6 +1,3 @@ { - "close": "Cerrar", - "info": "Información", - "negative": "Error", - "positive": "Éxito" + "close": "Cerrar" } diff --git a/packages/@react-aria/toast/intl/et-EE.json b/packages/@react-aria/toast/intl/et-EE.json index 4739df3bb24..654e30fb367 100644 --- a/packages/@react-aria/toast/intl/et-EE.json +++ b/packages/@react-aria/toast/intl/et-EE.json @@ -1,6 +1,3 @@ { - "close": "Sule", - "info": "Teave", - "negative": "Viga", - "positive": "Valmis" + "close": "Sule" } diff --git a/packages/@react-aria/toast/intl/fi-FI.json b/packages/@react-aria/toast/intl/fi-FI.json index 625048f7d46..9f769e1bbf4 100644 --- a/packages/@react-aria/toast/intl/fi-FI.json +++ b/packages/@react-aria/toast/intl/fi-FI.json @@ -1,6 +1,3 @@ { - "close": "Sulje", - "info": "Tiedot", - "negative": "Virhe", - "positive": "Onnistui" + "close": "Sulje" } diff --git a/packages/@react-aria/toast/intl/fr-FR.json b/packages/@react-aria/toast/intl/fr-FR.json index 8a01f790229..fae7179a10f 100644 --- a/packages/@react-aria/toast/intl/fr-FR.json +++ b/packages/@react-aria/toast/intl/fr-FR.json @@ -1,6 +1,3 @@ { - "close": "Fermer", - "info": "Infos", - "negative": "Erreur", - "positive": "Succès" + "close": "Fermer" } diff --git a/packages/@react-aria/toast/intl/he-IL.json b/packages/@react-aria/toast/intl/he-IL.json index d4429e6c5a5..5fa2edf9f8b 100644 --- a/packages/@react-aria/toast/intl/he-IL.json +++ b/packages/@react-aria/toast/intl/he-IL.json @@ -1,6 +1,4 @@ { "close": "סגור", - "info": "מידע", - "negative": "שגיאה", - "positive": "הצלחה" + "notifications": "התראות" } diff --git a/packages/@react-aria/toast/intl/hr-HR.json b/packages/@react-aria/toast/intl/hr-HR.json index fe2dfa9fabd..db94104b416 100644 --- a/packages/@react-aria/toast/intl/hr-HR.json +++ b/packages/@react-aria/toast/intl/hr-HR.json @@ -1,6 +1,3 @@ { - "close": "Zatvori", - "info": "Informacije", - "negative": "Pogreška", - "positive": "Uspješno" + "close": "Zatvori" } diff --git a/packages/@react-aria/toast/intl/hu-HU.json b/packages/@react-aria/toast/intl/hu-HU.json index d9f87785871..b4b179d4c64 100644 --- a/packages/@react-aria/toast/intl/hu-HU.json +++ b/packages/@react-aria/toast/intl/hu-HU.json @@ -1,6 +1,3 @@ { - "close": "Bezárás", - "info": "Információ", - "negative": "Hiba", - "positive": "Siker" + "close": "Bezárás" } diff --git a/packages/@react-aria/toast/intl/it-IT.json b/packages/@react-aria/toast/intl/it-IT.json index 1fc28239f9a..40cf2a93cfa 100644 --- a/packages/@react-aria/toast/intl/it-IT.json +++ b/packages/@react-aria/toast/intl/it-IT.json @@ -1,6 +1,3 @@ { - "close": "Chiudi", - "info": "Informazioni", - "negative": "Errore", - "positive": "Operazione riuscita" + "close": "Chiudi" } diff --git a/packages/@react-aria/toast/intl/ja-JP.json b/packages/@react-aria/toast/intl/ja-JP.json index 74648ed0568..93c4744dd42 100644 --- a/packages/@react-aria/toast/intl/ja-JP.json +++ b/packages/@react-aria/toast/intl/ja-JP.json @@ -1,6 +1,3 @@ { - "close": "閉じる", - "info": "情報", - "negative": "エラー", - "positive": "成功" + "close": "閉じる" } diff --git a/packages/@react-aria/toast/intl/ko-KR.json b/packages/@react-aria/toast/intl/ko-KR.json index 9b115d82f4e..ee041777348 100644 --- a/packages/@react-aria/toast/intl/ko-KR.json +++ b/packages/@react-aria/toast/intl/ko-KR.json @@ -1,6 +1,3 @@ { - "close": "닫기", - "info": "정보", - "negative": "오류", - "positive": "성공" + "close": "닫기" } diff --git a/packages/@react-aria/toast/intl/lt-LT.json b/packages/@react-aria/toast/intl/lt-LT.json index 56785f0cb84..0b9bcbbfb26 100644 --- a/packages/@react-aria/toast/intl/lt-LT.json +++ b/packages/@react-aria/toast/intl/lt-LT.json @@ -1,6 +1,3 @@ { - "close": "Uždaryti", - "info": "Informacija", - "negative": "Klaida", - "positive": "Sėkmingai" + "close": "Uždaryti" } diff --git a/packages/@react-aria/toast/intl/lv-LV.json b/packages/@react-aria/toast/intl/lv-LV.json index 086f7aecee8..844b8c630e8 100644 --- a/packages/@react-aria/toast/intl/lv-LV.json +++ b/packages/@react-aria/toast/intl/lv-LV.json @@ -1,6 +1,3 @@ { - "close": "Aizvērt", - "info": "Informācija", - "negative": "Kļūda", - "positive": "Izdevās" + "close": "Aizvērt" } diff --git a/packages/@react-aria/toast/intl/nb-NO.json b/packages/@react-aria/toast/intl/nb-NO.json index b7060ff7cc1..ae990c1be53 100644 --- a/packages/@react-aria/toast/intl/nb-NO.json +++ b/packages/@react-aria/toast/intl/nb-NO.json @@ -1,6 +1,3 @@ { - "close": "Lukk", - "info": "Info", - "negative": "Feil", - "positive": "Vellykket" + "close": "Lukk" } diff --git a/packages/@react-aria/toast/intl/nl-NL.json b/packages/@react-aria/toast/intl/nl-NL.json index c369d941341..97cb041c3a4 100644 --- a/packages/@react-aria/toast/intl/nl-NL.json +++ b/packages/@react-aria/toast/intl/nl-NL.json @@ -1,6 +1,3 @@ { - "close": "Sluiten", - "info": "Info", - "negative": "Fout", - "positive": "Geslaagd" + "close": "Sluiten" } diff --git a/packages/@react-aria/toast/intl/pl-PL.json b/packages/@react-aria/toast/intl/pl-PL.json index 9e67c204564..6122f93e8f1 100644 --- a/packages/@react-aria/toast/intl/pl-PL.json +++ b/packages/@react-aria/toast/intl/pl-PL.json @@ -1,6 +1,3 @@ { - "close": "Zamknij", - "info": "Informacje", - "negative": "Błąd", - "positive": "Powodzenie" + "close": "Zamknij" } diff --git a/packages/@react-aria/toast/intl/pt-BR.json b/packages/@react-aria/toast/intl/pt-BR.json index 2cb841712df..7243d9f953f 100644 --- a/packages/@react-aria/toast/intl/pt-BR.json +++ b/packages/@react-aria/toast/intl/pt-BR.json @@ -1,6 +1,3 @@ { - "close": "Fechar", - "info": "Informações", - "negative": "Erro", - "positive": "Sucesso" + "close": "Fechar" } diff --git a/packages/@react-aria/toast/intl/pt-PT.json b/packages/@react-aria/toast/intl/pt-PT.json index 94c0831ebe7..7243d9f953f 100644 --- a/packages/@react-aria/toast/intl/pt-PT.json +++ b/packages/@react-aria/toast/intl/pt-PT.json @@ -1,6 +1,3 @@ { - "close": "Fechar", - "info": "Informação", - "negative": "Erro", - "positive": "Sucesso" + "close": "Fechar" } diff --git a/packages/@react-aria/toast/intl/ro-RO.json b/packages/@react-aria/toast/intl/ro-RO.json index e5f8528d41b..2dd16214255 100644 --- a/packages/@react-aria/toast/intl/ro-RO.json +++ b/packages/@react-aria/toast/intl/ro-RO.json @@ -1,6 +1,3 @@ { - "close": "Închideţi", - "info": "Informaţii", - "negative": "Eroare", - "positive": "Succes" + "close": "Închideţi" } diff --git a/packages/@react-aria/toast/intl/ru-RU.json b/packages/@react-aria/toast/intl/ru-RU.json index 0a54676e7e1..eeeebe6d6b5 100644 --- a/packages/@react-aria/toast/intl/ru-RU.json +++ b/packages/@react-aria/toast/intl/ru-RU.json @@ -1,6 +1,3 @@ { - "close": "Закрыть", - "info": "Информация", - "negative": "Ошибка", - "positive": "Успешно" + "close": "Закрыть" } diff --git a/packages/@react-aria/toast/intl/sk-SK.json b/packages/@react-aria/toast/intl/sk-SK.json index 787ec6bb8b9..388831f6738 100644 --- a/packages/@react-aria/toast/intl/sk-SK.json +++ b/packages/@react-aria/toast/intl/sk-SK.json @@ -1,6 +1,3 @@ { - "close": "Zatvoriť", - "info": "Informácie", - "negative": "Chyba", - "positive": "Úspech" + "close": "Zatvoriť" } diff --git a/packages/@react-aria/toast/intl/sl-SI.json b/packages/@react-aria/toast/intl/sl-SI.json index 626b7cc13dc..50bc09c72d3 100644 --- a/packages/@react-aria/toast/intl/sl-SI.json +++ b/packages/@react-aria/toast/intl/sl-SI.json @@ -1,6 +1,3 @@ { - "close": "Zapri", - "info": "Informacije", - "negative": "Napaka", - "positive": "Uspešno" + "close": "Zapri" } diff --git a/packages/@react-aria/toast/intl/sr-SP.json b/packages/@react-aria/toast/intl/sr-SP.json index f2c6fe2ff8b..db94104b416 100644 --- a/packages/@react-aria/toast/intl/sr-SP.json +++ b/packages/@react-aria/toast/intl/sr-SP.json @@ -1,6 +1,3 @@ { - "close": "Zatvori", - "info": "Informacije", - "negative": "Greška", - "positive": "Uspešno" + "close": "Zatvori" } diff --git a/packages/@react-aria/toast/intl/sv-SE.json b/packages/@react-aria/toast/intl/sv-SE.json index 188dc43652f..9ff8f09568e 100644 --- a/packages/@react-aria/toast/intl/sv-SE.json +++ b/packages/@react-aria/toast/intl/sv-SE.json @@ -1,6 +1,3 @@ { - "close": "Stäng", - "info": "Info", - "negative": "Fel", - "positive": "Lyckades" + "close": "Stäng" } diff --git a/packages/@react-aria/toast/intl/tr-TR.json b/packages/@react-aria/toast/intl/tr-TR.json index 548f5e028bb..9ed73bb69ca 100644 --- a/packages/@react-aria/toast/intl/tr-TR.json +++ b/packages/@react-aria/toast/intl/tr-TR.json @@ -1,6 +1,3 @@ { - "close": "Kapat", - "info": "Bilgiler", - "negative": "Hata", - "positive": "Başarılı" + "close": "Kapat" } diff --git a/packages/@react-aria/toast/intl/uk-UA.json b/packages/@react-aria/toast/intl/uk-UA.json index 9f56fb8f4b5..b8f3443d079 100644 --- a/packages/@react-aria/toast/intl/uk-UA.json +++ b/packages/@react-aria/toast/intl/uk-UA.json @@ -1,6 +1,3 @@ { - "close": "Закрити", - "info": "Інформація", - "negative": "Помилка", - "positive": "Успішно" + "close": "Закрити" } diff --git a/packages/@react-aria/toast/intl/zh-CN.json b/packages/@react-aria/toast/intl/zh-CN.json index 386aa25782e..74bb1264ef9 100644 --- a/packages/@react-aria/toast/intl/zh-CN.json +++ b/packages/@react-aria/toast/intl/zh-CN.json @@ -1,6 +1,3 @@ { - "close": "关闭", - "info": "信息", - "negative": "错误", - "positive": "成功" + "close": "关闭" } diff --git a/packages/@react-aria/toast/intl/zh-TW.json b/packages/@react-aria/toast/intl/zh-TW.json index f004780b7d3..388446c4e18 100644 --- a/packages/@react-aria/toast/intl/zh-TW.json +++ b/packages/@react-aria/toast/intl/zh-TW.json @@ -1,6 +1,3 @@ { - "close": "關閉", - "info": "資訊", - "negative": "錯誤", - "positive": "成功" + "close": "關閉" } diff --git a/packages/@react-aria/toast/package.json b/packages/@react-aria/toast/package.json index 1e0fb4ce91a..172e9900a1f 100644 --- a/packages/@react-aria/toast/package.json +++ b/packages/@react-aria/toast/package.json @@ -20,10 +20,11 @@ "dependencies": { "@react-aria/i18n": "^3.1.0", "@react-aria/interactions": "^3.1.0", + "@react-aria/landmark": "3.0.0-alpha.5", "@react-aria/utils": "^3.1.0", "@react-stately/toast": "3.0.0-alpha.1", + "@react-types/button": "^3.7.0", "@react-types/shared": "^3.1.0", - "@react-types/toast": "3.0.0-alpha.1", "@swc/helpers": "^0.4.14" }, "peerDependencies": { diff --git a/packages/@react-aria/toast/src/index.ts b/packages/@react-aria/toast/src/index.ts index 44d86aedae1..e12f6adf336 100644 --- a/packages/@react-aria/toast/src/index.ts +++ b/packages/@react-aria/toast/src/index.ts @@ -10,3 +10,7 @@ * governing permissions and limitations under the License. */ export {useToast} from './useToast'; +export {useToastRegion} from './useToastRegion'; + +export type {AriaToastRegionProps, ToastRegionAria} from './useToastRegion'; +export type {AriaToastProps, ToastAria} from './useToast'; diff --git a/packages/@react-aria/toast/src/useToast.ts b/packages/@react-aria/toast/src/useToast.ts index fa516ccdd1f..609c4ad9cd5 100644 --- a/packages/@react-aria/toast/src/useToast.ts +++ b/packages/@react-aria/toast/src/useToast.ts @@ -10,85 +10,102 @@ * governing permissions and limitations under the License. */ -import {chain, filterDOMProps, mergeProps} from '@react-aria/utils'; -import {DOMAttributes, DOMProps} from '@react-types/shared'; -import {ImgHTMLAttributes} from 'react'; +import {AriaButtonProps} from '@react-types/button'; +import {AriaLabelingProps, DOMAttributes, FocusableElement} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {PressProps} from '@react-aria/interactions'; -import {ToastProps, ToastState} from '@react-types/toast'; -import {useFocus, useHover} from '@react-aria/interactions'; +import {QueuedToast, ToastState} from '@react-stately/toast'; +import {RefObject, useEffect, useRef} from 'react'; +import {useId, useLayoutEffect, useSlotId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; -interface ToastAriaProps extends ToastProps {} +export interface AriaToastProps extends AriaLabelingProps { + /** The toast object. */ + toast: QueuedToast +} -interface ToastAria { +export interface ToastAria { + /** Props for the toast container element. */ toastProps: DOMAttributes, - iconProps: ImgHTMLAttributes, - actionButtonProps: PressProps, - closeButtonProps: DOMProps & PressProps + /** Props for the toast title element. */ + titleProps: DOMAttributes, + /** Props for the toast description element, if any. */ + descriptionProps: DOMAttributes, + /** Props for the toast close button. */ + closeButtonProps: AriaButtonProps } -export function useToast(props: ToastAriaProps, state: ToastState): ToastAria { +/** + * Provides the behavior and accessibility implementation for a toast component. + * Toasts display brief, temporary notifications of actions, errors, or other events in an application. + */ +export function useToast(props: AriaToastProps, state: ToastState, ref: RefObject): ToastAria { let { - toastKey, - onAction, - onClose, - shouldCloseOnAction, + key, timer, - variant - } = props; - let { - onRemove - } = state; - let stringFormatter = useLocalizedStringFormatter(intlMessages); - let domProps = filterDOMProps(props); + timeout, + animation + } = props.toast; - const handleAction = (...args) => { - if (onAction) { - onAction(...args); + useEffect(() => { + if (!timer) { + return; } - if (shouldCloseOnAction) { - onClose && onClose(...args); - onRemove && onRemove(toastKey); - } - }; + timer.reset(timeout); + return () => { + timer.pause(); + }; + }, [timer, timeout]); - let iconProps = variant ? {'aria-label': stringFormatter.format(variant)} : {}; + // Restore focus to the toast container on unmount. + // If there are no more toasts, the container will be unmounted + // and will restore focus to wherever focus was before the user + // focused the toast region. + let focusOnUnmount = useRef(null); + useLayoutEffect(() => { + let container = ref.current.closest('[role=region]') as HTMLElement; + return () => { + if (container && container.contains(document.activeElement)) { + // Focus must be delayed for focus ring to appear, but we can't wait + // until useEffect cleanup to check if focus was inside the container. + focusOnUnmount.current = container; + } + }; + }, [ref]); - let pauseTimer = () => { - timer && timer.pause(); - }; + // eslint-disable-next-line + useEffect(() => { + return () => { + if (focusOnUnmount.current) { + focusOnUnmount.current.focus(); + } + }; + }, [ref]); - let resumeTimer = () => { - timer && timer.resume(); - }; - - let {hoverProps} = useHover({ - onHoverStart: pauseTimer, - onHoverEnd: resumeTimer - }); - - let {focusProps} = useFocus({ - onFocus: pauseTimer, - onBlur: resumeTimer - }); + let titleId = useId(); + let descriptionId = useSlotId(); + let stringFormatter = useLocalizedStringFormatter(intlMessages); return { - toastProps: mergeProps(domProps, { - ...hoverProps, - role: 'alert' - }), - iconProps, - actionButtonProps: { - ...focusProps, - onPress: handleAction + toastProps: { + role: 'alert', + 'aria-label': props['aria-label'], + 'aria-labelledby': props['aria-labelledby'] || titleId, + 'aria-describedby': props['aria-describedby'] || descriptionId, + 'aria-details': props['aria-details'], + // Hide toasts that are animating out so VoiceOver doesn't announce them. + 'aria-hidden': animation === 'exiting' ? 'true' : undefined + }, + titleProps: { + id: titleId + }, + descriptionProps: { + id: descriptionId }, closeButtonProps: { 'aria-label': stringFormatter.format('close'), - ...focusProps, - onPress: chain(onClose, () => onRemove(toastKey)) + onPress: () => state.close(key) } }; } diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts new file mode 100644 index 00000000000..7ce9b4d7a0f --- /dev/null +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -0,0 +1,79 @@ +import {AriaLabelingProps, DOMAttributes} from '@react-types/shared'; +import {focusWithoutScrolling, mergeProps} from '@react-aria/utils'; +import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {RefObject, useEffect, useRef} from 'react'; +import {ToastState} from '@react-stately/toast'; +import {useLandmark} from '@react-aria/landmark'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; + +export interface AriaToastRegionProps extends AriaLabelingProps { + /** + * An accessibility label for the toast region. + * @default "Notifications" + */ + 'aria-label'?: string +} + +export interface ToastRegionAria { + /** Props for the landmark region element. */ + regionProps: DOMAttributes +} + +/** + * Provides the behavior and accessibility implementation for a toast region containing one or more toasts. + * Toasts display brief, temporary notifications of actions, errors, or other events in an application. + */ +export function useToastRegion(props: AriaToastRegionProps, state: ToastState, ref: RefObject): ToastRegionAria { + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let {landmarkProps} = useLandmark({ + role: 'region', + 'aria-label': props['aria-label'] || stringFormatter.format('notifications') + }, ref); + + let {hoverProps} = useHover({ + onHoverStart: state.pauseAll, + onHoverEnd: state.resumeAll + }); + + let lastFocused = useRef(null); + let {focusWithinProps} = useFocusWithin({ + onFocusWithin: (e) => { + state.pauseAll(); + lastFocused.current = e.relatedTarget; + }, + onBlurWithin: () => { + state.resumeAll(); + lastFocused.current = null; + } + }); + + // When the region unmounts, restore focus to the last element that had focus + // before the user moved focus into the region. + // TODO: handle when the element has unmounted like FocusScope does? + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => { + if (lastFocused.current && document.body.contains(lastFocused.current)) { + if (getInteractionModality() === 'pointer') { + focusWithoutScrolling(lastFocused.current); + } else { + lastFocused.current.focus(); + } + } + }; + }, [ref]); + + return { + regionProps: mergeProps(landmarkProps, hoverProps, focusWithinProps, { + tabIndex: -1, + // Mark the toast region as a "top layer", so that it: + // - is not aria-hidden when opening an overlay + // - allows focus even outside a containing focus scope + // - doesn’t dismiss overlays when clicking on it, even though it is outside + // @ts-ignore + 'data-react-aria-top-layer': true + }) + }; +} diff --git a/packages/@react-aria/toast/stories/Example.tsx b/packages/@react-aria/toast/stories/Example.tsx new file mode 100644 index 00000000000..dbbc01deae1 --- /dev/null +++ b/packages/@react-aria/toast/stories/Example.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React, {createContext, useContext, useRef} from 'react'; +import {ToastState, useToastState} from '@react-stately/toast'; +import {useButton} from 'react-aria'; +import {useToast, useToastRegion} from '../src'; + +const ToastContext = createContext>(null); + +export function ToastContainer({children, ...otherProps}) { + let state = useToastState(otherProps); + return ( + + {children(state)} + + + ); +} + +function ToastRegion() { + let state = useContext(ToastContext); + let ref = useRef(); + let {regionProps} = useToastRegion({}, state, ref); + return ( +
+ {state.visibleToasts.map(toast => ( + + ))} +
+ ); +} + +function Toast(props) { + let state = useContext(ToastContext); + let ref = useRef(null); + let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref); + let buttonRef = useRef(); + let {buttonProps} = useButton(closeButtonProps, buttonRef); + + return ( +
+
{props.toast.content}
+ +
+ ); +} diff --git a/packages/@react-aria/toast/stories/useToast.stories.tsx b/packages/@react-aria/toast/stories/useToast.stories.tsx new file mode 100644 index 00000000000..985a8b305b8 --- /dev/null +++ b/packages/@react-aria/toast/stories/useToast.stories.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React from 'react'; +import {ToastContainer} from './Example'; + +export default { + title: 'useToast', + args: { + maxVisibleToasts: 1 + } +}; + +let count = 0; + +export const Default = args => ( + + {state => (<> + + + + )} + +); diff --git a/packages/@react-aria/toast/test/useToast.test.js b/packages/@react-aria/toast/test/useToast.test.js index 3825b272bb4..81ade7cdba9 100644 --- a/packages/@react-aria/toast/test/useToast.test.js +++ b/packages/@react-aria/toast/test/useToast.test.js @@ -10,75 +10,36 @@ * governing permissions and limitations under the License. */ -// @ts-ignore -import intlMessages from '../intl/*.json'; -import {Provider} from '@react-spectrum/provider'; -import React from 'react'; import {renderHook} from '@react-spectrum/test-utils'; -import {theme} from '@react-spectrum/theme-default'; +import {useRef} from 'react'; import {useToast} from '../'; describe('useToast', () => { - let onClose = jest.fn(); - let onAction = jest.fn(); - let onRemove = jest.fn(); + let close = jest.fn(); afterEach(() => { - onClose.mockClear(); - onAction.mockClear(); + close.mockClear(); }); - let renderToastHook = (props, state, wrapper) => { - let {result} = renderHook(() => useToast(props, state), {wrapper}); + let renderToastHook = (toast, state, wrapper) => { + let {result} = renderHook(() => useToast({toast}, state, useRef(document.createElement('div'))), {wrapper}); return result.current; }; it('handles defaults', function () { - let {actionButtonProps, closeButtonProps, iconProps, toastProps} = renderToastHook({}, {onRemove}); + let {closeButtonProps, toastProps, titleProps} = renderToastHook({}, {close}); expect(toastProps.role).toBe('alert'); - expect(iconProps['aria-label']).toBe(undefined); - expect(typeof actionButtonProps.onPress).toBe('function'); expect(closeButtonProps['aria-label']).toBe('Close'); expect(typeof closeButtonProps.onPress).toBe('function'); + expect(titleProps.id).toEqual(toastProps['aria-labelledby']); }); - it('variant sets icon aria-label property', function () { - let {iconProps} = renderToastHook({variant: 'info'}, {onRemove}); - - expect(iconProps['aria-label']).toBe('Info'); - }); - - it('with a localized aria-label', () => { - let locale = 'de-DE'; - let wrapper = ({children}) => {children}; - let expectedIntl = intlMessages[locale]['info']; - let {iconProps} = renderToastHook({variant: 'info'}, {onRemove}, wrapper); - expect(iconProps['aria-label']).toBe(expectedIntl); - }); - - it('handles onClose', function () { - let {closeButtonProps} = renderToastHook({onClose}, {onRemove}); - closeButtonProps.onPress(); - - expect(onClose).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(1); - }); - - it('handles shouldCloseOnAction', function () { - let {actionButtonProps} = renderToastHook({onClose, onAction, shouldCloseOnAction: true}, {onRemove}); - actionButtonProps.onPress(); - - expect(onClose).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(1); - }); - - it('onRemove is called with toastKey', function () { - let {closeButtonProps} = renderToastHook({toastKey: 'key1'}, {onRemove}); + it('handles close button', function () { + let {closeButtonProps} = renderToastHook({key: 1}, {close}); closeButtonProps.onPress(); - expect(onRemove).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenLastCalledWith('key1'); + expect(close).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledWith(1); }); }); diff --git a/packages/@react-spectrum/toast/chromatic/Toast.chromatic.tsx b/packages/@react-spectrum/toast/chromatic/Toast.chromatic.tsx new file mode 100644 index 00000000000..7050499b0e5 --- /dev/null +++ b/packages/@react-spectrum/toast/chromatic/Toast.chromatic.tsx @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React from 'react'; +import {Toast} from '../src/Toast'; +import {useToastState} from '@react-stately/toast'; + +function FakeToast(props) { + let state = useToastState(); + return ; +} + +export default { + title: 'Toast', + component: FakeToast +}; + +export const Default = { + args: {children: 'Neutral toast'} +}; + +export const Info = { + args: {variant: 'info', children: 'Neutral toast'} +}; + +export const Positive = { + args: {variant: 'positive', children: 'Neutral toast'} +}; + +export const Negative = { + args: {variant: 'negative', children: 'Neutral toast'} +}; + +export const Action = { + args: {variant: 'positive', children: 'Neutral toast', actionLabel: 'Undo'} +}; + +export const LongContent = { + args: {variant: 'positive', children: 'Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra.'} +}; + +export const LongContentAction = { + args: {variant: 'positive', children: 'Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra.', actionLabel: 'Undo'} +}; diff --git a/packages/@react-spectrum/toast/docs/Toast.mdx b/packages/@react-spectrum/toast/docs/Toast.mdx new file mode 100644 index 00000000000..b862c02d915 --- /dev/null +++ b/packages/@react-spectrum/toast/docs/Toast.mdx @@ -0,0 +1,191 @@ +{/* Copyright 2020 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +import {Layout} from '@react-spectrum/docs'; +export default Layout; + +import docs from 'docs:@react-spectrum/toast'; +import {HeaderInfo, PropTable, PageDescription, ClassAPI, TypeContext, InterfaceType, TypeLink} from '@react-spectrum/docs'; +import packageData from '@react-spectrum/toast/package.json'; +import {Keyboard} from '@react-spectrum/text'; + +```tsx import +import {ButtonGroup, Button} from '@adobe/react-spectrum'; +import {ToastContainer, ToastQueue} from '@react-spectrum/toast'; +``` + +--- +category: Status +keywords: [toast, notifications, alert] +after_version: 3.0.0 +--- + +# Toast + +Toasts display brief, temporary notifications of actions, errors, or other events in an application. + + + +## Example + +First, render a toast container at the root of your app: + +```tsx example hidden + +``` + +Then, queue a toast from anywhere: + +```tsx example + +``` + +## Content + +Toasts are triggered using one of the methods of . A <> element must be rendered at the root of your app in order to display the queued toasts. + +Toasts are shown according to a priority queue, depending on their variant. Actionable toasts are prioritized over non-actionable toasts, and errors are prioritized over other types of notifications. Only one toast is displayed at a time. See the [Spectrum design docs](https://spectrum.adobe.com/page/toast/#Priority-queue) for full information on toast priorities. + +```tsx example + + + + + + +``` + +### Internationalization + +To internationalize a Toast, all text content within the toast should be localized. This includes the `actionLabel` option, if provided. For languages that are read right-to-left (e.g. Hebrew and Arabic), the layout of Toast is automatically flipped. + +### Accessibility + +Toasts are automatically displayed in a [landmark region](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/) labeled "Notifications". The label can be overridden using the `aria-label` prop of the `ToastContainer` element. Landmark regions can be navigated using the keyboard by pressing the F6 key to move forward, and the Shift + F6 key to move backward. This provides an easy way for keyboard users to jump to the toasts from anywhere in the app. When the last toast is closed, keyboard focus is restored. + +## Events + +Toasts can include an optional action by specifying the `actionLabel` and `onAction` options when queueing a toast. In addition, the `onClose` event is triggered when the toast is dismissed. The `shouldCloseOnAction` option automatically closes the toast when an action is performed. + +```tsx example + +``` + +## Auto-dismiss + +Toasts support a `timeout` option to automatically hide them after a certain amount of time. For accessibility, toasts have a minimum timeout of 5 seconds, and actionable toasts will not auto dismiss. In addition, timers will pause when the user focuses or hovers over a toast. + +Be sure only to automatically dismiss toasts when the information is not important, or may be found elsewhere. Some users may require additional time to read a toast message, and screen zoom users may miss toasts entirely. + +```tsx example + +``` + +## Programmatic dismissal + +Toasts may be programmatically dismissed if they become irrelevant before the user manually closes them. Each method of `ToastQueue` returns a function which may be used to close a toast. + +```tsx example +function Example() { + let [close, setClose] = React.useState(null); + + return ( + + ); +} +``` + +## API + +### ToastQueue + +
+ + + +
+ +### Toast options + +
+ + + +
+ +### ToastContainer props + + + + diff --git a/packages/@react-spectrum/toast/intl/ar-AE.json b/packages/@react-spectrum/toast/intl/ar-AE.json new file mode 100644 index 00000000000..f0f3a5adb6c --- /dev/null +++ b/packages/@react-spectrum/toast/intl/ar-AE.json @@ -0,0 +1,5 @@ +{ + "info": "معلومات", + "negative": "خطأ", + "positive": "تم بنجاح" +} diff --git a/packages/@react-spectrum/toast/intl/bg-BG.json b/packages/@react-spectrum/toast/intl/bg-BG.json new file mode 100644 index 00000000000..ef82635f06d --- /dev/null +++ b/packages/@react-spectrum/toast/intl/bg-BG.json @@ -0,0 +1,5 @@ +{ + "info": "Инфо", + "negative": "Грешка", + "positive": "Успех" +} diff --git a/packages/@react-spectrum/toast/intl/cs-CZ.json b/packages/@react-spectrum/toast/intl/cs-CZ.json new file mode 100644 index 00000000000..d300691da02 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/cs-CZ.json @@ -0,0 +1,5 @@ +{ + "info": "Informace", + "negative": "Chyba", + "positive": "Úspěch" +} diff --git a/packages/@react-spectrum/toast/intl/da-DK.json b/packages/@react-spectrum/toast/intl/da-DK.json new file mode 100644 index 00000000000..e632f4a8df6 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/da-DK.json @@ -0,0 +1,5 @@ +{ + "info": "Info", + "negative": "Fejl", + "positive": "Fuldført" +} diff --git a/packages/@react-spectrum/toast/intl/de-DE.json b/packages/@react-spectrum/toast/intl/de-DE.json new file mode 100644 index 00000000000..0a3fe816721 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/de-DE.json @@ -0,0 +1,5 @@ +{ + "info": "Informationen", + "negative": "Fehler", + "positive": "Erfolg" +} diff --git a/packages/@react-spectrum/toast/intl/el-GR.json b/packages/@react-spectrum/toast/intl/el-GR.json new file mode 100644 index 00000000000..bf8e0602dfc --- /dev/null +++ b/packages/@react-spectrum/toast/intl/el-GR.json @@ -0,0 +1,5 @@ +{ + "info": "Πληροφορίες", + "negative": "Σφάλμα", + "positive": "Επιτυχία" +} diff --git a/packages/@react-spectrum/toast/intl/en-US.json b/packages/@react-spectrum/toast/intl/en-US.json new file mode 100644 index 00000000000..021f1a3af03 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/en-US.json @@ -0,0 +1,5 @@ +{ + "info": "Info", + "negative": "Error", + "positive": "Success" +} diff --git a/packages/@react-spectrum/toast/intl/es-ES.json b/packages/@react-spectrum/toast/intl/es-ES.json new file mode 100644 index 00000000000..49ff647c83d --- /dev/null +++ b/packages/@react-spectrum/toast/intl/es-ES.json @@ -0,0 +1,5 @@ +{ + "info": "Información", + "negative": "Error", + "positive": "Éxito" +} diff --git a/packages/@react-spectrum/toast/intl/et-EE.json b/packages/@react-spectrum/toast/intl/et-EE.json new file mode 100644 index 00000000000..b4bfeb6e95a --- /dev/null +++ b/packages/@react-spectrum/toast/intl/et-EE.json @@ -0,0 +1,5 @@ +{ + "info": "Teave", + "negative": "Viga", + "positive": "Valmis" +} diff --git a/packages/@react-spectrum/toast/intl/fi-FI.json b/packages/@react-spectrum/toast/intl/fi-FI.json new file mode 100644 index 00000000000..79e3fde3788 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/fi-FI.json @@ -0,0 +1,5 @@ +{ + "info": "Tiedot", + "negative": "Virhe", + "positive": "Onnistui" +} diff --git a/packages/@react-spectrum/toast/intl/fr-FR.json b/packages/@react-spectrum/toast/intl/fr-FR.json new file mode 100644 index 00000000000..67671b6fe12 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/fr-FR.json @@ -0,0 +1,5 @@ +{ + "info": "Infos", + "negative": "Erreur", + "positive": "Succès" +} diff --git a/packages/@react-spectrum/toast/intl/he-IL.json b/packages/@react-spectrum/toast/intl/he-IL.json new file mode 100644 index 00000000000..fcee9c1f9eb --- /dev/null +++ b/packages/@react-spectrum/toast/intl/he-IL.json @@ -0,0 +1,5 @@ +{ + "info": "מידע", + "negative": "שגיאה", + "positive": "הצלחה" +} diff --git a/packages/@react-spectrum/toast/intl/hr-HR.json b/packages/@react-spectrum/toast/intl/hr-HR.json new file mode 100644 index 00000000000..df6b1301daf --- /dev/null +++ b/packages/@react-spectrum/toast/intl/hr-HR.json @@ -0,0 +1,5 @@ +{ + "info": "Informacije", + "negative": "Pogreška", + "positive": "Uspješno" +} diff --git a/packages/@react-spectrum/toast/intl/hu-HU.json b/packages/@react-spectrum/toast/intl/hu-HU.json new file mode 100644 index 00000000000..fd388410484 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/hu-HU.json @@ -0,0 +1,5 @@ +{ + "info": "Információ", + "negative": "Hiba", + "positive": "Siker" +} diff --git a/packages/@react-spectrum/toast/intl/it-IT.json b/packages/@react-spectrum/toast/intl/it-IT.json new file mode 100644 index 00000000000..1e2090a324c --- /dev/null +++ b/packages/@react-spectrum/toast/intl/it-IT.json @@ -0,0 +1,5 @@ +{ + "info": "Informazioni", + "negative": "Errore", + "positive": "Operazione riuscita" +} diff --git a/packages/@react-spectrum/toast/intl/ja-JP.json b/packages/@react-spectrum/toast/intl/ja-JP.json new file mode 100644 index 00000000000..e582b704f5a --- /dev/null +++ b/packages/@react-spectrum/toast/intl/ja-JP.json @@ -0,0 +1,5 @@ +{ + "info": "情報", + "negative": "エラー", + "positive": "成功" +} diff --git a/packages/@react-spectrum/toast/intl/ko-KR.json b/packages/@react-spectrum/toast/intl/ko-KR.json new file mode 100644 index 00000000000..ea1a70b6a1b --- /dev/null +++ b/packages/@react-spectrum/toast/intl/ko-KR.json @@ -0,0 +1,5 @@ +{ + "info": "정보", + "negative": "오류", + "positive": "성공" +} diff --git a/packages/@react-spectrum/toast/intl/lt-LT.json b/packages/@react-spectrum/toast/intl/lt-LT.json new file mode 100644 index 00000000000..4a74d6fc4d5 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/lt-LT.json @@ -0,0 +1,5 @@ +{ + "info": "Informacija", + "negative": "Klaida", + "positive": "Sėkmingai" +} diff --git a/packages/@react-spectrum/toast/intl/lv-LV.json b/packages/@react-spectrum/toast/intl/lv-LV.json new file mode 100644 index 00000000000..c13f2f6ec63 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/lv-LV.json @@ -0,0 +1,5 @@ +{ + "info": "Informācija", + "negative": "Kļūda", + "positive": "Izdevās" +} diff --git a/packages/@react-spectrum/toast/intl/nb-NO.json b/packages/@react-spectrum/toast/intl/nb-NO.json new file mode 100644 index 00000000000..586a6be0810 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/nb-NO.json @@ -0,0 +1,5 @@ +{ + "info": "Info", + "negative": "Feil", + "positive": "Vellykket" +} diff --git a/packages/@react-spectrum/toast/intl/nl-NL.json b/packages/@react-spectrum/toast/intl/nl-NL.json new file mode 100644 index 00000000000..abfe89948b6 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/nl-NL.json @@ -0,0 +1,5 @@ +{ + "info": "Info", + "negative": "Fout", + "positive": "Geslaagd" +} diff --git a/packages/@react-spectrum/toast/intl/pl-PL.json b/packages/@react-spectrum/toast/intl/pl-PL.json new file mode 100644 index 00000000000..872da7e574a --- /dev/null +++ b/packages/@react-spectrum/toast/intl/pl-PL.json @@ -0,0 +1,5 @@ +{ + "info": "Informacje", + "negative": "Błąd", + "positive": "Powodzenie" +} diff --git a/packages/@react-spectrum/toast/intl/pt-BR.json b/packages/@react-spectrum/toast/intl/pt-BR.json new file mode 100644 index 00000000000..63eaee0477f --- /dev/null +++ b/packages/@react-spectrum/toast/intl/pt-BR.json @@ -0,0 +1,5 @@ +{ + "info": "Informações", + "negative": "Erro", + "positive": "Sucesso" +} diff --git a/packages/@react-spectrum/toast/intl/pt-PT.json b/packages/@react-spectrum/toast/intl/pt-PT.json new file mode 100644 index 00000000000..59e5b549b63 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/pt-PT.json @@ -0,0 +1,5 @@ +{ + "info": "Informação", + "negative": "Erro", + "positive": "Sucesso" +} diff --git a/packages/@react-spectrum/toast/intl/ro-RO.json b/packages/@react-spectrum/toast/intl/ro-RO.json new file mode 100644 index 00000000000..a11645ca19c --- /dev/null +++ b/packages/@react-spectrum/toast/intl/ro-RO.json @@ -0,0 +1,5 @@ +{ + "info": "Informaţii", + "negative": "Eroare", + "positive": "Succes" +} diff --git a/packages/@react-spectrum/toast/intl/ru-RU.json b/packages/@react-spectrum/toast/intl/ru-RU.json new file mode 100644 index 00000000000..ca091e737c9 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/ru-RU.json @@ -0,0 +1,5 @@ +{ + "info": "Информация", + "negative": "Ошибка", + "positive": "Успешно" +} diff --git a/packages/@react-spectrum/toast/intl/sk-SK.json b/packages/@react-spectrum/toast/intl/sk-SK.json new file mode 100644 index 00000000000..4f5475bb5e7 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/sk-SK.json @@ -0,0 +1,5 @@ +{ + "info": "Informácie", + "negative": "Chyba", + "positive": "Úspech" +} diff --git a/packages/@react-spectrum/toast/intl/sl-SI.json b/packages/@react-spectrum/toast/intl/sl-SI.json new file mode 100644 index 00000000000..642dbd129d7 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/sl-SI.json @@ -0,0 +1,5 @@ +{ + "info": "Informacije", + "negative": "Napaka", + "positive": "Uspešno" +} diff --git a/packages/@react-spectrum/toast/intl/sr-SP.json b/packages/@react-spectrum/toast/intl/sr-SP.json new file mode 100644 index 00000000000..476dce9a8a7 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/sr-SP.json @@ -0,0 +1,5 @@ +{ + "info": "Informacije", + "negative": "Greška", + "positive": "Uspešno" +} diff --git a/packages/@react-spectrum/toast/intl/sv-SE.json b/packages/@react-spectrum/toast/intl/sv-SE.json new file mode 100644 index 00000000000..a703595aa5c --- /dev/null +++ b/packages/@react-spectrum/toast/intl/sv-SE.json @@ -0,0 +1,5 @@ +{ + "info": "Info", + "negative": "Fel", + "positive": "Lyckades" +} diff --git a/packages/@react-spectrum/toast/intl/tr-TR.json b/packages/@react-spectrum/toast/intl/tr-TR.json new file mode 100644 index 00000000000..97a0cf732ac --- /dev/null +++ b/packages/@react-spectrum/toast/intl/tr-TR.json @@ -0,0 +1,5 @@ +{ + "info": "Bilgiler", + "negative": "Hata", + "positive": "Başarılı" +} diff --git a/packages/@react-spectrum/toast/intl/uk-UA.json b/packages/@react-spectrum/toast/intl/uk-UA.json new file mode 100644 index 00000000000..099dcee19a5 --- /dev/null +++ b/packages/@react-spectrum/toast/intl/uk-UA.json @@ -0,0 +1,5 @@ +{ + "info": "Інформація", + "negative": "Помилка", + "positive": "Успішно" +} diff --git a/packages/@react-spectrum/toast/intl/zh-CN.json b/packages/@react-spectrum/toast/intl/zh-CN.json new file mode 100644 index 00000000000..0917e36bdce --- /dev/null +++ b/packages/@react-spectrum/toast/intl/zh-CN.json @@ -0,0 +1,5 @@ +{ + "info": "信息", + "negative": "错误", + "positive": "成功" +} diff --git a/packages/@react-spectrum/toast/intl/zh-TW.json b/packages/@react-spectrum/toast/intl/zh-TW.json new file mode 100644 index 00000000000..726e2570a6d --- /dev/null +++ b/packages/@react-spectrum/toast/intl/zh-TW.json @@ -0,0 +1,5 @@ +{ + "info": "資訊", + "negative": "錯誤", + "positive": "成功" +} diff --git a/packages/@react-spectrum/toast/package.json b/packages/@react-spectrum/toast/package.json index a0cae66c3fe..d20327ba0ff 100644 --- a/packages/@react-spectrum/toast/package.json +++ b/packages/@react-spectrum/toast/package.json @@ -32,21 +32,25 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/focus": "^3.10.0", + "@react-aria/i18n": "^3.6.2", "@react-aria/toast": "3.0.0-alpha.1", "@react-spectrum/button": "^3.1.0", "@react-spectrum/utils": "^3.1.0", "@react-stately/toast": "3.0.0-alpha.1", "@react-types/shared": "^3.1.0", - "@react-types/toast": "3.0.0-alpha.1", "@spectrum-icons/ui": "^3.1.0", - "@swc/helpers": "^0.4.14" + "@swc/helpers": "^0.4.14", + "use-sync-external-store": "^1.2.0" }, "devDependencies": { "@adobe/spectrum-css-temp": "3.0.0-alpha.1", - "@react-spectrum/test-utils": "3.0.0-alpha.1" + "@react-spectrum/test-utils": "3.0.0-alpha.1", + "@types/use-sync-external-store": "^0.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", "@react-spectrum/provider": "^3.0.0" }, "publishConfig": { diff --git a/packages/@react-spectrum/toast/src/Toast.tsx b/packages/@react-spectrum/toast/src/Toast.tsx index e87699511ac..387dd8af7a2 100644 --- a/packages/@react-spectrum/toast/src/Toast.tsx +++ b/packages/@react-spectrum/toast/src/Toast.tsx @@ -16,15 +16,30 @@ import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import CrossMedium from '@spectrum-icons/ui/CrossMedium'; import {DOMRef} from '@react-types/shared'; import InfoMedium from '@spectrum-icons/ui/InfoMedium'; -import React from 'react'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {QueuedToast, ToastState} from '@react-stately/toast'; +import React, {ReactNode} from 'react'; import styles from '@adobe/spectrum-css-temp/components/toast/vars.css'; import SuccessMedium from '@spectrum-icons/ui/SuccessMedium'; import toastContainerStyles from './toastContainer.css'; -import {ToastProps, ToastState} from '@react-types/toast'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useToast} from '@react-aria/toast'; -interface SpectrumToastProps extends ToastProps, ToastState {} +export interface SpectrumToastValue { + children: ReactNode, + variant: 'positive' | 'negative' | 'info' | 'neutral', + actionLabel?: ReactNode, + onAction?: () => void, + shouldCloseOnAction?: boolean +} + +export interface SpectrumToastProps { + toast: QueuedToast, + state: ToastState +} +// TODO: express should use filled icons... export const ICONS = { info: InfoMedium, negative: AlertMedium, @@ -33,22 +48,42 @@ export const ICONS = { function Toast(props: SpectrumToastProps, ref: DOMRef) { let { - actionLabel, - children, - onRemove, - variant, + toast: { + key, + animation, + content: { + children, + variant, + actionLabel, + onAction, + shouldCloseOnAction + } + }, + state, ...otherProps } = props; + let domRef = useDOMRef(ref); let { - actionButtonProps, closeButtonProps, - iconProps, + titleProps, toastProps - } = useToast({...otherProps, variant}, {onRemove}); - let domRef = useDOMRef(ref); + } = useToast(props, state, domRef); let {styleProps} = useStyleProps(otherProps); + + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let iconLabel = variant && variant !== 'neutral' ? stringFormatter.format(variant) : null; let Icon = ICONS[variant]; + const handleAction = () => { + if (onAction) { + onAction(); + } + + if (shouldCloseOnAction) { + state.close(key); + } + }; + return (
) { toastContainerStyles, 'spectrum-Toast' ) - )}> + )} + style={{ + ...styleProps.style, + zIndex: props.toast.priority + }} + data-animation={animation} + onAnimationEnd={() => { + if (animation === 'exiting') { + state.remove(key); + } + }}> {Icon && }
-
{children}
+
{children}
{actionLabel && + variant="secondary" + staticColor="white"> + {actionLabel} + }
diff --git a/packages/@react-spectrum/toast/src/ToastContainer.tsx b/packages/@react-spectrum/toast/src/ToastContainer.tsx index 1751c812b7d..8532ee24565 100644 --- a/packages/@react-spectrum/toast/src/ToastContainer.tsx +++ b/packages/@react-spectrum/toast/src/ToastContainer.tsx @@ -10,43 +10,179 @@ * governing permissions and limitations under the License. */ -import {classNames} from '@react-spectrum/utils'; -import React, {ReactElement} from 'react'; -import {Toast} from './'; -import toastContainerStyles from './toastContainer.css'; -import {ToastState} from '@react-types/toast'; -// import {useProvider} from '@react-spectrum/provider'; - -export function ToastContainer(props: ToastState): ReactElement { - let { - onRemove, - toasts - } = props; - // let providerProps = useProvider(); - let toastPlacement = 'bottom'; /* providerProps && providerProps.toastPlacement && providerProps.toastPlacement.split(' '); */ - let containerPosition = toastPlacement && toastPlacement[0]; - let containerPlacement = toastPlacement && toastPlacement[1]; - - let renderToasts = () => toasts.map((toast) => - ( - {toast.content} - ) - ); - - - return ( -
- {renderToasts()} -
- ); +import {AriaToastRegionProps} from '@react-aria/toast'; +import React, {ReactElement, ReactNode, useEffect, useRef} from 'react'; +import {SpectrumToastValue, Toast} from './Toast'; +import {Toaster} from './Toaster'; +import {ToastOptions, ToastQueue, useToastQueue} from '@react-stately/toast'; +import {useSyncExternalStore} from 'use-sync-external-store/shim'; + +export interface SpectrumToastContainerProps extends AriaToastRegionProps {} + +export interface SpectrumToastOptions extends Omit { + /** A label for the action button within the toast. */ + actionLabel?: ReactNode, + /** Handler that is called when the action button is pressed. */ + onAction?: () => void, + /** Whether the toast should automatically close when an action is performed. */ + shouldCloseOnAction?: boolean +} + +type CloseFunction = () => void; + +// There is a single global toast queue instance for the whole app, initialized lazily. +let globalToastQueue: ToastQueue | null = null; +function getGlobalToastQueue() { + if (!globalToastQueue) { + globalToastQueue = new ToastQueue({ + maxVisibleToasts: 1, + hasExitAnimation: true + }); + } + + return globalToastQueue; +} + +// For testing. Not exported from the package index. +export function clearToastQueue() { + globalToastQueue = null; +} + +let toastProviders = new Set(); +let subscriptions = new Set<() => void>(); +function subscribe(fn: () => void) { + subscriptions.add(fn); + return () => subscriptions.delete(fn); +} + +function getActiveToastContainer() { + return toastProviders.values().next().value; +} + +function useActiveToastContainer() { + return useSyncExternalStore(subscribe, getActiveToastContainer); +} + +/** + * A ToastContainer renders the queued toasts in an application. It should be placed + * at the root of the app. + */ +export function ToastContainer(props: SpectrumToastContainerProps): ReactElement { + // Track all toast provider instances in a set. + // Only the first one will actually render. + // We use a ref to do this, since it will have a stable identity + // over the lifetime of the component. + let ref = useRef(); + toastProviders.add(ref); + + // eslint-disable-next-line arrow-body-style + useEffect(() => { + return () => { + // When this toast provider unmounts, reset all animations so that + // when the new toast provider renders, it is seamless. + for (let toast of getGlobalToastQueue().visibleToasts) { + toast.animation = null; + } + + // Remove this toast provider, and call subscriptions. + // This will cause all other instances to re-render, + // and the first one to become the new active toast provider. + toastProviders.delete(ref); + for (let fn of subscriptions) { + fn(); + } + }; + }, []); + + // Only render if this is the active toast provider instance, and there are visible toasts. + let activeToastContainer = useActiveToastContainer(); + let state = useToastQueue(getGlobalToastQueue()); + if (ref === activeToastContainer && state.visibleToasts.length > 0) { + return ( + + {state.visibleToasts.map((toast) => ( + + ))} + + ); + } + + return null; +} + +function addToast(children: ReactNode, variant: SpectrumToastValue['variant'], options: SpectrumToastOptions = {}) { + // Dispatch a custom event so that toasts can be intercepted and re-targeted, e.g. when inside an iframe. + if (typeof CustomEvent !== 'undefined' && typeof window !== 'undefined') { + let event = new CustomEvent('react-spectrum-toast', { + cancelable: true, + bubbles: true, + detail: { + children, + variant, + options + } + }); + + let shouldContinue = window.dispatchEvent(event); + if (!shouldContinue) { + return; + } + } + + let value = { + children, + variant, + actionLabel: options.actionLabel, + onAction: options.onAction, + shouldCloseOnAction: options.shouldCloseOnAction + }; + + // Minimum time of 5s from https://spectrum.adobe.com/page/toast/#Auto-dismissible + // Actionable toasts cannot be auto dismissed. That would fail WCAG SC 2.2.1. + // It is debatable whether non-actionable toasts would also fail. + let timeout = options.timeout && !options.onAction ? Math.max(options.timeout, 5000) : null; + let queue = getGlobalToastQueue(); + let key = queue.add(value, {priority: getPriority(variant, options), timeout, onClose: options.onClose}); + return () => queue.close(key); +} + +const SpectrumToastQueue = { + /** Queues a neutral toast. */ + neutral(children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'neutral', options); + }, + /** Queues a positive toast. */ + positive(children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'positive', options); + }, + /** Queues a negative toast. */ + negative(children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'negative', options); + }, + /** Queues an informational toast. */ + info(children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'info', options); + } +}; + +export {SpectrumToastQueue as ToastQueue}; + +// https://spectrum.adobe.com/page/toast/#Priority-queue +// TODO: if a lower priority toast comes in, no way to know until you dismiss the higher priority one. +const VARIANT_PRIORITY = { + negative: 10, + positive: 3, + info: 2, + neutral: 1 +}; + +function getPriority(variant: SpectrumToastValue['variant'], options: SpectrumToastOptions) { + let priority = VARIANT_PRIORITY[variant] || 1; + if (options.onAction) { + priority += 4; + } + return priority; } diff --git a/packages/@react-spectrum/toast/src/ToastProvider.tsx b/packages/@react-spectrum/toast/src/ToastProvider.tsx deleted file mode 100644 index 0cb5af61978..00000000000 --- a/packages/@react-spectrum/toast/src/ToastProvider.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import React, {ReactElement, ReactNode, useContext} from 'react'; -import {ToastContainer} from './'; -import {ToastOptions} from '@react-types/toast'; -import {useProviderProps} from '@react-spectrum/provider'; -import {useToastState} from '@react-stately/toast'; - -interface ToastContextProps { - positive?: (content: ReactNode, options: ToastOptions) => void, - negative?: (content: ReactNode, options: ToastOptions) => void, - neutral?: (content: ReactNode, options: ToastOptions) => void, - info?: (content: ReactNode, options: ToastOptions) => void -} - -interface ToastProviderProps { - children: ReactNode -} - -export const ToastContext = React.createContext(null); - -export function useToastProvider() { - return useContext(ToastContext); -} - -let keyCounter = 0; -function generateKey(pre = 'toast') { - return `${pre}_${keyCounter++}`; -} - -export function ToastProvider(props: ToastProviderProps): ReactElement { - let {onAdd, onRemove, toasts} = useToastState(); - let { - children - } = useProviderProps(props); - - let contextValue = { - neutral: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey()}); - }, - positive: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey(), variant: 'positive'}); - }, - negative: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey(), variant: 'negative'}); - }, - info: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey(), variant: 'info'}); - } - }; - - return ( - - - {children} - - ); -} diff --git a/packages/@react-spectrum/toast/src/Toaster.tsx b/packages/@react-spectrum/toast/src/Toaster.tsx new file mode 100644 index 00000000000..b70881219ee --- /dev/null +++ b/packages/@react-spectrum/toast/src/Toaster.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AriaToastRegionProps, useToastRegion} from '@react-aria/toast'; +import {classNames, useIsMobileDevice} from '@react-spectrum/utils'; +import {FocusRing} from '@react-aria/focus'; +import {Provider} from '@react-spectrum/provider'; +import React, {ReactElement, ReactNode, useRef} from 'react'; +import ReactDOM from 'react-dom'; +import toastContainerStyles from './toastContainer.css'; +import {ToastState} from '@react-stately/toast'; + +interface ToastContainerProps extends AriaToastRegionProps { + children: ReactNode, + state: ToastState +} + +export function Toaster(props: ToastContainerProps): ReactElement { + let { + children, + state + } = props; + let containerPlacement = useIsMobileDevice() ? 'center' : 'right'; + + let ref = useRef(); + let {regionProps} = useToastRegion(props, state, ref); + + let contents = ( + + +
+ {children} +
+
+
+ ); + + return ReactDOM.createPortal(contents, document.body); +} diff --git a/packages/@react-spectrum/toast/src/index.ts b/packages/@react-spectrum/toast/src/index.ts index 3d63c9dda54..aec1a02db95 100644 --- a/packages/@react-spectrum/toast/src/index.ts +++ b/packages/@react-spectrum/toast/src/index.ts @@ -12,6 +12,6 @@ /// -export {ICONS, Toast} from './Toast'; -export {ToastContainer} from './ToastContainer'; -export {ToastContext, useToastProvider, ToastProvider} from './ToastProvider'; +export {ToastContainer, ToastQueue} from './ToastContainer'; + +export type {SpectrumToastOptions, SpectrumToastContainerProps} from './ToastContainer'; diff --git a/packages/@react-spectrum/toast/src/toastContainer.css b/packages/@react-spectrum/toast/src/toastContainer.css index 53f12649245..e8a0c5b701d 100644 --- a/packages/@react-spectrum/toast/src/toastContainer.css +++ b/packages/@react-spectrum/toast/src/toastContainer.css @@ -10,38 +10,99 @@ * governing permissions and limitations under the License. */ +@import "../../../@adobe/spectrum-css-temp/components/commons/focus-ring.css"; + .react-spectrum-ToastContainer { position: fixed; - top: unset; - bottom: 0; inset-inline-start: 0; inset-inline-end: 0; z-index: 100050; /* above modals */ display: flex; - flex-direction: column-reverse; - align-items: center; pointer-events: none; + outline: none; + + > :first-child { + @inherit %spectrum-FocusRing-ring; + --spectrum-focus-ring-border-radius: var(--spectrum-toast-border-radius); + --spectrum-focus-ring-gap: var(--spectrum-alias-focus-ring-gap); + --spectrum-focus-ring-size: var(--spectrum-alias-focus-ring-size); + } + + &:focus-ring > :first-child { + @inherit %spectrum-FocusRing-active; + } .spectrum-Toast { - margin: 8px; + position: absolute; + margin: 16px; pointer-events: all; } + + &[data-position=top] { + top: 0; + flex-direction: column; + --slide-from: translateY(-100%); + --slide-to: translateY(0); + } + + &[data-position=bottom] { + bottom: 0; + flex-direction: column-reverse; + --slide-from: translateY(100%); + --slide-to: translateY(0); + } + + &[data-placement=left] { + align-items: flex-start; + --slide-from: translateX(-100%); + --slide-to: translateX(0); + + &:dir(rtl) { + --slide-from: translateX(100%); + } + } + + &[data-placement=center] { + align-items: center; + } + + &[data-placement=right] { + align-items: flex-end; + --slide-from: translateX(100%); + --slide-to: translateX(0); + + &:dir(rtl) { + --slide-from: translateX(-100%); + } + } } -.react-spectrum-ToastContainer--top { - top: 0; - flex-direction: column; - bottom: unset; -} -.react-spectrum-ToastContainer--bottom { - flex-direction: column-reverse; - bottom: 0; -} -.react-spectrum-ToastContainer--left { - align-items: flex-start; + +.spectrum-Toast { + &[data-animation=entering] { + animation: slide-in 300ms; + } + + &[data-animation=exiting] { + animation: fade-out 300ms forwards; + } } -.react-spectrum-ToastContainer--center { - align-items: center; + +@keyframes slide-in { + from { + transform: var(--slide-from); + } + + to { + transform: var(--slide-to); + } } -.react-spectrum-ToastContainer--right { - align-items: flex-end; + +@keyframes fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } } diff --git a/packages/@react-spectrum/toast/stories/Toast.stories.tsx b/packages/@react-spectrum/toast/stories/Toast.stories.tsx index 7db7e7df16f..6449c439664 100644 --- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx +++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx @@ -12,106 +12,137 @@ import {action} from '@storybook/addon-actions'; import {Button} from '@react-spectrum/button'; -import React from 'react'; +import {ButtonGroup} from '@react-spectrum/buttongroup'; +import {Checkbox} from '@react-spectrum/checkbox'; +import {Content} from '@react-spectrum/view'; +import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; +import {Flex} from '@react-spectrum/layout'; +import {Heading} from '@react-spectrum/text'; +import React, {useState} from 'react'; +import {SpectrumToastOptions} from '../src/ToastContainer'; import {storiesOf} from '@storybook/react'; -import {Toast} from '../'; -import {ToastProps} from '@react-types/toast'; -import {ToastProvider, useToastProvider} from '../'; +import {ToastContainer, ToastQueue} from '../'; storiesOf('Toast', module) + .addParameters({ + args: { + shouldCloseOnAction: false, + timeout: null + }, + argTypes: { + timeout: { + control: { + type: 'radio', + options: [null, 5000] + } + } + } + }) + .addDecorator((story, {parameters}) => ( + <> + {!parameters.disableToastContainer && } + {story()} + + )) .add( 'Default', - () => render({onClose: action('onClose')}, 'Toast is done.') + args => ) .add( - 'variant = info', - () => render({variant: 'info', onClose: action('onClose')}, 'Toast is happening.') + 'With action', + args => ) .add( - 'variant = positive', - () => render({variant: 'positive', onClose: action('onClose')}, 'Toast is perfect.') + 'With dialog', + args => ( + + + + Toasty + + + + + + ) ) .add( - 'variant = Negative', - () => render({variant: 'negative', onClose: action('onClose')}, 'Toast is not done.') + 'multiple ToastContainers', + args => , + {disableToastContainer: true} ) .add( - 'actionable', - () => render({actionLabel: 'Undo', onAction: action('onAction'), onClose: action('onClose')}, 'Untoast the toast') - ) - .add( - 'action triggers close', - () => render({actionLabel: 'Undo', onAction: action('onAction'), shouldCloseOnAction: true, onClose: action('onClose')}, 'Close on untoasting of the toast') - ).add( - 'add via provider', - () => - ).add( - 'add via provider with timers', - () => - ); - -function render(props:ToastProps = {}, message:String) { - return ( - - {message} - + 'programmatically closing', + args => ); -} - -function RenderProvider() { - let toastContext = useToastProvider(); +function RenderProvider(options: SpectrumToastOptions) { return ( -
+ -
+ ); } -function RenderProviderTimers() { - let toastContext = useToastProvider(); +function ToastToggle(options: SpectrumToastOptions) { + let [close, setClose] = useState(null); return ( -
- - - - -
+ + ); +} + +function Multiple(options: SpectrumToastOptions) { + let [isMounted1, setMounted1] = useState(true); + + return ( + + First mounted + {isMounted1 && } + + + + ); +} + +function MultipleInner() { + let [isMounted2, setMounted2] = useState(true); + + return ( + <> + Second mounted + {isMounted2 && } + ); } diff --git a/packages/@react-spectrum/toast/test/Toast.test.js b/packages/@react-spectrum/toast/test/Toast.test.js deleted file mode 100644 index d47eebfc2ec..00000000000 --- a/packages/@react-spectrum/toast/test/Toast.test.js +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {act, render, triggerPress} from '@react-spectrum/test-utils'; -import React from 'react'; -import {Toast} from '../'; - -let testId = 'test-id'; - -function renderComponent(Component, props, message) { - return render({message}); -} - -describe('Toast', function () { - let onClose = jest.fn(); - let onAction = jest.fn(); - let onRemove = jest.fn(); - - afterEach(() => { - onClose.mockClear(); - onAction.mockClear(); - }); - - it.each` - name | Component | props | message - ${'Toast'} | ${Toast} | ${{}} | ${'Toast time!'} - `('$name handles defaults', function ({name, Component, props, message}) { - let {getAllByRole, getByRole, getByText} = renderComponent(Component, props, message); - - expect(getByRole('alert')).toBeTruthy(); - expect(getByText(message)).toBeTruthy(); - expect(getAllByRole('img', {hidden: true}).length).toBe(1); - }); - - it.each` - Name | Component | props | message - ${'Toast'} | ${Toast} | ${{UNSAFE_className: 'myClass'}} | ${'Toast time!'} - `('$Name supports UNSAFE_className', function ({Component, props, message}) { - let {getByTestId} = renderComponent(Component, props, message); - let className = getByTestId(testId).className; - - expect(className.includes('myClass')).toBeTruthy(); - }); - - it.each` - Name | Component | props | message - ${'Toast'} | ${Toast} | ${{variant: 'info'}} | ${'Toast time!'} - `('$Name supports variant info', function ({Component, props, message}) { - let {getAllByRole} = renderComponent(Component, props, message); - - expect(getAllByRole('img', {hidden: true}).length).toBe(2); // there's one hidden and one not - }); - - it.each` - Name | Component | props | message - ${'Toast'} | ${Toast} | ${{actionLabel: 'Undo', onRemove}} | ${'Toast time!'} - `('$Name handles action and close button clicks', function ({Component, props, message}) { - let {getAllByRole, getByText} = renderComponent(Component, {onClose, onAction, ...props}, message); - let button = getAllByRole('button'); - - // action button - triggerPress(button[0]); - expect(onClose).toHaveBeenCalledTimes(0); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(0); - expect(getByText(props.actionLabel)).toBeTruthy(); - - // close button - triggerPress(button[1]); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(1); - }); - - it.each` - Name | Component | props | message - ${'Toast'} | ${Toast} | ${{actionLabel: 'Undo', shouldCloseOnAction: true, onRemove}} | ${'Toast time!'} - `('$Name handles action and close button clicks when action closes', function ({Component, props, message}) { - let {getAllByRole, getByText} = renderComponent(Component, {onClose, onAction, ...props}, message); - let button = getAllByRole('button'); - - // action button - triggerPress(button[0]); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(1); - expect(getByText(props.actionLabel)).toBeTruthy(); - - // close button - triggerPress(button[1]); - expect(onClose).toHaveBeenCalledTimes(2); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(2); - }); - - it.each` - Name | Component | props | message - ${'Toast'} | ${Toast} | ${{actionLabel: 'Undo', shouldCloseOnAction: true, onRemove}} | ${'Toast time!'} - `('$Name action button and close button are focusable', function ({Component, props, message}) { - let {getAllByRole} = renderComponent(Component, {onClose, onAction, ...props}, message); - let button = getAllByRole('button'); - - // action button - act(() => {button[0].focus();}); - triggerPress(document.activeElement); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(1); - - // close button - act(() => {button[1].focus();}); - triggerPress(document.activeElement); - expect(onClose).toHaveBeenCalledTimes(2); - expect(onAction).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(2); - }); - - // New v3 functionality, omitting v2 components - it.each` - Name | Component - ${'Toast'} | ${Toast} - `('$Name will attach a ref to the toast', ({Component}) => { - let ref = React.createRef(); - let toast = renderComponent(Component, {ref}); - let input = toast.getByTestId(testId); - - expect(ref.current.UNSAFE_getDOMNode()).toEqual(input); - }); -}); diff --git a/packages/@react-spectrum/toast/test/ToastContainer.test.js b/packages/@react-spectrum/toast/test/ToastContainer.test.js index dbb0ed4cab5..dd26498cba8 100644 --- a/packages/@react-spectrum/toast/test/ToastContainer.test.js +++ b/packages/@react-spectrum/toast/test/ToastContainer.test.js @@ -10,19 +10,19 @@ * governing permissions and limitations under the License. */ +import {act, fireEvent, render, triggerPress, within} from '@react-spectrum/test-utils'; import {Button} from '@react-spectrum/button'; +import {clearToastQueue, ToastContainer, ToastQueue} from '../src/ToastContainer'; +import {defaultTheme} from '@adobe/react-spectrum'; import {Provider} from '@react-spectrum/provider'; -import React from 'react'; -import {render, triggerPress, waitFor} from '@react-spectrum/test-utils'; -import {ToastContainer, ToastProvider, useToastProvider} from '../'; +import React, {useState} from 'react'; +import userEvent from '@testing-library/user-event'; function RenderToastButton(props = {}) { - let toastContext = useToastProvider(); - return (
@@ -31,52 +31,400 @@ function RenderToastButton(props = {}) { } function renderComponent(contents) { - return render( - {contents} - ); + return render( + + + {contents} + + ); } -describe.skip('Toast Provider and Container', function () { - it('Renders a button that triggers a toast via the provider', async () => { - let {getByRole, queryAllByRole, queryByRole} = renderComponent(); +function fireAnimationEnd(alert) { + let e = new Event('animationend', {bubbles: true, cancelable: false}); + e.animationName = 'fade-out'; + fireEvent(alert, e); +} + +describe('Toast Provider and Container', function () { + beforeEach(() => { + jest.useFakeTimers(); + clearToastQueue(); + }); + + afterEach(() => { + act(() => jest.runAllTimers()); + }); + + it('renders a button that triggers a toast', () => { + let {getByRole, queryByRole} = renderComponent(); let button = getByRole('button'); - expect(() => { - queryByRole('alert'); - }).toBeNull(); + expect(queryByRole('alert')).toBeNull(); + triggerPress(button); + + let region = getByRole('region'); + expect(region).toHaveAttribute('aria-label', 'Notifications'); + + let alert = getByRole('alert'); + expect(alert).toBeVisible(); + + button = within(alert).getByRole('button'); + expect(button).toHaveAttribute('aria-label', 'Close'); + triggerPress(button); + + fireAnimationEnd(alert); + expect(queryByRole('alert')).toBeNull(); + }); + + it('should label icon by variant', () => { + let {getByRole} = renderComponent(); + let button = getByRole('button'); + triggerPress(button); + + let alert = getByRole('alert'); + let icon = within(alert).getByRole('img'); + expect(icon).toHaveAttribute('aria-label', 'Success'); + }); + + it('removes a toast via timeout', () => { + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); + + triggerPress(button); + + let toast = getByRole('alert'); + expect(toast).toBeVisible(); + + act(() => jest.advanceTimersByTime(1000)); + expect(toast).not.toHaveAttribute('data-animation', 'exiting'); + + act(() => jest.advanceTimersByTime(5000)); + expect(toast).toHaveAttribute('data-animation', 'exiting'); + + fireAnimationEnd(toast); + expect(queryByRole('alert')).toBeNull(); + }); + + it('pauses timers when hovering', () => { + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); + + triggerPress(button); + + let toast = getByRole('alert'); + expect(toast).toBeVisible(); + + act(() => jest.advanceTimersByTime(1000)); + act(() => userEvent.hover(toast)); + + act(() => jest.advanceTimersByTime(7000)); + expect(toast).not.toHaveAttribute('data-animation', 'exiting'); + + act(() => userEvent.unhover(toast)); + + act(() => jest.advanceTimersByTime(4000)); + expect(toast).toHaveAttribute('data-animation', 'exiting'); + + fireAnimationEnd(toast); + expect(queryByRole('alert')).toBeNull(); + }); + + it('pauses timers when focusing', () => { + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); triggerPress(button); - expect(queryAllByRole('alert').length).toBe(1); - expect(getByRole('alert')).toBeVisible(); + let toast = getByRole('alert'); + expect(toast).toBeVisible(); + + act(() => jest.advanceTimersByTime(1000)); + act(() => within(toast).getByRole('button').focus()); + + act(() => jest.advanceTimersByTime(7000)); + expect(toast).not.toHaveAttribute('data-animation', 'exiting'); + + act(() => within(toast).getByRole('button').blur()); + + act(() => jest.advanceTimersByTime(4000)); + expect(toast).toHaveAttribute('data-animation', 'exiting'); + + fireAnimationEnd(toast); + expect(queryByRole('alert')).toBeNull(); }); - it('get position from provider', async () => { - let {getByTestId} = renderComponent( - - Toast - ); + it('renders a toast with an action', () => { + let onAction = jest.fn(); + let onClose = jest.fn(); + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); + + expect(queryByRole('alert')).toBeNull(); + triggerPress(button); - let className = getByTestId('testId1').className; - expect(className.includes('react-spectrum-ToastContainer--top')).toBeTruthy(); - expect(className.includes('react-spectrum-ToastContainer--left')).toBeTruthy(); + let alert = getByRole('alert'); + expect(alert).toBeVisible(); + + let buttons = within(alert).getAllByRole('button'); + expect(buttons[0]).toHaveTextContent('Action'); + triggerPress(buttons[0]); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); }); - it('removes a toast via timeout', async () => { - let {getByRole, queryByRole} = renderComponent(); + it('closes toast on action', () => { + let onAction = jest.fn(); + let onClose = jest.fn(); + let {getByRole, queryByRole} = renderComponent(); let button = getByRole('button'); + expect(queryByRole('alert')).toBeNull(); triggerPress(button); - // confirm toast is there, wait for it disappear, then confirm it is gone - let toasts = getByRole('alert'); - expect(toasts).toBeVisible(); + let alert = getByRole('alert'); + expect(alert).toBeVisible(); + + let buttons = within(alert).getAllByRole('button'); + expect(buttons[0]).toHaveTextContent('Action'); + triggerPress(buttons[0]); + + expect(onAction).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + + expect(alert).toHaveAttribute('data-animation', 'exiting'); + fireAnimationEnd(alert); + expect(queryByRole('alert')).toBeNull(); + }); + + it('prioritizes toasts based on variant', () => { + function ToastPriorites(props = {}) { + return ( +
+ + +
+ ); + } + + let {getByRole, getAllByRole, queryByRole} = renderComponent(); + let buttons = getAllByRole('button'); + + // show info toast first. error toast should supersede it. + + expect(queryByRole('alert')).toBeNull(); + triggerPress(buttons[0]); + + let alert = getByRole('alert'); + expect(alert).toBeVisible(); + expect(alert).toHaveTextContent('Info'); + + triggerPress(buttons[1]); + fireAnimationEnd(alert); + + alert = getByRole('alert'); + expect(alert).toHaveTextContent('Error'); + + triggerPress(within(alert).getByRole('button')); + fireAnimationEnd(alert); + + alert = getByRole('alert'); + expect(alert).toHaveTextContent('Info'); + + triggerPress(within(alert).getByRole('button')); + fireAnimationEnd(alert); + expect(queryByRole('alert')).toBeNull(); - await waitFor(() => { - expect(() => { - queryByRole('alert'); - }).toBeNull(); + // again, but with error toast first. + + triggerPress(buttons[1]); + alert = getByRole('alert'); + expect(alert).toHaveTextContent('Error'); + + triggerPress(buttons[0]); + alert = getByRole('alert'); + expect(alert).toHaveTextContent('Error'); + + triggerPress(within(alert).getByRole('button')); + fireAnimationEnd(alert); + + alert = getByRole('alert'); + expect(alert).toHaveTextContent('Info'); + + triggerPress(within(alert).getByRole('button')); + fireAnimationEnd(alert); + expect(queryByRole('alert')).toBeNull(); + }); + + it('can focus toast region using F6', () => { + let {getByRole} = renderComponent(); + let button = getByRole('button'); + + triggerPress(button); + + let toast = getByRole('alert'); + expect(toast).toBeVisible(); + + expect(document.activeElement).toBe(button); + fireEvent.keyDown(button, {key: 'F6'}); + fireEvent.keyUp(button, {key: 'F6'}); + + let region = getByRole('region'); + expect(document.activeElement).toBe(region); + }); + + it('should restore focus when a toast exits', () => { + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); + + triggerPress(button); + + let toast = getByRole('alert'); + let closeButton = within(toast).getByRole('button'); + act(() => closeButton.focus()); + + triggerPress(closeButton); + fireAnimationEnd(toast); + expect(queryByRole('alert')).toBeNull(); + expect(document.activeElement).toBe(button); + }); + + it('should move focus to container when a toast exits and there are more', () => { + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); + + triggerPress(button); + triggerPress(button); + + let toast = getByRole('alert'); + let closeButton = within(toast).getByRole('button'); + triggerPress(closeButton); + fireAnimationEnd(toast); + + expect(document.activeElement).toBe(getByRole('region')); + + toast = getByRole('alert'); + closeButton = within(toast).getByRole('button'); + triggerPress(closeButton); + fireAnimationEnd(toast); + + expect(queryByRole('alert')).toBeNull(); + expect(document.activeElement).toBe(button); + }); + + it('should support programmatically closing toasts', () => { + function ToastToggle() { + let [close, setClose] = useState(null); + + return ( + + ); + } + + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); + + triggerPress(button); + + let toast = getByRole('alert'); + expect(toast).toBeVisible(); + + triggerPress(button); + fireAnimationEnd(toast); + expect(queryByRole('alert')).toBeNull(); + }); + + it('should only render one ToastContainer', () => { + let {getByRole, getAllByRole, rerender} = render( + + + + + + ); + + let button = getByRole('button'); + triggerPress(button); + + expect(getAllByRole('region')).toHaveLength(1); + expect(getAllByRole('alert')).toHaveLength(1); + + rerender( + + + + + ); + + expect(getAllByRole('region')).toHaveLength(1); + expect(getAllByRole('alert')).toHaveLength(1); + + rerender( + + + + + ); + + expect(getAllByRole('region')).toHaveLength(1); + expect(getAllByRole('alert')).toHaveLength(1); + + rerender( + + + + + + ); + + expect(getAllByRole('region')).toHaveLength(1); + expect(getAllByRole('alert')).toHaveLength(1); + }); + + it('should support custom toast events', () => { + let {getByRole, queryByRole} = renderComponent(); + + let onToast = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('react-spectrum-toast', onToast); + + let button = getByRole('button'); + triggerPress(button); + + expect(queryByRole('alert')).toBeNull(); + expect(onToast).toHaveBeenCalledTimes(1); + expect(onToast.mock.calls[0][0].detail).toEqual({ + children: 'Toast is default', + variant: 'neutral', + options: {} }); + window.removeEventListener('react-spectrum-toast', onToast); + }); + + it('should support custom aria-label', () => { + let {getByRole} = render( + + + + + ); + + let button = getByRole('button'); + triggerPress(button); + + let region = getByRole('region'); + expect(region).toHaveAttribute('aria-label', 'Toasts'); }); }); diff --git a/packages/@react-stately/toast/docs/useToastState.mdx b/packages/@react-stately/toast/docs/useToastState.mdx new file mode 100644 index 00000000000..26bb45923dc --- /dev/null +++ b/packages/@react-stately/toast/docs/useToastState.mdx @@ -0,0 +1,47 @@ +{/* Copyright 2022 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +import {Layout} from '@react-spectrum/docs'; +export default Layout; + +import docs from 'docs:@react-stately/toast'; +import {ClassAPI, HeaderInfo, TypeContext, FunctionAPI, TypeLink, PageDescription} from '@react-spectrum/docs'; +import packageData from '@react-stately/toast/package.json'; + +--- +category: Status +keywords: [toast, notifications, alert, state] +after_version: 3.0.0 +--- + +# useToastState + +{docs.exports.useToastState.description} + + + +## API + + + +## Interface + + + +## ToastQueue + +`useToastState` uses a `ToastQueue` under the hood, which means the queue is owned by the component that calls it. If you want to have a global toast queue outside the React tree, you can use the `ToastQueue` class directly. The hook can be used to to subscribe to a `ToastQueue` within a React component, and returns the same interface as described above. + + + +## Example + +See the docs for [useToast](../react-aria/useToast.html) in react-aria for an example of `useToastState`. diff --git a/packages/@react-stately/toast/package.json b/packages/@react-stately/toast/package.json index c2e3d12bc9f..6624f53f158 100644 --- a/packages/@react-stately/toast/package.json +++ b/packages/@react-stately/toast/package.json @@ -18,8 +18,11 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-types/toast": "3.0.0-alpha.1", - "@swc/helpers": "^0.4.14" + "@swc/helpers": "^0.4.14", + "use-sync-external-store": "^1.2.0" + }, + "devDependencies": { + "@types/use-sync-external-store": "^0.0.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" diff --git a/packages/@react-stately/toast/src/index.ts b/packages/@react-stately/toast/src/index.ts index ae805488072..d1d6c78a6d4 100644 --- a/packages/@react-stately/toast/src/index.ts +++ b/packages/@react-stately/toast/src/index.ts @@ -9,5 +9,6 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -export {useToastState} from './useToastState'; -export {Timer} from './timer'; +export {useToastState, ToastQueue, useToastQueue} from './useToastState'; + +export type {ToastState, QueuedToast, ToastStateProps, ToastOptions} from './useToastState'; diff --git a/packages/@react-stately/toast/src/timer.ts b/packages/@react-stately/toast/src/timer.ts deleted file mode 100644 index 2858360c54c..00000000000 --- a/packages/@react-stately/toast/src/timer.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -export function Timer(callback: () => void, delay: number) { - let timerId, start; - let remaining = delay; - - this.pause = () => { - clearTimeout(timerId); - remaining -= Date.now() - start; - }; - - this.resume = () => { - start = Date.now(); - timerId && clearTimeout(timerId); - timerId = setTimeout(callback, remaining); - }; - - this.clear = () => { - clearTimeout(timerId); - }; - - this.resume(); -} diff --git a/packages/@react-stately/toast/src/useToastState.ts b/packages/@react-stately/toast/src/useToastState.ts index b0806e4c9c7..2901ad72dc5 100644 --- a/packages/@react-stately/toast/src/useToastState.ts +++ b/packages/@react-stately/toast/src/useToastState.ts @@ -10,57 +10,234 @@ * governing permissions and limitations under the License. */ -import {ReactNode, useRef, useState} from 'react'; -import {Timer} from './'; -import {ToastProps, ToastState, ToastStateValue} from '@react-types/toast'; +import {useCallback, useMemo} from 'react'; +// Shim to support React 17 and below. +import {useSyncExternalStore} from 'use-sync-external-store/shim'; -interface ToastStateProps { - value?: ToastStateValue[] +export interface ToastStateProps { + /** The maximum number of toasts to display at a time. */ + maxVisibleToasts?: number, + /** + * Whether toasts have an exit animation. If true, toasts are not + * removed immediately but transition into an "exiting" state instead. + * Once the animation is complete, call the `remove` function. + */ + hasExitAnimation?: boolean } -const TOAST_TIMEOUT = 5000; +export interface ToastOptions { + /** Handler that is called when the toast is closed, either by the user or after a timeout. */ + onClose?: () => void, + /** A timeout to automatically close the toast after, in milliseconds. */ + timeout?: number, + /** The priority of the toast relative to other toasts. Larger numbers indicate higher priority. */ + priority?: number +} + +export interface QueuedToast extends ToastOptions { + /** The content of the toast. */ + content: T, + /** A unique key for the toast. */ + key: string, + /** A timer for the toast, if a timeout was set. */ + timer?: Timer, + /** The current animation state for the toast. */ + animation?: 'entering' | 'queued' | 'exiting' +} + +export interface ToastState { + /** Adds a new toast to the queue. */ + add(content: T, options?: ToastOptions): string, + /** + * Closes a toast. If `hasExitAnimation` is true, the toast + * transitions to an "exiting" state instead of being removed immediately. + */ + close(key: string): void, + /** Removes a toast from the visible toasts after an exiting animation. */ + remove(key: string): void, + /** Pauses the timers for all visible toasts. */ + pauseAll(): void, + /** Resumes the timers for all visible toasts. */ + resumeAll(): void, + /** The visible toasts. */ + visibleToasts: QueuedToast[] +} + +/** + * Provides state management for a toast queue. Toasts display brief, temporary notifications + * of actions, errors, or other events in an application. + */ +export function useToastState(props: ToastStateProps = {}): ToastState { + let {maxVisibleToasts = 1, hasExitAnimation = false} = props; + let queue = useMemo(() => new ToastQueue({maxVisibleToasts, hasExitAnimation}), [maxVisibleToasts, hasExitAnimation]); + return useToastQueue(queue); +} -export function useToastState(props?: ToastStateProps): ToastState { - const [toasts, setToasts] = useState(props && props.value || []); - const toastsRef = useRef(toasts); - toastsRef.current = toasts; +/** + * Subscribes to a provided toast queue and provides methods to update it. + */ +export function useToastQueue(queue: ToastQueue): ToastState { + let subscribe = useCallback(fn => queue.subscribe(fn), [queue]); + let getSnapshot = useCallback(() => queue.visibleToasts, [queue]); + let visibleToasts = useSyncExternalStore(subscribe, getSnapshot); - const onAdd = (content: ReactNode, options: ToastProps) => { - let tempToasts = [...toasts]; - let timer; + return { + visibleToasts, + add: (content, options) => queue.add(content, options), + close: key => queue.close(key), + remove: key => queue.remove(key), + pauseAll: () => queue.pauseAll(), + resumeAll: () => queue.resumeAll() + }; +} - // set timer to remove toasts - if (!(options.actionLabel || options.timeout === 0)) { - if (options.timeout < 0) { - options.timeout = TOAST_TIMEOUT; +/** + * A ToastQueue is a priority queue of toasts. + */ +export class ToastQueue { + private queue: QueuedToast[] = []; + private subscriptions: Set<() => void> = new Set(); + private maxVisibleToasts: number; + private hasExitAnimation: boolean; + /** The currently visible toasts. */ + visibleToasts: QueuedToast[] = []; + + constructor(options?: ToastStateProps) { + this.maxVisibleToasts = options?.maxVisibleToasts ?? 1; + this.hasExitAnimation = options?.hasExitAnimation ?? false; + } + + /** Subscribes to updates to the visible toasts. */ + subscribe(fn: () => void) { + this.subscriptions.add(fn); + return () => this.subscriptions.delete(fn); + } + + /** Adds a new toast to the queue. */ + add(content: T, options: ToastOptions = {}) { + let toastKey = Math.random().toString(36); + let toast: QueuedToast = { + ...options, + content, + key: toastKey, + timer: options.timeout ? new Timer(() => this.close(toastKey), options.timeout) : null + }; + + let low = 0; + let high = this.queue.length; + while (low < high) { + let mid = Math.floor((low + high) / 2); + if ((toast.priority || 0) > (this.queue[mid].priority || 0)) { + high = mid; + } else { + low = mid + 1; } - timer = new Timer(() => onRemove(options.toastKey), options.timeout || TOAST_TIMEOUT); } - tempToasts.push({ - content, - props: options, - timer - }); - setToasts(tempToasts); + this.queue.splice(low, 0, toast); + toast.animation = low < this.maxVisibleToasts ? 'entering' : 'queued'; + let i = this.maxVisibleToasts; + while (i < this.queue.length) { + this.queue[i++].animation = 'queued'; + } - }; + this.updateVisibleToasts(); + return toastKey; + } + + /** + * Closes a toast. If `hasExitAnimation` is true, the toast + * transitions to an "exiting" state instead of being removed immediately. + */ + close(key: string) { + let index = this.queue.findIndex(t => t.key === key); + if (index >= 0) { + this.queue[index].onClose?.(); + this.queue.splice(index, 1); + } - const onRemove = (toastKey: string) => { - let tempToasts = [...toastsRef.current].filter(item => { - if (item.props.toastKey === toastKey && item.timer) { - item.timer.clear(); + this.updateVisibleToasts(); + } + + /** Removes a toast from the visible toasts after an exiting animation. */ + remove(key: string) { + this.visibleToasts = this.visibleToasts.filter(t => t.key !== key); + this.updateVisibleToasts(); + } + + private updateVisibleToasts() { + let toasts = this.queue.slice(0, this.maxVisibleToasts); + if (this.hasExitAnimation) { + let prevToasts: QueuedToast[] = this.visibleToasts + .filter(t => !toasts.some(t2 => t.key === t2.key)) + .map(t => ({...t, animation: 'exiting'})); + + this.visibleToasts = prevToasts.concat(toasts).sort((a, b) => b.priority - a.priority); + } else { + this.visibleToasts = toasts; + } + + for (let fn of this.subscriptions) { + fn(); + } + } + + /** Pauses the timers for all visible toasts. */ + pauseAll() { + for (let toast of this.visibleToasts) { + if (toast.timer) { + toast.timer.pause(); } - return item.props.toastKey !== toastKey; - }); - setToasts(tempToasts); - }; + } + } - return { - onAdd, - onRemove, - setToasts, - toasts - }; + /** Resumes the timers for all visible toasts. */ + resumeAll() { + for (let toast of this.visibleToasts) { + if (toast.timer) { + toast.timer.resume(); + } + } + } +} + +class Timer { + private timerId; + private startTime: number; + private remaining: number; + private callback: () => void; + + constructor(callback: () => void, delay: number) { + this.remaining = delay; + this.callback = callback; + } + + reset(delay: number) { + this.remaining = delay; + this.resume(); + } + + pause() { + if (this.timerId == null) { + return; + } + + clearTimeout(this.timerId); + this.timerId = null; + this.remaining -= Date.now() - this.startTime; + } + + resume() { + if (this.remaining <= 0) { + return; + } + + this.startTime = Date.now(); + this.timerId = setTimeout(() => { + this.timerId = null; + this.remaining = 0; + this.callback(); + }, this.remaining); + } } diff --git a/packages/@react-stately/toast/test/useToastState.test.js b/packages/@react-stately/toast/test/useToastState.test.js index 75db9826206..323a501bb12 100644 --- a/packages/@react-stately/toast/test/useToastState.test.js +++ b/packages/@react-stately/toast/test/useToastState.test.js @@ -14,115 +14,105 @@ import {actHook as act, renderHook} from '@react-spectrum/test-utils'; import {useToastState} from '../'; describe('useToastState', () => { - let toastKey = 'toast1'; let newValue = [{ content: 'Toast Message', - props: {timeout: 0, toastKey} + props: {timeout: 0} }]; - it('should be able to update via setToasts', () => { + it('should add a new toast via add', () => { let {result} = renderHook(() => useToastState()); - expect(result.current.toasts).toStrictEqual([]); - - act(() => result.current.setToasts(newValue)); - expect(result.current.toasts).toStrictEqual(newValue); + expect(result.current.visibleToasts).toStrictEqual([]); + + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); + expect(result.current.visibleToasts).toHaveLength(1); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); + expect(result.current.visibleToasts[0].animation).toBe('entering'); + expect(result.current.visibleToasts[0].timeout).toBe(0); + expect(result.current.visibleToasts[0].timer).toBe(null); + expect(result.current.visibleToasts[0]).toHaveProperty('key'); }); - it('should add a new toast via onAdd', () => { + it('should add a new toast with a timer', () => { let {result} = renderHook(() => useToastState()); - expect(result.current.toasts).toStrictEqual([]); - - act(() => result.current.onAdd(newValue[0].content, newValue[0].props)); - expect(result.current.toasts).toStrictEqual([{...newValue[0], timer: undefined}]); + expect(result.current.visibleToasts).toStrictEqual([]); + + act(() => {result.current.add('Test', {timeout: 5000});}); + expect(result.current.visibleToasts).toHaveLength(1); + expect(result.current.visibleToasts[0].content).toBe('Test'); + expect(result.current.visibleToasts[0].animation).toBe('entering'); + expect(result.current.visibleToasts[0].timeout).toBe(5000); + expect(result.current.visibleToasts[0].timer).not.toBe(null); + expect(result.current.visibleToasts[0]).toHaveProperty('key'); }); it('should be able to add multiple toasts', () => { let secondToast = { content: 'Second Toast', - props: {variant: 'info', timeout: 0} + props: {timeout: 0} }; - let {result} = renderHook(() => useToastState()); - expect(result.current.toasts).toStrictEqual([]); + let {result} = renderHook(() => useToastState({maxVisibleToasts: 2})); + expect(result.current.visibleToasts).toStrictEqual([]); - act(() => result.current.onAdd(newValue[0].content, newValue[0].props)); - expect(result.current.toasts).toStrictEqual([{...newValue[0], timer: undefined}]); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); - act(() => result.current.onAdd(secondToast.content, secondToast.props)); - expect(result.current.toasts.length).toBe(2); - expect(result.current.toasts[0]).toStrictEqual({...newValue[0], timer: undefined}); - expect(result.current.toasts[1]).toStrictEqual({...secondToast, timer: undefined}); + act(() => {result.current.add(secondToast.content, secondToast.props);}); + expect(result.current.visibleToasts.length).toBe(2); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); + expect(result.current.visibleToasts[1].content).toBe(secondToast.content); }); - it('should remove a toast via onRemove', () => { - let {result} = renderHook(() => useToastState({value: newValue})); - expect(result.current.toasts).toStrictEqual(newValue); + it('should close a toast', () => { + let {result} = renderHook(() => useToastState()); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); - act(() => result.current.onRemove(toastKey)); - expect(result.current.toasts).toStrictEqual([]); + act(() => {result.current.close(result.current.visibleToasts[0].key);}); + expect(result.current.visibleToasts).toStrictEqual([]); }); - it('onRemove should remove a toast by toastKey', () => { - let toast1Key = 'toast1'; - let toast2Key = 'toast2'; - let toast3Key = 'toast3'; - let threeToasts = [{ - content: 'Toast One', - props: {timeout: 0, toastKey: toast1Key} - }, { - content: 'Toast Two', - props: {timeout: 0, toastKey: toast2Key} - }, { - content: 'Toast Three', - props: {timeout: 0, toastKey: toast3Key} - }]; - - let {result} = renderHook(() => useToastState({value: threeToasts})); - expect(result.current.toasts).toStrictEqual(threeToasts); - expect(result.current.toasts.length).toEqual(3); - - act(() => result.current.onRemove(toast2Key)); - expect(result.current.toasts.length).toEqual(2); - expect(result.current.toasts[0].props.toastKey).toEqual(toast1Key); - expect(result.current.toasts[1].props.toastKey).toEqual(toast3Key); + it('should close a toast with animations', () => { + let {result} = renderHook(() => useToastState({hasExitAnimation: true})); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); + + act(() => {result.current.close(result.current.visibleToasts[0].key);}); + expect(result.current.visibleToasts.length).toBe(1); + expect(result.current.visibleToasts[0].animation).toBe('exiting'); + + act(() => {result.current.remove(result.current.visibleToasts[0].key);}); + expect(result.current.visibleToasts).toStrictEqual([]); }); - it('should call onRemove via onAdd', async () => { - jest.useFakeTimers('legacy'); - let timeoutToast = { - content: 'Timeout Toast', - props: {variant: 'info', timeout: 1} - }; + it('should queue toasts', () => { let {result} = renderHook(() => useToastState()); - expect(result.current.toasts.length).toEqual(0); - act(() => result.current.onAdd(timeoutToast.content, timeoutToast.props)); - expect(result.current.toasts.length).toEqual(1); - expect(result.current.toasts[0].timer).not.toBe(undefined); + expect(result.current.visibleToasts).toStrictEqual([]); + + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); - act(() => jest.runAllTimers()); + act(() => {result.current.add('Second Toast');}); + expect(result.current.visibleToasts.length).toBe(1); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); - expect(result.current.toasts.length).toEqual(0); + act(() => {result.current.close(result.current.visibleToasts[0].key);}); + expect(result.current.visibleToasts.length).toBe(1); + expect(result.current.visibleToasts[0].content).toBe('Second Toast'); + expect(result.current.visibleToasts[0].animation).toBe('queued'); }); - describe('timers', () => { - beforeEach(() => { - jest.useFakeTimers('legacy'); - }); - afterEach(() => { - jest.useRealTimers(); - }); - it('should not call onRemove via onAdd when there is an actionLabel', async () => { - let timeoutToast = { - content: 'Action Toast', - props: {variant: 'info', timeout: 1, actionLabel: 'Undo'} - }; - let {result} = renderHook(() => useToastState()); - expect(result.current.toasts.length).toEqual(0); - act(() => result.current.onAdd(timeoutToast.content, timeoutToast.props)); - - act(() => {jest.runAllTimers();}); - - expect(result.current.toasts.length).toEqual(1); - expect(result.current.toasts[0].timer).toBe(undefined); - }); + it('should queue toasts with priority', () => { + let {result} = renderHook(() => useToastState()); + expect(result.current.visibleToasts).toStrictEqual([]); + + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); + + act(() => {result.current.add('Second Toast', {priority: 1});}); + expect(result.current.visibleToasts.length).toBe(1); + expect(result.current.visibleToasts[0].content).toBe('Second Toast'); + + act(() => {result.current.close(result.current.visibleToasts[0].key);}); + expect(result.current.visibleToasts.length).toBe(1); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); + expect(result.current.visibleToasts[0].animation).toBe('queued'); }); }); diff --git a/packages/@react-types/toast/README.md b/packages/@react-types/toast/README.md deleted file mode 100644 index f5b3cf5012e..00000000000 --- a/packages/@react-types/toast/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @react-types/toast - -This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-types/toast/package.json b/packages/@react-types/toast/package.json deleted file mode 100644 index 648c908f7d6..00000000000 --- a/packages/@react-types/toast/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@react-types/toast", - "version": "3.0.0-alpha.1", - "description": "Spectrum UI components in React", - "license": "Apache-2.0", - "private": true, - "types": "src/index.d.ts", - "repository": { - "type": "git", - "url": "https://github.com/adobe/react-spectrum" - }, - "dependencies": { - "@react-types/shared": "^3.1.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/@react-types/toast/src/index.d.ts b/packages/@react-types/toast/src/index.d.ts deleted file mode 100644 index d47b3226e0f..00000000000 --- a/packages/@react-types/toast/src/index.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {DOMProps, StyleProps} from '@react-types/shared'; -import {ReactNode} from 'react'; - -export interface ToastOptions extends DOMProps, StyleProps { - actionLabel?: ReactNode, - onAction?: (...args: any[]) => void, - shouldCloseOnAction?: boolean, - onClose?: (...args: any[]) => void, - timeout?: number -} - -export interface ToastProps extends ToastOptions { - children?: ReactNode, - variant?: 'positive' | 'negative' | 'info', - toastKey?: string, - timer?: any -} - -export interface ToastState { - onAdd?: (content: ReactNode, options: ToastProps) => void, - onRemove?: (idKey: string) => void, - setToasts?: (value: ToastStateValue[]) => void, - toasts?: ToastStateValue[] -} - -export interface ToastStateValue { - content: ReactNode, - props: ToastProps, - timer: any -} diff --git a/packages/dev/docs/src/ThemeSwitcher.js b/packages/dev/docs/src/ThemeSwitcher.js index 6b8b5d178ff..ffadcaf498b 100644 --- a/packages/dev/docs/src/ThemeSwitcher.js +++ b/packages/dev/docs/src/ThemeSwitcher.js @@ -39,11 +39,11 @@ function useCurrentColorScheme() { return colorScheme; } -export function ThemeProvider({children, colorScheme: colorSchemeProp, UNSAFE_className}) { +export function ThemeProvider({children, colorScheme: colorSchemeProp, UNSAFE_className, isHidden}) { let colorScheme = useCurrentColorScheme(); return ( - + {children} ); @@ -53,9 +53,9 @@ export function Snippet({children}) { return {children}; } -export function Example({children, colorScheme}) { +export function Example({children, colorScheme, isHidden}) { return ( - + {children} diff --git a/packages/dev/parcel-transformer-docs/DocsTransformer.js b/packages/dev/parcel-transformer-docs/DocsTransformer.js index 3893f2d66ec..209b680770d 100644 --- a/packages/dev/parcel-transformer-docs/DocsTransformer.js +++ b/packages/dev/parcel-transformer-docs/DocsTransformer.js @@ -78,7 +78,11 @@ module.exports = new Transformer({ for (let specifier of path.node.specifiers) { let binding = path.scope.getBinding(specifier.local.name); if (binding) { - exports[specifier.exported.name] = processExport(binding.path); + let value = processExport(binding.path); + if (value.name) { + value.name = specifier.exported.name; + } + exports[specifier.exported.name] = value; asset.symbols.set(specifier.exported.name, specifier.local.name); } } @@ -115,6 +119,10 @@ module.exports = new Transformer({ let docs = getJSDocs(path.parentPath); processExport(path.get('init'), node); addDocs(node, docs); + if (node.type === 'interface') { + node.id = `${asset.filePath}:${path.node.id.name}`; + node.name = path.node.id.name; + } return node; } @@ -160,7 +168,37 @@ module.exports = new Transformer({ }, docs)); } - if (path.isClassMethod() || path.isTSDeclareMethod()) { + if (path.isObjectExpression()) { + let properties = {}; + for (let propertyPath of path.get('properties')) { + let property = processExport(propertyPath); + if (property) { + properties[property.name] = property; + } else { + console.log('UNKNOWN PROPERTY', propertyPath.node); + } + } + + return Object.assign(node, { + type: 'interface', + extends: [], + properties, + typeParameters: [] + }); + } + + if (path.isObjectProperty()) { + let name = t.isStringLiteral(path.node.key) ? path.node.key.value : path.node.key.name; + let docs = getJSDocs(path); + return Object.assign(node, addDocs({ + type: 'property', + name, + value: processExport(path.get('value')), + optional: false + }, docs)); + } + + if (path.isClassMethod() || path.isTSDeclareMethod() || path.isObjectMethod()) { // not sure why isTSDeclareMethod isn't a recognized method, can't find documentation on it either, but it works and that's the type // it seems to be mostly abstract class methods that comes through as this? let name = t.isStringLiteral(path.node.key) ? path.node.key.value : path.node.key.name; diff --git a/packages/dev/parcel-transformer-mdx-docs/MDXTransformer.js b/packages/dev/parcel-transformer-mdx-docs/MDXTransformer.js index d88876f2d94..806a9d69d14 100644 --- a/packages/dev/parcel-transformer-mdx-docs/MDXTransformer.js +++ b/packages/dev/parcel-transformer-mdx-docs/MDXTransformer.js @@ -69,11 +69,12 @@ module.exports = new Transformer({ } if (!options.includes('render=false')) { + let props = options.includes('hidden') ? 'isHidden' : ''; if (/^(\s|\/\/.*)*function (.|\n)*}\s*$/.test(code)) { let name = code.match(/^(\s|\/\/.*)*function (.*?)\s*\(/)[2]; - code = `${code}\nReactDOM.render(<${provider}><${name} />, document.getElementById("${id}"));`; + code = `${code}\nRENDER_FNS.push(() => ReactDOM.render(<${provider} ${props}><${name} />, document.getElementById("${id}")));`; } else if (/^<(.|\n)*>$/m.test(code)) { - code = code.replace(/^(<(.|\n)*>)$/m, `ReactDOM.render(<${provider}>$1, document.getElementById("${id}"));`); + code = code.replace(/^(<(.|\n)*>)$/m, `RENDER_FNS.push(() => ReactDOM.render(<${provider} ${props}>$1, document.getElementById("${id}")));`); } } @@ -109,7 +110,7 @@ module.exports = new Transformer({ ]; } - node.meta = 'example'; + node.meta = options.includes('hidden') ? null : 'example'; return [ ...transformExample(node, preRelease, keepIndividualImports), @@ -436,7 +437,11 @@ module.exports = new Transformer({ clientBundle += `import React from 'react'; import ReactDOM from 'react-dom'; import {Example as ExampleProvider} from '@react-spectrum/docs/src/ThemeSwitcher'; +let RENDER_FNS = []; ${exampleCode.join('\n')} +for (let render of RENDER_FNS) { + render(); +} export default {}; `; } diff --git a/scripts/compareAPIs.js b/scripts/compareAPIs.js index dabb08bdf64..fd894f0b2fc 100644 --- a/scripts/compareAPIs.js +++ b/scripts/compareAPIs.js @@ -353,7 +353,7 @@ function processType(value) { if (value.type === 'parameter') { return processType(value.value); } - if (value.type === 'link') { + if (value.type === 'link' && value.id) { let name = value.id.substr(value.id.lastIndexOf(':') + 1); if (dependantOnLinks.has(currentlyProcessing)) { let foo = dependantOnLinks.get(currentlyProcessing); diff --git a/yarn.lock b/yarn.lock index 4f05e65c303..318dc338790 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4496,6 +4496,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/webpack-env@^1.16.0": version "1.16.4" resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.16.4.tgz#1f4969042bf76d7ef7b5914f59b3b60073f4e1f4" @@ -21188,6 +21193,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"