mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user