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:
1 parent
4c0a5de7e7
commit
f24f4972ba
9 files changed
+170
-352
No files matched your search
@@ -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)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user