From ac8b368d3d069db596cc923eb07baa9b7e97ffd6 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 2 Dec 2022 13:30:32 -0800 Subject: [PATCH 01/27] Toast --- .../components/button/skin.css | 19 +- .../components/toast/index.css | 13 +- .../components/toast/skin.css | 48 +--- .../components/tooltip/skin.css | 1 + .../@adobe/spectrum-css-temp/vars/express.css | 2 +- .../vars/spectrum-global.css | 2 +- .../@react-aria/landmark/src/useLandmark.ts | 4 +- packages/@react-aria/toast/intl/ar-AE.json | 5 +- packages/@react-aria/toast/intl/bg-BG.json | 5 +- packages/@react-aria/toast/intl/cs-CZ.json | 5 +- packages/@react-aria/toast/intl/da-DK.json | 5 +- packages/@react-aria/toast/intl/de-DE.json | 5 +- packages/@react-aria/toast/intl/el-GR.json | 5 +- packages/@react-aria/toast/intl/en-US.json | 4 +- packages/@react-aria/toast/intl/es-ES.json | 5 +- packages/@react-aria/toast/intl/et-EE.json | 5 +- packages/@react-aria/toast/intl/fi-FI.json | 5 +- packages/@react-aria/toast/intl/fr-FR.json | 5 +- packages/@react-aria/toast/intl/he-IL.json | 4 +- packages/@react-aria/toast/intl/hr-HR.json | 5 +- packages/@react-aria/toast/intl/hu-HU.json | 5 +- packages/@react-aria/toast/intl/it-IT.json | 5 +- packages/@react-aria/toast/intl/ja-JP.json | 5 +- packages/@react-aria/toast/intl/ko-KR.json | 5 +- packages/@react-aria/toast/intl/lt-LT.json | 5 +- packages/@react-aria/toast/intl/lv-LV.json | 5 +- packages/@react-aria/toast/intl/nb-NO.json | 5 +- packages/@react-aria/toast/intl/nl-NL.json | 5 +- packages/@react-aria/toast/intl/pl-PL.json | 5 +- packages/@react-aria/toast/intl/pt-BR.json | 5 +- packages/@react-aria/toast/intl/pt-PT.json | 5 +- packages/@react-aria/toast/intl/ro-RO.json | 5 +- packages/@react-aria/toast/intl/ru-RU.json | 5 +- packages/@react-aria/toast/intl/sk-SK.json | 5 +- packages/@react-aria/toast/intl/sl-SI.json | 5 +- packages/@react-aria/toast/intl/sr-SP.json | 5 +- packages/@react-aria/toast/intl/sv-SE.json | 5 +- packages/@react-aria/toast/intl/tr-TR.json | 5 +- packages/@react-aria/toast/intl/uk-UA.json | 5 +- packages/@react-aria/toast/intl/zh-CN.json | 5 +- packages/@react-aria/toast/intl/zh-TW.json | 5 +- packages/@react-aria/toast/package.json | 3 +- packages/@react-aria/toast/src/index.ts | 4 + packages/@react-aria/toast/src/useToast.ts | 100 +++---- .../@react-aria/toast/src/useToastRegion.ts | 39 +++ .../@react-aria/toast/stories/Example.tsx | 55 ++++ .../toast/stories/useToast.stories.tsx | 33 +++ .../@react-aria/toast/test/useToast.test.js | 60 +--- .../toast/chromatic/Toast.chromatic.tsx | 53 ++++ .../@react-spectrum/toast/intl/ar-AE.json | 5 + .../@react-spectrum/toast/intl/bg-BG.json | 5 + .../@react-spectrum/toast/intl/cs-CZ.json | 5 + .../@react-spectrum/toast/intl/da-DK.json | 5 + .../@react-spectrum/toast/intl/de-DE.json | 5 + .../@react-spectrum/toast/intl/el-GR.json | 5 + .../@react-spectrum/toast/intl/en-US.json | 5 + .../@react-spectrum/toast/intl/es-ES.json | 5 + .../@react-spectrum/toast/intl/et-EE.json | 5 + .../@react-spectrum/toast/intl/fi-FI.json | 5 + .../@react-spectrum/toast/intl/fr-FR.json | 5 + .../@react-spectrum/toast/intl/he-IL.json | 5 + .../@react-spectrum/toast/intl/hr-HR.json | 5 + .../@react-spectrum/toast/intl/hu-HU.json | 5 + .../@react-spectrum/toast/intl/it-IT.json | 5 + .../@react-spectrum/toast/intl/ja-JP.json | 5 + .../@react-spectrum/toast/intl/ko-KR.json | 5 + .../@react-spectrum/toast/intl/lt-LT.json | 5 + .../@react-spectrum/toast/intl/lv-LV.json | 5 + .../@react-spectrum/toast/intl/nb-NO.json | 5 + .../@react-spectrum/toast/intl/nl-NL.json | 5 + .../@react-spectrum/toast/intl/pl-PL.json | 5 + .../@react-spectrum/toast/intl/pt-BR.json | 5 + .../@react-spectrum/toast/intl/pt-PT.json | 5 + .../@react-spectrum/toast/intl/ro-RO.json | 5 + .../@react-spectrum/toast/intl/ru-RU.json | 5 + .../@react-spectrum/toast/intl/sk-SK.json | 5 + .../@react-spectrum/toast/intl/sl-SI.json | 5 + .../@react-spectrum/toast/intl/sr-SP.json | 5 + .../@react-spectrum/toast/intl/sv-SE.json | 5 + .../@react-spectrum/toast/intl/tr-TR.json | 5 + .../@react-spectrum/toast/intl/uk-UA.json | 5 + .../@react-spectrum/toast/intl/zh-CN.json | 5 + .../@react-spectrum/toast/intl/zh-TW.json | 5 + packages/@react-spectrum/toast/package.json | 3 +- packages/@react-spectrum/toast/src/Toast.tsx | 134 ++++++--- .../toast/src/ToastContainer.tsx | 47 ++- .../toast/src/ToastProvider.tsx | 98 +++++-- packages/@react-spectrum/toast/src/index.ts | 5 +- .../toast/src/toastContainer.css | 85 ++++-- .../toast/stories/Toast.stories.tsx | 102 ++----- .../@react-spectrum/toast/test/Toast.test.js | 137 --------- .../toast/test/ToastContainer.test.js | 269 +++++++++++++++--- packages/@react-stately/toast/package.json | 1 - packages/@react-stately/toast/src/index.ts | 3 +- packages/@react-stately/toast/src/timer.ts | 33 --- .../@react-stately/toast/src/useToastState.ts | 190 ++++++++++--- .../toast/test/useToastState.test.js | 140 +++++---- packages/@react-types/toast/README.md | 3 - packages/@react-types/toast/package.json | 21 -- packages/@react-types/toast/src/index.d.ts | 42 --- 100 files changed, 1213 insertions(+), 874 deletions(-) create mode 100644 packages/@react-aria/toast/src/useToastRegion.ts create mode 100644 packages/@react-aria/toast/stories/Example.tsx create mode 100644 packages/@react-aria/toast/stories/useToast.stories.tsx create mode 100644 packages/@react-spectrum/toast/chromatic/Toast.chromatic.tsx create mode 100644 packages/@react-spectrum/toast/intl/ar-AE.json create mode 100644 packages/@react-spectrum/toast/intl/bg-BG.json create mode 100644 packages/@react-spectrum/toast/intl/cs-CZ.json create mode 100644 packages/@react-spectrum/toast/intl/da-DK.json create mode 100644 packages/@react-spectrum/toast/intl/de-DE.json create mode 100644 packages/@react-spectrum/toast/intl/el-GR.json create mode 100644 packages/@react-spectrum/toast/intl/en-US.json create mode 100644 packages/@react-spectrum/toast/intl/es-ES.json create mode 100644 packages/@react-spectrum/toast/intl/et-EE.json create mode 100644 packages/@react-spectrum/toast/intl/fi-FI.json create mode 100644 packages/@react-spectrum/toast/intl/fr-FR.json create mode 100644 packages/@react-spectrum/toast/intl/he-IL.json create mode 100644 packages/@react-spectrum/toast/intl/hr-HR.json create mode 100644 packages/@react-spectrum/toast/intl/hu-HU.json create mode 100644 packages/@react-spectrum/toast/intl/it-IT.json create mode 100644 packages/@react-spectrum/toast/intl/ja-JP.json create mode 100644 packages/@react-spectrum/toast/intl/ko-KR.json create mode 100644 packages/@react-spectrum/toast/intl/lt-LT.json create mode 100644 packages/@react-spectrum/toast/intl/lv-LV.json create mode 100644 packages/@react-spectrum/toast/intl/nb-NO.json create mode 100644 packages/@react-spectrum/toast/intl/nl-NL.json create mode 100644 packages/@react-spectrum/toast/intl/pl-PL.json create mode 100644 packages/@react-spectrum/toast/intl/pt-BR.json create mode 100644 packages/@react-spectrum/toast/intl/pt-PT.json create mode 100644 packages/@react-spectrum/toast/intl/ro-RO.json create mode 100644 packages/@react-spectrum/toast/intl/ru-RU.json create mode 100644 packages/@react-spectrum/toast/intl/sk-SK.json create mode 100644 packages/@react-spectrum/toast/intl/sl-SI.json create mode 100644 packages/@react-spectrum/toast/intl/sr-SP.json create mode 100644 packages/@react-spectrum/toast/intl/sv-SE.json create mode 100644 packages/@react-spectrum/toast/intl/tr-TR.json create mode 100644 packages/@react-spectrum/toast/intl/uk-UA.json create mode 100644 packages/@react-spectrum/toast/intl/zh-CN.json create mode 100644 packages/@react-spectrum/toast/intl/zh-TW.json delete mode 100644 packages/@react-spectrum/toast/test/Toast.test.js delete mode 100644 packages/@react-stately/toast/src/timer.ts delete mode 100644 packages/@react-types/toast/README.md delete mode 100644 packages/@react-types/toast/package.json delete mode 100644 packages/@react-types/toast/src/index.d.ts 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/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/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 extends AriaLabelingProps { + toast: QueuedToast +} -interface ToastAria { +export interface ToastAria { toastProps: DOMAttributes, - iconProps: ImgHTMLAttributes, - actionButtonProps: PressProps, - closeButtonProps: DOMProps & PressProps + titleProps: DOMAttributes, + descriptionProps: DOMAttributes, + closeButtonProps: AriaButtonProps } -export function useToast(props: ToastAriaProps, state: ToastState): ToastAria { +export function useToast(props: AriaToastProps, state: ToastState): ToastAria { let { - toastKey, - onAction, - onClose, - shouldCloseOnAction, + key, timer, - variant - } = props; - let { - onRemove - } = state; - let stringFormatter = useLocalizedStringFormatter(intlMessages); - let domProps = filterDOMProps(props); + timeout + } = props.toast; - const handleAction = (...args) => { - if (onAction) { - onAction(...args); + useEffect(() => { + if (!timer) { + return; } - if (shouldCloseOnAction) { - onClose && onClose(...args); - onRemove && onRemove(toastKey); - } - }; - - let iconProps = variant ? {'aria-label': stringFormatter.format(variant)} : {}; - - let pauseTimer = () => { - timer && timer.pause(); - }; - - let resumeTimer = () => { - timer && timer.resume(); - }; - - let {hoverProps} = useHover({ - onHoverStart: pauseTimer, - onHoverEnd: resumeTimer - }); + timer.reset(timeout); + return () => { + timer.pause(); + }; + }, [timer, timeout]); - 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-descibedby'] || descriptionId, + 'aria-details': props['aria-details'] + }, + 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..abe68a192ed --- /dev/null +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -0,0 +1,39 @@ +import {AriaLabelingProps, DOMAttributes} from '@react-types/shared'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {RefObject} from 'react'; +import {ToastState} from '@react-stately/toast'; +import {useFocusWithin, useHover} from '@react-aria/interactions'; +import {useLandmark} from '@react-aria/landmark'; +import {useLocalizedStringFormatter} from '@react-aria/i18n'; + +export interface AriaToastRegionProps extends AriaLabelingProps {} +export interface ToastRegionAria { + regionProps: DOMAttributes +} + +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 {focusWithinProps} = useFocusWithin({ + onFocusWithin: state.pauseAll, + onBlurWithin: state.resumeAll + }); + + return { + regionProps: { + ...landmarkProps, + ...hoverProps, + ...focusWithinProps + } + }; +} diff --git a/packages/@react-aria/toast/stories/Example.tsx b/packages/@react-aria/toast/stories/Example.tsx new file mode 100644 index 00000000000..35889077629 --- /dev/null +++ b/packages/@react-aria/toast/stories/Example.tsx @@ -0,0 +1,55 @@ +/* + * 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.toasts.map(toast => ( + + ))} +
+ ); +} + +function Toast(props) { + let state = useContext(ToastContext); + let {toastProps, titleProps, closeButtonProps} = useToast(props, state); + 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..7ca24c889b3 100644 --- a/packages/@react-aria/toast/test/useToast.test.js +++ b/packages/@react-aria/toast/test/useToast.test.js @@ -10,75 +10,35 @@ * 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 {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), {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/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 42d2dc35e37..02e66a106a5 100644 --- a/packages/@react-spectrum/toast/package.json +++ b/packages/@react-spectrum/toast/package.json @@ -37,12 +37,13 @@ "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" }, diff --git a/packages/@react-spectrum/toast/src/Toast.tsx b/packages/@react-spectrum/toast/src/Toast.tsx index e87699511ac..b5c37048c7d 100644 --- a/packages/@react-spectrum/toast/src/Toast.tsx +++ b/packages/@react-spectrum/toast/src/Toast.tsx @@ -15,16 +15,32 @@ import {Button, ClearButton} from '@react-spectrum/button'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import CrossMedium from '@spectrum-icons/ui/CrossMedium'; import {DOMRef} from '@react-types/shared'; +import {FocusScope} from '@react-aria/focus'; 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,57 +49,91 @@ 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 { - actionButtonProps, closeButtonProps, - iconProps, + titleProps, toastProps - } = useToast({...otherProps, variant}, {onRemove}); + } = useToast(props, state); let domRef = useDOMRef(ref); 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 ( -
- {Icon && - - } -
-
{children}
- {actionLabel && - + +
{ + if (e.animationName === toastContainerStyles['fade-out']) { + state.remove(key); + } + }}> + {Icon && + } +
+
{children}
+ {actionLabel && + + } +
+
+ + + +
-
- - - -
-
+ ); } diff --git a/packages/@react-spectrum/toast/src/ToastContainer.tsx b/packages/@react-spectrum/toast/src/ToastContainer.tsx index 1751c812b7d..d42ef56b88d 100644 --- a/packages/@react-spectrum/toast/src/ToastContainer.tsx +++ b/packages/@react-spectrum/toast/src/ToastContainer.tsx @@ -11,42 +11,39 @@ */ import {classNames} from '@react-spectrum/utils'; -import React, {ReactElement} from 'react'; -import {Toast} from './'; +import React, {ReactElement, ReactNode, useRef} from 'react'; import toastContainerStyles from './toastContainer.css'; -import {ToastState} from '@react-types/toast'; -// import {useProvider} from '@react-spectrum/provider'; +import {ToastState} from '@react-stately/toast'; +import {useProvider} from '@react-spectrum/provider'; +import {useToastRegion} from '@react-aria/toast'; -export function ToastContainer(props: ToastState): ReactElement { +interface ToastContainerProps { + children: ReactNode, + state: ToastState +} + +export function ToastContainer(props: ToastContainerProps): ReactElement { let { - onRemove, - toasts + children, + state } = 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} - ) - ); + let provider = useProvider(); + let containerPlacement = provider.scale === 'large' ? 'center' : 'right'; + let ref = useRef(); + let {regionProps} = useToastRegion({}, state, ref); return (
- {renderToasts()} + {children}
); } diff --git a/packages/@react-spectrum/toast/src/ToastProvider.tsx b/packages/@react-spectrum/toast/src/ToastProvider.tsx index 0cb5af61978..efb307b3ad7 100644 --- a/packages/@react-spectrum/toast/src/ToastProvider.tsx +++ b/packages/@react-spectrum/toast/src/ToastProvider.tsx @@ -11,58 +11,94 @@ */ 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'; +import {SpectrumToastValue, Toast} from './Toast'; +import {ToastContainer} from './ToastContainer'; +import {ToastOptions, 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 +export interface SpectrumToastOptions extends Omit { + actionLabel?: ReactNode, + onAction?: () => void, + shouldCloseOnAction?: boolean } -interface ToastProviderProps { +export interface ToastProviderContext { + positive(content: ReactNode, options?: SpectrumToastOptions): void, + negative(content: ReactNode, options?: SpectrumToastOptions): void, + neutral(content: ReactNode, options?: SpectrumToastOptions): void, + info(content: ReactNode, options?: SpectrumToastOptions): void +} + +export interface ToastProviderProps { children: ReactNode } -export const ToastContext = React.createContext(null); +const ToastContext = React.createContext(null); -export function useToastProvider() { +export function useToastProvider(): ToastProviderContext { 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 state = useToastState({ + hasExitAnimation: true + }); + + let add = (children: ReactNode, variant: SpectrumToastValue['variant'], options: SpectrumToastOptions = {}) => { + let value = { + children, + variant, + actionLabel: options.actionLabel, + onAction: options.onAction, + shouldCloseOnAction: options.shouldCloseOnAction + }; + + // https://spectrum.adobe.com/page/toast/#Auto-dismissible + let timeout = options.timeout ? Math.max(options.timeout, 5000) : null; + state.add(value, {priority: getPriority(variant, options), timeout, onClose: options.onClose}); + }; let contextValue = { - neutral: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey()}); + neutral: (children: ReactNode, options: SpectrumToastOptions = {}) => { + add(children, 'neutral', options); }, - positive: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey(), variant: 'positive'}); + positive: (children: ReactNode, options: SpectrumToastOptions = {}) => { + add(children, 'positive', options); }, - negative: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey(), variant: 'negative'}); + negative: (children: ReactNode, options: SpectrumToastOptions = {}) => { + add(children, 'negative', options); }, - info: (content: ReactNode, options: ToastOptions = {}) => { - onAdd(content, {...options, toastKey: generateKey(), variant: 'info'}); + info: (children: ReactNode, options: SpectrumToastOptions = {}) => { + add(children, 'info', options); } }; return ( - - {children} + + {state.toasts.map((toast) => ( + + ))} + + {props.children} ); } + +// https://spectrum.adobe.com/page/toast/#Priority-queue +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/index.ts b/packages/@react-spectrum/toast/src/index.ts index 3d63c9dda54..3ab291e1a31 100644 --- a/packages/@react-spectrum/toast/src/index.ts +++ b/packages/@react-spectrum/toast/src/index.ts @@ -12,6 +12,7 @@ /// -export {ICONS, Toast} from './Toast'; export {ToastContainer} from './ToastContainer'; -export {ToastContext, useToastProvider, ToastProvider} from './ToastProvider'; +export {useToastProvider, ToastProvider} from './ToastProvider'; + +export type {SpectrumToastOptions, ToastProviderContext, ToastProviderProps} from './ToastProvider'; diff --git a/packages/@react-spectrum/toast/src/toastContainer.css b/packages/@react-spectrum/toast/src/toastContainer.css index 53f12649245..4335f636689 100644 --- a/packages/@react-spectrum/toast/src/toastContainer.css +++ b/packages/@react-spectrum/toast/src/toastContainer.css @@ -12,36 +12,83 @@ .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; .spectrum-Toast { + position: absolute; margin: 8px; 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..ffbc9a4b2b8 100644 --- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx +++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx @@ -12,106 +12,64 @@ import {action} from '@storybook/addon-actions'; import {Button} from '@react-spectrum/button'; +import {ButtonGroup} from '@react-spectrum/buttongroup'; import React from 'react'; +import {SpectrumToastOptions} from '../src/ToastProvider'; import {storiesOf} from '@storybook/react'; -import {Toast} from '../'; -import {ToastProps} from '@react-types/toast'; import {ToastProvider, useToastProvider} from '../'; +// TODO: dialog stories with toasts, make toast go to top provider + storiesOf('Toast', module) + .addParameters({ + args: { + shouldCloseOnAction: false, + timeout: null + }, + argTypes: { + timeout: { + control: { + type: 'radio', + options: [null, 5000] + } + } + } + }) .add( 'Default', - () => render({onClose: action('onClose')}, 'Toast is done.') - ) - .add( - 'variant = info', - () => render({variant: 'info', onClose: action('onClose')}, 'Toast is happening.') - ) - .add( - 'variant = positive', - () => render({variant: 'positive', onClose: action('onClose')}, 'Toast is perfect.') - ) - .add( - 'variant = Negative', - () => render({variant: 'negative', onClose: action('onClose')}, 'Toast is not done.') - ) - .add( - 'actionable', - () => render({actionLabel: 'Undo', onAction: action('onAction'), onClose: action('onClose')}, 'Untoast the toast') + args => ) .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} - + 'With action', + args => ); -} -function RenderProvider() { +function RenderProvider(options: SpectrumToastOptions) { let toastContext = useToastProvider(); return ( -
+ -
- ); -} - -function RenderProviderTimers() { - let toastContext = useToastProvider(); - - return ( -
- - - - -
+ ); } 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..02a5bbef82e 100644 --- a/packages/@react-spectrum/toast/test/ToastContainer.test.js +++ b/packages/@react-spectrum/toast/test/ToastContainer.test.js @@ -10,11 +10,13 @@ * 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 {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 {ToastProvider, useToastProvider} from '../'; +import userEvent from '@testing-library/user-event'; function RenderToastButton(props = {}) { let toastContext = useToastProvider(); @@ -22,7 +24,7 @@ function RenderToastButton(props = {}) { return (
@@ -31,52 +33,255 @@ function RenderToastButton(props = {}) { } function renderComponent(contents) { - return render( - {contents} - ); + return render( + + + {contents} + + + ); +} + +function fireAnimationEnd(alert) { + let e = new Event('animationend', {bubbles: true, cancelable: false}); + e.animationName = 'fade-out'; + fireEvent(alert, e); } -describe.skip('Toast Provider and Container', function () { - it('Renders a button that triggers a toast via the provider', async () => { - let {getByRole, queryAllByRole, queryByRole} = renderComponent(); +describe('Toast Provider and Container', function () { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => jest.runAllTimers()); + }); + + it('Renders a button that triggers a toast via the provider', () => { + let {getByRole, queryByRole} = renderComponent(); + let button = getByRole('button'); + + let region = getByRole('region'); + expect(region).toHaveAttribute('aria-label', 'Notifications'); + + expect(queryByRole('alert')).toBeNull(); + triggerPress(button); + + 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'); - expect(() => { - queryByRole('alert'); - }).toBeNull(); + triggerPress(button); + + 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('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); - expect(queryAllByRole('alert').length).toBe(1); - expect(getByRole('alert')).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).not.toHaveBeenCalled(); }); - it('get position from provider', async () => { - let {getByTestId} = renderComponent( - - Toast - ); + 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); + + 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 = {}) { + let toastContext = useToastProvider(); + + 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(); + + // 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'); - let className = getByTestId('testId1').className; - expect(className.includes('react-spectrum-ToastContainer--top')).toBeTruthy(); - expect(className.includes('react-spectrum-ToastContainer--left')).toBeTruthy(); + triggerPress(within(alert).getByRole('button')); + fireAnimationEnd(alert); + expect(queryByRole('alert')).toBeNull(); }); - it('removes a toast via timeout', async () => { - let {getByRole, queryByRole} = renderComponent(); + it('can focus toast region using F6', () => { + let {getByRole} = renderComponent(); let button = getByRole('button'); triggerPress(button); - // confirm toast is there, wait for it disappear, then confirm it is gone - let toasts = getByRole('alert'); - expect(toasts).toBeVisible(); + let toast = getByRole('alert'); + expect(toast).toBeVisible(); - await waitFor(() => { - expect(() => { - queryByRole('alert'); - }).toBeNull(); - }); + expect(document.activeElement).toBe(button); + fireEvent.keyDown(button, {key: 'F6'}); + fireEvent.keyUp(button, {key: 'F6'}); + let region = getByRole('region'); + expect(document.activeElement).toBe(region); }); }); diff --git a/packages/@react-stately/toast/package.json b/packages/@react-stately/toast/package.json index 083301f0226..03cbb0d1c0e 100644 --- a/packages/@react-stately/toast/package.json +++ b/packages/@react-stately/toast/package.json @@ -23,7 +23,6 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-types/toast": "3.0.0-alpha.1", "@swc/helpers": "^0.4.14" }, "peerDependencies": { diff --git a/packages/@react-stately/toast/src/index.ts b/packages/@react-stately/toast/src/index.ts index ae805488072..b6183250c87 100644 --- a/packages/@react-stately/toast/src/index.ts +++ b/packages/@react-stately/toast/src/index.ts @@ -10,4 +10,5 @@ * governing permissions and limitations under the License. */ export {useToastState} from './useToastState'; -export {Timer} from './timer'; + +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..5cc3ea49a5b 100644 --- a/packages/@react-stately/toast/src/useToastState.ts +++ b/packages/@react-stately/toast/src/useToastState.ts @@ -10,57 +10,173 @@ * 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 {useMemo, useState} from 'react'; -interface ToastStateProps { - value?: ToastStateValue[] +export interface ToastStateProps { + maxVisibleToasts?: number, + hasExitAnimation?: boolean } -const TOAST_TIMEOUT = 5000; +export interface ToastOptions { + onClose?: () => void, + timeout?: number, + priority?: number +} -export function useToastState(props?: ToastStateProps): ToastState { - const [toasts, setToasts] = useState(props && props.value || []); - const toastsRef = useRef(toasts); - toastsRef.current = toasts; +export interface QueuedToast extends ToastOptions { + content: T, + key: string, + timer?: Timer, + animation?: 'entering' | 'queued' | 'exiting' +} - const onAdd = (content: ReactNode, options: ToastProps) => { - let tempToasts = [...toasts]; - let timer; +export interface ToastState { + add(content: T, options?: ToastOptions): void, + close(key: string): void, + remove(key: string): void, + pauseAll(): void, + resumeAll(): void, + toasts: QueuedToast[] +} - // set timer to remove toasts - if (!(options.actionLabel || options.timeout === 0)) { - if (options.timeout < 0) { - options.timeout = TOAST_TIMEOUT; - } - timer = new Timer(() => onRemove(options.toastKey), options.timeout || TOAST_TIMEOUT); - } +export function useToastState(props: ToastStateProps = {}): ToastState { + let {maxVisibleToasts = 1, hasExitAnimation} = props; + let queue = useMemo(() => new ToastQueue(maxVisibleToasts), [maxVisibleToasts]); + let [visibleToasts, setVisibleToasts] = useState[]>([]); - tempToasts.push({ - content, - props: options, - timer + let setCurrentToasts = (toasts: QueuedToast[]) => { + setVisibleToasts(visibleToasts => { + if (hasExitAnimation) { + let prevToasts: QueuedToast[] = visibleToasts + .filter(t => !toasts.some(t2 => t.key === t2.key)) + .map(t => ({...t, animation: 'exiting'})); + + return prevToasts.concat(toasts).sort((a, b) => b.priority - a.priority); + } else { + return toasts; + } }); - setToasts(tempToasts); + }; + const add = (content: T, options: ToastOptions = {}) => { + let toastKey = Math.random().toString(36); + let timer = options.timeout ? new Timer(() => close(toastKey), options.timeout) : null; + queue.add({content, key: toastKey, timer, ...options}); + setCurrentToasts(queue.getVisibleToasts()); }; - const onRemove = (toastKey: string) => { - let tempToasts = [...toastsRef.current].filter(item => { - if (item.props.toastKey === toastKey && item.timer) { - item.timer.clear(); - } - return item.props.toastKey !== toastKey; - }); - setToasts(tempToasts); + const close = (toastKey: string) => { + queue.remove(toastKey); + setCurrentToasts(queue.getVisibleToasts()); + }; + + let remove = (toastKey: string) => { + setVisibleToasts(visibleToasts => visibleToasts.filter(t => t.key !== toastKey)); }; return { - onAdd, - onRemove, - setToasts, - toasts + add, + close, + remove, + toasts: visibleToasts, + pauseAll: () => { + for (let toast of visibleToasts) { + if (toast.timer) { + toast.timer.pause(); + } + } + }, + resumeAll: () => { + for (let toast of visibleToasts) { + if (toast.timer) { + toast.timer.resume(); + } + } + } }; } + +class ToastQueue { + queue: QueuedToast[] = []; + maxVisibleToasts: number; + + constructor(maxVisibleToasts: number) { + this.maxVisibleToasts = maxVisibleToasts; + } + + add(toast: QueuedToast) { + 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; + } + } + + 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'; + } + + return low; + } + + remove(key: string) { + let index = this.queue.findIndex(t => t.key === key); + if (index >= 0) { + this.queue[index].onClose?.(); + this.queue.splice(index, 1); + } + } + + getVisibleToasts(): QueuedToast[] { + return this.queue.slice(0, this.maxVisibleToasts); + } +} + +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..7ed28881fda 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); + act(() => result.current.add(newValue[0].content, newValue[0].props)); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].content).toBe(newValue[0].content); + expect(result.current.toasts[0].animation).toBe('entering'); + expect(result.current.toasts[0].timeout).toBe(0); + expect(result.current.toasts[0].timer).toBe(null); + expect(result.current.toasts[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}]); + act(() => result.current.add('Test', {timeout: 5000})); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].content).toBe('Test'); + expect(result.current.toasts[0].animation).toBe('entering'); + expect(result.current.toasts[0].timeout).toBe(5000); + expect(result.current.toasts[0].timer).not.toBe(null); + expect(result.current.toasts[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()); + let {result} = renderHook(() => useToastState({maxVisibleToasts: 2})); expect(result.current.toasts).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.toasts[0].content).toBe(newValue[0].content); - act(() => result.current.onAdd(secondToast.content, secondToast.props)); + act(() => result.current.add(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}); + expect(result.current.toasts[0].content).toBe(newValue[0].content); + expect(result.current.toasts[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)); + act(() => result.current.close(result.current.toasts[0].key)); expect(result.current.toasts).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.toasts[0].key)); + expect(result.current.toasts.length).toBe(1); + expect(result.current.toasts[0].animation).toBe('exiting'); + + act(() => result.current.remove(result.current.toasts[0].key)); + expect(result.current.toasts).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.toasts).toStrictEqual([]); - act(() => jest.runAllTimers()); + act(() => result.current.add(newValue[0].content, newValue[0].props)); + expect(result.current.toasts[0].content).toBe(newValue[0].content); - expect(result.current.toasts.length).toEqual(0); + act(() => result.current.add('Second Toast')); + expect(result.current.toasts.length).toBe(1); + expect(result.current.toasts[0].content).toBe(newValue[0].content); + + act(() => result.current.close(result.current.toasts[0].key)); + expect(result.current.toasts.length).toBe(1); + expect(result.current.toasts[0].content).toBe('Second Toast'); + expect(result.current.toasts[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.toasts).toStrictEqual([]); + + act(() => result.current.add(newValue[0].content, newValue[0].props)); + expect(result.current.toasts[0].content).toBe(newValue[0].content); + + act(() => result.current.add('Second Toast', {priority: 1})); + expect(result.current.toasts.length).toBe(1); + expect(result.current.toasts[0].content).toBe('Second Toast'); + + act(() => result.current.close(result.current.toasts[0].key)); + expect(result.current.toasts.length).toBe(1); + expect(result.current.toasts[0].content).toBe(newValue[0].content); + expect(result.current.toasts[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 -} From 335d3ffb26af42f3305807b4ca6dc562959e1645 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 2 Dec 2022 16:29:40 -0800 Subject: [PATCH 02/27] Only render one toast provider --- packages/@react-spectrum/toast/src/ToastProvider.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/@react-spectrum/toast/src/ToastProvider.tsx b/packages/@react-spectrum/toast/src/ToastProvider.tsx index efb307b3ad7..648371ddbaf 100644 --- a/packages/@react-spectrum/toast/src/ToastProvider.tsx +++ b/packages/@react-spectrum/toast/src/ToastProvider.tsx @@ -39,6 +39,16 @@ export function useToastProvider(): ToastProviderContext { } export function ToastProvider(props: ToastProviderProps): ReactElement { + // If there's already a ToastProvider above us in the React tree, don't render another one. + let ctx = useToastProvider(); + if (ctx) { + return <>{props.children}; + } + + return ; +} + +function ToastProviderInner(props: ToastProviderProps) { let state = useToastState({ hasExitAnimation: true }); From 6b8411eacd7c637e8028f8ac71f9df14f7466785 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 2 Dec 2022 16:41:01 -0800 Subject: [PATCH 03/27] Make it work with dialogs --- packages/@react-aria/focus/src/FocusScope.tsx | 5 +++ .../interactions/src/useInteractOutside.ts | 7 +++- .../overlays/src/ariaHideOutside.ts | 22 +++++++---- .../@react-aria/toast/src/useToastRegion.ts | 8 +++- packages/@react-spectrum/toast/package.json | 1 + .../toast/src/ToastContainer.tsx | 31 ++++++++------- .../toast/stories/Toast.stories.tsx | 38 +++++++++++++++++++ 7 files changed, 89 insertions(+), 23 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index bd84a79c577..6826ff2c459 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -365,6 +365,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.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/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 8306879b783..d207ad7ac53 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -26,16 +26,17 @@ let observerStack = []; export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(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/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index abe68a192ed..a2cd1b5c56c 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -33,7 +33,13 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState regionProps: { ...landmarkProps, ...hoverProps, - ...focusWithinProps + ...focusWithinProps, + // 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-spectrum/toast/package.json b/packages/@react-spectrum/toast/package.json index 02e66a106a5..0ab6332ef43 100644 --- a/packages/@react-spectrum/toast/package.json +++ b/packages/@react-spectrum/toast/package.json @@ -53,6 +53,7 @@ }, "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/ToastContainer.tsx b/packages/@react-spectrum/toast/src/ToastContainer.tsx index d42ef56b88d..c3deef28c0c 100644 --- a/packages/@react-spectrum/toast/src/ToastContainer.tsx +++ b/packages/@react-spectrum/toast/src/ToastContainer.tsx @@ -11,10 +11,11 @@ */ import {classNames} from '@react-spectrum/utils'; +import {Provider, useProvider} 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'; -import {useProvider} from '@react-spectrum/provider'; import {useToastRegion} from '@react-aria/toast'; interface ToastContainerProps { @@ -33,17 +34,21 @@ export function ToastContainer(props: ToastContainerProps): ReactElement { let ref = useRef(); let {regionProps} = useToastRegion({}, state, ref); - return ( -
- {children} -
+ let contents = ( + +
+ {children} +
+
); + + return ReactDOM.createPortal(contents, document.body); } diff --git a/packages/@react-spectrum/toast/stories/Toast.stories.tsx b/packages/@react-spectrum/toast/stories/Toast.stories.tsx index ffbc9a4b2b8..97a12695fe4 100644 --- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx +++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx @@ -13,6 +13,9 @@ import {action} from '@storybook/addon-actions'; import {Button} from '@react-spectrum/button'; import {ButtonGroup} from '@react-spectrum/buttongroup'; +import {Content} from '@react-spectrum/view'; +import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; +import {Heading} from '@react-spectrum/text'; import React from 'react'; import {SpectrumToastOptions} from '../src/ToastProvider'; import {storiesOf} from '@storybook/react'; @@ -42,6 +45,41 @@ storiesOf('Toast', module) .add( 'With action', args => + ) + .add( + 'With dialog', + args => ( + + + + + Toasty + + + + + + + ) + ) + .add( + 'nested ToastProvider', + args => ( + +
+
+

Outer

+ +
+ +
+

Inner

+ +
+
+
+
+ ) ); function RenderProvider(options: SpectrumToastOptions) { From 6a84bfdde0817a13cc68de6564f7585d9c5fe11d Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 2 Dec 2022 16:55:29 -0800 Subject: [PATCH 04/27] todos --- packages/@react-spectrum/toast/src/Toast.tsx | 1 + packages/@react-spectrum/toast/src/ToastProvider.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/toast/src/Toast.tsx b/packages/@react-spectrum/toast/src/Toast.tsx index b5c37048c7d..0a07d27e744 100644 --- a/packages/@react-spectrum/toast/src/Toast.tsx +++ b/packages/@react-spectrum/toast/src/Toast.tsx @@ -85,6 +85,7 @@ function Toast(props: SpectrumToastProps, ref: DOMRef) { } }; + // TODO: if this isn't the last toast, move to the next one when closing instead? return (
{ add(children, 'neutral', options); @@ -98,6 +101,7 @@ function ToastProviderInner(props: ToastProviderProps) { } // 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, From 3dff3199a0a3ab8baf786c40358e294df48b53cc Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 12 Dec 2022 13:40:35 -0800 Subject: [PATCH 05/27] Improve focus management --- .../components/commons/focus-ring.css | 18 +++- packages/@react-aria/toast/src/useToast.ts | 22 ++++- .../@react-aria/toast/src/useToastRegion.ts | 26 ++++- .../@react-aria/toast/test/useToast.test.js | 3 +- packages/@react-spectrum/toast/package.json | 1 + packages/@react-spectrum/toast/src/Toast.tsx | 96 +++++++++---------- .../toast/src/ToastProvider.tsx | 18 ++-- .../toast/src/toastContainer.css | 14 +++ .../toast/test/ToastContainer.test.js | 45 ++++++++- 9 files changed, 169 insertions(+), 74 deletions(-) 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/@react-aria/toast/src/useToast.ts b/packages/@react-aria/toast/src/useToast.ts index 68993001656..479bfa00b00 100644 --- a/packages/@react-aria/toast/src/useToast.ts +++ b/packages/@react-aria/toast/src/useToast.ts @@ -11,12 +11,12 @@ */ import {AriaButtonProps} from '@react-types/button'; -import {AriaLabelingProps, DOMAttributes} from '@react-types/shared'; +import {AriaLabelingProps, DOMAttributes, FocusableElement} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; import {QueuedToast, ToastState} from '@react-stately/toast'; -import {useEffect} from 'react'; -import {useId, useSlotId} from '@react-aria/utils'; +import {RefObject, useEffect} from 'react'; +import {useId, useLayoutEffect, useSlotId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; export interface AriaToastProps extends AriaLabelingProps { @@ -30,7 +30,7 @@ export interface ToastAria { closeButtonProps: AriaButtonProps } -export function useToast(props: AriaToastProps, state: ToastState): ToastAria { +export function useToast(props: AriaToastProps, state: ToastState, ref: RefObject): ToastAria { let { key, timer, @@ -48,6 +48,20 @@ export function useToast(props: AriaToastProps, state: ToastState): Toa }; }, [timer, timeout]); + // 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. + useLayoutEffect(() => { + let container = ref.current.closest('[role=region]') as HTMLElement; + return () => { + if (container && container.contains(document.activeElement)) { + container.focus(); + } + }; + }, [ref]); + + let titleId = useId(); let descriptionId = useSlotId(); let stringFormatter = useLocalizedStringFormatter(intlMessages); diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index a2cd1b5c56c..690c9253b09 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -1,7 +1,7 @@ import {AriaLabelingProps, DOMAttributes} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {RefObject} from 'react'; +import {RefObject, useEffect, useRef} from 'react'; import {ToastState} from '@react-stately/toast'; import {useFocusWithin, useHover} from '@react-aria/interactions'; import {useLandmark} from '@react-aria/landmark'; @@ -24,16 +24,36 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState onHoverEnd: state.resumeAll }); + let lastFocused = useRef(null); let {focusWithinProps} = useFocusWithin({ - onFocusWithin: state.pauseAll, - onBlurWithin: state.resumeAll + 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)) { + lastFocused.current.focus(); + } + }; + }, [ref]); + return { regionProps: { ...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 diff --git a/packages/@react-aria/toast/test/useToast.test.js b/packages/@react-aria/toast/test/useToast.test.js index 7ca24c889b3..81ade7cdba9 100644 --- a/packages/@react-aria/toast/test/useToast.test.js +++ b/packages/@react-aria/toast/test/useToast.test.js @@ -11,6 +11,7 @@ */ import {renderHook} from '@react-spectrum/test-utils'; +import {useRef} from 'react'; import {useToast} from '../'; describe('useToast', () => { @@ -21,7 +22,7 @@ describe('useToast', () => { }); let renderToastHook = (toast, state, wrapper) => { - let {result} = renderHook(() => useToast({toast}, state), {wrapper}); + let {result} = renderHook(() => useToast({toast}, state, useRef(document.createElement('div'))), {wrapper}); return result.current; }; diff --git a/packages/@react-spectrum/toast/package.json b/packages/@react-spectrum/toast/package.json index 0ab6332ef43..baca09abce5 100644 --- a/packages/@react-spectrum/toast/package.json +++ b/packages/@react-spectrum/toast/package.json @@ -40,6 +40,7 @@ "@react-aria/focus": "^3.10.0", "@react-aria/i18n": "^3.6.2", "@react-aria/toast": "3.0.0-alpha.1", + "@react-aria/utils": "^3.14.1", "@react-spectrum/button": "^3.1.0", "@react-spectrum/utils": "^3.1.0", "@react-stately/toast": "3.0.0-alpha.1", diff --git a/packages/@react-spectrum/toast/src/Toast.tsx b/packages/@react-spectrum/toast/src/Toast.tsx index 0a07d27e744..a9099774c1a 100644 --- a/packages/@react-spectrum/toast/src/Toast.tsx +++ b/packages/@react-spectrum/toast/src/Toast.tsx @@ -15,7 +15,6 @@ import {Button, ClearButton} from '@react-spectrum/button'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import CrossMedium from '@spectrum-icons/ui/CrossMedium'; import {DOMRef} from '@react-types/shared'; -import {FocusScope} from '@react-aria/focus'; import InfoMedium from '@spectrum-icons/ui/InfoMedium'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -63,12 +62,12 @@ function Toast(props: SpectrumToastProps, ref: DOMRef) { state, ...otherProps } = props; + let domRef = useDOMRef(ref); let { closeButtonProps, titleProps, toastProps - } = useToast(props, state); - let domRef = useDOMRef(ref); + } = useToast(props, state, domRef); let {styleProps} = useStyleProps(otherProps); let stringFormatter = useLocalizedStringFormatter(intlMessages); @@ -85,56 +84,53 @@ function Toast(props: SpectrumToastProps, ref: DOMRef) { } }; - // TODO: if this isn't the last toast, move to the next one when closing instead? return ( - -
{ - if (e.animationName === toastContainerStyles['fade-out']) { - state.remove(key); - } - }}> - {Icon && - +
{ + if (e.animationName === toastContainerStyles['fade-out']) { + state.remove(key); } -
-
{children}
- {actionLabel && - - } -
-
- - - -
+ }}> + {Icon && + + } +
+
{children}
+ {actionLabel && + + } +
+
+ + +
- +
); } diff --git a/packages/@react-spectrum/toast/src/ToastProvider.tsx b/packages/@react-spectrum/toast/src/ToastProvider.tsx index 54ff309604e..36484db3b55 100644 --- a/packages/@react-spectrum/toast/src/ToastProvider.tsx +++ b/packages/@react-spectrum/toast/src/ToastProvider.tsx @@ -87,15 +87,17 @@ function ToastProviderInner(props: ToastProviderProps) { return ( - - {state.toasts.map((toast) => ( - - ))} - {props.children} + {state.toasts.length > 0 && + + {state.toasts.map((toast) => ( + + ))} + + } ); } diff --git a/packages/@react-spectrum/toast/src/toastContainer.css b/packages/@react-spectrum/toast/src/toastContainer.css index 4335f636689..0a306dc229e 100644 --- a/packages/@react-spectrum/toast/src/toastContainer.css +++ b/packages/@react-spectrum/toast/src/toastContainer.css @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +@import "../../../@adobe/spectrum-css-temp/components/commons/focus-ring.css"; + .react-spectrum-ToastContainer { position: fixed; inset-inline-start: 0; @@ -17,6 +19,18 @@ z-index: 100050; /* above modals */ display: flex; 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 > :first-child { + @inherit %spectrum-FocusRing-active; + } .spectrum-Toast { position: absolute; diff --git a/packages/@react-spectrum/toast/test/ToastContainer.test.js b/packages/@react-spectrum/toast/test/ToastContainer.test.js index 02a5bbef82e..ec3b6eed23a 100644 --- a/packages/@react-spectrum/toast/test/ToastContainer.test.js +++ b/packages/@react-spectrum/toast/test/ToastContainer.test.js @@ -61,12 +61,12 @@ describe('Toast Provider and Container', function () { let {getByRole, queryByRole} = renderComponent(); let button = getByRole('button'); - let region = getByRole('region'); - expect(region).toHaveAttribute('aria-label', 'Notifications'); - expect(queryByRole('alert')).toBeNull(); triggerPress(button); + let region = getByRole('region'); + expect(region).toHaveAttribute('aria-label', 'Notifications'); + let alert = getByRole('alert'); expect(alert).toBeVisible(); @@ -284,4 +284,43 @@ describe('Toast Provider and Container', function () { 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); + }); }); From 9f5989539ee4ae72715125978a94d690fbedf705 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 13 Dec 2022 14:05:12 -0800 Subject: [PATCH 06/27] Support programmatically closing toasts --- .../toast/src/ToastProvider.tsx | 37 ++++++++++--------- .../toast/stories/Toast.stories.tsx | 21 +++++++++-- .../toast/test/ToastContainer.test.js | 29 ++++++++++++++- .../@react-stately/toast/src/useToastState.ts | 3 +- 4 files changed, 67 insertions(+), 23 deletions(-) diff --git a/packages/@react-spectrum/toast/src/ToastProvider.tsx b/packages/@react-spectrum/toast/src/ToastProvider.tsx index 36484db3b55..b5729289a7a 100644 --- a/packages/@react-spectrum/toast/src/ToastProvider.tsx +++ b/packages/@react-spectrum/toast/src/ToastProvider.tsx @@ -21,11 +21,12 @@ export interface SpectrumToastOptions extends Omit { shouldCloseOnAction?: boolean } +type CloseFunction = () => void; export interface ToastProviderContext { - positive(content: ReactNode, options?: SpectrumToastOptions): void, - negative(content: ReactNode, options?: SpectrumToastOptions): void, - neutral(content: ReactNode, options?: SpectrumToastOptions): void, - info(content: ReactNode, options?: SpectrumToastOptions): void + positive(content: ReactNode, options?: SpectrumToastOptions): CloseFunction, + negative(content: ReactNode, options?: SpectrumToastOptions): CloseFunction, + neutral(content: ReactNode, options?: SpectrumToastOptions): CloseFunction, + info(content: ReactNode, options?: SpectrumToastOptions): CloseFunction } export interface ToastProviderProps { @@ -66,23 +67,23 @@ function ToastProviderInner(props: ToastProviderProps) { // 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; - state.add(value, {priority: getPriority(variant, options), timeout, onClose: options.onClose}); + let key = state.add(value, {priority: getPriority(variant, options), timeout, onClose: options.onClose}); + return () => state.close(key); }; - // TODO: return a function to allow programmatically closing the toast? let contextValue = { - neutral: (children: ReactNode, options: SpectrumToastOptions = {}) => { - add(children, 'neutral', options); - }, - positive: (children: ReactNode, options: SpectrumToastOptions = {}) => { - add(children, 'positive', options); - }, - negative: (children: ReactNode, options: SpectrumToastOptions = {}) => { - add(children, 'negative', options); - }, - info: (children: ReactNode, options: SpectrumToastOptions = {}) => { - add(children, 'info', options); - } + neutral: (children: ReactNode, options: SpectrumToastOptions = {}) => ( + add(children, 'neutral', options) + ), + positive: (children: ReactNode, options: SpectrumToastOptions = {}) => ( + add(children, 'positive', options) + ), + negative: (children: ReactNode, options: SpectrumToastOptions = {}) => ( + add(children, 'negative', options) + ), + info: (children: ReactNode, options: SpectrumToastOptions = {}) => ( + add(children, 'info', options) + ) }; return ( diff --git a/packages/@react-spectrum/toast/stories/Toast.stories.tsx b/packages/@react-spectrum/toast/stories/Toast.stories.tsx index 97a12695fe4..d13cbaa620f 100644 --- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx +++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx @@ -16,13 +16,11 @@ import {ButtonGroup} from '@react-spectrum/buttongroup'; import {Content} from '@react-spectrum/view'; import {Dialog, DialogTrigger} from '@react-spectrum/dialog'; import {Heading} from '@react-spectrum/text'; -import React from 'react'; +import React, {useState} from 'react'; import {SpectrumToastOptions} from '../src/ToastProvider'; import {storiesOf} from '@storybook/react'; import {ToastProvider, useToastProvider} from '../'; -// TODO: dialog stories with toasts, make toast go to top provider - storiesOf('Toast', module) .addParameters({ args: { @@ -80,6 +78,10 @@ storiesOf('Toast', module)
) + ) + .add( + 'programmatically closing', + args => ); function RenderProvider(options: SpectrumToastOptions) { @@ -111,3 +113,16 @@ function RenderProvider(options: SpectrumToastOptions) { ); } + +function ToastToggle(options: SpectrumToastOptions) { + let toastContext = useToastProvider(); + let [close, setClose] = useState(null); + + return ( + + ); +} diff --git a/packages/@react-spectrum/toast/test/ToastContainer.test.js b/packages/@react-spectrum/toast/test/ToastContainer.test.js index ec3b6eed23a..d85e33134c5 100644 --- a/packages/@react-spectrum/toast/test/ToastContainer.test.js +++ b/packages/@react-spectrum/toast/test/ToastContainer.test.js @@ -14,7 +14,7 @@ import {act, fireEvent, render, triggerPress, within} from '@react-spectrum/test import {Button} from '@react-spectrum/button'; import {defaultTheme} from '@adobe/react-spectrum'; import {Provider} from '@react-spectrum/provider'; -import React from 'react'; +import React, {useState} from 'react'; import {ToastProvider, useToastProvider} from '../'; import userEvent from '@testing-library/user-event'; @@ -323,4 +323,31 @@ describe('Toast Provider and Container', function () { expect(queryByRole('alert')).toBeNull(); expect(document.activeElement).toBe(button); }); + + it('should support programmatically closing toasts', () => { + function ToastToggle() { + let toastContext = useToastProvider(); + 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(); + }); }); diff --git a/packages/@react-stately/toast/src/useToastState.ts b/packages/@react-stately/toast/src/useToastState.ts index 5cc3ea49a5b..b845d2af757 100644 --- a/packages/@react-stately/toast/src/useToastState.ts +++ b/packages/@react-stately/toast/src/useToastState.ts @@ -31,7 +31,7 @@ export interface QueuedToast extends ToastOptions { } export interface ToastState { - add(content: T, options?: ToastOptions): void, + add(content: T, options?: ToastOptions): string, close(key: string): void, remove(key: string): void, pauseAll(): void, @@ -64,6 +64,7 @@ export function useToastState(props: ToastStateProps = {}): ToastState { queue.add({content, key: toastKey, timer, ...options}); setCurrentToasts(queue.getVisibleToasts()); + return toastKey; }; const close = (toastKey: string) => { From ea014f062458c4c328f67cca65dd3751383b06de Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 13 Dec 2022 14:22:17 -0800 Subject: [PATCH 07/27] Fix focus ring --- packages/@react-aria/toast/src/useToast.ts | 15 +++++++++-- .../toast/src/ToastContainer.tsx | 25 +++++++++++-------- .../toast/src/toastContainer.css | 2 +- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/@react-aria/toast/src/useToast.ts b/packages/@react-aria/toast/src/useToast.ts index 479bfa00b00..e6308f26816 100644 --- a/packages/@react-aria/toast/src/useToast.ts +++ b/packages/@react-aria/toast/src/useToast.ts @@ -15,7 +15,7 @@ import {AriaLabelingProps, DOMAttributes, FocusableElement} from '@react-types/s // @ts-ignore import intlMessages from '../intl/*.json'; import {QueuedToast, ToastState} from '@react-stately/toast'; -import {RefObject, useEffect} from 'react'; +import {RefObject, useEffect, useRef} from 'react'; import {useId, useLayoutEffect, useSlotId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -52,15 +52,26 @@ export function useToast(props: AriaToastProps, state: ToastState, ref: // 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)) { - container.focus(); + // 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]); + // eslint-disable-next-line + useEffect(() => { + return () => { + if (focusOnUnmount.current) { + focusOnUnmount.current.focus(); + } + }; + }, [ref]); let titleId = useId(); let descriptionId = useSlotId(); diff --git a/packages/@react-spectrum/toast/src/ToastContainer.tsx b/packages/@react-spectrum/toast/src/ToastContainer.tsx index c3deef28c0c..696fff246df 100644 --- a/packages/@react-spectrum/toast/src/ToastContainer.tsx +++ b/packages/@react-spectrum/toast/src/ToastContainer.tsx @@ -11,6 +11,7 @@ */ import {classNames} from '@react-spectrum/utils'; +import {FocusRing} from '@react-aria/focus'; import {Provider, useProvider} from '@react-spectrum/provider'; import React, {ReactElement, ReactNode, useRef} from 'react'; import ReactDOM from 'react-dom'; @@ -36,17 +37,19 @@ export function ToastContainer(props: ToastContainerProps): ReactElement { let contents = ( -
- {children} -
+ +
+ {children} +
+
); diff --git a/packages/@react-spectrum/toast/src/toastContainer.css b/packages/@react-spectrum/toast/src/toastContainer.css index 0a306dc229e..8e4118fde87 100644 --- a/packages/@react-spectrum/toast/src/toastContainer.css +++ b/packages/@react-spectrum/toast/src/toastContainer.css @@ -28,7 +28,7 @@ --spectrum-focus-ring-size: var(--spectrum-alias-focus-ring-size); } - &:focus > :first-child { + &:focus-ring > :first-child { @inherit %spectrum-FocusRing-active; } From a38ddb9aa9527b815d93052d54613d6551d9d20b Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 13 Dec 2022 14:38:33 -0800 Subject: [PATCH 08/27] Hide toasts that are animating out so VoiceOver doesn't announce them. --- packages/@react-aria/toast/src/useToast.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/toast/src/useToast.ts b/packages/@react-aria/toast/src/useToast.ts index e6308f26816..9c01bd23e3f 100644 --- a/packages/@react-aria/toast/src/useToast.ts +++ b/packages/@react-aria/toast/src/useToast.ts @@ -34,7 +34,8 @@ export function useToast(props: AriaToastProps, state: ToastState, ref: let { key, timer, - timeout + timeout, + animation } = props.toast; useEffect(() => { @@ -83,7 +84,9 @@ export function useToast(props: AriaToastProps, state: ToastState, ref: 'aria-label': props['aria-label'], 'aria-labelledby': props['aria-labelledby'] || titleId, 'aria-describedby': props['aria-descibedby'] || descriptionId, - 'aria-details': props['aria-details'] + '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 From 2156b260c04c4463ba94d00d63bef7ca9618e213 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 13 Dec 2022 17:07:58 -0800 Subject: [PATCH 09/27] Fix --- packages/@react-aria/focus/src/FocusScope.tsx | 2 +- packages/@react-aria/toast/stories/Example.tsx | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index 6826ff2c459..66e38f7996d 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -366,7 +366,7 @@ 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.closest('[data-react-aria-top-layer]')) { + if (element instanceof Element && element.closest('[data-react-aria-top-layer]')) { return true; } diff --git a/packages/@react-aria/toast/stories/Example.tsx b/packages/@react-aria/toast/stories/Example.tsx index 35889077629..6fbae79a1ed 100644 --- a/packages/@react-aria/toast/stories/Example.tsx +++ b/packages/@react-aria/toast/stories/Example.tsx @@ -42,12 +42,13 @@ function ToastRegion() { function Toast(props) { let state = useContext(ToastContext); - let {toastProps, titleProps, closeButtonProps} = useToast(props, state); + let ref = useRef(null); + let {toastProps, titleProps, closeButtonProps} = useToast(props, state, ref); let buttonRef = useRef(); let {buttonProps} = useButton(closeButtonProps, buttonRef); return ( -
+
{props.toast.content}
From 044495c7c741265029595a6167d7f2434802621a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 13 Dec 2022 17:14:50 -0800 Subject: [PATCH 10/27] Fix 16 --- .../toast/test/useToastState.test.js | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/@react-stately/toast/test/useToastState.test.js b/packages/@react-stately/toast/test/useToastState.test.js index 7ed28881fda..56f0b0b9ebc 100644 --- a/packages/@react-stately/toast/test/useToastState.test.js +++ b/packages/@react-stately/toast/test/useToastState.test.js @@ -23,7 +23,7 @@ describe('useToastState', () => { let {result} = renderHook(() => useToastState()); expect(result.current.toasts).toStrictEqual([]); - act(() => result.current.add(newValue[0].content, newValue[0].props)); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); expect(result.current.toasts).toHaveLength(1); expect(result.current.toasts[0].content).toBe(newValue[0].content); expect(result.current.toasts[0].animation).toBe('entering'); @@ -36,7 +36,7 @@ describe('useToastState', () => { let {result} = renderHook(() => useToastState()); expect(result.current.toasts).toStrictEqual([]); - act(() => result.current.add('Test', {timeout: 5000})); + act(() => {result.current.add('Test', {timeout: 5000});}); expect(result.current.toasts).toHaveLength(1); expect(result.current.toasts[0].content).toBe('Test'); expect(result.current.toasts[0].animation).toBe('entering'); @@ -53,10 +53,10 @@ describe('useToastState', () => { let {result} = renderHook(() => useToastState({maxVisibleToasts: 2})); expect(result.current.toasts).toStrictEqual([]); - act(() => result.current.add(newValue[0].content, newValue[0].props)); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); expect(result.current.toasts[0].content).toBe(newValue[0].content); - act(() => result.current.add(secondToast.content, secondToast.props)); + act(() => {result.current.add(secondToast.content, secondToast.props);}); expect(result.current.toasts.length).toBe(2); expect(result.current.toasts[0].content).toBe(newValue[0].content); expect(result.current.toasts[1].content).toBe(secondToast.content); @@ -64,21 +64,21 @@ describe('useToastState', () => { it('should close a toast', () => { let {result} = renderHook(() => useToastState()); - act(() => result.current.add(newValue[0].content, newValue[0].props)); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); - act(() => result.current.close(result.current.toasts[0].key)); + act(() => {result.current.close(result.current.toasts[0].key);}); expect(result.current.toasts).toStrictEqual([]); }); 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.add(newValue[0].content, newValue[0].props);}); - act(() => result.current.close(result.current.toasts[0].key)); + act(() => {result.current.close(result.current.toasts[0].key);}); expect(result.current.toasts.length).toBe(1); expect(result.current.toasts[0].animation).toBe('exiting'); - act(() => result.current.remove(result.current.toasts[0].key)); + act(() => {result.current.remove(result.current.toasts[0].key);}); expect(result.current.toasts).toStrictEqual([]); }); @@ -86,14 +86,14 @@ describe('useToastState', () => { let {result} = renderHook(() => useToastState()); expect(result.current.toasts).toStrictEqual([]); - act(() => result.current.add(newValue[0].content, newValue[0].props)); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); expect(result.current.toasts[0].content).toBe(newValue[0].content); - act(() => result.current.add('Second Toast')); + act(() => {result.current.add('Second Toast');}); expect(result.current.toasts.length).toBe(1); expect(result.current.toasts[0].content).toBe(newValue[0].content); - act(() => result.current.close(result.current.toasts[0].key)); + act(() => {result.current.close(result.current.toasts[0].key);}); expect(result.current.toasts.length).toBe(1); expect(result.current.toasts[0].content).toBe('Second Toast'); expect(result.current.toasts[0].animation).toBe('queued'); @@ -103,14 +103,14 @@ describe('useToastState', () => { let {result} = renderHook(() => useToastState()); expect(result.current.toasts).toStrictEqual([]); - act(() => result.current.add(newValue[0].content, newValue[0].props)); + act(() => {result.current.add(newValue[0].content, newValue[0].props);}); expect(result.current.toasts[0].content).toBe(newValue[0].content); - act(() => result.current.add('Second Toast', {priority: 1})); + act(() => {result.current.add('Second Toast', {priority: 1});}); expect(result.current.toasts.length).toBe(1); expect(result.current.toasts[0].content).toBe('Second Toast'); - act(() => result.current.close(result.current.toasts[0].key)); + act(() => {result.current.close(result.current.toasts[0].key);}); expect(result.current.toasts.length).toBe(1); expect(result.current.toasts[0].content).toBe(newValue[0].content); expect(result.current.toasts[0].animation).toBe('queued'); From a90dea137023f730c0be2f3ec8f8baeec8c5c522 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 19 Dec 2022 19:41:01 -0800 Subject: [PATCH 11/27] Refactor to use global store outside React tree --- packages/@react-aria/toast/package.json | 2 +- packages/@react-spectrum/toast/package.json | 6 +- packages/@react-spectrum/toast/src/Toast.tsx | 4 +- .../toast/src/ToastProvider.tsx | 184 +++++++++++------- packages/@react-spectrum/toast/src/index.ts | 5 +- .../toast/stories/Toast.stories.tsx | 100 ++++++---- .../toast/test/ToastContainer.test.js | 21 +- packages/@react-stately/toast/package.json | 6 +- packages/@react-stately/toast/src/index.ts | 2 +- .../@react-stately/toast/src/useToastState.ts | 137 +++++++------ yarn.lock | 10 + 11 files changed, 288 insertions(+), 189 deletions(-) diff --git a/packages/@react-aria/toast/package.json b/packages/@react-aria/toast/package.json index bf7702145ae..172e9900a1f 100644 --- a/packages/@react-aria/toast/package.json +++ b/packages/@react-aria/toast/package.json @@ -20,7 +20,7 @@ "dependencies": { "@react-aria/i18n": "^3.1.0", "@react-aria/interactions": "^3.1.0", - "@react-aria/landmark": "3.0.0-alpha.4", + "@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", diff --git a/packages/@react-spectrum/toast/package.json b/packages/@react-spectrum/toast/package.json index e46d9628136..103cd77a0a6 100644 --- a/packages/@react-spectrum/toast/package.json +++ b/packages/@react-spectrum/toast/package.json @@ -41,11 +41,13 @@ "@react-stately/toast": "3.0.0-alpha.1", "@react-types/shared": "^3.1.0", "@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", diff --git a/packages/@react-spectrum/toast/src/Toast.tsx b/packages/@react-spectrum/toast/src/Toast.tsx index a9099774c1a..387dd8af7a2 100644 --- a/packages/@react-spectrum/toast/src/Toast.tsx +++ b/packages/@react-spectrum/toast/src/Toast.tsx @@ -103,8 +103,8 @@ function Toast(props: SpectrumToastProps, ref: DOMRef) { zIndex: props.toast.priority }} data-animation={animation} - onAnimationEnd={e => { - if (e.animationName === toastContainerStyles['fade-out']) { + onAnimationEnd={() => { + if (animation === 'exiting') { state.remove(key); } }}> diff --git a/packages/@react-spectrum/toast/src/ToastProvider.tsx b/packages/@react-spectrum/toast/src/ToastProvider.tsx index b5729289a7a..6c893de3205 100644 --- a/packages/@react-spectrum/toast/src/ToastProvider.tsx +++ b/packages/@react-spectrum/toast/src/ToastProvider.tsx @@ -10,10 +10,11 @@ * governing permissions and limitations under the License. */ -import React, {ReactElement, ReactNode, useContext} from 'react'; +import React, {ReactElement, ReactNode, useEffect, useRef} from 'react'; import {SpectrumToastValue, Toast} from './Toast'; import {ToastContainer} from './ToastContainer'; -import {ToastOptions, useToastState} from '@react-stately/toast'; +import {ToastOptions, ToastQueue, useToastQueue} from '@react-stately/toast'; +import {useSyncExternalStore} from 'use-sync-external-store/shim'; export interface SpectrumToastOptions extends Omit { actionLabel?: ReactNode, @@ -22,87 +23,138 @@ export interface SpectrumToastOptions extends Omit { } type CloseFunction = () => void; -export interface ToastProviderContext { - positive(content: ReactNode, options?: SpectrumToastOptions): CloseFunction, - negative(content: ReactNode, options?: SpectrumToastOptions): CloseFunction, - neutral(content: ReactNode, options?: SpectrumToastOptions): CloseFunction, - info(content: ReactNode, options?: SpectrumToastOptions): CloseFunction + +// 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; } -export interface ToastProviderProps { - children: ReactNode +// For testing. Not exported from the package index. +export function clearToastQueue() { + globalToastQueue = null; } -const ToastContext = React.createContext(null); +let toastProviders = new Set(); +let subscriptions = new Set<() => void>(); +function subscribe(fn: () => void) { + subscriptions.add(fn); + return () => subscriptions.delete(fn); +} -export function useToastProvider(): ToastProviderContext { - return useContext(ToastContext); +function getActiveToastProvider() { + return toastProviders.values().next().value; } -export function ToastProvider(props: ToastProviderProps): ReactElement { - // If there's already a ToastProvider above us in the React tree, don't render another one. - let ctx = useToastProvider(); - if (ctx) { - return <>{props.children}; +function useActiveToastProvider() { + return useSyncExternalStore(subscribe, getActiveToastProvider); +} + +export function ToastProvider(): 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 activeToastProvider = useActiveToastProvider(); + let state = useToastQueue(getGlobalToastQueue()); + if (ref === activeToastProvider && state.toasts.length > 0) { + return ( + + {state.toasts.map((toast) => ( + + ))} + + ); } - return ; + return null; } -function ToastProviderInner(props: ToastProviderProps) { - let state = useToastState({ - hasExitAnimation: true - }); - - let add = (children: ReactNode, variant: SpectrumToastValue['variant'], options: SpectrumToastOptions = {}) => { - let value = { - children, - variant, - actionLabel: options.actionLabel, - onAction: options.onAction, - shouldCloseOnAction: options.shouldCloseOnAction - }; +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 + } + }); - // 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 key = state.add(value, {priority: getPriority(variant, options), timeout, onClose: options.onClose}); - return () => state.close(key); - }; + let shouldContinue = window.dispatchEvent(event); + if (!shouldContinue) { + return; + } + } - let contextValue = { - neutral: (children: ReactNode, options: SpectrumToastOptions = {}) => ( - add(children, 'neutral', options) - ), - positive: (children: ReactNode, options: SpectrumToastOptions = {}) => ( - add(children, 'positive', options) - ), - negative: (children: ReactNode, options: SpectrumToastOptions = {}) => ( - add(children, 'negative', options) - ), - info: (children: ReactNode, options: SpectrumToastOptions = {}) => ( - add(children, 'info', options) - ) + let value = { + children, + variant, + actionLabel: options.actionLabel, + onAction: options.onAction, + shouldCloseOnAction: options.shouldCloseOnAction }; - return ( - - {props.children} - {state.toasts.length > 0 && - - {state.toasts.map((toast) => ( - - ))} - - } - - ); + // 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.remove(key); } +ToastProvider.neutral = function (children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'neutral', options); +}; + +ToastProvider.positive = function (children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'positive', options); +}; + +ToastProvider.negative = function (children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'negative', options); +}; + +ToastProvider.info = function (children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { + return addToast(children, 'info', options); +}; + // 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 = { diff --git a/packages/@react-spectrum/toast/src/index.ts b/packages/@react-spectrum/toast/src/index.ts index 3ab291e1a31..8a40f0c5179 100644 --- a/packages/@react-spectrum/toast/src/index.ts +++ b/packages/@react-spectrum/toast/src/index.ts @@ -12,7 +12,6 @@ /// -export {ToastContainer} from './ToastContainer'; -export {useToastProvider, ToastProvider} from './ToastProvider'; +export {ToastProvider} from './ToastProvider'; -export type {SpectrumToastOptions, ToastProviderContext, ToastProviderProps} from './ToastProvider'; +export type {SpectrumToastOptions} from './ToastProvider'; diff --git a/packages/@react-spectrum/toast/stories/Toast.stories.tsx b/packages/@react-spectrum/toast/stories/Toast.stories.tsx index d13cbaa620f..1e2bccd9532 100644 --- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx +++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx @@ -13,13 +13,15 @@ import {action} from '@storybook/addon-actions'; import {Button} from '@react-spectrum/button'; 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/ToastProvider'; import {storiesOf} from '@storybook/react'; -import {ToastProvider, useToastProvider} from '../'; +import {ToastProvider} from '../'; storiesOf('Toast', module) .addParameters({ @@ -36,76 +38,64 @@ storiesOf('Toast', module) } } }) + .addDecorator((story, {parameters}) => ( + <> + {!parameters.disableToastProvider && } + {story()} + + )) .add( 'Default', - args => + args => ) .add( 'With action', - args => + args => ) .add( 'With dialog', args => ( - - - - - Toasty - - - - - - + + + + Toasty + + + + + ) ) .add( - 'nested ToastProvider', - args => ( - -
-
-

Outer

- -
- -
-

Inner

- -
-
-
-
- ) + 'multiple ToastProviders', + args => , + {disableToastProvider: true} ) .add( 'programmatically closing', - args => + args => ); function RenderProvider(options: SpectrumToastOptions) { - let toastContext = useToastProvider(); - 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/ToastContainer.test.js b/packages/@react-spectrum/toast/test/ToastContainer.test.js index d85e33134c5..5fddb364bb2 100644 --- a/packages/@react-spectrum/toast/test/ToastContainer.test.js +++ b/packages/@react-spectrum/toast/test/ToastContainer.test.js @@ -12,19 +12,17 @@ import {act, fireEvent, render, triggerPress, within} from '@react-spectrum/test-utils'; import {Button} from '@react-spectrum/button'; +import {clearToastQueue, ToastProvider} from '../src/ToastProvider'; import {defaultTheme} from '@adobe/react-spectrum'; import {Provider} from '@react-spectrum/provider'; import React, {useState} from 'react'; -import {ToastProvider, useToastProvider} from '../'; import userEvent from '@testing-library/user-event'; function RenderToastButton(props = {}) { - let toastContext = useToastProvider(); - return (
@@ -35,9 +33,8 @@ function RenderToastButton(props = {}) { function renderComponent(contents) { return render( - - {contents} - + + {contents} ); } @@ -51,6 +48,7 @@ function fireAnimationEnd(alert) { describe('Toast Provider and Container', function () { beforeEach(() => { jest.useFakeTimers(); + clearToastQueue(); }); afterEach(() => { @@ -201,17 +199,15 @@ describe('Toast Provider and Container', function () { it('prioritizes toasts based on variant', () => { function ToastPriorites(props = {}) { - let toastContext = useToastProvider(); - return (
@@ -326,12 +322,11 @@ describe('Toast Provider and Container', function () { it('should support programmatically closing toasts', () => { function ToastToggle() { - let toastContext = useToastProvider(); let [close, setClose] = useState(null); return ( diff --git a/packages/@react-stately/toast/package.json b/packages/@react-stately/toast/package.json index 3af0ad5e977..6624f53f158 100644 --- a/packages/@react-stately/toast/package.json +++ b/packages/@react-stately/toast/package.json @@ -18,7 +18,11 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@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 b6183250c87..d1d6c78a6d4 100644 --- a/packages/@react-stately/toast/src/index.ts +++ b/packages/@react-stately/toast/src/index.ts @@ -9,6 +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 {useToastState, ToastQueue, useToastQueue} from './useToastState'; export type {ToastState, QueuedToast, ToastStateProps, ToastOptions} from './useToastState'; diff --git a/packages/@react-stately/toast/src/useToastState.ts b/packages/@react-stately/toast/src/useToastState.ts index b845d2af757..28dccd0c01a 100644 --- a/packages/@react-stately/toast/src/useToastState.ts +++ b/packages/@react-stately/toast/src/useToastState.ts @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {useMemo, useState} from 'react'; +import {useCallback, useMemo} from 'react'; +import {useSyncExternalStore} from 'use-sync-external-store/shim'; export interface ToastStateProps { maxVisibleToasts?: number, @@ -40,73 +41,52 @@ export interface ToastState { } export function useToastState(props: ToastStateProps = {}): ToastState { - let {maxVisibleToasts = 1, hasExitAnimation} = props; - let queue = useMemo(() => new ToastQueue(maxVisibleToasts), [maxVisibleToasts]); - let [visibleToasts, setVisibleToasts] = useState[]>([]); - - let setCurrentToasts = (toasts: QueuedToast[]) => { - setVisibleToasts(visibleToasts => { - if (hasExitAnimation) { - let prevToasts: QueuedToast[] = visibleToasts - .filter(t => !toasts.some(t2 => t.key === t2.key)) - .map(t => ({...t, animation: 'exiting'})); - - return prevToasts.concat(toasts).sort((a, b) => b.priority - a.priority); - } else { - return toasts; - } - }); - }; - - const add = (content: T, options: ToastOptions = {}) => { - let toastKey = Math.random().toString(36); - let timer = options.timeout ? new Timer(() => close(toastKey), options.timeout) : null; - - queue.add({content, key: toastKey, timer, ...options}); - setCurrentToasts(queue.getVisibleToasts()); - return toastKey; - }; - - const close = (toastKey: string) => { - queue.remove(toastKey); - setCurrentToasts(queue.getVisibleToasts()); - }; + let {maxVisibleToasts = 1, hasExitAnimation = false} = props; + let queue = useMemo(() => new ToastQueue({maxVisibleToasts, hasExitAnimation}), [maxVisibleToasts, hasExitAnimation]); + return useToastQueue(queue); +} - let remove = (toastKey: string) => { - setVisibleToasts(visibleToasts => visibleToasts.filter(t => t.key !== toastKey)); - }; +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); return { - add, - close, - remove, toasts: visibleToasts, - pauseAll: () => { - for (let toast of visibleToasts) { - if (toast.timer) { - toast.timer.pause(); - } - } - }, - resumeAll: () => { - for (let toast of visibleToasts) { - if (toast.timer) { - toast.timer.resume(); - } - } - } + add: (content, options) => queue.add(content, options), + close: key => queue.remove(key), + remove: key => queue.exit(key), + pauseAll: () => queue.pauseAll(), + resumeAll: () => queue.resumeAll() }; } -class ToastQueue { - queue: QueuedToast[] = []; +export class ToastQueue { + private queue: QueuedToast[] = []; + private subscriptions: Set<() => void> = new Set(); maxVisibleToasts: number; + hasExitAnimation: boolean; + visibleToasts: QueuedToast[] = []; + + constructor(options?: ToastStateProps) { + this.maxVisibleToasts = options?.maxVisibleToasts ?? 1; + this.hasExitAnimation = options?.hasExitAnimation ?? false; + } - constructor(maxVisibleToasts: number) { - this.maxVisibleToasts = maxVisibleToasts; + subscribe(fn: () => void) { + this.subscriptions.add(fn); + return () => this.subscriptions.delete(fn); } - add(toast: QueuedToast) { + add(content: T, options: ToastOptions = {}) { + let toastKey = Math.random().toString(36); + let toast: QueuedToast = { + ...options, + content, + key: toastKey, + timer: options.timeout ? new Timer(() => this.remove(toastKey), options.timeout) : null + }; + let low = 0; let high = this.queue.length; while (low < high) { @@ -126,7 +106,8 @@ class ToastQueue { this.queue[i++].animation = 'queued'; } - return low; + this.updateVisibleToasts(); + return toastKey; } remove(key: string) { @@ -135,10 +116,46 @@ class ToastQueue { this.queue[index].onClose?.(); this.queue.splice(index, 1); } + + this.updateVisibleToasts(); + } + + exit(key: string) { + this.visibleToasts = this.visibleToasts.filter(t => t.key !== key); + this.updateVisibleToasts(); } - getVisibleToasts(): QueuedToast[] { - return this.queue.slice(0, this.maxVisibleToasts); + 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(); + } + } + + pauseAll() { + for (let toast of this.visibleToasts) { + if (toast.timer) { + toast.timer.pause(); + } + } + } + + resumeAll() { + for (let toast of this.visibleToasts) { + if (toast.timer) { + toast.timer.resume(); + } + } } } diff --git a/yarn.lock b/yarn.lock index 4dfcd8d0f4d..461965981af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4489,6 +4489,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" @@ -21181,6 +21186,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" From 8848f4ebd6ced898d68150d761215d984c874311 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Dec 2022 09:21:00 -0800 Subject: [PATCH 12/27] Docs --- packages/@react-aria/toast/src/useToast.ts | 9 + .../@react-aria/toast/src/useToastRegion.ts | 23 ++- .../@react-aria/toast/stories/Example.tsx | 2 +- packages/@react-spectrum/toast/docs/Toast.mdx | 176 ++++++++++++++++++ .../toast/src/ToastContainer.tsx | 6 +- .../toast/src/ToastProvider.tsx | 20 +- packages/@react-spectrum/toast/src/index.ts | 2 +- .../toast/src/toastContainer.css | 2 +- .../toast/test/ToastContainer.test.js | 82 ++++++++ .../@react-stately/toast/src/useToastState.ts | 60 +++++- .../toast/test/useToastState.test.js | 84 ++++----- 11 files changed, 401 insertions(+), 65 deletions(-) create mode 100644 packages/@react-spectrum/toast/docs/Toast.mdx diff --git a/packages/@react-aria/toast/src/useToast.ts b/packages/@react-aria/toast/src/useToast.ts index 9c01bd23e3f..e5f5217ea64 100644 --- a/packages/@react-aria/toast/src/useToast.ts +++ b/packages/@react-aria/toast/src/useToast.ts @@ -20,16 +20,25 @@ import {useId, useLayoutEffect, useSlotId} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; export interface AriaToastProps extends AriaLabelingProps { + /** The toast object. */ toast: QueuedToast } export interface ToastAria { + /** Props for the toast container element. */ toastProps: DOMAttributes, + /** 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 } +/** + * Provides the behavior and accessibility implementation for a toast component. + * Toasts are transient notifications of actions, errors, or other events in an application. + */ export function useToast(props: AriaToastProps, state: ToastState, ref: RefObject): ToastAria { let { key, diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 690c9253b09..258b7e46998 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -1,17 +1,30 @@ import {AriaLabelingProps, DOMAttributes} from '@react-types/shared'; +import {focusWithoutScrolling} 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 {useFocusWithin, useHover} from '@react-aria/interactions'; import {useLandmark} from '@react-aria/landmark'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; -export interface AriaToastRegionProps extends AriaLabelingProps {} +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 are transient 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({ @@ -43,7 +56,11 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState useEffect(() => { return () => { if (lastFocused.current && document.body.contains(lastFocused.current)) { - lastFocused.current.focus(); + if (getInteractionModality() === 'pointer') { + focusWithoutScrolling(lastFocused.current); + } else { + lastFocused.current.focus(); + } } }; }, [ref]); diff --git a/packages/@react-aria/toast/stories/Example.tsx b/packages/@react-aria/toast/stories/Example.tsx index 6fbae79a1ed..dbbc01deae1 100644 --- a/packages/@react-aria/toast/stories/Example.tsx +++ b/packages/@react-aria/toast/stories/Example.tsx @@ -33,7 +33,7 @@ function ToastRegion() { let {regionProps} = useToastRegion({}, state, ref); return (
- {state.toasts.map(toast => ( + {state.visibleToasts.map(toast => ( ))}
diff --git a/packages/@react-spectrum/toast/docs/Toast.mdx b/packages/@react-spectrum/toast/docs/Toast.mdx new file mode 100644 index 00000000000..7e2c4f9eb7c --- /dev/null +++ b/packages/@react-spectrum/toast/docs/Toast.mdx @@ -0,0 +1,176 @@ +{/* 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} 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 {ToastProvider} from '@react-spectrum/toast'; +``` + +--- +category: Status +keywords: [toast, notifications, alert] +--- + +# Toast + +Toasts are transient notifications of actions, errors, or other events in an application. + + + +## Example + +```tsx example + + +``` + +## Content + +Toasts are triggered using one of the methods of `ToastProvider`. 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 labeled "Notifications". The label can be overridden using the `aria-label` prop of the `ToastProvider` 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 `ToastProvider` returns a function which may be used to close a toast. + +```tsx example +function Example() { + let [close, setClose] = React.useState(null); + + return ( + + ); +} +``` + +## API + +### Toast options + + +
+ +
+
+ +### ToastProvider props + + + + diff --git a/packages/@react-spectrum/toast/src/ToastContainer.tsx b/packages/@react-spectrum/toast/src/ToastContainer.tsx index 696fff246df..480b7875d69 100644 --- a/packages/@react-spectrum/toast/src/ToastContainer.tsx +++ b/packages/@react-spectrum/toast/src/ToastContainer.tsx @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {AriaToastRegionProps, useToastRegion} from '@react-aria/toast'; import {classNames} from '@react-spectrum/utils'; import {FocusRing} from '@react-aria/focus'; import {Provider, useProvider} from '@react-spectrum/provider'; @@ -17,9 +18,8 @@ import React, {ReactElement, ReactNode, useRef} from 'react'; import ReactDOM from 'react-dom'; import toastContainerStyles from './toastContainer.css'; import {ToastState} from '@react-stately/toast'; -import {useToastRegion} from '@react-aria/toast'; -interface ToastContainerProps { +interface ToastContainerProps extends AriaToastRegionProps { children: ReactNode, state: ToastState } @@ -33,7 +33,7 @@ export function ToastContainer(props: ToastContainerProps): ReactElement { let containerPlacement = provider.scale === 'large' ? 'center' : 'right'; let ref = useRef(); - let {regionProps} = useToastRegion({}, state, ref); + let {regionProps} = useToastRegion(props, state, ref); let contents = ( diff --git a/packages/@react-spectrum/toast/src/ToastProvider.tsx b/packages/@react-spectrum/toast/src/ToastProvider.tsx index 6c893de3205..25038ba2637 100644 --- a/packages/@react-spectrum/toast/src/ToastProvider.tsx +++ b/packages/@react-spectrum/toast/src/ToastProvider.tsx @@ -10,15 +10,21 @@ * governing permissions and limitations under the License. */ +import {AriaToastRegionProps} from '@react-aria/toast'; import React, {ReactElement, ReactNode, useEffect, useRef} from 'react'; import {SpectrumToastValue, Toast} from './Toast'; import {ToastContainer} from './ToastContainer'; import {ToastOptions, ToastQueue, useToastQueue} from '@react-stately/toast'; import {useSyncExternalStore} from 'use-sync-external-store/shim'; +export interface SpectrumToastProviderProps 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 } @@ -57,7 +63,11 @@ function useActiveToastProvider() { return useSyncExternalStore(subscribe, getActiveToastProvider); } -export function ToastProvider(): ReactElement { +/** + * A ToastProvider renders the queued toasts in an application. It should be placed + * at the root of the app. + */ +export function ToastProvider(props: SpectrumToastProviderProps): 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 @@ -87,10 +97,10 @@ export function ToastProvider(): ReactElement { // Only render if this is the active toast provider instance, and there are visible toasts. let activeToastProvider = useActiveToastProvider(); let state = useToastQueue(getGlobalToastQueue()); - if (ref === activeToastProvider && state.toasts.length > 0) { + if (ref === activeToastProvider && state.visibleToasts.length > 0) { return ( - - {state.toasts.map((toast) => ( + + {state.visibleToasts.map((toast) => ( queue.remove(key); + return () => queue.close(key); } ToastProvider.neutral = function (children: ReactNode, options: SpectrumToastOptions = {}): CloseFunction { diff --git a/packages/@react-spectrum/toast/src/index.ts b/packages/@react-spectrum/toast/src/index.ts index 8a40f0c5179..f3b2b680965 100644 --- a/packages/@react-spectrum/toast/src/index.ts +++ b/packages/@react-spectrum/toast/src/index.ts @@ -14,4 +14,4 @@ export {ToastProvider} from './ToastProvider'; -export type {SpectrumToastOptions} from './ToastProvider'; +export type {SpectrumToastOptions, SpectrumToastProviderProps} from './ToastProvider'; diff --git a/packages/@react-spectrum/toast/src/toastContainer.css b/packages/@react-spectrum/toast/src/toastContainer.css index 8e4118fde87..e8a0c5b701d 100644 --- a/packages/@react-spectrum/toast/src/toastContainer.css +++ b/packages/@react-spectrum/toast/src/toastContainer.css @@ -34,7 +34,7 @@ .spectrum-Toast { position: absolute; - margin: 8px; + margin: 16px; pointer-events: all; } diff --git a/packages/@react-spectrum/toast/test/ToastContainer.test.js b/packages/@react-spectrum/toast/test/ToastContainer.test.js index 5fddb364bb2..c1eaf298e74 100644 --- a/packages/@react-spectrum/toast/test/ToastContainer.test.js +++ b/packages/@react-spectrum/toast/test/ToastContainer.test.js @@ -345,4 +345,86 @@ describe('Toast Provider and Container', function () { fireAnimationEnd(toast); expect(queryByRole('alert')).toBeNull(); }); + + it('should only render one ToastProvider', () => { + 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/src/useToastState.ts b/packages/@react-stately/toast/src/useToastState.ts index 28dccd0c01a..b5d10ef9239 100644 --- a/packages/@react-stately/toast/src/useToastState.ts +++ b/packages/@react-stately/toast/src/useToastState.ts @@ -14,58 +14,91 @@ import {useCallback, useMemo} from 'react'; import {useSyncExternalStore} from 'use-sync-external-store/shim'; 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 } 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. */ 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, - toasts: QueuedToast[] + /** The visible toasts. */ + visibleToasts: QueuedToast[] } +/** + * Provides state management for a toast queue. Toasts are transient 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); } +/** + * 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); return { - toasts: visibleToasts, + visibleToasts, add: (content, options) => queue.add(content, options), - close: key => queue.remove(key), - remove: key => queue.exit(key), + close: key => queue.close(key), + remove: key => queue.remove(key), pauseAll: () => queue.pauseAll(), resumeAll: () => queue.resumeAll() }; } +/** + * A ToastQueue is a priority queue of toasts. + */ export class ToastQueue { private queue: QueuedToast[] = []; private subscriptions: Set<() => void> = new Set(); - maxVisibleToasts: number; - hasExitAnimation: boolean; + private maxVisibleToasts: number; + private hasExitAnimation: boolean; + /** The currently visible toasts. */ visibleToasts: QueuedToast[] = []; constructor(options?: ToastStateProps) { @@ -73,18 +106,20 @@ export class ToastQueue { 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.remove(toastKey), options.timeout) : null + timer: options.timeout ? new Timer(() => this.close(toastKey), options.timeout) : null }; let low = 0; @@ -110,7 +145,11 @@ export class ToastQueue { return toastKey; } - remove(key: string) { + /** + * 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?.(); @@ -120,7 +159,8 @@ export class ToastQueue { this.updateVisibleToasts(); } - exit(key: string) { + /** 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(); } @@ -142,6 +182,7 @@ export class ToastQueue { } } + /** Pauses the timers for all visible toasts. */ pauseAll() { for (let toast of this.visibleToasts) { if (toast.timer) { @@ -150,6 +191,7 @@ export class ToastQueue { } } + /** Resumes the timers for all visible toasts. */ resumeAll() { for (let toast of this.visibleToasts) { if (toast.timer) { diff --git a/packages/@react-stately/toast/test/useToastState.test.js b/packages/@react-stately/toast/test/useToastState.test.js index 56f0b0b9ebc..323a501bb12 100644 --- a/packages/@react-stately/toast/test/useToastState.test.js +++ b/packages/@react-stately/toast/test/useToastState.test.js @@ -21,28 +21,28 @@ describe('useToastState', () => { it('should add a new toast via add', () => { let {result} = renderHook(() => useToastState()); - expect(result.current.toasts).toStrictEqual([]); + expect(result.current.visibleToasts).toStrictEqual([]); act(() => {result.current.add(newValue[0].content, newValue[0].props);}); - expect(result.current.toasts).toHaveLength(1); - expect(result.current.toasts[0].content).toBe(newValue[0].content); - expect(result.current.toasts[0].animation).toBe('entering'); - expect(result.current.toasts[0].timeout).toBe(0); - expect(result.current.toasts[0].timer).toBe(null); - expect(result.current.toasts[0]).toHaveProperty('key'); + 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 with a timer', () => { let {result} = renderHook(() => useToastState()); - expect(result.current.toasts).toStrictEqual([]); + expect(result.current.visibleToasts).toStrictEqual([]); act(() => {result.current.add('Test', {timeout: 5000});}); - expect(result.current.toasts).toHaveLength(1); - expect(result.current.toasts[0].content).toBe('Test'); - expect(result.current.toasts[0].animation).toBe('entering'); - expect(result.current.toasts[0].timeout).toBe(5000); - expect(result.current.toasts[0].timer).not.toBe(null); - expect(result.current.toasts[0]).toHaveProperty('key'); + 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', () => { @@ -51,68 +51,68 @@ describe('useToastState', () => { props: {timeout: 0} }; let {result} = renderHook(() => useToastState({maxVisibleToasts: 2})); - expect(result.current.toasts).toStrictEqual([]); + expect(result.current.visibleToasts).toStrictEqual([]); act(() => {result.current.add(newValue[0].content, newValue[0].props);}); - expect(result.current.toasts[0].content).toBe(newValue[0].content); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); act(() => {result.current.add(secondToast.content, secondToast.props);}); - expect(result.current.toasts.length).toBe(2); - expect(result.current.toasts[0].content).toBe(newValue[0].content); - expect(result.current.toasts[1].content).toBe(secondToast.content); + 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 close a toast', () => { let {result} = renderHook(() => useToastState()); act(() => {result.current.add(newValue[0].content, newValue[0].props);}); - act(() => {result.current.close(result.current.toasts[0].key);}); - expect(result.current.toasts).toStrictEqual([]); + act(() => {result.current.close(result.current.visibleToasts[0].key);}); + expect(result.current.visibleToasts).toStrictEqual([]); }); 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.toasts[0].key);}); - expect(result.current.toasts.length).toBe(1); - expect(result.current.toasts[0].animation).toBe('exiting'); + 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.toasts[0].key);}); - expect(result.current.toasts).toStrictEqual([]); + act(() => {result.current.remove(result.current.visibleToasts[0].key);}); + expect(result.current.visibleToasts).toStrictEqual([]); }); it('should queue toasts', () => { let {result} = renderHook(() => useToastState()); - expect(result.current.toasts).toStrictEqual([]); + expect(result.current.visibleToasts).toStrictEqual([]); act(() => {result.current.add(newValue[0].content, newValue[0].props);}); - expect(result.current.toasts[0].content).toBe(newValue[0].content); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); act(() => {result.current.add('Second Toast');}); - expect(result.current.toasts.length).toBe(1); - expect(result.current.toasts[0].content).toBe(newValue[0].content); + expect(result.current.visibleToasts.length).toBe(1); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); - act(() => {result.current.close(result.current.toasts[0].key);}); - expect(result.current.toasts.length).toBe(1); - expect(result.current.toasts[0].content).toBe('Second Toast'); - expect(result.current.toasts[0].animation).toBe('queued'); + 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'); }); it('should queue toasts with priority', () => { let {result} = renderHook(() => useToastState()); - expect(result.current.toasts).toStrictEqual([]); + expect(result.current.visibleToasts).toStrictEqual([]); act(() => {result.current.add(newValue[0].content, newValue[0].props);}); - expect(result.current.toasts[0].content).toBe(newValue[0].content); + expect(result.current.visibleToasts[0].content).toBe(newValue[0].content); act(() => {result.current.add('Second Toast', {priority: 1});}); - expect(result.current.toasts.length).toBe(1); - expect(result.current.toasts[0].content).toBe('Second Toast'); + expect(result.current.visibleToasts.length).toBe(1); + expect(result.current.visibleToasts[0].content).toBe('Second Toast'); - act(() => {result.current.close(result.current.toasts[0].key);}); - expect(result.current.toasts.length).toBe(1); - expect(result.current.toasts[0].content).toBe(newValue[0].content); - expect(result.current.toasts[0].animation).toBe('queued'); + 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'); }); }); From 155076c8d4be4f2d5359bf121f644d2a23302282 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Dec 2022 11:58:43 -0800 Subject: [PATCH 13/27] Split into ToastContainer and ToastQueue --- packages/@react-spectrum/toast/docs/Toast.mdx | 61 +++-- .../toast/src/ToastContainer.tsx | 215 ++++++++++++++---- .../toast/src/ToastProvider.tsx | 183 --------------- .../@react-spectrum/toast/src/Toaster.tsx | 57 +++++ packages/@react-spectrum/toast/src/index.ts | 4 +- .../toast/stories/Toast.stories.tsx | 24 +- .../toast/test/ToastContainer.test.js | 30 +-- packages/dev/docs/src/ThemeSwitcher.js | 8 +- .../DocsTransformer.js | 42 +++- .../MDXTransformer.js | 7 +- 10 files changed, 346 insertions(+), 285 deletions(-) delete mode 100644 packages/@react-spectrum/toast/src/ToastProvider.tsx create mode 100644 packages/@react-spectrum/toast/src/Toaster.tsx diff --git a/packages/@react-spectrum/toast/docs/Toast.mdx b/packages/@react-spectrum/toast/docs/Toast.mdx index 7e2c4f9eb7c..e00b15a379b 100644 --- a/packages/@react-spectrum/toast/docs/Toast.mdx +++ b/packages/@react-spectrum/toast/docs/Toast.mdx @@ -11,18 +11,19 @@ import {Layout} from '@react-spectrum/docs'; export default Layout; import docs from 'docs:@react-spectrum/toast'; -import {HeaderInfo, PropTable, PageDescription, ClassAPI, TypeContext, InterfaceType} from '@react-spectrum/docs'; +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 {ToastProvider} from '@react-spectrum/toast'; +import {ToastContainer, ToastQueue} from '@react-spectrum/toast'; ``` --- category: Status keywords: [toast, notifications, alert] +after_version: 3.0.0 --- # Toast @@ -31,17 +32,24 @@ keywords: [toast, notifications, alert] ## Example +First, render a toast container at the root of your app: + +```tsx example hidden + +``` + +Then, queue a toast from anywhere: + ```tsx example - @@ -49,37 +57,38 @@ keywords: [toast, notifications, alert] ## Content -Toasts are triggered using one of the methods of `ToastProvider`. A `` element must be rendered at the root of your app in order to display the queued toasts. +

{console.log(docs.exports)}

+ +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 - @@ -33,7 +33,7 @@ function RenderToastButton(props = {}) { function renderComponent(contents) { return render( - + {contents} ); @@ -55,7 +55,7 @@ describe('Toast Provider and Container', function () { act(() => jest.runAllTimers()); }); - it('Renders a button that triggers a toast via the provider', () => { + it('renders a button that triggers a toast', () => { let {getByRole, queryByRole} = renderComponent(); let button = getByRole('button'); @@ -202,12 +202,12 @@ describe('Toast Provider and Container', function () { return (
@@ -326,7 +326,7 @@ describe('Toast Provider and Container', function () { return ( @@ -346,11 +346,11 @@ describe('Toast Provider and Container', function () { expect(queryByRole('alert')).toBeNull(); }); - it('should only render one ToastProvider', () => { + it('should only render one ToastContainer', () => { let {getByRole, getAllByRole, rerender} = render( - - + + ); @@ -363,7 +363,7 @@ describe('Toast Provider and Container', function () { rerender( - + ); @@ -373,7 +373,7 @@ describe('Toast Provider and Container', function () { rerender( - + ); @@ -383,8 +383,8 @@ describe('Toast Provider and Container', function () { rerender( - - + + ); @@ -416,7 +416,7 @@ describe('Toast Provider and Container', function () { it('should support custom aria-label', () => { let {getByRole} = render( - + ); 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..fba111b97ad 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}\nReactDOM.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, `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), From 1d7c00d82575de144863032935b728af15bdd4a7 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 20 Dec 2022 12:13:59 -0800 Subject: [PATCH 14/27] Cleanup --- packages/@react-aria/toast/src/useToastRegion.ts | 2 +- packages/@react-spectrum/toast/docs/Toast.mdx | 2 -- packages/@react-spectrum/toast/package.json | 1 - scripts/compareAPIs.js | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 258b7e46998..54e70be2036 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -13,7 +13,7 @@ export interface AriaToastRegionProps extends AriaLabelingProps { * An accessibility label for the toast region. * @default "Notifications" */ - 'aria-label'?: string + 'aria-label'?: string } export interface ToastRegionAria { diff --git a/packages/@react-spectrum/toast/docs/Toast.mdx b/packages/@react-spectrum/toast/docs/Toast.mdx index e00b15a379b..2ff09fe0011 100644 --- a/packages/@react-spectrum/toast/docs/Toast.mdx +++ b/packages/@react-spectrum/toast/docs/Toast.mdx @@ -57,8 +57,6 @@ Then, queue a toast from anywhere: ## Content -

{console.log(docs.exports)}

- 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. diff --git a/packages/@react-spectrum/toast/package.json b/packages/@react-spectrum/toast/package.json index 103cd77a0a6..d20327ba0ff 100644 --- a/packages/@react-spectrum/toast/package.json +++ b/packages/@react-spectrum/toast/package.json @@ -35,7 +35,6 @@ "@react-aria/focus": "^3.10.0", "@react-aria/i18n": "^3.6.2", "@react-aria/toast": "3.0.0-alpha.1", - "@react-aria/utils": "^3.14.1", "@react-spectrum/button": "^3.1.0", "@react-spectrum/utils": "^3.1.0", "@react-stately/toast": "3.0.0-alpha.1", 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); From a105fdf63b3520fa5d4c03b686e62f972451df42 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 21 Dec 2022 11:21:07 -0800 Subject: [PATCH 15/27] Add aria docs --- .../@react-aria/toast/docs/toast-anatomy.svg | 41 ++ packages/@react-aria/toast/docs/useToast.mdx | 451 ++++++++++++++++++ .../MDXTransformer.js | 8 +- 3 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 packages/@react-aria/toast/docs/toast-anatomy.svg create mode 100644 packages/@react-aria/toast/docs/useToast.mdx 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..0d2c512100e --- /dev/null +++ b/packages/@react-aria/toast/docs/useToast.mdx @@ -0,0 +1,451 @@ +{/* 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; +} + +.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/dev/parcel-transformer-mdx-docs/MDXTransformer.js b/packages/dev/parcel-transformer-mdx-docs/MDXTransformer.js index fba111b97ad..806a9d69d14 100644 --- a/packages/dev/parcel-transformer-mdx-docs/MDXTransformer.js +++ b/packages/dev/parcel-transformer-mdx-docs/MDXTransformer.js @@ -72,9 +72,9 @@ module.exports = new Transformer({ 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} ${props}><${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} ${props}>$1, document.getElementById("${id}"));`); + code = code.replace(/^(<(.|\n)*>)$/m, `RENDER_FNS.push(() => ReactDOM.render(<${provider} ${props}>$1, document.getElementById("${id}")));`); } } @@ -437,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 {}; `; } From 6f4de0bc58cd74804be2947808e084f12e7835c5 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 21 Dec 2022 12:36:15 -0800 Subject: [PATCH 16/27] Stately docs --- .../toast/docs/useToastState.mdx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/@react-stately/toast/docs/useToastState.mdx 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`. From 1378c2a70d247cb690222847bcb2e8e09318f7e1 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 6 Jan 2023 11:46:17 -0800 Subject: [PATCH 17/27] Fix ariaHideOutside when added node includes a top layer element --- .../overlays/src/ariaHideOutside.ts | 83 ++++++++++++------- .../overlays/test/ariaHideOutside.test.js | 30 +++++++ 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index d207ad7ac53..791aa9b6e8b 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -27,38 +27,54 @@ export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(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 walk = (root: Element) => { + // Keep live announcer and top layer elements (e.g. toasts) visible. + for (let element of root.querySelectorAll('[data-live-announcer], [data-react-aria-top-layer]')) { + visibleNodes.add(element); + } - let walker = document.createTreeWalker( - root, - NodeFilter.SHOW_ELEMENT, - { - acceptNode(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". - // For that case we want to hide the cells inside as well (https://bugs.webkit.org/show_bug.cgi?id=222623). - if ( - visibleNodes.has(node as Element) || - (hiddenNodes.has(node.parentElement) && node.parentElement.getAttribute('role') !== 'row') - ) { - return NodeFilter.FILTER_REJECT; - } + let acceptNode = (node: Element) => { + // 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". + // For that case we want to hide the cells inside as well (https://bugs.webkit.org/show_bug.cgi?id=222623). + if ( + visibleNodes.has(node) || + (hiddenNodes.has(node.parentElement) && node.parentElement.getAttribute('role') !== 'row') + ) { + return NodeFilter.FILTER_REJECT; + } - // Skip this node but continue to children if one of the targets is inside the node. - for (let target of visibleNodes) { - if (node.contains(target)) { - return NodeFilter.FILTER_SKIP; - } + // Skip this node but continue to children if one of the targets is inside the node. + for (let target of visibleNodes) { + if (node.contains(target)) { + return NodeFilter.FILTER_SKIP; } + } + + return NodeFilter.FILTER_ACCEPT; + }; + + let walker = document.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + {acceptNode} + ); - return NodeFilter.FILTER_ACCEPT; + // TreeWalker does not include the root. + let acceptRoot = acceptNode(root); + if (acceptRoot === NodeFilter.FILTER_ACCEPT) { + hide(root); + } + + if (acceptRoot !== NodeFilter.FILTER_REJECT) { + let node = walker.nextNode() as Element; + while (node != null) { + hide(node); + node = walker.nextNode() as Element; } } - ); + }; let hide = (node: Element) => { let refCount = refCountMap.get(node) ?? 0; @@ -83,11 +99,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { observerStack[observerStack.length - 1].disconnect(); } - let node = walker.nextNode() as Element; - while (node != null) { - hide(node); - node = walker.nextNode() as Element; - } + walk(root); let observer = new MutationObserver(changes => { for (let change of changes) { @@ -98,6 +110,13 @@ export function ariaHideOutside(targets: Element[], root = document.body) { // If the parent element of the added nodes is not within one of the targets, // 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.removedNodes) { + if (node instanceof Element) { + visibleNodes.delete(node); + hiddenNodes.delete(node); + } + } + for (let node of change.addedNodes) { if ( (node instanceof HTMLElement || node instanceof SVGElement) && @@ -105,7 +124,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { ) { visibleNodes.add(node); } else if (node instanceof Element) { - hide(node); + walk(node); } } } diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index da6a3708b12..44434ba32fd 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -206,6 +206,36 @@ describe('ariaHideOutside', function () { expect(getAllByRole('checkbox')).toHaveLength(2); }); + it('should handle when a new element is added along with a top layer element', async function () { + let Test = props => ( + <> + + {props.show &&
+
Top layer
+ +
} + + ); + + let {getByRole, queryByRole, getAllByRole, rerender} = render(); + + let button = getByRole('button'); + expect(queryByRole('checkbox')).toBeNull(); + + let revert = ariaHideOutside([button]); + + rerender(); + + // MutationObserver is async + await waitFor(() => queryByRole('checkbox') == null); + expect(queryByRole('button')).not.toBeNull(); + expect(getByRole('alert')).toHaveTextContent('Top layer'); + + revert(); + + expect(getAllByRole('checkbox')).toHaveLength(1); + }); + it('should handle when a new element is added inside a target element', async function () { let Test = props => ( <> From aa07403746e2df2ce3f65d8656b01f8fae491e90 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 6 Jan 2023 11:56:09 -0800 Subject: [PATCH 18/27] Skip hidden landmarks and fire a custom event when wrapping --- .../@react-aria/landmark/src/useLandmark.ts | 70 ++++++++--- .../landmark/test/useLandmark.test.tsx | 118 ++++++++++++++++++ 2 files changed, 169 insertions(+), 19 deletions(-) diff --git a/packages/@react-aria/landmark/src/useLandmark.ts b/packages/@react-aria/landmark/src/useLandmark.ts index 4f835a6a1a4..43823aa4140 100644 --- a/packages/@react-aria/landmark/src/useLandmark.ts +++ b/packages/@react-aria/landmark/src/useLandmark.ts @@ -17,7 +17,8 @@ import {useLayoutEffect} from '@react-aria/utils'; export type AriaLandmarkRole = 'main' | 'region' | 'search' | 'navigation' | 'form' | 'banner' | 'contentinfo' | 'complementary'; export interface AriaLandmarkProps extends AriaLabelingProps { - role: AriaLandmarkRole + role: AriaLandmarkRole, + focus?: (direction: 'forward' | 'backward') => void } export interface LandmarkAria { @@ -29,7 +30,7 @@ type Landmark = { role: AriaLandmarkRole, label?: string, lastFocused?: FocusableElement, - focus: () => void, + focus: (direction: 'forward' | 'backward') => void, blur: () => void }; @@ -66,8 +67,8 @@ class LandmarkManager { this.isListening = false; } - private focusLandmark(landmark: Element) { - this.landmarks.find(l => l.ref.current === landmark)?.focus(); + private focusLandmark(landmark: Element, direction: 'forward' | 'backward') { + this.landmarks.find(l => l.ref.current === landmark)?.focus(direction); } /** @@ -190,16 +191,46 @@ class LandmarkManager { } let currentLandmark = this.closestLandmark(element); - let nextLandmarkIndex = backward ? -1 : 0; + let nextLandmarkIndex = backward ? this.landmarks.length - 1 : 0; if (currentLandmark) { - nextLandmarkIndex = this.landmarks.findIndex(landmark => landmark === currentLandmark) + (backward ? -1 : 1); + nextLandmarkIndex = this.landmarks.indexOf(currentLandmark) + (backward ? -1 : 1); } - // Wrap if necessary - if (nextLandmarkIndex < 0) { - nextLandmarkIndex = this.landmarks.length - 1; - } else if (nextLandmarkIndex >= this.landmarks.length) { - nextLandmarkIndex = 0; + let wrapIfNeeded = () => { + // When we reach the end of the landmark sequence, fire a custom event that can be listened for by applications. + // If this event is canceled, we return immediately. This can be used to implement landmark navigation across iframes. + if (nextLandmarkIndex < 0) { + if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', {detail: {direction: 'backward'}, bubbles: true, cancelable: true}))) { + return true; + } + + nextLandmarkIndex = this.landmarks.length - 1; + } else if (nextLandmarkIndex >= this.landmarks.length) { + if (!element.dispatchEvent(new CustomEvent('react-aria-landmark-navigation', {detail: {direction: 'forward'}, bubbles: true, cancelable: true}))) { + return true; + } + + nextLandmarkIndex = 0; + } + + return false; + }; + + if (wrapIfNeeded()) { + return undefined; + } + + // Skip over hidden landmarks. + let i = nextLandmarkIndex; + while (this.landmarks[nextLandmarkIndex].ref.current.closest('[aria-hidden]')) { + nextLandmarkIndex += backward ? -1 : 1; + if (wrapIfNeeded()) { + return undefined; + } + + if (nextLandmarkIndex === i) { + break; + } } return this.landmarks[nextLandmarkIndex]; @@ -212,9 +243,6 @@ class LandmarkManager { */ public f6Handler(e: KeyboardEvent) { if (e.key === 'F6') { - e.preventDefault(); - e.stopPropagation(); - let backward = e.shiftKey; let nextLandmark = this.getNextLandmark(e.target as Element, {backward}); @@ -223,11 +251,14 @@ class LandmarkManager { return; } + e.preventDefault(); + e.stopPropagation(); + // If alt key pressed, focus main landmark if (e.altKey) { let main = this.getLandmarkByRole('main'); if (main && document.contains(main.ref.current)) { - this.focusLandmark(main.ref.current); + this.focusLandmark(main.ref.current, 'forward'); } return; } @@ -243,7 +274,7 @@ class LandmarkManager { // Otherwise, focus the landmark itself if (document.contains(nextLandmark.ref.current)) { - this.focusLandmark(nextLandmark.ref.current); + this.focusLandmark(nextLandmark.ref.current, backward ? 'backward' : 'forward'); } } } @@ -292,13 +323,14 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject { + let defaultFocus = useCallback(() => { setIsLandmarkFocused(true); }, [setIsLandmarkFocused]); @@ -316,7 +348,7 @@ export function useLandmark(props: AriaLandmarkProps, ref: MutableRefObject { - manager.updateLandmark({ref, label, role, focus, blur}); + manager.updateLandmark({ref, label, role, focus: focus || defaultFocus, blur}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [label, ref, role]); diff --git a/packages/@react-aria/landmark/test/useLandmark.test.tsx b/packages/@react-aria/landmark/test/useLandmark.test.tsx index 07357b34ced..d3f574287ca 100644 --- a/packages/@react-aria/landmark/test/useLandmark.test.tsx +++ b/packages/@react-aria/landmark/test/useLandmark.test.tsx @@ -1156,4 +1156,122 @@ describe('LandmarkManager', function () { expect(nav).toHaveAttribute('tabIndex', '-1'); expect(document.activeElement).toBe(nav); }); + + it('landmark navigation fires custom event when wrapping forward', function () { + let tree = render( +
+ + + +
+ +
+
+ ); + let main = tree.getByRole('main'); + + let onLandmarkNavigation = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('react-aria-landmark-navigation', onLandmarkNavigation); + + userEvent.tab(); + expect(document.activeElement).toBe(tree.getAllByRole('link')[0]); + + fireEvent.keyDown(document.activeElement, {key: 'F6'}); + fireEvent.keyUp(document.activeElement, {key: 'F6'}); + expect(document.activeElement).toBe(main); + + fireEvent.keyDown(document.activeElement, {key: 'F6'}); + fireEvent.keyUp(document.activeElement, {key: 'F6'}); + + expect(document.activeElement).toBe(main); + + expect(onLandmarkNavigation).toHaveBeenCalledTimes(1); + expect(onLandmarkNavigation.mock.calls[0][0].detail).toEqual({ + direction: 'forward' + }); + + window.removeEventListener('react-aria-landmark-navigation', onLandmarkNavigation); + }); + + it('landmark navigation fires custom event when wrapping backward', function () { + let tree = render( +
+ + + +
+ +
+
+ ); + + let onLandmarkNavigation = jest.fn().mockImplementation(e => e.preventDefault()); + window.addEventListener('react-aria-landmark-navigation', onLandmarkNavigation); + + userEvent.tab(); + expect(document.activeElement).toBe(tree.getAllByRole('link')[0]); + + fireEvent.keyDown(document.activeElement, {key: 'F6', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'F6', shiftKey: true}); + expect(document.activeElement).toBe(tree.getAllByRole('link')[0]); + + expect(onLandmarkNavigation).toHaveBeenCalledTimes(1); + expect(onLandmarkNavigation.mock.calls[0][0].detail).toEqual({ + direction: 'backward' + }); + + window.removeEventListener('react-aria-landmark-navigation', onLandmarkNavigation); + }); + + it('goes skips over aria-hidden landmarks', function () { + let tree = render( +
+
+ + + + + + Checkbox label 2 + + + +
+
+ ); + let main = tree.getByRole('main'); + let region2 = tree.getAllByRole('region')[0]; + + fireEvent.keyDown(document.activeElement, {key: 'F6'}); + fireEvent.keyUp(document.activeElement, {key: 'F6'}); + expect(document.activeElement).toBe(main); + + fireEvent.keyDown(document.activeElement, {key: 'F6'}); + fireEvent.keyUp(document.activeElement, {key: 'F6'}); + expect(document.activeElement).toBe(region2); + + fireEvent.keyDown(document.activeElement, {key: 'F6'}); + fireEvent.keyUp(document.activeElement, {key: 'F6'}); + expect(document.activeElement).toBe(main); + + fireEvent.keyDown(document.activeElement, {key: 'F6', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'F6', shiftKey: true}); + expect(document.activeElement).toBe(region2); + + fireEvent.keyDown(document.activeElement, {key: 'F6', shiftKey: true}); + fireEvent.keyUp(document.activeElement, {key: 'F6', shiftKey: true}); + expect(document.activeElement).toBe(main); + }); }); From e7f13b11f9403611f990a6773f40f4bfb4a07655 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 6 Jan 2023 12:01:55 -0800 Subject: [PATCH 19/27] Add example of landmark navigation with iframe --- .storybook/custom-addons/provider/index.js | 2 +- .../landmark/stories/Landmark.stories.tsx | 104 +++++++++++++++++- .../@react-aria/landmark/stories/index.css | 18 +++ 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/.storybook/custom-addons/provider/index.js b/.storybook/custom-addons/provider/index.js index 29ea9398a33..fca9d9977ea 100644 --- a/.storybook/custom-addons/provider/index.js +++ b/.storybook/custom-addons/provider/index.js @@ -19,7 +19,7 @@ function ProviderUpdater(props) { 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 + let [storyReady, setStoryReady] = useState(window.parent === window || window.parent !== window.top); // reduce content flash because it takes a moment to get the provider details // Typically themes are provided with both light + dark, and both scales. // To build our selector to see all themes, we need to hack it a bit. let theme = (expressValue ? expressThemes : themes)[themeValue || 'light'] || defaultTheme; diff --git a/packages/@react-aria/landmark/stories/Landmark.stories.tsx b/packages/@react-aria/landmark/stories/Landmark.stories.tsx index 8615322cbfe..a1d0622c85c 100644 --- a/packages/@react-aria/landmark/stories/Landmark.stories.tsx +++ b/packages/@react-aria/landmark/stories/Landmark.stories.tsx @@ -17,7 +17,7 @@ import {classNames, useStyleProps} from '@react-spectrum/utils'; import {Flex} from '@react-spectrum/layout'; import {Link} from '@react-spectrum/link'; import {Meta, Story} from '@storybook/react'; -import React, {useRef} from 'react'; +import React, {SyntheticEvent, useEffect, useRef} from 'react'; import {SearchField} from '@react-spectrum/searchfield'; import styles from './index.css'; import {TextField} from '@react-spectrum/textfield'; @@ -69,7 +69,7 @@ function Search(props) { let {styleProps} = useStyleProps(props); let {landmarkProps} = useLandmark({...props, role: 'search'}, ref); return ( -
+ ); @@ -313,6 +313,104 @@ function ApplicationExample() { ); } +function IframeExample() { + let onLoad = (e: SyntheticEvent) => { + let iframe = e.target as HTMLIFrameElement; + let window = iframe.contentWindow; + let document = window.document; + + let prevFocusedElement = null; + window.addEventListener('react-aria-landmark-navigation', (e: CustomEvent) => { + e.preventDefault(); + let el = document.activeElement; + if (el !== document.body) { + prevFocusedElement = el; + } + + // Prevent focus scope from stealing focus back when we move focus to the iframe. + document.body.setAttribute('data-react-aria-top-layer', 'true'); + + window.parent.postMessage({ + type: 'landmark-navigation', + direction: e.detail.direction + }); + + setTimeout(() => { + document.body.removeAttribute('data-react-aria-top-layer'); + }, 100); + }); + + // When the iframe is re-focused, restore focus back inside where it was before. + window.addEventListener('focus', () => { + if (prevFocusedElement) { + prevFocusedElement.focus(); + prevFocusedElement = null; + } + }); + + // Move focus to first or last landmark when we receive a message from the parent page. + window.addEventListener('message', e => { + if (e.data.type === 'landmark-navigation') { + document.body.dispatchEvent(new KeyboardEvent('keydown', {key: 'F6', shiftKey: e.data.direction === 'backward', bubbles: true})); + } + }); + }; + + useEffect(() => { + let onMessage = (e: MessageEvent) => { + let iframe = ref.current; + if (e.data.type === 'landmark-navigation') { + // Move focus to the iframe so that when focus is restored there, and we can redirect it back inside (below). + iframe.focus(); + + // Now re-dispatch the keyboard event so landmark navigation outside the iframe picks it up. + iframe.dispatchEvent(new KeyboardEvent('keydown', {key: 'F6', shiftKey: e.data.direction === 'backward', bubbles: true})); + } + }; + + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, []); + + let ref = useRef(null); + let {landmarkProps} = useLandmark({ + role: 'main', + focus(direction) { + // when iframe landmark receives focus via landmark navigation, go to first/last landmark inside iframe. + ref.current.contentWindow.postMessage({ + type: 'landmark-navigation', + direction + }); + } + }, ref); + + return ( +
+ + + React Spectrum + + + + + + One + Two + Three + + +