diff --git a/.changeset/toast-v1.md b/.changeset/toast-v1.md
new file mode 100644
index 00000000..7f4aa248
--- /dev/null
+++ b/.changeset/toast-v1.md
@@ -0,0 +1,5 @@
+---
+'@human-kit/ui': minor
+---
+
+Add the Toast primitive with `Provider`, `Viewport`, `Positioner`, `Root`, `Content`, `Title`, `Description`, `Action` and `Close`. The manager on `useToastManager()` or `bind:manager` has `add`, `update`, `close` and `promise`. The viewport is a `role="region"` landmark named with the count of toasts, with `F6` in and out, and two live regions beside it announce each toast without its buttons. Each toast is a `dialog` that is not modal, or an `alertdialog` for a high priority, with `Escape` to close, a swipe that follows the finger, and an exit animation from its CSS. `Toast.Positioner` puts a toast with an `anchor` against that element. The timers stop on hover, on keyboard focus and in a hidden tab, the newest toasts stay up to a limit, and the viewport stays reachable behind a modal dialog. The `ariaHideOutside` primitive learned `data-hk-hide-outside-exempt`, and the focus trap leaves `Tab` to a surface with that attribute while the focus is in it.
diff --git a/docs/src/content/toast/api.json b/docs/src/content/toast/api.json
new file mode 100644
index 00000000..395ec84c
--- /dev/null
+++ b/docs/src/content/toast/api.json
@@ -0,0 +1,404 @@
+{
+ "component": "toast",
+ "parts": [
+ {
+ "name": "Provider",
+ "description": "The list of toasts and the timers. It renders nothing of its own.",
+ "props": [
+ {
+ "name": "timeout",
+ "type": "number",
+ "required": false,
+ "default": "DEFAULT_TOAST_TIMEOUT",
+ "description": "The default time a toast stays, in milliseconds. `0` keeps each toast until a close."
+ },
+ {
+ "name": "limit",
+ "type": "number",
+ "required": false,
+ "default": "DEFAULT_TOAST_LIMIT",
+ "description": "The count of toasts on the screen. The older ones past it wait behind, hidden and inert,\nwith their timers stopped, and they come forward as the newer ones close."
+ },
+ {
+ "name": "manager",
+ "type": "ToastManager",
+ "required": false,
+ "default": null,
+ "description": "The manager: `add`, `update`, `close` and `promise`. Use `bind:manager` to read it in the\npage, or give one to share a list of toasts between two providers."
+ },
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The app, with a `Toast.Viewport` somewhere in it."
+ }
+ ],
+ "dataAttributes": []
+ },
+ {
+ "name": "Viewport",
+ "description": "The region where the toasts land. It makes a div with role=\"region\", at the end of the body, and it renders the snippet for each toast.",
+ "props": [
+ {
+ "name": "children",
+ "type": "Snippet<[ToastItem]>",
+ "required": true,
+ "default": null,
+ "description": "The toast to render for each item. It gets the item; render a `Toast.Root` with it."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ },
+ {
+ "name": "portal",
+ "type": "boolean",
+ "required": false,
+ "default": "true",
+ "description": "Renders the viewport at the end of `
`, thus it draws above the page. Set it to\n`false` to render it in place."
+ },
+ {
+ "name": "element",
+ "type": "HTMLDivElement | null",
+ "required": false,
+ "default": "null",
+ "description": "The bindable viewport element."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-expanded",
+ "description": "Present while the pointer rests on the viewport, or the focus is in it. A tap on a toast, not a swipe, holds it on until a touch outside the viewport."
+ },
+ {
+ "name": "data-toast-viewport",
+ "description": "Identifies the viewport element."
+ }
+ ]
+ },
+ {
+ "name": "Positioner",
+ "description": "Puts a toast with an anchor against that element, as a fixed div. For a toast without one, it is out of the layout.",
+ "props": [
+ {
+ "name": "toast",
+ "type": "ToastItem",
+ "required": true,
+ "default": null,
+ "description": "The item from `Toast.Viewport`."
+ },
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The `Toast.Root`."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ },
+ {
+ "name": "element",
+ "type": "HTMLDivElement | null",
+ "required": false,
+ "default": "null",
+ "description": "The bindable positioner element."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-anchored",
+ "description": "Present when the item has an anchor."
+ },
+ {
+ "name": "data-placement",
+ "description": "The side of the anchor where the toast sits, after a flip."
+ },
+ {
+ "name": "data-toast-positioner",
+ "description": "Identifies the positioner element."
+ }
+ ]
+ },
+ {
+ "name": "Root",
+ "description": "One toast. It makes a div with role=\"dialog\", or role=\"alertdialog\" for a high priority, that is not modal.",
+ "props": [
+ {
+ "name": "toast",
+ "type": "ToastItem",
+ "required": true,
+ "default": null,
+ "description": "The item from `Toast.Viewport`."
+ },
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The parts of the toast."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ },
+ {
+ "name": "swipeDirection",
+ "type": "SwipeSide[]",
+ "required": false,
+ "default": "['bottom', 'right']",
+ "description": "The sides a swipe can push the toast out through: `bottom` and `right` by default. One side\nper axis counts. An empty array turns the swipe off. A swipe on `Toast.Close` or\n`Toast.Action` never starts."
+ },
+ {
+ "name": "element",
+ "type": "HTMLDivElement | null",
+ "required": false,
+ "default": "null",
+ "description": "The bindable toast element."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-anchored",
+ "description": "Present when the item has an anchor. The toast is out of the stack."
+ },
+ {
+ "name": "data-ending",
+ "description": "Present through the exit animation."
+ },
+ {
+ "name": "data-entering",
+ "description": "Present through the enter animation."
+ },
+ {
+ "name": "data-exiting",
+ "description": "Present through the exit animation."
+ },
+ {
+ "name": "data-expanded",
+ "description": "Present while the pointer rests on the viewport, or the focus is in it. A tap on a toast, not a swipe, holds it on until a touch outside the viewport."
+ },
+ {
+ "name": "data-front",
+ "description": "Present on the one toast in front. A toast on its way out drops it, and it keeps the place it had for its exit."
+ },
+ {
+ "name": "data-limited",
+ "description": "Present on a toast past the limit. It is inert, and it waits in the place behind the stack."
+ },
+ {
+ "name": "data-priority",
+ "description": "The priority of the item: `low` or `high`."
+ },
+ {
+ "name": "data-swipe-direction",
+ "description": "The side of the swipe in progress, or of the swipe that dismissed the toast."
+ },
+ {
+ "name": "data-swipe-dismissed",
+ "description": "Present when a swipe dismissed the toast, for the exit."
+ },
+ {
+ "name": "data-swiping",
+ "description": "Present while a swipe is in progress."
+ },
+ {
+ "name": "data-toast-id",
+ "description": "The id of the item."
+ },
+ {
+ "name": "data-toast-root",
+ "description": "Identifies the toast element."
+ },
+ {
+ "name": "data-type",
+ "description": "The `type` of the item, such as `success` or `loading`."
+ }
+ ]
+ },
+ {
+ "name": "Content",
+ "description": "The message: the title and the description. Keep the buttons out of it.",
+ "props": [
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The title and the description."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-priority",
+ "description": "The priority of the item: `low` or `high`."
+ },
+ {
+ "name": "data-toast-content",
+ "description": "Identifies the content element."
+ },
+ {
+ "name": "data-type",
+ "description": "The `type` of the item, such as `success` or `loading`."
+ }
+ ]
+ },
+ {
+ "name": "Title",
+ "description": "The name of the toast. It gives its id to the toast as `aria-labelledby`, and it shows the `title` of the item without children.",
+ "props": [
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The title. Without children, it shows the `title` of the item."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "required": false,
+ "default": null,
+ "description": "The id of the element. The component makes one when you give none."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-toast-title",
+ "description": "Identifies the title element."
+ }
+ ]
+ },
+ {
+ "name": "Description",
+ "description": "The message of the toast. It gives its id to the toast as `aria-describedby`, and it shows the `description` of the item without children.",
+ "props": [
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The description. Without children, it shows the `description` of the item."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "required": false,
+ "default": null,
+ "description": "The id of the element. The component makes one when you give none."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-toast-description",
+ "description": "Identifies the description element."
+ }
+ ]
+ },
+ {
+ "name": "Close",
+ "description": "A button that closes the toast.",
+ "props": [
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The name on the button."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ },
+ {
+ "name": "element",
+ "type": "HTMLButtonElement | null",
+ "required": false,
+ "default": "null",
+ "description": "The bindable button element."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-toast-close",
+ "description": "Identifies the close button."
+ }
+ ]
+ },
+ {
+ "name": "Action",
+ "description": "The one thing the toast offers. The press closes the toast.",
+ "props": [
+ {
+ "name": "children",
+ "type": "Snippet",
+ "required": false,
+ "default": null,
+ "description": "The name on the button."
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "''",
+ "description": "The CSS class names of the element."
+ },
+ {
+ "name": "keepOpen",
+ "type": "boolean",
+ "required": false,
+ "default": "false",
+ "description": "Keeps the toast open after the press. By default, the press closes it."
+ },
+ {
+ "name": "element",
+ "type": "HTMLButtonElement | null",
+ "required": false,
+ "default": "null",
+ "description": "The bindable button element."
+ }
+ ],
+ "dataAttributes": [
+ {
+ "name": "data-toast-action",
+ "description": "Identifies the action button."
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/src/content/toast/demos/action.svelte b/docs/src/content/toast/demos/action.svelte
new file mode 100644
index 00000000..a0507476
--- /dev/null
+++ b/docs/src/content/toast/demos/action.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ {#snippet children(toast)}
+
+
+
+
+ (deleted = false)}
+ class="text-sm font-medium text-neutral-900 underline outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-neutral-900 dark:text-white dark:focus-visible:outline-white"
+ >
+ Undo
+
+
+
+
+
+ {/snippet}
+
+
+
+
diff --git a/docs/src/content/toast/demos/anchored.svelte b/docs/src/content/toast/demos/anchored.svelte
new file mode 100644
index 00000000..3b515896
--- /dev/null
+++ b/docs/src/content/toast/demos/anchored.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+ {#snippet children(toast)}
+
+
+
+
+
+
+
+ {/snippet}
+
+
+
+
diff --git a/docs/src/content/toast/demos/hero.svelte b/docs/src/content/toast/demos/hero.svelte
new file mode 100644
index 00000000..46d88f4e
--- /dev/null
+++ b/docs/src/content/toast/demos/hero.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/src/content/toast/demos/promise.svelte b/docs/src/content/toast/demos/promise.svelte
new file mode 100644
index 00000000..c8f2eaa8
--- /dev/null
+++ b/docs/src/content/toast/demos/promise.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
diff --git a/docs/src/content/toast/demos/viewport.svelte b/docs/src/content/toast/demos/viewport.svelte
new file mode 100644
index 00000000..7ad29792
--- /dev/null
+++ b/docs/src/content/toast/demos/viewport.svelte
@@ -0,0 +1,161 @@
+
+
+
+
+ {#snippet children(toast)}
+
+
+
+
+
+
+
+
+
+ {/snippet}
+
+
+
diff --git a/docs/src/content/toast/index.md b/docs/src/content/toast/index.md
new file mode 100644
index 00000000..a7f019e3
--- /dev/null
+++ b/docs/src/content/toast/index.md
@@ -0,0 +1,183 @@
+---
+title: Toast
+description: A short message about an event, for a time, in a landmark the keyboard reaches with F6, announced by a live region, with timers that stop while the user reads, a limit, a swipe to dismiss, and a promise that turns loading into success or error.
+---
+
+
+
+# Toast
+
+`Toast` shows a short message about an event, for a time, without a stop of the work. The provider holds the list, and the viewport is the landmark where the toasts land. A screen reader hears each message from a live region beside them.
+
+
+
+## Anatomy
+
+`Toast.Provider` holds the list of toasts and the timers. Put one around the app. `Toast.Viewport` is the region where the toasts land. Put one in the provider, and give it a snippet that renders a `Toast.Root` for each item.
+
+`Toast.Content` holds `Toast.Title` and `Toast.Description`. `Toast.Action` and `Toast.Close` are the buttons, outside the content. `Toast.Positioner` is optional: it puts a toast with an `anchor` against that element.
+
+```svelte
+
+
+
+
+
+ {#snippet children(toast)}
+
+
+
+
+
+ x
+
+ {/snippet}
+
+
+```
+
+## The manager
+
+Call `useToastManager()` in a component under the provider, or use `bind:manager` on the provider. The manager has `add`, `update`, `close` and `promise`.
+
+`add` takes `title`, `description`, `type`, `timeout`, `priority` and `data`, and it answers the id of the toast. With the id of a toast that is on the screen, `add` updates it. `update` replaces the fields it names, and it gives the toast its full time again. `close` closes one toast, or all of them without an id.
+
+```svelte
+
+
+
+```
+
+The title and the description are text. That text is what the screen reader hears. `Toast.Title` and `Toast.Description` show it without children, and your own children replace it on the screen.
+
+## A promise
+
+`promise` adds a `loading` toast, which stays until the promise settles. It then turns into `success` or `error`, with the text for each state, and the timer starts.
+
+
+
+## An action
+
+`Toast.Action` is the one thing the toast offers, such as `Undo`. The press closes the toast, because the offer is taken. `keepOpen` holds it for an action that changes the toast instead.
+
+
+
+## Timers
+
+A toast stays `timeout` milliseconds: 5000 by default, on the provider or on the toast. `0` keeps it until a close.
+
+The timers stop while the pointer rests on the viewport, and while the keyboard focus is in it. A touch has no hover: a tap on a toast holds the stack open, and the timers with it, until a touch outside the viewport. A tap lifts where it landed. A swipe is not a tap, and a press on a button is not one. They also stop while the tab is hidden. They start again with the time each toast had left. A toast that closes while the user reads it is a toast the user did not read.
+
+## The stack
+
+The viewport shows the newest toasts up to `limit`: 3 by default. The older ones wait behind, hidden and inert, with their timers stopped, and they come forward as the newer ones close.
+
+Each toast gets `--toast-index`, `--toast-offset-y` and `--toast-height`, and the viewport gets `--toast-frontmost-height`. `data-front` marks the one toast in front. A toast on its way out drops it, and it keeps the place it had. `data-expanded` is on the viewport and on each toast while the pointer rests on the viewport, or while the focus is in it. A tap on a toast holds it on until a touch outside the viewport. The viewport of the first demo stacks the toasts in the corner with them, and spreads them out on `data-expanded`. Its source is in `viewport.svelte`, under the demo.
+
+```css
+.toast {
+ position: absolute;
+ inset-block-end: 0;
+ transform: translateY(calc(var(--toast-index) * -12px)) scale(calc(1 - var(--toast-index) * 0.05));
+}
+
+.toast[data-expanded] {
+ transform: translateY(calc(-1 * var(--toast-offset-y) - var(--toast-index) * 8px));
+}
+```
+
+A toast that closes is out of the stack at once: the ones behind it move up while it fades. It keeps the `--toast-index` and `--toast-offset-y` it had, thus its exit runs from its place. A rule that hides the toasts behind must let it pass, with `:not([data-ending])`.
+
+A toast past the limit waits in the place behind the stack. It comes forward one step when a place frees.
+
+The pointer must not leave the viewport on its way from one toast to the next. The spread stack has a gap between two toasts, and a pointer in the gap is out of all of them. Put a pseudo-element on the toast that covers the gap, as the demo does.
+
+```css
+.toast::after {
+ content: '';
+ position: absolute;
+ inset-inline: 0;
+ inset-block-start: 100%;
+ height: 8px;
+}
+```
+
+## Motion
+
+`data-entering` is on the toast through the enter motion, and `data-exiting` through the exit motion. The toast leaves the DOM when the exit motion ends. Write the enter as an animation on `data-entering`. The attribute stays on for the whole motion, thus a transition would sit still in the start state. Write the exit as a transition on `data-exiting`: it is the end state, and the motion runs from where the toast is. Keep the place in the stack in that end state. A `transform` that reads only `translateY(1rem)` puts a toast from behind under the front one for its exit.
+
+Move the enter with `translate`, and leave `transform` to the place in the stack. A toast that comes in while the next one arrives is pushed back at that moment. With the enter on `transform` too, the two fight for the property, and the toast jumps to its new place.
+
+```css
+.toast {
+ transform: translateY(calc(var(--toast-index) * -12px));
+ transition: transform 0.4s;
+}
+
+.toast[data-entering] {
+ animation: toast-in 0.4s ease-out;
+}
+
+.toast[data-exiting] {
+ opacity: 0;
+ transition: opacity 0.3s;
+}
+
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ translate: 0 1rem;
+ }
+}
+```
+
+## A swipe
+
+A swipe pushes the toast out through one of `swipeDirection`: `bottom` and `right` by default. The toast follows the finger with `--toast-swipe-movement-x` and `--toast-swipe-movement-y`, and it comes back when the swipe is short. A pull the other way gives 8 px at most, thus the toast behind stays behind. `data-swipe-dismissed` and `data-swipe-direction` are on the toast for the exit.
+
+A swipe never starts on `Toast.Close` or `Toast.Action`. Put `data-hk-swipe-ignore` on other content of your own that must not start one, such as a slider in the toast.
+
+## Against an element
+
+Give `anchor` to `add`, with an optional `placement` and `offset`, for a toast that sits against an element in place of the corner. Put `Toast.Positioner` around `Toast.Root` in the viewport snippet. For a toast without an anchor, the positioner is out of the layout, thus one snippet serves both kinds. A toast with an anchor is out of the stack: `--toast-index` reads 0, and `data-anchored` is on the root.
+
+
+
+## Usage guidelines
+
+- Keep the default `timeout` at 5 seconds or more. A user who reads slowly needs the time, and a user with a screen magnifier can miss a short toast.
+- Use `priority: 'high'` only for a message that cannot wait. It interrupts the screen reader.
+- Put a message the user must act on in a `Dialog`, not in a toast. A toast goes away.
+- Keep the buttons out of `Toast.Content`. The announcement reads the content, not the buttons.
+- Give the app one viewport. Two providers can share one `manager`, for example an app and a dialog in it.
+
+## Accessibility
+
+- `Toast.Viewport` is a `role="region"` landmark, named with the count of toasts. `F6` moves the focus into it from anywhere on the page, and back. A `Tab` past the last button goes back to where the focus was.
+- Two live regions beside the viewport announce each toast: `role="status"` for the normal priority, and `role="alert"` for the high priority. They are on the page before the first toast, thus the first toast is announced too. Two toasts in the same tick are two messages.
+- `Toast.Root` is a `role="dialog"` that is not modal, or an `alertdialog` for the high priority. It has `aria-labelledby` from the title and `aria-describedby` from the description, and it is a tab stop. A toast without a title takes its name from the description: a dialog without a name is a fault.
+- `Escape` closes the focused toast. The focus moves to the next toast, or back to where it was.
+- The viewport stays reachable behind a modal `Dialog`: `F6` moves the focus into it, `Tab` moves in it, and a `Tab` past its last button goes back to the dialog.
+
+## API reference
+
+
diff --git a/docs/src/lib/docs/nav.ts b/docs/src/lib/docs/nav.ts
index 130f45b7..a17d817e 100644
--- a/docs/src/lib/docs/nav.ts
+++ b/docs/src/lib/docs/nav.ts
@@ -56,6 +56,7 @@ export const nav: NavGroup[] = [
{ slug: 'drawer', title: 'Drawer' },
{ slug: 'menu', title: 'Menu' },
{ slug: 'popover', title: 'Popover' },
+ { slug: 'toast', title: 'Toast' },
{ slug: 'tooltip', title: 'Tooltip' }
]
},
diff --git a/docs/src/routes/+layout.svelte b/docs/src/routes/+layout.svelte
index 289c5b64..eb856061 100644
--- a/docs/src/routes/+layout.svelte
+++ b/docs/src/routes/+layout.svelte
@@ -18,8 +18,10 @@
of their own, because Svelte dedupes the former and not the latter. -->
+
{#if dev && !isBench}
-
+
{/if}
{@render children()}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 433bdc3b..001c564d 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -128,6 +128,11 @@
"svelte": "./dist/timepicker/index.js",
"default": "./dist/timepicker/index.js"
},
+ "./toast": {
+ "types": "./dist/toast/index.d.ts",
+ "svelte": "./dist/toast/index.js",
+ "default": "./dist/toast/index.js"
+ },
"./dialog": {
"types": "./dist/dialog/index.d.ts",
"svelte": "./dist/dialog/index.js",
diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts
index 57f372f9..b1c62abf 100644
--- a/packages/ui/src/lib/index.ts
+++ b/packages/ui/src/lib/index.ts
@@ -29,6 +29,7 @@ export { Switch } from './switch/index.js';
export { Tooltip } from './tooltip/index.js';
export { Table } from './table/index.js';
export { Tabs } from './tabs/index.js';
+export { Toast } from './toast/index.js';
export { Toggle } from './toggle/index.js';
export { ToggleGroup } from './toggle-group/index.js';
export { TransferList } from './transfer-list/index.js';
@@ -77,6 +78,7 @@ export * from './switch/index.js';
export * from './tooltip/index.js';
export * from './table/index.js';
export * from './tabs/index.js';
+export * from './toast/index.js';
export * from './toggle/index.js';
export * from './toggle-group/index.js';
export * from './transfer-list/index.js';
diff --git a/packages/ui/src/lib/internal/localized-strings.ts b/packages/ui/src/lib/internal/localized-strings.ts
index a0db04f8..2b37d4d4 100644
--- a/packages/ui/src/lib/internal/localized-strings.ts
+++ b/packages/ui/src/lib/internal/localized-strings.ts
@@ -369,6 +369,30 @@ const LOCALIZED_STRINGS = {
fr: 'Vide',
de: 'Leer',
it: 'Vuoto'
+ },
+ 'toast.notifications': {
+ en: 'Notifications',
+ es: 'Notificaciones',
+ pt: 'Notificações',
+ fr: 'Notifications',
+ de: 'Benachrichtigungen',
+ it: 'Notifiche'
+ },
+ 'toast.oneNotification': {
+ en: '1 notification',
+ es: '1 notificación',
+ pt: '1 notificação',
+ fr: '1 notification',
+ de: '1 Benachrichtigung',
+ it: '1 notifica'
+ },
+ 'toast.multipleNotifications': {
+ en: '{count} notifications',
+ es: '{count} notificaciones',
+ pt: '{count} notificações',
+ fr: '{count} notifications',
+ de: '{count} Benachrichtigungen',
+ it: '{count} notifiche'
}
} as const satisfies Record;
diff --git a/packages/ui/src/lib/primitives/aria-hide-outside.ts b/packages/ui/src/lib/primitives/aria-hide-outside.ts
index 2c152e35..c74ae52e 100644
--- a/packages/ui/src/lib/primitives/aria-hide-outside.ts
+++ b/packages/ui/src/lib/primitives/aria-hide-outside.ts
@@ -17,7 +17,14 @@ import { TOP_LAYER_SELECTOR } from './click-outside';
* region staying reachable behind a modal — is intentional: status/alert content is transient
* and announcing it is the whole point.
*/
-const LIVE_REGION_SELECTOR = '[aria-live], [role="status"], [role="alert"], [role="log"]';
+/**
+ * An element that opts out of the hide, without being a live region itself: a toast viewport,
+ * whose toasts are announced by a live region beside it, but whose buttons must stay reachable
+ * behind a modal.
+ */
+export const HIDE_OUTSIDE_EXEMPT_ATTRIBUTE = 'data-hk-hide-outside-exempt';
+
+const LIVE_REGION_SELECTOR = `[aria-live], [role="status"], [role="alert"], [role="log"], [${HIDE_OUTSIDE_EXEMPT_ATTRIBUTE}]`;
/**
* Elements the MutationObserver must never hide when they appear while a modal is active:
diff --git a/packages/ui/src/lib/primitives/focus-trap.ts b/packages/ui/src/lib/primitives/focus-trap.ts
index 6d17a310..eb1e0f6f 100644
--- a/packages/ui/src/lib/primitives/focus-trap.ts
+++ b/packages/ui/src/lib/primitives/focus-trap.ts
@@ -3,6 +3,7 @@
* Traps keyboard focus within a container element.
*/
+import { HIDE_OUTSIDE_EXEMPT_ATTRIBUTE } from './aria-hide-outside';
import { focusWithModality, getInteractionModality } from './input-modality';
const FOCUSABLE_SELECTOR = [
@@ -63,7 +64,8 @@ function resolveInitialFocus(
return initialFocus;
}
-function getFocusableElements(container: HTMLElement): HTMLElement[] {
+/** The elements in `container` that a `Tab` can reach, in DOM order. */
+export function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(element) =>
// getClientRects covers display:none and detached nodes while still
@@ -97,6 +99,9 @@ export function focusTrap(node: HTMLElement, options: boolean | FocusTrapOptions
// Only the topmost active trap arbitrates Tab; ancestor traps under a
// nested overlay must not intercept it.
if (!isTopmostTrap(node)) return;
+ // A surface that stays reachable behind the modal, such as a toast viewport, manages its
+ // own Tab while the focus is in it, and sends the focus back on its own.
+ if (document.activeElement?.closest(`[${HIDE_OUTSIDE_EXEMPT_ATTRIBUTE}]`)) return;
const focusableElements = getFocusableElements(node);
if (focusableElements.length === 0) {
diff --git a/packages/ui/src/lib/primitives/presence.svelte.ts b/packages/ui/src/lib/primitives/presence.svelte.ts
index f9a94e6d..01deb1aa 100644
--- a/packages/ui/src/lib/primitives/presence.svelte.ts
+++ b/packages/ui/src/lib/primitives/presence.svelte.ts
@@ -10,6 +10,7 @@
* starts on mount and tears down (canceling any in-flight motion tracker) on destroy.
*/
+import { untrack } from 'svelte';
import { trackMotionEnd, type MotionTracker } from './motion';
export type PresenceOptions = {
@@ -57,7 +58,11 @@ export function createPresence(
options: PresenceOptions = {}
): Presence {
let isMounted = $state(options.initiallyMounted ?? false);
- let isEntering = $state(false);
+ // A node that is in the DOM from the first render, such as a toast, must paint its first frame
+ // in the enter state. The effect below runs after the mount, and a layout read in between
+ // (a measure, a floating position) would fix the final styles first: the enter would then
+ // play backwards before it plays forwards. So the enter state starts here, at creation.
+ let isEntering = $state(!(options.initiallyMounted ?? false) && untrack(getOpen));
let isExiting = $state(false);
let tracker: MotionTracker | undefined;
diff --git a/packages/ui/src/lib/toast/README.md b/packages/ui/src/lib/toast/README.md
new file mode 100644
index 00000000..9ba0d57a
--- /dev/null
+++ b/packages/ui/src/lib/toast/README.md
@@ -0,0 +1,75 @@
+# Toast
+
+## Description
+
+`Toast` shows a short message about an event, for a time, without a stop of the work. The provider holds the list, and the viewport is the landmark where the toasts land. A screen reader hears each message from a live region beside them.
+
+## Anatomy
+
+- `Toast.Provider`
+- `Toast.Viewport`
+- `Toast.Positioner`
+- `Toast.Root`
+- `Toast.Content`
+- `Toast.Title`
+- `Toast.Description`
+- `Toast.Action`
+- `Toast.Close`
+
+```svelte
+
+
+
+ {#snippet children(toast)}
+
+
+
+
+
+ x
+
+ {/snippet}
+
+
+```
+
+## Usage guidelines
+
+- Put one `Toast.Provider` around the app, and one `Toast.Viewport` in it.
+- Call `useToastManager()` in a component under the provider, or `bind:manager` on the provider, to `add`, `update`, `close` and `promise`.
+- Give `title` and `description` as text: that text is the announcement. The parts show it without children.
+- Keep the buttons out of `Toast.Content`, thus the announcement does not read them.
+- Use `priority: 'high'` only for a message that cannot wait. It interrupts the screen reader.
+- Keep the default `timeout` at 5 seconds or more. A user who reads slowly needs the time.
+- Give `anchor` to `add`, and put `Toast.Positioner` around the root, for a toast against an element.
+- Style the stack with `--toast-index`, `--toast-offset-y`, `--toast-height`, `--toast-frontmost-height`, `data-front` and `data-expanded`. Move the toast with `--toast-swipe-movement-x` and `--toast-swipe-movement-y`.
+- A toast that closes is out of the stack at once, and it keeps the place it had for its exit. It drops `data-front`, thus a rule that hides the toasts behind needs `:not([data-ending])`.
+- Move the enter motion with `translate`, and leave `transform` to the place in the stack. A toast pushed back mid-enter then slides.
+- A toast past the limit waits in the place behind the stack, and it comes forward one step when a place frees.
+- Cover the gap between two spread toasts with a pseudo-element, or the pointer leaves the viewport between them.
+
+## API reference
+
+- `Toast.Provider`
+ - `timeout?: number` (5000), `limit?: number` (3)
+ - `manager?: ToastManager` (bindable)
+- `Toast.Viewport`
+ - `children: Snippet<[ToastItem]>`, `portal?: boolean` (true)
+- `Toast.Positioner`
+ - `toast: ToastItem`
+- `Toast.Root`
+ - `toast: ToastItem`, `swipeDirection?: SwipeSide[]` (`['bottom', 'right']`)
+- `Toast.Action`
+ - `keepOpen?: boolean`
+- `ToastManager`
+ - `add({ title, description, type, timeout, priority, data, anchor, placement, offset }) => id`, `update(id, options | (toast) => options)`, `close(id?)`, `promise(promise, { loading, success, error })`
+ - `pauseTimers()`, `resumeTimers()`, `toasts`, `visibleToasts`
+
+## Accessibility
+
+- `Toast.Viewport` is a `role="region"` landmark, named with the count of toasts. `F6` moves the focus into it from anywhere on the page, and back.
+- Two live regions beside the viewport announce each toast: `role="status"` for the normal priority, and `role="alert"` for the high priority. The message is the title and the description.
+- `Toast.Root` is a `role="dialog"` that is not modal, or an `alertdialog` for the high priority. It has `aria-labelledby` from the title and `aria-describedby` from the description, and it is a tab stop.
+- `Escape` closes the focused toast, and the focus moves to the next toast, or back to where it was.
+- The timers stop while the pointer rests on the viewport, and while the focus is in it. A tap on a toast, not a swipe, holds the stack open, and the timers with it, until a touch outside the viewport. They also stop while the tab is hidden.
+- The viewport stays reachable behind a modal dialog, with `F6` and `Tab`.
diff --git a/packages/ui/src/lib/toast/TODO.md b/packages/ui/src/lib/toast/TODO.md
new file mode 100644
index 00000000..272d6cd7
--- /dev/null
+++ b/packages/ui/src/lib/toast/TODO.md
@@ -0,0 +1,20 @@
+# Toast TODO
+
+## Goal
+
+Track Toast work with a single mandatory TODO format.
+
+## Backlog
+
+- [x] [M][P0][Area: Architecture][Owner: Unassigned][Target: Done] Create the `provider`, `viewport`, `root`, `content`, `title`, `description`, `action` and `close` parts, with a manager that holds the list and the timers.
+- [x] [M][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Make the viewport a `role="region"` landmark named with the count, with `F6` in and out, and a tab that leaves it back to the previous focus.
+- [x] [M][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Announce each toast from two live regions beside the viewport, thus the buttons are not part of the message.
+- [x] [S][P0][Area: Accessibility][Owner: Unassigned][Target: Done] Make each toast a `dialog` or an `alertdialog` that is not modal, named by its title and described by its description.
+- [x] [M][P0][Area: State][Owner: Unassigned][Target: Done] Stop the timers on hover, on keyboard focus, and in a hidden tab, and give each toast the time it had left on resume.
+- [x] [S][P1][Area: State][Owner: Unassigned][Target: Done] Keep the newest toasts up to the limit, and hold the older ones inert with their timers stopped.
+- [x] [S][P1][Area: State][Owner: Unassigned][Target: Done] Add `promise` for a loading toast that turns into success or error.
+- [x] [M][P1][Area: Interaction][Owner: Unassigned][Target: Done] Dismiss on a swipe that follows the finger, with a threshold and a flick, and never from a button.
+- [x] [S][P1][Area: Accessibility][Owner: Unassigned][Target: Done] Keep the viewport reachable behind a modal dialog.
+- [x] [M][P0][Area: Testing][Owner: Unassigned][Target: Done] Add coverage for the region, the announcements, the timers, the keyboard, the buttons, the limit, the swipe, the shared manager and SSR.
+- [x] [S][P2][Area: API][Owner: Unassigned][Target: Done] Add a `Toast.Positioner` for a toast anchored to an element, such as a button.
+- [x] [S][P2][Area: Interaction][Owner: Unassigned][Target: Done] Add a `data-hk-swipe-ignore` note to the docs for content of the consumer that must not start a swipe.
diff --git a/packages/ui/src/lib/toast/action/toast-action.svelte b/packages/ui/src/lib/toast/action/toast-action.svelte
new file mode 100644
index 00000000..c72a570c
--- /dev/null
+++ b/packages/ui/src/lib/toast/action/toast-action.svelte
@@ -0,0 +1,42 @@
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/toast/close/toast-close.svelte b/packages/ui/src/lib/toast/close/toast-close.svelte
new file mode 100644
index 00000000..2a6d1322
--- /dev/null
+++ b/packages/ui/src/lib/toast/close/toast-close.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {@render children?.()}
+
diff --git a/packages/ui/src/lib/toast/content/toast-content.svelte b/packages/ui/src/lib/toast/content/toast-content.svelte
new file mode 100644
index 00000000..93900bd7
--- /dev/null
+++ b/packages/ui/src/lib/toast/content/toast-content.svelte
@@ -0,0 +1,24 @@
+
+
+
+{/if}
diff --git a/packages/ui/src/lib/toast/index.parts.ts b/packages/ui/src/lib/toast/index.parts.ts
new file mode 100644
index 00000000..20c5abf4
--- /dev/null
+++ b/packages/ui/src/lib/toast/index.parts.ts
@@ -0,0 +1,9 @@
+export { default as Provider } from './provider/toast-provider.svelte';
+export { default as Viewport } from './viewport/toast-viewport.svelte';
+export { default as Positioner } from './positioner/toast-positioner.svelte';
+export { default as Root } from './root/toast-root.svelte';
+export { default as Content } from './content/toast-content.svelte';
+export { default as Title } from './title/toast-title.svelte';
+export { default as Description } from './description/toast-description.svelte';
+export { default as Close } from './close/toast-close.svelte';
+export { default as Action } from './action/toast-action.svelte';
diff --git a/packages/ui/src/lib/toast/index.ts b/packages/ui/src/lib/toast/index.ts
new file mode 100644
index 00000000..637aff2e
--- /dev/null
+++ b/packages/ui/src/lib/toast/index.ts
@@ -0,0 +1,59 @@
+import type { ComponentProps } from 'svelte';
+import type ToastActionComponent from './action/toast-action.svelte';
+import type ToastCloseComponent from './close/toast-close.svelte';
+import type ToastContentComponent from './content/toast-content.svelte';
+import type ToastDescriptionComponent from './description/toast-description.svelte';
+import type ToastPositionerComponent from './positioner/toast-positioner.svelte';
+import type ToastProviderComponent from './provider/toast-provider.svelte';
+import type ToastRootComponent from './root/toast-root.svelte';
+import type ToastTitleComponent from './title/toast-title.svelte';
+import type ToastViewportComponent from './viewport/toast-viewport.svelte';
+
+export * as Toast from './index.parts.js';
+
+export { default as ToastProvider } from './provider/toast-provider.svelte';
+export { default as ToastViewport } from './viewport/toast-viewport.svelte';
+export { default as ToastPositioner } from './positioner/toast-positioner.svelte';
+export { default as ToastRoot } from './root/toast-root.svelte';
+export { default as ToastContent } from './content/toast-content.svelte';
+export { default as ToastTitle } from './title/toast-title.svelte';
+export { default as ToastDescription } from './description/toast-description.svelte';
+export { default as ToastClose } from './close/toast-close.svelte';
+export { default as ToastAction } from './action/toast-action.svelte';
+export type ToastProviderProps = ComponentProps;
+export type ToastViewportProps = ComponentProps;
+export type ToastPositionerProps = ComponentProps;
+export type ToastRootProps = ComponentProps;
+export type ToastContentProps = ComponentProps;
+export type ToastTitleProps = ComponentProps;
+export type ToastDescriptionProps = ComponentProps;
+export type ToastCloseProps = ComponentProps;
+export type ToastActionProps = ComponentProps;
+export {
+ getToastProviderContext,
+ setToastProviderContext,
+ useToastManager,
+ useToastProviderContext,
+ type ToastProviderContext
+} from './provider/context.js';
+export {
+ getToastContext,
+ setToastContext,
+ useToastContext,
+ type ToastContext
+} from './root/context.js';
+export {
+ createToastManager,
+ DEFAULT_TOAST_LIMIT,
+ DEFAULT_TOAST_TIMEOUT,
+ type ToastItem,
+ type ToastManager,
+ type ToastOptions,
+ type ToastPriority,
+ type ToastPromiseOptions,
+ type ToastStatus,
+ type ToastUpdate
+} from './provider/toast-manager.svelte.js';
+
+import * as ToastParts from './index.parts.js';
+export default ToastParts;
diff --git a/packages/ui/src/lib/toast/positioner/toast-positioner.svelte b/packages/ui/src/lib/toast/positioner/toast-positioner.svelte
new file mode 100644
index 00000000..edf111d4
--- /dev/null
+++ b/packages/ui/src/lib/toast/positioner/toast-positioner.svelte
@@ -0,0 +1,75 @@
+
+
+{#if anchor}
+
+{/if}
diff --git a/packages/ui/src/lib/toast/types.ts b/packages/ui/src/lib/toast/types.ts
new file mode 100644
index 00000000..c2a036c1
--- /dev/null
+++ b/packages/ui/src/lib/toast/types.ts
@@ -0,0 +1,127 @@
+import type { Snippet } from 'svelte';
+import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
+import type { ToastItem, ToastManager } from './provider/toast-manager.svelte.js';
+import type { SwipeSide } from '../primitives/swipe-gesture.js';
+
+export type {
+ ToastItem,
+ ToastManager,
+ ToastOptions,
+ ToastPriority,
+ ToastPromiseOptions,
+ ToastStatus,
+ ToastUpdate
+} from './provider/toast-manager.svelte.js';
+
+export type ToastProviderProps = {
+ /** The default time a toast stays, in milliseconds. `0` keeps each toast until a close. */
+ timeout?: number;
+ /**
+ * The count of toasts on the screen. The older ones past it wait behind, hidden and inert,
+ * with their timers stopped, and they come forward as the newer ones close.
+ */
+ limit?: number;
+ /**
+ * The manager: `add`, `update`, `close` and `promise`. Use `bind:manager` to read it in the
+ * page, or give one to share a list of toasts between two providers.
+ */
+ manager?: ToastManager;
+ /** The app, with a `Toast.Viewport` somewhere in it. */
+ children?: Snippet;
+};
+
+export type ToastViewportProps = Omit<
+ HTMLAttributes,
+ 'children' | 'class' | 'role' | 'tabindex'
+> & {
+ /** The toast to render for each item. It gets the item; render a `Toast.Root` with it. */
+ children: Snippet<[ToastItem]>;
+ /** The CSS class names of the element. */
+ class?: string;
+ /**
+ * Renders the viewport at the end of ``, thus it draws above the page. Set it to
+ * `false` to render it in place.
+ */
+ portal?: boolean;
+ /** The bindable viewport element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ToastRootProps = Omit<
+ HTMLAttributes,
+ 'children' | 'class' | 'role' | 'tabindex' | 'id'
+> & {
+ /** The item from `Toast.Viewport`. */
+ toast: ToastItem;
+ /** The parts of the toast. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /**
+ * The sides a swipe can push the toast out through: `bottom` and `right` by default. One side
+ * per axis counts. An empty array turns the swipe off. A swipe on `Toast.Close` or
+ * `Toast.Action` never starts.
+ */
+ swipeDirection?: SwipeSide[];
+ /** The bindable toast element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ToastPositionerProps = Omit, 'children' | 'class'> & {
+ /** The item from `Toast.Viewport`. */
+ toast: ToastItem;
+ /** The `Toast.Root`. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The bindable positioner element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ToastContentProps = Omit, 'children' | 'class'> & {
+ /** The title and the description. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+};
+
+export type ToastTitleProps = Omit, 'children' | 'class' | 'id'> & {
+ /** The title. Without children, it shows the `title` of the item. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The id of the element. The component makes one when you give none. */
+ id?: string;
+};
+
+export type ToastDescriptionProps = Omit<
+ HTMLAttributes,
+ 'children' | 'class' | 'id'
+> & {
+ /** The description. Without children, it shows the `description` of the item. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The id of the element. The component makes one when you give none. */
+ id?: string;
+};
+
+export type ToastCloseProps = Omit & {
+ /** The name on the button. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** The bindable button element. */
+ element?: HTMLButtonElement | null;
+};
+
+export type ToastActionProps = Omit & {
+ /** The name on the button. */
+ children?: Snippet;
+ /** The CSS class names of the element. */
+ class?: string;
+ /** Keeps the toast open after the press. By default, the press closes it. */
+ keepOpen?: boolean;
+ /** The bindable button element. */
+ element?: HTMLButtonElement | null;
+};
diff --git a/packages/ui/src/lib/toast/viewport/toast-viewport.svelte b/packages/ui/src/lib/toast/viewport/toast-viewport.svelte
new file mode 100644
index 00000000..5a2a166b
--- /dev/null
+++ b/packages/ui/src/lib/toast/viewport/toast-viewport.svelte
@@ -0,0 +1,389 @@
+
+
+{#snippet viewport()}
+
+ {#each politeMessages as message (message.key)}
+
{message.text}
+ {/each}
+
+
+ {#each assertiveMessages as message (message.key)}
+