fix(filters): auto-apply radios, route popover bug, date operators, polish

- Radio-based filters (single-select enum, boolean) apply the instant
  an option is picked and close — no Apply click needed. Checkbox
  (multi-select) keeps the explicit Apply, since picking several is a
  multi-step gesture.
- Route filter's Select dropdowns portal separately by default, so a
  click landed as "outside" the outer pill popover and closed it
  before a pick registered — same class of bug DateBody had.
  `comboboxProps={{ withinPortal: false }}` fixes it.
- Route filter now pinned by default on both pages instead of behind
  More filters.
- Date filter: widened to before/between/after (repository already
  applies each bound independently) via a new `dateRangeParams`
  helper — the old positional `{v[0]:from, v[1]:to}` mapping silently
  put a "before" pick in the from param. Added a calendar icon + sm
  sizing, and a wider popover so the range presets sidebar has room.
- Pill's clear (X) button enlarged; search box border/text opacity
  strengthened to match the pill trigger restyle.
This commit is contained in:
Nathnael
2026-08-15 07:31:19 +00:00
parent 839dfdbae3
commit 4b4ab21ea1
9 changed files with 93 additions and 26 deletions

View File

@@ -84,7 +84,16 @@ export function FilterBar({
onChange={(e) => controls.setSearchText(e.currentTarget.value)}
size="xs"
radius="lg"
styles={{ input: { fontWeight: 400 } }}
// Regular weight (not the Button-driven 600 the rest of the bar
// uses) and a solid, fully-opaque border/text — same "opaque, not
// faint" fix the inactive pill trigger got.
styles={{
input: {
fontWeight: 400,
borderColor: "var(--mantine-color-gray-6)",
color: "var(--mantine-color-gray-9)",
},
}}
style={{ minWidth: 160, flex: "1 1 160px" }}
/>
)}

View File

@@ -20,6 +20,10 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
route: RouteBody,
};
// Most bodies fit a narrow popover; a date range needs room for the presets
// sidebar next to the calendar, so it gets a wider minimum.
const DROPDOWN_WIDTH: Partial<Record<FilterDef["type"], number>> = { date: 340 };
export interface FilterPillProps {
def: FilterDef;
value: FilterValue | undefined;
@@ -57,7 +61,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
active ? (
<ActionIcon
component="span"
size={16}
size={22}
radius="xl"
variant="subtle"
color="edr-green"
@@ -66,7 +70,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
onChange(undefined);
}}
>
<X size={12} />
<X size={16} />
</ActionIcon>
) : (
<ChevronDown size={14} />
@@ -77,7 +81,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
{active ? `${def.label} | ${formatFilterValue(def, value!)}` : def.label}
</Button>
</Popover.Target>
<Popover.Dropdown miw={260} p="xs">
<Popover.Dropdown miw={DROPDOWN_WIDTH[def.type] ?? 260} p="xs">
<Body def={def} value={value} onChange={onChange} onClose={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button, Radio, Stack } from "@mantine/core";
import { Radio, Stack } from "@mantine/core";
import { DEFAULT_OP } from "../types";
import type { BooleanFilterDef, Operator } from "../types";
@@ -10,23 +10,23 @@ export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps<B
const [op, setOp] = useState<Operator>(value?.op ?? DEFAULT_OP.boolean);
const [v, setV] = useState(value?.v[0] ?? "");
const apply = () => {
onChange(v ? { op, v: [v] } : undefined);
// Two mutually-exclusive options — apply the moment one is picked, same as
// EnumBody's single-select radio. No Apply button needed.
const pick = (next: string) => {
setV(next);
onChange({ op, v: [next] });
onClose();
};
return (
<Stack gap="xs">
<OperatorSelect def={def} value={op} onChange={setOp} />
<Radio.Group value={v} onChange={setV}>
<Radio.Group value={v} onChange={pick}>
<Stack gap={6}>
<Radio value="true" label={def.trueLabel ?? "Yes"} size="sm" />
<Radio value="false" label={def.falseLabel ?? "No"} size="sm" />
</Stack>
</Radio.Group>
<Button size="sm" onClick={apply}>
Apply
</Button>
</Stack>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { Button, Stack } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { CalendarDays } from "lucide-react";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates";
@@ -58,6 +59,8 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<Date
{op === "between" ? (
<DatePickerInput
type="range"
size="sm"
leftSection={<CalendarDays size={14} />}
placeholder="Any"
value={[from, to]}
onChange={([f, t]) => {
@@ -71,6 +74,8 @@ export function DateBody({ def, value, onChange, onClose }: FilterBodyProps<Date
/>
) : (
<DatePickerInput
size="sm"
leftSection={<CalendarDays size={14} />}
placeholder="Any"
value={from}
onChange={setFrom}

View File

@@ -56,11 +56,20 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
const hiddenCount = def.options.length - visible.length;
const apply = () => {
onChange(selected.length ? { op, v: selected } : undefined);
const apply = (v: string[] = selected) => {
onChange(v.length ? { op, v } : undefined);
onClose();
};
// Single-select is a radio pick, not a build-up-a-set gesture — apply the
// instant one is chosen, same as picking an option in a plain Select.
// Checkbox (multiple) still needs the explicit Apply: picking several
// options is a multi-step gesture the popover shouldn't close mid-way through.
const applyRadio = (v: string) => {
setSelected([v]);
apply([v]);
};
return (
<Stack gap="xs">
<OperatorSelect def={def} value={op} onChange={setOp} />
@@ -95,7 +104,7 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
) : (
<Radio.Group
value={selected[0] ?? ""}
onChange={(v) => setSelected(v ? [v] : [])}
onChange={(v) => v && applyRadio(v)}
aria-label={`Filter by ${def.label}`}
>
<Stack gap={0}>
@@ -120,9 +129,11 @@ export function EnumBody({ def, value, onChange, onClose }: FilterBodyProps<Enum
</UnstyledButton>
)}
</Stack>
<Button size="sm" onClick={apply}>
Apply
</Button>
{multiple && (
<Button size="sm" onClick={() => apply()}>
Apply
</Button>
)}
</Stack>
);
}

View File

@@ -20,6 +20,13 @@ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<Rou
onClose();
};
// This popover already lives inside FilterPill's own Popover. A Select's
// dropdown portals separately by default, so a click on an option registers
// as "outside" the outer Popover and closes the whole filter before a pick
// lands — same nested-portal bug DateBody had. Un-portalling keeps it
// inside the outer popover's DOM subtree instead.
const comboboxProps = { withinPortal: false } as const;
return (
<Stack gap="xs" w={240}>
<Select
@@ -28,6 +35,7 @@ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<Rou
data={def.options}
value={origin}
onChange={setOrigin}
comboboxProps={comboboxProps}
searchable
clearable
autoFocus
@@ -39,6 +47,7 @@ export function RouteBody({ def, value, onChange, onClose }: FilterBodyProps<Rou
data={def.options}
value={destination}
onChange={setDestination}
comboboxProps={comboboxProps}
searchable
clearable
/>

View File

@@ -1,3 +1,5 @@
import type { FilterValue } from "./types";
/** Local start-of-day -> ISO, for inclusive "from" date filters. Lifted out of
* ContractRequestsPage (where it was duplicated into BookingRequestsPage) so
* every date filter shares one definition. */
@@ -29,3 +31,21 @@ export function parseDateStr(dateStr: string): Date {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
/**
* `toParams` for a date `FilterDef` widened to `["between", "before", "after"]`
* operators. `DateBody` always emits a single-element `v` for before/after —
* a plain positional `{[fromKey]: v[0], [toKey]: v[1]}` mapping (the
* between-only default) would wrongly land a "before" pick in `fromKey`
* instead of `toKey`. This routes each operator to the right bound.
*/
export function dateRangeParams(
fromKey: string,
toKey: string,
): (v: FilterValue) => Record<string, string | undefined> {
return (value) => {
if (value.op === "before") return { [toKey]: value.v[0] };
if (value.op === "after") return { [fromKey]: value.v[0] };
return { [fromKey]: value.v[0], [toKey]: value.v[1] };
};
}

View File

@@ -26,7 +26,7 @@ import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { formatDate, humanize } from "@/lib/format";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -150,11 +150,19 @@ export default function BookingRequestsPage() {
},
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
{
key: "route", label: "Route", type: "route", options: yardOptions, secondary: true,
key: "route", label: "Route", type: "route", options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
},
{ key: "created", label: "Created", type: "date", secondary: true, toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }) },
{ key: "scheduled", label: "Scheduled", type: "date", secondary: true, toParams: ({ v }) => ({ scheduledFrom: v[0], scheduledTo: v[1] }) },
{
key: "created", label: "Created", type: "date", secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
{
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
},
],
[filterOptions, yardOptions],
);

View File

@@ -52,7 +52,7 @@ import {
DataTableFooter,
type ColumnDef,
} from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
@@ -156,16 +156,17 @@ export default function ContractRequestsPage() {
label: "Created",
type: "date",
secondary: true,
// DateBody already converts to start/end-of-day ISO before calling
// onChange, so this just routes to the API's existing param names.
toParams: ({ v }) => ({ createdFrom: v[0], createdTo: v[1] }),
// Before/after are safe to expose: the repository applies
// createdFrom/createdTo independently, so a single-sided bound
// already works server-side.
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
{
key: "route",
label: "Route",
type: "route",
options: yardOptions,
secondary: true,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
},
],