"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { facetLabel, normalisePhaseLabel } from "@/lib/kinds"; import Link from "next/link"; import { valueTone } from "@/lib/valueTone"; import { ValueIcon } from "@/components/ValueIcon"; import { Tip } from "@/components/Tip"; import { MoleculeSlot } from "./MoleculeSlot"; import { ApprovalChip } from "./ApprovalChip"; import { flagFor, COUNTRY_FACETS } from "@/lib/flags"; import { TargetThumb } from "./TargetThumb"; import { TechThumb } from "./TechThumb"; import { TermThumb } from "./TermThumb"; import { RowAvatar } from "./RowAvatar"; import { RowVisualFallback } from "./RowVisualFallback"; import { CancerIcon } from "./CancerIcon"; import { FrontIcon } from "./FrontIcon"; import type { TargetSchematicTarget } from "./TargetSchematic"; import type { Kind } from "@/lib/kinds"; import { fallbackSlot, visualSource } from "@/lib/row-visual"; import { STATUS_LABEL, STATUS_TIPS, statusClass } from "@/lib/text"; import { baseSortFor, cellText, compareBrowserRows, isFacetLink, isRich, isYearRange, itemLabel, STATUS_ORDER } from "@/lib/browser-sort"; import { FacetSelect } from "./filters/FacetSelect"; import { ResultsTable, Toolbar, type Column, type SortState } from "./filters/ResultsTable"; import { useRemoteRows } from "./filters/FilterableTable"; import { TABLE_PAGE, type MoreRows } from "@/lib/static-tables"; import { DownloadTable } from "./DownloadTable"; import { SaveViewButton } from "./SaveViewButton"; import { WatchButton } from "./WatchButton"; import type { CsvRow } from "@/lib/csv"; import { useT } from "@/lib/i18n/ui"; import { viewParams } from "@/lib/table-view"; import { tldrFor } from "./TldrText"; import { useTable } from "@/lib/tldr-tables"; import { nameAttrs } from "@/lib/translate"; /** * One templated, full-width, sortable and filterable table for any kind of entity. * The server page turns entities into BrowserRow[] with facet values and extra columns; * this component owns the single control row at the top and the table below. * * Filter state lives in the URL (`?group=Solid&type=Cancer+center&q=her2&sort=-name`): facet keys are the * query keys, values repeat (or are comma-separated), `q` is the search box and `sort` is the column key with * a leading `-` for descending. The URL is read once on mount and rewritten with `history.replaceState` as * filters change, so any view is linkable and the page stays a static export (no server search params). * A compact form `?v=` is also accepted on load (for short shared links); it is * expanded into the readable form on the first write. "Save view" stores the URL under a name (see /saved/). */ export type BrowserRow = { id: string; name: string; tldr: string; route: string; status?: string; /** Drug id: renders a small rotating molecule beside the name, or an explained placeholder when there is no structure. */ molecule?: string; modality?: string; /** Target: renders a small animated schematic of the target class beside the name. */ target?: TargetSchematicTarget; /** Technology: renders its small rotating schematic beside the name. */ schematic?: { id: string; sections: string[] }; /** Glossary term: renders its category schematic beside the name. */ term?: { category: string }; /** Organ icon (a cancer id) beside the name, for anatomical and pathological terms. */ cancerIcon?: string; /** Front icon (a section id) beside the name. */ sectionIcon?: string; /** Facet values keyed by facet key; arrays for multi-valued facets. */ facets: Record; /** Extra columns keyed by column key: formatted strings, numbers, lists of links, glossary-marked text, or facet chips. */ cols: Record; /** Optional numeric sort keys for extra columns. */ sortKeys?: Record; /** Secondary sort when the chosen column ties (higher first), e.g. year reported for trials sorted by status. */ tie?: number; sub?: string; logo?: string; /** Render the logo as a circle with object-cover (portraits). */ round?: boolean; /** Organisation or person row: show an initials tile when there is no usable logo or portrait. */ avatar?: "org" | "person"; /** The record's kind: when none of the visuals above apply, the row shows the kind's symbol tile (see RowVisualFallback) instead of a gap. */ kind?: Kind; }; /** A linked object; `tip` is the object's one-line explanation, shown on hover. */ export type LinkItem = { label: string; href: string; tip?: string }; /** A list of linked objects. */ export type LinkList = LinkItem[]; /** * A clickable chip that sets a facet in the same table (and so in the URL): `facet` is the facet key, * `value` the exact facet value, `label` what to print (defaults to the value), `tip` extra hover text. */ export type FacetLink = { facet: string; value: string; label?: string; tip?: string }; /** Free text with glossary marks: each mark is a span with a one-line explanation and a link to the term page. */ export type RichText = { text: string; marks: Array<{ s: number; e: number; label: string; tip: string; href: string }> }; /** * A span of years such as a product's approvals: `first` prints in the foreground weight, "to `last`" muted after it * when the two differ, and `regions` (the row's own region strings, "US", "EU", "China") become small flags with a * tooltip. Sorting uses the row's `sortKeys` (the first year), never the text. * * `facet` makes the first year filter the table: only the first year, because only it is an exact value the facet * holds. The range as a whole ("2007 to 2017") is not a facet value, so the muted "to 2017" stays plain text. */ export type YearRange = { first: number; last?: number; regions?: string[]; facet?: string }; /** Anything a table cell can hold. Lists may mix entity links and facet chips (e.g. a trial's sponsors). */ export type CellValue = string | number | undefined | RichText | FacetLink | YearRange | Array; /** Re-exported for the pages and tests that read cell text; the definition lives in src/lib/browser-sort.ts beside the row comparator. */ export { cellText }; /** `normalise` names a client-side mapper of historical URL values to the current facet label, so old shared links keep filtering. A string key, not a function: facet definitions cross the server-to-client boundary. */ const NORMALISERS: Record<"phase" | "cancer", (value: string) => string> = { phase: normalisePhaseLabel, cancer: facetLabel }; export type FacetDef = { key: string; label: string; searchable?: boolean; width?: string; order?: string[]; normalise?: "phase" | "cancer" }; export type ColDef = { key: string; label: string; sortable?: boolean; hide?: string; className?: string; numeric?: boolean; chip?: boolean; tip?: string; /** Tips for chip/string values keyed by value, e.g. { "Phase 3": "..." }. */ valueTips?: Record; /** The facet this column filters from its header. Defaults to the facet with the column's key, or the facet its chips point at; `null` turns the header filter off. */ facet?: string | null; /** Most linked objects a list cell shows before a "+N more" pill that expands the rest inline (default LIST_CAP). */ cap?: number }; /** Default cap on the linked objects a list cell shows before its "+N more" pill. */ export const LIST_CAP = 3; /** * A list cell capped at `cap` items: the rest sit behind a "+N more" pill (a button, so no anchor nests in an * anchor) whose tooltip names them; pressing it shows every item inline. The record's own page always has the * full list, so the pill is a shortcut, not the only way to the rest. */ function CappedList({ items, cap, render, allChips, muted }: { items: Array; cap: number; render: (item: LinkItem | FacetLink, i: number) => React.ReactNode; allChips: boolean; muted: string }) { const [open, setOpen] = useState(false); const { t } = useT(); const shown = open ? items : items.slice(0, cap); const hidden = items.length - shown.length; const body = shown.map((l, i) => {i > 0 && !allChips && ", "}{render(l, i)}); if (!hidden) return {body}; const rest = items.slice(cap).map(itemLabel); const tip = t("table.moreItemsTip", { list: rest.slice(0, 6).join(", ") + (rest.length > 6 ? "…" : ""), total: items.length }); return ( {body}{!allChips && " "} ); } /** * A year range cell: the first year in the foreground weight, a muted "to " only when the two differ, a * tooltip "First approval 2007, latest 2017", and one small flag per region the row records (unknown regions are * named in the tooltip instead). * * With `filter`, the first year is also a button that filters the table to that year, the way a facet chip does; * it keeps the year's own weight rather than becoming a chip, so the column reads as a column of years still. */ function YearRangeCell({ value, tipTitle, filter }: { value: YearRange; tipTitle: string; filter?: { on: boolean; facet: string; onClick: () => void } }) { const { t } = useT(); const last = value.last && value.last !== value.first ? value.last : undefined; const regions = [...new Set((value.regions ?? []).map((r) => r.trim()).filter(Boolean))]; const flagged = regions.map((r) => ({ r, flag: flagFor(r) })).filter((x) => x.flag); const action = filter ? ` ${filter.on ? t("table.filteringBy", { facet: filter.facet, value: String(value.first) }) : t("table.filterBy", { facet: filter.facet, value: String(value.first) })}` : ""; const tip = `${t("table.firstApproval", { first: value.first, last: last ?? value.first })}${regions.length ? `. ${regions.length === 1 ? regions[0] : `${regions.length} regions: ${regions.join(", ")}`}` : ""}.${action}`; const year = {value.first}; return ( {/* The years are one unbreakable unit; the flags may drop to a second line, so the column is never wider than "2007 to 2017". */} {filter ? : year}{last && <> to {last}} {flagged.length > 0 && x.r).join(", ")}`}>{flagged.map((x) => {x.flag})}} ); } const STATUS_FACET: FacetDef = { key: "status", label: "Phase / status", searchable: false, width: "w-48", order: STATUS_ORDER }; const QUERY_KEY = "q"; const SORT_KEY = "sort"; const VIEW_KEY = "v"; /** Decode `?v=` (base64url JSON `{ f: facets, q: search, s: sort }`) into ordinary query parameters. */ function decodeView(v: string): URLSearchParams | null { try { const json = atob(v.replace(/-/g, "+").replace(/_/g, "/")); const view = JSON.parse(json) as { f?: Record; q?: string; s?: { key: string; dir: 1 | -1 } }; const p = new URLSearchParams(); for (const [k, vals] of Object.entries(view.f ?? {})) for (const x of vals) if (typeof x === "string") p.append(k, x); if (typeof view.q === "string" && view.q) p.set(QUERY_KEY, view.q); if (view.s?.key) p.set(SORT_KEY, `${view.s.dir === -1 ? "-" : ""}${view.s.key}`); return p; } catch { return null; } } /** Encode the state as the compact `v` value; the inverse of `decodeView`. Exported for tests and share links. */ export function encodeView(state: { f: Record; q: string; s: SortState }): string { const f = Object.fromEntries(Object.entries(state.f).filter(([, v]) => v.length)); return btoa(JSON.stringify({ f, q: state.q || undefined, s: state.s })).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } /** Per-facet value counts over a whole paged table, keyed by facet key (`status` included). */ export type FacetCounts = Record>; export function EntityBrowser({ rows: first, more, counts, facets, columns, noun, defaultSort, hideStatus = false, hideTldr = false, external = null, onExternalChange, nameKind }: { rows: BrowserRow[]; facets: FacetDef[]; columns: ColDef[]; noun: string; defaultSort?: SortState; hideStatus?: boolean; hideTldr?: boolean; /** `rows` are only the first page (in the default order) and the rest is one static file, fetched when the reader scrolls, searches, filters or sorts (src/lib/static-tables.ts). */ more?: MoreRows; /** With `more`: the facet counts over every row, computed by the page, so the facet pickers are complete before the file is fetched. */ counts?: FacetCounts; /** Kind of the rows, so proper-noun names (drugs, genes, companies, trials, people) carry translate="no". Drug rows are recognised by `molecule` regardless. */ nameKind?: Kind; /** Facet values set by a parent (e.g. a map legend); merged with the internal selection for that key and shown as selected. */ external?: { key: string; values: string[] } | null; /** Called when the user changes the externally controlled facet from inside the table (or clears all filters). */ onExternalChange?: (values: string[]) => void; }) { const [q, setQ] = useState(""); const [own, setOwn] = useState>({}); /** Every row once the file is fetched; the first page until then. */ const remote = useRemoteRows(first, more); const rows = remote.rows; const { t, tl, status: statusText, noun: nounText, lang } = useT(); const tldrTable = useTable(lang === "en" ? null : lang); /** True once the URL has been read, so the write-back effect never clobbers a shared link with the empty initial state. */ const [synced, setSynced] = useState(false); /** Effective selection: the internal choice plus whatever the parent set on the controlled key. */ const sel = useMemo(() => { if (!external?.values.length) return own; return { ...own, [external.key]: [...new Set([...(own[external.key] ?? []), ...external.values])] }; }, [own, external]); const setFacet = (key: string, vals: string[]) => { if (external && key === external.key) { if (onExternalChange) { onExternalChange(vals); setOwn((s) => ({ ...s, [key]: [] })); return; } setOwn((s) => ({ ...s, [key]: vals.filter((v) => !external.values.includes(v)) })); return; } setOwn((s) => ({ ...s, [key]: vals })); }; const root = useRef(null); /** * A facet chip: select exactly that value, or clear it when it is already selected. Filtering shortens the * table, so if the control row has scrolled off the top, bring it back into view rather than leave the * reader looking at whatever follows the table. */ const clickFacet = (f: FacetLink) => { const cur = sel[f.facet] ?? []; setFacet(f.facet, cur.includes(f.value) ? cur.filter((v) => v !== f.value) : [f.value]); requestAnimationFrame(() => { const top = root.current?.getBoundingClientRect().top; if (top !== undefined && top < 0) window.scrollTo({ top: window.scrollY + top - 120, behavior: "smooth" }); }); }; const clearAll = () => { setOwn({}); setQ(""); onExternalChange?.([]); }; const baseSort = useMemo(() => baseSortFor(defaultSort, hideStatus), [defaultSort, hideStatus]); const [sort, setSort] = useState(baseSort); // Anything the first page cannot answer needs every row: a search, a facet, or a sort other than the order the rows came in. const needAll = !remote.complete && (!!q.trim() || Object.values(sel).some((v) => v.length) || sort.key !== baseSort.key || sort.dir !== baseSort.dir); const { want } = remote; useEffect(() => { if (needAll) want(); }, [needAll, want]); const allFacets = useMemo(() => (hideStatus ? facets : [STATUS_FACET, ...facets]), [facets, hideStatus]); /** Size of the picture slot for this table (null when no row has a picture); rows without one get the kind's symbol tile at this size. */ const slot = useMemo(() => fallbackSlot(rows), [rows]); /** Molecules take the wide slot in tables that also show drawings or organ icons, so the name column starts at one x for every row. */ const moleculeClass = slot?.className === "h-10 w-14" ? "h-10 w-14" : "h-10 w-10"; const facetVals = (r: BrowserRow, k: string) => (k === "status" ? (r.status ? [r.status] : []) : (r.facets[k] ?? [])); const facetLabel = (key: string) => tl(allFacets.find((f) => f.key === key)?.label ?? key); // Read the URL once on mount (deferred a frame, as the other URL-backed views do, so the effect sets no state // synchronously). Values repeat (`?cancers=A&cancers=B`) or are comma-separated; a raw value that is itself a // known facet value is kept whole so values containing commas survive. useEffect(() => { const raf = requestAnimationFrame(() => { let params = new URLSearchParams(window.location.search); const compact = params.get(VIEW_KEY); if (compact) { const decoded = decodeView(compact); if (decoded) params = decoded; } const next: Record = {}; let ext: string[] = []; for (const f of allFacets) { const raw = params.getAll(f.key); if (!raw.length) continue; const known = new Set([...rows.flatMap((r) => facetVals(r, f.key)), ...(counts?.[f.key] ?? []).map(([v]) => v)]); const norm = f.normalise ? NORMALISERS[f.normalise] : (v: string) => v; const vals = [...new Set(raw.flatMap((s) => (known.has(s) ? [s] : s.split(",").map((x) => norm(x.trim())).filter(Boolean))))]; if (!vals.length) continue; if (external && f.key === external.key && onExternalChange) ext = vals; else next[f.key] = vals; } if (Object.keys(next).length) setOwn(next); if (ext.length) onExternalChange?.(ext); const q0 = params.get(QUERY_KEY); if (q0) setQ(q0); const s0 = params.get(SORT_KEY); if (s0) { const key = s0.replace(/^-/, ""); const known = key === "name" || (key === "status" && !hideStatus) || columns.some((c) => c.key === key && c.sortable); if (known) setSort({ key, dir: s0.startsWith("-") ? -1 : 1 }); } setSynced(true); }); return () => cancelAnimationFrame(raf); // eslint-disable-next-line react-hooks/exhaustive-deps -- mount only: the URL seeds state, then state owns the URL }, []); // Write the selection and search back to the URL, leaving unrelated query keys and the hash alone. useEffect(() => { if (!synced) return; const url = new URL(window.location.href); url.search = viewParams(allFacets.map((f) => f.key), sel, q, sort, baseSort, url.searchParams).toString(); const next = url.pathname + url.search + url.hash; if (next !== window.location.pathname + window.location.search + window.location.hash) window.history.replaceState(window.history.state, "", next); }, [synced, sel, q, sort, baseSort, allFacets]); const matches = (r: BrowserRow, skip?: string) => { const needle = q.trim().toLowerCase(); if (needle && !`${r.name} ${r.tldr} ${r.sub ?? ""} ${Object.values(r.facets).flat().join(" ")} ${Object.values(r.cols).map((v) => cellText(v)).join(" ")}`.toLowerCase().includes(needle)) return false; for (const f of allFacets) { if (f.key === skip) continue; const want = sel[f.key]; if (!want || !want.length) continue; if (!want.some((w) => facetVals(r, f.key).includes(w))) return false; } return true; }; const filtered = useMemo(() => { return rows.filter((r) => matches(r)).sort(compareBrowserRows(sort)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows, q, sel, sort]); /** * Options per facet, counted over rows that pass every other filter (so counts answer "what happens if I pick this?"). * While a paged table still stands on its first page, the page's whole-table counts are shown instead: the first * rows alone would make every facet look small, and any filter fetches the file, after which the live counts return. */ const options = useMemo(() => { const out: Record = {}; for (const f of allFacets) { let arr: Array<[string, number]>; if (counts?.[f.key] && !remote.complete) arr = counts[f.key]; else { const tally = new Map(); for (const r of rows) if (matches(r, f.key)) for (const v of facetVals(r, f.key)) tally.set(v, (tally.get(v) ?? 0) + 1); arr = [...tally.entries()]; } arr = f.order ? arr.sort((a, b) => (f.order!.indexOf(a[0]) + 1 || 999) - (f.order!.indexOf(b[0]) + 1 || 999)) : arr.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); const dot = (v: string) => { let h = 0; for (const ch of v) h = (h * 31 + ch.charCodeAt(0)) % 360; return ; }; out[f.key] = arr.map(([v, n]) => ({ value: v, label: f.key === "status" ? statusText(v) : v, count: n, icon: COUNTRY_FACETS.has(f.key) ? flagFor(v) || undefined : f.key === "status" ? c.startsWith("bg-")) ?? "bg-foreground/30"}`} /> : ["type","group","stage","severity","maturity","modality","access","scope","category","class","kind","actor","cost","front","specialism","license","nci","phase","purpose"].includes(f.key) ? dot(v) : undefined })); } return out; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows, q, sel, lang, counts, remote.complete]); const onSort = (key: string) => setSort((s) => (s.key === key ? { key, dir: s.dir === 1 ? -1 : 1 } : { key, dir: 1 })); const active = Object.values(sel).some((a) => a.length) || q; /** Which facet each extra column filters from its header: its own key when that is a facet, else the facet its chips point at. */ const columnFacet = useMemo(() => { const keys = new Set(allFacets.map((f) => f.key)); const out: Record = {}; for (const c of columns) { if (c.facet === null) continue; if (c.facet) { if (keys.has(c.facet)) out[c.key] = c.facet; continue; } if (keys.has(c.key)) { out[c.key] = c.key; continue; } for (const r of rows) { const v = r.cols[c.key]; const f = isFacetLink(v) ? v : Array.isArray(v) ? v.find(isFacetLink) : undefined; if (f) { if (keys.has(f.facet)) out[c.key] = f.facet; break; } } } return out; }, [allFacets, columns, rows]); /** The header filter for a facet: the same options and the same setter as its toolbar control, so the two stay in step. */ const headerFilter = (facetKey: string | undefined) => { if (!facetKey) return undefined; const opts = options[facetKey] ?? []; const cur = sel[facetKey] ?? []; if (!opts.length && !cur.length) return undefined; return { options: opts, value: cur, onChange: (v: string[]) => setFacet(facetKey, v) }; }; /** Changes whenever the URL-backed state changes; SaveViewButton re-checks the saved list on it. */ const stateKey = JSON.stringify([sel, q, sort]); /** The filtered rows as flat records for CSV/JSON: identity, status, every facet, every column as text. */ const exportRows = (): CsvRow[] => filtered.map((r) => { const row: CsvRow = { id: r.id, name: r.name }; if (!hideStatus) row.status = r.status ? STATUS_LABEL[r.status] ?? r.status : ""; if (r.sub) row.sub = r.sub; row.tldr = r.tldr; row.url = typeof window === "undefined" ? r.route : new URL(r.route, window.location.origin).toString(); for (const f of facets) row[f.key] = (r.facets[f.key] ?? []).join("; "); for (const c of columns) row[c.key] = cellText(r.cols[c.key]); return row; }); /** A facet chip: a button that filters the table by that value, with a tooltip saying so. */ const facetChip = (f: FacetLink, extraTip?: string, className?: string) => { const label = itemLabel(f); const fl = facetLabel(f.facet); const on = (sel[f.facet] ?? []).includes(f.value); // What the chip says it will do names the value it sets, which is not always what it prints: "2020" filters the // 2020s, "Apache-2.0" filters permissive licences. The printed label stays as the record has it. const action = on ? t("table.filteringBy", { facet: fl, value: f.value }) : t("table.filterBy", { facet: fl, value: f.value }); const tip = [f.tip, extraTip, action].filter(Boolean).join(" "); const tone = valueTone(f.facet, label); const look = className ? `${className} ${on ? "ring-2 ring-accent/50" : ""}` : tone ? `${tone} ${on ? "ring-2 ring-accent/50" : "hover:ring-2 hover:ring-accent/30"}` : on ? "bg-accent-soft text-accent border-accent" : "bg-foreground/5 hover:bg-accent-soft hover:text-accent"; return ( ); }; const entityLink = (l: LinkItem) => (l.tip ? {l.label} : {l.label}); const tableCols: Column[] = [ // The name cell is bounded: its TL;DR would otherwise pull the whole column as wide as the longest summary and push the last columns off the page. { key: "name", label: t("name"), sortable: true, render: (r) => (
{r.molecule && } {r.target && } {r.schematic && } {r.term && } {r.sectionIcon && } {r.cancerIcon && } {(r.logo || r.avatar) && !r.molecule && } {slot && r.kind && visualSource(r) === "kind" && }
{r.name}{r.sub &&
{r.sub}
}{!hideTldr &&
{tldrFor(r.id, r.tldr, lang, tldrTable)}
}
{/* Watch from the list: the star lives in this browser and, when signed in on me.onco.cc, on the account too. */}
) }, ...(hideStatus ? [] : [{ key: "status", label: "Phase / status", sortable: true, filter: headerFilter("status"), render: (r: BrowserRow) => r.molecule ? : r.status ? {facetChip({ facet: "status", value: r.status, label: statusText(r.status) }, STATUS_TIPS[r.status], statusClass(r.status))} : null } as Column]), ...columns.map((c): Column => ({ key: c.key, label: c.label, sortable: c.sortable, hide: c.hide, className: c.className, tip: c.tip, filter: headerFilter(columnFacet[c.key]), render: (r) => { const v = r.cols[c.key]; if (v === undefined || v === "" || (Array.isArray(v) && v.length === 0)) return -{t("none")}; if (isRich(v)) { const parts: React.ReactNode[] = []; let pos = 0; v.marks.forEach((m, i) => { if (m.s > pos) parts.push(v.text.slice(pos, m.s)); parts.push({v.text.slice(m.s, m.e)}); pos = m.e; }); if (pos < v.text.length) parts.push(v.text.slice(pos)); return {parts}; } if (isFacetLink(v)) return facetChip(v, c.valueTips?.[itemLabel(v)]); if (isYearRange(v)) { // The first year filters the table when the row's facets hold it; the rest of the range stays plain text. const key = v.facet; const filter = key && allFacets.some((f) => f.key === key) ? { on: (sel[key] ?? []).includes(String(v.first)), facet: facetLabel(key), onClick: () => clickFacet({ facet: key, value: String(v.first) }) } : undefined; return ; } if (Array.isArray(v)) { const allChips = v.every((i) => !("href" in i)); return ("href" in l ? entityLink(l) : facetChip(l, c.valueTips?.[itemLabel(l)]))} />; } const vt = c.valueTips?.[String(v)]; // Short categorical values (Phase 3, Public, 2024) stay on one line, so a narrow column never splits them. const short = String(v).length <= 18; const cell = c.chip ? {v} : {v}; return vt ? {cell} : cell; }, })), ]; return ( // A paged browser fetches its file as soon as the reader reaches for it (pointer or focus), so facet lists, search and the download are complete by the time they are used.
{allFacets.map((f) => { const opts = options[f.key] ?? []; if (!opts.length && !(sel[f.key]?.length)) return null; return setFacet(f.key, v as string[])} multi searchable={f.searchable ?? true} allLabel="Any" width={f.width ?? "w-48"} />; })} setQ(e.target.value)} placeholder={`${t("table.filterNoun", { noun: nounText(noun) })}…`} aria-label={t("table.filterNoun", { noun: nounText(noun) })} className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-accent/40 w-56" /> {active ? : null} } right={<> } /> {needAll && remote.loading &&

{t("table.loadingMore")}

} {needAll && remote.failed &&

Only the first {first.length} of {more?.total} rows could be searched: the full list did not load. Check your connection and reload.

} r.id} sort={sort} onSort={onSort} pageSize={more ? Math.max(TABLE_PAGE, first.length) : 100} more={more && !remote.complete && !remote.failed && !needAll ? { total: more.total, load: want, loading: remote.loading } : undefined} />
); }