diff --git a/apps/www/src/app/examples/datatable/page.tsx b/apps/www/src/app/examples/datatable/page.tsx index 7a5d592f7..c8a2d9b6e 100644 --- a/apps/www/src/app/examples/datatable/page.tsx +++ b/apps/www/src/app/examples/datatable/page.tsx @@ -423,10 +423,24 @@ const columns: DataTableColumnDef<(typeof sampleData)[number], unknown>[] = [ { accessorKey: 'team', header: 'Team', + enableColumnFilter: true, + filterType: 'multiselect' as const, enableGrouping: true, showGroupCount: true, enableSorting: true, - enableHiding: true + enableHiding: true, + filterOptions: [ + { value: 'Frontend', label: 'Frontend' }, + { value: 'Backend', label: 'Backend' }, + { value: 'Design', label: 'Design' }, + { value: 'DevOps', label: 'DevOps' }, + { value: 'Content', label: 'Content' }, + { value: 'Growth', label: 'Growth' }, + { value: 'East', label: 'East' }, + { value: 'West', label: 'West' }, + { value: 'Tier 1', label: 'Tier 1' }, + { value: 'Tier 2', label: 'Tier 2' } + ] }, { accessorKey: 'location', diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 18e9e9c79..59a142675 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -46,6 +46,7 @@ import { import dayjs from 'dayjs'; import React, { useState } from 'react'; +/** Kitchen-sink examples route rendering Apsara components for manual QA. */ const Page = () => { const [dialogOpen, setDialogOpen] = useState(false); const [nestedDialogOpen, setNestedDialogOpen] = useState(false); @@ -274,18 +275,20 @@ const Page = () => { dateFormat='D MMM YYYY' value={dayjs().add(16, 'year').toDate()} onSelect={(value: Date) => console.log(value)} - calendarProps={{ - captionLayout: 'dropdown', - startMonth: dayjs().add(3, 'month').toDate(), - endMonth: dayjs().add(4, 'year').toDate(), - disabled: { - before: dayjs().add(3, 'month').toDate(), - after: dayjs().add(3, 'year').toDate() + slotProps={{ + calendar: { + captionLayout: 'dropdown', + startMonth: dayjs().add(3, 'month').toDate(), + endMonth: dayjs().add(4, 'year').toDate(), + disabled: { + before: dayjs().add(3, 'month').toDate(), + after: dayjs().add(3, 'year').toDate() + } + }, + input: { + size: 'small' } }} - inputProps={{ - size: 'small' - }} /> { to: range.to ?? new Date() }) } - calendarProps={{ - captionLayout: 'dropdown', - numberOfMonths: 2, - startMonth: dayjs('2024-01-01').toDate(), - endMonth: dayjs('2027-12-01').toDate(), - defaultMonth: dayjs('2027-11-01').toDate() - }} - inputsProps={{ - startDate: { + slotProps={{ + calendar: { + captionLayout: 'dropdown', + numberOfMonths: 2, + startMonth: dayjs('2024-01-01').toDate(), + endMonth: dayjs('2027-12-01').toDate(), + defaultMonth: dayjs('2027-11-01').toDate() + }, + startInput: { size: 'small' }, - endDate: { + endInput: { size: 'small' } }} @@ -323,8 +326,10 @@ const Page = () => { /> diff --git a/apps/www/src/components/playground/calendar-examples.tsx b/apps/www/src/components/playground/calendar-examples.tsx index 3f59619fb..76d42a74c 100644 --- a/apps/www/src/components/playground/calendar-examples.tsx +++ b/apps/www/src/components/playground/calendar-examples.tsx @@ -3,13 +3,14 @@ import { Calendar, DatePicker, Flex, RangePicker } from '@raystack/apsara'; import PlaygroundLayout from './playground-layout'; +/** Playground showcase for `Calendar`, `DatePicker`, and `RangePicker` using the `slotProps` API. */ export function CalendarExamples() { return ( - - + + ); diff --git a/apps/www/src/components/playground/filter-chip-examples.tsx b/apps/www/src/components/playground/filter-chip-examples.tsx index 360bf0137..42f028832 100644 --- a/apps/www/src/components/playground/filter-chip-examples.tsx +++ b/apps/www/src/components/playground/filter-chip-examples.tsx @@ -17,9 +17,19 @@ export function FilterChipExamples() { { label: 'Inactive', value: 'inactive' } ]} /> - } columnType='date' /> - } columnType='string' /> - } columnType='number' /> + } + columnType='multiselect' + options={[ + { label: 'Frontend', value: 'frontend' }, + { label: 'Backend', value: 'backend' }, + { label: 'Design', value: 'design' } + ]} + /> + } columnType='date' /> + } columnType='string' /> + } columnType='number' /> ); diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index ffd7b05f7..cebb5cc3d 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -146,7 +146,7 @@ export interface RangePickerProps { * - With spaces: "DD MMM YYYY", "MMM DD YYYY", "YYYY MMM DD" * - With full month: "DD MMMM YYYY", "MMMM DD YYYY", "YYYY MMMM DD" * - For more supported formats, refer to https://day.js.org/docs/en/display/format - * @defaultValue "DD/MM/YYYY" + * @defaultValue "DD MMM YYYY" */ dateFormat?: string; @@ -229,7 +229,7 @@ export interface DatePickerProps { * - With dots: "DD.MM.YYYY", "MM.DD.YYYY", "YYYY.MM.DD" * - With spaces: "DD MMM YYYY", "MMM DD YYYY", "YYYY MMM DD" * - With full month: "DD MMMM YYYY", "MMMM DD YYYY", "YYYY MMMM DD" - * @defaultValue "DD/MM/YYYY" + * @defaultValue "DD MMM YYYY" */ dateFormat?: string; diff --git a/apps/www/src/content/docs/components/datatable/index.mdx b/apps/www/src/content/docs/components/datatable/index.mdx index 5944d0e75..caef21bd9 100644 --- a/apps/www/src/content/docs/components/datatable/index.mdx +++ b/apps/www/src/content/docs/components/datatable/index.mdx @@ -159,7 +159,8 @@ interface DataTableColumnDef { enableColumnFilter?: boolean; // Enable filtering enableHiding?: boolean; // Enable column visibility toggle enableGrouping?: boolean; // Enable grouping - filterOptions?: FilterSelectOption[]; // Options for select filter + filterType?: "string" | "number" | "select" | "multiselect" | "date"; // Filter input type + filterOptions?: FilterSelectOption[]; // Options for select/multiselect filters defaultHidden?: boolean; // Hide column by default } ``` @@ -174,6 +175,7 @@ Filter types: - Number: equals, not equals, less than, less than or equal, greater than, greater than or equal - Date: equals, not equals, before, on or before, after, on or after - Select: equals, not equals +- Multiselect: is any of, is none of (value is a `string[]`) ### Sorting diff --git a/apps/www/src/content/docs/components/datatable/props.ts b/apps/www/src/content/docs/components/datatable/props.ts index ec2c71adf..5895f2b5c 100644 --- a/apps/www/src/content/docs/components/datatable/props.ts +++ b/apps/www/src/content/docs/components/datatable/props.ts @@ -92,10 +92,15 @@ export interface DataTableColumnDef { /** Enable grouping */ enableGrouping?: boolean; - /** Options for select filter */ + /** Type of filter input rendered in the filter chip + * @default "string" + */ + filterType?: 'string' | 'number' | 'select' | 'multiselect' | 'date'; + + /** Options for select and multiselect filters */ filterOptions?: FilterSelectOption[]; - /** Props forwarded to filter components by type. Refer to Select component for full props list. */ + /** Props forwarded to filter components by type. Refer to Select and DatePicker for full props lists. */ filterProps?: { select?: { autocomplete?: boolean; @@ -104,6 +109,16 @@ export interface DataTableColumnDef { searchValue?: string; defaultSearchValue?: string; }; + calendar?: { + dateFormat?: string; + showCalendarIcon?: boolean; + timeZone?: string; + slotProps?: { + input?: Record; + calendar?: Record; + popover?: Record; + }; + }; }; /** Hide column by default */ @@ -126,6 +141,16 @@ export interface FiltersProps { availableFilters: DataTableColumn[]; appliedFilters: Set; }) => ReactNode); + + /** Additional CSS class names for the filters container */ + className?: string; + + /** Class names for inner elements. `addFilter` applies to the default + * add-filter triggers only — a custom `trigger` styles itself. */ + classNames?: { + filterChips?: string; + addFilter?: string; + }; } export interface DataTableContentProps { /** diff --git a/apps/www/src/content/docs/components/filter-chip/demo.ts b/apps/www/src/content/docs/components/filter-chip/demo.ts index 2119da7b8..0794409b8 100644 --- a/apps/www/src/content/docs/components/filter-chip/demo.ts +++ b/apps/www/src/content/docs/components/filter-chip/demo.ts @@ -7,7 +7,7 @@ export const getCode = (props: ComponentPropsType) => { const { onRemove, ...rest } = props; const onRemoveProp = onRemove ? `onRemove={() => alert("Removed")}` : ''; - if (props.columnType === 'select') + if (props.columnType === 'select' || props.columnType === 'multiselect') return ` ` }, { - name: 'Select with Autocomplete', + name: 'Multiselect', code: ` } - columnType="select" + columnType="multiselect" options={[ { label: "Active", value: "active" }, { label: "Inactive", value: "inactive" }, - { label: "Pending", value: "pending" }, - { label: "Archived", value: "archived" } + { label: "Pending", value: "pending" } ]} - selectProps={{ autocomplete: true }} />` }, { @@ -123,6 +121,21 @@ export const autocompleteDemo = { selectProps={{ autocomplete: true }} />` }; +export const calendarPropsDemo = { + type: 'code', + code: ` +} + columnType="date" + calendarProps={{ + dateFormat: "YYYY-MM-DD", + slotProps: { + calendar: { captionLayout: "dropdown" } + } + }} +/>` +}; export const iconDemo = { type: 'code', code: ` diff --git a/apps/www/src/content/docs/components/filter-chip/index.mdx b/apps/www/src/content/docs/components/filter-chip/index.mdx index 2e925c4e6..41cbe7d14 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -4,7 +4,7 @@ description: A compact, interactive element for filtering data with various inpu source: packages/raystack/components/filter-chip --- -import { playground, inputDemo, autocompleteDemo, iconDemo, actionDemo, operationsDemo } from "./demo.ts"; +import { playground, inputDemo, autocompleteDemo, calendarPropsDemo, iconDemo, actionDemo, operationsDemo } from "./demo.ts"; @@ -28,7 +28,7 @@ Renders an interactive chip for filtering content. ### Input Types -FilterChip supports four different input types to handle various filtering needs. +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". @@ -38,6 +38,12 @@ Use `selectProps` to enable autocomplete search on select and multiselect filter +### Date with calendarProps + +Use `calendarProps` to forward DatePicker options — `dateFormat`, `timeZone`, `slotProps.calendar`, etc. — to the chip's date control. `value`, `onSelect`, and `defaultValue` are owned by `FilterChip`, and `children` is excluded so the chip's input trigger isn't replaced. + + + ### With Leading Icon FilterChip can display an icon before the label to provide visual context. diff --git a/apps/www/src/content/docs/components/filter-chip/props.ts b/apps/www/src/content/docs/components/filter-chip/props.ts index a8f36ad60..a94709b86 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -2,27 +2,31 @@ export interface FilterChipProps { /** Text label for the filter (required) */ label: string; - /** Current value of the filter */ - value?: string; + /** Current value of the filter. `multiselect` takes a `string[]`; `date` + * takes a `Date` (a string or epoch number is parsed for you). */ + value?: string | string[] | number | Date; /** Type of input for the filter * @default "string" */ - columnType?: 'select' | 'date' | 'string' | 'number'; + columnType?: 'select' | 'multiselect' | 'date' | 'string' | 'number'; /** Filterchip variant * @default "default" */ variant?: 'default' | 'text'; - /** Array of options for the select type input */ + /** Array of options for the select and multiselect type inputs */ options?: { label: string; value: string }[]; - /** Optional array of operations for the type of filter oepration */ + /** Optional array of operations for the type of filter operation */ operations?: { label: string; value: string }[]; - /** Callback when the filter value changes */ - onValueChange?: (value: string) => void; + /** Callback when the filter value changes; receives the value and the active operation */ + onValueChange?: ( + value: string | string[] | number | Date, + operation: string + ) => void; /** Callback when the filter operation changes */ onOperationChange?: (operation: string) => void; @@ -42,6 +46,18 @@ export interface FilterChipProps { defaultSearchValue?: string; }; + /** Props forwarded to the underlying DatePicker for `columnType="date"`. Refer to DatePicker for full props list. `dateFormat` defaults to `"DD MMM YYYY"`. */ + calendarProps?: { + dateFormat?: string; + showCalendarIcon?: boolean; + timeZone?: string; + slotProps?: { + input?: Record; + calendar?: Record; + popover?: Record; + }; + }; + /** Additional CSS class names */ className?: string; } diff --git a/docs/V1-migration.md b/docs/V1-migration.md index efd8e2052..c3298a467 100644 --- a/docs/V1-migration.md +++ b/docs/V1-migration.md @@ -22,6 +22,7 @@ This guide covers all breaking changes when upgrading from the last stable Radix - [Avatar](#avatar) - [Breadcrumb](#breadcrumb) - [Button](#button) + - [Calendar, DatePicker & RangePicker](#calendar-datepicker--rangepicker) - [Chip](#chip) - [Checkbox](#checkbox) - [New: `Checkbox.Group`](#new-checkboxgroup) @@ -35,6 +36,7 @@ This guide covers all breaking changes when upgrading from the last stable Radix - [New Features](#new-features-3) - [DropdownMenu -\> Menu](#dropdownmenu---menu) - [New Features](#new-features-4) + - [FilterChip](#filterchip) - [Flex](#flex) - [Grid](#grid) - [Headline](#headline) @@ -437,6 +439,91 @@ Unchanged: `size`, `radius`, `variant`, `color`, `fallback`, `src`, `alt`, `clas --- +### Calendar, DatePicker & RangePicker + +The three calendar surfaces were overhauled (see the package CHANGELOG, PR #819). The consumer-facing migration items: + +1. **`slotProps` replaces the per-slot props.** `inputProps`, `calendarProps`, `popoverProps` (and `RangePicker`'s `inputsProps`) are now `@deprecated` — they still work, but `slotProps` wins when both are set. Migrate to the consolidated shape: + +```tsx +// Before + + +// After + +``` + +`RangePicker` splits its two inputs explicitly: + +```tsx +// Before + + +// After + +``` + +2. **`DatePicker` no longer defaults to today.** Previously `value` defaulted to `new Date()`, so the picker always rendered with today selected. It now starts **unselected** when neither `value` nor `defaultValue` is passed, and honors the "Select date" placeholder. If you relied on the today-default, opt in explicitly: + +```tsx +// Before — implicitly selected today + + +// After — opt in to the old behavior + +``` + +`RangePicker` likewise drops its `{ from: today, to: today }` default and starts empty. + +3. **`value` requires a real `Date` (or `undefined`).** Both pickers are now strict about their controlled value and sync on its timestamp — passing a string or other non-`Date` will throw. Coerce before passing: + +```tsx +// Before — a string happened to coerce via dayjs + + +// After + +``` + +4. **`onSelect` only fires with a defined date.** It stays typed `(date: Date) => void` and no longer fires with `undefined` (e.g. on deselect). Use `defaultValue` for uncontrolled initialization instead of relying on `onSelect` firing on mount. + +5. **`value` is now reactive.** Controlled changes — form resets, preset buttons, URL-driven updates — propagate to the input on both pickers (previously the input could go stale). + +6. **Calendar date-bound props renamed.** `fromYear` / `toYear` / `fromMonth` / `toMonth` / `fromDate` / `toDate` are superseded by `startMonth` / `endMonth` (bounds) and `hidden` (disable specific days). See the Calendar docs. + +7. **New public types.** `CalendarProps`, `CalendarPropsExtended`, `DateRange`, `DatePickerProps`, `DatePickerSlotProps`, `FilterChipProps`, `FilterChipValue`, and `FilterChipCalendarProps` are now re-exported from `@raystack/apsara`. +8. **`dateFormat` default is now `"DD MMM YYYY"`.** Both `DatePicker` and `RangePicker` now render text-based months (e.g. "27 May 2026") by default instead of the locale-ambiguous "27/05/2026". If you rely on the slash format, opt in explicitly: + +```tsx +// Before — implicit DD/MM/YYYY default + + +// After — same display + +``` + +`FilterChip`'s `columnType="date"` inherits this new default directly (its prior internal override is dropped). To restore slash-format inside a chip, pass it via `calendarProps`: + +```tsx + +``` + +--- + ### Chip **`ariaLabel` prop removed** -- use the standard `aria-label` HTML attribute: @@ -1021,6 +1108,43 @@ import { Menu } from '@raystack/apsara'; --- +### FilterChip + +**Date column values are parsed, not forwarded raw.** `FilterChip` forwards its value to the overhauled `DatePicker` (see [Calendar, DatePicker & RangePicker](#calendar-datepicker--rangepicker)), which is now strict about its `value` type — but the chip shields you from that: a `Date` passes through, and a string or epoch number (e.g. filter state hydrated from a URL or serialized query) is parsed for you. Only an unparseable value starts the date field **unselected** instead of erroring. + +```tsx +// All equivalent + + +``` + +`onValueChange` for date columns receives a `Date` as before, and non-date column types are unaffected. + +**New: `calendarProps` forwards arbitrary `DatePicker` props.** Mirrors the existing `selectProps` pattern. Use it to set `dateFormat` (defaults to `"DD MMM YYYY"`), `timeZone`, `slotProps.calendar`, etc. on the chip's date control. `value`, `onSelect`, `defaultValue`, and `children` stay owned by `FilterChip`. + +```tsx + +``` + +In `DataTable` / `DataView`, column-level `filterProps` gains a parallel `calendar` slot alongside `select`: + +```tsx +{ + accessorKey: "created_at", + filterType: "date", + filterProps: { calendar: { dateFormat: "YYYY-MM-DD" } } +} +``` + +--- + ### Flex ```tsx diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 2750c464e..238c21663 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -78,6 +78,32 @@ API added, and the three legacy prop names (`inputProps`, `fill="none"` on stroke-based icons (lucide) and filling the outline paths solid. `color` alone now carries the selected style via `currentColor` for both stroke- and fill-based icon libraries. +- **FilterChip date column no longer crashes** — the stricter + `DatePicker` `value?: Date` contract above surfaced a latent bug: + `FilterChip` seeded its value with `''` and forwarded that string + straight to the picker, so the new controlled-sync effect's + `valueProp?.getTime()` threw `TypeError`. `FilterChip` now parses + string and epoch-number values into a `Date` (unparseable values + start the field unselected) and uses the new `slotProps.input` API + instead of the deprecated `inputProps`. +- **FilterChip `calendarProps`** — mirrors the existing `selectProps` + pattern: forwards arbitrary props (e.g. `dateFormat`, `timeZone`, + `slotProps.calendar`) to the underlying `DatePicker` for + `columnType="date"`. `value`/`onSelect`/`defaultValue`/`children` + remain owned by `FilterChip`. The standalone `dateFormat` prop is + removed — pass `calendarProps={{ dateFormat: '…' }}` instead. + `DataTable` / `DataView` columns gain a parallel `filterProps.calendar` + slot alongside `filterProps.select`. The supporting types — + `FilterChipProps`, `FilterChipCalendarProps`, `FilterChipValue`, + `DatePickerProps`, `DatePickerSlotProps` — are exported from the + package root. +- **`DatePicker` / `RangePicker` `dateFormat` default is now + `"DD MMM YYYY"`** (previously `"DD/MM/YYYY"`). Text-based months + (e.g. "27 May 2026") avoid the DD/MM vs MM/DD ambiguity that + showed up in mixed-locale screenshots. Consumers who relied on + the slash default must pass `dateFormat="DD/MM/YYYY"` explicitly. + `FilterChip`'s `columnType="date"` inherits the new default + directly — its prior internal override is removed. #### Code-review and audit follow-ups @@ -153,6 +179,32 @@ for the tz-aware `dateKey` fix. - `dayjs` bumped to `^1.11.20` (was `^1.11.11`) for the strict-parse + tz plugins. +### FilterChip & filter toolbar fixes (PR #821) + +#### Fixes + +- **FilterChip values truncate instead of clipping** — the value + input hugs its content (`field-sizing: content`, `width: auto` + fallback), caps at 200px, and under toolbar resize pressure shrinks + with a visible ellipsis and intact side padding (previously the + wrapper clipped the input, hiding both). An empty value keeps a + 50px clickable floor — the whole visible value area now focuses the + input. +- **Applied filter chips wrap to the panel** — `DataTable`'s + `.filterContainer` and `DataView`'s filters row fill the toolbar, + wrap, and let chips shrink instead of overflowing in a single row. +- **DataTable: adding a `select` filter with no `filterOptions` no + longer crashes** (`options[0].value` → `options[0]?.value`, parity + with DataView). +- **DataTable: `multiselect` filters preselect the first option** + (matching `select`; `[]` when there are no options) instead of + falling through to `''` — the chip's multi-`Select` expects an + array value. +- **DataTable: `classNames.addFilter` is now applied** to the default + add-filter triggers (it was accepted and silently dropped). +- **FilterChip: removed a dead `selectColumn` class reference** left + behind by #810; the chip's `border-radius` + `overflow: clip` + already rounds the select trigger. ## 0.11.3 diff --git a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx index 0a0b5e9ad..dbc539924 100644 --- a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx +++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx @@ -161,6 +161,18 @@ describe('DatePicker', () => { }); }); + describe('default dateFormat', () => { + // Default is `DD MMM YYYY` (text-based month) so the input reads + // "27 May 2026" instead of the locale-ambiguous "27/05/2026". + it('renders a controlled Date in the DD MMM YYYY format by default', () => { + render(); + const input = screen.getByPlaceholderText( + 'Select date' + ) as HTMLInputElement; + expect(input.value).toBe('27 May 2026'); + }); + }); + describe('value prop is reactive', () => { /* * Earlier the picker only used `value` as the initial useState seed, so @@ -173,10 +185,10 @@ describe('DatePicker', () => { const input = screen.getByPlaceholderText( 'Select date' ) as HTMLInputElement; - expect(input.value).toBe('01/01/2026'); + expect(input.value).toBe('01 Jan 2026'); rerender(); - expect(input.value).toBe('15/06/2026'); + expect(input.value).toBe('15 Jun 2026'); }); }); @@ -224,7 +236,7 @@ describe('DatePicker', () => { const input = screen.getByPlaceholderText( 'Select date' ) as HTMLInputElement; - expect(input.value).toBe('15/06/2024'); + expect(input.value).toBe('15 Jun 2024'); }); it('value prop takes precedence over defaultValue', () => { @@ -237,7 +249,7 @@ describe('DatePicker', () => { const input = screen.getByPlaceholderText( 'Select date' ) as HTMLInputElement; - expect(input.value).toBe('01/01/2025'); + expect(input.value).toBe('01 Jan 2025'); }); it('defaultValue is only honored at mount, not on later rerenders', () => { @@ -247,12 +259,12 @@ describe('DatePicker', () => { const input = screen.getByPlaceholderText( 'Select date' ) as HTMLInputElement; - expect(input.value).toBe('15/06/2024'); + expect(input.value).toBe('15 Jun 2024'); // Changing defaultValue after mount should NOT update the input // (uncontrolled semantics). rerender(); - expect(input.value).toBe('15/06/2024'); + expect(input.value).toBe('15 Jun 2024'); }); }); @@ -264,7 +276,7 @@ describe('DatePicker', () => { */ it('Enter after typing a valid date fires onSelect exactly once', () => { const onSelect = vi.fn(); - render(); + render(); const input = screen.getByPlaceholderText( 'Select date' @@ -287,7 +299,7 @@ describe('DatePicker', () => { * are now allowed; bounds come from `startMonth` / `endMonth`. */ it('accepts a future date when no calendarProps bounds are set', () => { - render(); + render(); const input = screen.getByPlaceholderText( 'Select date' @@ -299,7 +311,10 @@ describe('DatePicker', () => { it('accepts a future date within endMonth', () => { render( - + ); const input = screen.getByPlaceholderText( @@ -311,7 +326,10 @@ describe('DatePicker', () => { it('rejects a date past endMonth', () => { render( - + ); const input = screen.getByPlaceholderText( @@ -323,7 +341,10 @@ describe('DatePicker', () => { it('rejects a date before startMonth', () => { render( - + ); const input = screen.getByPlaceholderText( @@ -345,7 +366,13 @@ describe('DatePicker', () => { it('does not commit partial single-digit input as a date', () => { const onSelect = vi.fn(); const initial = new Date(2026, 4, 20); // 20/05/2026 - render(); + render( + + ); const input = screen.getByPlaceholderText( 'Select date' @@ -365,7 +392,7 @@ describe('DatePicker', () => { it('does not commit partial multi-char input that V8 would lenient-parse', () => { const initial = new Date(2026, 4, 20); - render(); + render(); const input = screen.getByPlaceholderText( 'Select date' @@ -377,7 +404,7 @@ describe('DatePicker', () => { }); it('accepts a fully-typed valid date matching dateFormat', () => { - render(); + render(); const input = screen.getByPlaceholderText( 'Select date' @@ -395,7 +422,13 @@ describe('DatePicker', () => { */ it('keeps typed characters visible while typing a full date one char at a time', () => { const onSelect = vi.fn(); - render(); + render( + + ); const input = screen.getByPlaceholderText( 'Select date' @@ -422,7 +455,7 @@ describe('DatePicker', () => { it('does not fire onSelect while typing — partial stays uncommitted, valid waits for commit (Enter/blur/outside-click)', () => { const onSelect = vi.fn(); - render(); + render(); const input = screen.getByPlaceholderText( 'Select date' @@ -523,6 +556,7 @@ describe('DatePicker', () => { */ render( { * Covers the no-bounds path — past regression had an unconditional * `isSameOrBefore(dayjs())` that threw without the plugin extended. */ - render(); + render(); const input = screen.getByPlaceholderText( 'Select date' diff --git a/packages/raystack/components/calendar/__tests__/range-picker.test.tsx b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx index e1a58acf4..18df0a121 100644 --- a/packages/raystack/components/calendar/__tests__/range-picker.test.tsx +++ b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx @@ -75,8 +75,8 @@ describe('RangePicker', () => { 'Select end date' ) as HTMLInputElement; - expect(startInput.value).toBe('01/01/2026'); - expect(endInput.value).toBe('15/01/2026'); + expect(startInput.value).toBe('01 Jan 2026'); + expect(endInput.value).toBe('15 Jan 2026'); }); it('does not crash when value is undefined and no defaultValue', () => { @@ -227,7 +227,7 @@ describe('RangePicker', () => { const endInput = screen.getByPlaceholderText( 'Select end date' ) as HTMLInputElement; - expect(startInput.value).toBe('15/01/2026'); + expect(startInput.value).toBe('15 Jan 2026'); expect(endInput.value).toBe(''); /* @@ -264,8 +264,8 @@ describe('RangePicker', () => { const endInput = screen.getByPlaceholderText( 'Select end date' ) as HTMLInputElement; - expect(startInput.value).toBe('15/01/2026'); - expect(endInput.value).toBe('20/01/2026'); + expect(startInput.value).toBe('15 Jan 2026'); + expect(endInput.value).toBe('20 Jan 2026'); /* * Popover should have closed -> Calendar unmounted. A tight call-count @@ -299,7 +299,7 @@ describe('RangePicker', () => { const endInput = screen.getByPlaceholderText( 'Select end date' ) as HTMLInputElement; - expect(startInput.value).toBe('10/01/2026'); + expect(startInput.value).toBe('10 Jan 2026'); expect(endInput.value).toBe(''); // to cleared expect(endInput.getAttribute('data-active')).toBe('true'); // still on 'to' }); @@ -325,7 +325,7 @@ describe('RangePicker', () => { const endInput = screen.getByPlaceholderText( 'Select end date' ) as HTMLInputElement; - expect(startInput.value).toBe('01/02/2026'); + expect(startInput.value).toBe('01 Feb 2026'); expect(endInput.value).toBe(''); expect(endInput.getAttribute('data-active')).toBe('true'); }); diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx index da311dabf..4206495b4 100644 --- a/packages/raystack/components/calendar/date-picker.tsx +++ b/packages/raystack/components/calendar/date-picker.tsx @@ -27,13 +27,13 @@ dayjs.extend(isSameOrBefore); */ type DatePickerCalendarSlot = Omit & CalendarPropsExtended; -interface DatePickerSlotProps { +export interface DatePickerSlotProps { input?: InputProps; calendar?: DatePickerCalendarSlot; popover?: PopoverContentProps; } -interface DatePickerProps { +export interface DatePickerProps { dateFormat?: string; /** * Props for each picker slot. When both this and the legacy @@ -57,7 +57,7 @@ interface DatePickerProps { } export function DatePicker({ - dateFormat = 'DD/MM/YYYY', + dateFormat = 'DD MMM YYYY', slotProps, inputProps: legacyInputProps, calendarProps: legacyCalendarProps, diff --git a/packages/raystack/components/calendar/index.tsx b/packages/raystack/components/calendar/index.tsx index b9b02d6d0..536256abe 100644 --- a/packages/raystack/components/calendar/index.tsx +++ b/packages/raystack/components/calendar/index.tsx @@ -1,5 +1,6 @@ export type { DateRange } from 'react-day-picker'; export type { CalendarProps, CalendarPropsExtended } from './calendar'; export { Calendar } from './calendar'; +export type { DatePickerProps, DatePickerSlotProps } from './date-picker'; export { DatePicker } from './date-picker'; export { RangePicker } from './range-picker'; diff --git a/packages/raystack/components/calendar/range-picker.tsx b/packages/raystack/components/calendar/range-picker.tsx index 051b03202..f97a5db91 100644 --- a/packages/raystack/components/calendar/range-picker.tsx +++ b/packages/raystack/components/calendar/range-picker.tsx @@ -56,7 +56,7 @@ interface RangePickerProps { type RangeFields = keyof DateRange; export function RangePicker({ - dateFormat = 'DD/MM/YYYY', + dateFormat = 'DD MMM YYYY', slotProps, inputsProps: legacyInputsProps = {}, calendarProps: legacyCalendarProps, diff --git a/packages/raystack/components/data-table/components/filters.tsx b/packages/raystack/components/data-table/components/filters.tsx index a6faba50e..cc319ea2e 100644 --- a/packages/raystack/components/data-table/components/filters.tsx +++ b/packages/raystack/components/data-table/components/filters.tsx @@ -29,13 +29,16 @@ interface AddFilterProps { appliedFiltersSet: Set; onAddFilter: (column: DataTableColumn) => void; children?: Trigger; + /** Applied to the default triggers; custom `children` triggers style themselves. */ + className?: string; } function AddFilter({ columnList = [], appliedFiltersSet, onAddFilter, - children + children, + className }: AddFilterProps) { const availableFilters = columnList?.filter( col => !appliedFiltersSet.has(col.id) @@ -47,7 +50,7 @@ function AddFilter({ else if (children) return children; else if (appliedFiltersSet.size > 0) return ( - + ); @@ -58,11 +61,12 @@ function AddFilter({ size='small' leadingIcon={} color='neutral' + className={className} > Filter ); - }, [children, appliedFiltersSet, availableFilters]); + }, [children, appliedFiltersSet, availableFilters, className]); return availableFilters.length > 0 ? ( @@ -126,6 +130,7 @@ export function Filters({ label: (columnDef?.header as string) || '', options: columnDef?.filterOptions || [], selectProps: columnDef?.filterProps?.select, + calendarProps: columnDef?.filterProps?.calendar, ...filter }; }) || []; @@ -153,6 +158,7 @@ export function Filters({ columnType={filter.filterType} options={filter.options} selectProps={filter.selectProps} + calendarProps={filter.calendarProps} className={classNames?.filterChips} /> ))} @@ -160,6 +166,7 @@ export function Filters({ columnList={columnList} appliedFiltersSet={appliedFiltersSet} onAddFilter={onAddFilter} + className={classNames?.addFilter} > {trigger} diff --git a/packages/raystack/components/data-table/data-table.module.css b/packages/raystack/components/data-table/data-table.module.css index cb157c921..9d8b69d6b 100644 --- a/packages/raystack/components/data-table/data-table.module.css +++ b/packages/raystack/components/data-table/data-table.module.css @@ -238,4 +238,8 @@ .filterContainer { flex-wrap: wrap; + /* Fill the toolbar and shrink below content width so FilterChips truncate to + * the panel (resolve their `max-width: 100%`) instead of overflowing it. */ + flex: 1; + min-width: 0; } diff --git a/packages/raystack/components/data-table/data-table.types.tsx b/packages/raystack/components/data-table/data-table.types.tsx index 1af28b3c3..44b92fee7 100644 --- a/packages/raystack/components/data-table/data-table.types.tsx +++ b/packages/raystack/components/data-table/data-table.types.tsx @@ -11,6 +11,7 @@ import type { FilterTypes, FilterValueType } from '~/types/filters'; +import type { FilterChipCalendarProps } from '../filter-chip'; import type { BaseSelectProps } from '../select/select-root'; export type DataTableMode = 'client' | 'server'; @@ -84,6 +85,7 @@ export type DataTableColumnDef = ColumnDef & { defaultFilterValue?: unknown; filterProps?: { select?: BaseSelectProps; + calendar?: FilterChipCalendarProps; }; classNames?: { cell?: string; diff --git a/packages/raystack/components/data-table/hooks/useFilters.tsx b/packages/raystack/components/data-table/hooks/useFilters.tsx index febf9e218..1b0aafaf8 100644 --- a/packages/raystack/components/data-table/hooks/useFilters.tsx +++ b/packages/raystack/components/data-table/hooks/useFilters.tsx @@ -22,15 +22,19 @@ export function useFilters() { (filterType === FilterType.date ? new Date() : filterType === FilterType.select - ? options[0].value - : ''); + ? options[0]?.value + : filterType === FilterType.multiselect + ? // first option preselected like select; [] when there are no options + options + .slice(0, 1) + .map(opt => opt.value) + : ''); updateTableQuery(query => { return { ...query, filters: [ ...(query.filters || []), - // TODO: Add default filter value in column definition { _dataType: dataType, _type: filterType, diff --git a/packages/raystack/components/data-view-beta/components/filters.tsx b/packages/raystack/components/data-view-beta/components/filters.tsx index 8b7f4220a..ac957e27b 100644 --- a/packages/raystack/components/data-view-beta/components/filters.tsx +++ b/packages/raystack/components/data-view-beta/components/filters.tsx @@ -1,5 +1,6 @@ 'use client'; +import { cx } from 'class-variance-authority'; import { isValidElement, ReactNode, useMemo } from 'react'; import { FilterIcon } from '~/icons'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; @@ -8,6 +9,7 @@ import { FilterChip } from '../../filter-chip'; import { Flex } from '../../flex'; import { IconButton } from '../../icon-button'; import { Menu } from '../../menu'; +import styles from '../data-view.module.css'; import { DataViewField } from '../data-view.types'; import { useDataView } from '../hooks/useDataView'; import { useFilters } from '../hooks/useFilters'; @@ -114,14 +116,18 @@ export function Filters({ label: field?.label || '', options: field?.filterOptions || [], selectProps: field?.filterProps?.select, + calendarProps: field?.filterProps?.calendar, ...filter }; }) || []; return ( - + {appliedFilters.length > 0 && ( - + {appliedFilters.map(filter => ( ({ columnType={filter.filterType} options={filter.options} selectProps={filter.selectProps} + calendarProps={filter.calendarProps} className={classNames?.filterChips} /> ))} diff --git a/packages/raystack/components/data-view-beta/data-view.module.css b/packages/raystack/components/data-view-beta/data-view.module.css index 23376a41f..12078f2f7 100644 --- a/packages/raystack/components/data-view-beta/data-view.module.css +++ b/packages/raystack/components/data-view-beta/data-view.module.css @@ -6,6 +6,19 @@ background: var(--rs-color-background-base-primary); } +/* Fill the toolbar and let the applied FilterChips wrap/shrink to the panel + * instead of overflowing in a single row. */ +.filters { + flex: 1; + min-width: 0; + flex-wrap: wrap; +} + +.appliedFilters { + flex-wrap: wrap; + min-width: 0; +} + .display-popover-content { padding: 0px; /* Todo: var does not exist for 300px */ diff --git a/packages/raystack/components/data-view-beta/data-view.types.tsx b/packages/raystack/components/data-view-beta/data-view.types.tsx index 297d49197..455bd7e0d 100644 --- a/packages/raystack/components/data-view-beta/data-view.types.tsx +++ b/packages/raystack/components/data-view-beta/data-view.types.tsx @@ -6,6 +6,7 @@ import type { FilterTypes, FilterValueType } from '~/types/filters'; +import type { FilterChipCalendarProps } from '../filter-chip'; import type { BaseSelectProps } from '../select/select-root'; export type DataViewMode = 'client' | 'server'; @@ -80,6 +81,7 @@ export interface DataViewField { defaultFilterValue?: unknown; filterProps?: { select?: BaseSelectProps; + calendar?: FilterChipCalendarProps; }; // ordering / grouping / visibility capability diff --git a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx index bb40414a2..2bbfcbe3f 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -125,6 +125,100 @@ describe('FilterChip', () => { }); }); + 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 + // that string to DatePicker, whose controlled-sync effect ran + // `valueProp?.getTime()` → "getTime is not a function". + expect(() => + render() + ).not.toThrow(); + expect(screen.getByPlaceholderText('Select date')).toBeInTheDocument(); + }); + + it('parses a serialized string date value instead of rendering blank', () => { + render( + + ); + expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument(); + }); + + it('parses an epoch number value', () => { + // Local-component Date so the timestamp is timezone-stable. + render( + + ); + expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument(); + }); + + it('coerces an unparseable value to unselected instead of crashing', () => { + expect(() => + render( + + ) + ).not.toThrow(); + expect(screen.getByPlaceholderText('Select date')).toHaveValue(''); + }); + + it('formats a Date value with the default month-as-text format', () => { + // Local-component Date so the formatted string is timezone-stable. + render( + + ); + expect(screen.getByDisplayValue('27 May 2026')).toBeInTheDocument(); + }); + + it('forwards calendarProps to the underlying DatePicker', () => { + // dateFormat is the easiest forwarded prop to observe — the formatted + // string in the input changes when it lands on DatePicker. + render( + + ); + expect(screen.getByDisplayValue('27/05/2026')).toBeInTheDocument(); + }); + }); + + describe('Content-fit width', () => { + // The value hug lives on the input (`field-sizing` in `.chip input`); + // these pin the containers to the fluid width that lets the input shrink. + it('keeps the string input container fluid so the input can shrink', () => { + const { container } = render( + + ); + const inputContainer = container.querySelector(`.${styles.inputField}`); + expect(inputContainer).toHaveStyle({ width: '100%' }); + }); + + it('keeps the date field container fluid so the input can shrink', () => { + const { container } = render( + + ); + const dateContainer = container.querySelector(`.${styles.dateField}`); + expect(dateContainer).toHaveStyle({ width: '100%' }); + }); + }); + describe('Forwarded HTML attributes', () => { it('forwards arbitrary HTML attributes onto the root div', () => { render( diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index 299d2f38a..5ba007416 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -7,8 +7,11 @@ background: var(--rs-color-background-base-primary); border-radius: var(--rs-radius-2); text-wrap: nowrap; + /* Hug content, but cap at the parent and shrink under it. `min-width: 0` + * is required: `min-content` would let the value leak in and beat the cap. */ width: fit-content; - min-width: min-content; + min-width: 0; + max-width: 100%; overflow: clip; } @@ -36,13 +39,20 @@ background: var(--rs-color-background-base-primary-hover); } -/* Target any input fields within the chip */ +/* Inputs within the chip hug their value (`field-sizing`) and shrink under + * resize pressure so `text-overflow` ellipsizes with padding intact; + * `min-width` keeps an empty value clickable, `max-width` caps a runaway one. */ .chip input { border: none; box-shadow: none; background: transparent; min-height: unset; padding: 0; + field-sizing: content; + max-width: 200px; + width: auto; + min-width: 50px; + flex-shrink: 1; text-overflow: ellipsis; } diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 3f72046c6..d4c9d9ef4 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -1,7 +1,8 @@ 'use client'; import { Cross1Icon } from '@radix-ui/react-icons'; -import { cva, cx, VariantProps } from 'class-variance-authority'; +import { cva, VariantProps } from 'class-variance-authority'; +import dayjs from 'dayjs'; import { ComponentProps, ReactElement, useCallback, useState } from 'react'; import { FilterOperation, @@ -11,7 +12,7 @@ import { FilterTypes, filterOperators } from '~/types/filters'; -import { DatePicker } from '../calendar'; +import { DatePicker, type DatePickerProps } from '../calendar'; import { Flex } from '../flex'; import { Input } from '../input'; import { Select } from '../select'; @@ -34,6 +35,31 @@ const chip = cva(styles.chip, { export type FilterChipValue = string | string[] | number | Date; +/** + * Coerce a `FilterChipValue` to the `Date` the DatePicker expects — filter + * state hydrated from a serialized query arrives as a string or epoch number. + * Unparseable values leave the field unselected. + */ +const toDateValue = (value: unknown): Date | undefined => { + if (value instanceof Date) return value; + if (typeof value === 'string' || typeof value === 'number') { + const parsed = dayjs(value); + return parsed.isValid() ? parsed.toDate() : undefined; + } + return undefined; +}; + +/** + * Subset of `DatePickerProps` that consumers may forward to the chip's + * built-in DatePicker via `calendarProps`. `value`/`onSelect`/`defaultValue` + * are owned by `FilterChip`; `children` would replace the input trigger and + * break the chip layout. + */ +export type FilterChipCalendarProps = Omit< + DatePickerProps, + 'value' | 'onSelect' | 'defaultValue' | 'children' +>; + export interface FilterChipProps extends ComponentProps<'div'>, VariantProps { @@ -47,8 +73,22 @@ export interface FilterChipProps leadingIcon?: ReactElement; operations?: FilterOperator[]; selectProps?: BaseSelectProps; + /** + * Props forwarded to the underlying `DatePicker` for `columnType="date"`. + * `value`/`onSelect`/`defaultValue` are owned by `FilterChip` and excluded; + * `children` is excluded so the chip's input trigger isn't replaced. + */ + calendarProps?: FilterChipCalendarProps; } +/** + * A compact, removable filter pill that pairs a label and operator with a + * value control chosen by `columnType`: a `Select` (`select`/`multiselect`), + * a `DatePicker` (`date`), or a text `Input` (`string`/`number`). The value + * control sizes to its content so the chip hugs the active filter. Emits + * `onValueChange`/`onOperationChange` and renders a remove button when + * `onRemove` is provided. + */ export const FilterChip = ({ label, value, @@ -63,6 +103,7 @@ export const FilterChip = ({ variant, operations, selectProps, + calendarProps, ...props }: FilterChipProps) => { const computedOperations = operations?.length @@ -111,10 +152,7 @@ export const FilterChip = ({ } }} variant='text' - className={cx( - styles.selectValue, - !showOnRemove && styles.selectColumn - )} + className={styles.selectValue} > {isMultiSelectColumn && filterValue.length > 1 @@ -138,10 +176,17 @@ export const FilterChip = ({ return (
handleFilterValueChange(date)} showCalendarIcon={false} - inputProps={{ classNames: { container: styles.dateField } }} + {...calendarProps} + value={toDateValue(filterValue)} + onSelect={date => handleFilterValueChange(date)} + slotProps={{ + ...calendarProps?.slotProps, + input: { + classNames: { container: styles.dateField }, + ...calendarProps?.slotProps?.input + } + }} />
); diff --git a/packages/raystack/components/filter-chip/index.tsx b/packages/raystack/components/filter-chip/index.tsx index cc6c68db1..4ea010f42 100644 --- a/packages/raystack/components/filter-chip/index.tsx +++ b/packages/raystack/components/filter-chip/index.tsx @@ -1 +1,6 @@ -export { FilterChip, type FilterChipValue } from './filter-chip'; +export { + FilterChip, + type FilterChipCalendarProps, + type FilterChipProps, + type FilterChipValue +} from './filter-chip'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 9ad6c91cd..791dc3557 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -15,6 +15,8 @@ export { type CalendarProps, type CalendarPropsExtended, DatePicker, + type DatePickerProps, + type DatePickerSlotProps, type DateRange, RangePicker } from './components/calendar'; @@ -54,7 +56,12 @@ export { Drawer } from './components/drawer'; export { EmptyState } from './components/empty-state'; export { Field } from './components/field'; export { Fieldset } from './components/fieldset'; -export { FilterChip } from './components/filter-chip'; +export { + FilterChip, + type FilterChipCalendarProps, + type FilterChipProps, + type FilterChipValue +} from './components/filter-chip'; export { Flex } from './components/flex'; export { FloatingActions } from './components/floating-actions'; export { Form } from './components/form';