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

@@ -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>
);
}