Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: S2 toast #7975

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open

feat: S2 toast #7975

wants to merge 28 commits into from

Conversation

devongovett
Copy link
Member

@devongovett devongovett commented Mar 21, 2025

This implements the Toast component in Spectrum 2, with the new toast stacking animation implemented with CSS view transitions. Style macros don't have an API for view transitions yet, and I haven't come up with a good API for that yet. In the meantime, I used a CSS module file for the toast animations.

A couple RAC Toast changes were needed:

  • I split out a new ToastList component from ToastRegion so that I could add some additional elements adjacent to the list, namely the clear/collapse buttons when the stack is expanded. If you pass a function directly to the ToastRegion as before then we'll render a ToastList for you. Not sure I'm totally happy with this API.
  • I needed to add a hover state to the ToastRegion so I could implement an animation.
  • I added an action parameter to the toast queue subscription function, which tells you what happened (e.g. a toast was added, removed, etc.). This allows us to change the animations accordingly.

Some stuff that's specific to Spectrum:

  • To implement clicking on the region to expand it, I attached a click event to the ref for now. Wasn't sure if we wanted to add press events to the region or not since it's kind of an unusual pattern.
  • In the expanded state, the whole toast lists acts kind of like a modal. There's a transparent backdrop that covers the rest of the page, we aria hide outside, prevent page scrolling, and contain focus.

Test instructions

Make sure to test the animations with reduce motion settings enabled as well. In this mode, the toasts fade in/out instead of animating their positions.

# Conflicts:
#	packages/@react-aria/toast/src/useToast.ts
#	packages/@react-aria/toast/test/useToast.test.js
#	packages/@react-spectrum/s2/package.json
#	packages/@react-spectrum/toast/test/ToastContainer.test.js
#	packages/@react-stately/toast/src/useToastState.ts
#	packages/react-aria-components/docs/Toast.mdx
#	packages/react-aria-components/package.json
#	packages/react-aria-components/src/Toast.tsx
#	packages/react-aria-components/src/index.ts
#	yarn.lock
# Conflicts:
#	packages/react-aria-components/src/Toast.tsx
@rspbot

This comment was marked as outdated.

@rspbot
Copy link

rspbot commented Mar 21, 2025

Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

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

Looks really really good, only have a couple comments so far.

When keyboard navigating, it's not always clear that focus has gone to the region. Especially when the region is the same focus scope, just behind all the others. So it looks like there is a bug taking two Tabs to get back into the newest toast after dismissing one. Other times, the region focus ring doesn't show at all.
First one:

Open a few toasts
Keyboard navigate to them
Expand all
Esc
Focus is on the region but kind of looks like it's on the top toast, and it takes two tabs to get back inside the toast

Second:

Open a few toasts
Keyboard navigate to them
Keyboard navigate to the close button
Top toast dismisses
Focus goes to the toast region, but is not visible

@rspbot
Copy link

rspbot commented Apr 8, 2025

Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

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

Just a heads up that I'm seeing a content jump depending on the toast I open/close. This is present on both my monitors and likely because my scrollbars are always on. I'm not sure why though. I can't find anything about the viewTransition hiding the scrollbars, so I'm not sure why they flicker briefly.

I'm assuming it has something to do with:

In the expanded state, the whole toast lists acts kind of like a modal. There's a transparent backdrop that covers the rest of the page, we aria hide outside, prevent page scrolling, and contain focus.

So I'm trying to see if it's briefly thinking that it's expanded. Update, it doesn't think it's briefly expanded, there is no rerender, as expected, of the ToastContainer. It's also not calling usePreventScroll.

Possibly related if I understood it better? [css-view-transitions-2] Define behavior of scrollbars and overflow with layered capture

Screen.Recording.2025-04-09.at.3.55.44.pm.mov

snowystinger
snowystinger previously approved these changes Apr 9, 2025
Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

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

Nothing I see blocking, the reduced motion looks good. Only after removing one in an expanded state does it still motion animate a bit. But I don't think we can realistically do much about that. If we instantly change the content I think that would be confusing.

children: (renderProps: {toast: QueuedToast<T>}) => ReactElement
}

export const ToastList = /*#__PURE__*/ (forwardRef as forwardRefType)(function ToastList<T>(props: ToastListProps<T>, ref: ForwardedRef<HTMLOListElement>) {
Copy link
Member

Choose a reason for hiding this comment

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

Can we have a test for the newly split out ToastList setup? It could be a chromatic of the s2 Toast, so long as there is something so we know if S2 Toasts stopped working catastrophically. We should get some actual s2 tests as well.

staticColor="white"
styles={style({gridArea: 'expand'})}
// Make the chevron line up with the toast text, even though there is padding within the button.
UNSAFE_style={{marginInlineStart: variant === 'neutral' ? -10 : 14}}
Copy link
Member

Choose a reason for hiding this comment

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

Roughly lined up?
Screenshot 2025-04-09 at 4 08 57 pm
Looks farther off on iphone mobile for the Neutral toast

Instead of doing this as an UNSAFE_style, we could wrap the ActionButton in a div, use that for the grid area and the negative margin, it'd then get all the right scaling, and we could do away with the unsafe's?

@LFDanLu LFDanLu added the release label Apr 9, 2025
@rspbot
Copy link

rspbot commented Apr 9, 2025

@rspbot
Copy link

rspbot commented Apr 9, 2025

## API Changes

react-aria-components

/react-aria-components:ColorWheelTrack

 ColorWheelTrack {
   className?: string | ((ColorWheelTrackRenderProps & {
     defaultClassName: string | undefined
 })) => string
+  id?: string
   style?: CSSProperties | ((ColorWheelTrackRenderProps & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

/react-aria-components:UNSTABLE_ToastRegion

 UNSTABLE_ToastRegion <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string = "Notifications"
   aria-labelledby?: string
-  children: ({
+  children: ReactNode | ({
     toast: QueuedToast<T>
 }) => ReactElement
   className?: string | ((ToastRegionRenderProps<T> & {
     defaultClassName: string | undefined
   queue: ToastQueue<T>
   style?: CSSProperties | ((ToastRegionRenderProps<T> & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

/react-aria-components:UNSTABLE_ToastQueue

 UNSTABLE_ToastQueue <T> {
   add: (T, ToastOptions) => string
+  clear: () => void
   close: (string) => void
   constructor: (ToastStateProps) => void
   pauseAll: () => void
   resumeAll: () => void
-  subscribe: (() => void) => () => boolean
+  subscribe: (() => void) => () => void
   visibleToasts: Array<QueuedToast<T>>
 }

/react-aria-components:GridLayout

 GridLayout <O extends GridLayoutOptions = GridLayoutOptions, T> {
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
-  getLayoutInfo: (Key) => LayoutInfo | null
+  getLayoutInfo: (Key) => LayoutInfo
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (GridLayoutOptions, GridLayoutOptions) => boolean
   update: (InvalidationContext<GridLayoutOptions>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

/react-aria-components:ColorWheelTrackProps

 ColorWheelTrackProps {
   className?: string | ((ColorWheelTrackRenderProps & {
     defaultClassName: string | undefined
 })) => string
+  id?: string
   style?: CSSProperties | ((ColorWheelTrackRenderProps & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

/react-aria-components:ToastRegionProps

 ToastRegionProps <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string = "Notifications"
   aria-labelledby?: string
-  children: ({
+  children: ReactNode | ({
     toast: QueuedToast<T>
 }) => ReactElement
   className?: string | ((ToastRegionRenderProps<T> & {
     defaultClassName: string | undefined
   queue: ToastQueue<T>
   style?: CSSProperties | ((ToastRegionRenderProps<T> & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

/react-aria-components:ToastRegionRenderProps

 ToastRegionRenderProps <T> {
   isFocusVisible: boolean
   isFocused: boolean
+  isHovered: boolean
   visibleToasts: Array<QueuedToast<T>>
 }

/react-aria-components:UNSTABLE_ToastList

+UNSTABLE_ToastList <T> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string = "Notifications"
+  aria-labelledby?: string
+  children: ({
+    toast: QueuedToast<T>
+}) => ReactElement
+  className?: string | ((ToastRegionRenderProps<T> & {
+    defaultClassName: string | undefined
+})) => string
+  style?: CSSProperties | ((ToastRegionRenderProps<T> & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}

/react-aria-components:UNSTABLE_ToastStateContext

+UNSTABLE_ToastStateContext {
+  UNTYPED
+}

/react-aria-components:ToastListProps

+ToastListProps <T> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string = "Notifications"
+  aria-labelledby?: string
+  children: ({
+    toast: QueuedToast<T>
+}) => ReactElement
+  className?: string | ((ToastRegionRenderProps<T> & {
+    defaultClassName: string | undefined
+})) => string
+  style?: CSSProperties | ((ToastRegionRenderProps<T> & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}

@react-aria/overlays

/@react-aria/overlays:Overlay

 Overlay {
   children: ReactNode
   disableFocusManagement?: boolean
   isExiting?: boolean
-  portalContainer?: Element = document.body
   shouldContainFocus?: boolean
 }

/@react-aria/overlays:OverlayProps

 OverlayProps {
   children: ReactNode
   disableFocusManagement?: boolean
   isExiting?: boolean
-  portalContainer?: Element = document.body
   shouldContainFocus?: boolean
 }

@react-spectrum/s2

/@react-spectrum/s2:UNSTABLE_ToastContainer

+UNSTABLE_ToastContainer {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string = "Notifications"
+  aria-labelledby?: string
+  className?: string | ((ToastRegionRenderProps<T> & {
+    defaultClassName: string | undefined
+})) => string
+  placement?: ToastPlacement = "bottom"
+  style?: CSSProperties | ((ToastRegionRenderProps<T> & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}

/@react-spectrum/s2:UNSTABLE_ToastQueue

+UNSTABLE_ToastQueue {
+  info: (string, ToastOptions) => CloseFunction
+  negative: (string, ToastOptions) => CloseFunction
+  neutral: (string, ToastOptions) => CloseFunction
+  positive: (string, ToastOptions) => CloseFunction
+}

/@react-spectrum/s2:AutocompleteContext

+AutocompleteContext {
+  UNTYPED
+}

/@react-spectrum/s2:AutocompleteStateContext

+AutocompleteStateContext {
+  UNTYPED
+}

/@react-spectrum/s2:ToastOptions

+ToastOptions {
+  actionLabel?: string
+  id?: string
+  onAction?: () => void
+  onClose?: () => void
+  shouldCloseOnAction?: boolean
+  timeout?: number
+}

/@react-spectrum/s2:ToastContainerProps

+ToastContainerProps {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string = "Notifications"
+  aria-labelledby?: string
+  className?: string | ((ToastRegionRenderProps<T> & {
+    defaultClassName: string | undefined
+})) => string
+  placement?: ToastPlacement = "bottom"
+  style?: CSSProperties | ((ToastRegionRenderProps<T> & {
+    defaultStyle: CSSProperties
+})) => CSSProperties | undefined
+}

@react-stately/layout

/@react-stately/layout:GridLayout

 GridLayout <O extends GridLayoutOptions = GridLayoutOptions, T> {
   getContentSize: () => Size
   getDropTargetFromPoint: (number, number, (DropTarget) => boolean) => DropTarget
   getDropTargetLayoutInfo: (ItemDropTarget) => LayoutInfo
-  getLayoutInfo: (Key) => LayoutInfo | null
+  getLayoutInfo: (Key) => LayoutInfo
   getVisibleLayoutInfos: (Rect) => Array<LayoutInfo>
   shouldInvalidate: (Rect, Rect) => boolean
   shouldInvalidateLayoutOptions: (GridLayoutOptions, GridLayoutOptions) => boolean
   update: (InvalidationContext<GridLayoutOptions>) => void
   virtualizer: Virtualizer<{}, any> | null
 }

@react-stately/toast

/@react-stately/toast:ToastQueue

 ToastQueue <T> {
   add: (T, ToastOptions) => string
+  clear: () => void
   close: (string) => void
   constructor: (ToastStateProps) => void
   pauseAll: () => void
   resumeAll: () => void
-  subscribe: (() => void) => () => boolean
+  subscribe: (() => void) => () => void
   visibleToasts: Array<QueuedToast<T>>
 }

/@react-stately/toast:ToastStateProps

 ToastStateProps {
   maxVisibleToasts?: number
-  wrapUpdate?: (() => void) => void
+  wrapUpdate?: (() => void, ToastAction) => void
 }

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

just a few comments from local testing but otherwise seems good enough for an alpha

Comment on lines +46 to +53
export interface ToastOptions extends Omit<RACToastOptions, 'priority'>, DOMProps {
/** A label for the action button within the toast. */
actionLabel?: string,
/** 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
}
Copy link
Member

Choose a reason for hiding this comment

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

Would be nice to mention that the minimum time that the toast stays open is 5 seconds in the prop description, otherwise people might get confused why their provided timeout doesn't seem to work. Would also be nice to tie the storybook onAction to a control but that can be followup

Copy link
Member

Choose a reason for hiding this comment

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

I noticed that the view transition prevents subsequent clicks from actually happening while the transition is in effect which seems to be an intentional behavior of view transitions. Do we want to hack around it and keep the page interactive while transitioning or do we deem it a unlikely case that someone might try to click on a different element while a Toast is appearing/exiting?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, see
#7838 (comment)

Copy link
Member

Choose a reason for hiding this comment

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

ah derp, forgot it was mentioned there. Seems we could do something like this though: https://www.bram.us/2025/01/29/view-transitions-page-interactivity/ perhaps?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants