mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
refactor(documents): one DocumentPickerSheet behind both vault pickers
Linking documents to an illness episode and choosing documents for a share link carried the same sheet as two copies: the same endpoint and query key, the same debounced search, skeleton, error and empty states and toggle rows. DocumentPickerSheet owns that body; each picker keeps what a toggle means (an immediate link write, or a capped local choice posted with the share link).
This commit is contained in:
1 parent
3cc3829fba
commit
cc190c4710
3 files changed
+235
-303
No files matched your search
@@ -2,41 +2,20 @@
|
||||
|
||||
/**
|
||||
* "Verknüpfen" — link existing vault documents to an illness episode from
|
||||
* the episode's detail page. A `ResponsiveSheet` with a debounced search
|
||||
* over the vault (title/filename, same recall path as the vault page) and
|
||||
* one toggle row per document; toggling fires the bulk link/unlink action
|
||||
* for that single id (idempotent by contract, no-op-success on
|
||||
* already-in-state rows) and refreshes every document read through the
|
||||
* `["documents"]` prefix.
|
||||
*
|
||||
* Shows one page (50) of matches — the search input is the way to reach an
|
||||
* older document, mirroring the vault's own recall model.
|
||||
* the episode's detail page. The shared `DocumentPickerSheet` lists and
|
||||
* searches the vault; toggling a row fires the bulk link/unlink action for
|
||||
* that single id (idempotent by contract, no-op-success on already-in-state
|
||||
* rows) and refreshes every document read through the `["documents"]`
|
||||
* prefix.
|
||||
*/
|
||||
import { useDocumentDate } from "@/components/documents/use-document-date";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DocumentBulkResultDto,
|
||||
InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
import type { DocumentBulkResultDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentPickerSheet } from "./document-picker-sheet";
|
||||
|
||||
export function DocumentLinkPicker({
|
||||
episodeId,
|
||||
@@ -48,39 +27,8 @@ export function DocumentLinkPicker({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const documentDate = useDocumentDate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// 200 ms debounce, matching the vault's search feel.
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearchDraft(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
|
||||
};
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker(query),
|
||||
enabled: open,
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (query) sp.set("q", query);
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", "50");
|
||||
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (input: { documentId: string; linked: boolean }) =>
|
||||
apiPost<{ results: DocumentBulkResultDto[] }>(
|
||||
@@ -103,99 +51,21 @@ export function DocumentLinkPicker({
|
||||
},
|
||||
});
|
||||
|
||||
const documents = list.data?.documents ?? [];
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
<DocumentPickerSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("documents.linkPicker.title")}
|
||||
description={t("documents.linkPicker.description")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchDraft}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("documents.linkPicker.searchPlaceholder")}
|
||||
aria-label={t("documents.linkPicker.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{list.isPending && open ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<QueryErrorRow
|
||||
error={list.error}
|
||||
message={t("documents.list.loadError")}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
) : documents.length === 0 ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
title={t("documents.linkPicker.empty")}
|
||||
/>
|
||||
) : (
|
||||
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
|
||||
{documents.map((doc) => {
|
||||
const linked = doc.conditionLinks.some(
|
||||
(l) => l.episodeId === episodeId,
|
||||
);
|
||||
const title =
|
||||
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
return (
|
||||
<li key={doc.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={linked}
|
||||
disabled={toggle.isPending}
|
||||
onClick={() =>
|
||||
toggle.mutate({ documentId: doc.id, linked })
|
||||
}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
linked && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{documentDate(doc)}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary size-4 shrink-0",
|
||||
linked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
searchPlaceholder={t("documents.linkPicker.searchPlaceholder")}
|
||||
emptyLabel={t("documents.linkPicker.empty")}
|
||||
isPicked={(doc) =>
|
||||
doc.conditionLinks.some((l) => l.episodeId === episodeId)
|
||||
}
|
||||
isDisabled={() => toggle.isPending}
|
||||
onToggle={(doc, _title, linked) =>
|
||||
toggle.mutate({ documentId: doc.id, linked })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on
|
||||
* phones) with a debounced search over the person's own vault — the same
|
||||
* recall path as the vault page, one page (50) of matches, the search input
|
||||
* as the way to an older document — and one toggle row per document.
|
||||
*
|
||||
* Two pickers used to carry this whole body as copies: linking documents to
|
||||
* an illness episode (each toggle writes at once) and choosing documents for
|
||||
* a share link (the choice is local state, capped, posted with the link).
|
||||
* What a toggle means stays with each caller; the list, the search, the rows
|
||||
* and their states live here once.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { useDocumentDate } from "@/components/documents/use-document-date";
|
||||
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export function DocumentPickerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
isPicked,
|
||||
onToggle,
|
||||
isDisabled,
|
||||
notice,
|
||||
footer,
|
||||
listTestId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
searchPlaceholder: string;
|
||||
emptyLabel: string;
|
||||
isPicked: (doc: InboundDocumentDto) => boolean;
|
||||
/** `title` is the row's display title (title, filename or "untitled"). */
|
||||
onToggle: (doc: InboundDocumentDto, title: string, picked: boolean) => void;
|
||||
isDisabled?: (doc: InboundDocumentDto, picked: boolean) => boolean;
|
||||
/** A status line above the list (the share cap). */
|
||||
notice?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
listTestId?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const documentDate = useDocumentDate();
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// 200 ms debounce, matching the vault's search feel.
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearchDraft(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
|
||||
};
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker(query),
|
||||
enabled: open,
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (query) sp.set("q", query);
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", "50");
|
||||
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const documents = list.data?.documents ?? [];
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={description}
|
||||
footer={footer}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchDraft}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label={searchPlaceholder}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notice}
|
||||
|
||||
{list.isPending && open ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<QueryErrorRow
|
||||
error={list.error}
|
||||
message={t("documents.list.loadError")}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
) : documents.length === 0 ? (
|
||||
<EmptyState variant="plain" size="compact" title={emptyLabel} />
|
||||
) : (
|
||||
<ul
|
||||
className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain"
|
||||
data-testid={listTestId}
|
||||
>
|
||||
{documents.map((doc) => {
|
||||
const picked = isPicked(doc);
|
||||
const rowTitle =
|
||||
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
const disabled = isDisabled?.(doc, picked) ?? false;
|
||||
return (
|
||||
<li key={doc.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={picked}
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(doc, rowTitle, picked)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
picked && "border-primary/40 bg-primary/5",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{rowTitle}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{documentDate(doc)}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary size-4 shrink-0",
|
||||
picked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -9,27 +9,13 @@
|
||||
* is bounded at `max` (mirrors `SHARE_LINK_MAX_DOCUMENTS`): once the cap is
|
||||
* reached, unselected rows disable so the client never posts an oversized set.
|
||||
*
|
||||
* Reuses the vault list endpoint + the `inboundDocumentPicker` query key from
|
||||
* the central factory; reads unwrap `(await res.json()).data` via `apiGet`.
|
||||
* No write happens here — attaching is the create mutation's job.
|
||||
* The list, search and rows are the shared `DocumentPickerSheet`. No write
|
||||
* happens here — attaching is the create mutation's job.
|
||||
*/
|
||||
import { useDocumentDate } from "@/components/documents/use-document-date";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { DocumentPickerSheet } from "@/components/documents/document-picker-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
/** A picked document, carried by id + a display title (for the owner chips). */
|
||||
export interface PickedDocument {
|
||||
@@ -37,11 +23,6 @@ export interface PickedDocument {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export function ShareDocumentPicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -56,38 +37,6 @@ export function ShareDocumentPicker({
|
||||
max: number;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const documentDate = useDocumentDate();
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// 200 ms debounce, matching the vault's search feel.
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearchDraft(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
|
||||
};
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker(query),
|
||||
enabled: open,
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (query) sp.set("q", query);
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", "50");
|
||||
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedIds = new Set(selected.map((s) => s.id));
|
||||
const atCap = selected.length >= max;
|
||||
|
||||
@@ -100,14 +49,28 @@ export function ShareDocumentPicker({
|
||||
onSelectedChange([...selected, { id: doc.id, title }]);
|
||||
}
|
||||
|
||||
const documents = list.data?.documents ?? [];
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
<DocumentPickerSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("settings.sharing.pickerTitle")}
|
||||
description={t("settings.sharing.pickerDescription", { max })}
|
||||
searchPlaceholder={t("settings.sharing.pickerSearch")}
|
||||
emptyLabel={t("settings.sharing.pickerEmpty")}
|
||||
listTestId="share-doc-picker-list"
|
||||
isPicked={(doc) => selectedIds.has(doc.id)}
|
||||
isDisabled={(_doc, picked) => !picked && atCap}
|
||||
onToggle={(doc, title) => toggle(doc, title)}
|
||||
notice={
|
||||
atCap ? (
|
||||
<p
|
||||
className="text-warning bg-warning/10 rounded-md px-3 py-2 text-xs"
|
||||
role="status"
|
||||
>
|
||||
{t("settings.sharing.pickerMaxReached", { max })}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground text-xs" aria-live="polite">
|
||||
@@ -121,101 +84,6 @@ export function ShareDocumentPicker({
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchDraft}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("settings.sharing.pickerSearch")}
|
||||
aria-label={t("settings.sharing.pickerSearch")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{atCap ? (
|
||||
<p
|
||||
className="text-warning bg-warning/10 rounded-md px-3 py-2 text-xs"
|
||||
role="status"
|
||||
>
|
||||
{t("settings.sharing.pickerMaxReached", { max })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{list.isPending && open ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<QueryErrorRow
|
||||
error={list.error}
|
||||
message={t("documents.list.loadError")}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
) : documents.length === 0 ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
title={t("settings.sharing.pickerEmpty")}
|
||||
/>
|
||||
) : (
|
||||
<ul
|
||||
className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain"
|
||||
data-testid="share-doc-picker-list"
|
||||
>
|
||||
{documents.map((doc) => {
|
||||
const picked = selectedIds.has(doc.id);
|
||||
const title =
|
||||
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
const disabled = !picked && atCap;
|
||||
return (
|
||||
<li key={doc.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={picked}
|
||||
disabled={disabled}
|
||||
onClick={() => toggle(doc, title)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
picked && "border-primary/40 bg-primary/5",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{documentDate(doc)}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary size-4 shrink-0",
|
||||
picked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user