fix(files): review round 1, the page — honest states, the keyboard, the tray's words

- A library past the list's cap is searched and sorted on the server; the count says "the newest".
- Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope).
- A file dropped where the page takes none never reaches the browser (which would unload the app).
- The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line;
  a text shows its first 5000 lines and says where the rest is.
- Confirms name the file (no more "your downloads"); the asking menu rows end in "…".
- The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly
  while only paused work waits, and forgets a finished file that was deleted.
- An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top;
  the link box never replaces what was typed.
- Honest states: folders that failed to load, a file no longer there, the storage figure; the rail
  says "nothing downloaded" only when there is nothing.
- Focus after deleting the open folder and when the format form opens; the format name's label.
- ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer.
- Copy fixes, stale comments and the speculative hub key; a contract test for the list's
  parameters; e2e: the Files page and the paste intake.
This commit is contained in:
2026-10-13 00:14:09 +02:00
parent 815083bcc4
commit 8b2260c9a8
41 changed files with 920 additions and 218 deletions
@@ -36,4 +36,27 @@ describe("DataTable pager", () => {
expect(screen.getByText("row 12")).toBeTruthy();
expect(screen.queryByText("datatable.rowsPerPage")).toBeNull();
});
it("another list (pageScope) starts on its first page; a remembered page comes back only for its own", () => {
const table = (scope: string) => (
<DataTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
pageSize={10}
persistKey="t"
pageScope={scope}
/>
);
localStorage.setItem("t", JSON.stringify({ size: 5, page: 2, pageScope: "folder 1" }));
const { rerender } = render(table("folder 1"));
expect(screen.getByText("row 11")).toBeTruthy(); // the page it was on, in the list it was on
rerender(table("folder 2"));
expect(screen.getByText("row 1")).toBeTruthy();
expect(screen.queryByText("row 11")).toBeNull();
cleanup();
localStorage.setItem("t", JSON.stringify({ size: 5, page: 2, pageScope: "folder 1" }));
render(table("a search"));
expect(screen.getByText("row 1")).toBeTruthy();
});
});
+18 -4
View File
@@ -31,6 +31,8 @@ interface Persisted {
filters: FilterMap;
page: number;
size?: number;
// The `pageScope` the page was on: it is taken back only for the same list.
pageScope?: string;
}
// Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a
@@ -38,12 +40,13 @@ interface Persisted {
// render). Mirrors controlled sort's stable `null` fallback.
const EMPTY_FILTERS: FilterMap = {};
function loadPersist(key?: string): Persisted {
function loadPersist(key?: string, pageScope?: string): Persisted {
const empty: Persisted = { sort: null, filters: {}, page: 0 };
if (!key) return empty;
try {
const v = JSON.parse(localStorage.getItem(key) || "{}");
return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size };
const page = pageScope === undefined || v.pageScope === pageScope ? (v.page ?? 0) : 0;
return { sort: v.sort ?? null, filters: v.filters ?? {}, page, size: v.size };
} catch {
return empty;
}
@@ -114,6 +117,7 @@ export default function DataTable<T>({
reveal,
surfacedFilters,
narrowedBy = "",
pageScope,
}: {
rows: T[];
columns: Column<T>[];
@@ -181,10 +185,13 @@ export default function DataTable<T>({
// in the shared top bar): a change starts a long page over from its first rows, as the table's own
// sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page.
narrowedBy?: string;
// Which list `rows` are, when one table shows several by turns (a folder, then another; a search):
// another list starts on its first page, and a remembered page is only taken back for its own list.
pageScope?: string;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const initial = loadPersist(persistKey);
const initial = loadPersist(persistKey, pageScope);
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
// the table keeps its own, seeded from and saved to persistKey.
const controlledSort = onSortChange !== undefined;
@@ -196,6 +203,12 @@ export default function DataTable<T>({
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
// Adjusted during render against the last list seen, so the first frame of a new list is its first page.
const [scopeSeen, setScopeSeen] = useState(pageScope);
if (pageScope !== scopeSeen) {
setScopeSeen(pageScope);
setPage(0);
}
// 0 = "All": an unpaged table is one page of every row.
const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0);
const wide = useMediaQuery("(min-width: 48rem)");
@@ -236,9 +249,10 @@ export default function DataTable<T>({
filters: controlledFilters ? {} : filters,
page,
size,
pageScope,
}),
);
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size, pageScope]);
// Destructured so the deps are the values the body actually reads. `externalFilter` itself is an
// object literal at every call site, so depending on it would re-apply the filter — and reset the
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
//
// One meter for every place that shows how full a quota is: it turns to a warning at the share it
// SHOWS (the rounded percent), so the user's own rail and the admin's Storage tab never disagree
// about the same fill, and it reads as a meter with that percent.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { Meter } from "./Meter";
afterEach(cleanup);
const fill = () => screen.getByRole("meter").firstElementChild!;
describe("Meter", () => {
it.each([
[896, "90", true],
[894, "89", false],
[2000, "100", true],
])("%i of 1000 shows its percent and warns from 90", (value, shown, danger) => {
render(<Meter value={value} max={1000} label="Storage" />);
expect(screen.getByRole("meter", { name: "Storage" }).getAttribute("aria-valuenow")).toBe(
shown,
);
expect(fill().classList.contains("bg-danger")).toBe(danger);
});
it("an unset limit is an empty meter, never a division by zero", () => {
render(<Meter value={5} max={0} label="Storage" />);
expect(screen.getByRole("meter").getAttribute("aria-valuenow")).toBe("0");
});
});
+46
View File
@@ -0,0 +1,46 @@
import clsx from "clsx";
/** How full something is against its limit, from this share up drawn as a warning. */
const METER_DANGER_PCT = 90;
/** The share of `max` that `value` fills, as the whole percent a reader is shown (0–100). */
function meterPct(value: number, max: number): number {
return max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0;
}
// A thin bar of how full something is (a storage quota): one drawing and one danger line for every
// place that shows the same measure, and a `meter` to assistive tech with the percent it shows — two
// hand-painted bars once turned red at different fills for the same quota.
export function Meter({
value,
max,
label,
className,
}: {
value: number;
max: number;
/** The meter's name for assistive tech (what it measures). */
label: string;
/** Its height and place, e.g. `h-1.5`; the track and the fill are the meter's. */
className?: string;
}) {
const pct = meterPct(value, max);
return (
<span
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-label={label}
className={clsx("block overflow-hidden rounded-full bg-fg/10", className ?? "h-1")}
>
<span
className={clsx(
"block h-full rounded-full",
pct >= METER_DANGER_PCT ? "bg-danger" : "bg-accent",
)}
style={{ width: `${pct}%` }}
/>
</span>
);
}
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Input } from "./ui/Input";
import { Input } from "./Input";
// A name being typed in place of the name itself — a library file's in its row, a folder's in the rail
// or the page title, a new folder's. Enter or leaving the field saves, Escape cancels: unmounting the