fix(files): a steady list header, every user on the Storage tab, the name gets the row's room

- The Files list starts at the scroller's top: the padding above it let the sticky header ride up
  12px on the first scroll, then stop.
- Users -> Storage lists every user with their quota, not only those already holding a file — a
  quota is set before anyone downloads.
- On a wide Files list only the "…" holds a place; Copy link, Share and Save come up over the date
  while the row is hovered or holds the keyboard (a card keeps them in its line). Keeps is narrower.
This commit is contained in:
2026-10-11 20:33:48 +02:00
parent 078194731b
commit 6707550513
5 changed files with 221 additions and 171 deletions
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
//
// The Files page: a row keeps Copy link, Share, Save and its "…" in sight — a slot an action does not
// apply to stays empty, so the icons line up down the list — and its every action is its ONE menu,
// The Files page: a row has Copy link, Share and Save (on a wide list, shown while it is hovered or
// focused) and its "…" — a slot an action does not apply to stays empty, so the icons line up down
// the list — and its every action is its ONE menu,
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
// list. i18n is key-passthrough, so labels read as keys.
@@ -177,6 +178,20 @@ describe("Files · my file's row", () => {
]);
});
it("holds back the quick icons on a wide row until it is hovered or focused; the '…' stays", () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mount();
const row = rowOf("Clip 2");
const quick = row.querySelector<HTMLElement>("[data-quick-actions]")!;
expect(row.className).toMatch(/(^|\s)group(\s|$)/);
expect(quick.className).toContain("md:opacity-0");
expect(quick.className).toContain("md:group-hover:opacity-100");
expect(quick.className).toContain("md:group-focus-within:opacity-100");
expect(quick.contains(row.querySelector('[aria-label="files.actions.share"]'))).toBe(true);
expect(quick.contains(moreOf("Clip 2"))).toBe(false);
});
it("keeps the slot of an action a plain file lacks, so the icons line up", () => {
mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mocks.folders = [YT];
@@ -388,6 +403,14 @@ describe("Files · a shared file's row", () => {
});
describe("Files · the view", () => {
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mount();
const list = screen.getByRole("table").closest<HTMLElement>(".max-w-7xl")!;
expect(list.className).not.toMatch(/(^|\s)(pt|py|p)-/);
});
it("reads one folder's files and names the folder", () => {
mocks.files = [file(2, { folder_id: 2 })];
mocks.folders = [YT, OWN];
+52 -37
View File
@@ -369,7 +369,7 @@ export default function Files() {
{
key: "retention",
header: t("files.columns.retention"),
width: "7.5rem",
width: "6.75rem",
narrowHidden: true,
render: (f) => <RetentionBadge file={f} />,
},
@@ -408,7 +408,10 @@ export default function Files() {
{
key: "actions",
header: "",
width: "10rem",
// Only the "…" holds a place: the quick icons come up over the date while the pointer or the
// keyboard is on the row (the mail-list pattern), so the name gets their room. A card keeps
// them in its own line — a phone has no hover — and the menu holds them all anyway.
width: "3rem",
align: "right",
cardLabel: false,
cardRow: true,
@@ -416,39 +419,48 @@ export default function Files() {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
const mine = rowMenu.open && rowMenu.target?.id === f.id;
return (
<span className="flex items-center justify-end gap-0.5">
{!f.shared && a.play ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.copyLink")}
disabled={actions.sharing !== null}
onClick={() => actions.share(f)}
>
<Link />
</IconButton>
) : (
iconSlot
)}
{!f.shared ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.share")}
onClick={() => setShareFile(f)}
>
<Share2 />
</IconButton>
) : (
iconSlot
)}
{f.can_download ? (
<IconLink href={api.fileUrl(f.id)} label={t("downloads.actions.download")} size="md">
<Download />
</IconLink>
) : (
iconSlot
)}
<span className="relative flex items-center justify-end gap-0.5">
<span
data-quick-actions
className="flex items-center gap-0.5 md:absolute md:right-full md:top-1/2 md:-translate-y-1/2 md:rounded-md md:bg-bg md:pointer-events-none md:opacity-0 md:transition-opacity md:group-hover:pointer-events-auto md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:opacity-100"
>
{!f.shared && a.play ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.copyLink")}
disabled={actions.sharing !== null}
onClick={() => actions.share(f)}
>
<Link />
</IconButton>
) : (
iconSlot
)}
{!f.shared ? (
<IconButton
variant="ghost"
size="md"
label={t("files.actions.share")}
onClick={() => setShareFile(f)}
>
<Share2 />
</IconButton>
) : (
iconSlot
)}
{f.can_download ? (
<IconLink
href={api.fileUrl(f.id)}
label={t("downloads.actions.download")}
size="md"
>
<Download />
</IconLink>
) : (
iconSlot
)}
</span>
<MoreButton
label={t("files.rowMenu")}
size="md"
@@ -529,8 +541,9 @@ export default function Files() {
reveal={reveal}
narrowedBy={q}
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
// `group`: the row's quick icons show while it is hovered or holds the focus.
rowClassName={(f) =>
ringed === f.id ? "bg-accent/8 ring-2 ring-inset ring-accent/60" : ""
ringed === f.id ? "group bg-accent/8 ring-2 ring-inset ring-accent/60" : "group"
}
emptyText={emptyText}
/>
@@ -614,7 +627,9 @@ export default function Files() {
</div>
</PageToolbar>
<div ref={listRef} className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4">
{/* The table starts at the scroller's top: a padding above it let its sticky header ride up that
far on the first scroll, then stop. The cards have no header, so they keep the gap. */}
<div ref={listRef} className={`mx-auto w-full max-w-7xl px-4 pb-4${isGrid ? " pt-3" : ""}`}>
{body}
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
<ContextMenu