Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/conceptual-guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ add-on-driven-configuration
cookieplone-frontend-add-on
routing
slots
toasts
```
87 changes: 87 additions & 0 deletions docs/conceptual-guides/toasts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
myst:
html_meta:
"description": "An explanation of the toast notification framework in Plone Aurora, including its architecture, visual variants, and the countdown bar."
"property=og:description": "An explanation of the toast notification framework in Plone Aurora, including its architecture, visual variants, and the countdown bar."
"property=og:title": "Toast notifications"
"keywords": "Plone Aurora, Plone, toast, notification, React Aria, @plone/layout"
---

# Toast notifications

Plone Aurora ships a global toast framework based on the `UNSTABLE_Toast` primitives from React Aria Components.
It is registered by `@plone/layout` and reachable from anywhere in the app via `@plone/registry`, so add-ons can confirm successful actions, surface route errors, or report background task progress without each one having to mount its own region.

See a [demo of toasts](https://plone-storybook.readthedocs.io/?path=/docs/layout_toast--docs).

For task-oriented guidance, see {doc}`../how-to-guides/show-toasts`.


## Architecture

The framework has three layers: a single shared queue, three registry utilities that wrap it, and a visual region that subscribes to the queue and renders each toast.


### The queue and registry utilities

In the package `@plone/layout`, its file {file}`packages/layout/config/toast.ts` exports a module-level `ToastQueue<ToastItem>` and an `install()` function.

The queue wraps state updates with the function `document.startViewTransition` when the browser supports it, falling back to a plain update otherwise.
Enter and exit animations stay in sync with React.

The `install()` function registers three utilities in `@plone/registry` for working with toasts:

`{ type: 'toast', name: 'queue' }`
: Returns the queue itself.
This is useful to subscribe or render a custom region.

`{ type: 'toast', name: 'show' }`
: Adds a `ToastItem` to the queue and returns its key.

`{ type: 'toast', name: 'dismiss' }`
: Closes the toast identified by a key returned from `show`.


### The region and CSS

The visual `<Toast>` region is defined in {file}`packages/layout/components/Toast/Toast.tsx`.
It's mounted once per layout, so toasts appear regardless of which route the user is on.

The shared CSS lives in {file}`components/src/styles/basic/Toast.css`.
It defines:

- The region anchor (`.react-aria-ToastRegion`) and base toast container (`.react-aria-Toast`).
- Four visual variants picked up from the toast's `className`: the default (Quanta `denim`), `.success` (`turtle`), `.info` (`royal`), and `.error` (`wine`).
All colors reference Quanta tokens with hexadecimal fallbacks, so the styles render correctly outside a Quanta theme.
- The countdown bar (`.react-aria-Toast-progress`) rendered along the bottom edge of every timed toast.


## Countdown bar

Every timed toast renders a thin progress bar pinned to its bottom edge.
The bar shrinks from full width to empty over the toast's `timeout` duration, giving the user a visual cue for how long the toast will remain on screen.

A toast appears and behaves as described below.

- Drawn as a single absolutely-positioned `<div>` inside the toast, clipped to the toast's rounded corners.
- Background color is a lightening overlay, implemented with (`rgba(255, 255, 255, 0.35)`).
It works against every variant background without per-variant styling.
- Pauses on hover or focus, mirroring React Aria's own pause-timers behavior.
- Never renders for sticky toasts, either declared with `timeout: null` or when the caller sets `showProgress: false` on the item.
- Marked `aria-hidden`, because it's decorative, not announced.


## Where toasts appear today

The region is mounted in the following files.

{file}`packages/cmsui/routes/layout.tsx`
: every editor route

{file}`packages/publicui/routes/index.tsx`
: every visitor-facing page

{file}`packages/contents/routes/layout.tsx`
: the `/contents` UI

`@plone/layout` is installed by `@plone/aurora`, so the queue and utilities are available in any package that depends on it.
1 change: 1 addition & 0 deletions docs/how-to-guides/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ extend-vite-configuration
access-registry
register-and-retrieve-components
register-and-retrieve-utilities
show-toasts
configure-style-fields
register-slots
customize-login-screen
Expand Down
145 changes: 145 additions & 0 deletions docs/how-to-guides/show-toasts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
myst:
html_meta:
"description": "How to show toast notifications in Plone Aurora using the @plone/layout toast framework."
"property=og:description": "How to show toast notifications in Plone Aurora using the @plone/layout toast framework."
"property=og:title": "Show toast notifications"
"keywords": "Plone Aurora, Plone, toast, notification, React Aria, @plone/layout"
---

# Show toast notifications

Plone Aurora ships a global toast framework based on the `UNSTABLE_Toast` primitives from React Aria Components.
It is registered by `@plone/layout` and reachable from anywhere in the app via `@plone/registry`.
Use it to confirm successful actions, surface route errors, or report background task progress.

Comment thread
InteraktivPreuss marked this conversation as resolved.
See a [demo of toasts](https://plone-storybook.readthedocs.io/?path=/docs/layout_toast--docs).

For an explanation of how the framework is wired and where the region is mounted, see {doc}`../conceptual-guides/toasts`.


## Show a toast

```ts
import config from '@plone/registry';

config.getUtility({ type: 'toast', name: 'show' }).method({
title: 'Saved',
description: 'Your changes have been saved.',
});
```

The fields on `ToastItem` are:

`title`
: Required.
Announced first by screen readers.

`description`
: Optional supporting copy rendered below the title.

`icon`
: Optional leading `ReactNode` rendered before the title.

`className`
: Optional modifier class.
Pass `'success'`, `'info'`, or `'error'` to use one of the variants that ship in {file}`Toast.css`.
Project add-ons can register their own classes for additional toast alert styles.

`showProgress`
: Whether to render the auto-dismiss countdown bar at the bottom of the toast.
Defaults to `true`.
Pass `false` for short confirmations where the bar adds visual noise.
Ignored for sticky toasts (`timeout: null`), which never render a bar because there is no countdown to draw.


## Customize the timeout

`show` accepts an options object as a second argument:

```ts
config.getUtility({ type: 'toast', name: 'show' }).method(
{ title: 'Uploading…', description: 'Hold on.' },
{ timeout: 8000 },
);
```

`timeout`
: An integer representing the duration in milliseconds after which a toast will be auto-dismissed.
Defaults to the value set by `DEFAULT_TOAST_TIMEOUT_MS`, currently 6000 milliseconds, exported from {file}`layout/config/toast.ts`.
Pass `null` to require manual dismissal, which is useful for long-running operations that resolve via {ref}`dismiss <show-toasts-dismiss>`.
React Aria recommends a minimum of 5 seconds, so people who use a screen reader have time to read the announcement.

`onClose`
: Fires when the toast is removed for any reason, including auto-dismiss, close button, and programmatic dismiss.


(show-toasts-dismiss)=

## Dismiss a toast programmatically

`show` returns an opaque `ToastKey`.
Pass it to the `dismiss` utility to remove the toast from code:

```ts
import config from '@plone/registry';

const show = config.getUtility({ type: 'toast', name: 'show' }).method;
const dismiss = config.getUtility({ type: 'toast', name: 'dismiss' }).method;

const key = show(
{ title: 'Uploading…' },
{ timeout: null },
);

// later, when the upload finishes:
dismiss(key);
```


## Surface route errors

`@plone/layout` exposes an `ErrorToast` helper for React Router error boundaries.
It reads the route error and pushes a styled toast.

```tsx
import config from '@plone/registry';
import ErrorToast from '@plone/layout/components/Toast/ErrorToast';

export function ErrorBoundary() {
const queue = config.getUtility({ type: 'toast', name: 'queue' }).method();
return ErrorToast(queue);
}
```


## Render your own region

The default `<Toast>` region covers the common case, including bottom-center, dismissable, and view-transition animations.
For a different layout—for example, a side-panel region or a region scoped to a specific route—render `UNSTABLE_ToastRegion` directly, and pass the shared queue:

```tsx
import {
UNSTABLE_Toast as Toast,
UNSTABLE_ToastContent as ToastContent,
UNSTABLE_ToastRegion as ToastRegion,
} from 'react-aria-components';
import config from '@plone/registry';

function MyCustomRegion() {
const queue = config.getUtility({ type: 'toast', name: 'queue' }).method();
return (
<ToastRegion queue={queue} aria-label="Background tasks">
{({ toast }) => (
<Toast toast={toast}>
<ToastContent>{/* … */}</ToastContent>
</Toast>
)}
</ToastRegion>
);
}
```

Multiple regions can share the same queue.
Each rendered region receives every queued toast, so use them carefully.
Typically, one global region per layout is enough.
1 change: 1 addition & 0 deletions packages/cmsui/news/+toast-region.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Mounted the global toast region in the `cmsui` layout, so toasts triggered from editor routes render. @InteraktivPreuss
5 changes: 5 additions & 0 deletions packages/cmsui/routes/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import i18next from '@plone/aurora/app/i18next.server';
import type { RootLoader } from '@plone/aurora/app/root';
import { PluggablesProvider } from '@plone/layout/components/Pluggable';
import Toolbar from '@plone/layout/components/Toolbar/Toolbar';
import Toast from '@plone/layout/components/Toast/Toast';
import { shouldShowToolbar } from '@plone/layout/helpers';
import config from '@plone/registry';

import stylesheet from '@plone/aurora/.plone/cmsui.css?url';
import { ploneContentContext } from '@plone/aurora/app/middleware.server';
Expand Down Expand Up @@ -105,6 +107,9 @@ export default function Index() {
</div>
</PluggablesProvider>
</RACRouterProvider>
<Toast
queue={config.getUtility({ name: 'queue', type: 'toast' }).method()}
/>
<ScrollRestoration />
<Scripts />
</body>
Expand Down
1 change: 1 addition & 0 deletions packages/components/news/+toast-variants.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added `success`, `info`, and `error` variants and an auto-dismiss countdown bar to `Toast.css`, and switched its hardcoded hex values to Quanta tokens with fallbacks. @InteraktivPreuss
59 changes: 50 additions & 9 deletions packages/components/src/styles/basic/Toast.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.react-aria-ToastRegion {
--toast-bg: #021322;
--toast-fg: #fff;
--toast-bg: var(--color-quanta-denim, #021322);
--toast-fg: var(--color-quanta-air, #fff);

position: fixed;

Expand All @@ -23,22 +23,32 @@

.react-aria-Toast {
&.error {
--toast-bg: #6d030a;
--toast-bg: var(--color-quanta-wine, #a91c09);
}

&.success {
--toast-bg: var(--color-quanta-turtle, #256619);
}

&.info {
--toast-bg: var(--color-quanta-royal, #085696);
}

position: relative;
display: flex;
overflow: hidden;
align-items: center;
padding: 0.8rem 1rem;
border-radius: 6px;
background: var(--toast-bg);
box-shadow: 0px 2px 4px 0px #f0f1f2;
box-shadow: 0px 2px 4px 0px var(--color-quanta-snow, #f3f5f7);
color: var(--toast-fg);
gap: 16px;
outline: none;

&[data-focus-visible] {
outline: 2px solid var(--toast-bg);
outline-offset: 2px;
outline: 2px solid var(--toast-fg);
outline-offset: -2px;
}

.react-aria-ToastContent {
Expand Down Expand Up @@ -72,13 +82,44 @@
outline: none;

&[data-focus-visible] {
box-shadow:
0 0 0 2px var(--toast-bg),
0 0 0 4px var(--toast-fg);
outline: 2px solid var(--toast-fg);
outline-offset: -2px;
}

&[data-pressed] {
background: rgba(255, 255, 255, 0.2);
}
}

.react-aria-Toast-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
animation: react-aria-Toast-progress var(--toast-duration, 6000ms) linear
forwards;
background: rgba(255, 255, 255, 0.35);
transform-origin: left center;
}
}

.react-aria-ToastRegion:hover .react-aria-Toast-progress,
.react-aria-ToastRegion:focus-within .react-aria-Toast-progress {
animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
.react-aria-Toast-progress {
animation: none;
}
}

@keyframes react-aria-Toast-progress {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
Loading
Loading