mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
+5
-10
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user