@salesforce/webapp-template-app-react-sample-b2e-experimental 1.116.13 → 1.117.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/webapplications/propertymanagementapp/package.json +3 -3
  3. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/components/layout/FilterRow.tsx +1 -1
  4. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +15 -6
  5. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/FilterContext.tsx +1 -32
  6. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/BooleanFilter.tsx +9 -5
  7. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/DateFilter.tsx +15 -8
  8. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/DateRangeFilter.tsx +8 -7
  9. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +33 -0
  10. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +4 -5
  11. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +113 -82
  12. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/SearchFilter.tsx +24 -11
  13. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/SelectFilter.tsx +9 -5
  14. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/components/filters/TextFilter.tsx +29 -12
  15. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/hooks/useDebouncedCallback.ts +34 -0
  16. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/hooks/useObjectSearchParams.ts +5 -86
  17. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/utils/debounce.ts +4 -1
  18. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/features/object-search/utils/filterUtils.ts +24 -1
  19. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/ApplicationSearch.tsx +13 -18
  20. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/MaintenanceRequestSearch.tsx +13 -18
  21. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/MaintenanceWorkerSearch.tsx +11 -18
  22. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/pages/PropertySearch.tsx +6 -15
  23. package/dist/package-lock.json +2 -2
  24. package/dist/package.json +1 -1
  25. package/package.json +2 -2
  26. package/dist/force-app/main/default/webapplications/propertymanagementapp/src/lib/filterUtils.ts +0 -11
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [1.117.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.117.0...v1.117.1) (2026-03-28)
7
+
8
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
9
+
10
+
11
+
12
+
13
+
14
+ # [1.117.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.13...v1.117.0) (2026-03-27)
15
+
16
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
17
+
18
+
19
+
20
+
21
+
6
22
  ## [1.116.13](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.12...v1.116.13) (2026-03-27)
7
23
 
8
24
  **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
@@ -15,8 +15,8 @@
15
15
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@salesforce/sdk-data": "^1.116.13",
19
- "@salesforce/webapp-experimental": "^1.116.13",
18
+ "@salesforce/sdk-data": "^1.117.1",
19
+ "@salesforce/webapp-experimental": "^1.117.1",
20
20
  "@tailwindcss/vite": "^4.1.17",
21
21
  "class-variance-authority": "^0.7.1",
22
22
  "clsx": "^2.1.1",
@@ -43,7 +43,7 @@
43
43
  "@graphql-eslint/eslint-plugin": "^4.1.0",
44
44
  "@graphql-tools/utils": "^11.0.0",
45
45
  "@playwright/test": "^1.49.0",
46
- "@salesforce/vite-plugin-webapp-experimental": "^1.116.13",
46
+ "@salesforce/vite-plugin-webapp-experimental": "^1.117.1",
47
47
  "@testing-library/jest-dom": "^6.6.3",
48
48
  "@testing-library/react": "^16.1.0",
49
49
  "@testing-library/user-event": "^14.5.2",
@@ -12,7 +12,7 @@ export function FilterRow({
12
12
  }: FilterRowProps) {
13
13
  return (
14
14
  <div
15
- className={cn("bg-white rounded-lg shadow-sm border border-gray-200 p-4", className)}
15
+ className={cn("bg-white rounded-lg shadow-sm border border-gray-200 p-4 pb-2", className)}
16
16
  role="region"
17
17
  aria-label={ariaLabel}
18
18
  {...props}
@@ -62,8 +62,8 @@ const FILTER_CONFIGS: FilterFieldConfig[] = [
62
62
  { field: "Industry", label: "Industry", type: "picklist" },
63
63
  { field: "Type", label: "Type", type: "multipicklist" },
64
64
  { field: "AnnualRevenue", label: "Annual Revenue", type: "numeric" },
65
- { field: "CreatedDate", label: "Created Date", type: "date" },
66
- { field: "LastModifiedDate", label: "Last Modified Date", type: "daterange" },
65
+ { field: "CreatedDate", label: "Created Date", type: "datetime" },
66
+ { field: "LastModifiedDate", label: "Last Modified Date", type: "datetimerange" },
67
67
  ];
68
68
 
69
69
  const ACCOUNT_SORT_CONFIGS: SortFieldConfig<keyof Account_OrderBy>[] = [
@@ -152,7 +152,7 @@ export default function AccountSearch() {
152
152
  </div>
153
153
  </CardHeader>
154
154
  <CollapsibleContent>
155
- <CardContent className="space-y-4 pt-0">
155
+ <CardContent className="space-y-1 pt-0">
156
156
  <SearchFilter
157
157
  field="search"
158
158
  label="Search"
@@ -165,9 +165,18 @@ export default function AccountSearch() {
165
165
  options={industryOptions ?? []}
166
166
  />
167
167
  <MultiSelectFilter field="Type" label="Type" options={typeOptions ?? []} />
168
- <NumericRangeFilter field="AnnualRevenue" label="Annual Revenue" />
169
- <DateFilter field="CreatedDate" label="Created Date" />
170
- <DateRangeFilter field="LastModifiedDate" label="Last Modified Date" />
168
+ <NumericRangeFilter
169
+ field="AnnualRevenue"
170
+ label="Annual Revenue"
171
+ min={0}
172
+ max={1_000_000_000_000}
173
+ />
174
+ <DateFilter field="CreatedDate" label="Created Date" filterType="datetime" />
175
+ <DateRangeFilter
176
+ field="LastModifiedDate"
177
+ label="Last Modified Date"
178
+ filterType="datetimerange"
179
+ />
171
180
  </CardContent>
172
181
  </CollapsibleContent>
173
182
  </Collapsible>
@@ -7,9 +7,6 @@ interface FilterContextValue {
7
7
  onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
8
8
  onFilterRemove: (field: string) => void;
9
9
  onReset: () => void;
10
- onApply: () => void;
11
- hasPendingChanges: boolean;
12
- hasValidationError: boolean;
13
10
  }
14
11
 
15
12
  const FilterContext = createContext<FilterContextValue | null>(null);
@@ -19,9 +16,6 @@ interface FilterProviderProps {
19
16
  onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
20
17
  onFilterRemove: (field: string) => void;
21
18
  onReset: () => void;
22
- onApply?: () => void;
23
- hasPendingChanges?: boolean;
24
- hasValidationError?: boolean;
25
19
  children: ReactNode;
26
20
  }
27
21
 
@@ -30,9 +24,6 @@ export function FilterProvider({
30
24
  onFilterChange,
31
25
  onFilterRemove,
32
26
  onReset,
33
- onApply,
34
- hasPendingChanges = false,
35
- hasValidationError = false,
36
27
  children,
37
28
  }: FilterProviderProps) {
38
29
  return (
@@ -42,9 +33,6 @@ export function FilterProvider({
42
33
  onFilterChange,
43
34
  onFilterRemove,
44
35
  onReset,
45
- onApply: onApply ?? (() => {}),
46
- hasPendingChanges,
47
- hasValidationError,
48
36
  }}
49
37
  >
50
38
  {children}
@@ -75,13 +63,10 @@ export function useFilterField(field: string) {
75
63
  }
76
64
 
77
65
  export function useFilterPanel() {
78
- const { filters, onReset, onApply, hasPendingChanges, hasValidationError } = useFilterContext();
66
+ const { filters, onReset } = useFilterContext();
79
67
  return {
80
68
  hasActiveFilters: filters.length > 0,
81
- hasPendingChanges,
82
- hasValidationError,
83
69
  resetAll: onReset,
84
- apply: onApply,
85
70
  };
86
71
  }
87
72
 
@@ -96,19 +81,3 @@ export function FilterResetButton({ children, ...props }: FilterResetButtonProps
96
81
  </Button>
97
82
  );
98
83
  }
99
-
100
- type FilterApplyButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "disabled">;
101
-
102
- export function FilterApplyButton({ children, ...props }: FilterApplyButtonProps) {
103
- const { apply, hasPendingChanges, hasValidationError } = useFilterPanel();
104
- return (
105
- <Button
106
- onClick={apply}
107
- disabled={!hasPendingChanges || hasValidationError}
108
- aria-label="Apply filters"
109
- {...props}
110
- >
111
- {children ?? "Apply"}
112
- </Button>
113
- );
114
- }
@@ -5,9 +5,9 @@ import {
5
5
  SelectTrigger,
6
6
  SelectValue,
7
7
  } from "../../../../components/ui/select";
8
- import { Label } from "../../../../components/ui/label";
9
8
  import { cn } from "../../../../lib/utils";
10
9
  import { useFilterField } from "../FilterContext";
10
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
11
11
  import type { ActiveFilterValue } from "../../utils/filterUtils";
12
12
 
13
13
  const ALL_VALUE = "__all__";
@@ -21,11 +21,15 @@ interface BooleanFilterProps extends Omit<React.ComponentProps<"div">, "onChange
21
21
  export function BooleanFilter({ field, label, helpText, className, ...props }: BooleanFilterProps) {
22
22
  const { value, onChange } = useFilterField(field);
23
23
  return (
24
- <div className={cn("space-y-1.5", className)} {...props}>
25
- <Label htmlFor={`filter-${field}`}>{label}</Label>
24
+ <FilterFieldWrapper
25
+ label={label}
26
+ htmlFor={`filter-${field}`}
27
+ helpText={helpText}
28
+ className={className}
29
+ {...props}
30
+ >
26
31
  <BooleanFilterSelect field={field} label={label} value={value} onChange={onChange} />
27
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
28
- </div>
32
+ </FilterFieldWrapper>
29
33
  );
30
34
  }
31
35
 
@@ -1,14 +1,15 @@
1
1
  import { useState } from "react";
2
2
  import { parseISO } from "date-fns";
3
- import { Label } from "../../../../components/ui/label";
4
3
  import {
5
4
  DatePicker,
6
5
  DatePickerTrigger,
7
6
  DatePickerContent,
8
7
  DatePickerCalendar,
9
8
  } from "../../../../components/ui/datePicker";
10
- import { cn } from "../../../../lib/utils";
9
+
11
10
  import { useFilterField } from "../FilterContext";
11
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
12
+ import type { FilterFieldType } from "../../utils/filterUtils";
12
13
  import {
13
14
  Select,
14
15
  SelectContent,
@@ -32,9 +33,17 @@ interface DateFilterProps extends Omit<React.ComponentProps<"div">, "onChange">
32
33
  field: string;
33
34
  label: string;
34
35
  helpText?: string;
36
+ filterType?: FilterFieldType;
35
37
  }
36
38
 
37
- export function DateFilter({ field, label, helpText, className, ...props }: DateFilterProps) {
39
+ export function DateFilter({
40
+ field,
41
+ label,
42
+ helpText,
43
+ filterType = "date",
44
+ className,
45
+ ...props
46
+ }: DateFilterProps) {
38
47
  const { value, onChange } = useFilterField(field);
39
48
 
40
49
  const initialOp: DateOperator = value?.min ? "gt" : "lt";
@@ -63,7 +72,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
63
72
  onChange({
64
73
  field,
65
74
  label,
66
- type: "date",
75
+ type: filterType,
67
76
  value: op,
68
77
  min: f === "min" ? dateStr : undefined,
69
78
  max: f === "max" ? dateStr : undefined,
@@ -71,8 +80,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
71
80
  }
72
81
 
73
82
  return (
74
- <div className={cn("space-y-1.5", className)} {...props}>
75
- <Label>{label}</Label>
83
+ <FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
76
84
  <div className="flex gap-2">
77
85
  <Select value={operator} onValueChange={(v) => handleOperatorChange(v as DateOperator)}>
78
86
  <SelectTrigger className="w-full flex-1">
@@ -104,8 +112,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
104
112
  </DatePickerContent>
105
113
  </DatePicker>
106
114
  </div>
107
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
108
- </div>
115
+ </FilterFieldWrapper>
109
116
  );
110
117
  }
111
118
 
@@ -1,25 +1,28 @@
1
1
  import type { DateRange } from "react-day-picker";
2
- import { Label } from "../../../../components/ui/label";
3
2
  import {
4
3
  DatePicker,
5
4
  DatePickerRangeTrigger,
6
5
  DatePickerContent,
7
6
  DatePickerCalendar,
8
7
  } from "../../../../components/ui/datePicker";
9
- import { cn } from "../../../../lib/utils";
8
+
10
9
  import { useFilterField } from "../FilterContext";
10
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
11
+ import type { FilterFieldType } from "../../utils/filterUtils";
11
12
  import { toDate, toDateString } from "./DateFilter";
12
13
 
13
14
  interface DateRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
14
15
  field: string;
15
16
  label: string;
16
17
  helpText?: string;
18
+ filterType?: FilterFieldType;
17
19
  }
18
20
 
19
21
  export function DateRangeFilter({
20
22
  field,
21
23
  label,
22
24
  helpText,
25
+ filterType = "daterange",
23
26
  className,
24
27
  ...props
25
28
  }: DateRangeFilterProps) {
@@ -35,7 +38,7 @@ export function DateRangeFilter({
35
38
  onChange({
36
39
  field,
37
40
  label,
38
- type: "daterange",
41
+ type: filterType,
39
42
  min: toDateString(range?.from),
40
43
  max: toDateString(range?.to),
41
44
  });
@@ -43,8 +46,7 @@ export function DateRangeFilter({
43
46
  }
44
47
 
45
48
  return (
46
- <div className={cn("space-y-1.5", className)} {...props}>
47
- <Label>{label}</Label>
49
+ <FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
48
50
  <DatePicker>
49
51
  <DatePickerRangeTrigger
50
52
  className="w-full"
@@ -63,7 +65,6 @@ export function DateRangeFilter({
63
65
  />
64
66
  </DatePickerContent>
65
67
  </DatePicker>
66
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
67
- </div>
68
+ </FilterFieldWrapper>
68
69
  );
69
70
  }
@@ -0,0 +1,33 @@
1
+ import { Label } from "../../../../components/ui/label";
2
+ import { cn } from "../../../../lib/utils";
3
+
4
+ interface FilterFieldWrapperProps extends React.ComponentProps<"div"> {
5
+ label: string;
6
+ htmlFor?: string;
7
+ helpText?: string;
8
+ error?: string;
9
+ }
10
+
11
+ export function FilterFieldWrapper({
12
+ label,
13
+ htmlFor,
14
+ helpText,
15
+ error,
16
+ className,
17
+ children,
18
+ ...props
19
+ }: FilterFieldWrapperProps) {
20
+ return (
21
+ <div className={cn("space-y-1", className)} {...props}>
22
+ <Label htmlFor={htmlFor}>{label}</Label>
23
+ {children}
24
+ <div className="min-h-4">
25
+ {error ? (
26
+ <p className="text-xs text-destructive">{error}</p>
27
+ ) : (
28
+ helpText && <p className="text-xs text-muted-foreground">{helpText}</p>
29
+ )}
30
+ </div>
31
+ </div>
32
+ );
33
+ }
@@ -4,11 +4,12 @@ import {
4
4
  PopoverTrigger,
5
5
  } from "../../../../components/ui/popover";
6
6
  import { Checkbox } from "../../../../components/ui/checkbox";
7
- import { Label } from "../../../../components/ui/label";
8
7
  import { Button } from "../../../../components/ui/button";
9
8
  import { cn } from "../../../../lib/utils";
9
+ import { Label } from "../../../../components/ui/label";
10
10
  import { ChevronDown } from "lucide-react";
11
11
  import { useFilterField } from "../FilterContext";
12
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
12
13
 
13
14
  interface MultiSelectFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
14
15
  field: string;
@@ -53,8 +54,7 @@ export function MultiSelectFilter({
53
54
  }
54
55
 
55
56
  return (
56
- <div className={cn("space-y-1.5", className)} {...props}>
57
- <Label>{label}</Label>
57
+ <FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
58
58
  <Popover>
59
59
  <PopoverTrigger asChild>
60
60
  <Button
@@ -92,7 +92,6 @@ export function MultiSelectFilter({
92
92
  </div>
93
93
  </PopoverContent>
94
94
  </Popover>
95
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
96
- </div>
95
+ </FilterFieldWrapper>
97
96
  );
98
97
  }
@@ -1,49 +1,52 @@
1
+ import { useEffect, useState } from "react";
1
2
  import { Input } from "../../../../components/ui/input";
2
- import { Label } from "../../../../components/ui/label";
3
- import { toast } from "sonner";
4
- import { cn } from "../../../../lib/utils";
3
+
5
4
  import { useFilterField } from "../FilterContext";
5
+ import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
6
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
6
7
  import type { ActiveFilterValue } from "../../utils/filterUtils";
7
8
 
8
9
  interface NumericRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
9
10
  field: string;
10
11
  label: string;
11
12
  helpText?: string;
12
- minInputProps?: React.ComponentProps<typeof Input>;
13
- maxInputProps?: React.ComponentProps<typeof Input>;
13
+ min?: number;
14
+ max?: number;
14
15
  }
15
16
 
16
17
  export function NumericRangeFilter({
17
18
  field,
18
19
  label,
19
20
  helpText,
21
+ min,
22
+ max,
20
23
  className,
21
- minInputProps,
22
- maxInputProps,
23
24
  ...props
24
25
  }: NumericRangeFilterProps) {
25
26
  const { value, onChange } = useFilterField(field);
26
27
  return (
27
- <div className={cn("space-y-1.5", className)} {...props}>
28
- <Label>{label}</Label>
29
- <NumericRangeFilterInputs
30
- field={field}
31
- label={label}
32
- value={value}
33
- onChange={onChange}
34
- minInputProps={minInputProps}
35
- maxInputProps={maxInputProps}
36
- />
37
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
38
- </div>
28
+ <NumericRangeFilterInputs
29
+ field={field}
30
+ label={label}
31
+ helpText={helpText}
32
+ value={value}
33
+ onChange={onChange}
34
+ min={min}
35
+ max={max}
36
+ className={className}
37
+ {...props}
38
+ />
39
39
  );
40
40
  }
41
41
 
42
42
  interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">, "onChange"> {
43
43
  field: string;
44
44
  label: string;
45
+ helpText?: string;
45
46
  value: ActiveFilterValue | undefined;
46
47
  onChange: (value: ActiveFilterValue | undefined) => void;
48
+ min?: number;
49
+ max?: number;
47
50
  minInputProps?: React.ComponentProps<typeof Input>;
48
51
  maxInputProps?: React.ComponentProps<typeof Input>;
49
52
  }
@@ -51,82 +54,110 @@ interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">
51
54
  export function NumericRangeFilterInputs({
52
55
  field,
53
56
  label,
57
+ helpText,
54
58
  value,
55
59
  onChange,
60
+ min: boundMin,
61
+ max: boundMax,
56
62
  className,
57
- minInputProps,
58
- maxInputProps,
59
63
  ...props
60
64
  }: NumericRangeFilterInputsProps) {
61
- const validateNumericRangeFilter = (filter: ActiveFilterValue) => {
62
- if (filter.type !== "numeric") return null;
63
-
64
- const min = filter.min?.trim();
65
- const max = filter.max?.trim();
66
- const filterLabel = filter.label || filter.field;
65
+ const [localMin, setLocalMin] = useState(value?.min ?? "");
66
+ const [localMax, setLocalMax] = useState(value?.max ?? "");
67
67
 
68
- if (!min || !max) return null;
68
+ const externalMin = value?.min ?? "";
69
+ const externalMax = value?.max ?? "";
70
+ useEffect(() => {
71
+ setLocalMin(externalMin);
72
+ }, [externalMin]);
73
+ useEffect(() => {
74
+ setLocalMax(externalMax);
75
+ }, [externalMax]);
69
76
 
70
- const minValue = Number(min);
71
- const maxValue = Number(max);
72
- if (!Number.isNaN(minValue) && !Number.isNaN(maxValue) && minValue >= maxValue) {
73
- return `${filterLabel}: minimum value must be less than maximum value.`;
74
- }
75
-
76
- return null;
77
+ const isOutOfBounds = (v: string) => {
78
+ if (v === "") return false;
79
+ const n = Number(v);
80
+ return (boundMin != null && n < boundMin) || (boundMax != null && n > boundMax);
77
81
  };
82
+ const minOutOfBounds = isOutOfBounds(localMin);
83
+ const maxOutOfBounds = isOutOfBounds(localMax);
84
+ const isRangeInverted = localMin !== "" && localMax !== "" && Number(localMin) > Number(localMax);
85
+ const hasError = minOutOfBounds || maxOutOfBounds || isRangeInverted;
78
86
 
79
- const handleChange = (bound: "min" | "max", v: string) => {
80
- const next = {
81
- field,
82
- label,
83
- type: "numeric" as const,
84
- min: value?.min ?? "",
85
- max: value?.max ?? "",
86
- [bound]: v,
87
- };
88
-
89
- if (!next.min && !next.max) {
87
+ const debouncedOnChange = useDebouncedCallback((min: string, max: string) => {
88
+ if (!min && !max) {
90
89
  onChange(undefined);
91
- } else {
92
- onChange(next);
90
+ return;
93
91
  }
94
- };
92
+ const minNum = min !== "" ? Number(min) : null;
93
+ const maxNum = max !== "" ? Number(max) : null;
94
+ if (minNum != null && maxNum != null && minNum > maxNum) return;
95
+ if (
96
+ minNum != null &&
97
+ ((boundMin != null && minNum < boundMin) || (boundMax != null && minNum > boundMax))
98
+ )
99
+ return;
100
+ if (
101
+ maxNum != null &&
102
+ ((boundMin != null && maxNum < boundMin) || (boundMax != null && maxNum > boundMax))
103
+ )
104
+ return;
105
+ onChange({ field, label, type: "numeric" as const, min, max });
106
+ });
95
107
 
96
- const handleBlur = (bound: "min" | "max", currentValue: string) => {
97
- const next = {
98
- field,
99
- label,
100
- type: "numeric" as const,
101
- min: bound === "min" ? currentValue : (value?.min ?? ""),
102
- max: bound === "max" ? currentValue : (value?.max ?? ""),
103
- };
104
- const validationError = validateNumericRangeFilter(next);
105
- if (validationError) {
106
- toast.error("Invalid range filter", { description: validationError });
107
- }
108
- };
108
+ const boundsLabel =
109
+ boundMin != null && boundMax != null
110
+ ? `${boundMin}–${boundMax}`
111
+ : boundMin != null
112
+ ? `${boundMin} or more`
113
+ : boundMax != null
114
+ ? `${boundMax} or less`
115
+ : null;
116
+
117
+ const errorMessage = isRangeInverted
118
+ ? "Min must not exceed max"
119
+ : (minOutOfBounds || maxOutOfBounds) && boundsLabel
120
+ ? `Value must be between ${boundsLabel}`
121
+ : undefined;
109
122
 
110
123
  return (
111
- <div className={cn("flex gap-2", className)} {...props}>
112
- <Input
113
- type="number"
114
- placeholder="Min"
115
- value={value?.min ?? ""}
116
- onChange={(e) => handleChange("min", e.target.value)}
117
- onBlur={(e) => handleBlur("min", e.target.value)}
118
- aria-label={`${label} minimum`}
119
- {...minInputProps}
120
- />
121
- <Input
122
- type="number"
123
- placeholder="Max"
124
- value={value?.max ?? ""}
125
- onChange={(e) => handleChange("max", e.target.value)}
126
- onBlur={(e) => handleBlur("max", e.target.value)}
127
- aria-label={`${label} maximum`}
128
- {...maxInputProps}
129
- />
130
- </div>
124
+ <FilterFieldWrapper
125
+ label={label}
126
+ helpText={helpText}
127
+ error={errorMessage}
128
+ className={className}
129
+ {...props}
130
+ >
131
+ <div className="flex gap-2">
132
+ <Input
133
+ type="number"
134
+ placeholder="Min"
135
+ value={localMin}
136
+ min={boundMin}
137
+ max={boundMax}
138
+ onChange={(e) => {
139
+ const v = e.target.value;
140
+ setLocalMin(v);
141
+ debouncedOnChange(v, localMax);
142
+ }}
143
+ aria-label={`${label} minimum`}
144
+ aria-invalid={hasError || undefined}
145
+ />
146
+ <Input
147
+ type="number"
148
+ placeholder="Max"
149
+ value={localMax}
150
+ min={boundMin}
151
+ max={boundMax}
152
+ onChange={(e) => {
153
+ const v = e.target.value;
154
+ setLocalMax(v);
155
+ debouncedOnChange(localMin, v);
156
+ }}
157
+ aria-label={`${label} maximum`}
158
+ aria-invalid={hasError || undefined}
159
+ />
160
+ </div>
161
+ </FilterFieldWrapper>
131
162
  );
132
163
  }