mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #435 from MBombeck/release/v1.27.25
Release v1.27.25 — documents filter dropdowns
This commit is contained in:
13 files changed
+280
-121
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.27.25] — 2026-07-08 — Documents filter dropdowns
|
||||
|
||||
### Changed
|
||||
|
||||
- The Documents page filters (type, condition, year) move from an inline chip row into compact dropdowns beside the search field, so the filter bar stays on a single line at every width — on a phone the dropdowns collapse to icons rather than wrapping or scrolling. Selection behaviour is unchanged (type is multi-select; condition and year pick one).
|
||||
|
||||
## [1.27.24] — 2026-07-07 — Share documents
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.27.24
|
||||
version: 1.27.25
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+17
-10
@@ -108,7 +108,7 @@ test.describe("document vault", () => {
|
||||
|
||||
// ── §1A — the 30-second doctor flow, three independent routes ─────────
|
||||
|
||||
test("doctor flow A: the type chip reaches the MRT report", async ({
|
||||
test("doctor flow A: the type filter reaches the MRT report", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents");
|
||||
@@ -116,7 +116,11 @@ test.describe("document vault", () => {
|
||||
page.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Imaging", exact: true }).click();
|
||||
// Open the type dropdown, tick Imaging (multi-select stays open), then
|
||||
// dismiss the menu.
|
||||
await page.getByRole("button", { name: "All types" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Imaging" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
// Autumn 2025 sits below the newest sections — scroll the window down.
|
||||
await scrollUntilVisible(page, openButton(page, "MRT Knie"));
|
||||
await openButton(page, "MRT Knie").click();
|
||||
@@ -128,13 +132,15 @@ test.describe("document vault", () => {
|
||||
await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("doctor flow B: the condition chip reaches the MRT report", async ({
|
||||
test("doctor flow B: the condition filter reaches the MRT report", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents");
|
||||
// Open the condition dropdown and pick the linked episode (single-select
|
||||
// → the menu closes on choice).
|
||||
await page.locator('[data-slot="document-condition-filter"]').click();
|
||||
await page
|
||||
.locator('[data-slot="document-filter-bar"]')
|
||||
.getByRole("button", { name: "Knie", exact: true })
|
||||
.getByRole("menuitemcheckbox", { name: "Knie", exact: true })
|
||||
.click();
|
||||
|
||||
// The Knie filter narrows the corpus to the linked MRT trio.
|
||||
@@ -162,12 +168,13 @@ test.describe("document vault", () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/documents?kind=IMAGING&year=2025");
|
||||
// A deep-linked facet surfaces on its dropdown trigger label.
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Imaging", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
page.locator('[data-slot="document-type-filter"]'),
|
||||
).toContainText("Imaging");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "2025", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
page.locator('[data-slot="document-year-filter"]'),
|
||||
).toContainText("2025");
|
||||
await scrollUntilVisible(page, openButton(page, "MRT Knie"));
|
||||
|
||||
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}`);
|
||||
@@ -345,7 +352,7 @@ test.describe("document vault", () => {
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
|
||||
// Tab from the search into the grid's single roving slot (bounded walk
|
||||
// across the chip rail), then arrows move the active card.
|
||||
// across the filter dropdowns), then arrows move the active card.
|
||||
let reachedGrid = false;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
|
||||
+5
-2
@@ -8067,11 +8067,14 @@
|
||||
"filter": {
|
||||
"searchLabel": "Dokumente durchsuchen",
|
||||
"searchPlaceholder": "Titel oder Dateiname suchen…",
|
||||
"groupLabel": "Dokumente filtern",
|
||||
"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",
|
||||
|
||||
+5
-2
@@ -8067,11 +8067,14 @@
|
||||
"filter": {
|
||||
"searchLabel": "Search documents",
|
||||
"searchPlaceholder": "Search title or filename…",
|
||||
"groupLabel": "Filter documents",
|
||||
"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",
|
||||
|
||||
+5
-2
@@ -8067,11 +8067,14 @@
|
||||
"filter": {
|
||||
"searchLabel": "Buscar documentos",
|
||||
"searchPlaceholder": "Buscar por título o nombre de archivo…",
|
||||
"groupLabel": "Filtrar documentos",
|
||||
"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",
|
||||
|
||||
+5
-2
@@ -8067,11 +8067,14 @@
|
||||
"filter": {
|
||||
"searchLabel": "Rechercher des documents",
|
||||
"searchPlaceholder": "Rechercher un titre ou un nom de fichier…",
|
||||
"groupLabel": "Filtrer les documents",
|
||||
"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",
|
||||
|
||||
+5
-2
@@ -8067,11 +8067,14 @@
|
||||
"filter": {
|
||||
"searchLabel": "Cerca documenti",
|
||||
"searchPlaceholder": "Cerca titolo o nome file…",
|
||||
"groupLabel": "Filtra documenti",
|
||||
"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",
|
||||
|
||||
+5
-2
@@ -8067,11 +8067,14 @@
|
||||
"filter": {
|
||||
"searchLabel": "Szukaj dokumentów",
|
||||
"searchPlaceholder": "Szukaj tytułu lub nazwy pliku…",
|
||||
"groupLabel": "Filtruj dokumenty",
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.27.24",
|
||||
"version": "1.27.25",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.27.24";
|
||||
/* @sw-version-fallback */ "v1.27.25";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -80,6 +80,27 @@ describe("<DocumentsView> 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"');
|
||||
|
||||
@@ -1,30 +1,67 @@
|
||||
"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 {
|
||||
Activity,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ListFilter,
|
||||
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";
|
||||
|
||||
/**
|
||||
* The facet label is the whole point of the trigger — but three text labels
|
||||
* plus the search will not fit a 360px phone. So the label shows from `sm`
|
||||
* up, and on a phone only when the facet is ACTIVE (truncated), while the
|
||||
* leading icon always carries the facet identity. The trailing chevron is
|
||||
* desktop-only chrome. Net: inactive phone triggers collapse to a single
|
||||
* icon, the row stays one line at every width, and the accessible name is
|
||||
* pinned via `aria-label` so an icon-only trigger still announces its state.
|
||||
*/
|
||||
function facetLabelClass(active: boolean): string {
|
||||
return cn("max-w-28 truncate", active ? "inline" : "hidden sm:inline");
|
||||
}
|
||||
|
||||
export interface ConditionChip {
|
||||
episodeId: string;
|
||||
@@ -73,17 +110,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 (
|
||||
<div
|
||||
data-slot="document-filter-bar"
|
||||
className="bg-background/95 border-border sticky top-0 z-10 -mx-4 border-b px-4 pt-1 pb-3 backdrop-blur md:-mx-6 md:px-6"
|
||||
>
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-3">
|
||||
<div className="relative w-full shrink-0 md:w-64">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-nowrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
@@ -106,109 +162,160 @@ export function DocumentFilterBar({
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="-mx-4 flex min-w-0 flex-1 [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
|
||||
role="group"
|
||||
aria-label={t("documents.filter.groupLabel")}
|
||||
>
|
||||
<span
|
||||
role="group"
|
||||
{/* Type — multi-select (OR inside the facet). Items keep the menu
|
||||
open so several kinds can be toggled in one pass. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="document-type-filter"
|
||||
aria-label={typeLabel}
|
||||
className={cn(TRIGGER_CLASSES, kindCount > 0 && TRIGGER_ACTIVE)}
|
||||
>
|
||||
<ListFilter className="size-4 shrink-0" aria-hidden />
|
||||
<span className={facetLabelClass(kindCount > 0)}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="hidden size-3.5 opacity-60 sm:inline"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
aria-label={t("documents.filter.typeGroup")}
|
||||
className="contents"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("documents.filter.typeGroup")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{DOCUMENT_KIND_ORDER.map((kind) => {
|
||||
const Icon = DOCUMENT_KIND_ICONS[kind];
|
||||
const active = activeKinds.has(kind);
|
||||
return (
|
||||
<button
|
||||
<DropdownMenuCheckboxItem
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggleKind(kind)}
|
||||
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
|
||||
checked={activeKinds.has(kind)}
|
||||
onCheckedChange={() => onToggleKind(kind)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden />
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{t(`documents.kind.${kind}`)}
|
||||
</button>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{conditionChips.length > 0 ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
|
||||
/>
|
||||
<span
|
||||
role="group"
|
||||
aria-label={t("documents.filter.conditionGroup")}
|
||||
className="contents"
|
||||
{/* Condition — single-select; only rendered when episodes carry
|
||||
links. Picking closes the menu (natural single-choice cadence). */}
|
||||
{conditionChips.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="document-condition-filter"
|
||||
aria-label={conditionLabel}
|
||||
className={cn(
|
||||
TRIGGER_CLASSES,
|
||||
activeCondition && TRIGGER_ACTIVE,
|
||||
)}
|
||||
>
|
||||
{conditionChips.map((chip) => {
|
||||
const active = chip.episodeId === activeEpisodeId;
|
||||
return (
|
||||
<button
|
||||
key={chip.episodeId}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggleEpisode(chip.episodeId)}
|
||||
className={cn(
|
||||
CHIP_CLASSES,
|
||||
active && CHIP_ACTIVE_CLASSES,
|
||||
)}
|
||||
>
|
||||
<span className="max-w-40 truncate">{chip.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<Activity className="size-4 shrink-0" aria-hidden />
|
||||
<span className={facetLabelClass(Boolean(activeCondition))}>
|
||||
{conditionLabel}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="hidden size-3.5 opacity-60 sm:inline"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
aria-label={t("documents.filter.conditionGroup")}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("documents.filter.conditionGroup")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{conditionChips.map((chip) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={chip.episodeId}
|
||||
checked={chip.episodeId === activeEpisodeId}
|
||||
onCheckedChange={() => onToggleEpisode(chip.episodeId)}
|
||||
>
|
||||
<span className="max-w-56 truncate">{chip.name}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{years.length > 0 ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
|
||||
/>
|
||||
<span
|
||||
role="group"
|
||||
aria-label={t("documents.filter.yearGroup")}
|
||||
className="contents"
|
||||
{/* Year — single-select; only rendered when the corpus spans years. */}
|
||||
{years.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="document-year-filter"
|
||||
aria-label={yearLabel}
|
||||
className={cn(
|
||||
TRIGGER_CLASSES,
|
||||
"tabular-nums",
|
||||
activeYear !== undefined && TRIGGER_ACTIVE,
|
||||
)}
|
||||
>
|
||||
{years.map((year) => {
|
||||
const active = year === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggleYear(year)}
|
||||
className={cn(
|
||||
CHIP_CLASSES,
|
||||
"tabular-nums",
|
||||
active && CHIP_ACTIVE_CLASSES,
|
||||
)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Calendar className="size-4 shrink-0" aria-hidden />
|
||||
<span className={facetLabelClass(activeYear !== undefined)}>
|
||||
{yearLabel}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="hidden size-3.5 opacity-60 sm:inline"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
aria-label={t("documents.filter.yearGroup")}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
{t("documents.filter.yearGroup")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{years.map((year) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={year}
|
||||
checked={year === activeYear}
|
||||
onCheckedChange={() => onToggleYear(year)}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{year}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{activeCount > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="text-muted-foreground shrink-0 self-start md:self-auto"
|
||||
aria-label={t("documents.filter.clear")}
|
||||
className="text-muted-foreground shrink-0"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
{t("documents.filter.clear")}
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.filter.clear")}
|
||||
</span>
|
||||
{activeCount > 1 ? (
|
||||
<span className="tabular-nums">({activeCount})</span>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user