Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/filter-chip/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The input type decides what the chip edits: text, a number, a date, or a list of

### Input types

FilterChip supports five input types, `select`, `multiselect`, `date`, `string`, and `number`, to handle various filtering needs. `multiselect` takes a `string[]` value and summarizes two or more selections as "N selected".
FilterChip supports five input types, `select`, `multiselect`, `date`, `string`, and `number`, to handle various filtering needs. `multiselect` takes a `string[]` value and summarizes two or more selections as "N selected". `number` rejects any typed or pasted value that is not a number, such as `12ab`. `onValueChange` receives a `number`, or the raw string while the field has no digits (`""`, `-`, `.`, `-.`).

<Demo data={inputDemo} />

Expand Down
9 changes: 7 additions & 2 deletions apps/www/src/content/docs/components/filter-chip/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ export interface FilterChipProps {
label: string;

/** Current value of the filter. `multiselect` takes a `string[]`; `date`
* takes a `Date` (a string or epoch number is parsed for you). */
* takes a `Date` (a string or epoch number is parsed for you); `number`
* shows a value that is not a number as empty. */
value?: string | string[] | number | Date;

/** Type of input for the filter
Expand All @@ -22,7 +23,11 @@ export interface FilterChipProps {
/** Optional array of operations for the type of filter operation */
operations?: { label: string; value: string }[];

/** Callback when the filter value changes; receives the value and the active operation */
/** Callback when the filter value changes; receives the value and the active
* operation. For `number`, non-numeric input is rejected and never reported,
* and the value arrives as a `number`, except for the intermediate states
* `""`, `"-"`, `"."` and `"-."`, which are reported as-is so the field
* stays editable. */
onValueChange?: (
value: string | string[] | number | Date,
operation: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,178 @@ describe('FilterChip', () => {
});
});

describe('Number Filter Type', () => {
const getInput = (container: HTMLElement) =>
container.querySelector(
`.${styles.inputFieldWrapper} input`
) as HTMLInputElement;

it('emits a number, not a string, for numeric input', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

fireEvent.change(getInput(container), { target: { value: '42' } });

expect(onValueChange).toHaveBeenCalledWith(42, expect.any(String));
});

it('rejects non-numeric input', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

const input = getInput(container);
fireEvent.change(input, { target: { value: 'abc' } });

expect(onValueChange).not.toHaveBeenCalled();
expect(input).toHaveValue('');
});

it('rejects a partially numeric paste wholesale', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

const input = getInput(container);
fireEvent.change(input, { target: { value: '12ab' } });

expect(onValueChange).not.toHaveBeenCalled();
expect(input).toHaveValue('');
});

it('keeps intermediate states typeable', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

const input = getInput(container);

fireEvent.change(input, { target: { value: '-' } });
expect(input).toHaveValue('-');

fireEvent.change(input, { target: { value: '1.' } });
expect(input).toHaveValue('1.');

expect(onValueChange).toHaveBeenNthCalledWith(1, '-', expect.any(String));
expect(onValueChange).toHaveBeenNthCalledWith(2, 1, expect.any(String));
});

it('emits a trailing decimal as the number it parses to', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

const input = getInput(container);

fireEvent.change(input, { target: { value: '1.' } });
expect(onValueChange).toHaveBeenLastCalledWith(1, expect.any(String));

fireEvent.change(input, { target: { value: '1.5' } });
expect(onValueChange).toHaveBeenLastCalledWith(1.5, expect.any(String));
});

it('rejects a second decimal point', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

const input = getInput(container);
fireEvent.change(input, { target: { value: '1.2' } });
fireEvent.change(input, { target: { value: '1.2.' } });

expect(input).toHaveValue('1.2');
expect(onValueChange).toHaveBeenCalledTimes(1);
});

it('rejects input that parses to Infinity', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

const input = getInput(container);
fireEvent.change(input, { target: { value: '9'.repeat(309) } });

expect(onValueChange).not.toHaveBeenCalled();
expect(input).toHaveValue('');
});

it('shows a numeric value without an exponent', () => {
const { container: small } = render(
<FilterChip label='Size' value={1e-7} columnType={FilterType.number} />
);
const { container: large } = render(
<FilterChip label='Size' value={1e21} columnType={FilterType.number} />
);

expect(getInput(small)).toHaveValue('0.0000001');
expect(getInput(large)).toHaveValue('1000000000000000000000');
});

it('shows a numeric value and starts empty for a non-numeric one', () => {
const { container: numeric } = render(
<FilterChip label='Size' value={-1.5} columnType={FilterType.number} />
);
const { container: nonNumeric } = render(
<FilterChip label='Size' value='abc' columnType={FilterType.number} />
);

expect(getInput(numeric)).toHaveValue('-1.5');
expect(getInput(nonNumeric)).toHaveValue('');
});

it('emits an empty string when the field is cleared', () => {
const onValueChange = vi.fn();
const { container } = render(
<FilterChip
label='Size'
value={7}
columnType={FilterType.number}
onValueChange={onValueChange}
/>
);

fireEvent.change(getInput(container), { target: { value: '' } });

expect(onValueChange).toHaveBeenCalledWith('', expect.any(String));
});
});

describe('Date Filter Type', () => {
it('renders the date picker without crashing when no value is set', () => {
// Regression: an unset date chip seeds its value with '' and forwarded
Expand Down
38 changes: 36 additions & 2 deletions packages/raystack/components/filter-chip/filter-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ const toDateValue = (value: unknown): Date | undefined => {
return undefined;
};

/** Checked on change, not keydown, so paste and IME input are covered. */
const PARTIAL_NUMBER = /^-?\d*\.?\d*$/;

/**
* Subset of `DatePickerProps` that consumers may forward to the chip's
* built-in DatePicker via `calendarProps`. `value`/`onSelect`/`defaultValue`
Expand Down Expand Up @@ -113,8 +116,21 @@ export const FilterChip = ({
const [operation, setOperation] = useState<FilterOperation | undefined>(
computedOperations?.[0]
);
const isNumberColumn = columnType === FilterType.number;
// `??` not `||`, since a falsy option value like `0` is a real selection.
const [filterValue, setFilterValue] = useState<any>(value ?? '');
// For `number`, the value is shown without an exponent (`1e-7`), and a
// non-numeric value starts empty, since the regex would reject every edit.
const [filterValue, setFilterValue] = useState<any>(() => {
if (!isNumberColumn) return value ?? '';
const text =
typeof value === 'number'
? value.toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 20
})
: String(value ?? '');
return PARTIAL_NUMBER.test(text) ? text : '';
});

const showOnRemove = typeof onRemove === 'function';
const isMultiSelectColumn = columnType === FilterType.multiselect;
Expand All @@ -135,6 +151,24 @@ export const FilterChip = ({
[operation, onValueChange]
);

const handleTextInputChange = useCallback(
(raw: string) => {
if (!isNumberColumn) {
handleFilterValueChange(raw);
return;
}
// Skipping setFilterValue makes React restore the controlled value.
if (!PARTIAL_NUMBER.test(raw)) return;
const parsed = Number(raw);
if (parsed === Infinity || parsed === -Infinity) return;

setFilterValue(raw);
const isIntermediate = raw === '' || Number.isNaN(parsed);
onValueChange?.(isIntermediate ? raw : parsed, operation?.value ?? '');
},
[isNumberColumn, handleFilterValueChange, onValueChange, operation]
);

Comment on lines +154 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use Base UI NumberField primitive instead of all this custom logic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion, Rohan. I did evaluate NumberField initially, but encountered a few issues when using it within the chip:

  1. data-slot: Its input uses a different data-slot, so the chip's existing reset styles do not apply to it.
  2. Styling: It applies its own border, height and text alignment, which conflicts with the chip's content-hugging sizing (field-sizing).
  3. Stepper: It includes Increment and Decrement parts, so the +/− buttons would need to be hidden or restyled within the chip.
  4. Scope: Taken together, this would turn a validation fix into a larger styling change. I therefore kept this fix minimal and reused the existing Input.

I agree the primitive would be a cleaner long-term option. Would you be okay with me addressing it in a follow-up PR, so this one stays limited to the validation fix?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am talking about the BaseUI primitive NumberField, not our Apsara wrapped component

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't switch. I tried the Base UI NumberField primitive directly (Root + Input only) and hit these problems:

  • A paste of 12ab leaves 12ab in the field and sends 12, because parseNumber ignores the letters.
  • It parses with the browser locale: 1,5 is 15 in en-US, and 1.5 is 15 in de-DE.
  • Blur reformats the text: 1000 becomes 1,000, and 1.23456 shows as 1.235 while the value stays 1.23456.
  • It doesn't size to its content: field-sizing is fixed and the width stays at 149px. It also doesn't match the chip's CSS, because the chip's reset rules target [data-slot="input"].
  • - and . send nothing, so the table keeps the previous value.

Pinning locale and format fixes some of this, but paste and layout would still need custom code. The code change would come out about the same size.

const renderValueInput = () => {
switch (columnType) {
case FilterType.multiselect:
Expand Down Expand Up @@ -205,7 +239,7 @@ export const FilterChip = ({
variant={variant === 'text' ? 'borderless' : 'default'}
classNames={{ container: styles.inputField }}
value={filterValue}
onChange={e => handleFilterValueChange(e.target.value)}
onChange={e => handleTextInputChange(e.target.value)}
/>
</div>
);
Expand Down
Loading