fix(documents): one cap line for both pickers, with a search-aware hint

The vault picker and the Coach's attachment picker each wrote their own
"showing the newest 50" line, which the UI standards leave to
`ListCapNotice`, and with a search running past fifty matches the line
still said "Search to find an older document". Both pickers now share
`DocumentPickerCapNotice` on `ListCapNotice`, which takes the sentence
to show, and a running search asks for a narrower one.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 12:51:32 +01:00
1 parent 92e1e24599
commit 671245c782
15 files changed
+83 -37

No files matched your search

@@ -37,7 +37,10 @@ vi.mock("@tanstack/react-query", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentPickerSheet } from "../document-picker-sheet";
import {
DocumentPickerCapNotice,
DocumentPickerSheet,
} from "../document-picker-sheet";
function render() {
return renderToStaticMarkup(
@@ -60,12 +63,22 @@ describe("DocumentPickerSheet", () => {
it("says the list is cut, and how to reach the rest", () => {
page.nextCursor = "d49";
const html = render();
expect(html).toContain('data-slot="document-picker-capped"');
expect(html).toContain('data-slot="list-cap-notice"');
expect(html).toContain("Showing the newest 50");
});
it("says nothing when every document is listed", () => {
page.nextCursor = null;
expect(render()).not.toContain('data-slot="document-picker-capped"');
expect(render()).not.toContain('data-slot="list-cap-notice"');
});
it("asks for a narrower search, not a search, while one runs", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentPickerCapNotice cut count={50} searching />
</I18nProvider>,
);
expect(html).toContain("Narrow the search");
expect(html).not.toContain("Search to find");
});
});
@@ -18,6 +18,7 @@ import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useDocumentDate } from "@/components/documents/use-document-date";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -191,17 +192,40 @@ export function DocumentPickerSheet({
})}
</ul>
)}
{cut && !list.isError ? (
<p
data-slot="document-picker-capped"
className="text-muted-foreground text-xs"
>
{t("documents.picker.capped", {
count: String(documents.length),
})}
</p>
) : null}
<DocumentPickerCapNotice
cut={cut && !list.isError}
count={documents.length}
searching={query !== ""}
/>
</div>
</ResponsiveSheet>
);
}
/**
* The line under a picker list cut at one page: the search reaches the rest,
* and while one runs, a narrower one does. Shared by the vault picker and
* the Coach's attachment picker.
*/
export function DocumentPickerCapNotice({
cut,
count,
searching,
}: {
cut: boolean;
count: number;
searching: boolean;
}) {
const { t } = useTranslations();
return (
<ListCapNotice
cap={{ truncated: cut, limit: count }}
message={t(
searching
? "documents.picker.cappedSearching"
: "documents.picker.capped",
{ count: String(count) },
)}
/>
);
}
@@ -56,14 +56,14 @@ describe("AttachmentPicker", () => {
it("says the list is cut, and how to reach the rest", () => {
page.nextCursor = "d49";
const html = render();
expect(html).toContain('data-slot="coach-attachment-picker-capped"');
expect(html).toContain('data-slot="list-cap-notice"');
expect(html).toContain("Showing the newest 50");
});
it("says nothing when every document is listed", () => {
page.nextCursor = null;
const html = render();
expect(html).not.toContain('data-slot="coach-attachment-picker-capped"');
expect(html).not.toContain('data-slot="list-cap-notice"');
expect(
html.match(/data-slot="coach-attachment-picker-item"/g),
).toHaveLength(3);
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { DocumentPickerCapNotice } from "@/components/documents/document-picker-sheet";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
@@ -266,16 +267,11 @@ export function AttachmentPicker({
})}
</ul>
)}
{cut && !list.isError ? (
<p
data-slot="coach-attachment-picker-capped"
className="text-muted-foreground text-xs"
>
{t("documents.picker.capped", {
count: String(list.data?.documents.length ?? 0),
})}
</p>
) : null}
<DocumentPickerCapNotice
cut={cut && !list.isError}
count={list.data?.documents.length ?? 0}
searching={query !== ""}
/>
</div>
</ResponsiveSheet>
);
+5 -2
View File
@@ -8,14 +8,17 @@ import { cn } from "@/lib/utils";
* The line under a list the server cut at its ceiling. Renders nothing for a
* complete list, so a surface passes the cap it read (`apiGetList`) and
* never decides on its own whether to show it. Meta tier: one muted sentence
* at the `text-xs` floor.
* at the `text-xs` floor. `message` replaces the generic sentence where the
* surface can say how to reach the rest (a search).
*/
export function ListCapNotice({
cap,
className,
message,
}: {
cap: ListCap | null | undefined;
className?: string;
message?: string;
}) {
const { t } = useTranslations();
if (!cap?.truncated) return null;
@@ -24,7 +27,7 @@ export function ListCapNotice({
data-slot="list-cap-notice"
className={cn("text-muted-foreground text-xs", className)}
>
{t("common.listCapped", { count: String(cap.limit) })}
{message ?? t("common.listCapped", { count: String(cap.limit) })}
</p>
);
}