NumberRangeFilter
NumberRangeFilter filters by a number between a minimum and a maximum. Use it when both ends matter, such as a price band. For a single threshold, use NumberComparisonFilter.
Size
'use client';
import { NumberRangeFilterExample } from '@/components/nxds/blocks/filtering/number-range-filter/number-range-filter-example';
export default function NumberRangeFilterDefault() {
return <NumberRangeFilterExample />;
}
Installation
npx nxds-cli@latest add number-range-filter
Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.
Source
To install by hand, copy these files into your project. Paths are relative to packages/react/src/. Copy each file to the same path under components/nxds/, and the imports between files work without edits.
Install these components first: DropdownMenu, FilterCondition, Input, Label, Popover.
import type * as React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '../../components/dropdown-menu/dropdown-menu';
import { FilterConditionSegment } from '../../components/filter-condition/filter-condition';
import {
type FilterOperator,
filterOperatorLabels,
} from '../../lib/filter-model';
/** Dropdown that picks a condition's operator. Your app controls the operator along with the value. */
export function ConditionOperator<Operator extends FilterOperator>({
label,
value,
options,
onChange,
triggerRef,
disabled = false,
onCloseAutoFocus,
}: {
triggerRef?: React.Ref<HTMLButtonElement>;
disabled?: boolean;
onCloseAutoFocus?: (event: Event) => void;
label: string;
value: Operator;
options: readonly Operator[];
onChange: (value: Operator) => void;
}) {
// Radix also fires for the item that is already selected.
function select(next: string) {
const operator = options.find((option) => option === next);
if (disabled || !operator || operator === value) return;
onChange(operator);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<FilterConditionSegment
ref={triggerRef}
disabled={disabled}
className="nx:min-w-16 nx:text-muted-foreground"
title={filterOperatorLabels[value]}
aria-label={`Change ${label} operator`}
>
{filterOperatorLabels[value]}
</FilterConditionSegment>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
aria-label={`${label} operators`}
onCloseAutoFocus={onCloseAutoFocus}
>
<DropdownMenuRadioGroup value={value} onValueChange={select}>
{options.map((operator) => (
<DropdownMenuRadioItem
key={operator}
value={operator}
disabled={disabled}
>
{filterOperatorLabels[operator]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
'use client';
import { useState } from 'react';
import type { FilterRule } from '../../../lib/filter-model';
import {
type NumberRangeCondition,
NumberRangeFilter,
} from './number-range-filter';
export function NumberRangeFilterExample() {
const [value, setValue] = useState<NumberRangeCondition | null>({
operator: 'between',
min: 100,
max: 500,
});
return (
<NumberRangeFilter
label="Size"
value={value}
onChange={setValue}
unit="KB"
lowerBound={0}
/>
);
}
/** Converts this block's condition to a FilterBuilder rule; null adds no rule. */
export function numberRangeRule(
id: string,
field: string,
condition: NumberRangeCondition | null
): FilterRule | null {
if (!condition) return null;
const rule = { kind: 'rule', id, field } as const;
if (condition.operator !== 'between')
return { ...rule, ...condition, value: '' };
return {
...rule,
operator: 'between',
value: [String(condition.min), String(condition.max)],
};
}
'use client';
import * as React from 'react';
import { Button } from '../../../components/button/button';
import {
FilterCondition,
FilterConditionField,
FilterConditionRemove,
FilterConditionSegment,
} from '../../../components/filter-condition/filter-condition';
import { Input } from '../../../components/input/input';
import { Label } from '../../../components/label/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '../../../components/popover/popover';
import { ConditionOperator } from '../filter-operator';
import { useConditionEditor } from '../use-condition-editor';
export type NumberRangeCondition =
| { operator: 'between'; min: number; max: number }
| { operator: 'isEmpty' }
| { operator: 'isNotEmpty' };
export type NumberRangeFilterProps = {
label: string;
icon?: React.ReactNode;
unit?: string;
lowerBound?: number;
upperBound?: number;
disabled?: boolean;
value: NumberRangeCondition | null;
onChange: (value: NumberRangeCondition | null) => void;
};
export function NumberRangeFilter({
label,
icon,
unit,
lowerBound,
upperBound,
disabled = false,
value,
onChange,
}: NumberRangeFilterProps) {
const id = React.useId();
const [min, setMin] = React.useState('');
const [max, setMax] = React.useState('');
const {
applied,
open,
pending,
changeOpen,
changeOperator,
commit,
openPending,
remove,
focusAdd,
operatorRef,
restoreFocus,
} = useConditionEditor({ value, onChange, disabled, onOpen: seedDraft });
const valid =
min !== '' &&
max !== '' &&
Number.isFinite(Number(min)) &&
Number.isFinite(Number(max)) &&
(lowerBound === undefined || Number(min) >= lowerBound) &&
(upperBound === undefined || Number(max) <= upperBound) &&
Number(max) >= Number(min);
const invalid = min !== '' && max !== '' && !valid;
const error = invalid
? `Enter an ordered range${lowerBound === undefined ? '' : ` from ${lowerBound}`}${upperBound === undefined ? '' : ` up to ${upperBound}`}.`
: '';
function seedDraft(current: { min: number; max: number } | null) {
setMin(current ? String(current.min) : '');
setMax(current ? String(current.max) : '');
}
function apply(event: React.FormEvent) {
event.preventDefault();
event.stopPropagation();
if (!valid) return;
commit({ operator: 'between', min: Number(min), max: Number(max) });
}
const summary = applied
? `${applied.min}–${applied.max}${unit ? ` ${unit}` : ''}`
: 'Choose…';
return (
<Popover open={open} onOpenChange={changeOpen}>
{value ? (
<FilterCondition className="nx:flex-wrap nx:gap-y-1">
<div className="nx:inline-flex nx:max-w-full nx:min-w-0">
<FilterConditionField
className="nx:min-w-0 nx:shrink"
title={label}
>
{icon}
<span className="nx:truncate">{label}</span>
</FilterConditionField>
<ConditionOperator
label={label}
disabled={disabled}
triggerRef={operatorRef}
onCloseAutoFocus={openPending}
value={pending ?? value.operator}
options={['between', 'isEmpty', 'isNotEmpty']}
onChange={changeOperator}
/>
</div>
<div className="nx:inline-flex nx:max-w-full nx:min-w-0 nx:border-s-default nx:border-border-default nx:-ms-(--nx-borderwidth-default)">
{(applied || pending) && (
<PopoverTrigger asChild>
<FilterConditionSegment
className="nx:min-w-20"
disabled={disabled}
aria-label={`Edit ${label}: ${summary}`}
>
{summary}
</FilterConditionSegment>
</PopoverTrigger>
)}
<FilterConditionRemove
disabled={disabled}
aria-label={`Remove ${label} filter`}
onClick={remove}
/>
</div>
</FilterCondition>
) : (
<PopoverTrigger asChild>
<Button
disabled={disabled}
ref={focusAdd}
size="sm"
className="nx:h-(--nx-spacing-8)"
variant="outline"
>
Add {label.toLowerCase()} filter
</Button>
</PopoverTrigger>
)}
<PopoverContent
className="nx:w-72 nx:max-w-(--radix-popover-content-available-width) nx:max-h-(--radix-popover-content-available-height) nx:overflow-y-auto nx:p-0"
aria-label={`Filter by ${label.toLowerCase()}`}
align="start"
onCloseAutoFocus={restoreFocus}
>
<form onSubmit={apply}>
<div className="nx:grid nx:grid-cols-2 nx:gap-3 nx:p-3">
<p className="nx:m-0 nx:col-span-2 nx:typography-label-small">
{label}{' '}
{unit && (
<span className="nx:text-muted-foreground">({unit})</span>
)}
</p>
<div className="nx:grid nx:gap-1">
<Label htmlFor={`${id}-min`}>Minimum</Label>
<Input
id={`${id}-min`}
type="number"
min={lowerBound}
max={upperBound}
disabled={disabled}
step="any"
value={min}
onChange={(event) => setMin(event.target.value)}
aria-describedby={`${id}-error`}
aria-invalid={invalid}
/>
</div>
<div className="nx:grid nx:gap-1">
<Label htmlFor={`${id}-max`}>Maximum</Label>
<Input
id={`${id}-max`}
type="number"
min={lowerBound}
max={upperBound}
disabled={disabled}
step="any"
value={max}
onChange={(event) => setMax(event.target.value)}
aria-describedby={`${id}-error`}
aria-invalid={invalid}
/>
</div>
<p
id={`${id}-error`}
role="status"
className="nx:m-0 nx:col-span-2 nx:typography-body-small nx:text-error-subtle-foreground nx:empty:hidden"
>
{error}
</p>
</div>
<div className="nx:flex nx:justify-between nx:gap-2 nx:border-t nx:border-border-default nx:bg-muted-extralight nx:p-3">
<Button
variant="ghost"
size="sm"
className="nx:h-(--nx-spacing-8)"
onClick={() => changeOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="nx:h-(--nx-spacing-8)"
disabled={!valid || disabled}
>
Apply
</Button>
</div>
</form>
</PopoverContent>
</Popover>
);
}
import * as React from 'react';
import {
type FilterOperator,
isValuelessOperator,
type ValuelessOperator,
} from '../../lib/filter-model';
type ValuelessCondition = { operator: ValuelessOperator };
type AppliedCondition<Condition> = Exclude<Condition, ValuelessCondition>;
function isApplied<Condition extends { operator: FilterOperator }>(
value: Condition
): value is AppliedCondition<Condition> {
return !isValuelessOperator(value.operator);
}
/**
* Editor state every filter block shares. Only a complete condition is ever
* published: choosing an operator that needs a value waits as `pending` until
* the editor applies. A controlled replacement closes the editor and drops the
* pending operator. Removing returns focus to the add button.
*/
export function useConditionEditor<
Condition extends { operator: FilterOperator },
>({
value,
onChange,
disabled,
onOpen,
}: {
value: Condition | null;
/** Typed to receive the valueless variant, so every block's condition must include it. */
onChange: (value: NoInfer<Condition> | ValuelessCondition | null) => void;
disabled: boolean;
/** Seeds the editor's draft from the applied condition each time it opens. */
onOpen?: (applied: AppliedCondition<Condition> | null) => void;
}) {
const applied = value && isApplied(value) ? value : null;
const [open, setOpen] = React.useState(false);
const [pending, setPending] = React.useState<
AppliedCondition<Condition>['operator'] | null
>(null);
const addRef = React.useRef<HTMLButtonElement | null>(null);
const operatorRef = React.useRef<HTMLButtonElement>(null);
const restoreAdd = React.useRef(false);
const nextSnapshot = JSON.stringify([value, disabled]);
const [snapshot, setSnapshot] = React.useState(nextSnapshot);
if (snapshot !== nextSnapshot) {
setSnapshot(nextSnapshot);
setOpen(false);
setPending(null);
}
function changeOpen(next: boolean) {
if (disabled && next) return;
if (next) onOpen?.(applied);
setOpen(next);
if (!next) setPending(null);
}
function commit(next: AppliedCondition<Condition>) {
if (disabled) return;
onChange(next);
setOpen(false);
setPending(null);
}
function changeOperator(next: Condition['operator']) {
if (disabled) return;
if (isValuelessOperator(next)) {
setPending(null);
onChange({ operator: next });
return;
}
if (applied) {
onChange({ ...applied, operator: next });
return;
}
setPending(next);
}
function openPending(event: Event) {
if (!pending) return;
event.preventDefault();
changeOpen(true);
}
function remove() {
if (disabled) return;
restoreAdd.current = true;
setOpen(false);
setPending(null);
onChange(null);
}
function focusAdd(node: HTMLButtonElement | null) {
addRef.current = node;
if (node && restoreAdd.current) {
restoreAdd.current = false;
node.focus();
}
}
function restoreFocus(event: Event) {
if (!value) {
event.preventDefault();
addRef.current?.focus();
} else if (!applied) {
event.preventDefault();
operatorRef.current?.focus();
}
}
return {
applied,
open: open && !disabled,
pending,
changeOpen,
changeOperator,
commit,
openPending,
remove,
focusAdd,
operatorRef,
restoreFocus,
};
}
/**
* The meaning every evaluator of a rule follows — the blocks' conditions,
* FilterBuilder trees and the application's own queries:
*
* - Text comparisons (`is`, `isNot`, `contains`, `startsWith`) ignore case.
* - Choice values (`is`, `isNot`, `isAnyOf`, `isNoneOf` on a choice field)
* compare option values exactly.
* - Numbers compare numerically; dates compare as `YYYY-MM-DD` calendar days.
* - `between` includes both ends. `greaterThan`, `lessThan`, `before` and
* `after` exclude the operand.
* - `isEmpty` matches a record with no value: missing, an empty string or an
* empty list. `isNotEmpty` is its negation.
* - `isNot` and `isNoneOf` are the negations of `is` and `isAnyOf`, so they
* also match records with no value.
*/
export type FilterOperator =
| 'is'
| 'isNot'
| 'contains'
| 'startsWith'
| 'greaterThan'
| 'lessThan'
| 'between'
| 'before'
| 'after'
| 'isAnyOf'
| 'isNoneOf'
| 'isEmpty'
| 'isNotEmpty';
export interface FilterOption {
value: string;
label: string;
}
export type FilterField = {
id: string;
label: string;
operators?: readonly FilterOperator[];
} & (
| { type: 'text' | 'number' | 'date' }
| { type: 'choice'; options: readonly FilterOption[] }
);
export type ValuelessOperator = 'isEmpty' | 'isNotEmpty';
type ListOperator = 'isAnyOf' | 'isNoneOf';
type FilterValue<Operator extends FilterOperator> =
Operator extends ValuelessOperator
? ''
: Operator extends ListOperator
? string[]
: Operator extends 'between'
? [string, string]
: string;
/** Numeric and date operands stay strings, so a rule can hold an unfinished draft. */
export type FilterRule = {
[Operator in FilterOperator]: {
kind: 'rule';
id: string;
field: string;
operator: Operator;
value: FilterValue<Operator>;
};
}[FilterOperator];
export interface FilterGroup {
kind: 'group';
id: string;
conjunction: 'all' | 'any';
children: (FilterRule | FilterGroup)[];
}
export type FilterErrorCode =
| 'duplicateId'
| 'unknownField'
| 'unknownOperator'
| 'unexpectedValue'
| 'valueShape'
| 'incompleteRange'
| 'missingValue'
| 'invalidNumber'
| 'invalidDate'
| 'unknownOption'
| 'reversedRange'
| 'tooDeep'
| 'emptyGroup';
export interface FilterError {
id: string;
code: FilterErrorCode;
}
export const filterOperatorLabels: Record<FilterOperator, string> = {
is: 'is',
isNot: 'is not',
contains: 'contains',
startsWith: 'starts with',
greaterThan: 'is greater than',
lessThan: 'is less than',
between: 'is between',
before: 'is before',
after: 'is after',
isAnyOf: 'is any of',
isNoneOf: 'is none of',
isEmpty: 'is empty',
isNotEmpty: 'is not empty',
};
const operatorsByType: Record<FilterField['type'], readonly FilterOperator[]> =
{
text: ['contains', 'is', 'isNot', 'startsWith', 'isEmpty', 'isNotEmpty'],
number: [
'is',
'isNot',
'greaterThan',
'lessThan',
'between',
'isEmpty',
'isNotEmpty',
],
date: [
'is',
'isNot',
'before',
'after',
'between',
'isEmpty',
'isNotEmpty',
],
choice: ['is', 'isNot', 'isAnyOf', 'isNoneOf', 'isEmpty', 'isNotEmpty'],
};
export function getFilterOperators(
field: FilterField
): readonly FilterOperator[] {
const compatible = operatorsByType[field.type];
return field.operators
? compatible.filter((operator) => field.operators?.includes(operator))
: compatible;
}
export function isValuelessOperator(
operator: FilterOperator
): operator is ValuelessOperator {
return operator === 'isEmpty' || operator === 'isNotEmpty';
}
function isListOperator(operator: FilterOperator): operator is ListOperator {
return operator === 'isAnyOf' || operator === 'isNoneOf';
}
export function emptyFilterRule(
id: string,
field: string,
operator: FilterOperator
): FilterRule {
const rule = { kind: 'rule', id, field } as const;
if (isValuelessOperator(operator)) return { ...rule, operator, value: '' };
if (isListOperator(operator)) return { ...rule, operator, value: [] };
if (operator === 'between') return { ...rule, operator, value: ['', ''] };
return { ...rule, operator, value: '' };
}
/** Keeps the value when the new operator takes the same shape of value; otherwise starts empty. */
export function changeFilterOperator(
rule: FilterRule,
operator: FilterOperator
): FilterRule {
const next = emptyFilterRule(rule.id, rule.field, operator);
if (next.operator === 'isEmpty' || next.operator === 'isNotEmpty')
return next;
if (next.operator === 'between')
return rule.operator === 'between' ? { ...next, value: rule.value } : next;
if (next.operator === 'isAnyOf' || next.operator === 'isNoneOf')
return rule.operator === 'isAnyOf' || rule.operator === 'isNoneOf'
? { ...next, value: rule.value }
: next;
return typeof rule.value === 'string' ? { ...next, value: rule.value } : next;
}
/** Plain decimal notation; `Number()` alone also accepts `0x10` and `0b1`. */
const decimalNumber = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
/** A real `YYYY-MM-DD` calendar day. */
export function isCalendarDay(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000'))
return false;
const parsed = new Date(`${value}T00:00:00Z`);
return (
!Number.isNaN(parsed.getTime()) &&
parsed.toISOString().slice(0, 10) === value
);
}
export function getFilterRuleError(
rule: FilterRule,
fields: readonly FilterField[]
): FilterErrorCode | undefined {
const field = fields.find((item) => item.id === rule.field);
if (!field) return 'unknownField';
if (!getFilterOperators(field).includes(rule.operator))
return 'unknownOperator';
if (isValuelessOperator(rule.operator))
return rule.value === '' ? undefined : 'unexpectedValue';
const multiple = isListOperator(rule.operator);
const range = rule.operator === 'between';
if ((multiple || range) !== Array.isArray(rule.value)) return 'valueShape';
const values = Array.isArray(rule.value) ? rule.value : [rule.value];
if (range && values.length !== 2) return 'incompleteRange';
if (!values.length || values.some((value) => !value.trim()))
return 'missingValue';
if (
field.type === 'number' &&
values.some(
(value) =>
!decimalNumber.test(value.trim()) || !Number.isFinite(Number(value))
)
)
return 'invalidNumber';
if (field.type === 'date' && values.some((value) => !isCalendarDay(value)))
return 'invalidDate';
if (
field.type === 'choice' &&
values.some(
(value) => !field.options.some((option) => option.value === value)
)
)
return 'unknownOption';
if (range) {
const [start = '', end = ''] = values;
if (field.type === 'number' ? Number(start) > Number(end) : start > end)
return 'reversedRange';
}
return undefined;
}
/** Root depth is zero. An empty root means no filters; empty nested groups are incomplete. */
export function getFilterErrors(
group: FilterGroup,
fields: readonly FilterField[],
maxDepth = 3
): FilterError[] {
const errors: FilterError[] = [];
const ids = new Set<string>();
function visit(node: FilterGroup | FilterRule, depth: number) {
if (!node.id || ids.has(node.id))
errors.push({ id: node.id, code: 'duplicateId' });
ids.add(node.id);
if (node.kind === 'rule') {
const code = getFilterRuleError(node, fields);
if (code) errors.push({ id: node.id, code });
return;
}
if (depth > maxDepth) errors.push({ id: node.id, code: 'tooDeep' });
if (depth > 0 && !node.children.length)
errors.push({ id: node.id, code: 'emptyGroup' });
for (const child of node.children)
visit(child, child.kind === 'group' ? depth + 1 : depth);
}
visit(group, 0);
return errors;
}