Toast on sonner's behaviour; Dashboard figures as the canvas's KPI strip
Toast: sonner draws the notice and runs its clock, pauses it under the pointer and removes it. ui/Toast keeps the live region and the action with its permission check. Gone, because sonner has none of it: holding the clock while the keyboard focus is inside, restarting it when the notice gains a rule, Escape, and handing the focus back to a named element. The two Rules tests and three Toast tests about those are removed. Dashboard: the eleven figures are one KPI strip, every cell the same weight, an icon tile on the left, the name, the figure at 24 px and under it the share and the change. The sparklines and the larger Total Queries card are gone; a card still opens Query Logs. Check.module.css gets the switch drawing back: Blocking > Lists composes it, and dev/css-dead.mjs reported the missing class. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
4c0a5de7e7
commit
f24f4972ba
9 files changed
+170
-352
No files matched your search
@@ -67,7 +67,7 @@
|
|||||||
| Menu | ReUI Popover (роли и клавиатура меню остались в `Menu.tsx`) |
|
| Menu | ReUI Popover (роли и клавиатура меню остались в `Menu.tsx`) |
|
||||||
| Select | ReUI Popover (поиск, группы и набор по буквам сохранены) |
|
| Select | ReUI Popover (поиск, группы и набор по буквам сохранены) |
|
||||||
| Table | Все 23 таблицы описаны колонками TanStack: `ui/DataTable.tsx` строит ReUI `DataGrid` из `columns` и `data`, а то, что DataGrid не принимает (класс строки или ячейки, `aria-sort` у `<th>`, `style`, `colSpan`, `role`, `hidden`), навешивает на нарисованные им элементы после каждого рендера. Определения колонок держат стабильную идентичность (иначе ячейки пересоздавались бы при каждом рендере родителя), встроенная пагинация DataGrid выключена (`manualPagination`). В Blocking › Lists строка-заголовок группы это строка, первая ячейка которой растянута на четыре колонки (`colSpan`, `role="columnheader"`, `scope="colgroup"`), а остальные три скрыты. Режим карточек на телефоне, подписи колонок и сортировка «как в оригинале» работают поверх той же таблицы (проверено в браузере на моке: 1440 px таблица, 390 px карточки, без горизонтальной прокрутки). |
|
| Table | Все 23 таблицы описаны колонками TanStack: `ui/DataTable.tsx` строит ReUI `DataGrid` из `columns` и `data`, а то, что DataGrid не принимает (класс строки или ячейки, `aria-sort` у `<th>`, `style`, `colSpan`, `role`, `hidden`), навешивает на нарисованные им элементы после каждого рендера. Определения колонок держат стабильную идентичность (иначе ячейки пересоздавались бы при каждом рендере родителя), встроенная пагинация DataGrid выключена (`manualPagination`). В Blocking › Lists строка-заголовок группы это строка, первая ячейка которой растянута на четыре колонки (`colSpan`, `role="columnheader"`, `scope="colgroup"`), а остальные три скрыты. Режим карточек на телефоне, подписи колонок и сортировка «как в оригинале» работают поверх той же таблицы (проверено в браузере на моке: 1440 px таблица, 390 px карточки, без горизонтальной прокрутки). |
|
||||||
| Toast | свой (фокус возвращается, пауза под курсором, действие с правом); цвета и шрифты из темы ReUI |
|
| Toast | Уведомление целиком на ReUI `sonner`: его таймер, пауза под курсором, расстановка и анимация. В `ui/Toast.tsx` остались только живая область `role="status"` (всегда в странице, пустая, пока уведомление закрыто) и действие с проверкой прав. Убрано, потому что у sonner этого нет: пауза таймера, пока фокус клавиатуры внутри уведомления, перезапуск таймера при пополнении, закрытие по Escape и возврат фокуса на названный элемент. Тесты на эти поведения удалены. |
|
||||||
| Тема | палитра shnt Ops в `tokens.css`, Geist, шкала 12/13/14 px, радиусы 8/10/14 px |
|
| Тема | палитра 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`). |
|
| Toast | Уведомление рисует ReUI `sonner`. Часы, пауза под курсором и фокусом, Escape и возврат фокуса остались в `ui/Toast.tsx`. Живая область `role="status"` по-прежнему всегда в странице. Цена замены: перед кнопкой Undo появился один лишний Tab-стоп (обёртка sonner), закрывающееся уведомление остаётся в DOM около 200 мс, а тесты Rules ждут его асинхронно (`findBy`, `waitFor`). |
|
||||||
| Карточки Dashboard | Одиннадцать карточек собраны в одну панель KPI как на холсте: границы-волоски вместо рамок, плитка с иконкой, подпись без капса. Спарклайны, доля, изменение и переход в Query Logs сохранены. |
|
| Карточки Dashboard | Одиннадцать карточек в одной панели KPI точно как на холсте: ячейки равного веса, плитка-иконка 40 px слева, подпись, значение 24 px Geist, под ним доля и изменение (это подсказка холста). Убраны спарклайны и большая карточка Total Queries. Переход в Query Logs по клику на карточку сохранён. Проверено в браузере на моке: 1440 px шесть колонок по 190 px, 390 px одна колонка, без горизонтальной прокрутки. |
|
||||||
| Table | Таблица строится на примитивах ReUI (`TableHeader`, `TableBody`, `TableFooter` и вид DataGrid). Перевод на TanStack `DataGrid` не сделан: строки и ячейки 23 таблиц несут свои атрибуты (классы строк, `colSpan`, `aria-sort` в `<th>`), а DataGrid отдаёт `<tr>`/`<td>` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
|
| Table | Таблица строится на примитивах ReUI (`TableHeader`, `TableBody`, `TableFooter` и вид DataGrid). Перевод на TanStack `DataGrid` не сделан: строки и ячейки 23 таблиц несут свои атрибуты (классы строк, `colSpan`, `aria-sort` в `<th>`), а DataGrid отдаёт `<tr>`/`<td>` без хуков для них. Режим карточек на телефоне и подписи колонок держатся на тех же атрибутах. |
|
||||||
|
|
||||||
Сборка, `tsc`, `lint:colours`, `oxlint` проходят. Тесты (`npx vitest run --maxWorkers=3`): 28 падений из 2076, все они падали и до миграции (локаль Windows и отсутствие коммита).
|
Сборка, `tsc`, `lint:colours`, `oxlint` проходят. Тесты (`npx vitest run --maxWorkers=3`): 28 падений из 2076, все они падали и до миграции (локаль Windows и отсутствие коммита).
|
||||||
@@ -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 { readRuleExport } from '../../api/blocking'
|
||||||
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
|
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
|
||||||
import { SectionHeader } from '../../ui/SectionHeader'
|
import { SectionHeader } from '../../ui/SectionHeader'
|
||||||
@@ -132,15 +132,9 @@ export function Rules({
|
|||||||
treeContext.current = { view, viewBlocked, viewAllowed }
|
treeContext.current = { view, viewBlocked, viewAllowed }
|
||||||
}, [view, viewBlocked, viewAllowed])
|
}, [view, viewBlocked, viewAllowed])
|
||||||
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
|
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
|
||||||
/* The undo notice: the rules it can put back, and where it is (`undo.ts`).
|
/* 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. */
|
|
||||||
const [undo, setUndo] = useState<UndoState | null>(null)
|
const [undo, setUndo] = useState<UndoState | null>(null)
|
||||||
const [undoKey, setUndoKey] = useState(0)
|
|
||||||
const undoButton = useRef<HTMLButtonElement>(null)
|
|
||||||
const search = useRef<HTMLInputElement>(null)
|
const search = useRef<HTMLInputElement>(null)
|
||||||
/* The row that held the focus is gone once the table is read again: the focus
|
|
||||||
goes to Undo then, and not to the page's body. */
|
|
||||||
const focusUndo = useRef(false)
|
|
||||||
const mounted = useRef(true)
|
const mounted = useRef(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mounted.current = true
|
mounted.current = true
|
||||||
@@ -193,21 +187,6 @@ export function Rules({
|
|||||||
void load()
|
void load()
|
||||||
}, [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
|
/* 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
|
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. */
|
value is compared, because `readRuleParam` already reads nonsense as All. */
|
||||||
@@ -297,8 +276,6 @@ export function Rules({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setUndo((u) => joinDeleted(u, rule))
|
setUndo((u) => joinDeleted(u, rule))
|
||||||
setUndoKey((k) => k + 1)
|
|
||||||
focusUndo.current = true
|
|
||||||
changed()
|
changed()
|
||||||
await load(true)
|
await load(true)
|
||||||
}
|
}
|
||||||
@@ -316,7 +293,6 @@ export function Rules({
|
|||||||
if (!mounted.current) return
|
if (!mounted.current) return
|
||||||
if (result.failure == null) {
|
if (result.failure == null) {
|
||||||
setUndo({ rules: deleted, phase: 'restored' })
|
setUndo({ rules: deleted, phase: 'restored' })
|
||||||
setUndoKey((k) => k + 1)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setUndo(null)
|
setUndo(null)
|
||||||
@@ -633,9 +609,6 @@ export function Rules({
|
|||||||
}
|
}
|
||||||
hold={undo?.phase === 'restoring'}
|
hold={undo?.phase === 'restoring'}
|
||||||
duration={undo?.phase === 'restored' ? RESTORED_MS : UNDO_MS}
|
duration={undo?.phase === 'restored' ? RESTORED_MS : UNDO_MS}
|
||||||
timerKey={undoKey}
|
|
||||||
actionRef={undoButton}
|
|
||||||
returnFocus={() => search.current?.focus()}
|
|
||||||
onDismiss={() => setUndo(null)}
|
onDismiss={() => setUndo(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -130,16 +130,7 @@ describe('Rules: Delete with Undo', () => {
|
|||||||
expect(screen.getByText('No rules')).toBeInTheDocument()
|
expect(screen.getByText('No rules')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Escape inside the notice dismisses it', async () => {
|
it('the notice goes after a few seconds, and not while the pointer is on it', async () => {
|
||||||
server(['a.test'], [])
|
|
||||||
render(<Rules token="T" permissions={undefined} />)
|
|
||||||
await deleteRule('a.test')
|
|
||||||
;(await screen.findByRole('button', { name: 'Undo' })).focus()
|
|
||||||
await userEvent.keyboard('{Escape}')
|
|
||||||
expect(status()).toBeEmptyDOMElement()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the notice goes after a few seconds, and not while the pointer is on it', async () => {
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime })
|
const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime })
|
||||||
server(['a.test'], [])
|
server(['a.test'], [])
|
||||||
@@ -177,20 +168,7 @@ describe('Rules: Delete with Undo', () => {
|
|||||||
expect(add).not.toHaveBeenCalled()
|
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 () => {
|
it('without Modify on the list the Delete asks first, as upstream; with it, it does not', async () => {
|
||||||
server(['a.test', 'b.test'], [])
|
|
||||||
render(<Rules token="T" permissions={undefined} />)
|
|
||||||
const button = await screen.findByRole('button', { name: 'Delete a.test' })
|
|
||||||
button.focus()
|
|
||||||
await userEvent.keyboard('{Enter}')
|
|
||||||
const undo = await screen.findByRole('button', { name: 'Undo' })
|
|
||||||
await vi.waitFor(() => expect(undo).toHaveFocus())
|
|
||||||
await userEvent.keyboard('{Enter}')
|
|
||||||
await vi.waitFor(() => expect(status()).toHaveTextContent("'a.test' is back in Blocked."))
|
|
||||||
await vi.waitFor(() => expect(screen.getByLabelText('Filter domains')).toHaveFocus())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('without Modify on the list the Delete asks first, as upstream; with it, it does not', async () => {
|
|
||||||
const { remove } = server(['ads.test'], ['ok.test'])
|
const { remove } = server(['ads.test'], ['ok.test'])
|
||||||
render(<Rules token="T" permissions={{ Blocked: P(true), Allowed: P(true, false) }} />)
|
render(<Rules token="T" permissions={{ Blocked: P(true), Allowed: P(true, false) }} />)
|
||||||
await deleteRule('ads.test')
|
await deleteRule('ads.test')
|
||||||
|
|||||||
@@ -17,41 +17,39 @@ and its own pieces.
|
|||||||
.period > * { max-width: 100%; }
|
.period > * { max-width: 100%; }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The eleven figures in one block: `Total Queries` spans both rows, the other ten are
|
The eleven figures in one ReUI KPI strip, as the canvas draws it: a panel, cells told apart
|
||||||
compact cards in two rows of five (`Figures.tsx` says which and why).
|
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 {
|
.tiles {
|
||||||
display:grid; grid-template-columns: minmax(0, 1.15fr) repeat(5, minmax(0, 1fr)); gap: 0;
|
display: grid;
|
||||||
/* ReUI's KPI strip: one panel, cells told apart by hairlines, an icon tile in each. */
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
background: var(--pan); border: 1px solid var(--line); border-radius: var(--r-4); overflow: hidden;
|
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 {
|
.tile {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: transparent;
|
display: flex; align-items: center; gap: var(--s-8);
|
||||||
border: 0;
|
padding: var(--s-9) var(--s-10);
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: 1px 0 0 var(--line2), 0 1px 0 var(--line2);
|
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 {
|
.ico {
|
||||||
position: absolute; top: var(--s-5); right: var(--s-6);
|
flex: none;
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
width: 28px; height: 28px;
|
width: 40px; height: 40px;
|
||||||
border: 1px solid var(--line); border-radius: var(--r-2); background: var(--pan2);
|
border: 1px solid var(--line); border-radius: var(--r-3); background: var(--pan2);
|
||||||
color: var(--tc, var(--ink));
|
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
|
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.
|
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:hover { border-color: var(--tc, var(--acc)); }
|
||||||
.open:focus-visible { outline: none; box-shadow: var(--halo-focus); }
|
.open:focus-visible { outline: none; box-shadow: var(--halo-focus); }
|
||||||
|
|
||||||
/*
|
.tile .k { font-size: var(--t-1); font-weight: 500; color:var(--mute); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
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; }
|
|
||||||
.line { display:flex; flex-wrap:wrap; align-items:baseline; column-gap: var(--s-3); min-width: 0; }
|
.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.
|
parent's size.
|
||||||
*/
|
*/
|
||||||
.v { --fit: calc((100cqi - var(--len, 1) * 1px) / (var(--len, 1) * 0.62)); white-space: nowrap; }
|
.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); }
|
.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); }
|
||||||
|
|
||||||
/*
|
|
||||||
`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); }
|
|
||||||
|
|
||||||
/* With no data the figure is a dash, and dimmed: it is not a value. */
|
/* With no data the figure is a dash, and dimmed: it is not a value. */
|
||||||
.noData { color: var(--faint); }
|
.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
|
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.
|
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
|
At 860 the week and `Server` stack.
|
||||||
whole row, and the week and `Server` stack.
|
|
||||||
|
|
||||||
At 720 the lists go to one column.
|
At 720 the lists go to one column.
|
||||||
*/
|
*/
|
||||||
@media (max-width: 1360px) {
|
@media (max-width: 1360px) {
|
||||||
.lists { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.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) {
|
@media (max-width: 1180px) {
|
||||||
@@ -275,7 +235,6 @@ At 720 the lists go to one column.
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.side { grid-template-columns: 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) {
|
@media (max-width: 560px) {
|
||||||
.hint { display: none; }
|
.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))); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -409,8 +409,6 @@ export function Dashboard({
|
|||||||
total={total}
|
total={total}
|
||||||
changes={shift}
|
changes={shift}
|
||||||
against={against}
|
against={against}
|
||||||
chart={view?.mainChartData}
|
|
||||||
hero
|
|
||||||
onOpen={cardJump(TOTAL)}
|
onOpen={cardJump(TOTAL)}
|
||||||
/>
|
/>
|
||||||
{FIGURES.map((m) => (
|
{FIGURES.map((m) => (
|
||||||
@@ -421,7 +419,6 @@ export function Dashboard({
|
|||||||
total={total}
|
total={total}
|
||||||
changes={shift}
|
changes={shift}
|
||||||
against={against}
|
against={against}
|
||||||
chart={view?.mainChartData}
|
|
||||||
onOpen={cardJump(m)}
|
onOpen={cardJump(m)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 { num2, percentage, type Metric } from './metrics'
|
||||||
import { formatChange, type Changes } from './compare'
|
import { formatChange, type Changes } from './compare'
|
||||||
import { tokenForLabel } from './palette'
|
import { tokenForLabel } from './palette'
|
||||||
import { Icon, type IconName } from '../../ui/Icon'
|
import { Icon, type IconName } from '../../ui/Icon'
|
||||||
import { Spark } from './Spark'
|
|
||||||
import styles from './Dashboard.module.css'
|
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. */
|
/* One glyph per figure, in the series' colour: the icon tile of the KPI strip. */
|
||||||
const GLYPH: Record<string, IconName> = {
|
const GLYPH: Record<string, IconName> = {
|
||||||
totalQueries: 'dashboard', totalClients: 'admin', totalNoError: 'check', totalServerFailure: 'warning',
|
totalQueries: 'dashboard', totalClients: 'admin', totalNoError: 'check', totalServerFailure: 'warning',
|
||||||
@@ -79,8 +74,6 @@ export function StatCard({
|
|||||||
total,
|
total,
|
||||||
changes,
|
changes,
|
||||||
against,
|
against,
|
||||||
chart,
|
|
||||||
hero = false,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
m: Metric
|
m: Metric
|
||||||
@@ -89,10 +82,6 @@ export function StatCard({
|
|||||||
changes: Changes | null
|
changes: Changes | null
|
||||||
/** What the change is against, in words ("previous hour"). */
|
/** What the change is against, in words ("previous hour"). */
|
||||||
against: string
|
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
|
/** The card's jump to Query Logs (`Metric.open`), when it has one and Query Logs
|
||||||
* is there to land on. */
|
* is there to land on. */
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
@@ -101,25 +90,22 @@ export function StatCard({
|
|||||||
const share = m.pct && s ? percentage(s[m.k], total) : null
|
const share = m.pct && s ? percentage(s[m.k], total) : null
|
||||||
const ratio = s ? changes?.[m.k] : undefined
|
const ratio = s ? changes?.[m.k] : undefined
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.tile} ${hero ? styles.hero : styles.fig}`} style={{ ['--tc' as string]: colour }}>
|
<div className={styles.tile} style={{ ['--tc' as string]: colour }}>
|
||||||
<span className={styles.ico} aria-hidden="true"><Icon name={GLYPH[m.k] ?? 'dashboard'} size={15} /></span>
|
<span className={styles.ico} aria-hidden="true"><Icon name={GLYPH[m.k] ?? 'dashboard'} size={18} /></span>
|
||||||
<div className={styles.k}>{m.label}</div>
|
<div className={styles.text}>
|
||||||
<div className={styles.body}>
|
<div className={styles.k}>{m.label}</div>
|
||||||
<div className={styles.text}>
|
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
|
||||||
{/* 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. */}
|
||||||
true figure, and here it would be a lie. */}
|
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '\u2014'} />
|
||||||
<div className={styles.line}>
|
{/* The hint under the figure: its share of the total and its change. The gap is
|
||||||
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '\u2014'} />
|
a HARD space so a card with nothing to add keeps the line's height and its
|
||||||
{share != null && <span className={styles.p}>{share}</span>}
|
neighbours' figures stay level. */}
|
||||||
</div>
|
<div className={styles.ctx}>
|
||||||
{/* The gap is a HARD space so a card with nothing to add keeps the line's
|
{share != null && <span className={styles.p}>{share}</span>}
|
||||||
height and its neighbours' figures stay level. */}
|
{share != null && ratio != null && ' '}
|
||||||
<div className={styles.ctx}>
|
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
||||||
<Change ratio={ratio} against={against} long={hero} />
|
{share == null && ratio == null && '\u00a0'}
|
||||||
{ratio == null && '\u00a0'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Spark data={series(chart, m.series)} colour={colour} className={styles.spark} />
|
|
||||||
</div>
|
</div>
|
||||||
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
{/* 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
|
the whole card, so the card's own reading order stays as it is; its name
|
||||||
|
|||||||
@@ -43,3 +43,58 @@
|
|||||||
*/
|
*/
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The switch. It is the same `input[type=checkbox]`, redrawn: the track is the
|
||||||
|
input's own box and the knob is its offset inset shadow, so neither an extra
|
||||||
|
element nor a pseudo-element the browser paints its own way is needed.
|
||||||
|
*/
|
||||||
|
.toggle input {
|
||||||
|
width: var(--ctrl-track-w);
|
||||||
|
height: var(--ctrl-track-h);
|
||||||
|
border-radius: var(--r-full);
|
||||||
|
background: var(--field);
|
||||||
|
background-image: none;
|
||||||
|
box-shadow:
|
||||||
|
var(--sunken),
|
||||||
|
inset calc(var(--ctrl-track-w) - var(--ctrl-track-h)) 0 0 -1px transparent;
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
background-color var(--t-fast),
|
||||||
|
border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The knob: a circle that travels from one end of the track to the other. */
|
||||||
|
.toggle input::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: calc(var(--ctrl-track-h) - 6px);
|
||||||
|
height: calc(var(--ctrl-track-h) - 6px);
|
||||||
|
border-radius: var(--r-full);
|
||||||
|
background: var(--mute);
|
||||||
|
box-shadow: var(--shadow-knob);
|
||||||
|
transition: transform var(--t-medium), background var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked {
|
||||||
|
background-color: var(--acc);
|
||||||
|
background-image: none;
|
||||||
|
border-color: var(--acc-ink);
|
||||||
|
box-shadow: var(--edge-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked::after {
|
||||||
|
background: var(--on-acc);
|
||||||
|
transform: translateX(calc(var(--ctrl-track-w) - var(--ctrl-track-h)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:active:not(:disabled) { transform: none; }
|
||||||
|
|
||||||
|
.toggle .help { margin-left: calc(var(--ctrl-track-w) + var(--s-6)); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.toggle input,
|
||||||
|
.toggle input::after { transition: none; }
|
||||||
|
}
|
||||||
+27
-43
@@ -6,23 +6,22 @@ import { Toast } from './Toast'
|
|||||||
|
|
||||||
afterEach(() => vi.useRealTimers())
|
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 [open, setOpen] = useState(true)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
return (
|
return (
|
||||||
<>
|
<Toast
|
||||||
<input aria-label="Elsewhere" />
|
message={open ? (done ? 'Done.' : 'Deleted.') : null}
|
||||||
<Toast
|
action={open && !done ? { label: 'Undo', onClick: () => setDone(true) } : undefined}
|
||||||
message={open ? (done ? 'Done.' : 'Deleted.') : null}
|
duration={duration}
|
||||||
action={open && !done ? { label: 'Undo', onClick: () => setDone(true) } : undefined}
|
hold={hold}
|
||||||
duration={duration}
|
onDismiss={() => setOpen(false)}
|
||||||
returnFocus={returnFocus}
|
/>
|
||||||
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', () => {
|
describe('Toast', () => {
|
||||||
it('its live region is in the page while closed, and empty', () => {
|
it('its live region is in the page while closed, and empty', () => {
|
||||||
render(<Toast message={null} onDismiss={() => {}} />)
|
render(<Toast message={null} onDismiss={() => {}} />)
|
||||||
@@ -30,18 +29,11 @@ describe('Toast', () => {
|
|||||||
expect(screen.queryByRole('button')).toBeNull()
|
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()
|
const run = vi.fn()
|
||||||
render(<Toast message="Deleted." action={{ label: 'Undo', onClick: run }} onDismiss={() => {}} />)
|
render(<Toast message="Deleted." action={{ label: 'Undo', onClick: run }} onDismiss={() => {}} />)
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
|
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
|
||||||
/* sonner mounts the notice a moment after: the action is waited for. */
|
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
|
||||||
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}')
|
|
||||||
expect(run).toHaveBeenCalledOnce()
|
expect(run).toHaveBeenCalledOnce()
|
||||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -51,34 +43,26 @@ describe('Toast', () => {
|
|||||||
expect(await screen.findByRole('button', { name: 'Undo' })).toBeDisabled()
|
expect(await screen.findByRole('button', { name: 'Undo' })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes by itself after its duration, and holds while the focus is inside', async () => {
|
it('the close button dismisses it', async () => {
|
||||||
vi.useFakeTimers()
|
render(<Host />)
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Dismiss' }))
|
||||||
|
expect(screen.getByRole('status')).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('goes by itself after its duration', async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
render(<Host duration={1000} />)
|
render(<Host duration={1000} />)
|
||||||
await act(async () => { await vi.advanceTimersByTimeAsync(100) })
|
await act(async () => { await vi.advanceTimersByTimeAsync(100) })
|
||||||
act(() => screen.getByRole('button', { name: 'Undo' }).focus())
|
|
||||||
act(() => vi.advanceTimersByTime(5000))
|
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
|
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
|
||||||
act(() => screen.getByLabelText('Elsewhere').focus())
|
await act(async () => { await vi.advanceTimersByTimeAsync(2000) })
|
||||||
act(() => vi.advanceTimersByTime(1000))
|
|
||||||
expect(screen.getByRole('status')).toBeEmptyDOMElement()
|
expect(screen.getByRole('status')).toBeEmptyDOMElement()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Escape dismisses it, and the focus goes where the page says', async () => {
|
it('stays open while it is held, whatever the duration', async () => {
|
||||||
const back = vi.fn(() => screen.getByLabelText('Elsewhere').focus())
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
render(<Host returnFocus={back} />)
|
render(<Host duration={1000} hold />)
|
||||||
await screen.findByRole('button', { name: 'Undo' })
|
await act(async () => { await vi.advanceTimersByTimeAsync(100) })
|
||||||
act(() => screen.getByRole('button', { name: 'Undo' }).focus())
|
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||||
await userEvent.keyboard('{Escape}')
|
expect(screen.getByRole('status')).toHaveTextContent('Deleted.')
|
||||||
expect(screen.getByRole('status')).toBeEmptyDOMElement()
|
|
||||||
expect(back).toHaveBeenCalledOnce()
|
|
||||||
expect(screen.getByLabelText('Elsewhere')).toHaveFocus()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('losing its action while focused gives the focus back too', async () => {
|
|
||||||
const back = vi.fn(() => screen.getByLabelText('Elsewhere').focus())
|
|
||||||
render(<Host returnFocus={back} />)
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Undo' }))
|
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('Done.')
|
|
||||||
expect(back).toHaveBeenCalledOnce()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+45
-156
@@ -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 { toast } from 'sonner'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { Icon } from './Icon'
|
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
|
deviation 2). Every other destructive action of the console still asks first
|
||||||
(`ui/Confirm`).
|
(`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"`),
|
- **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.
|
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
|
The notice's own copy of the text is `aria-hidden`, so it is said once.
|
||||||
needs a permission the session lacks it is drawn with the padlock and says which,
|
- **The action is a real button**, named by its text. When it needs a permission the
|
||||||
as every `PermissionButton` does.
|
session lacks it is drawn with the padlock and says which, as every
|
||||||
- **It goes by itself after `duration`, but not while it is being used**: the
|
`PermissionButton` does.
|
||||||
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 itself is drawn by ReUI's `sonner` (placement, stacking, motion); the
|
What this no longer does, because sonner does not: hold the clock while the keyboard
|
||||||
clock, the hold and the focus rules stay HERE, because sonner has none of them. Its
|
focus is inside the notice, restart it when the notice gained something, and give the
|
||||||
text is `aria-hidden` there: the live region is a separate element that never leaves
|
focus back to a named element when the notice goes.
|
||||||
the page, so it is announced however sonner mounts its own.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ToastAction {
|
export interface ToastAction {
|
||||||
@@ -48,125 +40,27 @@ const DEFAULT_MS = 8000
|
|||||||
under the same one would be removed as soon as it appeared. */
|
under the same one would be removed as soon as it appeared. */
|
||||||
let counter = 0
|
let counter = 0
|
||||||
|
|
||||||
/* What sonner draws: the text (hidden from assistive technology, the live region
|
|
||||||
says it), the action and the dismiss button. */
|
|
||||||
function Body({
|
|
||||||
message,
|
|
||||||
action,
|
|
||||||
actionRef,
|
|
||||||
onDismiss,
|
|
||||||
onUse,
|
|
||||||
onFocusIn,
|
|
||||||
onFocusOut,
|
|
||||||
}: {
|
|
||||||
message: ReactNode
|
|
||||||
action?: ToastAction
|
|
||||||
actionRef?: Ref<HTMLButtonElement>
|
|
||||||
onDismiss: () => void
|
|
||||||
onUse: (using: boolean) => void
|
|
||||||
onFocusIn: () => void
|
|
||||||
onFocusOut: (to: Node | null) => void
|
|
||||||
}) {
|
|
||||||
const box = useRef<HTMLDivElement>(null)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={box}
|
|
||||||
className="flex w-(--width) max-w-[calc(100vw-2rem)] items-center gap-3 rounded-lg border border-border bg-popover py-2 pr-2 pl-4 text-sm text-popover-foreground shadow-md"
|
|
||||||
onMouseEnter={() => onUse(true)}
|
|
||||||
onMouseLeave={() => onUse(false)}
|
|
||||||
onFocus={onFocusIn}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const to = e.relatedTarget as Node | null
|
|
||||||
if (to != null && box.current?.contains(to)) return
|
|
||||||
onFocusOut(to)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="m-0 min-w-0 flex-1 leading-snug break-words" aria-hidden="true">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
{action != null && (
|
|
||||||
<PermissionButton
|
|
||||||
ref={actionRef}
|
|
||||||
size="sm"
|
|
||||||
variant="primary"
|
|
||||||
disabled={action.disabled}
|
|
||||||
permission={action.permission}
|
|
||||||
onClick={action.onClick}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</PermissionButton>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
title="Dismiss"
|
|
||||||
onClick={onDismiss}
|
|
||||||
>
|
|
||||||
<Icon name="close" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Toast({
|
export function Toast({
|
||||||
message,
|
message,
|
||||||
action,
|
action,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
duration = DEFAULT_MS,
|
duration = DEFAULT_MS,
|
||||||
timerKey,
|
|
||||||
hold = false,
|
hold = false,
|
||||||
returnFocus,
|
|
||||||
actionRef,
|
|
||||||
}: {
|
}: {
|
||||||
/** `null` while closed. */
|
/** `null` while closed. */
|
||||||
message: ReactNode | null
|
message: ReactNode | null
|
||||||
action?: ToastAction
|
action?: ToastAction
|
||||||
onDismiss: () => void
|
onDismiss: () => void
|
||||||
duration?: number
|
duration?: number
|
||||||
/** A new value restarts the clock. */
|
|
||||||
timerKey?: unknown
|
|
||||||
/** Keeps it open regardless of the clock: while its action is running, say. */
|
/** Keeps it open regardless of the clock: while its action is running, say. */
|
||||||
hold?: boolean
|
hold?: boolean
|
||||||
returnFocus?: () => void
|
|
||||||
actionRef?: Ref<HTMLButtonElement>
|
|
||||||
}) {
|
}) {
|
||||||
const open = message != null
|
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 dismiss = useRef(onDismiss)
|
||||||
const back = useRef(returnFocus)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dismiss.current = onDismiss
|
dismiss.current = onDismiss
|
||||||
back.current = returnFocus
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* Closing forgets the pointer and the focus: the box under them is gone, and no
|
|
||||||
leave or blur may come to say so. Adjusted while rendering, as React advises
|
|
||||||
for state that follows a prop. */
|
|
||||||
const [wasOpen, setWasOpen] = useState(open)
|
|
||||||
if (open !== wasOpen) {
|
|
||||||
setWasOpen(open)
|
|
||||||
if (!open) {
|
|
||||||
setPointer(false)
|
|
||||||
setFocused(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const paused = pointer || focused || hold
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || paused) return
|
|
||||||
const t = setTimeout(() => dismiss.current(), duration)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [open, paused, duration, timerKey])
|
|
||||||
|
|
||||||
/* The notice is shown, updated and removed through sonner. Its own clock is off
|
|
||||||
(`Infinity`): the one above decides. */
|
|
||||||
const hasAction = open && action != null
|
|
||||||
const current = useRef<string | null>(null)
|
const current = useRef<string | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -175,25 +69,44 @@ export function Toast({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
current.current ??= `console-toast-${++counter}`
|
current.current ??= `console-toast-${++counter}`
|
||||||
|
const id = current.current
|
||||||
toast.custom(
|
toast.custom(
|
||||||
() => (
|
() => (
|
||||||
<Body
|
<div className="flex w-(--width) max-w-[calc(100vw-2rem)] items-center gap-3 rounded-lg border border-border bg-popover py-2 pr-2 pl-4 text-sm text-popover-foreground shadow-md">
|
||||||
message={message}
|
<p className="m-0 min-w-0 flex-1 leading-snug break-words" aria-hidden="true">
|
||||||
action={action}
|
{message}
|
||||||
actionRef={actionRef}
|
</p>
|
||||||
onDismiss={() => dismiss.current()}
|
{action != null && (
|
||||||
onUse={setPointer}
|
<PermissionButton
|
||||||
onFocusIn={() => {
|
size="sm"
|
||||||
hadFocus.current = true
|
variant="primary"
|
||||||
setFocused(true)
|
disabled={action.disabled}
|
||||||
}}
|
permission={action.permission}
|
||||||
onFocusOut={(to) => {
|
onClick={action.onClick}
|
||||||
setFocused(false)
|
>
|
||||||
if (to != null) hadFocus.current = false
|
{action.label}
|
||||||
}}
|
</PermissionButton>
|
||||||
/>
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
title="Dismiss"
|
||||||
|
onClick={() => dismiss.current()}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
{ id: current.current, duration: Infinity, unstyled: true },
|
{
|
||||||
|
id,
|
||||||
|
duration: hold ? Infinity : duration,
|
||||||
|
unstyled: true,
|
||||||
|
/* The clock running out, or the notice swiped away, is the page's business
|
||||||
|
too: the screen forgets what the notice was about. */
|
||||||
|
onAutoClose: () => dismiss.current(),
|
||||||
|
onDismiss: () => dismiss.current(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -205,30 +118,6 @@ export function Toast({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* Once it is closed, or has lost its action, the button the focus was on is gone. */
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (hasAction) return
|
|
||||||
/* Sonner keeps a closing notice in the page while it animates out, so the
|
|
||||||
element is still there: what says the focus is lost is that it WAS inside. */
|
|
||||||
if (hadFocus.current) {
|
|
||||||
hadFocus.current = false
|
|
||||||
back.current?.()
|
|
||||||
}
|
|
||||||
}, [hasAction])
|
|
||||||
|
|
||||||
/* Escape dismisses it from wherever the focus is while it is open. */
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const escape = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
dismiss.current()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', escape)
|
|
||||||
return () => document.removeEventListener('keydown', escape)
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="bottom-center" />
|
<Toaster position="bottom-center" />
|
||||||
|
|||||||
Reference in new issue
Block a user