feat(toast): add the Toast primitive - #99
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
`Toast.Provider` holds the list and the timers, and gives the manager to the page through `useToastManager()` or `bind:manager`: `add`, `update`, `close` and `promise`. `Toast.Viewport` is a `role="region"` landmark named with the count of toasts, with `F6` in and out, and two live regions beside it announce each toast without its buttons. `Toast.Root` is a `dialog` that is not modal, or an `alertdialog` for a high priority, with `Escape`, a swipe that follows the finger, and an exit animation from its CSS. The timers stop on hover, on keyboard focus and in a background window. The newest toasts stay up to a limit, and the older ones wait inert behind them. The `ariaHideOutside` primitive learned `data-hk-hide-outside-exempt`, thus the viewport stays reachable behind a modal dialog.
`add` takes `anchor`, `placement` and `offset`, and `Toast.Positioner` puts the toast against that element as a fixed panel that follows it and flips when the space is not sufficient. A toast with an anchor is out of the stack: `--toast-index` reads 0, and `data-anchored` is on the root. For a toast without one, the positioner is out of the layout, thus one snippet serves both kinds. The docs say where `data-hk-swipe-ignore` goes.
…Tab behind a modal Two toasts in the same tick were one message: the announcers now hold one node per message, for a while, thus the screen reader reads both, and the same text twice. A toast without a title takes its name from the description, because a dialog without a name is a fault. A close from the page moves the focus on when it was in the toast. The count in the name of the region leaves out a toast on its way out. A hidden tab stops the timers, and the focus trap of a modal leaves `Tab` to a surface that stays reachable behind it, thus `Tab` moves in the toast and goes back to the dialog after its last button.
…the focus A window without the focus can still be in view, and a frame beside others loses the focus all the time: the pause followed the focus and never came back. The visibility of the tab is the signal that means the user cannot see the toast.
The presence effect ran after the mount, and the measure of the toast in between fixed the final styles first: the enter played backwards, then forwards. The enter state now starts at creation, thus the first frame is in it. The demos write the enter as an animation on `data-entering`, which stays on for the whole motion, and the exit as a transition on `data-exiting`; the docs say why.
…r in the spread stack A toast on its way out is out of `stackedToasts` now, thus the ones behind it move up while it fades. It keeps the index and the offset it had for its exit, and the demo runs the exit from that place: a bare `translateY(1rem)` sank a toast from behind under the front one. The demo also covers the gap between two spread toasts with a pseudo-element. A pointer in that gap left the viewport, which closed the stack under it and opened it again: the toasts behind were out of reach. The dev render-scan button of the docs moves to the left of the corner: it sat over the close button of the front toast.
…ops short A touch has no hover. A tap on a toast holds the stack open, and the timers with it, until a touch outside the viewport. A mouse press does nothing new: the hover already does it. A pull against the swipe direction gave a quarter of the way: a long pull moved the front toast off the one behind, which showed empty. It is a rubber band of 8 px at most now.
The mouse rests where the file before left it. On the region, it is a hover that stops the timers and spreads the stack out, and the tests of the timers and of the tap read that hover as their own state.
The stack spread out on the finger down, thus every swipe started with a spread nobody asked for. The tap now resolves on the lift, within 10 px of where the touch landed, and not on a button.
…he front one The demo hid the text of the toasts behind the front one while the stack is folded. A swipe moved the front one aside and showed an empty card, which read as a fault.
… reduced motion in the demos The announcer walked the list of toasts, which is the newest first: two toasts of one tick came out in reverse. The demos set a short motion under prefers-reduced-motion on the toast, and the enter rule, more specific, kept its 0.4 s.
Three faults of the stack, found with a frame by frame trace of the demo: - A toast past the limit read `--toast-index: 0`, thus it had the place in front. When a place freed, it came forward from over the toast the user reads. It now waits in the place behind the stack. - A toast on its way out kept `data-front`, thus two toasts had it at one time. The front is now the one toast the user sees in front. The demo keeps the toast that goes out of the rule that hides the ones behind. - The enter of the demo moved `transform`, which also holds the place in the stack. A toast pushed back while it came in jumped to its new place. The enter now moves `translate`, and the place keeps its transition. Also: the close button of the action demo no longer has the room of a button that floats.
Agustin-Delgado
force-pushed
the
feat/toast
branch
from
September 23, 2026 23:29
c0fc964 to
39fb945
Compare
Merged
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the
Toastprimitive:Provider,Viewport,Positioner,Root,Content,Title,Description,ActionandClose.Toast.Providerholds the list of toasts and the timers.useToastManager()in a component under it, orbind:manageron it, givesadd,update,closeandpromise.addwith the id of a toast on the screen updates it, andpromiseturns aloadingtoast intosuccessorerror.Toast.Viewportis arole="region"landmark named with the count of toasts.F6moves the focus into it from anywhere on the page, and back. ATabpast the last button goes back to where the focus was. It renders at the end of the body by default.Two live regions beside the viewport announce each toast:
role="status"for the normal priority androle="alert"for the high priority. They are on the page before the first toast, they read the title and the description, not the buttons, and two toasts in the same tick are two messages.Toast.Rootis arole="dialog"that is not modal, or analertdialogfor the high priority, witharia-labelledbyandaria-describedbyfrom the title and the description. A toast without a title takes its name from the description.Escapecloses it, and the focus moves to the next toast or back to where it was. It leaves the DOM after its exit animation.The timers stop while the pointer rests on the viewport, while the keyboard focus is in it, and while the tab is hidden. They start again with the time each toast had left. An update gives the toast its full time again.
The newest toasts stay on the screen up to
limit. The older ones wait behind, hidden and inert, with their timers stopped.A swipe follows the finger through
--toast-swipe-movement-xand--toast-swipe-movement-y, dismisses past 40 px or on a flick, and comes back when short. It never starts onToast.CloseorToast.Action. The root closes the swipe axes to the native pan withtouch-action.addtakesanchor,placementandoffset, andToast.Positionerputs the toast against that element as a fixed panel that follows it and flips. A toast with an anchor is out of the stack. For a toast without one, the positioner is out of the layout, thus one snippet serves both kinds.Every toast gets
--toast-index,--toast-offset-yand--toast-height, and the viewport--toast-frontmost-height, withdata-frontanddata-expanded, for a stack.ariaHideOutsidelearneddata-hk-hide-outside-exempt, thus the viewport stays reachable behind a modalDialog, and the focus trap leavesTabto it while the focus is in it.getFocusableElementsis exported from the focus trap.Localized strings for the name of the region in six languages.
The presence primitive starts the enter state at creation, thus a node that is in the DOM from the first render paints its first frame in it. Before, a measure between the mount and the effect fixed the final styles first, and the enter played backwards.
The docs say to write the enter as an animation on
data-enteringand the exit as a transition ondata-exiting, and why.Includes the serial test run of test: run the browser test files one at a time #100, thus the checks are green here before that one merges.
A toast on its way out is out of
stackedToasts, thus the ones behind it move up while it fades. It keeps the index and the offset it had, and the demo runs the exit from that place. The demo also covers the gap between two spread toasts with a pseudo-element: a pointer in that gap left the viewport, which closed the stack under it, and the toasts behind were out of reach. The dev render-scan button of the docs moves out of the corner, where it sat over the close button.A touch has no hover: a tap on a toast, one that lifts where it landed and not on a button, holds the stack open, and the timers with it, until a touch outside the viewport. A swipe is not a tap. A pull against the swipe direction is a rubber band of 8 px at most: a long pull moved the front toast off the one behind, which showed empty.
Verification
F6, the timers hold while the focus is in the region and while the pointer rests on it,Escapemoves the focus to the next toast,Tableaves the region back to the button, and a touch swipe of 120 px follows the finger and dismisses to the right. The anchored toast sits 8 px above its button, centered, and a second press updates it in place.