Toast on sonner's behaviour; Dashboard figures as the canvas's KPI strip

Toast: sonner draws the notice and runs its clock, pauses it under the pointer and
removes it. ui/Toast keeps the live region and the action with its permission check.
Gone, because sonner has none of it: holding the clock while the keyboard focus is
inside, restarting it when the notice gains a rule, Escape, and handing the focus back
to a named element. The two Rules tests and three Toast tests about those are removed.

Dashboard: the eleven figures are one KPI strip, every cell the same weight, an icon
tile on the left, the name, the figure at 24 px and under it the share and the change.
The sparklines and the larger Total Queries card are gone; a card still opens Query Logs.

Check.module.css gets the switch drawing back: Blocking > Lists composes it, and
dev/css-dead.mjs reported the missing class.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 01:16:12 +07:00
1 parent 4c0a5de7e7
commit f24f4972ba
9 files changed
+170 -352

No files matched your search

+2 -29
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { readRuleExport } from '../../api/blocking'
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
import { SectionHeader } from '../../ui/SectionHeader'
@@ -132,15 +132,9 @@ export function Rules({
treeContext.current = { view, viewBlocked, viewAllowed }
}, [view, viewBlocked, viewAllowed])
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
/* The undo notice: the rules it can put back, and where it is (`undo.ts`).
`undoKey` restarts its clock when a Delete joins it or it reports. */
/* The undo notice: the rules it can put back, and where it is (`undo.ts`). */
const [undo, setUndo] = useState<UndoState | null>(null)
const [undoKey, setUndoKey] = useState(0)
const undoButton = useRef<HTMLButtonElement>(null)
const search = useRef<HTMLInputElement>(null)
/* The row that held the focus is gone once the table is read again: the focus
goes to Undo then, and not to the page's body. */
const focusUndo = useRef(false)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
@@ -193,21 +187,6 @@ export function Rules({
void load()
}, [load])
useLayoutEffect(() => {
if (!focusUndo.current) return
const active = document.activeElement
if (active != null && active !== document.body && document.contains(active)) return
focusUndo.current = false
/* The notice is drawn by sonner a moment after this render, so its button may
not exist yet: it is looked for until it does. */
let tries = 0
const focusWhenThere = () => {
if (undoButton.current != null) undoButton.current.focus()
else if (tries++ < 30) setTimeout(focusWhenThere, 16)
}
focusWhenThere()
}, [rules])
/* The bar always says the filter on screen: on choosing, and on opening when the
one it asked for fell back to All or meant nothing (`?rule=garbage`). The RAW
value is compared, because `readRuleParam` already reads nonsense as All. */
@@ -297,8 +276,6 @@ export function Rules({
return
}
setUndo((u) => joinDeleted(u, rule))
setUndoKey((k) => k + 1)
focusUndo.current = true
changed()
await load(true)
}
@@ -316,7 +293,6 @@ export function Rules({
if (!mounted.current) return
if (result.failure == null) {
setUndo({ rules: deleted, phase: 'restored' })
setUndoKey((k) => k + 1)
return
}
setUndo(null)
@@ -633,9 +609,6 @@ export function Rules({
}
hold={undo?.phase === 'restoring'}
duration={undo?.phase === 'restored' ? RESTORED_MS : UNDO_MS}
timerKey={undoKey}
actionRef={undoButton}
returnFocus={() => search.current?.focus()}
onDismiss={() => setUndo(null)}
/>
+2 -24
View File
@@ -130,16 +130,7 @@ describe('Rules: Delete with Undo', () => {
expect(screen.getByText('No rules')).toBeInTheDocument()
})
it('Escape inside the notice dismisses it', async () => {
server(['a.test'], [])
render(<Rules token="T" permissions={undefined} />)
await deleteRule('a.test')
;(await screen.findByRole('button', { name: 'Undo' })).focus()
await userEvent.keyboard('{Escape}')
expect(status()).toBeEmptyDOMElement()
})
it('the notice goes after a few seconds, and not while the pointer is on it', async () => {
it('the notice goes after a few seconds, and not while the pointer is on it', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime })
server(['a.test'], [])
@@ -177,20 +168,7 @@ describe('Rules: Delete with Undo', () => {
expect(add).not.toHaveBeenCalled()
})
it('the focus the deleted row held goes to Undo, and from Undo to the filter when it is done', async () => {
server(['a.test', 'b.test'], [])
render(<Rules token="T" permissions={undefined} />)
const button = await screen.findByRole('button', { name: 'Delete a.test' })
button.focus()
await userEvent.keyboard('{Enter}')
const undo = await screen.findByRole('button', { name: 'Undo' })
await vi.waitFor(() => expect(undo).toHaveFocus())
await userEvent.keyboard('{Enter}')
await vi.waitFor(() => expect(status()).toHaveTextContent("'a.test' is back in Blocked."))
await vi.waitFor(() => expect(screen.getByLabelText('Filter domains')).toHaveFocus())
})
it('without Modify on the list the Delete asks first, as upstream; with it, it does not', async () => {
it('without Modify on the list the Delete asks first, as upstream; with it, it does not', async () => {
const { remove } = server(['ads.test'], ['ok.test'])
render(<Rules token="T" permissions={{ Blocked: P(true), Allowed: P(true, false) }} />)
await deleteRule('ads.test')