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 @@ + + +
+ {@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..c1932b36 --- /dev/null +++ b/packages/ui/src/lib/toast/description/toast-description.svelte @@ -0,0 +1,41 @@ + + +{#if hasContent} +

+ {#if children} + {@render children()} + {:else} + {ctx.toast.description} + {/if} +

+{/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} +
{ + resolvedPlacement = resolvePlacementSide(finalPlacement); + } + }} + > + {@render children?.()} +
+{:else} + +
+ {@render children?.()} +
+{/if} 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..1ec8a14e --- /dev/null +++ b/packages/ui/src/lib/toast/provider/context.ts @@ -0,0 +1,56 @@ +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; + /** A touch tapped a toast, lifted where it landed, and no touch outside came after it. */ + readonly tapped: boolean; + /** + * The toasts are spread out: the pointer rests on them, the focus is in them, or a touch + * tapped 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; + setTapped: (tapped: 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..8cb5e69e --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-manager.svelte.ts @@ -0,0 +1,370 @@ +import { untrack } from 'svelte'; +import type { ExtendedPlacement } from '../../primitives/floating'; + +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; + /** + * 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. */ + 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; + 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. */ + 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[]; + /** + * The toasts that take a place in the stack of the viewport: the ones on the screen, without + * an anchor, and not on their way out. A toast through its exit keeps the place it had, and + * the ones behind it move up at once. + */ + 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. */ + 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)); + const stackedToasts = $derived( + visibleToasts.filter((toast) => !toast.anchor && toast.status !== 'ending') + ); + + 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, + anchor: options.anchor, + placement: options.placement, + offset: options.offset, + 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 stackedToasts() { + return stackedToasts; + }, + 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..77cb986d --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-provider.svelte @@ -0,0 +1,98 @@ + + +{@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..c04144fa --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast-test.svelte @@ -0,0 +1,89 @@ + + + + + + + + + + {#snippet children(toast)} + {#snippet root()} + + + + + + {#if withAction} + Undo + {/if} + x + + {/snippet} + {#if withPositioner} + + {@render root()} + + {:else} + {@render root()} + {/if} + {/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..8483fb6d --- /dev/null +++ b/packages/ui/src/lib/toast/provider/toast.test.ts @@ -0,0 +1,824 @@ +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]'); +} + +/** The last message of the polite announcer. */ +function polite(): string { + return document.querySelector('[role="status"] > div:last-child')?.textContent?.trim() ?? ''; +} + +/** The last message of the assertive announcer. */ +function assertive(): string { + return document.querySelector('[role="alert"] > div:last-child')?.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; + +async function setup(props: Record = {}) { + const screen = render(ToastTest, { + ...props, + onManager: (value: ToastManager) => { + manager = value; + } + }); + // The real mouse rests where the file before left it. On the region, it would be a hover + // that stops the timers and spreads the stack out: park it on the button above the region. + await userEvent.hover(byTestId('before')); + return screen; +} + +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'] }); + // The full run puts each file in a frame the browser can report as hidden, and a hidden + // page stops the timers. These tests are about a page the user looks at. + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + manager = undefined; + Reflect.deleteProperty(document, 'visibilityState'); + }); + + describe('region and announcement', () => { + it('renders nothing but the announcers while there is no toast', async () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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('moves the toasts behind up while one is on its way out, which keeps its place', async () => { + await setup({ timeout: 0 }); + await tick(); + + getManager().add({ title: 'A' }); + const middle = getManager().add({ title: 'B' }); + getManager().add({ title: 'C' }); + await tick(); + expect(toasts().map((toast) => toast.style.getPropertyValue('--toast-index'))).toEqual([ + '0', + '1', + '2' + ]); + + getManager().close(middle); + await tick(); + + const [front, ending, behind] = toasts(); + expect(ending.getAttribute('data-ending')).toBe('true'); + expect(ending.style.getPropertyValue('--toast-index')).toBe('1'); + expect(front.style.getPropertyValue('--toast-index')).toBe('0'); + expect(behind.style.getPropertyValue('--toast-index')).toBe('1'); + expect(getManager().stackedToasts.map((toast) => toast.title)).toEqual(['C', 'A']); + }); + + it('gives up the front to the toast behind while it goes', async () => { + await setup({ timeout: 0 }); + await tick(); + + getManager().add({ title: 'A' }); + const front = getManager().add({ title: 'B' }); + await tick(); + expect(toasts()[0].getAttribute('data-front')).toBe('true'); + + getManager().close(front); + await tick(); + + const [ending, behind] = toasts(); + expect(ending.hasAttribute('data-front')).toBe(false); + expect(behind.getAttribute('data-front')).toBe('true'); + }); + + it('does not read the buttons as part of the message', async () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 tab is hidden', async () => { + await setup({ timeout: 1000 }); + await press('add'); + await advance(500); + + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + await advance(3000); + expect(toasts()).toHaveLength(1); + + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + await advance(500); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('does not stop the timers for a touch that passes over the region', async () => { + await setup({ timeout: 1000 }); + await press('add'); + + pointer(region()!, 'pointerenter', 'touch'); + await advance(1000); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('a tap spreads the stack out and holds the timers, and a touch outside folds it back', async () => { + await setup({ timeout: 1000 }); + await press('add'); + await press('add'); + expect(region()?.hasAttribute('data-expanded')).toBe(false); + + // The finger down alone is not a tap yet: a swipe starts the same way. + pointer(toasts()[0], 'pointerdown', 'touch'); + await tick(); + expect(region()?.hasAttribute('data-expanded')).toBe(false); + pointer(toasts()[0], 'pointerup', 'touch'); + await tick(); + expect(region()?.getAttribute('data-expanded')).toBe('true'); + expect(toasts()[1].getAttribute('data-expanded')).toBe('true'); + await advance(1500); + expect(toasts().length).toBe(2); + + // A mouse press outside is not a touch: the stack stays open. + pointer(byTestId('before'), 'pointerdown', 'mouse'); + await tick(); + expect(region()?.getAttribute('data-expanded')).toBe('true'); + + pointer(byTestId('before'), 'pointerdown', 'touch'); + await tick(); + expect(region()?.hasAttribute('data-expanded')).toBe(false); + await advance(1000); + await expect.poll(() => toasts().length).toBe(0); + }); + + it('a swipe and a press on a button are not taps', async () => { + await setup({ timeout: 0, withAction: true }); + await press('add'); + await press('add'); + const toast = toasts()[0]; + const touchAt = (target: Element, type: string, x: number) => + target.dispatchEvent( + new PointerEvent(type, { + pointerType: 'touch', + pointerId: 8, + isPrimary: true, + bubbles: true, + clientX: 100 + x, + clientY: 100 + }) + ); + + // A finger that moves before it lifts is a swipe, even a short one. + touchAt(toast, 'pointerdown', 0); + touchAt(toast, 'pointerup', 30); + await tick(); + expect(region()?.hasAttribute('data-expanded')).toBe(false); + + const close = byTestId('close'); + touchAt(close, 'pointerdown', 0); + touchAt(close, 'pointerup', 0); + await tick(); + expect(region()?.hasAttribute('data-expanded')).toBe(false); + + touchAt(toast, 'pointerdown', 0); + touchAt(toast, 'pointerup', 4); + await tick(); + expect(region()?.getAttribute('data-expanded')).toBe('true'); + }); + }); + + 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(); + }); + + it('F6 reaches the toast from the modal, and F6 goes back to it', 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(); + const dismiss = byTestId('dialog').querySelector('[data-dialog-close]'); + dismiss?.focus(); + expect(document.activeElement).toBe(dismiss); + + await userEvent.keyboard('{F6}'); + await tick(); + await new Promise((done) => requestAnimationFrame(done)); + expect(document.activeElement?.hasAttribute('data-toast-root')).toBe(true); + + await userEvent.keyboard('{Tab}'); + await tick(); + expect(document.activeElement).toBe(byTestId('close')); + + await userEvent.keyboard('{F6}'); + await tick(); + expect(document.activeElement).toBe(dismiss); + }); + }); + + describe('announcements', () => { + it('reads two toasts of the same tick in their order, and the same text twice', async () => { + await setup(); + await tick(); + + getManager().add({ title: 'First' }); + getManager().add({ title: 'Same' }); + getManager().add({ title: 'Same' }); + await tick(); + + const nodes = document.querySelectorAll('[role="status"] > div'); + expect(Array.from(nodes).map((node) => node.textContent)).toEqual(['First', 'Same', 'Same']); + + await advance(2100); + expect(document.querySelectorAll('[role="status"] > div')).toHaveLength(0); + }); + + it('names a toast without a title by its description', async () => { + await setup(); + await tick(); + + getManager().add({ description: 'Only a message' }); + await tick(); + const toast = toasts()[0]; + + await expect + .poll(() => toast.getAttribute('aria-labelledby')) + .toBe(byTestId('description').id); + expect(toast.hasAttribute('aria-describedby')).toBe(false); + }); + + it('does not count a toast on its way out in the name of the region', async () => { + await setup({ timeout: 0 }); + await tick(); + + const id = getManager().add({ title: 'A' }); + getManager().add({ title: 'B' }); + await tick(); + expect(region()?.getAttribute('aria-label')).toBe('2 notifications'); + + getManager().close(id); + await tick(); + expect(region()?.getAttribute('aria-label')).toBe('1 notification'); + }); + + it('moves the focus on when the page closes the focused toast', async () => { + await setup(); + await tick(); + + const first = getManager().add({ title: 'A' }); + getManager().add({ title: 'B' }); + await tick(); + byTestId('before').focus(); + await userEvent.keyboard('{F6}'); + await tick(); + // The newest is first: F6 lands on B. Focus A, then close it from the page. + const a = byTestId(`toast-${first}`); + a.focus(); + expect(document.activeElement).toBe(a); + + getManager().close(first); + await tick(); + expect(document.activeElement?.getAttribute('data-toast-id')).not.toBe(first); + expect(document.activeElement?.hasAttribute('data-toast-root')).toBe(true); + }); + }); + + describe('keyboard', () => { + it('F6 lands on the first toast, Escape closes it and the focus goes back', async () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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 () => { + await 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); + }); + + it('gives a toast past the limit the place behind the stack', async () => { + await setup({ limit: 2, timeout: 0 }); + await tick(); + + getManager().add({ title: 'A' }); + getManager().add({ title: 'B' }); + getManager().add({ title: 'C' }); + await tick(); + + // The waiting toast must not take the place in front: it would come forward from + // there, over the toast the user reads, when a place frees. + const waiting = toasts()[2]; + expect(waiting.getAttribute('data-limited')).toBe('true'); + expect(waiting.hasAttribute('data-front')).toBe(false); + expect(waiting.style.getPropertyValue('--toast-index')).toBe('2'); + + getManager().close(toasts()[0].dataset.toastId); + await tick(); + + expect(waiting.style.getPropertyValue('--toast-index')).toBe('1'); + }); + }); + + describe('positioner', () => { + it('puts an anchored toast against its anchor, and keeps it out of the stack', async () => { + await setup({ withPositioner: true }); + + await press('add'); + await press('add-anchored'); + await tick(); + + const anchored = toasts().find((toast) => toast.hasAttribute('data-anchored')); + const stacked = toasts().find((toast) => !toast.hasAttribute('data-anchored')); + expect(anchored).toBeDefined(); + expect(stacked?.getAttribute('data-front')).toBe('true'); + expect(stacked?.style.getPropertyValue('--toast-index')).toBe('0'); + expect(anchored?.style.getPropertyValue('--toast-index')).toBe('0'); + + const positioner = anchored?.parentElement as HTMLElement; + expect(positioner.getAttribute('data-toast-positioner')).toBe('true'); + expect(positioner.getAttribute('data-anchored')).toBe('true'); + expect(positioner.style.position).toBe('fixed'); + await expect.poll(() => positioner.getAttribute('data-placement')).toBe('bottom'); + const anchorRect = byTestId('add-anchored').getBoundingClientRect(); + await expect + .poll(() => Math.round(positioner.getBoundingClientRect().top)) + .toBe(Math.round(anchorRect.bottom + 4)); + + // The plain wrapper of a stacked toast stays out of the layout. + const plain = stacked?.parentElement as HTMLElement; + expect(plain.getAttribute('data-toast-positioner')).toBe('true'); + expect(plain.hasAttribute('data-anchored')).toBe(false); + expect(plain.style.display).toBe('contents'); + }); + }); + + describe('swipe', () => { + it('follows the finger, and dismisses past the threshold', async () => { + await 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 () => { + await 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); + }); + + it('gives a little to a pull the wrong way, and no more', async () => { + await 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 + 200 + x, + clientY: rect.top + 10, + bubbles: true, + cancelable: true + }) + ); + + move(0, 'pointerdown', toast); + move(-8, 'pointermove'); + await tick(); + const short = parseFloat(toast.style.getPropertyValue('--toast-swipe-movement-x')); + move(-160, 'pointermove'); + await tick(); + const long = parseFloat(toast.style.getPropertyValue('--toast-swipe-movement-x')); + expect(short).toBeLessThan(0); + expect(long).toBeLessThan(short); + expect(long).toBeGreaterThan(-8); + move(-160, 'pointerup'); + await tick(); + 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..aa6050c5 --- /dev/null +++ b/packages/ui/src/lib/toast/root/toast-root.svelte @@ -0,0 +1,296 @@ + + + + +
+ {@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..b02086d1 --- /dev/null +++ b/packages/ui/src/lib/toast/title/toast-title.svelte @@ -0,0 +1,38 @@ + + +{#if hasContent} +
+ {#if children} + {@render children()} + {:else} + {ctx.toast.title} + {/if} +
+{/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)} +
{message.text}
+ {/each} +
+ {#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}