From bbcbef1e2b74894129569f15b7bcb0cd16080390 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 20:29:20 +0100 Subject: [PATCH 1/4] feat(toaster): follow the WordPress snackbar in place, timing, clicks and motion --- README.md | 5 - src/base.css | 79 +++++--- src/toaster.tsx | 196 ++++++++----------- test/base-css.test.ts | 106 ++++------ test/css-rules.ts | 11 ++ test/toast-motion-css.test.ts | 118 +++++++++++ test/toaster.test.tsx | 358 ++++++++++++++++++++++++---------- 7 files changed, 551 insertions(+), 322 deletions(-) create mode 100644 test/toast-motion-css.test.ts diff --git a/README.md b/README.md index 2ddb103..f98b493 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,6 @@ pnpm add @gopherium/godmin The design system packages are peer dependencies, so your application resolves and pins them. GodMin never redistributes them. -## Browsers - -GodMin needs `Intl.Segmenter`, which every browser WordPress supports has: -Chrome and Edge 87, Safari 14.1 and Firefox 125 or newer. - ## Setup Import the stylesheet once at your entry point, then mount `AdminRoot` around diff --git a/src/base.css b/src/base.css index d3e3ba7..1186888 100644 --- a/src/base.css +++ b/src/base.css @@ -279,26 +279,50 @@ body { .godmin-toasts { position: fixed; inset-inline: 0; - bottom: 32px; + bottom: var(--wpds-dimension-size-md); z-index: 1; display: flex; flex-direction: column; - align-items: center; - gap: var(--wpds-dimension-gap-sm); padding-inline: 16px; box-sizing: border-box; pointer-events: none; } - :root:has(.godmin-layout__sidebar) .godmin-toasts { - inset-inline-start: var(--godmin-nav-sidebar-width); - inset-inline-end: var(--godmin-canvas-margin); - } - .godmin-toasts:focus { outline: none; } + .godmin-toasts__item { + display: grid; + grid-template-rows: 1fr; + animation: godmin-toast-grow var(--wpds-motion-duration-lg) cubic-bezier(0, 0, 0.2, 1); + } + + .godmin-toasts__item--leaving { + animation: + godmin-toast-grow var(--wpds-motion-duration-lg) cubic-bezier(0, 0, 0.2, 1), + godmin-toast-shrink 450ms cubic-bezier(0.4, 0, 0.1, 1) var(--wpds-motion-duration-sm) forwards; + } + + .godmin-toasts__item-inner { + display: flex; + flex-direction: column; + align-items: center; + min-height: 0; + } + + @keyframes godmin-toast-grow { + 0% { + grid-template-rows: 0fr; + } + } + + @keyframes godmin-toast-shrink { + 100% { + grid-template-rows: 0fr; + } + } + .godmin-toast { display: flex; align-items: baseline; @@ -320,20 +344,25 @@ body { font-size: var(--wpds-typography-font-size-md); line-height: 1.4; overflow-wrap: anywhere; - cursor: default; + cursor: var(--wpds-cursor-control); pointer-events: auto; - animation: godmin-fade-in var(--wpds-motion-duration-md) var(--wpds-motion-easing-balanced); + animation: godmin-fade-in 250ms cubic-bezier(0, 0, 0.2, 1) var(--wpds-motion-duration-xs) backwards; } .godmin-toast--leaving { - animation: godmin-fade-out var(--wpds-motion-duration-sm) var(--wpds-motion-easing-balanced) forwards; + animation: + godmin-fade-in 250ms cubic-bezier(0, 0, 0.2, 1) var(--wpds-motion-duration-xs) backwards, + godmin-fade-out var(--wpds-motion-duration-sm) cubic-bezier(0, 0, 0.2, 1) forwards; } .godmin-toast--plain { margin: 0; border: 0; text-align: start; - cursor: var(--wpds-cursor-control); + } + + .godmin-toasts__item-inner > .godmin-toast { + margin-block-start: var(--wpds-dimension-gap-sm); } .godmin-toast__action { @@ -355,17 +384,6 @@ body { text-decoration: none; } - .godmin-toast__dismiss { - flex: none; - margin-inline-start: var(--wpds-dimension-gap-xl); - padding: 0; - border: 0; - background: none; - color: inherit; - font: inherit; - cursor: var(--wpds-cursor-control); - } - .godmin-toast--plain:focus-visible { box-shadow: inset 0 0 0 var(--wpds-border-width-xs) #fff, @@ -373,8 +391,8 @@ body { } .godmin-toast__action:focus-visible { - outline: var(--wpds-border-width-xs) dotted #fff; - outline-offset: 2px; + outline: var(--wpds-border-width-focus) solid #fff; + outline-offset: var(--wpds-border-width-focus); border-radius: var(--wpds-border-radius-sm); text-decoration: none; } @@ -982,10 +1000,6 @@ body { } @keyframes godmin-fade-out { - 0% { - opacity: 1; - } - 100% { opacity: 0; } @@ -994,9 +1008,14 @@ body { @media (prefers-reduced-motion: reduce) { .godmin-loading-screen, .godmin-loading-rows, - .godmin-arrival, + .godmin-arrival { + animation-duration: 1ms; + } + + .godmin-toasts__item, .godmin-toast { animation-duration: 1ms; + animation-delay: 0s; } .godmin-navigation__screen-wrapper { diff --git a/src/toaster.tsx b/src/toaster.tsx index be46221..aeb86a6 100644 --- a/src/toaster.tsx +++ b/src/toaster.tsx @@ -1,6 +1,7 @@ /* oxlint-disable react/only-export-components -- the region ships beside the hook that reaches it. */ import { speak } from '@wordpress/a11y' +import { ThemeProvider } from '@wordpress/theme' import { createContext, useCallback, @@ -15,50 +16,25 @@ import { import type { ReactNode, RefObject } from 'react' /** - * How long a toast nobody touches stays on screen. + * How long a toast with no action stays on screen when nobody touches it. */ const DISMISS_AFTER = 6000 /** - * The name of the control clearing a toast. + * How long a toast with an action stays on screen when nobody touches it, time to read it and then act. */ -const DISMISS_LABEL = 'Dismiss' +const ACTION_DISMISS_AFTER = 10000 /** - * The cross the WordPress snackbar draws in its close button. + * The hint telling a screen reader that a click clears a plain toast. */ -const DISMISS_GLYPH = '✕' +const DISMISS_LABEL = 'Dismiss this notice' /** * How many toasts stay on screen at once. */ const LIMIT = 3 -/** - * How many characters of a name a toast shows before the ellipsis. - */ -const NAME_LENGTH = 45 - -/** - * The mark that ends a name a toast cut short. - */ -const ELLIPSIS = '…' - -/** - * Returns a title cut to the given number of characters, its trailing spaces trimmed and an ellipsis added. - * @param title - The title to name. - * @param length - How many characters to keep. - * @returns The title whole when it fits, the cut title otherwise. - */ -function cutName(title: string, length: number): string { - const characters = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) - const parts = Array.from(characters.segment(title), (part) => part.segment) - if (parts.length <= length) { - return title - } - return `${parts.slice(0, length).join('').trimEnd()}${ELLIPSIS}` -} - export interface ToastAction { label: string onAct: () => void @@ -73,8 +49,6 @@ interface Toast { export interface ToasterHandle { show: (message: string, action?: ToastAction) => void - /** The title cut to the toaster's name length, with an ellipsis when cut. */ - name: (title: string) => string } const ToasterContext = createContext(null) @@ -105,43 +79,45 @@ function admit(held: Toast[], arriving: Toast, limit: number): Toast[] { } /** - * Returns the fades running on an element, none where the browser cannot report them. + * Returns the animations running on an element and inside it, none where the browser cannot report them. * @param element - The element to ask. * @returns The running animations. */ -function runningFades(element: Element): Animation[] { - return typeof element.getAnimations === 'function' ? element.getAnimations() : [] +function runningMotion(element: Element): Animation[] { + return typeof element.getAnimations === 'function' ? element.getAnimations({ subtree: true }) : [] } /** - * Returns the ref of a toast element that gives up focus as the toast leaves and drops the toast once faded. - * @param toast - The toast the element draws. + * Returns the ref of a toast item that gives up focus as the toast leaves and drops the toast once its motion ends, + * or at once when the toast was already leaving as it first showed. + * @param toast - The toast the item holds. * @param region - The toast region taking the focus a leaving toast held. * @param onGone - The handler dropping a toast that finished leaving. - * @returns The ref to attach to the toast element. + * @returns The ref to attach to the item element. */ -function useDeparture( +function useDeparture( toast: Toast, region: RefObject, onGone: (id: number) => void, -): RefObject { - const ref = useRef(null) +): RefObject { + const ref = useRef(null) const { id, leaving } = toast + const leavingAtMount = useRef(leaving) useLayoutEffect(() => { if (!leaving) { return } - const element = ref.current as T + const element = ref.current as HTMLDivElement const holder = region.current as HTMLDivElement if (element.contains(document.activeElement)) { holder.focus() } - const fades = runningFades(element) - if (fades.length === 0) { + const motion = leavingAtMount.current ? [] : runningMotion(element) + if (motion.length === 0) { onGone(id) return } - Promise.allSettled(fades.map((fade) => fade.finished)).then(() => onGone(id)) + Promise.allSettled(motion.map((animation) => animation.finished)).then(() => onGone(id)) }, [id, leaving, region, onGone]) return ref } @@ -173,33 +149,43 @@ function toastClass(base: string, leaving: boolean): string { return leaving ? `${base} godmin-toast--leaving` : base } +interface ToastItemProps { + toast: Toast + region: RefObject + onGone: (id: number) => void + children: ReactNode +} + +/** + * Renders the item that holds a toast in the region, its space growing as the toast arrives and closing once it left. + * @param props - The toast, the region, the handler dropping a toast that finished leaving, and the toast element. + * @returns The item element. + */ +function ToastItem({ toast, region, onGone, children }: ToastItemProps) { + const ref = useDeparture(toast, region, onGone) + const item = toast.leaving ? 'godmin-toasts__item godmin-toasts__item--leaving' : 'godmin-toasts__item' + return ( +
+
{children}
+
+ ) +} + interface ToastProps { toast: Toast dismissAfter: number - dismissLabel: string - region: RefObject onLeave: (id: number) => void - onGone: (id: number) => void } /** * Renders a toast with no action as one button that clears it, described by the dismiss hint. - * @param props - The toast, how long it stays, the id of the dismiss hint, the region and the handlers clearing it. + * @param props - The toast, how long it stays, the id of the dismiss hint and the handler clearing it. * @returns The toast element. */ -function PlainToast({ - toast, - dismissAfter, - hint, - region, - onLeave, - onGone, -}: Omit & { hint: string }) { +function PlainToast({ toast, dismissAfter, hint, onLeave }: ToastProps & { hint: string }) { useExpiry(toast, dismissAfter, onLeave) - const ref = useDeparture(toast, region, onGone) return ( - ) } @@ -255,27 +228,27 @@ function ActionToast({ export interface ToasterProps { /** The tree the toast region wraps. */ children: ReactNode - /** How many milliseconds a toast nobody touches stays on screen. */ + /** How many milliseconds a toast with no action stays on screen when nobody touches it. */ dismissAfter?: number - /** The name of the close button, also the hint screen readers hear that a click clears a plain toast. */ + /** How many milliseconds a toast with an action stays on screen when nobody touches it. */ + actionDismissAfter?: number + /** The hint screen readers hear after the message of a plain toast, that a click clears it. */ dismissLabel?: string /** How many toasts stay on screen at once, the oldest leaving first. */ limit?: number - /** How many characters of a name a toast shows before the ellipsis. */ - nameLength?: number } /** - * Renders the region holding raised messages around the given tree. - * @param props - The tree the region wraps, the toast time, the close button name, how many stay and the name length. + * Renders the region holding raised messages around the given tree, under the snackbar corners at every preset. + * @param props - The tree the region wraps, the time of each kind of toast, the plain toast hint and how many stay. * @returns The wrapped tree with its region. */ export function Toaster({ children, dismissAfter = DISMISS_AFTER, + actionDismissAfter = ACTION_DISMISS_AFTER, dismissLabel = DISMISS_LABEL, limit = LIMIT, - nameLength = NAME_LENGTH, }: ToasterProps) { const [toasts, setToasts] = useState([]) const nextId = useRef(0) @@ -305,38 +278,29 @@ export function Toaster({ }, [limit], ) - const name = useCallback((title: string) => cutName(title, nameLength), [nameLength]) - const handle = useMemo(() => ({ show, name }), [show, name]) + const handle = useMemo(() => ({ show }), [show]) const hint = useId() return ( {children} -
- {toasts.map((toast) => - toast.action === undefined ? ( - - ) : ( - - ), - )} -
+ +
+ {toasts.map((toast) => ( + + {toast.action === undefined ? ( + + ) : ( + + )} + + ))} +
+
diff --git a/test/base-css.test.ts b/test/base-css.test.ts index 41f9494..3993ebd 100644 --- a/test/base-css.test.ts +++ b/test/base-css.test.ts @@ -7,7 +7,7 @@ import { dirname, join } from 'node:path' import { expect, test } from 'vitest' import { DENSE_BREAKPOINT, EDGE_BREAKPOINT, SHEET_BREAKPOINT } from '../src/breakpoints.js' -import { atRuleBodies, blockOf, ruleOf } from './css-rules.js' +import { atRuleBodies, blockOf, guardedBy, ruleOf } from './css-rules.js' import { base } from './styles.js' const dataviewsManifest = createRequire(import.meta.url).resolve('@wordpress/dataviews/package.json') @@ -107,11 +107,19 @@ test('names the canvas margin once, 16px, and frames the canvas with it', () => expect(ruleOf('.godmin-layout__canvas')).toMatch(/(?:^|\s)margin:\s*var\(--godmin-canvas-margin\);/) }) -test('centres the toasts on the canvas beside a sidebar rather than on the whole window', () => { - const region = ruleOf(':root:has(.godmin-layout__sidebar) .godmin-toasts') +test('centres the toasts on the whole window, the sidebar included, as the WordPress new admin does', () => { + const css = base.replace(/\/\*[\s\S]*?\*\//g, '') + const selectors = [...css.matchAll(/([^{};]*)\{/g)] + .map((found) => found[1]) + .filter((prelude) => /\.godmin-toasts(?![\w-])/.test(prelude)) + .flatMap((prelude) => prelude.split(',').map((selector) => selector.trim())) + const across = ruleOf('.godmin-toasts') + .split(';') + .map((declaration) => declaration.trim()) + .filter((declaration) => /^(?:inset|left|right|margin|padding|translate|transform)\b/.test(declaration)) - expect(region).toMatch(/inset-inline-start:\s*var\(--godmin-nav-sidebar-width\)/) - expect(region, 'the region ends where the canvas ends').toMatch(/inset-inline-end:\s*var\(--godmin-canvas-margin\)/) + expect(selectors, 'no rule moves the region beside the sidebar').toEqual(['.godmin-toasts', '.godmin-toasts:focus']) + expect(across, 'the region moves no toast off the window centre').toEqual(['inset-inline: 0', 'padding-inline: 16px']) }) test('names the canvas gutter once and pads the canvas sides with it', () => { @@ -1007,14 +1015,12 @@ test('spans the toast region across the window, so each toast sizes to its own m expect(region, 'a fixed width stops a toast growing with its message').not.toMatch(/(?:^|[^-])width:/m) }) -test('stacks the toasts in a column at the bottom center, 32px up', () => { +test('stacks the toasts in a column at the bottom, the medium size token up', () => { const region = ruleOf('.godmin-toasts') - expect(region).toMatch(/bottom:\s*32px/) + expect(region).toMatch(/bottom:\s*var\(--wpds-dimension-size-md\)/) expect(region).toMatch(/display:\s*flex/) expect(region).toMatch(/flex-direction:\s*column/) - expect(region).toMatch(/align-items:\s*center/) - expect(region).toMatch(/gap:\s*var\(--wpds-dimension-gap-sm\)/) }) test('keeps a 16px gutter between a toast and the window edge', () => { @@ -1062,17 +1068,17 @@ test('lifts a toast off the page with the small elevation the WordPress snackbar expect(shadow.split(',').map((layer) => layer.trim())).toEqual(layers) }) -test('sets the action and the close button of a toast on the first line of its message, as the snackbar does', () => { +test('sets the action of a toast on the first line of its message, as the snackbar does', () => { const toast = ruleOf('.godmin-toast') expect(toast).toMatch(/display:\s*flex/) expect(toast).toMatch(/align-items:\s*baseline/) - expect(toast, 'the snackbar spaces its action and close button with margins').not.toMatch(/(?:^|\s)gap:/) + expect(toast, 'the snackbar spaces its action with a margin').not.toMatch(/(?:^|\s)gap:/) }) -test('shows the arrow pointer over a toast with an action, as the snackbar does', () => { - expect(ruleOf('.godmin-toast')).toMatch(/cursor:\s*default/) - expect(base.indexOf('.godmin-toast--plain {')).toBeGreaterThan(base.indexOf('.godmin-toast {')) +test('shows the pointer over every toast, since a click anywhere clears it, as the snackbar does', () => { + expect(ruleOf('.godmin-toast')).toMatch(/cursor:\s*var\(--wpds-cursor-control\)/) + expect(ruleOf('.godmin-toast--plain'), 'a plain toast takes the pointer of every toast').not.toMatch(/cursor:/) }) test('spreads the parts of a toast wider than its message apart, as the snackbar content does', () => { @@ -1080,18 +1086,12 @@ test('spreads the parts of a toast wider than its message apart, as the snackbar expect(base, 'the snackbar message takes no free room of its own').not.toMatch(/\.godmin-toast__message\s*\{/) }) -test('sets the action 32px after the message and the close button 24px after the action, as the snackbar does', () => { +test('sets the action 32px after the message, as the snackbar does', () => { expect(ruleOf('.godmin-toast__action')).toMatch(/margin-inline-start:\s*var\(--wpds-dimension-gap-2xl\)/) - expect(ruleOf('.godmin-toast__dismiss')).toMatch(/margin-inline-start:\s*var\(--wpds-dimension-gap-xl\)/) }) -test('sets the close button as a line of text, so a toast with an action is as tall as a plain one', () => { - const dismiss = ruleOf('.godmin-toast__dismiss') - - expect(dismiss).toMatch(/font:\s*inherit/) - expect(dismiss).not.toMatch(/display:/) - expect(dismiss).not.toMatch(/block-size:/) - expect(dismiss).not.toMatch(/align-items:/) +test('draws no close button, as the snackbar draws its cross only on a toast with no timer', () => { + expect(base).not.toMatch(/godmin-toast__dismiss/) }) test('caps a toast at the large surface width, padding included', () => { @@ -1121,13 +1121,12 @@ test('leaves the toast width alone between 600px and 640px', () => { expect(blockOf('@media (max-width: 639px)')).not.toMatch(/godmin-toast/) }) -test('draws the plain toast button like the rest of the snackbar, under a pointer', () => { +test('draws the plain toast button like the rest of the snackbar', () => { const plain = ruleOf('.godmin-toast--plain') expect(plain).toMatch(/margin:\s*0/) expect(plain).toMatch(/border:\s*0/) expect(plain).toMatch(/text-align:\s*start/) - expect(plain).toMatch(/cursor:\s*var\(--wpds-cursor-control\)/) }) test('draws the action as a white link that keeps its width', () => { @@ -1161,18 +1160,6 @@ test('drops the action underline under the pointer, as the snackbar does', () => expect(ruleOf('.godmin-toast__action:hover')).toMatch(/text-decoration:\s*none/) }) -test('draws the close button as a bare white cross', () => { - const dismiss = ruleOf('.godmin-toast__dismiss') - - expect(dismiss).toMatch(/flex:\s*none/) - expect(dismiss).toMatch(/padding:\s*0/) - expect(dismiss).toMatch(/border:\s*0/) - expect(dismiss).toMatch(/background:\s*none/) - expect(dismiss).toMatch(/color:\s*inherit/) - expect(dismiss).toMatch(/cursor:\s*var\(--wpds-cursor-control\)/) - expect(base, 'the cross is a text glyph, not an icon').not.toMatch(/\.godmin-toast__dismiss svg/) -}) - test('rings a focused plain toast with a white inner line and the focus colour, as the snackbar does', () => { const ring = ruleOf('.godmin-toast--plain:focus-visible') @@ -1185,49 +1172,20 @@ test('rings a focused plain toast with a white inner line and the focus colour, expect(ring, 'the snackbar keeps the browser outline as well').not.toMatch(/outline/) }) -test('outlines a focused action with a dotted white line and drops its underline, as the snackbar does', () => { +test('rings a focused action with the design system ring in white, as the snackbar action does', () => { const ring = ruleOf('.godmin-toast__action:focus-visible') - expect(ring).toMatch(/outline:\s*var\(--wpds-border-width-xs\) dotted #fff/) - expect(ring).toMatch(/outline-offset:\s*2px/) + expect(ring).toMatch(/outline:\s*var\(--wpds-border-width-focus\) solid #fff;/) + expect(ring).toMatch(/outline-offset:\s*var\(--wpds-border-width-focus\);/) expect(ring).toMatch(/border-radius:\s*var\(--wpds-border-radius-sm\)/) expect(ring).toMatch(/text-decoration:\s*none/) + expect(ring, 'the snackbar dropped its dotted outline').not.toMatch(/dotted/) }) -test('leaves the browser focus ring on the close button, as the snackbar cross keeps it', () => { - expect(base).not.toMatch(/\.godmin-toast__dismiss:focus/) -}) - -test('draws no ring on the toast region, which takes focus only from a leaving toast', () => { +test('draws no ring on the toast region, which takes focus only from a leaving or clicked toast', () => { expect(ruleOf('.godmin-toasts:focus')).toMatch(/outline:\s*none/) }) -test('fades a toast in as it arrives', () => { - expect(ruleOf('.godmin-toast')).toMatch( - /animation:\s*godmin-fade-in var\(--wpds-motion-duration-md\) var\(--wpds-motion-easing-balanced\)/, - ) -}) - -test('fades a leaving toast out and holds it hidden until it goes', () => { - const leaving = ruleOf('.godmin-toast--leaving') - - expect(leaving).toMatch(/animation:[^;]*godmin-fade-out/) - expect(leaving).toMatch(/animation:[^;]*var\(--wpds-motion-duration-sm\)/) - expect(leaving).toMatch(/animation:[^;]*forwards/) - expect(base).toMatch(/@keyframes godmin-fade-out\s*\{\s*0%\s*\{\s*opacity:\s*1;?\s*\}\s*100%\s*\{\s*opacity:\s*0/) -}) - -test('lets the leaving look outrank the arriving fade', () => { - expect(base.indexOf('.godmin-toast--leaving {')).toBeGreaterThan(base.indexOf('.godmin-toast {')) -}) - -test('stops fading toasts for a reduced motion preference', () => { - const guard = blockOf('@media (prefers-reduced-motion: reduce)') - - expect(guard).toMatch(/\.godmin-toast\s*[,{]/) - expect(guard).toMatch(/animation-duration:\s*1ms/) -}) - test('raises the drawer above the toast region', () => { const css = base.replace(/\/\*[\s\S]*?\*\//g, '') const drawer = css.match(/\.godmin-layout\s*\{[^}]*--wp-ui-drawer-z-index:\s*(\d+)/) @@ -1271,15 +1229,17 @@ test('stops fading for a reduced motion preference', () => { expect(guard).toMatch(/animation-duration:\s*1ms/) }) -test('keeps the reveal and the delay that a reduced motion preference must not undo', () => { +test('keeps the reveal and the ghost delay that a reduced motion preference must not undo', () => { const guard = reducedMotionGuard() + const delayed = guardedBy(/animation-delay:/) + const ghosts = ['.godmin-loading-screen', '.godmin-loading-rows', '.godmin-arrival'] expect(guard, 'the shorthand resets the delay and the fill').not.toMatch(/animation:\s/) expect(guard, 'a named animation of none leaves the ghost at opacity zero').not.toMatch( /animation-name:/, ) - expect(guard, 'the ghost must stay hidden for its first 150ms').not.toMatch(/animation-delay:/) expect(guard, 'forwards is what holds the revealed state').not.toMatch(/animation-fill-mode:/) + expect(ghosts.filter((ghost) => delayed.includes(ghost)), 'the ghost must stay hidden for its first 150ms').toEqual([]) }) test('pads the screen ghost, which can render with no frame around it', () => { diff --git a/test/css-rules.ts b/test/css-rules.ts index 0cea3ad..7d0610f 100644 --- a/test/css-rules.ts +++ b/test/css-rules.ts @@ -46,6 +46,17 @@ export function blockOf(prelude: string): string { return bodyFrom(start + prelude.length + 2) } +/** + * Returns the selectors of the reduced motion guard whose rule sets a declaration. + * @param declaration - The pattern the declaration matches, such as `animation-delay:`. + * @returns The selectors in source order. + */ +export function guardedBy(declaration: RegExp): string[] { + return [...blockOf('@media (prefers-reduced-motion: reduce)').matchAll(/([^{}]+)\{([^}]*)\}/g)] + .filter((found) => declaration.test(found[2])) + .flatMap((found) => found[1].split(',').map((selector) => selector.trim())) +} + /** * Returns the body of every at-rule of the given kinds, matching its braces. * @param kinds - The at-rule names to collect, such as media. diff --git a/test/toast-motion-css.test.ts b/test/toast-motion-css.test.ts new file mode 100644 index 0000000..79a3f0e --- /dev/null +++ b/test/toast-motion-css.test.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' + +import { blockOf, guardedBy, ruleOf } from './css-rules.js' +import { base } from './styles.js' + +/** + * Returns the animations the rule a selector opens runs, one for each comma of the list, spaces made single. + * @param selector - The exact selector the rule starts with. + * @returns The animations in list order. + */ +function animationsOf(selector: string): string[] { + const list = /(?:^|\s)animation:\s*([^;]+);/.exec(ruleOf(selector))?.[1] ?? '' + return list.split(/,(?![^(]*\))/).map((animation) => animation.replace(/\s+/g, ' ').trim()) +} + +test('spans the item of each toast across the column, so a toast below 600px can span it too', () => { + expect(ruleOf('.godmin-toasts'), 'a centred item shrinks to its toast').not.toMatch(/align-items:/) + expect(ruleOf('.godmin-toasts__item'), 'an item sized on its own').not.toMatch(/(?:^|[^-])width:/m) +}) + +test('centres each toast in its item, so a toast sizes to its own message', () => { + const inner = ruleOf('.godmin-toasts__item-inner') + + expect(inner).toMatch(/display:\s*flex/) + expect(inner).toMatch(/flex-direction:\s*column/) + expect(inner).toMatch(/align-items:\s*center/) +}) + +test('keeps each toast at the window centre inside its item and inner box, and clips none', () => { + const css = base.replace(/\/\*[\s\S]*?\*\//g, '') + const inside = [...css.matchAll(/([^{};]*)\{([^{}]*)\}/g)] + .filter((found) => found[1].includes('.godmin-toasts__item')) + .flatMap((found) => found[2].split(';').map((declaration) => declaration.trim())) + .filter((declaration) => /^(?:inset|left|right|margin|padding|translate|transform|overflow)\b/.test(declaration)) + + expect(inside, 'only the gap above each toast').toEqual(['margin-block-start: var(--wpds-dimension-gap-sm)']) +}) + +test('keeps two toasts the small gap apart inside the row that grows, so a toast rises its gap and its height', () => { + expect(ruleOf('.godmin-toasts__item-inner > .godmin-toast')).toMatch( + /margin-block-start:\s*var\(--wpds-dimension-gap-sm\);/, + ) + expect(ruleOf('.godmin-toasts'), 'a gap between items jumps as a toast comes or goes').not.toMatch(/(?:^|\s)gap:/) + expect(ruleOf('.godmin-toasts__item'), 'a gap outside the row starts the toast at the column bottom').not.toMatch( + /padding/, + ) + expect(ruleOf('.godmin-toasts__item-inner'), 'padding keeps its height in a row of none').not.toMatch(/padding/) +}) + +test('grows the item of an arriving toast from no height over 300ms on the curve of the WordPress snackbar', () => { + const item = ruleOf('.godmin-toasts__item') + + expect(item).toMatch(/display:\s*grid;/) + expect(item).toMatch(/grid-template-rows:\s*1fr;/) + expect(item).toContain('animation: godmin-toast-grow var(--wpds-motion-duration-lg) cubic-bezier(0, 0, 0.2, 1);') + expect(blockOf('@keyframes godmin-toast-grow')).toMatch(/^\s*0%\s*\{\s*grid-template-rows:\s*0fr;\s*\}\s*$/) +}) + +test('lets the item of a toast be shorter than the toast, so the toast rises out of it as the snackbar does', () => { + const inner = ruleOf('.godmin-toasts__item-inner') + + expect(inner).toMatch(/min-height:\s*0;/) + expect(inner, 'a clipped item cuts off the shadow and the focus ring').not.toMatch(/overflow/) +}) + +test('fades an arriving toast in over 250ms on the snackbar curve, after the extra small wait', () => { + expect(ruleOf('.godmin-toast')).toContain( + 'animation: godmin-fade-in 250ms cubic-bezier(0, 0, 0.2, 1) var(--wpds-motion-duration-xs) backwards;', + ) +}) + +test('fades a leaving toast out from where it got to, over the small duration on the snackbar curve', () => { + expect(animationsOf('.godmin-toast--leaving'), 'a dropped fade in snaps the toast opaque').toEqual([ + ...animationsOf('.godmin-toast'), + 'godmin-fade-out var(--wpds-motion-duration-sm) cubic-bezier(0, 0, 0.2, 1) forwards', + ]) + expect(blockOf('@keyframes godmin-fade-out'), 'a first frame of its own starts the fade fully opaque').toMatch( + /^\s*100%\s*\{\s*opacity:\s*0;\s*\}\s*$/, + ) +}) + +test('closes the item of a leaving toast after its fade, over 450ms, from the height it reached', () => { + expect(animationsOf('.godmin-toasts__item--leaving'), 'a dropped growth jumps to full height').toEqual([ + ...animationsOf('.godmin-toasts__item'), + 'godmin-toast-shrink 450ms cubic-bezier(0.4, 0, 0.1, 1) var(--wpds-motion-duration-sm) forwards', + ]) + expect(blockOf('@keyframes godmin-toast-shrink')).toMatch(/^\s*100%\s*\{\s*grid-template-rows:\s*0fr;\s*\}\s*$/) +}) + +test('lets the leaving looks outrank the arriving ones, and the reduced motion guard outrank both', () => { + const css = base.replace(/\/\*[\s\S]*?\*\//g, '') + const leaving = [...css.matchAll(/([^{};]*)\{/g)] + .map((found) => found[1].trim()) + .filter((prelude) => /\.godmin-toast[\w-]*--leaving\b/.test(prelude)) + const guard = base.indexOf('@media (prefers-reduced-motion: reduce)') + + expect(base.indexOf('.godmin-toast--leaving {')).toBeGreaterThan(base.indexOf('.godmin-toast {')) + expect(base.indexOf('.godmin-toasts__item--leaving {')).toBeGreaterThan(base.indexOf('.godmin-toasts__item {')) + expect(leaving, 'a leaving rule stronger than one class beats the guard').toEqual([ + '.godmin-toasts__item--leaving', + '.godmin-toast--leaving', + ]) + for (const selector of leaving) { + expect(guard, `the guard comes after ${selector}`).toBeGreaterThan(base.indexOf(`${selector} {`)) + } +}) + +test('runs every toast animation at once in 1ms for a reduced motion preference, so nothing slides or fades', () => { + expect(guardedBy(/animation-duration:\s*1ms/)).toEqual( + expect.arrayContaining(['.godmin-toasts__item', '.godmin-toast']), + ) + expect(guardedBy(/animation-delay:\s*0s;/), 'a wait leaves a blank space or a hidden toast').toEqual([ + '.godmin-toasts__item', + '.godmin-toast', + ]) +}) diff --git a/test/toaster.test.tsx b/test/toaster.test.tsx index 2716050..43815b1 100644 --- a/test/toaster.test.tsx +++ b/test/toaster.test.tsx @@ -4,12 +4,12 @@ import { speak } from '@wordpress/a11y' import { act, fireEvent, render, screen } from '@testing-library/react' import { Modal } from '@wordpress/components' import { StrictMode, useEffect } from 'react' -import { afterEach, beforeEach, expect, onTestFinished, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, expectTypeOf, onTestFinished, test, vi } from 'vitest' import manifest from '../package.json' import { getAnnouncement, installTestEnvironment, renderAdmin } from '../src/testing.js' import { Toaster, useToaster } from '../src/toaster.js' -import type { ToastAction, ToasterHandle } from '../src/toaster.js' +import type { ToastAction, ToasterHandle, ToasterProps } from '../src/toaster.js' vi.mock('@wordpress/a11y', async (importOriginal) => { const actual = await importOriginal() @@ -47,6 +47,20 @@ function Raise({ message, action }: { message: string; action?: ToastAction }) { ) } +/** + * Renders a button that raises several toasts at once when pressed, in one render. + * @param props - The messages of the toasts, oldest first. + * @returns The trigger element. + */ +function RaiseAll({ messages }: { messages: string[] }) { + const toaster = useToaster() + return ( + + ) +} + /** * Renders a toaster around a button that raises one toast, and presses it. * @param message - The message the toast carries. @@ -82,6 +96,18 @@ function Keep({ onHandle }: { onHandle: (handle: ToasterHandle) => void }) { return null } +/** + * Settles a held animation, then waits for the region to catch up. + * @param end - The call that settles the animation. + * @returns The wait. + */ +function settle(end: () => void): Promise { + return act(async () => { + end() + await new Promise((wait) => setTimeout(wait, 0)) + }) +} + /** * Makes every element report one running fade until a returned call settles it. * @returns The calls that finish the fade or cut it short, each waiting for the region to catch up. @@ -98,24 +124,34 @@ function holdFade(): { finish: () => Promise, cutShort: () => Promise [{ finished }], }) - const settle = (end: () => void) => - act(async () => { - end() - await new Promise((wait) => setTimeout(wait, 0)) - }) return { finish: () => settle(resolve), cutShort: () => settle(reject) } } -test('loads where the platform offers no grapheme splitter', async () => { - const held = Object.getOwnPropertyDescriptor(Intl, 'Segmenter') as PropertyDescriptor - Reflect.deleteProperty(Intl, 'Segmenter') - onTestFinished(() => { - Object.defineProperty(Intl, 'Segmenter', held) +/** + * Makes the item of a toast report the fade of its toast and its own closing when asked for every animation in it. + * @returns The calls that end the fade and the closing, each waiting for the region to catch up. + */ +function holdMotion(): { endFade: () => Promise, endClosing: () => Promise } { + const ends: (() => void)[] = [] + const [fade, closing] = [0, 1].map(() => new Promise((done) => ends.push(done))) + Object.defineProperty(HTMLElement.prototype, 'getAnimations', { + configurable: true, + value(this: HTMLElement, options?: GetAnimationsOptions) { + const asked = this.classList.contains('godmin-toasts__item') && options?.subtree === true + return asked ? [{ finished: fade }, { finished: closing }] : [] + }, }) - vi.resetModules() + return { endFade: () => settle(ends[0]), endClosing: () => settle(ends[1]) } +} - await expect(import('../src/toaster.js')).resolves.toHaveProperty('Toaster') -}) +/** + * Returns the item that holds a toast in the toast region. + * @param message - The message of the toast. + * @returns The item element. + */ +function itemOf(message: string): HTMLElement { + return screen.getByText(message).closest('.godmin-toasts__item') as HTMLElement +} test('shows a message once a screen raises one', () => { renderAdmin( @@ -129,7 +165,7 @@ test('shows a message once a screen raises one', () => { expect(screen.getByText('Post moved to trash')).not.toBeNull() }) -test('announces the message rather than the buttons beside it', () => { +test('announces the message rather than the action beside it', () => { renderAdmin( @@ -150,7 +186,7 @@ test('announces each message once, politely, when a screen raises it', () => { ) fireEvent.click(screen.getByRole('button', { name: 'Raise' })) - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + fireEvent.click(screen.getByText('Post moved to trash')) expect(speak).toHaveBeenCalledOnce() expect(speak).toHaveBeenCalledWith('Post moved to trash', 'polite') @@ -188,7 +224,7 @@ test('draws a plain toast as one snackbar button inside the toast region', () => const toast = screen.getByRole('button', { name: 'Saved' }) expect(toast.classList.contains('godmin-toast')).toBe(true) expect(toast.classList.contains('godmin-toast--plain')).toBe(true) - expect(toast.parentElement?.classList.contains('godmin-toasts')).toBe(true) + expect(toast.closest('.godmin-toasts')).toBe(region()) }) test('clears a plain toast on a click anywhere on it', () => { @@ -230,7 +266,7 @@ test('offers no close button on a plain toast', () => { fireEvent.click(screen.getByRole('button', { name: 'Raise' })) - expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Dismiss this notice' })).toBeNull() expect(screen.queryByRole('button', { name: 'Undo' })).toBeNull() }) @@ -243,7 +279,7 @@ test('tells the reader a click clears a plain toast', () => { fireEvent.click(screen.getByRole('button', { name: 'Raise' })) - expect(screen.getByRole('button', { name: 'Saved', description: 'Dismiss' })).not.toBeNull() + expect(screen.getByRole('button', { name: 'Saved', description: 'Dismiss this notice' })).not.toBeNull() }) test('tells the reader a click clears a plain toast in the words the application gives', () => { @@ -258,7 +294,7 @@ test('tells the reader a click clears a plain toast in the words the application expect(screen.getByRole('button', { name: 'Saved', description: 'Close' })).not.toBeNull() }) -test('draws a toast with an action as the message, the action and a close button, in that order', () => { +test('draws a toast with an action as the message and the action, in that order', () => { renderAdmin( @@ -269,14 +305,24 @@ test('draws a toast with an action as the message, the action and a close button const toast = screen.getByText('Post moved to trash').parentElement expect(toast?.classList.contains('godmin-toast')).toBe(true) - expect(toast?.parentElement?.classList.contains('godmin-toasts')).toBe(true) + expect(toast?.closest('.godmin-toasts')).toBe(region()) expect([...(toast?.children ?? [])].map((part) => part.className)).toEqual([ 'godmin-toast__message', 'godmin-toast__action', - 'godmin-toast__dismiss', ]) }) +test('leaves the action the only control of a toast with an action, with no tab stop on its body', () => { + raiseUndo() + const toast = screen.getByText('Post moved to trash').parentElement as HTMLElement + const controls = [...toast.querySelectorAll('button, [tabindex], [role]')] + + expect(controls.map((control) => control.textContent)).toEqual(['Undo']) + expect(toast.hasAttribute('role')).toBe(false) + expect(toast.hasAttribute('tabindex')).toBe(false) + expect(region().textContent).not.toContain('✕') +}) + test('keeps a toast with an action from being a button itself, so no button sits inside another', () => { renderAdmin( @@ -290,20 +336,6 @@ test('keeps a toast with an action from being a button itself, so no button sits expect(region().querySelector('button button, [role="button"] button')).toBeNull() }) -test('draws the close button as the cross the snackbar shows, named by its label rather than the cross', () => { - renderAdmin( - - - , - ) - - fireEvent.click(screen.getByRole('button', { name: 'Raise' })) - - const close = screen.getByRole('button', { name: 'Dismiss' }) - expect(close.textContent).toBe('✕') - expect(close.querySelector('svg')).toBeNull() -}) - test('shows no tooltip on a plain toast, as the snackbar shows none', () => { renderAdmin( @@ -326,7 +358,7 @@ test('keeps the hint of a plain toast out of the toast region, so the region hol fireEvent.click(screen.getByRole('button', { name: 'Raise' })) expect([...region().children].map((toast) => toast.textContent)).toEqual(['Saved']) - expect(screen.getByText('Dismiss').hidden).toBe(true) + expect(screen.getByText('Dismiss this notice').hidden).toBe(true) }) test('runs the action and clears the toast that offered it', () => { @@ -339,27 +371,37 @@ test('runs the action and clears the toast that offered it', () => { expect(screen.queryByText('Post moved to trash')).toBeNull() }) -test('clears a toast with an action through its close button without running the action', () => { - const onAct = vi.fn() - raiseUndo(onAct) +test('clears a toast before its action runs, so a toast the action raises pushes out no other', () => { + let kept: ToasterHandle | undefined + renderAdmin( + + (kept = handle)} /> + , + ) + const toaster = kept as ToasterHandle + act(() => { + toaster.show('First') + toaster.show('Second') + toaster.show('Post moved to trash', { label: 'Undo', onAct: () => toaster.show('Post restored') }) + }) - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + fireEvent.click(screen.getByRole('button', { name: 'Undo' })) - expect(onAct).not.toHaveBeenCalled() - expect(screen.queryByText('Post moved to trash')).toBeNull() + expect([...region().children].map((toast) => toast.textContent)).toEqual(['First', 'Second', 'Post restored']) }) -test('names the close button as the application asks', () => { - renderAdmin( - - - , - ) +test.each([ + ['its message', (toast: HTMLElement) => toast.querySelector('.godmin-toast__message') as HTMLElement], + ['its padding', (toast: HTMLElement) => toast], +])('clears a toast with an action on a click on %s, without running the action', (_case, clicked) => { + const onAct = vi.fn() + raiseUndo(onAct) + const toast = screen.getByText('Post moved to trash').parentElement as HTMLElement - fireEvent.click(screen.getByRole('button', { name: 'Raise' })) + fireEvent.click(clicked(toast)) - expect(screen.getByRole('button', { name: 'Close' })).not.toBeNull() - expect(screen.queryByRole('button', { name: 'Dismiss' })).toBeNull() + expect(onAct).not.toHaveBeenCalled() + expect(screen.queryByText('Post moved to trash')).toBeNull() }) test('keeps a leaving toast on screen until its fade ends, and lets nobody press it meanwhile', async () => { @@ -390,7 +432,7 @@ test('clears a leaving toast whose fade was cut short', async () => { , ) fireEvent.click(screen.getByRole('button', { name: 'Raise' })) - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + fireEvent.click(screen.getByText('Saved')) expect(screen.getByText('Saved')).not.toBeNull() await fade.cutShort() @@ -398,6 +440,59 @@ test('clears a leaving toast whose fade was cut short', async () => { expect(screen.queryByText('Saved')).toBeNull() }) +test('gives each toast an item of its own in the toast region, so the space it takes can grow and close', () => { + renderAdmin( + + + + , + ) + for (const raise of screen.getAllByRole('button', { name: 'Raise' })) { + fireEvent.click(raise) + } + + const items = [...region().children] + expect(items.map((item) => item.className)).toEqual(['godmin-toasts__item', 'godmin-toasts__item']) + expect(items.map((item) => [...item.children].map((inner) => inner.className))).toEqual([ + ['godmin-toasts__item-inner'], + ['godmin-toasts__item-inner'], + ]) + expect(items.map((item) => item.firstElementChild?.firstElementChild?.className)).toEqual([ + 'godmin-toast godmin-toast--plain', + 'godmin-toast', + ]) +}) + +test('closes the item of a leaving toast and keeps the other items open', () => { + holdFade() + renderAdmin( + + + + , + ) + for (const raise of screen.getAllByRole('button', { name: 'Raise' })) { + fireEvent.click(raise) + } + + fireEvent.click(screen.getByRole('button', { name: 'First' })) + + expect(itemOf('First').className).toBe('godmin-toasts__item godmin-toasts__item--leaving') + expect(itemOf('Second').className).toBe('godmin-toasts__item') +}) + +test('keeps a leaving toast on the page until its fade and the closing of its item both end', async () => { + const motion = holdMotion() + raiseToast('Saved') + + fireEvent.click(screen.getByRole('button', { name: 'Saved' })) + await motion.endFade() + + expect(itemOf('Saved')).not.toBeNull() + await motion.endClosing() + expect(screen.queryByText('Saved')).toBeNull() +}) + test('lets nobody press a toast with an action while it fades out', () => { holdFade() renderAdmin( @@ -407,7 +502,7 @@ test('lets nobody press a toast with an action while it fades out', () => { ) fireEvent.click(screen.getByRole('button', { name: 'Raise' })) - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + fireEvent.click(screen.getByText('Saved')) const leaving = screen.getByText('Saved').parentElement expect(leaving?.classList.contains('godmin-toast--leaving')).toBe(true) @@ -465,6 +560,65 @@ test('clears a toast nobody touched after six seconds when the application sets expect(screen.queryByText('Saved')).toBeNull() }) +test('keeps a toast with an action ten seconds when the application sets no time', () => { + vi.useFakeTimers() + raiseUndo() + + act(() => { + vi.advanceTimersByTime(9999) + }) + expect(screen.getByText('Post moved to trash')).not.toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(screen.queryByText('Post moved to trash')).toBeNull() +}) + +test('keeps a toast with an action as long as the application asks', () => { + vi.useFakeTimers() + renderAdmin( + + + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Raise' })) + + act(() => { + vi.advanceTimersByTime(7999) + }) + expect(screen.getByText('Post moved to trash')).not.toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(screen.queryByText('Post moved to trash')).toBeNull() +}) + +test('times a toast with no action and one with an action apart', () => { + vi.useFakeTimers() + renderAdmin( + + + + , + ) + for (const raise of screen.getAllByRole('button', { name: 'Raise' })) { + fireEvent.click(raise) + } + + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(screen.queryByText('Saved')).toBeNull() + expect(screen.getByText('Post moved to trash')).not.toBeNull() + + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(screen.queryByText('Post moved to trash')).toBeNull() +}) + test('keeps three toasts at most when the application sets no limit, dropping the oldest', () => { renderAdmin( @@ -480,8 +634,21 @@ test('keeps three toasts at most when the application sets no limit, dropping th } expect(screen.queryByText('First')).toBeNull() - const shown = screen.getByText('Second').parentElement?.children ?? [] - expect([...shown].map((toast) => toast.textContent)).toEqual(['Second', 'Third', 'Fourth']) + expect([...region().children].map((item) => item.textContent)).toEqual(['Second', 'Third', 'Fourth']) +}) + +test('drops at once a toast already past the limit as it first shows, so it never flashes or holds its space', () => { + holdFade() + renderAdmin( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Raise all' })) + + expect(screen.queryByText('First')).toBeNull() + expect([...region().children].map((item) => item.textContent)).toEqual(['Second', 'Third', 'Fourth']) }) test('keeps as many toasts as the application asks', () => { @@ -566,7 +733,7 @@ test('forgets the timer of a toast the reader dismissed', () => { raiseUndo() const running = vi.getTimerCount() - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + fireEvent.click(screen.getByText('Post moved to trash')) expect(vi.getTimerCount()).toBe(running - 1) }) @@ -577,7 +744,7 @@ test('forgets the timer of a dismissed toast as soon as it starts fading out', ( raiseUndo() const running = vi.getTimerCount() - fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + fireEvent.click(screen.getByText('Post moved to trash')) expect(screen.getByText('Post moved to trash')).not.toBeNull() expect(vi.getTimerCount()).toBe(running - 1) @@ -719,8 +886,7 @@ test('holds every message raised, the newest at the bottom', () => { fireEvent.click(first) fireEvent.click(second) - const shown = screen.getByText('First').parentElement?.children ?? [] - expect([...shown].map((toast) => toast.textContent)).toEqual(['First', 'Second']) + expect([...region().children].map((item) => item.textContent)).toEqual(['First', 'Second']) }) test('lets the toast region take focus from script but not from the tab key', () => { @@ -735,13 +901,12 @@ test('lets the toast region take focus from script but not from the tab key', () test.each([ ['a focused toast leaves through its action', 'Undo'], - ['a focused toast leaves through its close button', 'Dismiss'], -])('hands focus to the toast region when %s', (_case, name) => { + ['a toast holding focus is clicked away on its body', 'Post moved to trash'], +])('hands focus to the toast region when %s', (_case, clicked) => { raiseUndo() - const pressed = screen.getByRole('button', { name }) - pressed.focus() + screen.getByRole('button', { name: 'Undo' }).focus() - fireEvent.click(pressed) + fireEvent.click(screen.getByText(clicked)) expect(document.activeElement).toBe(region()) }) @@ -762,7 +927,7 @@ test('hands focus to the toast region when a focused toast runs out of time', () screen.getByRole('button', { name: 'Undo' }).focus() act(() => { - vi.advanceTimersByTime(6000) + vi.advanceTimersByTime(10000) }) expect(screen.queryByText('Post moved to trash')).toBeNull() @@ -771,7 +936,7 @@ test('hands focus to the toast region when a focused toast runs out of time', () test('moves focus out of a toast running out of time before the toast turns inert, as a browser renders', async () => { renderAdmin( - + , ) @@ -838,6 +1003,27 @@ test('keeps the toast region inside the application, under an open dialog, as Wo expect(container.contains(region())).toBe(true) }) +test.each(['none', 'moderate', 'pronounced'] as const)( + 'keeps the snackbar corners on the toasts under the %s preset, and the preset on the rest of the app', + (preset) => { + renderAdmin( + + + , + { cornerRadius: preset }, + ) + const raise = screen.getByRole('button', { name: 'Raise' }) + + fireEvent.click(raise) + + const corners = (element: Element) => + element.closest('[data-wpds-corner-radius]')?.getAttribute('data-wpds-corner-radius') + expect(corners(screen.getByRole('button', { name: 'Saved' }))).toBe('subtle') + expect(corners(region())).toBe('subtle') + expect(corners(raise)).toBe(preset) + }, +) + test('leaves focus where it was when a toast it is not in leaves', () => { renderAdmin( @@ -853,44 +1039,20 @@ test('leaves focus where it was when a toast it is not in leaves', () => { expect(document.activeElement).toBe(raise) }) -/** - * Returns the name a toaster gives a title, rendering the toaster with the given name length. - * @param title - The title to name. - * @param nameLength - How many characters the toaster keeps, its default when absent. - * @returns The name the toaster handle answers. - */ -function nameFrom(title: string, nameLength?: number): string { +test('hands out a toaster handle that only shows messages', () => { let kept: ToasterHandle | undefined renderAdmin( - + (kept = handle)} /> , ) - return (kept as ToasterHandle).name(title) -} - -test('names an item in full when its name fits the toast', () => { - expect(nameFrom('Hello world')).toBe('Hello world') - expect(nameFrom('a'.repeat(45))).toBe('a'.repeat(45)) -}) - -test('cuts a name longer than 45 characters to 45 and ends it with an ellipsis', () => { - expect(nameFrom('a'.repeat(46))).toBe(`${'a'.repeat(45)}…`) -}) - -test('cuts a name at the length the toaster is given', () => { - expect(nameFrom('Maria Perez wrote this', 10)).toBe('Maria Pere…') -}) - -test('counts an emoji as one character, so a cut never splits it', () => { - const family = '\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}' - expect(nameFrom(`${'a'.repeat(44)}${family}tail`)).toBe(`${'a'.repeat(44)}${family}…`) - expect(nameFrom(family.repeat(45))).toBe(family.repeat(45)) + expect(Object.keys(kept as ToasterHandle)).toEqual(['show']) }) -test('trims the spaces a cut leaves before the ellipsis', () => { - expect(nameFrom('Maria Perez', 8)).toBe('Maria…') +test('takes no name length', () => { + expectTypeOf().not.toHaveProperty('nameLength') + expectTypeOf().not.toHaveProperty('name') }) test('tells a screen it forgot the region rather than failing silently', () => { From 83b1f53528ee7ea2769f4a80a0d2c247e75233a0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 20:29:20 +0100 Subject: [PATCH 2/4] docs(design-system): write the toasts page and point every toast value to it --- docs/build-and-versioning.md | 29 ++++ docs/design-system/accessibility.md | 36 ++-- docs/design-system/color.md | 2 +- docs/design-system/custom-properties.md | 7 +- docs/design-system/layout.md | 2 +- docs/design-system/motion.md | 47 +++--- docs/design-system/overview.md | 14 +- docs/design-system/radius-and-elevation.md | 14 +- docs/design-system/toasts.md | 182 +++++++++++++++++++++ docs/design-system/typography.md | 2 +- docs/lists.md | 24 +-- docs/loading-and-feedback.md | 96 +++-------- test/design-system.test.ts | 158 ++++++++++++++++++ 13 files changed, 466 insertions(+), 147 deletions(-) create mode 100644 docs/design-system/toasts.md diff --git a/docs/build-and-versioning.md b/docs/build-and-versioning.md index bf7a6b1..d3d2d7f 100644 --- a/docs/build-and-versioning.md +++ b/docs/build-and-versioning.md @@ -100,6 +100,35 @@ the same peer ranges. follows the filter rules of that DataViews release. A list built on another release gets a peer warning when it installs godmin. +## Upgrading from godmin 0.27.0 + +godmin 0.28.0 draws toasts as WordPress's new admin draws its +snackbars. Change your code in these places: + +- `useToaster().name` and the `nameLength` prop of `Toaster` are gone. + Put the name in the message as it is. A long name wraps. +- `dismissAfter` now times only a toast with no action. Pass + `actionDismissAfter` to time a toast with an action. It stays 10 + seconds when you pass nothing. +- A toast with an action has no close button. A click anywhere on it + clears it, so a test that pressed the close button clicks the toast + instead. A stylesheet that styles `.godmin-toast__dismiss` styles + nothing now. +- `dismissLabel` is now only the hint of a toast with no action, and + its default is `Dismiss this notice`, WordPress's words. A test that + reads the old hint, `Dismiss`, reads the new one. +- A toast now sits in a `.godmin-toasts__item` element inside + `.godmin-toasts`. A selector such as `.godmin-toasts > .godmin-toast`, + or code that reads the children of the region as toasts, reaches the + toast through that element. +- A toast now rises into place as it arrives, as + [Motion](./design-system/toasts.md#motion) shows. A browser test that + measures where a toast sits waits for that motion to end, or runs + with a reduced motion preference. + +TypeScript points at each call to `name` and at `nameLength`. The rest +changes only what the reader sees. + ## Upgrading from godmin 0.24.0 godmin 0.25.0 gives the sidebar parts the names WordPress gives them. diff --git a/docs/design-system/accessibility.md b/docs/design-system/accessibility.md index 108ab05..3b6e333 100644 --- a/docs/design-system/accessibility.md +++ b/docs/design-system/accessibility.md @@ -44,13 +44,14 @@ Except on a toast, every ring godmin draws is the same line, | The menu button of the top bar | Inside the button | | The title link of a `godmin-table`, a `godmin-table-scroll` box and a row of `RepeatRows` | 2px outside the part | | A page tab | Around its label, not around the whole height of the tab | -| A toast | Its own rings, as [toasts](../loading-and-feedback.md#toasts) lists. A plain toast and its close button keep the browser's ring too | +| A toast | Its own rings, as [keyboard](./toasts.md#keyboard) on the toasts page shows. A plain toast keeps the browser's ring too | godmin turns the browser's own ring off in three places only. A page tab draws its own ring around the label instead. Back draws none on a -mouse focus. The toast region draws none, because only code ever -focuses it. Draw a ring of your own with the same line, on -`:focus-visible`, so it matches. +mouse focus. The toast region draws none. Tab never stops on it, and +only a toast that leaves or a click on a toast focuses it. Draw a +ring of your own with the same line, on `:focus-visible`, so it +matches. ## Where focus goes @@ -70,9 +71,8 @@ The title of a sidebar screen never takes focus. Some parts go away while they hold focus. godmin then moves focus to a part that stays, so the keyboard reader keeps their place: -- A toast that leaves, as - [how a toast behaves](../loading-and-feedback.md#how-a-toast-behaves) - says. +- A toast that leaves, as [keyboard](./toasts.md#keyboard) on the + toasts page says. - A list row that a run moved, as [running an action over the picked rows](../lists.md#running-an-action-over-the-picked-rows) says. @@ -108,13 +108,11 @@ no region. So a screen reader meets the sidebar under one name only. pass `label`. Every control godmin draws is named in words. A glyph, a small -picture such as an icon or a cross, is never the name: +picture such as an icon, is never the name: - Every icon godmin draws itself, such as a row icon, a chevron, the Back glyph or the menu glyph, is hidden from screen readers. The control around it carries the name. -- The cross on a toast is named by `dismissLabel`, never read as a - cross. - The initials avatar is hidden, because the name beside it already says who it is. @@ -140,7 +138,7 @@ aloud when it changes. | Part | What a screen reader hears | Set up in | | --- | --- | --- | -| A toast | Its message, politely. Never its buttons | [How a toast behaves](../loading-and-feedback.md#how-a-toast-behaves) | +| A toast | Its message, politely. Never its action | [What a screen reader hears](./toasts.md#what-a-screen-reader-hears) | | A loading ghost | The `label` sentence, from a visually hidden status. The grey shapes are hidden | [The ghosts](../loading-and-feedback.md#the-ghosts) | | A count beside a row | The `label` sentence, in place of the bare number | [A count beside a row](../framing-an-application.md#a-count-beside-a-row) | | An `ErrorNotice` | Its message, at once, as an alert | [ErrorNotice](../screens.md#errornotice) | @@ -174,9 +172,9 @@ translates them. [godmin's own words](../framing-an-application.md#godmins-own-words) lists them and the languages they ship in. The props `label`, `menuLabel` and `backLabel` put your own words in their place. One -default stays in English: the close button of a toast is named -Dismiss until you pass `dismissLabel`, as -[how a toast behaves](../loading-and-feedback.md#how-a-toast-behaves) +default stays in English: the hint a toast with no action gives a +screen reader, until you pass `dismissLabel`, as +[what a screen reader hears](./toasts.md#what-a-screen-reader-hears) says. Every other word comes from your app, such as the `label` of a loading ghost or a count. Pass each one through your translations. @@ -187,18 +185,14 @@ gives no label a fixed width: leave. A longer label wraps, and the row grows, since the row sets only a smallest height. - A long title on a sidebar screen breaks onto the next line. -- A toast grows with its message up to its widest, then wraps, even - inside a long word. The aside of a page and a log list break a long - word the same way. +- A toast grows with its message, then wraps, as + [size](./toasts.md#size) on the toasts page shows. The aside of a + page and a log list break a long word the same way. - The actions of a page header move under the title once they no longer fit beside it. A form row and the header line of a log list wrap the same way. - The page tabs keep each label on one line. A tab row too long for its page scrolls and fades the edge it cuts. -- Where a toast cuts a name, it counts the characters a reader sees, - so an emoji is never split. - [Naming an item in a toast](../lists.md#naming-an-item-in-a-toast) - shows it. In a right-to-left language the chevrons, the sub menu slide and the fade of the tab row turn around, as diff --git a/docs/design-system/color.md b/docs/design-system/color.md index 236113f..cf21495 100644 --- a/docs/design-system/color.md +++ b/docs/design-system/color.md @@ -61,7 +61,7 @@ The theme provider also checks contrast when it works out colors from a seed. It A raw color is a value written out, such as `#fff`, in place of a token. It stays the same whatever the seeds. `base.css` writes a few on purpose: -- The toast box is `rgb(0 0 0 / 0.85)`, a black that lets a blur of the page show through. Its text and its focus lines are `#fff`. These are the colors of the WordPress snackbar. [Toasts](../loading-and-feedback.md#toasts) describes the rest of the toast. +- A toast paints raw colors too: a see-through black box, white text and white focus lines, the colors of the WordPress snackbar. [Colors, corners and shadow](./toasts.md#colors-corners-and-shadow) gives each value. - The backdrop is the dark layer behind the open drawer. It is `#000` at 0.5 opacity, as in the new WordPress admin. godmin sets the opacity through `--backdrop-opacity`. The `@wordpress/ui` drawer fades its black backdrop to that value, so the fade in and out still run. - Shadows are black at a low opacity. [Elevation](./radius-and-elevation.md#elevation) lists them. - The `#000` in `--godmin-page-tabs-fade` and `--godmin-page-tabs-fade-both` is a mask. The mask fades the ends of a long row of tabs. It paints nothing. diff --git a/docs/design-system/custom-properties.md b/docs/design-system/custom-properties.md index d1535ac..824865b 100644 --- a/docs/design-system/custom-properties.md +++ b/docs/design-system/custom-properties.md @@ -30,10 +30,7 @@ values. This table says where each one is set and what it changes. ## Where each one is set -A value set on `:root` reaches every element on the page. So the -toast region reads the sidebar width and the canvas margin too. When -the sidebar is on screen, the region starts after it and ends where -the canvas ends, so the toasts stay centered on the canvas. +A value set on `:root` reaches every element on the page. To change one of the four on `:root`, write a plain rule in your own stylesheet. For example, a wider sidebar: @@ -44,7 +41,7 @@ stylesheet. For example, a wider sidebar: Your rule wins over godmin's, as [changing the page appearance](../overview.md#changing-the-page-appearance) -explains. The sidebar, the drawer and the toast region all follow it. +explains. The sidebar and the drawer both follow it. The canvas sets its two gutters on itself. So a gutter set on `:root` changes nothing inside the canvas. Read them inside the canvas, for diff --git a/docs/design-system/layout.md b/docs/design-system/layout.md index 7846317..45ac34f 100644 --- a/docs/design-system/layout.md +++ b/docs/design-system/layout.md @@ -84,7 +84,7 @@ as wide. A rule for the screens below it asks for one pixel less, as | `SIDEBAR_BREAKPOINT` | `1024` | The sidebar becomes the top bar and the drawer | | `EDGE_BREAKPOINT` | `782` | The canvas meets the screen edges. It is the WordPress medium breakpoint | | `DENSE_BREAKPOINT` | `640` | The canvas side gutter narrows to 16px. A list from `useListView` takes its phone layout, as [the view in the address](../lists.md#the-view-in-the-address) shows. A table inside `godmin-table-scroll` scrolls sideways, as [stylesheet helpers](../screens.md#stylesheet-helpers) shows | -| `SHEET_BREAKPOINT` | `600` | Dialogs rise as sheets along the bottom of the screen and toasts widen to the screen, as [a form dialog](../lists.md#a-form-dialog) and [toasts](../loading-and-feedback.md#toasts) show. It is the WordPress small breakpoint | +| `SHEET_BREAKPOINT` | `600` | Dialogs rise as sheets along the bottom of the screen and toasts widen to the screen, as [a form dialog](../lists.md#a-form-dialog) and [toasts](./toasts.md#size) show. It is the WordPress small breakpoint | | `TITLE_BREAKPOINT` | `404` | Nothing in the frame. The WordPress editor drops the document title from its header here, for an editor header of your own | | `SMALL_VIEWPORT` | `(max-width: 1023px)` | The media query the frame folds its sidebar at | diff --git a/docs/design-system/motion.md b/docs/design-system/motion.md index daef7e0..d6615f6 100644 --- a/docs/design-system/motion.md +++ b/docs/design-system/motion.md @@ -1,10 +1,11 @@ --- title: Motion -description: The sidebar slide and the fades of a godmin admin, the design tokens that time them, and what changes when a reader asks for less motion. +description: The sidebar slide, the toast motion and the fades of a godmin admin, the design tokens that time them, and what changes when a reader asks for less motion. --- godmin's own motion is short and small. The sidebar slides a new -menu screen in. Toasts fade in and out. Ghosts, the grey shapes shown +menu screen in. Toasts grow in and fade, and the toasts around them +slide along. Ghosts, the grey shapes shown while data loads, fade in, and so does the content that replaces them. godmin's stylesheet animates nothing else. It sets no transitions, so its own hover and focus looks change at once. @@ -18,17 +19,20 @@ and the bars inside a ghost. A design token is a value the WordPress Design System names, written as a CSS custom property. A custom property is a CSS variable. A duration is how long an animation takes. An easing is the curve its -speed follows from start to end. godmin times its fades with three +speed follows from start to end. godmin times its motion with five design tokens: | Token | Value | What it times | | --- | --- | --- | -| `--wpds-motion-duration-md` | 200ms | A toast, a ghost or new content fading in | -| `--wpds-motion-duration-sm` | 100ms | A toast fading out | -| `--wpds-motion-easing-balanced` | `cubic-bezier(0.4, 0, 0.2, 1)` | Every fade | +| `--wpds-motion-duration-lg` | 300ms | Toast motion, see [Motion](./toasts.md#motion) | +| `--wpds-motion-duration-md` | 200ms | A ghost or new content fading in | +| `--wpds-motion-duration-sm` | 100ms | Toast motion, see [Motion](./toasts.md#motion) | +| `--wpds-motion-duration-xs` | 50ms | Toast motion, see [Motion](./toasts.md#motion) | +| `--wpds-motion-easing-balanced` | `cubic-bezier(0.4, 0, 0.2, 1)` | The fades of ghosts and new content | -Two lengths have no token: the sidebar slide and the wait before a -ghost shows. The sections below cover both. +Some values have no token: the sidebar slide, the wait before a ghost +shows, and the rest of the toast motion. The sections below cover +them. ## The sidebar slide @@ -69,12 +73,11 @@ shows how. ## Toasts -A toast fades in when it arrives. When it leaves, it fades out and -stays hidden at the end. A leaving toast stays on the page until its -fade ends. Nobody can press it in that time. If the browser cuts the -fade short, the toast still goes. -[How a toast behaves](../loading-and-feedback.md#how-a-toast-behaves) -says how long a toast stays before it leaves. +Toasts move as the WordPress snackbar does. An arriving toast grows +its space in the column and fades in. A leaving toast fades out, then +its space closes. So the other toasts slide instead of jumping. +[Motion](./toasts.md#motion) on the toasts page gives every duration +and curve, and says what a leaving toast does. ## Loading @@ -100,17 +103,19 @@ A reader can ask their system for less motion. The browser then reports `prefers-reduced-motion: reduce` to CSS, and godmin changes two things: -- The fades of toasts, ghosts and new content last 1ms. They still - run and still end. So a leaving toast still goes, and a ghost - still waits its delay and then stays visible. +- The fades of ghosts and new content last 1ms. They still run and + still end. So a ghost still waits its delay and then stays visible. +- Every toast animation lasts 1ms and starts at once, with no wait. + It still runs and still ends, so a leaving toast still goes. A toast + shows in its place at once and goes at once, and the toasts above + move in one step, as in WordPress. - The sidebar slide lasts 0s, as in the WordPress Site Editor, even when you set your own slide duration. It does not play, and the new screen shows at once in its place. -These rules change only how long each animation lasts. They keep -the delay, the animation itself, and the setting that holds a ghost -visible after its fade. Turning the animation off would leave a -ghost transparent for good. +These rules keep each animation itself and the setting that holds a +ghost visible after its fade. They keep the delay of a ghost too. +Turning an animation off would leave a ghost transparent for good. The bars inside a ghost stop their pulse too. Their WordPress package does that on its own. diff --git a/docs/design-system/overview.md b/docs/design-system/overview.md index ce721ed..ac53fec 100644 --- a/docs/design-system/overview.md +++ b/docs/design-system/overview.md @@ -8,10 +8,10 @@ godmin looks. It says where each part of the screen sits, how big things are, how they move and which colors they take. A new product can follow these pages without reading any godmin code. -These pages hold the values the whole admin shares. A value that -belongs to one part, such as the size of a -[toast](../loading-and-feedback.md#toasts), sits on the page that -shows how to use that part. +These pages hold the values the whole admin shares. A part with a +page of its own, such as [toasts](./toasts.md), keeps its values +there. Every other part keeps its values on the page that shows how +to use it. godmin builds on the WordPress Design System, WPDS for short. For layout, sizes and motion it follows WordPress's new admin and the @@ -99,3 +99,9 @@ layer. landmarks, what a screen reader hears, and text length. - [Custom properties](./custom-properties.md): every godmin custom property, where it is set and what it changes. + +One more page covers a single part, how it looks and how it behaves: + +- [Toasts](./toasts.md): where a toast sits, how it looks, how long it + stays, how a reader clears it, and what a keyboard or a screen reader + meets. diff --git a/docs/design-system/radius-and-elevation.md b/docs/design-system/radius-and-elevation.md index 0ed13f8..2b6c1d9 100644 --- a/docs/design-system/radius-and-elevation.md +++ b/docs/design-system/radius-and-elevation.md @@ -23,8 +23,8 @@ presets. | --- | --- | --- | | `--wpds-border-radius-xl` (12px) | Page and app shell surfaces | The canvas, the panel that shows the active screen | | `--wpds-border-radius-lg` (8px) | Cards, dialogs, notices and other larger containers | A `LogList`, the box of items shown one under the other | -| `--wpds-border-radius-md` (4px) | Menus, popovers and other small overlays | A toast, the short message near the bottom of the screen | -| `--wpds-border-radius-sm` (2px) | Buttons, inputs and compact controls | A sidebar row, the Back button of a sidebar screen, the grey bars of a loading ghost, and the focus rings of a page tab and of a toast action | +| `--wpds-border-radius-md` (4px) | Menus, popovers and other small overlays | A toast, which keeps its corners at every preset, as [toasts](./toasts.md#colors-corners-and-shadow) shows | +| `--wpds-border-radius-sm` (2px) | Buttons, inputs and compact controls | A sidebar row, the Back button of a sidebar screen, the grey bars of a loading ghost, the focus ring of a page tab, and the focus ring of a toast action, which keeps its corners at every preset | | `--wpds-border-radius-xs` (1px) | Elements nested inside controls | Nothing of godmin's own | A surface inside another takes a smaller step than the one around @@ -88,6 +88,11 @@ notices take the same preset as the canvas around them. application](../framing-an-application.md#notices-for-the-whole-application) shows how to pass them. +The toast region keeps the corners of the WordPress snackbar at every +preset `AdminRoot` sets. +[Colors, corners and shadow](./toasts.md#colors-corners-and-shadow) +gives them. + `renderAdmin` from `@gopherium/godmin/testing` takes the same `cornerRadius`, so a test can draw a screen under any preset. @@ -101,7 +106,7 @@ show depth: | Surface | Shadow | | --- | --- | | The canvas | `0 1px 2px rgb(0 0 0 / 0.08)`. None below `EDGE_BREAKPOINT` | -| A toast | The WordPress small elevation, the one the snackbar uses. The snackbar is WordPress's own toast. Four layers: `0 1px 2px rgb(0 0 0 / 0.05)`, `0 2px 3px rgb(0 0 0 / 0.04)`, `0 6px 6px rgb(0 0 0 / 0.03)`, `0 8px 8px rgb(0 0 0 / 0.02)` | +| A toast | The WordPress small elevation, the one the snackbar uses. [Colors, corners and shadow](./toasts.md#colors-corners-and-shadow) writes out its four layers | | The navigation drawer | `2px 0 8px rgba(0, 0, 0, 0.2)`, the shadow of the phone sidebar in WordPress's new admin | A bleed canvas keeps the canvas shadow. The sidebar has no shadow. @@ -119,7 +124,8 @@ Two more shadows draw a line, not depth: merges the borders of its cells, and a merged border paints badly on a pinned cell. [Stylesheet helpers](../screens.md#stylesheet-helpers) shows the table markup. -- A focused toast with no action draws its focus ring as a shadow. +- A focused toast with no action draws its focus ring as a shadow, as + [keyboard](./toasts.md#keyboard) on the toasts page shows. [The focus ring](./accessibility.md#the-focus-ring) covers the other focus rings. diff --git a/docs/design-system/toasts.md b/docs/design-system/toasts.md new file mode 100644 index 0000000..0d19601 --- /dev/null +++ b/docs/design-system/toasts.md @@ -0,0 +1,182 @@ +--- +title: Toasts +description: Where a toast sits, how it looks, how long it stays, how a reader clears it, and what a keyboard or a screen reader meets. +--- + +A toast is a short message near the bottom of the window. It says how +something went, then goes away on its own. godmin draws it as +WordPress's new admin draws its snackbar. +[Toasts](../loading-and-feedback.md#toasts) shows how to raise one and +what to write in it. This page gives every value of how a toast looks +and behaves. + +## Where a toast sits + +Toasts stack in one column fixed to the window. The column spans the +whole width of the window, the sidebar included, and centers each +toast in it. So a toast sits at the center of the window, as in +WordPress's new admin. The sidebar and the drawer never move it. + +- The column sits `--wpds-dimension-size-md` (32px) above the bottom + edge of the window. +- It keeps 16px free on each side, so a toast never touches the edge. +- Two toasts sit `--wpds-dimension-gap-sm` (8px) apart, the newest at + the bottom. The gap sits above each toast, inside the space that + grows and closes with the toast, as in the snackbar. So an arriving + toast starts one gap below the bottom of the column, then rises by + the gap and its own height. +- A click on the column between toasts reaches the page under it. + +A toast paints above the canvas and the sidebar. On a list page it +floats over the list footer, as it does in WordPress. Two things paint +above a toast: + +- The drawer on a small screen. +- An open dialog. On a wide screen a toast shows dimmed under the dark + layer around the dialog. On a phone it sits behind the sheet. Screen + readers still hear it, because every message is also spoken. Most + dialogs close as their write starts or ends, so their toast shows in + full. A [form dialog](../lists.md#a-form-dialog) stays open after a + failure, so its toast shows under it, as in WordPress. + +## Size + +- A toast is as wide as its message, up to + `--wpds-dimension-surface-width-lg` (560px), its padding included. +- Below `SHEET_BREAKPOINT` (600px) it spans the column, so it keeps + 16px from each side of the window. It still stops at 560px. +- It keeps `--wpds-dimension-padding-md` (12px) above and below its + text and `--wpds-dimension-padding-xl` (20px) on each side. A toast + of one line is 42px tall, with or without an action. +- A long message wraps, even inside a long word. godmin never cuts a + message or a name in it. + +## Colors, corners and shadow + +These are the colors of the WordPress snackbar. They stay the same +whatever seeds you pick. + +- The box is `rgb(0 0 0 / 0.85)`, a black that lets the page show + through. The page behind it is blurred 16px and saturated to 180%. +- The text, the action and the focus lines on the toast are `#fff`. +- The corners take `--wpds-border-radius-md` under the `subtle` + preset, so they are 4px. The toast region keeps `subtle` whatever + [corner radius setting](./radius-and-elevation.md#the-corner-radius-setting) + your app picks, as WordPress keeps the corners of its snackbar. +- The shadow is the WordPress small elevation, in four layers: + `0 1px 2px rgb(0 0 0 / 0.05)`, `0 2px 3px rgb(0 0 0 / 0.04)`, + `0 6px 6px rgb(0 0 0 / 0.03)` and `0 8px 8px rgb(0 0 0 / 0.02)`. + +## Text + +- The message takes `--wpds-typography-font-family-body` at + `--wpds-typography-font-size-md` (13px), on a line 1.4 times its + size. +- The action is a link in the same text, underlined 0.2em below the + letters at the thickness the font sets. It loses its underline under + the pointer. +- The action sits on the first line of the message, at least + `--wpds-dimension-gap-2xl` (32px) after it. On a phone, where the + toast spans the column, the spare room goes between the message and + the action, as in the snackbar. + +## How long a toast stays + +- A toast with no action stays 6 seconds. `dismissAfter` changes it. +- A toast with an action stays 10 seconds, time to read the message + and then act. `actionDismissAfter` changes it. +- WordPress's own snackbar in `@wordpress/components` 42.0.0 still + stays 6 seconds, with or without an action. Its next release keeps + one with an action 10 seconds, as godmin does. +- Each toast keeps its own timer. So a toast a screen raises as it + mounts leaves on time even under React strict mode, which mounts the + screen twice in development. +- At most three toasts show at once. When a fourth arrives, the oldest + leaves. `limit` changes the three. A toast pushed out in the same + moment it is raised never shows, as WordPress shows only its newest + three. +- Every toast leaves on its own. WordPress also has a snackbar that + waits for the reader, with no timer and a close button. godmin + raises none. + +## Motion + +A toast moves as the WordPress snackbar does. Each toast takes a space +of its own in the column. The space grows as the toast arrives and +closes after it leaves, so the toasts above slide instead of jumping. + +- An arriving toast grows its space from no height over + `--wpds-motion-duration-lg` (300ms) on `cubic-bezier(0, 0, 0.2, 1)`. + The toast rises into place as its space grows, and the toasts above + slide up. When a toast leaves, its space closes over 450ms on + `cubic-bezier(0.4, 0, 0.1, 1)`, so the toasts above slide down. The + closing waits `--wpds-motion-duration-sm` (100ms) for the toast to + fade out first. A space that is still growing closes from the height + it has reached. +- The toast fades in over 250ms on `cubic-bezier(0, 0, 0.2, 1)`, after + a wait of `--wpds-motion-duration-xs` (50ms). So the fade ends with + the growth. A leaving toast fades out over + `--wpds-motion-duration-sm` on the same curve and stays hidden at the + end. A toast that is still fading in fades out from where it got to. +- A leaving toast stays on the page until its space has closed, and + nobody can press it in that time. If the browser cuts the motion + short, the toast still goes. +- [Reduced motion](./motion.md#reduced-motion) says what changes for a + reader who asks for less motion. Then a toast shows and goes at + once, and nothing grows, slides or fades, as in WordPress. + +## Clearing a toast + +- A click anywhere on a toast clears it, with or without an action. + The pointer turns to `--wpds-cursor-control` over every toast. +- A click on the action runs it and clears the toast. +- A click on a toast moves focus to the toast region, as WordPress + moves it to its snackbar list. The region draws no ring. +- A toast has no close button. WordPress draws its cross only on a + snackbar with no timer, and godmin raises none. + +## Keyboard + +- A toast with no action is one button. Tab reaches it, and Enter or + Space clears it. Its focus ring is a white line + `--wpds-border-width-xs` wide inside the toast and a ring of + `--wpds-color-stroke-focus` `--wpds-border-width-focus` wide around + it, both drawn as a shadow. The browser's own ring shows too. +- In a toast with an action, only the action takes focus. The rest of + the toast is no tab stop and no button. A keyboard reader runs the + action, or lets the toast leave on its own when its time is up. So + offer the same action somewhere else on the screen too, as + [Toasts](../loading-and-feedback.md#toasts) says. +- The focus ring of the action is the design system ring in white: a + solid `#fff` outline `--wpds-border-width-focus` wide, set + `--wpds-border-width-focus` away from the text, with + `--wpds-border-radius-sm` corners. They are 2px, because the toast + region keeps the `subtle` preset, as on the snackbar. The underline + goes while the ring shows. +- When a toast that holds focus leaves, focus moves to the toast + region instead of falling back to the top of the page. The region + draws no ring, and Tab never stops on it. The region comes after the + rest of the page, so the next Tab reaches any other toast still on + screen and then leaves the page for the browser's own controls. + Shift+Tab goes back to the last control before the region. + +WordPress makes its whole snackbar a button the keyboard reaches, with +the action button inside it. godmin never puts a button inside another +button. So it keeps the click on the whole toast, and leaves the +keyboard with the action. + +## What a screen reader hears + +- Each message once, politely, through `speak` from `@wordpress/a11y`. + Polite means the screen reader first finishes what it is saying. + The region itself is not a live region, so nothing is read twice. +- Never the action. A reader who moves into the toast finds the action + by its label. +- A toast with no action is a button named by its message. After the + name the screen reader reads the hint `dismissLabel`, which says + "Dismiss this notice" unless your app passes its own words. No + tooltip shows when the pointer rests on it. + +WordPress names its whole snackbar "Dismiss this notice", so a screen +reader hears those words in place of the message. godmin keeps the +message as the name of a plain toast and reads the words after it. diff --git a/docs/design-system/typography.md b/docs/design-system/typography.md index daf5073..3711785 100644 --- a/docs/design-system/typography.md +++ b/docs/design-system/typography.md @@ -53,7 +53,7 @@ Some parts give the size of their text on their own page: [the page tabs](../screens.md#pagetabs), [the title and description of a list row](../lists.md#the-title-field), [a table that stays custom](../lists.md#lists-that-stay-custom), -[a toast](../loading-and-feedback.md#toasts) and +[a toast](./toasts.md#text) and [the letter of an avatar](../lists.md#avatars). ## The heading outline diff --git a/docs/lists.md b/docs/lists.md index 6bdc567..2bd6c1c 100644 --- a/docs/lists.md +++ b/docs/lists.md @@ -213,7 +213,8 @@ The next line is the footer's own, at the bottom. When the list is longer than the screen, only the rows scroll. The title, the tabs, the search box and the column headers stay where they are, and the footer stays on the bottom edge. A toast floats -over the footer, as it does in WordPress. +over the footer, as +[where a toast sits](./design-system/toasts.md#where-a-toast-sits) shows. A list that is not DataViews, such as a `godmin-table`, grows the page instead, and the canvas scrolls as it does for any other page. @@ -889,7 +890,7 @@ function DuplicateModal({ items: [post], closeModal }: RenderModalProps) { const duplicate = () => { setBusy(true) duplicatePost(post.id, title) - .then(() => toaster.show(`"${toaster.name(title)}" created.`)) + .then(() => toaster.show(`"${title}" created.`)) .catch((failure) => toaster.show(failure.message)) .finally(close) } @@ -972,7 +973,7 @@ function AddProjectDialog({ onClose }: { onClose: () => void }) { const [name, setName] = useState('') const add = async () => { await addProject(name) - toaster.show(`"${toaster.name(name)}" added.`) + toaster.show(`"${name}" added.`) onClose() } return ( @@ -1216,23 +1217,6 @@ every other one is an outline compact button: ``` -## Naming an item in a toast - -A toast that names an item, such as `"Hello" moved to the trash.`, -can get long. `name` on the toaster handle cuts a long name and ends -it with an ellipsis: - -```tsx -const toaster = useToaster() - -toaster.show(`"${toaster.name(post.title)}" moved to the trash.`) -``` - -A name of 45 characters or fewer shows in full. A longer one keeps -its first 45, drops the spaces left at its end, and gets `…`. An -emoji counts as one character and is never split. `Toaster` takes -`nameLength` to change the 45. - ## The canvas colour The frame seeds the canvas with the design system default background, diff --git a/docs/loading-and-feedback.md b/docs/loading-and-feedback.md index 3f0564b..10bacf3 100644 --- a/docs/loading-and-feedback.md +++ b/docs/loading-and-feedback.md @@ -76,43 +76,9 @@ route a `pendingComponent`. Then: ## Toasts A toast is a short message that says how something went, then -removes itself. godmin draws it like the WordPress snackbar: a dark, -slightly see-through box with white 13px text, 32px above the bottom -edge. It sits at the center of the canvas, so beside the sidebar it -lines up with your content, and it sits at the center of the window -when no sidebar is on screen. - -The numbers are the snackbar's own: - -- 12px of padding above and below the text and 20px on each side. - A one line toast is 42px tall, with or without an action. -- 4px rounded corners and the small WordPress shadow under it. -- As wide as its message, up to 560px. On a screen narrower than - 600px it spans the width, with a 16px gap on each side, and still - stops at 560px. -- 8px between two toasts on screen at once. -- On a toast with an action, the action sits at least 32px after the - message and the close button at least 24px after the action. On a - phone, where the toast spans the width, the snackbar spreads the - spare room between the three, and godmin does the same. -- The close button is the snackbar's own cross, the ✕ character in - the toast's text. When a message wraps, the action and the cross - stay on its first line. -- The action is a white underlined link that loses its underline - under the pointer. Keyboard focus draws a dotted white line around - it, and a white inner line with a blue ring around a plain toast, - as the snackbar does. - -On a list page the toast floats over the list footer, as it does in -WordPress. - -An open dialog covers the toasts, as WordPress covers its snackbar. -On a wide screen a toast shows dimmed under the dark layer around the -dialog, and on a phone it sits behind the sheet. Screen readers still -hear it, because every message is also spoken. Most dialogs close as -their write starts or ends, so their toast shows in full. A -[form dialog](./lists.md#a-form-dialog) stays open after a -failure, so its toast shows under it, as in WordPress. +removes itself. godmin draws it like the WordPress snackbar. +[Toasts](./design-system/toasts.md) in the Design System gives where +it sits, how it looks, how long it stays and how a reader clears it. `Toaster` renders the region that holds the toasts. Wrap your tree once: @@ -132,48 +98,35 @@ toaster.show('Post moved to trash.', { label: 'Undo', onAct: restore }) ``` The second argument is optional and adds one action, given as a -`label` and an `onAct` callback. It shows as a white link after the +`label` and an `onAct` callback. It shows as a link after the message. That covers undo, the main reason to offer an action at all. The handle's type is exported as `ToasterHandle`, for holding one in a context or a test. -### How a toast behaves - -- It stays 6 seconds, then fades out. Every toast keeps its own - timer, so a toast a screen raises as it mounts leaves on time even - under React strict mode, which mounts the screen twice in - development. -- At most three are on screen at once. The newest sits at the - bottom, and when a fourth arrives the oldest leaves. -- A toast with no action goes away when you click anywhere on it. - A toast with an action shows a small close button instead, so - there is never a button inside another button. -- Screen readers hear each message once, politely, through `speak` - from `@wordpress/a11y`. The toast region itself is not a live - region, so nothing is read twice. -- When a toast that holds keyboard focus goes away, focus moves to - the toast region instead of falling back to the top of the page. - The next Tab carries on from there. -- The fades stop when the system asks for reduced motion. +Offer the same action somewhere else on the screen too, such as a +Restore row action in the trash. The toast leaves on its own, and a +keyboard reader may not reach its action in time. WordPress asks the +same of its snackbar. `@wordpress/a11y` is a peer dependency, so install it next to godmin. `godminDedupe` lists it, which makes `godminSingleCopy` stop the build if a second copy slips into your bundle. One copy means one live region on the page. -`Toaster` takes four props: - -- `dismissAfter`, how many milliseconds a toast stays. The default - is 6000. -- `limit`, how many toasts stay on screen at once. The default is 3. -- `nameLength`, how many characters of a name a toast shows before - an ellipsis. The default is 45. See - [naming an item in a toast](./lists.md#naming-an-item-in-a-toast). -- `dismissLabel`, the name of the close button. On a toast with no - action it is the hint a screen reader reads after the message. No - tooltip shows when the pointer rests on the toast, as in WordPress. - It defaults to `Dismiss`, and it is the only text godmin puts in a - toast, so set it if your application is not in English. +`Toaster` takes four props. +[How long a toast stays](./design-system/toasts.md#how-long-a-toast-stays) +gives the defaults of the first three, and +[what a screen reader hears](./design-system/toasts.md#what-a-screen-reader-hears) +gives the default of the last: + +- `dismissAfter`, how many milliseconds a toast with no action stays. +- `actionDismissAfter`, how many milliseconds a toast with an action + stays. +- `limit`, how many toasts stay on screen at once. +- `dismissLabel`, the hint a screen reader reads after the message of + a toast with no action, to say a click clears it. It is the only + text godmin puts in a toast, so set it if your application is not + in English. ### What goes in a toast @@ -181,6 +134,11 @@ Use a toast to confirm a change that worked and could otherwise go unnoticed: a record saved, a task completed, a file uploaded. Keep the message short and in the past tense, like `Post moved to trash.` +Name an item in straight double quotes and in full, like +`"Hello" moved to the trash.` WordPress never cuts a name in its +snackbar, and godmin does not either. +[Size](./design-system/toasts.md#size) says how a long one wraps. + Use a toast for a write that failed in a dialog or in a run over list rows too. WordPress shows those in its snackbar, never inside the dialog and never above the list, and godmin's dialogs and list runs diff --git a/test/design-system.test.ts b/test/design-system.test.ts index 56e073e..88e0174 100644 --- a/test/design-system.test.ts +++ b/test/design-system.test.ts @@ -7,6 +7,7 @@ import { resolve } from 'node:path' import { expect, test } from 'vitest' import * as breakpoints from '../src/breakpoints' +import { ruleValue } from './styles.js' /** The folder of Design System pages. */ const folder = resolve(import.meta.dirname, '../docs/design-system') @@ -34,6 +35,108 @@ const source = readdirSync(resolve(import.meta.dirname, '../src')) /** The stylesheet of design tokens the installed design system ships. */ const tokenSheet = readFileSync(createRequire(import.meta.url).resolve('@wordpress/theme/design-tokens.css'), 'utf8') +/** The toaster source, where each toast default is set. */ +const toaster = readFileSync(resolve(import.meta.dirname, '../src/toaster.tsx'), 'utf8') + +/** The usage page of the loading ghosts and the toasts. */ +const usage = readFileSync(resolve(import.meta.dirname, '../docs/loading-and-feedback.md'), 'utf8') + +/** A toast value the toasts page gives: its rule and property, then the section and words of its passage. */ +interface ToastValue { + selector: string + property: string + heading: string + phrase: string +} + +/** Each toast value the toasts page gives. */ +const toastValues: ToastValue[] = [ + ['.godmin-toasts', 'bottom', 'Where a toast sits', 'above the bottom'], + ['.godmin-toasts__item-inner > .godmin-toast', 'margin-block-start', 'Where a toast sits', 'apart'], + ['.godmin-toasts', 'padding-inline', 'Where a toast sits', 'on each side'], + ['.godmin-toast', 'max-width', 'Size', 'as wide as its message'], + ['.godmin-toast', 'padding', 'Size', 'above and below'], + ['.godmin-toast', 'background', 'Colors, corners and shadow', 'The box is'], + ['.godmin-toast', 'color', 'Colors, corners and shadow', 'The text, the action'], + ['.godmin-toast', 'border-radius', 'Colors, corners and shadow', 'The corners'], + ['.godmin-toast', 'box-shadow', 'Colors, corners and shadow', 'The shadow'], + ['.godmin-toast', 'font-family', 'Text', 'The message takes'], + ['.godmin-toast', 'font-size', 'Text', 'The message takes'], + ['.godmin-toast', 'line-height', 'Text', 'The message takes'], + ['.godmin-toast__action', 'margin-inline-start', 'Text', 'first line'], + ['.godmin-toast__action', 'text-underline-offset', 'Text', 'underlined'], + ['.godmin-toasts__item', 'animation', 'Motion', 'grows its space'], + ['.godmin-toast', 'animation', 'Motion', 'fades in'], + ['.godmin-toast--leaving', 'animation', 'Motion', 'fades out'], + ['.godmin-toasts__item--leaving', 'animation', 'Motion', 'closes over'], + ['.godmin-toast', 'cursor', 'Clearing a toast', 'The pointer'], + ['.godmin-toast--plain:focus-visible', 'box-shadow', 'Keyboard', 'one button'], + ['.godmin-toast__action:focus-visible', 'outline', 'Keyboard', 'focus ring of the action'], + ['.godmin-toast__action:focus-visible', 'outline-offset', 'Keyboard', 'focus ring of the action'], + ['.godmin-toast__action:focus-visible', 'border-radius', 'Keyboard', 'focus ring of the action'], +].map(([selector, property, heading, phrase]) => ({ selector, property, heading, phrase })) + +/** + * Returns the value a constant of the toaster source holds, as written. + * @param name - The constant, such as `DISMISS_AFTER`. + * @returns The text after the equals sign. + */ +function toasterValue(name: string): string { + const found = new RegExp(`const ${name} = (.+)`).exec(toaster) + if (found === null) { + throw new Error(`the toaster sets no ${name}`) + } + return found[1] +} + +/** + * Returns the words of the hint a plain toast gives a screen reader, as the toaster sets them. + * @returns The hint without its quotes. + */ +function toastHint(): string { + return toasterValue('DISMISS_LABEL').slice(1, -1) +} + +/** + * Returns the corner preset the toaster holds its region at, as the toaster sets it. + * @returns The preset, such as `subtle`. + */ +function toastCorners(): string { + const found = /cornerRadius="([a-z]+)"/.exec(toaster) + if (found === null) { + throw new Error('the toaster sets no corner preset') + } + return found[1] +} + +/** A toast value with what the toasts page and the stylesheet give for it. */ +interface ToastGiven extends ToastValue { + /** The bullet or paragraph of the page that gives the value, its spaces made single, empty when missing. */ + passage: string + /** The design tokens and raw timings the stylesheet sets for the value, or else each of its layers, spaces single. */ + parts: string[] +} + +/** + * Returns each toast value with the passage of the toasts page that gives it and the parts the stylesheet sets. + * @returns The values in table order. + */ +function toastsGiven(): ToastGiven[] { + const sections = (pages.get('toasts.md') ?? '').split('\n## ') + return toastValues.map((value) => { + const section = sections.find((part) => part.startsWith(`${value.heading}\n`)) ?? '' + const passages = section.split(/\n(?:- |\n)/).map((passage) => passage.replace(/\s+/g, ' ')) + const set = (ruleValue(value.selector, value.property) ?? '').replace(/\s+/g, ' ').trim() + const tokens = set.match(/--wpds-[a-z0-9-]*[a-z0-9]/g) + const timings = set.match(/\b\d+ms\b|cubic-bezier\([^)]*\)/g) ?? [] + return { + ...value, + passage: passages.find((passage) => passage.includes(value.phrase)) ?? '', + parts: tokens === null ? set.split(',').map((layer) => layer.trim()) : [...tokens, ...timings], + } + }) +} + /** * Returns the custom properties a text uses or declares that start with a prefix. * @param text - The text to search. @@ -73,6 +176,7 @@ test.each([ 'motion.md', 'accessibility.md', 'custom-properties.md', + 'toasts.md', ])('the Design System holds %s', (name) => { expect([...pages.keys()]).toContain(name) }) @@ -108,3 +212,57 @@ test('the colour reader finds short and long hex colours but no heading link', ( test('every colour a page names is set in the source', () => { expect(coloursIn(allPages).filter((colour) => !source.includes(colour))).toEqual([]) }) + +test('the toasts page gives how long each kind of toast stays, as the toaster sets it', () => { + const page = pages.get('toasts.md') ?? '' + + expect(page).toContain(`${Number(toasterValue('DISMISS_AFTER')) / 1000} seconds`) + expect(page).toContain(`${Number(toasterValue('ACTION_DISMISS_AFTER')) / 1000} seconds`) +}) + +test.each(toastsGiven())('the toasts page gives the $selector $property as the stylesheet sets it', (given) => { + expect(ruleValue(given.selector, given.property), 'the stylesheet sets the value').toBeDefined() + expect(given.passage, `the ${given.heading} section says nothing with "${given.phrase}"`).not.toBe('') + expect(given.parts.filter((part) => !given.passage.includes(part))).toEqual([]) +}) + +test('the toasts page names no design token beside a toast value that the stylesheet leaves out', () => { + const used = new Map() + for (const { passage, parts } of toastsGiven()) { + used.set(passage, [...(used.get(passage) ?? []), ...parts]) + } + const extra = [...used].flatMap(([passage, parts]) => + [...namesIn(passage, '--wpds-')].filter((name) => !parts.includes(name)), + ) + + expect(extra).toEqual([]) +}) + +test('the toasts page gives the corner preset every toast corner keeps, as the toaster sets it', () => { + const corners = toastsGiven().filter((given) => given.property === 'border-radius') + + expect(corners.map((given) => given.selector)).toEqual(['.godmin-toast', '.godmin-toast__action:focus-visible']) + expect(corners.filter((given) => !given.passage.includes(`\`${toastCorners()}\``))).toEqual([]) +}) + +test('the motion and corner pages leave every toast timing and the toast preset to the toasts page', () => { + const tokens = (pages.get('motion.md') ?? '').split('\n').filter((line) => /^\| `--wpds-motion-/.test(line)) + const toastTokens = tokens.filter((row) => /toast/i.test(row)) + const corners = (pages.get('radius-and-elevation.md') ?? '').split('\n\n').filter((passage) => /toast/i.test(passage)) + + expect(toastTokens.length, 'the motion page names the tokens of the toast motion').toBeGreaterThan(0) + expect(toastTokens.filter((row) => !row.endsWith('| Toast motion, see [Motion](./toasts.md#motion) |'))).toEqual([]) + expect(corners.filter((passage) => passage.includes(`\`${toastCorners()}\``))).toEqual([]) +}) + +test('the toasts page gives the hint a plain toast speaks, as the toaster sets it', () => { + expect(pages.get('toasts.md') ?? '').toContain(`"${toastHint()}"`) +}) + +test('the usage page leaves every toast value to the toasts page', () => { + const section = usage.slice(usage.indexOf('## Toasts\n')).split('\n## ')[0] + const values = ['6000', '10000', '6 seconds', '10 seconds', toastHint(), 'white'] + + expect(section, 'the usage page has a Toasts section').toMatch(/^## Toasts\n/) + expect(values.filter((value) => section.includes(value))).toEqual([]) +}) From b3c4aad842fe324e007784cb35280192d6083637 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 20:29:21 +0100 Subject: [PATCH 3/4] docs(changelog): note the toast changes under unreleased --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe316cc..e65d1c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/). While at 0.x, minor releases may break. Releases are tagged `vX.Y.Z` and publish from CI. +## [Unreleased] + +### Added + +- `Toaster` takes `actionDismissAfter`, how long a toast with an action stays, 10000 milliseconds by default, as the next `@wordpress/components` release keeps a snackbar with an action 10 seconds. +- `docs/design-system/toasts.md` gives where a toast sits, how it looks, how long it stays, how a reader clears it and what a keyboard or a screen reader meets. + +### Changed + +- Toasts centre on the whole window, the sidebar included, as WordPress's new admin centres its snackbars. +- The toast region sits `--wpds-dimension-size-md` above the bottom edge, still 32px, as WordPress writes it. +- The toast action shows the design system focus ring in white in place of a dotted line, as the WordPress snackbar action does. +- Toasts move as the WordPress snackbar does. Each toast's space grows as it arrives and closes after it fades out, so the toasts above slide instead of jumping, and under a reduced motion preference a toast shows and goes at once. +- A toast keeps 4px corners and its action ring 2px corners at every `cornerRadius` preset, as WordPress fixes them on its snackbar. +- **Breaking.** `dismissLabel` defaults to "Dismiss this notice", WordPress's words, in place of "Dismiss", and is only the hint a screen reader hears on a toast with no action. +- **Breaking.** `dismissAfter` times only a toast with no action. A toast with an action stays `actionDismissAfter`. +- **Breaking.** A click anywhere on a toast clears it, with or without an action, and a toast with an action has no close button, so the `.godmin-toast__dismiss` class is gone. Only its action takes keyboard focus. +- **Breaking.** A toast now sits in `.godmin-toasts__item`, not directly in `.godmin-toasts`, so a selector such as `.godmin-toasts > .godmin-toast` no longer matches it. + +### Removed + +- **Breaking.** `useToaster().name` and the `nameLength` prop of `Toaster`. A toast shows a whole name and wraps it, as WordPress does, so put the name in the message as it is. +- The README browser note, since godmin no longer needs `Intl.Segmenter`. + ## [0.27.0] - 2026-10-25 ### Added From ecd008c90976fa8a593525e71c9f02161a06df3c Mon Sep 17 00:00:00 2001 From: SirLouen Date: Sun, 25 Oct 2026 22:27:15 +0100 Subject: [PATCH 4/4] docs(design-system): name the focus color of a plain toast ring --- docs/design-system/color.md | 2 +- docs/design-system/toasts.md | 7 ++++++- test/design-system.test.ts | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/design-system/color.md b/docs/design-system/color.md index cf21495..b67f0a4 100644 --- a/docs/design-system/color.md +++ b/docs/design-system/color.md @@ -61,7 +61,7 @@ The theme provider also checks contrast when it works out colors from a seed. It A raw color is a value written out, such as `#fff`, in place of a token. It stays the same whatever the seeds. `base.css` writes a few on purpose: -- A toast paints raw colors too: a see-through black box, white text and white focus lines, the colors of the WordPress snackbar. [Colors, corners and shadow](./toasts.md#colors-corners-and-shadow) gives each value. +- A toast paints raw colors too: a see-through black box, white text and a white focus line, the colors of the WordPress snackbar. [Colors, corners and shadow](./toasts.md#colors-corners-and-shadow) gives each value. - The backdrop is the dark layer behind the open drawer. It is `#000` at 0.5 opacity, as in the new WordPress admin. godmin sets the opacity through `--backdrop-opacity`. The `@wordpress/ui` drawer fades its black backdrop to that value, so the fade in and out still run. - Shadows are black at a low opacity. [Elevation](./radius-and-elevation.md#elevation) lists them. - The `#000` in `--godmin-page-tabs-fade` and `--godmin-page-tabs-fade-both` is a mask. The mask fades the ends of a long row of tabs. It paints nothing. diff --git a/docs/design-system/toasts.md b/docs/design-system/toasts.md index 0d19601..2908a5c 100644 --- a/docs/design-system/toasts.md +++ b/docs/design-system/toasts.md @@ -58,7 +58,12 @@ whatever seeds you pick. - The box is `rgb(0 0 0 / 0.85)`, a black that lets the page show through. The page behind it is blurred 16px and saturated to 180%. -- The text, the action and the focus lines on the toast are `#fff`. +- The text and the action are `#fff`, and so is the ring around a + focused action. +- A focused toast with no action has a `#fff` line + `--wpds-border-width-xs` (1px) wide inside its edge. Outside it sits + the usual focus ring, `--wpds-border-width-focus` (2px) of + `--wpds-color-stroke-focus`. - The corners take `--wpds-border-radius-md` under the `subtle` preset, so they are 4px. The toast region keeps `subtle` whatever [corner radius setting](./radius-and-elevation.md#the-corner-radius-setting) diff --git a/test/design-system.test.ts b/test/design-system.test.ts index 88e0174..fac98c3 100644 --- a/test/design-system.test.ts +++ b/test/design-system.test.ts @@ -57,7 +57,8 @@ const toastValues: ToastValue[] = [ ['.godmin-toast', 'max-width', 'Size', 'as wide as its message'], ['.godmin-toast', 'padding', 'Size', 'above and below'], ['.godmin-toast', 'background', 'Colors, corners and shadow', 'The box is'], - ['.godmin-toast', 'color', 'Colors, corners and shadow', 'The text, the action'], + ['.godmin-toast', 'color', 'Colors, corners and shadow', 'The text and the action'], + ['.godmin-toast--plain:focus-visible', 'box-shadow', 'Colors, corners and shadow', 'A focused toast with no action'], ['.godmin-toast', 'border-radius', 'Colors, corners and shadow', 'The corners'], ['.godmin-toast', 'box-shadow', 'Colors, corners and shadow', 'The shadow'], ['.godmin-toast', 'font-family', 'Text', 'The message takes'],