From f24f4972ba613493763a338785971664d5787c61 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 27 Oct 2026 01:16:12 +0700 Subject: [PATCH] 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 --- docs/reui-migration-plan.md | 4 +- src/screens/blocking/Rules.tsx | 31 +--- src/screens/blocking/Rules.undo.test.tsx | 26 +-- src/screens/dashboard/Dashboard.module.css | 86 +++------ src/screens/dashboard/Dashboard.tsx | 3 - src/screens/dashboard/Figures.tsx | 46 ++--- src/ui/Check.module.css | 55 ++++++ src/ui/Toast.test.tsx | 70 +++---- src/ui/Toast.tsx | 201 +++++---------------- 9 files changed, 170 insertions(+), 352 deletions(-) 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. */} +
+ {share != null && {share}} + {share != null && ratio != null && ' '} + + {share == null && ratio == null && '\u00a0'}
-
{/* 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 ( -
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) - }} - > - - {action != null && ( - - {action.label} - - )} - -
- ) -} - 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 - }} - /> +
+ + {action != null && ( + + {action.label} + + )} + +
), - { 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 ( <>