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 ( <>