fix(web): address review of the dialog, toast and async primitives

Two of these were real defects in the previous two commits.

friendlyMessage discarded exactly the messages it claimed to keep: the
"is this a bare reason phrase" test was a shape regex, and "Default steps
cannot be edited" has the same shape as "Not Found". It is an exact-match set
of reason phrases now.

Modal depended on onKeyDown, which is rebuilt whenever onClose changes
identity — and onClose is an inline arrow at every call site, so any parent
re-render (a 30s poll, a mutation flipping to pending) tore the effect down
and rebuilt it: cleanup restored focus to the trigger, setup then moved it to
the top of the dialog, mid-typing. onClose is held in a ref and the effect is
keyed on `open` alone.

Also in Modal: initial focus takes the first control in the body rather than
the panel, since the header comes first in DOM order and every dialog was
opening on its own dismiss button; the Tab trap pulls focus back when it has
escaped the panel entirely rather than only handling the two ends; the scroll
lock is refcounted, because per-instance save/restore released the page when
an outer dialog unmounted under an open inner one; and the whole thing is
portalled to the body so a nested confirm is not clipped by its parent's
overflow box.

The fleet's filtered-empty state keyed on the search alone, so a tag filter
matching nothing told a customer with a full fleet to add their first server.

Remaining: pending mutation errors are reset when a confirm dialog closes, so
one member's failure no longer greets the next; ConfirmDialog clears typed
confirmation when the target changes, not only when it reopens; deleting a
workflow closes its dialogs before navigating rather than carrying a scroll
lock onto the next page; toasts split into a polite and an assertive region,
since one polite wrapper demotes the role="alert" children inside it; and a
custom skeleton gets a live "Loading" beside it, having been aria-hidden with
nothing else to announce.
This commit is contained in:
2026-08-10 09:44:29 +01:00
parent 4d67341ba5
commit 0cfaf6670c
8 changed files with 228 additions and 91 deletions
+5 -1
View File
@@ -142,6 +142,7 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
mutate: remove,
isPending: removing,
error: removeError,
reset: resetRemove,
} = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => {
@@ -199,7 +200,10 @@ function SecretRow({ group, secret }: { group: string; secret: Secret }) {
confirmLabel="Delete key"
loading={removing}
error={removeError ? friendlyMessage(removeError) : null}
onClose={() => setConfirming(false)}
onClose={() => {
resetRemove();
setConfirming(false);
}}
onConfirm={() => remove()}
body={
<>
+18 -4
View File
@@ -228,11 +228,25 @@ function ServersPageBody() {
skeleton={<TableSkeleton columns={6} />}
isEmpty={visible.length === 0}
empty={
search.trim() ? (
/* Narrowed to nothing is not the same as owning nothing. Telling a
customer with a full fleet to "add your first server" because a
tag filter matched none of it is the version of this that gets
screenshotted. */
search.trim() || Object.keys(selected).length > 0 ? (
<EmptyState
title="No servers match that search."
description="Clear the search to see the rest of the fleet."
action={{ label: "Clear search", onClick: () => setSearch("") }}
title="No servers match those filters."
description={
Object.keys(selected).length > 0 && search.trim()
? "Nothing matches both the tag filter and the search."
: Object.keys(selected).length > 0
? "No server carries every tag selected above."
: "Clear the search to see the rest of the fleet."
}
action={
search.trim()
? { label: "Clear search", onClick: () => setSearch("") }
: { label: "Clear filters", onClick: () => setSelected({}) }
}
/>
) : (
<EmptyState