diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index 1afa09ad3..d58ef382d 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -198,7 +198,27 @@ admin views. Default to `` + list rows for everything else; tables on mobile collapse poorly. When you must use a table on mobile, swap to a vertical list at `sm:` breakpoint. -### 2.6 Status indicators +### 2.6 Filter bars over data lists + +Every list surface with filters (measurements, mood, future admin +lists) uses the unified `` rail from +`src/components/ui/filter-bar.tsx` — never a per-page row of large +labelled ` - - - - - - {t("measurements.allTypes")} - - {Object.entries(TYPE_LABEL_KEYS).map(([val, labelKey]) => ( - - {t(labelKey)} - - ))} - - - - )} - - {/* v1.15.13 — source filter + date range. Suppressed on the - locked-type insights subpage, which wants a focused list - scoped to the route's single metric (matches the type- - selector suppression above). */} - {!lockedType && ( - <> -
- - -
- -
- - setFromDay(e.target.value)} - /> -
-
- - setToDay(e.target.value)} - /> -
- - )} - - - {data?.meta?.total !== undefined && ( - - {t("measurements.measurementCount", { - count: fmt.integer(data.meta.total), - })} - - )} - + {/* v1.16.1 — unified filter rail (``): compact pill + triggers in the canonical order date range · type · source, + active filters fold into their pill as removable chips, plus + a reset action and the result count. Filter state and query + keys are unchanged — this swap is presentation-only. + On the locked-type insights subpage the rail is suppressed + entirely (the metric is fixed by the route); only the count + line remains. */} + {lockedType ? ( + data?.meta?.total !== undefined && ( +
+ + {t("measurements.measurementCount", { + count: fmt.integer(data.meta.total), + })} + +
+ ) + ) : ( + { + setTypeFilter("ALL"); + setSourceFilter("ALL"); + setFromDay(""); + setToDay(""); + }} + count={ + data?.meta?.total !== undefined + ? t("measurements.measurementCount", { + count: fmt.integer(data.meta.total), + }) + : undefined + } + > + + ({ value: val, label: t(labelKey) }), + )} + /> + ({ + value: src, + label: formatMeasurementSource(src, t), + }))} + /> + + )} {isLoading ? (
diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx index 774e8e154..64438ff32 100644 --- a/src/components/mood/__tests__/mood-list-filter-select.test.tsx +++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx @@ -72,15 +72,16 @@ function render(locale: "en" | "de" = "en") { } describe("MoodList — filter bar + multi-select chrome", () => { - it("renders the source filter control", () => { + // v1.16.1 — the per-page Select/date-input row migrated to the unified + // `` pill rail; same grammar as the measurements list. + it("renders the filter rail with mood, source and date-range pills", () => { const html = render("en"); - expect(html).toContain("Filter by source"); - }); - - it("renders the date-range labels wired to from/to", () => { - const html = render("en"); - expect(html).toContain('for="mood-from"'); - expect(html).toContain('for="mood-to"'); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('aria-label="Mood"'); + expect(html).toContain('aria-label="Source"'); + expect(html).toContain('aria-label="Date range"'); + const pills = html.match(/data-slot="filter-bar-pill"/g); + expect(pills?.length).toBe(3); }); it("renders labelled selection checkboxes (per-row + select-all)", () => { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index b3c4e6bde..57b87fbfe 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -6,12 +6,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + FilterBar, + FilterBarDateRange, + FilterBarSelect, +} from "@/components/ui/filter-bar"; import { AlertDialog, AlertDialogAction, @@ -37,7 +35,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DateInput, DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { Table, @@ -415,93 +413,60 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { return ( <>
-
-
-
- - -
- - {/* v1.15.13 — source filter. */} -
- - -
- - {/* v1.15.13 — optional date range, wired to from/to. */} -
- - setFromDay(e.target.value)} - /> -
-
- - setToDay(e.target.value)} - /> -
-
- - {data?.meta?.total !== undefined && ( - - {t("mood.entryCount", { - count: fmt.integer(data.meta.total), - })} - - )} -
+ {/* v1.16.1 — unified filter rail (``), same grammar as + the measurements list: date range · mood · source as compact + pills, active filters as removable chips, reset + count. + Filter state and query keys unchanged. */} + { + setMoodFilter("ALL"); + setSourceFilter("ALL"); + setFromDay(""); + setToDay(""); + }} + count={ + data?.meta?.total !== undefined + ? t("mood.entryCount", { + count: fmt.integer(data.meta.total), + }) + : undefined + } + > + + ({ + value: val, + label: `${MOOD_SCORES[val]} (${t(MOOD_LABEL_KEYS[val])})`, + }))} + /> + ({ + value: src, + label: formatMoodSource(src, t), + }))} + /> + {isLoading ? (
diff --git a/src/components/ui/__tests__/filter-bar.test.tsx b/src/components/ui/__tests__/filter-bar.test.tsx new file mode 100644 index 000000000..5830b2e0f --- /dev/null +++ b/src/components/ui/__tests__/filter-bar.test.tsx @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { FilterBar, FilterBarDateRange, FilterBarSelect } from "../filter-bar"; + +/** + * v1.16.1 — unified filter rail. SSR-markup guards for the shared + * grammar: pill = trigger + active chip in one element, reset only + * while filtered, count slot, per-pill clear button with a translated + * aria-label. + */ + +const options = [ + { value: "WEIGHT", label: "Weight" }, + { value: "PULSE", label: "Pulse" }, +]; + +function render(node: React.ReactElement, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("FilterBarSelect", () => { + it("inactive: shows only the dimension name, no clear button", () => { + const html = render( + {}}> + {}} + allLabel="All types" + options={options} + /> + , + ); + expect(html).toContain('data-slot="filter-bar-pill"'); + expect(html).toContain('data-active="false"'); + expect(html).toContain("Type"); + expect(html).not.toContain("Clear filter"); + }); + + it("active: shows `label: value`, active styling and a labelled clear button", () => { + const html = render( + {}}> + {}} + allLabel="All types" + options={options} + /> + , + ); + expect(html).toContain('data-active="true"'); + expect(html).toContain("Weight"); + expect(html).toContain('aria-label="Clear filter: Type"'); + }); +}); + +describe("FilterBarDateRange", () => { + it("inactive: pill shows the dimension name only", () => { + const html = render( + {}}> + {}} + onToChange={() => {}} + idPrefix="probe" + /> + , + ); + expect(html).toContain('data-active="false"'); + expect(html).toContain("Date range"); + }); + + it("active: pill shows the formatted bounds and a clear button", () => { + const html = render( + {}}> + {}} + onToChange={() => {}} + idPrefix="probe" + /> + , + ); + expect(html).toContain('data-active="true"'); + // en dateShort: "05/12" / "06/10" — assert both day fragments are in. + expect(html).toMatch(/05\/12/); + expect(html).toMatch(/06\/10/); + expect(html).toContain('aria-label="Clear filter: Date range"'); + }); +}); + +describe("FilterBar shell", () => { + it("shows the reset action only while filtered", () => { + const inactive = render( + {}}> + + , + ); + const active = render( + {}}> + + , + ); + expect(inactive).not.toContain("Reset"); + expect(active).toContain("Reset"); + }); + + it("renders the result count slot", () => { + const html = render( + {}} count="42 readings"> + + , + ); + expect(html).toContain("42 readings"); + expect(html).toContain("tabular-nums"); + }); + + it("localises reset + clear labels in German", () => { + const html = render( + {}}> + {}} + allLabel="Alle Quellen" + options={[{ value: "MANUAL", label: "Manuell" }]} + /> + , + "de", + ); + expect(html).toContain("Zurücksetzen"); + expect(html).toContain('aria-label="Filter entfernen: Quelle"'); + }); +}); diff --git a/src/components/ui/filter-bar.tsx b/src/components/ui/filter-bar.tsx new file mode 100644 index 000000000..599ecdadd --- /dev/null +++ b/src/components/ui/filter-bar.tsx @@ -0,0 +1,289 @@ +"use client"; + +import * as React from "react"; +import { ListFilter, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { DateInput } from "@/components/ui/date-input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.16.1 — unified filter rail for data lists. + * + * One filter language for every list surface (measurements, mood, …) + * instead of the per-page rows of large labelled Selects + date inputs + * that had drifted apart. The grammar: + * + * [filter icon] [Zeitraum] [Typ] [Quelle] … [Zurücksetzen] [count] + * + * - Each dimension renders as ONE compact pill that is trigger and + * active-filter chip at once: inactive it shows just the dimension + * name ("Typ"), active it shows `Name: Wert`, picks up the primary + * accent, and grows a removable ✕. No second chip row, no duplicated + * state. + * - Canonical order at the call sites: date range, type, source — + * surface-specific dimensions append after. + * - Reset appears only while at least one dimension is active. + * - Mobile (< sm): the rail is a horizontal scroll row (hidden + * scrollbar, edge-bleed) so pills never wrap into a tall block; the + * result count drops onto its own line. + * - Purely presentational: filter state, query keys and fetching stay + * in the owning page/component — this file only renders the controls. + * + * A11y: every pill keeps an explicit `aria-label`; the per-pill clear + * button carries `dataList.clearFilter` with the dimension name; touch + * targets follow the dense-surface idiom (`min-h-11 sm:min-h-9`, + * ui-guidelines §5). + */ + +/** Shared pill chrome — trigger and chip in one element. */ +const PILL_CLASSES = + "border-border bg-card text-foreground inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-all duration-150 ease-out sm:min-h-9 h-auto py-1"; +const PILL_ACTIVE_CLASSES = "border-primary/40 bg-primary/10"; + +function ClearPillButton({ + label, + onClear, +}: { + label: string; + onClear: () => void; +}) { + const { t } = useTranslations(); + return ( + + ); +} + +export function FilterBar({ + isFiltered, + onReset, + count, + children, +}: { + /** True when at least one dimension is active — shows the reset button. */ + isFiltered: boolean; + /** Clears every dimension back to its default. */ + onReset: () => void; + /** Result count node (already translated/formatted by the caller). */ + count?: React.ReactNode; + children: React.ReactNode; +}) { + const { t } = useTranslations(); + return ( +
+ {/* Pill rail — horizontal scroll row below `sm` (hidden scrollbar, + edge-bleed so the row scrolls to the screen edge), wrapping flex + row on desktop. */} +
+
+ {count != null && ( + + {count} + + )} +
+ ); +} + +export interface FilterBarSelectOption { + value: string; + label: string; +} + +/** + * One enum dimension as a pill-shaped Radix Select. `allValue` is the + * "no filter" sentinel (the surfaces use `"ALL"`); the pill counts as + * active for any other value and then renders `label: valueLabel` plus + * the clear ✕. + */ +export function FilterBarSelect({ + label, + value, + onValueChange, + options, + allValue = "ALL", + allLabel, +}: { + label: string; + value: string; + onValueChange: (value: string) => void; + options: FilterBarSelectOption[]; + allValue?: string; + allLabel: string; +}) { + const active = value !== allValue; + const activeLabel = active + ? (options.find((o) => o.value === value)?.label ?? value) + : null; + return ( + + + {active && ( + onValueChange(allValue)} + /> + )} + + ); +} + +/** + * The date-range dimension as a pill-shaped Popover with the existing + * from/to `DateInput` pair inside. Active as soon as either bound is + * set; the pill then shows the formatted range and the clear ✕ resets + * both bounds. + */ +export function FilterBarDateRange({ + label, + from, + to, + onFromChange, + onToChange, + idPrefix, +}: { + label: string; + /** ISO day string (`YYYY-MM-DD`) or empty string. */ + from: string; + to: string; + onFromChange: (value: string) => void; + onToChange: (value: string) => void; + /** Unique prefix for the two input ids (label association). */ + idPrefix: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const active = from !== "" || to !== ""; + const display = active + ? `${from ? fmt.dateShort(from) : "…"} – ${to ? fmt.dateShort(to) : "…"}` + : null; + const clear = () => { + onFromChange(""); + onToChange(""); + }; + return ( + + + + {active ? ( + + {label}: + {display} + + ) : ( + {label} + )} + + +
+ + onFromChange(e.target.value)} + /> +
+
+ + onToChange(e.target.value)} + /> +
+
+
+ {active && } +
+ ); +}