fix(labs): put the search field beside the filter rail, not in it

Inside the rail, the full-width search field wrapped on phones and left
the filter icon alone on a line above it, and the shared FilterBar grew
a class override and a second layout rule to make room. The search
field now sits next to the unchanged rail: above it on phones, where
the pills keep the usual scroll row, and in one line with it from sm
up. FilterBar and docs/ui-guidelines.md are back as they were, so the
filter-bar grammar stays the one ui-standards.md defers to.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:13:41 +01:00
1 parent 2c63bf7a3a
commit 04a4353e50
5 files changed
+114 -110

No files matched your search

+5 -10
View File
@@ -236,20 +236,15 @@ lists) uses the unified `<FilterBar>` rail from
`src/components/ui/filter-bar.tsx` — never a per-page row of large
labelled `<Select>`s and date inputs.
- **Grammar**: `[search] [icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Where search is supported (such as labs), the
search input is integrated as the leading element in the rail. Canonical
dimension order remains date range, type, source; surface-specific
dimensions append after.
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
type, source; surface-specific dimensions append after.
- **Pill = trigger + chip**: inactive pills show only the dimension
name; active pills show `Name: Wert`, pick up the primary accent and
grow a removable ✕. No separate chip row.
- **Reset** renders only while at least one dimension is active.
- **Mobile** (< `sm`): standard pill rails scroll horizontally (hidden
scrollbar, edge-bleed); the result count wraps to its own line. When an
integrated search input is present in the rail (such as in labs), the
rail wraps (`max-sm:flex-wrap`) so search spans full width and pills
wrap underneath, avoiding horizontal scroll while typing.
- **Mobile** (< `sm`): the rail is a horizontal scroll row (hidden
scrollbar, edge-bleed); the result count wraps to its own line.
- The component is presentation-only — filter state, query keys and
fetching stay in the owning page. Use `FilterBarSelect` for enum
dimensions and `FilterBarDateRange` for the from/to pair.
@@ -41,15 +41,32 @@ describe("<LabFilterRail> layout", () => {
it("keeps search full width and wraps filter controls on phones", async () => {
await page.viewport(390, 844);
const { screen } = await renderWithApp(<LabFilterRail {...props()} />);
// The page gutter the labs page gives the rail (its edge bleed relies on it).
const { screen } = await renderWithApp(
<div className="px-4">
<LabFilterRail {...props()} />
</div>,
);
const search = screen.getByRole("searchbox", { name: "Search biomarkers" });
const dateRange = screen.getByRole("button", { name: "Last measured" });
const searchRect = search.element().getBoundingClientRect();
const dateRect = dateRange.element().getBoundingClientRect();
expect(searchRect.width).toBeGreaterThan(300);
expect(searchRect.width).toBeGreaterThan(340);
expect(dateRect.top).toBeGreaterThan(searchRect.bottom);
// The filter icon leads the pill row instead of sitting on a line of
// its own between the search field and the pills.
const icon = dateRange
.element()
.closest('[data-slot="filter-bar"]')
?.querySelector("svg.lucide-list-filter, svg.lucide-funnel");
expect(icon).toBeTruthy();
expect(Math.abs(centerY(icon!) - centerY(dateRange.element()))).toBeLessThan(
2,
);
expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(390);
});
it("wraps controls cleanly without horizontal overflow at intermediate widths", async () => {
@@ -82,7 +82,7 @@ describe("<LabFilterRail>", () => {
it("pluralizes marker count with tCount across locales", () => {
const extractCountText = (html: string) => {
const match = html.match(
/<span[^>]*data-slot="filter-bar-count"[^>]*>([^<]+)<\/span>/,
/<span[^>]*tabular-nums[^>]*>([^<]+)<\/span>/,
);
return match ? match[1].trim() : null;
};
+82 -76
View File
@@ -64,86 +64,92 @@ export function LabFilterRail({
}),
);
// Search sits beside the rail rather than in it: below `sm` the rail is
// the shared horizontal scroll row, and a full-width input inside it
// would leave the filter icon stranded on a line of its own.
return (
<div data-slot="lab-filter-rail" className="space-y-3">
<FilterBar
isFiltered={isFiltered}
onReset={onResetFilters}
railClassName="max-sm:mx-0 max-sm:flex-wrap max-sm:overflow-visible max-sm:px-0 sm:min-w-0 sm:flex-1"
count={
total !== undefined
? tCount("labs.filter.markerCount", total, {
count: fmt.integer(total),
})
: undefined
}
>
<div className="relative w-full shrink-0 sm:w-56">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
<div
data-slot="lab-filter-rail"
className="flex flex-col gap-3 sm:flex-row sm:items-start"
>
<div className="relative w-full sm:w-56 sm:shrink-0">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
ref={searchInputRef}
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("labs.filter.searchPlaceholder")}
aria-label={t("labs.filter.searchLabel")}
aria-keyshortcuts="/"
className="w-full min-w-0 pr-11 pl-9 [&::-webkit-search-cancel-button]:appearance-none"
/>
{searchDraft === "" ? (
<kbd
aria-hidden="true"
/>
<Input
ref={searchInputRef}
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("labs.filter.searchPlaceholder")}
aria-label={t("labs.filter.searchLabel")}
aria-keyshortcuts="/"
className="w-full min-w-0 pr-11 pl-9 [&::-webkit-search-cancel-button]:appearance-none"
/>
{searchDraft === "" ? (
<kbd
aria-hidden="true"
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
>
/
</kbd>
) : (
<button
type="button"
onClick={() => onSearchChange("")}
aria-label={t("labs.filter.clearSearch")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-0 inline-flex size-11 -translate-y-1/2 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]"
>
<X className="size-4" aria-hidden="true" />
</button>
)}
</div>
<FilterBarDateRange
label={t("labs.filter.lastMeasured")}
from={filters.fromDay ?? ""}
to={filters.toDay ?? ""}
onFromChange={onFromDayChange}
onToChange={onToDayChange}
idPrefix="labs-filter-date"
/>
<FilterBarSelect
label={t("labs.filter.statusLabel")}
value={filters.status ?? "ALL"}
onValueChange={(val) => {
if (isLabStatusFilter(val)) onStatusChange(val);
}}
options={statusOptions}
allValue="ALL"
allLabel={t("labs.filter.statusAll")}
/>
{categoryOptions.length > 0 && (
<FilterBarSelect
label={t("labs.filter.categoryLabel")}
value={filters.category ?? "ALL"}
onValueChange={onCategoryChange}
options={categoryOptions}
allValue="ALL"
allLabel={t("labs.filter.categoryAll")}
/>
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
>
/
</kbd>
) : (
<button
type="button"
onClick={() => onSearchChange("")}
aria-label={t("labs.filter.clearSearch")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-0 inline-flex size-11 -translate-y-1/2 sm:size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]"
>
<X className="size-4" aria-hidden="true" />
</button>
)}
</div>
</FilterBar>
<div className="min-w-0 sm:flex-1">
<FilterBar
isFiltered={isFiltered}
onReset={onResetFilters}
count={
total !== undefined
? tCount("labs.filter.markerCount", total, {
count: fmt.integer(total),
})
: undefined
}
>
<FilterBarDateRange
label={t("labs.filter.lastMeasured")}
from={filters.fromDay ?? ""}
to={filters.toDay ?? ""}
onFromChange={onFromDayChange}
onToChange={onToDayChange}
idPrefix="labs-filter-date"
/>
<FilterBarSelect
label={t("labs.filter.statusLabel")}
value={filters.status ?? "ALL"}
onValueChange={(val) => {
if (isLabStatusFilter(val)) onStatusChange(val);
}}
options={statusOptions}
allValue="ALL"
allLabel={t("labs.filter.statusAll")}
/>
{categoryOptions.length > 0 && (
<FilterBarSelect
label={t("labs.filter.categoryLabel")}
value={filters.category ?? "ALL"}
onValueChange={onCategoryChange}
options={categoryOptions}
allValue="ALL"
allLabel={t("labs.filter.categoryAll")}
/>
)}
</FilterBar>
</div>
</div>
);
}
+7 -21
View File
@@ -28,22 +28,19 @@ import { cn } from "@/lib/utils";
* instead of the per-page rows of large labelled Selects + date inputs
* that had drifted apart. The grammar:
*
* [search] [filter icon] [Zeitraum] [Typ] [Quelle] … [Zurücksetzen] [count]
* [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: search (if present), date range,
* type, source — surface-specific dimensions append after.
* - 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): standard pill rails use a horizontal scroll row (hidden
* - 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. Surfaces that integrate search
* into the rail (such as labs) wrap on mobile instead via
* `railClassName` (`max-sm:flex-wrap`) so the search input stays
* full-width and pills wrap naturally underneath.
* 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.
*
@@ -87,7 +84,6 @@ export function FilterBar({
onReset,
count,
children,
railClassName,
}: {
/** True when at least one dimension is active — shows the reset button. */
isFiltered: boolean;
@@ -95,8 +91,6 @@ export function FilterBar({
onReset: () => void;
/** Result count node (already translated/formatted by the caller). */
count?: React.ReactNode;
/** Optional classes for the row of filter controls. */
railClassName?: string;
children: React.ReactNode;
}) {
const { t } = useTranslations();
@@ -108,12 +102,7 @@ export function FilterBar({
{/* Pill rail — horizontal scroll row below `sm` (hidden scrollbar,
edge-bleed so the row scrolls to the screen edge), wrapping flex
row on desktop. */}
<div
className={cn(
"-mx-4 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden",
railClassName,
)}
>
<div className="-mx-4 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 [&::-webkit-scrollbar]:hidden">
<ListFilter
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
@@ -132,10 +121,7 @@ export function FilterBar({
)}
</div>
{count != null && (
<span
data-slot="filter-bar-count"
className="text-muted-foreground text-sm tabular-nums"
>
<span className="text-muted-foreground text-sm tabular-nums">
{count}
</span>
)}