diff --git a/messages/de.json b/messages/de.json index 4fa9fe2f1..3e310cb6e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8048,7 +8048,11 @@ "typeGroup": "Dokumenttyp", "conditionGroup": "Erkrankung", "yearGroup": "Jahr", - "clear": "Filter zurücksetzen" + "clear": "Filter zurücksetzen", + "typeAll": "Alle Typen", + "typeCount": "{count} Typen", + "conditionAll": "Alle Erkrankungen", + "yearAll": "Alle Jahre" }, "card": { "untitled": "Unbenanntes Dokument", diff --git a/messages/en.json b/messages/en.json index 11139b420..40376a2a6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8048,7 +8048,11 @@ "typeGroup": "Document type", "conditionGroup": "Condition", "yearGroup": "Year", - "clear": "Clear filters" + "clear": "Clear filters", + "typeAll": "All types", + "typeCount": "{count} types", + "conditionAll": "All conditions", + "yearAll": "All years" }, "card": { "untitled": "Untitled document", diff --git a/messages/es.json b/messages/es.json index 771e37580..8ff0241c5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8048,7 +8048,11 @@ "typeGroup": "Tipo de documento", "conditionGroup": "Afección", "yearGroup": "Año", - "clear": "Borrar filtros" + "clear": "Borrar filtros", + "typeAll": "Todos los tipos", + "typeCount": "{count} tipos", + "conditionAll": "Todas las afecciones", + "yearAll": "Todos los años" }, "card": { "untitled": "Documento sin título", diff --git a/messages/fr.json b/messages/fr.json index 251d74cb5..0fa043dde 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8048,7 +8048,11 @@ "typeGroup": "Type de document", "conditionGroup": "Affection", "yearGroup": "Année", - "clear": "Effacer les filtres" + "clear": "Effacer les filtres", + "typeAll": "Tous les types", + "typeCount": "{count} types", + "conditionAll": "Toutes les affections", + "yearAll": "Toutes les années" }, "card": { "untitled": "Document sans titre", diff --git a/messages/it.json b/messages/it.json index 3fc8f452a..3b5ed6961 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8048,7 +8048,11 @@ "typeGroup": "Tipo di documento", "conditionGroup": "Patologia", "yearGroup": "Anno", - "clear": "Cancella filtri" + "clear": "Cancella filtri", + "typeAll": "Tutti i tipi", + "typeCount": "{count} tipi", + "conditionAll": "Tutte le patologie", + "yearAll": "Tutti gli anni" }, "card": { "untitled": "Documento senza titolo", diff --git a/messages/pl.json b/messages/pl.json index fd8299a7a..06dba2454 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8048,7 +8048,11 @@ "typeGroup": "Typ dokumentu", "conditionGroup": "Schorzenie", "yearGroup": "Rok", - "clear": "Wyczyść filtry" + "clear": "Wyczyść filtry", + "typeAll": "Wszystkie typy", + "typeCount": "{count} typy", + "conditionAll": "Wszystkie schorzenia", + "yearAll": "Wszystkie lata" }, "card": { "untitled": "Dokument bez tytułu", diff --git a/src/components/documents/__tests__/documents-view-states.test.tsx b/src/components/documents/__tests__/documents-view-states.test.tsx index 0bfbd899d..56c270cf1 100644 --- a/src/components/documents/__tests__/documents-view-states.test.tsx +++ b/src/components/documents/__tests__/documents-view-states.test.tsx @@ -80,6 +80,27 @@ describe(" states", () => { expect(html).not.toContain("Your document vault is empty"); }); + it("filter bar: type facet is a compact dropdown, not an inline chip row", () => { + const html = render(""); + // The type facet renders as a single dropdown trigger labelled by the + // active selection ("All types" while nothing is picked)… + expect(html).toContain('data-slot="document-type-filter"'); + expect(html).toContain("All types"); + // …and the controls share ONE row — the container never wraps. + expect(html).toContain("flex-nowrap"); + // The old horizontal chip scroller is gone. + expect(html).not.toContain("overflow-x-auto"); + }); + + it("filter bar: an active type selection labels the dropdown trigger", () => { + const html = render("kind=IMAGING", { kinds: ["IMAGING"] }); + // Selection flows into the URL (→ the list query) AND surfaces on the + // trigger label rather than a pressed chip. + expect(html).toContain('data-slot="document-type-filter"'); + expect(html).toContain(">Imaging<"); + expect(html).not.toContain("All types"); + }); + it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => { const html = render("", {}); expect(html).toContain('data-slot="empty-state"'); diff --git a/src/components/documents/document-filter-bar.tsx b/src/components/documents/document-filter-bar.tsx index c71ef91df..c3f068873 100644 --- a/src/components/documents/document-filter-bar.tsx +++ b/src/components/documents/document-filter-bar.tsx @@ -1,30 +1,46 @@ "use client"; /** - * The vault's sticky filter rail: debounced title/filename search, the - * nine-kind type-chip row (multi-select, OR inside the facet), condition - * chips (episodes that actually carry links), a year segmenter (years - * present in the corpus), and a one-tap clear with the active-facet count. + * The vault's sticky filter rail: debounced title/filename search plus three + * compact dropdown facets — type (multi-select, OR inside the facet), + * condition (episodes that actually carry links, single-select), and year + * (years present in the corpus, single-select) — and a one-tap clear with + * the active-facet count. + * + * The controls sit on ONE row at every width: the search flexes and can + * shrink to nothing while each dropdown trigger stays a fixed compact + * control, so the bar never wraps to a second line and never grows a + * horizontal chip scroller. The content-search hint (indexed-content match + * + corpus backfill) rides its own helper line below, only when indexing is + * live. * * Purely presentational — the filter state lives in the page URL and is * owned by `documents-view.tsx`; this bar only renders the controls. It * sticks below the top edge inside the shell's scroll container (sticky, * translucent background, border-b — no viewport-height tricks). */ -import { ScanSearch, Search, X } from "lucide-react"; +import { ChevronDown, ScanSearch, Search, X } from "lucide-react"; import type { RefObject } from "react"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta"; -/** Shared chip chrome — mirrors the app-wide FilterBar pill vocabulary. */ -const CHIP_CLASSES = - "border-border bg-card text-foreground inline-flex min-h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none"; -const CHIP_ACTIVE_CLASSES = "border-primary/40 bg-primary/10"; +/** Active-facet tint on a dropdown trigger — mirrors the app pill vocabulary. */ +const TRIGGER_ACTIVE = "border-primary/40 bg-primary/10 text-foreground"; +/** Shared trigger chrome: compact, fixed, never shrinks below its label. */ +const TRIGGER_CLASSES = "shrink-0 gap-1.5 font-normal"; export interface ConditionChip { episodeId: string; @@ -73,17 +89,36 @@ export function DocumentFilterBar({ }) { const { t } = useTranslations(); + const kindCount = activeKinds.size; + const typeLabel = + kindCount === 0 + ? t("documents.filter.typeAll") + : kindCount === 1 + ? t(`documents.kind.${[...activeKinds][0]}`) + : t("documents.filter.typeCount", { count: kindCount }); + + const activeCondition = conditionChips.find( + (chip) => chip.episodeId === activeEpisodeId, + ); + const conditionLabel = + activeCondition?.name ?? t("documents.filter.conditionAll"); + + const yearLabel = + activeYear !== undefined + ? String(activeYear) + : t("documents.filter.yearAll"); + return (
- {/* Search shares one row with the tag chips on desktop — it wraps to - its own line only on phone widths where a chip scroller plus an - input can't share a row. The chip scroller flexes to fill the - rest; the clear button pins to the trailing edge. */} -
-
+ {/* One row at every width: the search flexes (min-w-0 → it can shrink to + nothing), every dropdown trigger stays a fixed compact control, and + the clear pins to the trailing edge. `flex-nowrap` guarantees the bar + never breaks to a second line. */} +
+
-
- + + + + + + {t("documents.filter.typeGroup")} + + {DOCUMENT_KIND_ORDER.map((kind) => { const Icon = DOCUMENT_KIND_ICONS[kind]; - const active = activeKinds.has(kind); return ( - + ); })} - + + - {conditionChips.length > 0 ? ( - <> - - 0 ? ( + + + - ); - })} - - - ) : null} + {conditionLabel} + + + + + + {t("documents.filter.conditionGroup")} + + + {conditionChips.map((chip) => ( + onToggleEpisode(chip.episodeId)} + > + {chip.name} + + ))} + + + ) : null} - {years.length > 0 ? ( - <> - - 0 ? ( + + + - ); - })} - - - ) : null} -
+ {yearLabel} + + + + + + {t("documents.filter.yearGroup")} + + + {years.map((year) => ( + onToggleYear(year)} + className="tabular-nums" + > + {year} + + ))} + + + ) : null} {activeCount > 0 ? (