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 <[email protected]>
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 -2
View File
@@ -67,7 +67,7 @@
| Menu | ReUI Popover (роли и клавиатура меню остались в `Menu.tsx`) |
| Select | ReUI Popover (поиск, группы и набор по буквам сохранены) |
| Table | Все 23 таблицы описаны колонками TanStack: `ui/DataTable.tsx` строит ReUI `DataGrid` из `columns` и `data`, а то, что DataGrid не принимает (класс строки или ячейки, `aria-sort` у `<th>`, `style`, `colSpan`, `role`, `hidden`), навешивает на нарисованные им элементы после каждого рендера. Определения колонок держат стабильную идентичность (иначе ячейки пересоздавались бы при каждом рендере родителя), встроенная пагинация DataGrid выключена (`manualPagination`). В Blocking › Lists строка-заголовок группы это строка, первая ячейка которой растянута на четыре колонки (`colSpan`, `role="columnheader"`, `scope="colgroup"`), а остальные три скрыты. Режим карточек на телефоне, подписи колонок и сортировка «как в оригинале» работают поверх той же таблицы (проверено в браузере на моке: 1440 px таблица, 390 px карточки, без горизонтальной прокрутки). |
| Toast | свой (фокус возвращается, пауза под курсором, действие с правом); цвета и шрифты из темы ReUI |
| Toast | Уведомление целиком на ReUI `sonner`: его таймер, пауза под курсором, расстановка и анимация. В `ui/Toast.tsx` остались только живая область `role="status"` (всегда в странице, пустая, пока уведомление закрыто) и действие с проверкой прав. Убрано, потому что у sonner этого нет: пауза таймера, пока фокус клавиатуры внутри уведомления, перезапуск таймера при пополнении, закрытие по Escape и возврат фокуса на названный элемент. Тесты на эти поведения удалены. |
| Тема | палитра shnt Ops в `tokens.css`, Geist, шкала 12/13/14 px, радиусы 8/10/14 px |
## Решения по плану
@@ -75,7 +75,7 @@
| Пункт | Что сделано |
|---|---|
| Toast | Уведомление рисует ReUI `sonner`. Часы, пауза под курсором и фокусом, Escape и возврат фокуса остались в `ui/Toast.tsx`. Живая область `role="status"` по-прежнему всегда в странице. Цена замены: перед кнопкой Undo появился один лишний Tab-стоп (обёртка sonner), закрывающееся уведомление остаётся в DOM около 200 мс, а тесты Rules ждут его асинхронно (`findBy`, `waitFor`). |
| Карточки Dashboard | Одиннадцать карточек собраны в одну панель KPI как на холсте: границы-волоски вместо рамок, плитка с иконкой, подпись без капса. Спарклайны, доля, изменение и переход в Query Logs сохранены. |
| Карточки Dashboard | Одиннадцать карточек в одной панели KPI точно как на холсте: ячейки равного веса, плитка-иконка 40 px слева, подпись, значение 24 px Geist, под ним доля и изменение (это подсказка холста). Убраны спарклайны и большая карточка Total Queries. Переход в Query Logs по клику на карточку сохранён. Проверено в браузере на моке: 1440 px шесть колонок по 190 px, 390 px одна колонка, без горизонтальной прокрутки. |
| Table | Таблица строится на примитивах ReUI (`TableHeader`, `TableBody`, `TableFooter` и вид DataGrid). Перевод на TanStack `DataGrid` не сделан: строки и ячейки 23 таблиц несут свои атрибуты (классы строк, `colSpan`, `aria-sort` в `<th>`), а DataGrid отдаёт `<tr>`/`<td>` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
Сборка, `tsc`, `lint:colours`, `oxlint` проходят. Тесты (`npx vitest run --maxWorkers=3`): 28 падений из 2076, все они падали и до миграции (локаль Windows и отсутствие коммита).
+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')
+21 -65
View File
@@ -17,41 +17,39 @@ and its own pieces.
.period > * { max-width: 100%; }
/*
The eleven figures in one block: `Total Queries` spans both rows, the other ten are
compact cards in two rows of five (`Figures.tsx` says which and why).
The eleven figures in one ReUI KPI strip, as the canvas draws it: a panel, cells told apart
by hairlines, and in each an icon tile in the colour of the figure's series, its name, its
figure and, under it, its share of the total and its change (`Figures.tsx`). All cells weigh
the same; the cards no longer carry a trend.
Each cell is a size container: its figure takes the smaller of its normal size (`--size`)
and what fits (`--fit`, below), so a long figure never spills out.
*/
.tiles {
display:grid; grid-template-columns: minmax(0, 1.15fr) repeat(5, minmax(0, 1fr)); gap: 0;
/* ReUI's KPI strip: one panel, cells told apart by hairlines, an icon tile in each. */
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
background: var(--pan); border: 1px solid var(--line); border-radius: var(--r-4); overflow: hidden;
}
/*
A figure's colour is the colour of ITS SERIES in the chart, said with an edge and
not with the square swatch a legend uses: the swatch already means "a series" in the
`Queries` legend, and a card is that series' total.
Each card is a size container: its figure takes the smaller of its normal size
(`--size`) and what fits (`--fit`, below), so a long figure never spills out.
*/
.tile {
container-type: inline-size;
position: relative;
min-width: 0;
background: transparent;
border: 0;
border-radius: 0;
display: flex; align-items: center; gap: var(--s-8);
padding: var(--s-9) var(--s-10);
box-shadow: 1px 0 0 var(--line2), 0 1px 0 var(--line2);
}
/* The icon tile, top right: the series' colour on the muted fill. */
/* The icon tile: 40 px, bordered, on the muted fill, the glyph in the series' colour. */
.ico {
position: absolute; top: var(--s-5); right: var(--s-6);
flex: none;
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
border: 1px solid var(--line); border-radius: var(--r-2); background: var(--pan2);
width: 40px; height: 40px;
border: 1px solid var(--line); border-radius: var(--r-3); background: var(--pan2);
color: var(--tc, var(--ink));
}
.text { min-width: 0; flex: 1 1 auto; }
/*
A card that opens Query Logs (upstream's since v15.6.2): a transparent button over
the whole card. Hover lifts the edge to the series' colour; focus draws the halo.
@@ -69,31 +67,7 @@ the whole card. Hover lifts the edge to the series' colour; focus draws the halo
.open:hover { border-color: var(--tc, var(--acc)); }
.open:focus-visible { outline: none; box-shadow: var(--halo-focus); }
/*
A compact card: what it is across the top; under it, how much with its share and the
change, and the trend beside them. The text keeps the width its figure line needs
and the trend takes the rest, up to 64 px; the text never shrinks while the trend
can (down to 32 px). Past that the share wraps under its figure and the card grows a
line: nothing is cut and nothing overlaps.
*/
.fig { padding: var(--s-4) var(--s-7); }
.body { display:flex; align-items:flex-end; justify-content: space-between; gap: var(--s-5); }
.text { flex: 0 1 auto; min-width: min-content; }
.fig .spark { flex: 1 0 var(--s-12); max-width: calc(2 * var(--s-12)); height: calc(var(--s-11) + var(--s-1)); }
/* `Total Queries`: label, figure, change, and its trend along the foot. */
.hero {
grid-row: span 2;
display:flex;
flex-direction: column;
padding: var(--s-7) var(--s-9);
}
.hero .body { flex: 1; flex-direction: column; align-items: stretch; gap: var(--s-3); }
.hero .spark { margin-top: auto; width: 100%; height: calc(var(--s-11) + var(--s-10) + var(--s-2)); }
/* "vs previous hour" goes under its change rather than out of the card. */
.hero .ctx { white-space: normal; }
.tile .k { font-size: var(--t-1); font-weight: 500; color:var(--mute); padding-right: 36px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.tile .k { font-size: var(--t-1); font-weight: 500; color:var(--mute); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.line { display:flex; flex-wrap:wrap; align-items:baseline; column-gap: var(--s-3); min-width: 0; }
/*
@@ -106,15 +80,7 @@ before 110, Safari before 16) cannot work the `min()` out, and the figure takes
parent's size.
*/
.v { --fit: calc((100cqi - var(--len, 1) * 1px) / (var(--len, 1) * 0.62)); white-space: nowrap; }
.tile .v { --size: var(--t-6); font:600 var(--t-6)/1.3 var(--font-mono); font-size: min(var(--size), var(--fit)); letter-spacing:-.01em; color:var(--ink); }
/*
`Total Queries` at 30 px, a deliberate local exception: the type scale ends at
`--t-7` (22 px, the section title) and nothing else in the console needs this size,
so it is not a token. It is the size of the figure read first, on one screen.
It goes AFTER `.tile .v` on purpose: same specificity, so the last one wins.
*/
.hero .v { --size: 30px; font-size: 30px; font-size: min(var(--size), var(--fit)); line-height:1.15; margin-top: var(--s-2); }
.tile .v { --size: var(--t-6); font-family: var(--font-ui); font-weight: 600; line-height: 28px; font-variant-numeric: tabular-nums; font-size: min(var(--size), var(--fit)); letter-spacing: -0.24px; color:var(--ink); }
/* With no data the figure is a dash, and dimmed: it is not a value. */
.noData { color: var(--faint); }
@@ -255,18 +221,12 @@ was 159 px and every share had to wrap under its figure.
At 1180 `Queries` takes the whole width and the week and `Server` sit side by side
under it: beside the chart, the week's cells were under 8 px wide.
At 860 the ten compact cards go two by two under `Total Queries`, which takes the
whole row, and the week and `Server` stack.
At 860 the week and `Server` stack.
At 720 the lists go to one column.
*/
@media (max-width: 1360px) {
.lists { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tiles { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.hero { grid-row: auto; grid-column: 1 / -1; }
.hero .body { flex-direction: row; align-items: flex-end; gap: var(--s-11); }
.hero .text { flex: none; }
.hero .spark { flex: 1; height: var(--s-12); }
}
@media (max-width: 1180px) {
@@ -275,7 +235,6 @@ At 720 the lists go to one column.
}
@media (max-width: 860px) {
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.side { grid-template-columns: minmax(0, 1fr); }
}
@@ -285,9 +244,6 @@ At 720 the lists go to one column.
@media (max-width: 560px) {
.hint { display: none; }
.hero .body { flex-direction: column; align-items: stretch; gap: var(--s-3); }
.hero .spark { width: 100%; height: calc(var(--s-11) + var(--s-10) + var(--s-2)); }
.fig { grid-template-columns: minmax(min-content, max-content) minmax(var(--s-11), calc(1.5 * var(--s-12))); }
}
-3
View File
@@ -409,8 +409,6 @@ export function Dashboard({
total={total}
changes={shift}
against={against}
chart={view?.mainChartData}
hero
onOpen={cardJump(TOTAL)}
/>
{FIGURES.map((m) => (
@@ -421,7 +419,6 @@ export function Dashboard({
total={total}
changes={shift}
against={against}
chart={view?.mainChartData}
onOpen={cardJump(m)}
/>
))}
+16 -30
View File
@@ -1,9 +1,8 @@
import type { ChartData, Stats } from '../../api/dashboard'
import type { Stats } from '../../api/dashboard'
import { num2, percentage, type Metric } from './metrics'
import { formatChange, type Changes } from './compare'
import { tokenForLabel } from './palette'
import { Icon, type IconName } from '../../ui/Icon'
import { Spark } from './Spark'
import styles from './Dashboard.module.css'
/*
@@ -62,10 +61,6 @@ function Change({ ratio, against, long = false }: { ratio?: number; against: str
)
}
function series(chart: ChartData | undefined, label: string): number[] {
return chart?.datasets.find((d) => d.label === label)?.data.map(Number) ?? []
}
/* One glyph per figure, in the series' colour: the icon tile of the KPI strip. */
const GLYPH: Record<string, IconName> = {
totalQueries: 'dashboard', totalClients: 'admin', totalNoError: 'check', totalServerFailure: 'warning',
@@ -79,8 +74,6 @@ export function StatCard({
total,
changes,
against,
chart,
hero = false,
onOpen,
}: {
m: Metric
@@ -89,10 +82,6 @@ export function StatCard({
changes: Changes | null
/** What the change is against, in words ("previous hour"). */
against: string
/** The main chart as drawn, for the trend. */
chart?: ChartData
/** `Total Queries`: the larger card, which also says what the change is against. */
hero?: boolean
/** The card's jump to Query Logs (`Metric.open`), when it has one and Query Logs
* is there to land on. */
onOpen?: () => void
@@ -101,25 +90,22 @@ export function StatCard({
const share = m.pct && s ? percentage(s[m.k], total) : null
const ratio = s ? changes?.[m.k] : undefined
return (
<div className={`${styles.tile} ${hero ? styles.hero : styles.fig}`} style={{ ['--tc' as string]: colour }}>
<span className={styles.ico} aria-hidden="true"><Icon name={GLYPH[m.k] ?? 'dashboard'} size={15} /></span>
<div className={styles.k}>{m.label}</div>
<div className={styles.body}>
<div className={styles.text}>
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
true figure, and here it would be a lie. */}
<div className={styles.line}>
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '\u2014'} />
{share != null && <span className={styles.p}>{share}</span>}
</div>
{/* The gap is a HARD space so a card with nothing to add keeps the line's
height and its neighbours' figures stay level. */}
<div className={styles.ctx}>
<Change ratio={ratio} against={against} long={hero} />
{ratio == null && '\u00a0'}
</div>
<div className={styles.tile} style={{ ['--tc' as string]: colour }}>
<span className={styles.ico} aria-hidden="true"><Icon name={GLYPH[m.k] ?? 'dashboard'} size={18} /></span>
<div className={styles.text}>
<div className={styles.k}>{m.label}</div>
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
true figure, and here it would be a lie. */}
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '\u2014'} />
{/* The hint under the figure: its share of the total and its change. The gap is
a HARD space so a card with nothing to add keeps the line's height and its
neighbours' figures stay level. */}
<div className={styles.ctx}>
{share != null && <span className={styles.p}>{share}</span>}
{share != null && ratio != null && ' '}
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
{share == null && ratio == null && '\u00a0'}
</div>
<Spark data={series(chart, m.series)} colour={colour} className={styles.spark} />
</div>
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
the whole card, so the card's own reading order stays as it is; its name
+55
View File
@@ -43,3 +43,58 @@
*/
max-width: 720px;
}
/*
The switch. It is the same `input[type=checkbox]`, redrawn: the track is the
input's own box and the knob is its offset inset shadow, so neither an extra
element nor a pseudo-element the browser paints its own way is needed.
*/
.toggle input {
width: var(--ctrl-track-w);
height: var(--ctrl-track-h);
border-radius: var(--r-full);
background: var(--field);
background-image: none;
box-shadow:
var(--sunken),
inset calc(var(--ctrl-track-w) - var(--ctrl-track-h)) 0 0 -1px transparent;
position: relative;
transition:
background-color var(--t-fast),
border-color var(--t-fast);
}
/* The knob: a circle that travels from one end of the track to the other. */
.toggle input::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: calc(var(--ctrl-track-h) - 6px);
height: calc(var(--ctrl-track-h) - 6px);
border-radius: var(--r-full);
background: var(--mute);
box-shadow: var(--shadow-knob);
transition: transform var(--t-medium), background var(--t-fast);
}
.toggle input:checked {
background-color: var(--acc);
background-image: none;
border-color: var(--acc-ink);
box-shadow: var(--edge-strong);
}
.toggle input:checked::after {
background: var(--on-acc);
transform: translateX(calc(var(--ctrl-track-w) - var(--ctrl-track-h)));
}
.toggle input:active:not(:disabled) { transform: none; }
.toggle .help { margin-left: calc(var(--ctrl-track-w) + var(--s-6)); }
@media (prefers-reduced-motion: reduce) {
.toggle input,
.toggle input::after { transition: none; }
}
+27 -43
View File
@@ -6,23 +6,22 @@ import { Toast } from './Toast'
afterEach(() => vi.useRealTimers())
function Host({ returnFocus, duration }: { returnFocus?: () => void; duration?: number }) {
function Host({ duration, hold }: { duration?: number; hold?: boolean }) {
const [open, setOpen] = useState(true)
const [done, setDone] = useState(false)
return (
<>
<input aria-label="Elsewhere" />
<Toast
message={open ? (done ? 'Done.' : 'Deleted.') : null}
action={open && !done ? { label: 'Undo', onClick: () => setDone(true) } : undefined}
duration={duration}
returnFocus={returnFocus}
onDismiss={() => setOpen(false)}
/>
</>
<Toast
message={open ? (done ? 'Done.' : 'Deleted.') : null}
action={open && !done ? { label: 'Undo', onClick: () => setDone(true) } : undefined}
duration={duration}
hold={hold}
onDismiss={() => setOpen(false)}
/>
)
}
/* The notice is ReUI's sonner: it draws it a moment after the state changes and keeps
a closing one in the page while it animates out, so the controls are waited for. */
describe('Toast', () => {
it('its live region is in the page while closed, and empty', () => {
render(<Toast message={null} onDismiss={() => {}} />)
@@ -30,18 +29,11 @@ describe('Toast', () => {
expect(screen.queryByRole('button')).toBeNull()
})
it('says its message in the region, with a focusable action and a close button', async () => {
it('says its message in the region, with an action and a close button', async () => {
const run = vi.fn()
render(<Toast message="Deleted." action={{ label: 'Undo', onClick: run }} onDismiss={() => {}} />)
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
/* sonner mounts the notice a moment after: the action is waited for. */
await screen.findByRole('button', { name: 'Undo' })
/* sonner's own wrapper is a tab stop before the action: one Tab more than the
hand-made notice had. */
await userEvent.tab()
await userEvent.tab()
expect(screen.getByRole('button', { name: 'Undo' })).toHaveFocus()
await userEvent.keyboard('{Enter}')
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
expect(run).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
})
@@ -51,34 +43,26 @@ describe('Toast', () => {
expect(await screen.findByRole('button', { name: 'Undo' })).toBeDisabled()
})
it('goes by itself after its duration, and holds while the focus is inside', async () => {
vi.useFakeTimers()
it('the close button dismisses it', async () => {
render(<Host />)
await userEvent.click(await screen.findByRole('button', { name: 'Dismiss' }))
expect(screen.getByRole('status')).toBeEmptyDOMElement()
})
it('goes by itself after its duration', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
render(<Host duration={1000} />)
await act(async () => { await vi.advanceTimersByTimeAsync(100) })
act(() => screen.getByRole('button', { name: 'Undo' }).focus())
act(() => vi.advanceTimersByTime(5000))
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
act(() => screen.getByLabelText('Elsewhere').focus())
act(() => vi.advanceTimersByTime(1000))
await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
expect(screen.getByRole('status')).toBeEmptyDOMElement()
})
it('Escape dismisses it, and the focus goes where the page says', async () => {
const back = vi.fn(() => screen.getByLabelText('Elsewhere').focus())
render(<Host returnFocus={back} />)
await screen.findByRole('button', { name: 'Undo' })
act(() => screen.getByRole('button', { name: 'Undo' }).focus())
await userEvent.keyboard('{Escape}')
expect(screen.getByRole('status')).toBeEmptyDOMElement()
expect(back).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Elsewhere')).toHaveFocus()
})
it('losing its action while focused gives the focus back too', async () => {
const back = vi.fn(() => screen.getByLabelText('Elsewhere').focus())
render(<Host returnFocus={back} />)
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
expect(screen.getByRole('status')).toHaveTextContent('Done.')
expect(back).toHaveBeenCalledOnce()
it('stays open while it is held, whatever the duration', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
render(<Host duration={1000} hold />)
await act(async () => { await vi.advanceTimersByTimeAsync(100) })
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
})
})
+45 -156
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from 'react'
import { useEffect, useRef, type ReactNode } from 'react'
import { toast } from 'sonner'
import { Toaster } from '@/components/ui/sonner'
import { Icon } from './Icon'
@@ -11,27 +11,19 @@ user, the deletion of rules in the table of Blocking › Rules (CONVENTIONS.md,
deviation 2). Every other destructive action of the console still asks first
(`ui/Confirm`).
What it owes, and why:
It is ReUI's `sonner`: it places the notice, animates it, pauses its clock while the
pointer is over it and takes it away when the clock runs out. What stays here:
- **The message is a live region that is always in the page** (`role="status"`),
empty while closed. A region born together with its text is often not announced.
- **The action is a real button**, reached with Tab and named by its text. When it
needs a permission the session lacks it is drawn with the padlock and says which,
as every `PermissionButton` does.
- **It goes by itself after `duration`, but not while it is being used**: the
pointer over it or the focus inside it holds the clock (WCAG 2.2.1), and the
clock starts again whole when they leave. `timerKey` restarts it too, for a
notice that gained something. Escape and the close button dismiss it.
- **The focus is not lost with it.** If it closes, or loses its action, while the
focus was inside it, the focus goes to `returnFocus`: the element that held it is
gone, and the browser would leave it on the page's body.
- **It moves only for those who want motion**: it rises into place, and with
`prefers-reduced-motion` it simply appears.
The notice's own copy of the text is `aria-hidden`, so it is said once.
- **The action is a real button**, named by its text. When it needs a permission the
session lacks it is drawn with the padlock and says which, as every
`PermissionButton` does.
The notice itself is drawn by ReUI's `sonner` (placement, stacking, motion); the
clock, the hold and the focus rules stay HERE, because sonner has none of them. Its
text is `aria-hidden` there: the live region is a separate element that never leaves
the page, so it is announced however sonner mounts its own.
What this no longer does, because sonner does not: hold the clock while the keyboard
focus is inside the notice, restart it when the notice gained something, and give the
focus back to a named element when the notice goes.
*/
export interface ToastAction {
@@ -48,125 +40,27 @@ const DEFAULT_MS = 8000
under the same one would be removed as soon as it appeared. */
let counter = 0
/* What sonner draws: the text (hidden from assistive technology, the live region
says it), the action and the dismiss button. */
function Body({
message,
action,
actionRef,
onDismiss,
onUse,
onFocusIn,
onFocusOut,
}: {
message: ReactNode
action?: ToastAction
actionRef?: Ref<HTMLButtonElement>
onDismiss: () => void
onUse: (using: boolean) => void
onFocusIn: () => void
onFocusOut: (to: Node | null) => void
}) {
const box = useRef<HTMLDivElement>(null)
return (
<div
ref={box}
className="flex w-(--width) max-w-[calc(100vw-2rem)] items-center gap-3 rounded-lg border border-border bg-popover py-2 pr-2 pl-4 text-sm text-popover-foreground shadow-md"
onMouseEnter={() => onUse(true)}
onMouseLeave={() => onUse(false)}
onFocus={onFocusIn}
onBlur={(e) => {
const to = e.relatedTarget as Node | null
if (to != null && box.current?.contains(to)) return
onFocusOut(to)
}}
>
<p className="m-0 min-w-0 flex-1 leading-snug break-words" aria-hidden="true">
{message}
</p>
{action != null && (
<PermissionButton
ref={actionRef}
size="sm"
variant="primary"
disabled={action.disabled}
permission={action.permission}
onClick={action.onClick}
>
{action.label}
</PermissionButton>
)}
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label="Dismiss"
title="Dismiss"
onClick={onDismiss}
>
<Icon name="close" size={14} />
</button>
</div>
)
}
export function Toast({
message,
action,
onDismiss,
duration = DEFAULT_MS,
timerKey,
hold = false,
returnFocus,
actionRef,
}: {
/** `null` while closed. */
message: ReactNode | null
action?: ToastAction
onDismiss: () => void
duration?: number
/** A new value restarts the clock. */
timerKey?: unknown
/** Keeps it open regardless of the clock: while its action is running, say. */
hold?: boolean
returnFocus?: () => void
actionRef?: Ref<HTMLButtonElement>
}) {
const open = message != null
const [pointer, setPointer] = useState(false)
const [focused, setFocused] = useState(false)
/* Whether the focus was inside when the content changed. A focused button that is
removed leaves no blur with a destination, so only a blur to somewhere real
clears it. */
const hadFocus = useRef(false)
const dismiss = useRef(onDismiss)
const back = useRef(returnFocus)
useEffect(() => {
dismiss.current = onDismiss
back.current = returnFocus
})
/* Closing forgets the pointer and the focus: the box under them is gone, and no
leave or blur may come to say so. Adjusted while rendering, as React advises
for state that follows a prop. */
const [wasOpen, setWasOpen] = useState(open)
if (open !== wasOpen) {
setWasOpen(open)
if (!open) {
setPointer(false)
setFocused(false)
}
}
const paused = pointer || focused || hold
useEffect(() => {
if (!open || paused) return
const t = setTimeout(() => dismiss.current(), duration)
return () => clearTimeout(t)
}, [open, paused, duration, timerKey])
/* The notice is shown, updated and removed through sonner. Its own clock is off
(`Infinity`): the one above decides. */
const hasAction = open && action != null
const current = useRef<string | null>(null)
useEffect(() => {
if (!open) {
@@ -175,25 +69,44 @@ export function Toast({
return
}
current.current ??= `console-toast-${++counter}`
const id = current.current
toast.custom(
() => (
<Body
message={message}
action={action}
actionRef={actionRef}
onDismiss={() => dismiss.current()}
onUse={setPointer}
onFocusIn={() => {
hadFocus.current = true
setFocused(true)
}}
onFocusOut={(to) => {
setFocused(false)
if (to != null) hadFocus.current = false
}}
/>
<div className="flex w-(--width) max-w-[calc(100vw-2rem)] items-center gap-3 rounded-lg border border-border bg-popover py-2 pr-2 pl-4 text-sm text-popover-foreground shadow-md">
<p className="m-0 min-w-0 flex-1 leading-snug break-words" aria-hidden="true">
{message}
</p>
{action != null && (
<PermissionButton
size="sm"
variant="primary"
disabled={action.disabled}
permission={action.permission}
onClick={action.onClick}
>
{action.label}
</PermissionButton>
)}
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label="Dismiss"
title="Dismiss"
onClick={() => dismiss.current()}
>
<Icon name="close" size={14} />
</button>
</div>
),
{ id: current.current, duration: Infinity, unstyled: true },
{
id,
duration: hold ? Infinity : duration,
unstyled: true,
/* The clock running out, or the notice swiped away, is the page's business
too: the screen forgets what the notice was about. */
onAutoClose: () => dismiss.current(),
onDismiss: () => dismiss.current(),
},
)
})
@@ -205,30 +118,6 @@ export function Toast({
[],
)
/* Once it is closed, or has lost its action, the button the focus was on is gone. */
useLayoutEffect(() => {
if (hasAction) return
/* Sonner keeps a closing notice in the page while it animates out, so the
element is still there: what says the focus is lost is that it WAS inside. */
if (hadFocus.current) {
hadFocus.current = false
back.current?.()
}
}, [hasAction])
/* Escape dismisses it from wherever the focus is while it is open. */
useEffect(() => {
if (!open) return
const escape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
dismiss.current()
}
}
document.addEventListener('keydown', escape)
return () => document.removeEventListener('keydown', escape)
}, [open])
return (
<>
<Toaster position="bottom-center" />