diff --git a/docs/reui-migration-plan.md b/docs/reui-migration-plan.md
index 7edb2ef..2056748 100644
--- a/docs/reui-migration-plan.md
+++ b/docs/reui-migration-plan.md
@@ -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` у `
`, `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` в `
`), а DataGrid отдаёт `
`/`
` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
Сборка, `tsc`, `lint:colours`, `oxlint` проходят. Тесты (`npx vitest run --maxWorkers=3`): 28 падений из 2076, все они падали и до миграции (локаль Windows и отсутствие коммита).
diff --git a/src/screens/blocking/Rules.tsx b/src/screens/blocking/Rules.tsx
index e392b83..c4f427e 100644
--- a/src/screens/blocking/Rules.tsx
+++ b/src/screens/blocking/Rules.tsx
@@ -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(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(null)
- const [undoKey, setUndoKey] = useState(0)
- const undoButton = useRef(null)
const search = useRef(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)}
/>
diff --git a/src/screens/blocking/Rules.undo.test.tsx b/src/screens/blocking/Rules.undo.test.tsx
index 6d50eaa..9138248 100644
--- a/src/screens/blocking/Rules.undo.test.tsx
+++ b/src/screens/blocking/Rules.undo.test.tsx
@@ -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()
- 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()
- 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()
await deleteRule('ads.test')
diff --git a/src/screens/dashboard/Dashboard.module.css b/src/screens/dashboard/Dashboard.module.css
index 21be556..11e9dea 100644
--- a/src/screens/dashboard/Dashboard.module.css
+++ b/src/screens/dashboard/Dashboard.module.css
@@ -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))); }
}
diff --git a/src/screens/dashboard/Dashboard.tsx b/src/screens/dashboard/Dashboard.tsx
index 5e1dec6..8749b04 100644
--- a/src/screens/dashboard/Dashboard.tsx
+++ b/src/screens/dashboard/Dashboard.tsx
@@ -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)}
/>
))}
diff --git a/src/screens/dashboard/Figures.tsx b/src/screens/dashboard/Figures.tsx
index 9197712..235ff3d 100644
--- a/src/screens/dashboard/Figures.tsx
+++ b/src/screens/dashboard/Figures.tsx
@@ -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 = {
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 (
-
-
-
{m.label}
-
-
- {/* 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. */}
-
-
- {share != null && {share}}
-
- {/* The gap is a HARD space so a card with nothing to add keeps the line's
- height and its neighbours' figures stay level. */}
-
-
- {ratio == null && '\u00a0'}
-
+
+
+
+
{m.label}
+ {/* 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. */}
+
+ {/* 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. */}
+
{/* 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
diff --git a/src/ui/Check.module.css b/src/ui/Check.module.css
index 3745d91..c1e60da 100644
--- a/src/ui/Check.module.css
+++ b/src/ui/Check.module.css
@@ -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; }
+}
diff --git a/src/ui/Toast.test.tsx b/src/ui/Toast.test.tsx
index cc3a1ef..47f8cb7 100644
--- a/src/ui/Toast.test.tsx
+++ b/src/ui/Toast.test.tsx
@@ -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 (
- <>
-
- setDone(true) } : undefined}
- duration={duration}
- returnFocus={returnFocus}
- onDismiss={() => setOpen(false)}
- />
- >
+ 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( {}} />)
@@ -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( {}} />)
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()
+ 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()
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()
- 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()
- 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()
+ await act(async () => { await vi.advanceTimersByTimeAsync(100) })
+ await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
+ expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
})
})
diff --git a/src/ui/Toast.tsx b/src/ui/Toast.tsx
index 76bf916..4fbbf93 100644
--- a/src/ui/Toast.tsx
+++ b/src/ui/Toast.tsx
@@ -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
- onDismiss: () => void
- onUse: (using: boolean) => void
- onFocusIn: () => void
- onFocusOut: (to: Node | null) => void
-}) {
- const box = useRef(null)
- return (
-
- )
-}
-
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
}) {
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(null)
useEffect(() => {
if (!open) {
@@ -175,25 +69,44 @@ export function Toast({
return
}
current.current ??= `console-toast-${++counter}`
+ const id = current.current
toast.custom(
() => (
- dismiss.current()}
- onUse={setPointer}
- onFocusIn={() => {
- hadFocus.current = true
- setFocused(true)
- }}
- onFocusOut={(to) => {
- setFocused(false)
- if (to != null) hadFocus.current = false
- }}
- />
+
),
- { 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 (
<>