From 0af04c43637ca70385e75015c6ac184efa071cda Mon Sep 17 00:00:00 2001 From: Agustin Delgado Date: Fri, 18 Sep 2026 16:27:14 -0300 Subject: [PATCH 01/13] feat(toast): add the Toast primitive `Toast.Provider` holds the list and the timers, and gives the manager to the page through `useToastManager()` or `bind:manager`: `add`, `update`, `close` and `promise`. `Toast.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. `Toast.Root` is a `dialog` that is not modal, or an `alertdialog` for a high priority, with `Escape`, a swipe that follows the finger, and an exit animation from its CSS. The timers stop on hover, on keyboard focus and in a background window. The newest toasts stay up to a limit, and the older ones wait inert behind them. The `ariaHideOutside` primitive learned `data-hk-hide-outside-exempt`, thus the viewport stays reachable behind a modal dialog. --- .changeset/toast-v1.md | 5 + docs/src/content/toast/api.json | 352 ++++++++++++ docs/src/content/toast/demos/action.svelte | 47 ++ docs/src/content/toast/demos/hero.svelte | 41 ++ docs/src/content/toast/demos/promise.svelte | 35 ++ docs/src/content/toast/demos/viewport.svelte | 115 ++++ docs/src/content/toast/index.md | 128 +++++ docs/src/lib/docs/nav.ts | 1 + packages/ui/package.json | 5 + packages/ui/src/lib/index.ts | 2 + .../ui/src/lib/internal/localized-strings.ts | 24 + .../src/lib/primitives/aria-hide-outside.ts | 9 +- packages/ui/src/lib/primitives/focus-trap.ts | 3 +- packages/ui/src/lib/toast/README.md | 67 +++ packages/ui/src/lib/toast/TODO.md | 20 + .../src/lib/toast/action/toast-action.svelte | 42 ++ .../ui/src/lib/toast/close/toast-close.svelte | 41 ++ .../lib/toast/content/toast-content.svelte | 24 + .../description/toast-description.svelte | 32 ++ packages/ui/src/lib/toast/index.parts.ts | 8 + packages/ui/src/lib/toast/index.ts | 56 ++ packages/ui/src/lib/toast/provider/context.ts | 50 ++ .../lib/toast/provider/toast-hook-test.svelte | 15 + .../toast/provider/toast-manager.svelte.ts | 342 ++++++++++++ .../toast/provider/toast-modal-test.svelte | 31 ++ .../lib/toast/provider/toast-provider.svelte | 91 +++ .../toast/provider/toast-shared-test.svelte | 27 + .../src/lib/toast/provider/toast-ssr.test.ts | 15 + .../src/lib/toast/provider/toast-test.svelte | 67 +++ .../ui/src/lib/toast/provider/toast.test.ts | 524 ++++++++++++++++++ packages/ui/src/lib/toast/root/context.ts | 35 ++ .../ui/src/lib/toast/root/toast-root.svelte | 256 +++++++++ .../ui/src/lib/toast/title/toast-title.svelte | 29 + packages/ui/src/lib/toast/types.ts | 116 ++++ .../lib/toast/viewport/toast-viewport.svelte | 306 ++++++++++ 35 files changed, 2959 insertions(+), 2 deletions(-) create mode 100644 .changeset/toast-v1.md create mode 100644 docs/src/content/toast/api.json create mode 100644 docs/src/content/toast/demos/action.svelte create mode 100644 docs/src/content/toast/demos/hero.svelte create mode 100644 docs/src/content/toast/demos/promise.svelte create mode 100644 docs/src/content/toast/demos/viewport.svelte create mode 100644 docs/src/content/toast/index.md create mode 100644 packages/ui/src/lib/toast/README.md create mode 100644 packages/ui/src/lib/toast/TODO.md create mode 100644 packages/ui/src/lib/toast/action/toast-action.svelte create mode 100644 packages/ui/src/lib/toast/close/toast-close.svelte create mode 100644 packages/ui/src/lib/toast/content/toast-content.svelte create mode 100644 packages/ui/src/lib/toast/description/toast-description.svelte create mode 100644 packages/ui/src/lib/toast/index.parts.ts create mode 100644 packages/ui/src/lib/toast/index.ts create mode 100644 packages/ui/src/lib/toast/provider/context.ts create mode 100644 packages/ui/src/lib/toast/provider/toast-hook-test.svelte create mode 100644 packages/ui/src/lib/toast/provider/toast-manager.svelte.ts create mode 100644 packages/ui/src/lib/toast/provider/toast-modal-test.svelte create mode 100644 packages/ui/src/lib/toast/provider/toast-provider.svelte create mode 100644 packages/ui/src/lib/toast/provider/toast-shared-test.svelte create mode 100644 packages/ui/src/lib/toast/provider/toast-ssr.test.ts create mode 100644 packages/ui/src/lib/toast/provider/toast-test.svelte create mode 100644 packages/ui/src/lib/toast/provider/toast.test.ts create mode 100644 packages/ui/src/lib/toast/root/context.ts create mode 100644 packages/ui/src/lib/toast/root/toast-root.svelte create mode 100644 packages/ui/src/lib/toast/title/toast-title.svelte create mode 100644 packages/ui/src/lib/toast/types.ts create mode 100644 packages/ui/src/lib/toast/viewport/toast-viewport.svelte diff --git a/.changeset/toast-v1.md b/.changeset/toast-v1.md new file mode 100644 index 00000000..f9bf2e35 --- /dev/null +++ b/.changeset/toast-v1.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': minor +--- + +Add the Toast primitive with `Provider`, `Viewport`, `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. The timers stop on hover, on keyboard focus and in a background window, 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`. diff --git a/docs/src/content/toast/api.json b/docs/src/content/toast/api.json new file mode 100644 index 00000000..96a4d2a0 --- /dev/null +++ b/docs/src/content/toast/api.json @@ -0,0 +1,352 @@ +{ + "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." + }, + { + "name": "data-toast-viewport", + "description": "Identifies the viewport 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-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." + }, + { + "name": "data-front", + "description": "Present on the toast in front." + }, + { + "name": "data-limited", + "description": "Present on a toast past the limit. It is inert." + }, + { + "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..e1a4c47e --- /dev/null +++ b/docs/src/content/toast/demos/action.svelte @@ -0,0 +1,47 @@ + + + + + + + {#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/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..b8c4cb80 --- /dev/null +++ b/docs/src/content/toast/demos/viewport.svelte @@ -0,0 +1,115 @@ + + + + + {#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..d492383e --- /dev/null +++ b/docs/src/content/toast/index.md @@ -0,0 +1,128 @@ +--- +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. + +```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, while the keyboard focus is in it, and while the window is in the background. 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 toast in front. `data-expanded` is on the viewport and on each toast while the pointer rests on the viewport, or while the focus is in it. 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 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. `data-swipe-dismissed` and `data-swipe-direction` are on the toast for the exit. A swipe never starts on `Toast.Close` or `Toast.Action`. + +## 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. +- `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. The focus moves to the next toast, or back to where it was. +- The viewport stays reachable behind a modal `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/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..23debef5 100644 --- a/packages/ui/src/lib/primitives/focus-trap.ts +++ b/packages/ui/src/lib/primitives/focus-trap.ts @@ -63,7 +63,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 diff --git a/packages/ui/src/lib/toast/README.md b/packages/ui/src/lib/toast/README.md new file mode 100644 index 00000000..01186155 --- /dev/null +++ b/packages/ui/src/lib/toast/README.md @@ -0,0 +1,67 @@ +# 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.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. +- 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`. + +## API reference + +- `Toast.Provider` + - `timeout?: number` (5000), `limit?: number` (3) + - `manager?: ToastManager` (bindable) +- `Toast.Viewport` + - `children: Snippet<[ToastItem]>`, `portal?: boolean` (true) +- `Toast.Root` + - `toast: ToastItem`, `swipeDirection?: SwipeSide[]` (`['bottom', 'right']`) +- `Toast.Action` + - `keepOpen?: boolean` +- `ToastManager` + - `add(options) => 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, while the focus is in it, and while the window is in the background. +- The viewport stays reachable behind a modal dialog. diff --git a/packages/ui/src/lib/toast/TODO.md b/packages/ui/src/lib/toast/TODO.md new file mode 100644 index 00000000..80109689 --- /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 background window, 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. +- [ ] [S][P2][Area: API][Owner: Unassigned][Target: Backlog] Add a `Toast.Positioner` for a toast anchored to an element, such as a button. +- [ ] [S][P2][Area: Interaction][Owner: Unassigned][Target: Backlog] 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 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/toast/description/toast-description.svelte b/packages/ui/src/lib/toast/description/toast-description.svelte new file mode 100644 index 00000000..964c796a --- /dev/null +++ b/packages/ui/src/lib/toast/description/toast-description.svelte @@ -0,0 +1,32 @@ + + +

+ {#if children} + {@render children()} + {:else} + {ctx.toast.description} + {/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..72d1e067 --- /dev/null +++ b/packages/ui/src/lib/toast/index.parts.ts @@ -0,0 +1,8 @@ +export { default as Provider } from './provider/toast-provider.svelte'; +export { default as Viewport } from './viewport/toast-viewport.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..95182105 --- /dev/null +++ b/packages/ui/src/lib/toast/index.ts @@ -0,0 +1,56 @@ +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 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 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 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/provider/context.ts b/packages/ui/src/lib/toast/provider/context.ts new file mode 100644 index 00000000..ce170860 --- /dev/null +++ b/packages/ui/src/lib/toast/provider/context.ts @@ -0,0 +1,50 @@ +import { getContext, setContext } from 'svelte'; +import type { InternalToastManager, ToastManager } from './toast-manager.svelte'; + +const PROVIDER_KEY = Symbol('toast-provider'); + +export type ToastProviderContext = { + manager: InternalToastManager; + /** The pointer rests on the viewport. */ + readonly hovering: boolean; + /** The focus is in the viewport. */ + readonly focused: boolean; + /** The toasts are spread out: the pointer rests on them, or the focus is in them. */ + readonly expanded: boolean; + /** The viewport element. */ + readonly viewportElement: HTMLElement | null; + /** The height of each toast on the screen, by id, for a stack. */ + readonly heights: ReadonlyMap; + setHovering: (hovering: boolean) => void; + setFocused: (focused: boolean) => void; + setViewportElement: (element: HTMLElement | null) => void; + setHeight: (id: string, height: number | null) => void; + /** Moves the focus to the next toast, or back to where it was before the viewport. */ + focusAfterClose: (id: string) => void; + /** The viewport gives the function behind `focusAfterClose`. */ + setFocusAfterClose: (handler: ((id: string) => void) | null) => void; +}; + +export function setToastProviderContext(context: ToastProviderContext) { + setContext(PROVIDER_KEY, context); +} + +export function getToastProviderContext(): ToastProviderContext | undefined { + return getContext(PROVIDER_KEY); +} + +export function useToastProviderContext(part = 'Toast'): ToastProviderContext { + const context = getToastProviderContext(); + if (!context) { + throw new Error(`${part} must be used within Toast.Provider.`); + } + return context; +} + +/** + * The manager of the nearest `Toast.Provider`: `add`, `update`, `close` and `promise`. Call it + * in a component under the provider, at the start of its script. + */ +export function useToastManager(): ToastManager { + return useToastProviderContext('useToastManager').manager as ToastManager; +} diff --git a/packages/ui/src/lib/toast/provider/toast-hook-test.svelte b/packages/ui/src/lib/toast/provider/toast-hook-test.svelte new file mode 100644 index 00000000..3b8df4f3 --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-hook-test.svelte @@ -0,0 +1,15 @@ + + + +{manager.visibleToasts.length} diff --git a/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts b/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts new file mode 100644 index 00000000..d47cda3f --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts @@ -0,0 +1,342 @@ +import { untrack } from 'svelte'; + +export type ToastPriority = 'low' | 'high'; +export type ToastStatus = 'open' | 'ending'; + +/** The default time a toast stays, in milliseconds. `0` keeps a toast until a close. */ +export const DEFAULT_TOAST_TIMEOUT = 5000; +/** The default count of toasts on the screen. The older ones wait behind. */ +export const DEFAULT_TOAST_LIMIT = 3; + +export type ToastOptions = { + /** The id of the toast. With the id of a toast that is on the screen, `add` updates it. */ + id?: string; + /** The title. It names the toast, and it is the first part of the announcement. */ + title?: string; + /** The description. It is the message when there is no title. */ + description?: string; + /** + * A kind of your own, such as `success` or `error`, for `data-type` on the toast. A `loading` + * toast stays until an update, because a task that runs has no end that a timer knows. + */ + type?: string; + /** The time the toast stays, in milliseconds. `0` keeps it until a close. */ + timeout?: number; + /** + * How the screen reader hears it. `low` is announced when the user is idle, and `high` + * interrupts, thus the toast is `alertdialog`. Use `high` only for a message that cannot wait. + */ + priority?: ToastPriority; + /** Data of your own, for the content you render. */ + data?: Data; + /** Runs when the toast starts to close. */ + onClose?: () => void; + /** Runs when the toast leaves the DOM, after its exit animation. */ + onRemove?: () => void; +}; + +export type ToastItem = { + readonly id: string; + readonly title: string | undefined; + readonly description: string | undefined; + readonly type: string | undefined; + readonly timeout: number; + readonly priority: ToastPriority; + readonly data: Data | undefined; + /** `open` on the screen, `ending` through the exit animation. */ + readonly status: ToastStatus; + /** The toast is past the limit of the viewport. It waits, hidden and inert. */ + readonly limited: boolean; + /** Goes up on each update. Replay an attention animation from it. */ + readonly updateKey: number; + readonly onClose: (() => void) | undefined; + readonly onRemove: (() => void) | undefined; +}; + +export type ToastUpdate = + Omit, 'id'> | ((toast: ToastItem) => Omit, 'id'>); + +export type ToastPromiseOptions = { + loading: string | Omit, 'id'>; + success: + | string + | Omit, 'id'> + | ((value: Value) => string | Omit, 'id'>); + error: + | string + | Omit, 'id'> + | ((error: unknown) => string | Omit, 'id'>); +}; + +export type ToastManager = { + /** The toasts, the newest first. Render them in `Toast.Viewport`. */ + readonly toasts: readonly ToastItem[]; + /** The toasts on the screen: the newest ones, up to the limit. */ + readonly visibleToasts: readonly ToastItem[]; + /** Adds a toast, and answers its id. */ + add: (options: ToastOptions) => string; + /** Updates a toast. The options replace the fields they name. */ + update: (id: string, update: ToastUpdate) => void; + /** Starts the close of one toast, or of all of them. */ + close: (id?: string) => void; + /** Takes a toast out of the list. `Toast.Root` calls it after the exit animation. */ + remove: (id: string) => void; + /** Adds a `loading` toast that turns into `success` or `error` with the promise. */ + promise: ( + promise: Promise, + options: ToastPromiseOptions + ) => Promise; + /** Stops every timer, for example while the pointer rests on the viewport. */ + pauseTimers: () => void; + /** Starts the timers again, with the time each toast had left. */ + resumeTimers: () => void; + /** Whether the timers are stopped. */ + readonly paused: boolean; +}; + +export type CreateToastManagerOptions = { + /** The default time a toast stays, in milliseconds. */ + timeout?: () => number; + /** The count of toasts on the screen. */ + limit?: () => number; +}; + +type Timer = { + timeoutId: ReturnType | null; + remaining: number; + startedAt: number; +}; + +let toastCounter = 0; + +function nextToastId() { + toastCounter += 1; + return `toast-${toastCounter}`; +} + +function resolveText( + source: string | Omit | ((value: Value) => string | Omit), + value: Value +): Omit { + const resolved = typeof source === 'function' ? source(value) : source; + return typeof resolved === 'string' ? { description: resolved } : resolved; +} + +/** + * The list of toasts and their timers. `Toast.Provider` makes one, and gives it to the parts + * and to the page through `useToastManager`. The timers live here and not in the toasts, + * thus one pause stops all of them, and a resume gives each one the time it had left. + */ +export function createToastManager( + options: CreateToastManagerOptions = {} +): InternalToastManager { + let toasts = $state[]>([]); + let paused = $state(false); + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- the timers are bookkeeping, not state: nothing renders from them. + const timers = new Map(); + + const getTimeout = options.timeout ?? (() => DEFAULT_TOAST_TIMEOUT); + const getLimit = options.limit ?? (() => DEFAULT_TOAST_LIMIT); + + const visibleToasts = $derived(toasts.filter((toast) => !toast.limited)); + + function setToasts(next: ToastItem[]) { + toasts = applyLimit(next); + } + + // The newest toasts stay on the screen; the older ones past the limit wait, inert. A toast on + // its way out no longer takes a place. + function applyLimit(list: ToastItem[]): ToastItem[] { + const limit = Math.max(0, untrack(getLimit)); + let active = 0; + return list.map((toast) => { + if (toast.status === 'ending') return toast.limited ? { ...toast, limited: false } : toast; + const limited = active >= limit; + active += 1; + return toast.limited === limited ? toast : { ...toast, limited }; + }); + } + + function hasTimer(toast: ToastItem) { + return toast.timeout > 0 && toast.type !== 'loading' && toast.status !== 'ending'; + } + + function clearTimer(id: string) { + const timer = timers.get(id); + if (!timer) return; + if (timer.timeoutId !== null) clearTimeout(timer.timeoutId); + timers.delete(id); + } + + function startTimer(id: string, duration: number) { + clearTimer(id); + const timer: Timer = { timeoutId: null, remaining: duration, startedAt: Date.now() }; + timers.set(id, timer); + if (!paused) run(id, timer); + } + + function run(id: string, timer: Timer) { + timer.startedAt = Date.now(); + timer.timeoutId = setTimeout(() => { + timers.delete(id); + close(id); + }, timer.remaining); + } + + // A timer runs only for a toast the user can see. A hidden one that closes on its own is a + // message nobody read. + function syncTimers() { + for (const toast of untrack(() => toasts)) { + const wants = hasTimer(toast) && !toast.limited; + const has = timers.has(toast.id); + if (wants && !has) startTimer(toast.id, toast.timeout); + if (!wants && has) clearTimer(toast.id); + } + } + + function pauseTimers() { + if (paused) return; + paused = true; + for (const timer of timers.values()) { + if (timer.timeoutId === null) continue; + clearTimeout(timer.timeoutId); + timer.timeoutId = null; + timer.remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt)); + } + } + + function resumeTimers() { + if (!paused) return; + paused = false; + for (const [id, timer] of timers) { + if (timer.timeoutId === null) run(id, timer); + } + } + + function add(options: ToastOptions): string { + const id = options.id ?? nextToastId(); + const existing = untrack(() => toasts.find((toast) => toast.id === id)); + if (existing && existing.status !== 'ending') { + update(id, options); + return id; + } + const toast: ToastItem = { + id, + title: options.title, + description: options.description, + type: options.type, + timeout: options.timeout ?? untrack(getTimeout), + priority: options.priority ?? 'low', + data: options.data, + status: 'open', + limited: false, + updateKey: 0, + onClose: options.onClose, + onRemove: options.onRemove + }; + setToasts([toast, ...untrack(() => toasts).filter((candidate) => candidate.id !== id)]); + syncTimers(); + return id; + } + + function update(id: string, incoming: ToastUpdate) { + const current = untrack(() => toasts.find((toast) => toast.id === id)); + if (!current || current.status === 'ending') return; + const changes = typeof incoming === 'function' ? incoming(current) : incoming; + const next: ToastItem = { + ...current, + ...changes, + timeout: + changes.timeout ?? + (Object.hasOwn(changes, 'timeout') ? untrack(getTimeout) : current.timeout), + priority: changes.priority ?? current.priority, + updateKey: current.updateKey + 1 + }; + setToasts(untrack(() => toasts).map((toast) => (toast.id === id ? next : toast))); + // An update is a new message: the toast gets its full time again. + clearTimer(id); + syncTimers(); + } + + function close(id?: string) { + const targets = untrack(() => + toasts.filter((toast) => (id === undefined || toast.id === id) && toast.status !== 'ending') + ); + if (targets.length === 0) return; + for (const toast of targets) clearTimer(toast.id); + setToasts( + untrack(() => toasts).map((toast) => + targets.includes(toast) ? { ...toast, status: 'ending' as const } : toast + ) + ); + syncTimers(); + for (const toast of targets) toast.onClose?.(); + } + + function remove(id: string) { + const target = untrack(() => toasts.find((toast) => toast.id === id)); + if (!target) return; + clearTimer(id); + setToasts(untrack(() => toasts).filter((toast) => toast.id !== id)); + syncTimers(); + target.onRemove?.(); + } + + async function promise( + task: Promise, + promiseOptions: ToastPromiseOptions + ): Promise { + const loading = resolveText(promiseOptions.loading as never, undefined) as Omit< + ToastOptions, + 'id' + >; + const id = add({ type: 'loading', ...loading, timeout: 0 }); + try { + const value = await task; + const success = resolveText(promiseOptions.success as never, value) as Omit< + ToastOptions, + 'id' + >; + update(id, { type: 'success', timeout: undefined, ...success }); + return value; + } catch (error) { + const failure = resolveText(promiseOptions.error as never, error) as Omit< + ToastOptions, + 'id' + >; + update(id, { type: 'error', timeout: undefined, ...failure }); + throw error; + } + } + + function syncLimit() { + toasts = applyLimit(untrack(() => toasts)); + syncTimers(); + } + + return { + get toasts() { + return toasts; + }, + get visibleToasts() { + return visibleToasts; + }, + get paused() { + return paused; + }, + add, + update, + close, + remove, + promise, + pauseTimers, + resumeTimers, + syncLimit + }; +} + +/** The manager with the calls that only the parts make. */ +export type InternalToastManager = ToastManager & { + /** `Toast.Provider` applies a new limit. */ + syncLimit: () => void; +}; diff --git a/packages/ui/src/lib/toast/provider/toast-modal-test.svelte b/packages/ui/src/lib/toast/provider/toast-modal-test.svelte new file mode 100644 index 00000000..5b89fdc2 --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-modal-test.svelte @@ -0,0 +1,31 @@ + + + + + + + + A modal + Dismiss + + + + + {#snippet children(toast)} + + + + + x + + {/snippet} + + diff --git a/packages/ui/src/lib/toast/provider/toast-provider.svelte b/packages/ui/src/lib/toast/provider/toast-provider.svelte new file mode 100644 index 00000000..d62c6c0e --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-provider.svelte @@ -0,0 +1,91 @@ + + +{@render children?.()} diff --git a/packages/ui/src/lib/toast/provider/toast-shared-test.svelte b/packages/ui/src/lib/toast/provider/toast-shared-test.svelte new file mode 100644 index 00000000..b2556f06 --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-shared-test.svelte @@ -0,0 +1,27 @@ + + + + + + {#snippet children(toast)} + + + + + + {/snippet} + + + +{#if manager} + + + + +{/if} diff --git a/packages/ui/src/lib/toast/provider/toast-ssr.test.ts b/packages/ui/src/lib/toast/provider/toast-ssr.test.ts new file mode 100644 index 00000000..e26aa5d0 --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-ssr.test.ts @@ -0,0 +1,15 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; +import { render } from 'svelte/server'; +import ToastTest from './toast-test.svelte'; + +describe('Toast SSR', () => { + it('renders the announcers and no region without a toast', () => { + const { body } = render(ToastTest, { props: {} }); + + expect(body).toContain('role="status"'); + expect(body).toContain('role="alert"'); + expect(body).not.toContain('data-toast-viewport'); + }); +}); diff --git a/packages/ui/src/lib/toast/provider/toast-test.svelte b/packages/ui/src/lib/toast/provider/toast-test.svelte new file mode 100644 index 00000000..d09c7bc7 --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-test.svelte @@ -0,0 +1,67 @@ + + + + + + + + + {#snippet children(toast)} + + + + + + {#if withAction} + Undo + {/if} + x + + {/snippet} + + + + diff --git a/packages/ui/src/lib/toast/provider/toast.test.ts b/packages/ui/src/lib/toast/provider/toast.test.ts new file mode 100644 index 00000000..8a0933d1 --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast.test.ts @@ -0,0 +1,524 @@ +import { tick } from 'svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import type { ToastManager } from '../index'; +import ToastModalTest from './toast-modal-test.svelte'; +import ToastSharedTest from './toast-shared-test.svelte'; +import ToastTest from './toast-test.svelte'; + +function byTestId(id: string): T { + const element = document.querySelector(`[data-testid="${id}"]`); + if (!element) throw new Error(`No element with data-testid="${id}"`); + return element; +} + +function toasts(): HTMLElement[] { + return Array.from(document.querySelectorAll('[data-toast-root]')); +} + +function region(): HTMLElement | null { + return document.querySelector('[data-toast-viewport]'); +} + +function polite(): string { + return document.querySelector('[role="status"]')?.textContent?.trim() ?? ''; +} + +function assertive(): string { + return document.querySelector('[role="alert"]')?.textContent?.trim() ?? ''; +} + +async function press(id: string) { + byTestId(id).click(); + await tick(); +} + +async function advance(ms: number) { + await vi.advanceTimersByTimeAsync(ms); + await tick(); +} + +function pointer(target: Element, type: string, pointerType: 'mouse' | 'touch' = 'mouse') { + target.dispatchEvent( + new PointerEvent(type, { + pointerType, + pointerId: 1, + bubbles: type !== 'pointerenter' && type !== 'pointerleave' + }) + ); +} + +let manager: ToastManager | undefined; + +function setup(props: Record = {}) { + return render(ToastTest, { + ...props, + onManager: (value: ToastManager) => { + manager = value; + } + }); +} + +function getManager(): ToastManager { + if (!manager) throw new Error('The manager is not bound yet.'); + return manager; +} + +describe('Toast', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + manager = undefined; + }); + + describe('region and announcement', () => { + it('renders nothing but the announcers while there is no toast', () => { + setup(); + + expect(region()).toBeNull(); + expect(document.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull(); + expect(document.querySelector('[role="alert"][aria-live="assertive"]')).not.toBeNull(); + }); + + it('adds a dialog with its name and its description, and announces it politely', async () => { + setup(); + + await press('add'); + + const [toast] = toasts(); + expect(region()?.getAttribute('role')).toBe('region'); + expect(region()?.getAttribute('aria-label')).toBe('1 notification'); + expect(region()?.getAttribute('tabindex')).toBe('-1'); + expect(toast.getAttribute('role')).toBe('dialog'); + expect(toast.getAttribute('aria-modal')).toBe('false'); + expect(toast.getAttribute('tabindex')).toBe('0'); + expect(toast.getAttribute('data-priority')).toBe('low'); + await expect.poll(() => toast.getAttribute('aria-labelledby')).toBe(byTestId('title').id); + await expect + .poll(() => toast.getAttribute('aria-describedby')) + .toBe(byTestId('description').id); + expect(byTestId('title').textContent?.trim()).toBe('Saved'); + expect(byTestId('description').textContent?.trim()).toBe('The file is on the server.'); + expect(polite()).toBe('Saved. The file is on the server.'); + expect(assertive()).toBe(''); + }); + + it('makes a high priority toast an alertdialog, and announces it assertively', async () => { + setup(); + + await press('add-high'); + + expect(toasts()[0].getAttribute('role')).toBe('alertdialog'); + expect(assertive()).toBe('Lost. The connection is gone.'); + expect(polite()).toBe(''); + }); + + it('counts the toasts in the name of the region', async () => { + setup(); + + await press('add'); + await press('add'); + + expect(region()?.getAttribute('aria-label')).toBe('2 notifications'); + expect(toasts()[0].getAttribute('data-front')).toBe('true'); + expect(toasts()[1].hasAttribute('data-front')).toBe(false); + expect(toasts()[0].style.getPropertyValue('--toast-index')).toBe('0'); + expect(toasts()[1].style.getPropertyValue('--toast-index')).toBe('1'); + }); + + it('does not read the buttons as part of the message', async () => { + setup({ withAction: true }); + + await press('add'); + + expect(polite()).toBe('Saved. The file is on the server.'); + expect(byTestId('content').contains(byTestId('close'))).toBe(false); + }); + }); + + describe('timers', () => { + it('closes on its own after the timeout, and leaves the DOM', async () => { + setup({ timeout: 1000 }); + + await press('add'); + await advance(900); + expect(toasts()).toHaveLength(1); + + await advance(100); + expect(toasts()[0]?.getAttribute('data-ending') ?? 'gone').toMatch(/true|gone/); + await expect.poll(() => toasts().length).toBe(0); + expect(region()).toBeNull(); + }); + + it('stops the timer while the pointer rests on the region', async () => { + setup({ timeout: 1000 }); + + await press('add'); + await advance(500); + pointer(region()!, 'pointerenter'); + await advance(2000); + expect(toasts()).toHaveLength(1); + expect(region()?.getAttribute('data-expanded')).toBe('true'); + + pointer(region()!, 'pointerleave'); + await advance(400); + expect(toasts()).toHaveLength(1); + await advance(100); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('stops the timer while the keyboard focus is in the region', async () => { + setup({ timeout: 1000 }); + + await press('add'); + byTestId('before').focus(); + await userEvent.keyboard('{F6}'); + await tick(); + expect(document.activeElement).toBe(toasts()[0]); + + await advance(3000); + expect(toasts()).toHaveLength(1); + expect(region()?.getAttribute('data-expanded')).toBe('true'); + + await userEvent.keyboard('{F6}'); + await tick(); + expect(document.activeElement).toBe(byTestId('before')); + await advance(1000); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('keeps a toast with timeout 0, and a loading toast', async () => { + setup({ timeout: 1000 }); + await tick(); + + getManager().add({ title: 'Forever', timeout: 0 }); + getManager().add({ title: 'Working', type: 'loading' }); + await advance(5000); + + expect(toasts()).toHaveLength(2); + }); + + it('gives a toast its full time again on an update', async () => { + setup({ timeout: 1000 }); + await tick(); + + const id = getManager().add({ title: 'One' }); + await advance(800); + getManager().update(id, { title: 'Two' }); + await tick(); + expect(byTestId('title').textContent?.trim()).toBe('Two'); + expect(polite()).toBe('Two'); + + await advance(800); + expect(toasts()).toHaveLength(1); + await advance(200); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('turns a promise into loading, then success', async () => { + setup({ timeout: 1000 }); + await tick(); + + let resolve!: (value: string) => void; + const task = new Promise((done) => (resolve = done)); + const result = getManager().promise(task, { + loading: 'Uploading', + success: (name) => `${name} is up`, + error: 'Failed' + }); + await tick(); + expect(toasts()[0].getAttribute('data-type')).toBe('loading'); + expect(byTestId('description').textContent?.trim()).toBe('Uploading'); + await advance(3000); + expect(toasts()).toHaveLength(1); + + resolve('photo.png'); + await result; + await tick(); + expect(toasts()[0].getAttribute('data-type')).toBe('success'); + expect(byTestId('description').textContent?.trim()).toBe('photo.png is up'); + await advance(1000); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('closes all toasts at once', async () => { + setup(); + await tick(); + + getManager().add({ title: 'A' }); + getManager().add({ title: 'B' }); + await tick(); + getManager().close(); + await expect.poll(() => toasts().length).toBe(0); + }); + }); + + describe('manager', () => { + it('reaches a component under the provider, and a second provider shares it', async () => { + render(ToastSharedTest); + await tick(); + const buttons = document.querySelectorAll( + '[data-testid="add-from-child"]' + ); + expect(buttons).toHaveLength(2); + + buttons[1].click(); + await tick(); + + expect(toasts()).toHaveLength(1); + const counts = Array.from(document.querySelectorAll('[data-testid="count"]')).map( + (node) => node.textContent + ); + expect(counts).toEqual(['1', '1']); + }); + + it('renders the viewport at the end of the body by default', async () => { + setup({ portal: true }); + await press('add'); + + expect(region()?.parentElement?.parentElement).toBe(document.body); + expect(document.body.lastElementChild?.contains(region())).toBe(true); + }); + + it('stops the timers while the window is in the background', async () => { + setup({ timeout: 1000 }); + await press('add'); + await advance(500); + + window.dispatchEvent(new Event('blur')); + await advance(3000); + expect(toasts()).toHaveLength(1); + + window.dispatchEvent(new Event('focus')); + await advance(500); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('does not stop the timers for a touch on the region', async () => { + setup({ timeout: 1000 }); + await press('add'); + + pointer(region()!, 'pointerenter', 'touch'); + await advance(1000); + await expect.poll(() => toasts().length).toBe(0); + }); + }); + + describe('behind a modal', () => { + it('stays reachable while a modal dialog is open', async () => { + render(ToastModalTest); + await tick(); + await expect.poll(() => byTestId('dialog').getAttribute('aria-modal')).toBe('true'); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + + byTestId('add').click(); + await tick(); + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + + const viewport = region(); + expect(viewport).not.toBeNull(); + expect(viewport?.closest('[aria-hidden="true"]')).toBeNull(); + expect(viewport?.closest('[inert]')).toBeNull(); + expect(byTestId('add').closest('[aria-hidden="true"]')).not.toBeNull(); + }); + }); + + describe('keyboard', () => { + it('F6 lands on the first toast, Escape closes it and the focus goes back', async () => { + setup(); + + await press('add'); + byTestId('before').focus(); + await userEvent.keyboard('{F6}'); + await tick(); + expect(document.activeElement).toBe(toasts()[0]); + + await userEvent.keyboard('{Escape}'); + await tick(); + expect(toasts()[0]?.getAttribute('data-ending') ?? 'gone').toMatch(/true|gone/); + expect(document.activeElement).toBe(byTestId('before')); + }); + + it('moves the focus to the next toast when the focused one closes', async () => { + setup(); + + await press('add'); + await press('add'); + byTestId('before').focus(); + await userEvent.keyboard('{F6}'); + await tick(); + const [first, second] = toasts(); + expect(document.activeElement).toBe(first); + + await userEvent.keyboard('{Escape}'); + await tick(); + expect(document.activeElement).toBe(second); + }); + + it('Tab past the last button goes back to where the focus was', async () => { + setup(); + + await press('add'); + byTestId('before').focus(); + await userEvent.keyboard('{F6}'); + await userEvent.keyboard('{Tab}'); + await tick(); + expect(document.activeElement).toBe(byTestId('close')); + + await userEvent.keyboard('{Tab}'); + await tick(); + expect(document.activeElement).toBe(byTestId('before')); + }); + }); + + describe('buttons', () => { + it('the close button closes the toast', async () => { + setup(); + + await press('add'); + await press('close'); + + expect(toasts()[0]?.getAttribute('data-ending') ?? 'gone').toMatch(/true|gone/); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('the action closes the toast, unless keepOpen', async () => { + setup({ withAction: true, keepOpen: true }); + + await press('add'); + await press('action'); + expect(toasts()).toHaveLength(1); + expect(toasts()[0].hasAttribute('data-ending')).toBe(false); + }); + }); + + describe('limit', () => { + it('keeps the newest toasts on the screen, and the older ones inert behind', async () => { + setup({ limit: 2, timeout: 1000 }); + + await press('add'); + await press('add'); + await press('add'); + + const all = toasts(); + expect(all).toHaveLength(3); + expect(all[2].hasAttribute('inert')).toBe(true); + expect(all[2].getAttribute('data-limited')).toBe('true'); + expect(region()?.getAttribute('aria-label')).toBe('2 notifications'); + + // The limited toast keeps its full time: its timer starts when it comes forward. + await advance(1000); + await expect + .poll(() => toasts().filter((t) => !t.hasAttribute('data-ending')).length) + .toBe(1); + const [left] = toasts().filter((t) => !t.hasAttribute('data-ending')); + expect(left.hasAttribute('inert')).toBe(false); + await advance(900); + expect(left.hasAttribute('data-ending')).toBe(false); + await advance(100); + await expect.poll(() => toasts().length).toBe(0); + }); + }); + + describe('swipe', () => { + it('follows the finger, and dismisses past the threshold', async () => { + setup({ swipeDirection: ['right'] }); + + await press('add'); + const toast = toasts()[0]; + const rect = toast.getBoundingClientRect(); + const at = (x: number, type: string) => + toast.dispatchEvent( + new PointerEvent(type, { + pointerType: 'touch', + pointerId: 7, + isPrimary: true, + button: 0, + clientX: rect.left + 20 + x, + clientY: rect.top + 10, + bubbles: true, + cancelable: true + }) + ); + + at(0, 'pointerdown'); + window.dispatchEvent( + new PointerEvent('pointermove', { + pointerType: 'touch', + pointerId: 7, + clientX: rect.left + 40, + clientY: rect.top + 10, + bubbles: true, + cancelable: true + }) + ); + await tick(); + expect(toast.getAttribute('data-swiping')).toBe('true'); + expect(toast.getAttribute('data-swipe-direction')).toBe('right'); + expect(toast.style.getPropertyValue('--toast-swipe-movement-x')).toBe('20px'); + + window.dispatchEvent( + new PointerEvent('pointermove', { + pointerType: 'touch', + pointerId: 7, + clientX: rect.left + 80, + clientY: rect.top + 10, + bubbles: true, + cancelable: true + }) + ); + window.dispatchEvent( + new PointerEvent('pointerup', { + pointerType: 'touch', + pointerId: 7, + clientX: rect.left + 80, + clientY: rect.top + 10, + bubbles: true + }) + ); + await tick(); + expect(toast.getAttribute('data-swipe-dismissed')).toBe('true'); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('comes back after a short swipe', async () => { + setup({ swipeDirection: ['right'] }); + + await press('add'); + const toast = toasts()[0]; + const rect = toast.getBoundingClientRect(); + const move = (x: number, type: string, target: EventTarget = window) => + target.dispatchEvent( + new PointerEvent(type, { + pointerType: 'touch', + pointerId: 7, + isPrimary: true, + button: 0, + clientX: rect.left + 20 + x, + clientY: rect.top + 10, + bubbles: true, + cancelable: true + }) + ); + + move(0, 'pointerdown', toast); + move(20, 'pointermove'); + await tick(); + expect(toast.style.getPropertyValue('--toast-swipe-movement-x')).toBe('20px'); + // A finger that stops before it lifts: the release has no speed. Frames, not timers, + // because the timers are fake and the velocity reads the real clock. + for (let frame = 0; frame < 12; frame += 1) { + await new Promise((done) => requestAnimationFrame(done)); + } + move(20, 'pointerup'); + await tick(); + expect(toast.style.getPropertyValue('--toast-swipe-movement-x')).toBe('0px'); + expect(toast.hasAttribute('data-swiping')).toBe(false); + expect(toast.hasAttribute('data-ending')).toBe(false); + }); + }); +}); diff --git a/packages/ui/src/lib/toast/root/context.ts b/packages/ui/src/lib/toast/root/context.ts new file mode 100644 index 00000000..849b6d27 --- /dev/null +++ b/packages/ui/src/lib/toast/root/context.ts @@ -0,0 +1,35 @@ +import { getContext, setContext } from 'svelte'; +import type { ToastItem } from '../provider/toast-manager.svelte'; + +const KEY = Symbol('toast'); + +export type ToastContext = { + /** The item of this toast. */ + readonly toast: ToastItem; + /** The ids of the registered titles, for `aria-labelledby`. */ + readonly labelledBy: string | undefined; + /** The ids of the registered descriptions, for `aria-describedby`. */ + readonly describedBy: string | undefined; + /** Registers a title id; the returned function unregisters it. */ + registerTitle: (id: string) => () => void; + /** Registers a description id; the returned function unregisters it. */ + registerDescription: (id: string) => () => void; + /** Closes this toast, and moves the focus on when it was in it. */ + close: () => void; +}; + +export function setToastContext(context: ToastContext) { + setContext(KEY, context); +} + +export function getToastContext(): ToastContext | undefined { + return getContext(KEY); +} + +export function useToastContext(part = 'Toast'): ToastContext { + const context = getToastContext(); + if (!context) { + throw new Error(`${part} must be used within Toast.Root.`); + } + return context; +} diff --git a/packages/ui/src/lib/toast/root/toast-root.svelte b/packages/ui/src/lib/toast/root/toast-root.svelte new file mode 100644 index 00000000..8845bf82 --- /dev/null +++ b/packages/ui/src/lib/toast/root/toast-root.svelte @@ -0,0 +1,256 @@ + + + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/toast/title/toast-title.svelte b/packages/ui/src/lib/toast/title/toast-title.svelte new file mode 100644 index 00000000..4e1c99ff --- /dev/null +++ b/packages/ui/src/lib/toast/title/toast-title.svelte @@ -0,0 +1,29 @@ + + +
+ {#if children} + {@render children()} + {:else} + {ctx.toast.title} + {/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..cda8f9ed --- /dev/null +++ b/packages/ui/src/lib/toast/types.ts @@ -0,0 +1,116 @@ +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 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..05e953fe --- /dev/null +++ b/packages/ui/src/lib/toast/viewport/toast-viewport.svelte @@ -0,0 +1,306 @@ + + +{#snippet viewport()} +
+ {#key politeKey} +
{politeMessage}
+ {/key} +
+
+ {#key assertiveKey} +
{assertiveMessage}
+ {/key} +
+ {#if manager.toasts.length > 0} +
+ {#each manager.toasts as toast (toast.id)} + {@render children(toast)} + {/each} +
+ {/if} +{/snippet} + +{#if portal} + {@render viewport()} +{:else} + {@render viewport()} +{/if} From 1468b36e7b524351c52572676dd92ad62d85c166 Mon Sep 17 00:00:00 2001 From: Agustin Delgado Date: Fri, 18 Sep 2026 17:09:10 -0300 Subject: [PATCH 02/13] feat(toast): add Toast.Positioner for a toast against an element `add` takes `anchor`, `placement` and `offset`, and `Toast.Positioner` puts the toast against that element as a fixed panel that follows it and flips when the space is not sufficient. A toast with an anchor is out of the stack: `--toast-index` reads 0, and `data-anchored` is on the root. For a toast without one, the positioner is out of the layout, thus one snippet serves both kinds. The docs say where `data-hk-swipe-ignore` goes. --- .changeset/toast-v1.md | 2 +- docs/src/content/toast/api.json | 52 +++++++++++++ docs/src/content/toast/demos/anchored.svelte | 59 +++++++++++++++ docs/src/content/toast/index.md | 14 +++- packages/ui/src/lib/toast/README.md | 6 +- packages/ui/src/lib/toast/TODO.md | 4 +- packages/ui/src/lib/toast/index.parts.ts | 1 + packages/ui/src/lib/toast/index.ts | 3 + .../toast/positioner/toast-positioner.svelte | 75 +++++++++++++++++++ .../toast/provider/toast-manager.svelte.ts | 22 ++++++ .../src/lib/toast/provider/toast-test.svelte | 42 ++++++++--- .../ui/src/lib/toast/provider/toast.test.ts | 33 ++++++++ .../ui/src/lib/toast/root/toast-root.svelte | 6 +- packages/ui/src/lib/toast/types.ts | 11 +++ .../lib/toast/viewport/toast-viewport.svelte | 2 +- 15 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 docs/src/content/toast/demos/anchored.svelte create mode 100644 packages/ui/src/lib/toast/positioner/toast-positioner.svelte diff --git a/.changeset/toast-v1.md b/.changeset/toast-v1.md index f9bf2e35..2ebe961d 100644 --- a/.changeset/toast-v1.md +++ b/.changeset/toast-v1.md @@ -2,4 +2,4 @@ '@human-kit/ui': minor --- -Add the Toast primitive with `Provider`, `Viewport`, `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. The timers stop on hover, on keyboard focus and in a background window, 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`. +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 background window, 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`. diff --git a/docs/src/content/toast/api.json b/docs/src/content/toast/api.json index 96a4d2a0..86955f78 100644 --- a/docs/src/content/toast/api.json +++ b/docs/src/content/toast/api.json @@ -80,6 +80,54 @@ } ] }, + { + "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.", @@ -121,6 +169,10 @@ } ], "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." diff --git a/docs/src/content/toast/demos/anchored.svelte b/docs/src/content/toast/demos/anchored.svelte new file mode 100644 index 00000000..3dbc2a92 --- /dev/null +++ b/docs/src/content/toast/demos/anchored.svelte @@ -0,0 +1,59 @@ + + + + + + + {#snippet children(toast)} + + + + + + + + {/snippet} + + + + diff --git a/docs/src/content/toast/index.md b/docs/src/content/toast/index.md index d492383e..9850bc98 100644 --- a/docs/src/content/toast/index.md +++ b/docs/src/content/toast/index.md @@ -11,6 +11,8 @@ description: A short message about an event, for a time, in a landmark the keybo import promiseSource from './demos/promise.svelte?highlight'; import Action from './demos/action.svelte'; import actionSource from './demos/action.svelte?highlight'; + import Anchored from './demos/anchored.svelte'; + import anchoredSource from './demos/anchored.svelte?highlight'; import api from './api.json'; @@ -24,7 +26,7 @@ description: A short message about an event, for a time, in a landmark the keybo `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.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 + +{#if anchor} +
{ + resolvedPlacement = resolvePlacementSide(finalPlacement); + } + }} + > + {@render children?.()} +
+{:else} + +
+ {@render children?.()} +
+{/if} diff --git a/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts b/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts index d47cda3f..1f253d22 100644 --- a/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts +++ b/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts @@ -1,4 +1,5 @@ import { untrack } from 'svelte'; +import type { ExtendedPlacement } from '../../primitives/floating'; export type ToastPriority = 'low' | 'high'; export type ToastStatus = 'open' | 'ending'; @@ -29,6 +30,15 @@ export type ToastOptions = { priority?: ToastPriority; /** Data of your own, for the content you render. */ data?: Data; + /** + * An element the toast sits against, in place of the viewport. `Toast.Positioner` puts the + * toast there. The toast is out of the stack: it has no place in the corner to take. + */ + anchor?: HTMLElement | null; + /** The side of the anchor for the toast. `top` by default. */ + placement?: ExtendedPlacement; + /** The gap between the anchor and the toast, in pixels. 8 by default. */ + offset?: number; /** Runs when the toast starts to close. */ onClose?: () => void; /** Runs when the toast leaves the DOM, after its exit animation. */ @@ -43,6 +53,9 @@ export type ToastItem = { readonly timeout: number; readonly priority: ToastPriority; readonly data: Data | undefined; + readonly anchor: HTMLElement | null | undefined; + readonly placement: ExtendedPlacement | undefined; + readonly offset: number | undefined; /** `open` on the screen, `ending` through the exit animation. */ readonly status: ToastStatus; /** The toast is past the limit of the viewport. It waits, hidden and inert. */ @@ -73,6 +86,8 @@ export type ToastManager = { readonly toasts: readonly ToastItem[]; /** The toasts on the screen: the newest ones, up to the limit. */ readonly visibleToasts: readonly ToastItem[]; + /** The toasts on the screen that stack in the viewport: the ones without an anchor. */ + readonly stackedToasts: readonly ToastItem[]; /** Adds a toast, and answers its id. */ add: (options: ToastOptions) => string; /** Updates a toast. The options replace the fields they name. */ @@ -139,6 +154,7 @@ export function createToastManager( const getLimit = options.limit ?? (() => DEFAULT_TOAST_LIMIT); const visibleToasts = $derived(toasts.filter((toast) => !toast.limited)); + const stackedToasts = $derived(visibleToasts.filter((toast) => !toast.anchor)); function setToasts(next: ToastItem[]) { toasts = applyLimit(next); @@ -228,6 +244,9 @@ export function createToastManager( timeout: options.timeout ?? untrack(getTimeout), priority: options.priority ?? 'low', data: options.data, + anchor: options.anchor, + placement: options.placement, + offset: options.offset, status: 'open', limited: false, updateKey: 0, @@ -321,6 +340,9 @@ export function createToastManager( get visibleToasts() { return visibleToasts; }, + get stackedToasts() { + return stackedToasts; + }, get paused() { return paused; }, diff --git a/packages/ui/src/lib/toast/provider/toast-test.svelte b/packages/ui/src/lib/toast/provider/toast-test.svelte index d09c7bc7..c04144fa 100644 --- a/packages/ui/src/lib/toast/provider/toast-test.svelte +++ b/packages/ui/src/lib/toast/provider/toast-test.svelte @@ -10,6 +10,7 @@ swipeDirection?: SwipeSide[]; withAction?: boolean; keepOpen?: boolean; + withPositioner?: boolean; onManager?: (manager: ToastManager) => void; }; @@ -20,9 +21,12 @@ swipeDirection, withAction = false, keepOpen = false, + withPositioner = false, onManager }: Props = $props(); + let anchorRef = $state(null); + let manager = $state(); $effect(() => { @@ -40,6 +44,15 @@ > Add +