From 4d22e88b14b35518ef3dccaa065beeb51a91df22 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 20 Oct 2026 02:51:50 +0530 Subject: [PATCH 1/4] refactor: use x, defaultX and onXChange for state props --- .../skills/apsara/references/composition.md | 2 + .../src/app/examples/timeline-stress/page.tsx | 2 +- apps/www/src/components/dataview-demo.tsx | 8 +- .../src/content/docs/(overview)/upgrading.mdx | 63 +++++++++ .../content/docs/components/calendar/demo.ts | 2 +- .../docs/components/calendar/index.mdx | 4 +- .../content/docs/components/calendar/props.ts | 21 ++- .../docs/components/filter-chip/index.mdx | 2 +- .../docs/components/filter-chip/props.ts | 34 ++++- .../content/docs/components/select/demo.ts | 2 +- .../content/docs/components/select/index.mdx | 2 +- .../content/docs/components/select/props.ts | 17 ++- .../content/docs/components/tour/index.mdx | 2 +- .../src/content/docs/components/tour/props.ts | 7 +- apps/www/src/content/docs/dataview/demo.ts | 8 +- apps/www/src/content/docs/dataview/index.mdx | 12 +- apps/www/src/content/docs/dataview/props.ts | 12 +- .../src/content/docs/dataview/timeline.mdx | 6 +- .../calendar/__tests__/date-picker.test.tsx | 80 ++++++++---- .../calendar/__tests__/range-picker.test.tsx | 15 ++- .../react/components/calendar/date-picker.tsx | 97 +++++++------- .../components/calendar/range-picker.tsx | 28 +++- .../data-view/__tests__/data-slots.test.tsx | 10 +- .../data-view/__tests__/data-view.test.tsx | 121 +++++++++++++----- .../data-view/__tests__/timeline.test.tsx | 4 +- .../data-view/components/filters.tsx | 3 +- .../react/components/data-view/data-view.tsx | 65 ++++++---- .../components/data-view/data-view.types.tsx | 12 +- .../__tests__/filter-chip.test.tsx | 67 +++++++++- .../filter-chip/filter-chip-operation.tsx | 10 +- .../components/filter-chip/filter-chip.tsx | 107 ++++++++++------ .../select/__tests__/select.test.tsx | 23 ++-- .../react/components/select/select-item.tsx | 4 +- .../react/components/select/select-root.tsx | 50 +++++--- .../components/tour/__tests__/tour.test.tsx | 25 +++- packages/react/components/tour/tour-root.tsx | 29 ++++- packages/react/figma/filter-chip.figma.ts | 4 +- 37 files changed, 674 insertions(+), 286 deletions(-) diff --git a/.agents/skills/apsara/references/composition.md b/.agents/skills/apsara/references/composition.md index 635416cab..c34a27b74 100644 --- a/.agents/skills/apsara/references/composition.md +++ b/.agents/skills/apsara/references/composition.md @@ -67,6 +67,8 @@ const [open, setOpen] = useState(false); Most change callbacks (`onValueChange`, `onOpenChange`, `onInputValueChange`, and the like) take the new value first and a details object second. Components that wrap Base UI pass Base UI's `eventDetails`. Other components pass their own `details`, for example `details.mode` on `ColorPicker` and `details.operation` on `FilterChip`. A few callbacks take only the value, for example CodeBlock `onValueChange` and ColorPicker `onModeChange`. Check the props table for the exact signature. +State props follow `x`, `defaultX` and `onXChange`, for example Select `inputValue`, Tour `stepIndex`, DataView `query` and FilterChip `operation`. The first render decides: pass `x` with `onXChange` to own the state, or `defaultX` to set only the first value. Do not switch a prop between `undefined` and a value. + Several overlay roots (`Dialog`, `Menu`, `Popover`, `Tooltip`) also expose `createHandle` for advanced imperative open/close from outside the subtree — use only when a trigger can't live in the same tree. ## Select diff --git a/apps/www/src/app/examples/timeline-stress/page.tsx b/apps/www/src/app/examples/timeline-stress/page.tsx index 67d6b85ce..46da36e14 100644 --- a/apps/www/src/app/examples/timeline-stress/page.tsx +++ b/apps/www/src/app/examples/timeline-stress/page.tsx @@ -398,7 +398,7 @@ export default function TimelineStressPage() { fields={fields} mode='client' defaultSort={{ name: 'start', order: 'asc' }} - query={grouped ? { group_by: ['team'] } : undefined} + defaultQuery={grouped ? { group_by: ['team'] } : undefined} getRowId={task => task.id} > diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index 473258d9b..11471e468 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -287,7 +287,7 @@ export function DataViewEmptyZeroDemo() { data={filtered ? [] : people} fields={fields} defaultSort={defaultSort} - query={ + defaultQuery={ filtered ? { filters: [{ name: 'name', operator: 'eq', value: 'Nobody' }] } : undefined @@ -436,7 +436,7 @@ export function DataViewGroupingDemo() { data={groupedPeople} fields={fields} defaultSort={defaultSort} - query={{ group_by: ['team'] }} + defaultQuery={{ group_by: ['team'] }} > @@ -468,7 +468,7 @@ export function DataViewVirtualizedGroupingDemo() { data={data} fields={fields} defaultSort={defaultSort} - query={{ group_by: ['team'] }} + defaultQuery={{ group_by: ['team'] }} > @@ -1078,7 +1078,7 @@ export function DataViewTimelineGroupingDemo() { data={tasks} fields={taskFields} defaultSort={{ name: 'start', order: 'asc' }} - query={{ group_by: ['team'] }} + defaultQuery={{ group_by: ['team'] }} getRowId={task => task.id} > diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index d7ca84e3b..5d37d40ca 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,69 @@ One section per release, newest first, with only the changes that need action from you. The full record of every release, features and fixes included, is on [GitHub releases](https://github.com/raystack/apsara/releases). +## Unreleased: controlled state uses `x`, `defaultX` and `onXChange` + +A prop that holds state is controlled from the first render. The component +shows the prop and changes only when you pass a new value. Pass `defaultX` to +set only the first value. + +### 1. Rename the props + +| Component | Before | After | +|---|---|---| +| `Select` | `searchValue`, `defaultSearchValue`, `onSearch(value)` | `inputValue`, `defaultInputValue`, `onInputValueChange(value, eventDetails)` | +| `DatePicker` | `onSelect(date)` | `onValueChange(date, details)` | +| `RangePicker` | `onSelect(range)` | `onValueChange(range, details)` | +| `Tour` | `onStepChange(index, step)` | `onStepIndexChange(index, details)` | +| `DataView` | `onTableQueryChange(query)` | `onQueryChange(query, details)` | + +Tour's step is `details.step`. + +```tsx +// Before + + + track(details.step.id)} /> + +``` + +### 2. Use `defaultQuery` and `defaultValue` for a first value + +`DataView` `query` and `FilterChip` `value` used to be read on the first render +only. They are now controlled. If you pass one only to set the first value, +rename it. + +```tsx +// Before + + + +// After + + +``` + +A later change to `query.search` no longer updates the search on its own. Pass +`query` with `onQueryChange` to own the query. + +### 3. Check these behavior changes + +- `DataView` calls `onQueryChange` in client mode too. Server mode still calls + it on mount, so the first page can load. +- `DatePicker` with `value` shows only the `value` you pass. A clicked or typed + date shows after you pass it back. +- `DatePicker` calls `onValueChange` on Enter, blur or an outside click only + when the typed date differs from the value. `onSelect` fired on every close. +- `FilterChip` has `operation` and `defaultOperation`, and `onOperationChange` + gets a `details` object second. `DataView` passes each filter's `operator` to + its chip, so a `query` with `operator: "neq"` shows "is not". + ## Unreleased: change callbacks get a details object The change callbacks below pass a second argument after the value. A diff --git a/apps/www/src/content/docs/components/calendar/demo.ts b/apps/www/src/content/docs/components/calendar/demo.ts index 42b611b85..7f6115e45 100644 --- a/apps/www/src/content/docs/components/calendar/demo.ts +++ b/apps/www/src/content/docs/components/calendar/demo.ts @@ -136,7 +136,7 @@ export const rangePickerDemo = { code: ` console.log('Date range:', range)} + onValueChange={(range) => console.log('Date range:', range)} value={{ from: new Date(2024, 0, 1), to: new Date(2024, 0, 15) diff --git a/apps/www/src/content/docs/components/calendar/index.mdx b/apps/www/src/content/docs/components/calendar/index.mdx index c642b2afe..a8c7bbd12 100644 --- a/apps/www/src/content/docs/components/calendar/index.mdx +++ b/apps/www/src/content/docs/components/calendar/index.mdx @@ -60,13 +60,13 @@ Selects a date range across two inputs. The grid uses a state machine that branc - **Only `from` set**: clicking a later date completes the range and closes the popover; clicking an earlier date resets `from`. - **Both set**: clicking again restarts: the new date becomes `from`, `to` clears. -`onSelect` fires on every step (the shape is `{ from?: Date; to?: Date }`, partial during interaction). Gate on `range.to` if you only want the completed-range event. +`onValueChange` fires on every step (the shape is `{ from?: Date; to?: Date }`, partial during interaction). Gate on `range.to` if you only want the completed-range event. ### Date picker -Single-date selection with a typable input. The popover opens on focus; pressing Enter, blurring, or clicking outside commits the value and fires `onSelect`. +Single-date selection with a typable input. The popover opens on focus; pressing Enter, blurring, or clicking outside commits the value and fires `onValueChange`. diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index e3f047615..14e9f5be7 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -154,7 +154,10 @@ export interface RangePickerProps { * Both fields are optional during partial selection; gate on `range.to` if * you only want completed ranges. */ - onSelect?: (range: { from?: Date; to?: Date }) => void; + onValueChange?: ( + range: { from?: Date; to?: Date }, + details: { reason: 'item-press'; event: Event } + ) => void; /** Initial (uncontrolled) date range. */ defaultValue?: { from?: Date; to?: Date }; @@ -167,7 +170,7 @@ export interface RangePickerProps { * `inputsProps` / `calendarProps` / `popoverProps` are set, `slotProps` wins. * * Input event handlers (`onChange`, `onFocus`, `onBlur`, `onKeyUp`) are not - * forwarded — use `onSelect` for value changes. + * forwarded — use `onValueChange` for value changes. */ slotProps?: { startInput?: InputProps; @@ -225,7 +228,7 @@ export interface DatePickerProps { * `inputProps` / `calendarProps` / `popoverProps` are set, `slotProps` wins. * * Input event handlers (`onChange`, `onFocus`, `onBlur`, `onKeyUp`) are not - * forwarded — use `onSelect` for value changes. + * forwarded — use `onValueChange` for value changes. */ slotProps?: { input?: InputProps; @@ -234,7 +237,7 @@ export interface DatePickerProps { }; /** - * Controlled date value. Pair with `onSelect`. Omit (along with + * Controlled date value. Pair with `onValueChange`. Omit (along with * `defaultValue`) to start the picker in an unselected state — the * input shows its placeholder until the user selects a date. */ @@ -246,8 +249,14 @@ export interface DatePickerProps { */ defaultValue?: Date; - /** Callback function when date is selected */ - onSelect?: (date: Date) => void; + /** + * Called when a day is clicked, and when a typed date is committed with + * Enter, blur or an outside click. + */ + onValueChange?: ( + date: Date, + details: { reason: 'item-press' | 'input-change'; event: Event } + ) => void; /** * Fires when the typed-input validation state changes: with a message when 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 4ee374b8e..27af4f25c 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -66,7 +66,7 @@ FilterChip supports custom filter operations through the `operations` prop. When ### Controlled -Pass `value` with `onValueChange` to own the filter. Do this when the chip drives a query and the value has to survive a refetch or a shared URL. `onValueChange` receives the value and a `details` object, and `details.operation` is the active operation. +Pass `value` with `onValueChange` to own the filter. Do this when the chip drives a query and the value has to survive a refetch or a shared URL. `onValueChange` receives the value and a `details` object, and `details.operation` is the active operation. Pass `operation` with `onOperationChange` to own the operation too. To set only the first value or operation, pass `defaultValue` or `defaultOperation`. 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 99c6cb2b9..b9362f920 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -5,11 +5,27 @@ export interface FilterChipProps { /** Text label for the filter (required) */ label: string; - /** Current value of the filter. `multiselect` takes an array; `date` + /** The filter value (controlled). `multiselect` takes an array; `date` * 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 | number | boolean | Array | Date; + /** The filter value on the first render (uncontrolled). Takes the same + * types as `value`. */ + defaultValue?: + | string + | number + | boolean + | Array + | Date; + + /** The operation value (controlled), for example `"eq"`. */ + operation?: string; + + /** The operation value on the first render (uncontrolled). Defaults to the + * first operation. */ + defaultOperation?: string; + /** Type of input for the filter * @default "string" */ @@ -37,8 +53,11 @@ export interface FilterChipProps { details: { reason: string; event: Event; operation: string } ) => void; - /** Callback when the filter operation changes */ - onOperationChange?: (operation: string) => void; + /** Callback when the filter operation changes. */ + onOperationChange?: ( + operation: string, + details: { reason: string; event: Event } + ) => void; /** Icon element to display before the label */ leadingIcon?: React.ReactNode; @@ -50,9 +69,12 @@ export interface FilterChipProps { selectProps?: { autocomplete?: boolean; autocompleteMode?: 'auto' | 'manual'; - onSearch?: (value: string) => void; - searchValue?: string; - defaultSearchValue?: string; + inputValue?: string; + defaultInputValue?: string; + onInputValueChange?: ( + inputValue: string, + eventDetails: { reason: string; event: Event } + ) => void; }; /** Props for the date control at `columnType="date"`. `timeZone` through `today` are CalendarPreview props. */ diff --git a/apps/www/src/content/docs/components/select/demo.ts b/apps/www/src/content/docs/components/select/demo.ts index 9ac38d769..ee24e4abc 100644 --- a/apps/www/src/content/docs/components/select/demo.ts +++ b/apps/www/src/content/docs/components/select/demo.ts @@ -212,7 +212,7 @@ export const autocompleteDemo = { ]; const [simpleSearchQuery, setSimpleSearchQuery] = React.useState(""); - return setSimpleSearchQuery(value)}> diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 2c2925bff..644011b73 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -83,7 +83,7 @@ When multiple selection is enabled, the value, onValueChange, and defaultValue w To enable autocomplete, pass the `autocomplete` prop to the Select root element. -By default, only select items are filtered using a simple match. For more advanced control, set `autocompleteMode="manual"` and implement your own custom filtering logic. +By default, only select items are filtered using a simple match. For more advanced control, set `autocompleteMode="manual"` and filter the items yourself in `onInputValueChange`. diff --git a/apps/www/src/content/docs/components/select/props.ts b/apps/www/src/content/docs/components/select/props.ts index 75fe36939..b321a8079 100644 --- a/apps/www/src/content/docs/components/select/props.ts +++ b/apps/www/src/content/docs/components/select/props.ts @@ -12,7 +12,7 @@ export interface SelectRootProps { /** Controls the autocomplete behavior mode * - "auto": Automatically filters items as user types - * - "manual": Requires explicit filtering through onSearch callback + * - "manual": Requires explicit filtering through the onInputValueChange callback * @default "auto" */ autocompleteMode?: 'auto' | 'manual'; @@ -29,14 +29,17 @@ export interface SelectRootProps { eventDetails: { reason: string; event: Event } ) => void; - /** Current search value for autocomplete */ - searchValue?: string; + /** The search text with `autocomplete` (controlled). */ + inputValue?: string; - /** Initial search value for autocomplete */ - defaultSearchValue?: string; + /** The search text on the first render (uncontrolled). */ + defaultInputValue?: string; - /** Callback fired when the search value changes */ - onSearch?: (value: string) => void; + /** Callback fired when the search text changes. */ + onInputValueChange?: ( + inputValue: string, + eventDetails: { reason: string; event: Event } + ) => void; } export interface SelectTriggerProps { diff --git a/apps/www/src/content/docs/components/tour/index.mdx b/apps/www/src/content/docs/components/tour/index.mdx index 8261b4d09..9cba874e6 100644 --- a/apps/www/src/content/docs/components/tour/index.mdx +++ b/apps/www/src/content/docs/components/tour/index.mdx @@ -66,7 +66,7 @@ const [index, setIndex] = useState(0); setOpen(open); if (!open) analytics.track("tour_end", { status }); }} - onStepChange={setIndex} + onStepIndexChange={setIndex} />; ``` diff --git a/apps/www/src/content/docs/components/tour/props.ts b/apps/www/src/content/docs/components/tour/props.ts index cf80107de..79f7b2778 100644 --- a/apps/www/src/content/docs/components/tour/props.ts +++ b/apps/www/src/content/docs/components/tour/props.ts @@ -34,8 +34,11 @@ export interface TourProps { */ defaultStepIndex?: number; - /** Called when the active step changes. */ - onStepChange?: (index: number, step: TourStep) => void; + /** Called when the active step changes. `details.step` is the new step. */ + onStepIndexChange?: ( + index: number, + details: { reason: 'none'; event: Event; step: TourStep } + ) => void; /** Receives every tour lifecycle event. */ onEvent?: (event: TourEvent) => void; diff --git a/apps/www/src/content/docs/dataview/demo.ts b/apps/www/src/content/docs/dataview/demo.ts index f5d51bbb1..c06fe2f05 100644 --- a/apps/www/src/content/docs/dataview/demo.ts +++ b/apps/www/src/content/docs/dataview/demo.ts @@ -138,7 +138,7 @@ export const groupingPreview = { { label: 'index.tsx', code: ` - /* Initial \`group_by\` is supplied via \`query\`. The user can pick a + /* Initial \`group_by\` is supplied via \`defaultQuery\`. The user can pick a different group from DisplayControls — same wire format either way. The active group header sticks under the column header as the user scrolls past it. */ @@ -146,7 +146,7 @@ export const groupingPreview = { data={data} fields={fields} defaultSort={{ name: "name", order: "asc" }} - query={{ group_by: ["team"] }} + defaultQuery={{ group_by: ["team"] }} > @@ -180,7 +180,7 @@ export const virtualizedGroupingPreview = { data={data} // ~1500 rows fields={fields} defaultSort={{ name: "name", order: "asc" }} - query={{ group_by: ["team"] }} + defaultQuery={{ group_by: ["team"] }} > @@ -485,7 +485,7 @@ export const timelineGroupingPreview = { data={tasks} fields={fields} defaultSort={{ name: "start", order: "asc" }} - query={{ group_by: ["team"] }} + defaultQuery={{ group_by: ["team"] }} getRowId={(t) => t.id}> diff --git a/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index aea5ac047..5304d4438 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -85,7 +85,7 @@ The split is what lets one set of filters, sorts, and toggles drive every render -By default search auto-disables in the zero state (no data and no active query) and re-enables the moment the user types. Pass `autoDisableInZeroState={false}` to keep it always enabled, or `disabled` to control it yourself. In server mode, read `query.search` in `onTableQueryChange` and filter on the backend. +By default search auto-disables in the zero state (no data and no active query) and re-enables the moment the user types. Pass `autoDisableInZeroState={false}` to keep it always enabled, or `disabled` to control it yourself. In server mode, read `query.search` in `onQueryChange` and filter on the backend. `DataView.Search` accepts every [Search](/docs/components/search) prop. For example, `leadingIcon={null}` hides the search icon. @@ -164,7 +164,7 @@ const listFields = fields.map((f) => ### Server mode -In client mode `DataView` derives everything locally: filter predicates, sort, grouping, and search all run over the `data` array you pass. Set `mode="server"` and that work moves to your backend. The local TanStack table goes manual, `onTableQueryChange` fires whenever the query changes, and it's on you to fetch matching rows and pass them back down. +In client mode `DataView` derives everything locally: filter predicates, sort, grouping, and search all run over the `data` array you pass. Set `mode="server"` and that work moves to your backend. The local TanStack table goes manual, `onQueryChange` fires on mount and whenever the query changes, and it's on you to fetch matching rows and pass them back down. ```tsx setQuery(q)} + onQueryChange={setQuery} onLoadMore={() => fetchNext()} > … ``` -- **`onTableQueryChange`** hands you the full [query object](#query) whenever any part of it changes: a filter chip, a sort, a grouping choice, a keystroke in search. Translate it to your API's parameters and refetch. +- **`onQueryChange`** hands you the full [query object](#query) whenever any part of it changes: a filter chip, a sort, a grouping choice, a keystroke in search. Translate it to your API's parameters and refetch. - **`totalRowCount`** is what the "hidden by filters" footer counts against. Without it the footer can't tell how many rows your filters excluded, since the client only ever sees the current page. - **Grouping** still comes from the root's `groupData`, so section order, labels, and counts match across renderers. @@ -217,7 +217,9 @@ Fields, sort and query are the shapes the root takes. The sibling components bel ### Query -The shape `query` accepts and `onTableQueryChange` hands back. In [server mode](#server-mode) this is the contract your backend implements. +The shape `query` and `defaultQuery` accept and `onQueryChange` hands back. In [server mode](#server-mode) this is the contract your backend implements. + +Pass `defaultQuery` to set only the first query. Pass `query` with `onQueryChange` to own the query, for example to keep it in the URL. A new `query` from outside updates the view without a call to `onQueryChange`. diff --git a/apps/www/src/content/docs/dataview/props.ts b/apps/www/src/content/docs/dataview/props.ts index 4a5374c76..24aec2d0d 100644 --- a/apps/www/src/content/docs/dataview/props.ts +++ b/apps/www/src/content/docs/dataview/props.ts @@ -27,11 +27,17 @@ export interface DataViewProps { */ isLoading?: boolean; - /** Initial query state. */ + /** The query (controlled). */ query?: DataViewQuery; - /** Called whenever the internal query changes — only in server mode. */ - onTableQueryChange?: (query: DataViewQuery) => void; + /** The query on the first render (uncontrolled). */ + defaultQuery?: DataViewQuery; + + /** Called when the query changes. In server mode it is also called on mount. */ + onQueryChange?: ( + query: DataViewQuery, + details: { reason: 'none'; event: Event } + ) => void; /** Infinite scroll callback. */ onLoadMore?: () => Promise | void; diff --git a/apps/www/src/content/docs/dataview/timeline.mdx b/apps/www/src/content/docs/dataview/timeline.mdx index 019ecc686..85fb5ca2f 100644 --- a/apps/www/src/content/docs/dataview/timeline.mdx +++ b/apps/www/src/content/docs/dataview/timeline.mdx @@ -115,7 +115,7 @@ The active sort does double duty here: it picks the field lanes are built from * ### Grouping -Set `group_by`, either from `DataView.DisplayControls` → Grouping or on the initial `query`, and the timeline splits into **swim-lane sections** stacked under the single shared time axis: a full-width header band per group, with that group's cards lane-packed beneath it. Horizontal position stays purely time; grouping reorganizes vertically only. +Set `group_by`, either from `DataView.DisplayControls` → Grouping or in `defaultQuery`, and the timeline splits into **swim-lane sections** stacked under the single shared time axis: a full-width header band per group, with that group's cards lane-packed beneath it. Horizontal position stays purely time; grouping reorganizes vertically only. @@ -126,7 +126,7 @@ const fields = [ … ]; - + ``` @@ -153,7 +153,7 @@ Leave the Ordering control visible for both. Under the default `auto` packing the sort has no visible effect at all, since lanes are assigned by dense chronological first-fit, so there hide the control with ``, or leave `sortable` off the timeline's per-view `fields`. -In [server mode](/docs/dataview#server-mode) the sort is the backend's to apply: rows arrive already ordered and `one-per-sort-value` takes lane order from that row order, first value seen first. A backend that ignores the `sort` in `onTableQueryChange` therefore produces lanes in whatever order it returned rows, with nothing logged to say so. The lane *membership* is still correct, only the ranking is arbitrary. +In [server mode](/docs/dataview#server-mode) the sort is the backend's to apply: rows arrive already ordered and `one-per-sort-value` takes lane order from that row order, first value seen first. A backend that ignores the `sort` in `onQueryChange` therefore produces lanes in whatever order it returned rows, with nothing logged to say so. The lane *membership* is still correct, only the ranking is arbitrary. ### Scale and axis diff --git a/packages/react/components/calendar/__tests__/date-picker.test.tsx b/packages/react/components/calendar/__tests__/date-picker.test.tsx index 1a3f37329..60a05c65d 100644 --- a/packages/react/components/calendar/__tests__/date-picker.test.tsx +++ b/packages/react/components/calendar/__tests__/date-picker.test.tsx @@ -136,9 +136,9 @@ describe('DatePicker', () => { it('selecting a date via the calendar updates both selected and month', async () => { const initial = new Date(2026, 0, 15); - const onSelect = vi.fn(); + const onValueChange = vi.fn(); const calls = renderWithCalendarSpy( - + ); const firstCall = calls[calls.length - 1]; @@ -158,7 +158,10 @@ describe('DatePicker', () => { * month follows selection via the open-time sync effect (picker re-opens * on next mount; while open, the click also closes the popover). */ - expect(onSelect).toHaveBeenCalledWith(newSelection); + expect(onValueChange).toHaveBeenCalledWith( + newSelection, + expect.objectContaining({ reason: 'item-press' }) + ); }); }); @@ -191,6 +194,29 @@ describe('DatePicker', () => { rerender(); expect(input.value).toBe('15 Jun 2026'); }); + + it('keeps a controlled value that the parent does not update', () => { + const onValueChange = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText( + 'Select date' + ) as HTMLInputElement; + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '15/06/2025' } }); + fireEvent.keyUp(input, { code: 'Enter' }); + + expect(onValueChange).toHaveBeenCalledWith( + new Date(2025, 5, 15), + expect.objectContaining({ reason: 'input-change' }) + ); + expect(input.value).toBe('01/01/2026'); + }); }); describe('unselected initial state (CLD-3195 #4)', () => { @@ -202,16 +228,16 @@ describe('DatePicker', () => { expect(input.value).toBe(''); }); - it('does not fire onSelect on close when nothing was ever selected', () => { - const onSelect = vi.fn(); - render(); + it('does not fire onValueChange on close when nothing was ever selected', () => { + const onValueChange = vi.fn(); + render(); const input = screen.getByPlaceholderText( 'Select date' ) as HTMLInputElement; // Open + close without selecting. fireEvent.focus(input); fireEvent.keyUp(input, { code: 'Enter' }); - expect(onSelect).not.toHaveBeenCalled(); + expect(onValueChange).not.toHaveBeenCalled(); }); it('passes selected=undefined to Calendar when picker has no value', () => { @@ -269,15 +295,17 @@ describe('DatePicker', () => { }); }); - describe('onSelect fires once per commit', () => { + describe('onValueChange fires once per commit', () => { /* * Regression for CLD-3195 #25: each commit path (calendar click, Enter, - * outside click) should fire onSelect exactly once with a single canonical + * outside click) should fire onValueChange exactly once with a single canonical * Date. */ - it('Enter after typing a valid date fires onSelect exactly once', () => { - const onSelect = vi.fn(); - render(); + it('Enter after typing a valid date fires onValueChange exactly once', () => { + const onValueChange = vi.fn(); + render( + + ); const input = screen.getByPlaceholderText( 'Select date' @@ -286,8 +314,8 @@ describe('DatePicker', () => { fireEvent.change(input, { target: { value: '15/06/2025' } }); fireEvent.keyUp(input, { code: 'Enter' }); - expect(onSelect).toHaveBeenCalledTimes(1); - const calledWith = onSelect.mock.calls[0][0] as Date; + expect(onValueChange).toHaveBeenCalledTimes(1); + const calledWith = onValueChange.mock.calls[0][0] as Date; expect(dayjs(calledWith).format('DD/MM/YYYY')).toBe('15/06/2025'); }); }); @@ -374,13 +402,13 @@ describe('DatePicker', () => { * visibly snapped. Now uses `dateFormat` with strict parsing. */ it('does not commit partial single-digit input as a date', () => { - const onSelect = vi.fn(); + const onValueChange = vi.fn(); const initial = new Date(2026, 4, 20); // 20/05/2026 render( ); @@ -393,8 +421,8 @@ describe('DatePicker', () => { // Input must NOT have jumped to a 2001 date. expect(input.value).not.toMatch(/2001/); - // onSelect must not have been called with a 2001 date. - for (const call of onSelect.mock.calls) { + // onValueChange must not have been called with a 2001 date. + for (const call of onValueChange.mock.calls) { const arg = call[0] as Date; expect(dayjs(arg).year()).not.toBe(2001); } @@ -434,12 +462,12 @@ describe('DatePicker', () => { * render and typing felt broken, and only paste of the full string worked. */ it('keeps typed characters visible while typing a full date one char at a time', () => { - const onSelect = vi.fn(); + const onValueChange = vi.fn(); render( ); @@ -466,13 +494,13 @@ describe('DatePicker', () => { } }); - it('does not fire onSelect while typing, so partial stays uncommitted, valid waits for commit (Enter/blur/outside-click)', () => { - const onSelect = vi.fn(); + it('does not fire onValueChange while typing, so partial stays uncommitted, valid waits for commit (Enter/blur/outside-click)', () => { + const onValueChange = vi.fn(); const onErrorChange = vi.fn(); render( ); @@ -483,14 +511,14 @@ describe('DatePicker', () => { // Partial input is not even parseable, so no commit. fireEvent.change(input, { target: { value: '15/06' } }); - expect(onSelect).not.toHaveBeenCalled(); + expect(onValueChange).not.toHaveBeenCalled(); - // Full valid input parses internally but still doesn't fire onSelect, + // Full valid input parses internally but still doesn't fire onValueChange, // commit only happens via Enter / blur / outside-click (see the // dedicated single-fire test below for that path). fireEvent.change(input, { target: { value: '15/06/2025' } }); expect(onErrorChange).toHaveBeenLastCalledWith(undefined); - expect(onSelect).not.toHaveBeenCalled(); + expect(onValueChange).not.toHaveBeenCalled(); }); }); diff --git a/packages/react/components/calendar/__tests__/range-picker.test.tsx b/packages/react/components/calendar/__tests__/range-picker.test.tsx index 2c4884867..85906b4c5 100644 --- a/packages/react/components/calendar/__tests__/range-picker.test.tsx +++ b/packages/react/components/calendar/__tests__/range-picker.test.tsx @@ -243,11 +243,11 @@ describe('RangePicker', () => { }); it('B2: from set, click after commits the range and closes', () => { - const onSelect = vi.fn(); + const onValueChange = vi.fn(); openPopoverAndCaptureCalendar( ); @@ -277,10 +277,13 @@ describe('RangePicker', () => { expect(callsAfter).toBeGreaterThanOrEqual(callsBefore); expect(startInput.getAttribute('data-active')).toBe('false'); - expect(onSelect).toHaveBeenLastCalledWith({ - from: new Date(2026, 0, 15), - to: new Date(2026, 0, 20) - }); + expect(onValueChange).toHaveBeenLastCalledWith( + { + from: new Date(2026, 0, 15), + to: new Date(2026, 0, 20) + }, + expect.objectContaining({ reason: 'item-press' }) + ); }); it('B1: from set, click before resets from and leaves popover open', () => { diff --git a/packages/react/components/calendar/date-picker.tsx b/packages/react/components/calendar/date-picker.tsx index aeb153c86..3fa48bdc5 100644 --- a/packages/react/components/calendar/date-picker.tsx +++ b/packages/react/components/calendar/date-picker.tsx @@ -1,13 +1,18 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import { useEffect, useRef, useState } from 'react'; -import { PropsBase } from 'react-day-picker'; +import { type Modifiers, PropsBase } from 'react-day-picker'; import { CalendarIcon } from '~/icons'; +import { + type ChangeDetails, + createChangeDetails +} from '~/shared/change-details'; import { Input } from '../input'; import { InputProps } from '../input/input'; import { Popover } from '../popover'; @@ -27,6 +32,10 @@ dayjs.extend(isSameOrBefore); */ type DatePickerCalendarSlot = Omit & CalendarPropsExtended; +export type DatePickerChangeDetails = ChangeDetails< + 'item-press' | 'input-change' +>; + export interface DatePickerSlotProps { input?: InputProps; calendar?: DatePickerCalendarSlot; @@ -46,7 +55,7 @@ export interface DatePickerProps { calendarProps?: DatePickerCalendarSlot; /** @deprecated Use `slotProps.popover` instead. */ popoverProps?: PopoverContentProps; - onSelect?: (date: Date) => void; + onValueChange?: (date: Date, details: DatePickerChangeDetails) => void; /** * Fires when the typed-input validation state changes: with a message when * the typed text stops parsing as a valid in-bounds date, and with @@ -73,7 +82,7 @@ export function DatePicker({ popoverProps: legacyPopoverProps, value: valueProp, defaultValue, - onSelect = () => undefined, + onValueChange, onErrorChange, children, showCalendarIcon = true, @@ -89,14 +98,15 @@ export function DatePicker({ * to `Popover.Trigger` even when the input itself is `disabled`. */ const isDisabled = !!inputProps.disabled; - /* - * Initial value: controlled prop > defaultValue (uncontrolled init) > - * undefined. With both omitted the picker starts unselected so the - * "Select date" placeholder is honest. - */ - const [selectedDate, setSelectedDate] = useState( - valueProp ?? defaultValue - ); + const [value, setValueUnwrapped] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'DatePicker', + state: 'value' + }); + // A valid typed date. Enter, blur or an outside click commits it. + const [typedDate, setTypedDate] = useState(); + const selectedDate = typedDate ?? value; const errorRef = useRef(undefined); @@ -105,12 +115,6 @@ export function DatePicker({ errorRef.current = next; } - // Sync only when controlled, since uncontrolled mode keeps its own state. - // biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity - useEffect(() => { - if (valueProp !== undefined) setSelectedDate(valueProp); - }, [valueProp?.getTime()]); - const formattedDate = selectedDate ? dayjs(selectedDate).format(dateFormat) : ''; @@ -126,13 +130,6 @@ export function DatePicker({ calendarProps?.defaultMonth ?? selectedDate ?? new Date() ); - // Mirror for reading inside the outside-click callback closure. - const selectedDateRef = useRef(selectedDate); - - useEffect(() => { - selectedDateRef.current = selectedDate; - }, [selectedDate]); - // Sync the input when the committed date changes from a non-typing source. useEffect(() => { setInputValue(formattedDate); @@ -148,36 +145,44 @@ export function DatePicker({ * `calendarProps.defaultMonth` so consumers controlling the initial view * see it every time the picker opens, not only on first mount. */ + // biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity useEffect(() => { if (popover.isOpen) { setViewMonth(calendarProps?.defaultMonth ?? selectedDate ?? new Date()); } - }, [popover.isOpen, selectedDate, calendarProps?.defaultMonth]); + }, [popover.isOpen, selectedDate?.getTime(), calendarProps?.defaultMonth]); - function closePicker() { + function setValue(date: Date, details: DatePickerChangeDetails) { + setValueUnwrapped(date); + onValueChange?.(date, details); + } + + function closePicker(event?: Event) { popover.disengage(); - const committedDate = selectedDateRef.current; const hadError = errorRef.current !== undefined; - setInputValue(committedDate ? dayjs(committedDate).format(dateFormat) : ''); + const nextDate = hadError ? value : selectedDate; + setTypedDate(undefined); + setInputValue(nextDate ? dayjs(nextDate).format(dateFormat) : ''); updateError(undefined); - /* - * Emit the committed Date directly. Going through - * `dayjs(formattedString).toDate()` re-parses the formatted string without - * a format spec, which falls back to native `Date` parsing and can shift - * non-ISO formats (e.g. DD/MM/YYYY → wrong Date). - * - * Skip when nothing was ever selected, since `onSelect` is typed - * `(date: Date) => void` so we don't fire with `undefined`. - */ - if (!hadError && committedDate) onSelect(committedDate); + if (!hadError && typedDate && typedDate.getTime() !== value?.getTime()) { + setValue(typedDate, createChangeDetails('input-change', event)); + } } - function handleSelect(day: Date | undefined) { - setSelectedDate(day); - // RDP can hand us `undefined` when `required={false}` and the user - // clicks the currently-selected day (deselect). Only forward defined - // dates to consumer `onSelect`, which keeps the prop type narrow. - if (day) onSelect(day); + function handleSelect( + day: Date | undefined, + _triggerDate?: Date, + _modifiers?: Modifiers, + event?: React.MouseEvent | React.KeyboardEvent + ) { + setTypedDate(undefined); + // RDP passes `undefined` when the user clicks the selected day, because + // `required` is false. `onValueChange` reports only a defined date. + if (day) { + setValue(day, createChangeDetails('item-press', event?.nativeEvent)); + } else { + setValueUnwrapped(undefined); + } updateError(undefined); popover.disengage(); } @@ -185,7 +190,7 @@ export function DatePicker({ function handleKeyUp(event: React.KeyboardEvent) { if (event.code === 'Enter' && popover.inputRef.current) { popover.inputRef.current.blur(); - closePicker(); + closePicker(event.nativeEvent); } } @@ -217,7 +222,7 @@ export function DatePicker({ const isValid = isValidDate && isAfter && isBefore; if (isValid) { - setSelectedDate(date.toDate()); + setTypedDate(date.toDate()); updateError(undefined); } else { updateError('Invalid date'); diff --git a/packages/react/components/calendar/range-picker.tsx b/packages/react/components/calendar/range-picker.tsx index ff07830a1..012eaad30 100644 --- a/packages/react/components/calendar/range-picker.tsx +++ b/packages/react/components/calendar/range-picker.tsx @@ -4,8 +4,12 @@ import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; import dayjs from 'dayjs'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { DateRange, PropsBase } from 'react-day-picker'; +import { DateRange, type Modifiers, PropsBase } from 'react-day-picker'; import { CalendarIcon } from '~/icons'; +import { + type ChangeDetails, + createChangeDetails +} from '~/shared/change-details'; import { Flex } from '../flex'; import { Input } from '../input'; import { InputProps } from '../input/input'; @@ -22,6 +26,8 @@ import { usePickerPopover } from './use-picker-popover'; */ type RangePickerCalendarSlot = Omit & CalendarPropsExtended; +export type RangePickerChangeDetails = ChangeDetails<'item-press'>; + export interface RangePickerSlotProps { startInput?: InputProps; endInput?: InputProps; @@ -42,7 +48,7 @@ export interface RangePickerProps { calendarProps?: RangePickerCalendarSlot; /** @deprecated Use `slotProps.popover` instead. */ popoverProps?: PopoverContentProps; - onSelect?: (date: DateRange) => void; + onValueChange?: (range: DateRange, details: RangePickerChangeDetails) => void; pickerGroupClassName?: string; value?: DateRange; defaultValue?: DateRange; @@ -62,7 +68,7 @@ export function RangePicker({ inputsProps: legacyInputsProps = {}, calendarProps: legacyCalendarProps, popoverProps: legacyPopoverProps, - onSelect = () => undefined, + onValueChange, value, /* * No inline default, since the state machine's "first click sets `from`" branch @@ -185,10 +191,15 @@ export function RangePicker({ * B1. from, before -> reset * B2. from, after -> commit `to`, close * C. from && to -> restart - * `onSelect` fires on every step; consumers gate on `range.to` for completed - * ranges. + * `onValueChange` fires on every step; consumers gate on `range.to` for + * completed ranges. */ - const handleSelect = (_: DateRange, selectedDay: Date) => { + const handleSelect = ( + _: DateRange, + selectedDay: Date, + _modifiers?: Modifiers, + event?: React.MouseEvent | React.KeyboardEvent + ) => { const { from, to } = selectedRange; let newRange: DateRange; let newField: RangeFields = 'to'; @@ -213,8 +224,11 @@ export function RangePicker({ } if (newField !== currentRangeField) setCurrentRangeField(newField); + onValueChange?.( + newRange, + createChangeDetails('item-press', event?.nativeEvent) + ); setValueUnwrapped(newRange); - onSelect(newRange); if (shouldClose) popover.disengage(); }; diff --git a/packages/react/components/data-view/__tests__/data-slots.test.tsx b/packages/react/components/data-view/__tests__/data-slots.test.tsx index e01ac3ae7..979b8fa2d 100644 --- a/packages/react/components/data-view/__tests__/data-slots.test.tsx +++ b/packages/react/components/data-view/__tests__/data-slots.test.tsx @@ -148,7 +148,7 @@ describe('DataView data-slot contract', () => { data={data} fields={fields} defaultSort={defaultSort} - query={{ group_by: ['status'] }} + defaultQuery={{ group_by: ['status'] }} > @@ -162,7 +162,7 @@ describe('DataView data-slot contract', () => { data={data} fields={fields} defaultSort={defaultSort} - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'eq', value: 'John' }] }} > @@ -185,7 +185,7 @@ describe('DataView data-slot contract', () => { data={data} fields={fields} defaultSort={defaultSort} - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'eq', value: 'John' }] }} > @@ -236,7 +236,7 @@ describe('DataView data-slot contract', () => { data={data} fields={fields} defaultSort={defaultSort} - query={{ search: 'John' }} + defaultQuery={{ search: 'John' }} > @@ -257,7 +257,7 @@ describe('DataView data-slot contract', () => { data={[]} fields={fields} defaultSort={defaultSort} - query={{ search: 'nomatch' }} + defaultQuery={{ search: 'nomatch' }} > No results diff --git a/packages/react/components/data-view/__tests__/data-view.test.tsx b/packages/react/components/data-view/__tests__/data-view.test.tsx index 11adc92ff..3de845a9a 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { afterEach, beforeAll, @@ -304,7 +305,7 @@ describe('DataView', () => { data={mockData} fields={mockFields} defaultSort={defaultSort} - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'eq', value: 'NonExistent' }] }} > @@ -520,7 +521,7 @@ describe('DataView', () => { fields={mockFields} defaultSort={defaultSort} mode='server' - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'neq', value: 'John Doe' }] }} > @@ -543,7 +544,9 @@ describe('DataView', () => { data={mockData} fields={mockFields} defaultSort={defaultSort} - query={{ filters: [{ name: 'name', operator: 'eq', value: 'x' }] }} + defaultQuery={{ + filters: [{ name: 'name', operator: 'eq', value: 'x' }] + }} > @@ -665,7 +668,7 @@ describe('DataView', () => { defaultSort={defaultSort} views={views} view='table' - query={{ + defaultQuery={{ filters: [{ name: 'status', operator: 'eq', value: 'active' }] }} > @@ -684,7 +687,7 @@ describe('DataView', () => { defaultSort={defaultSort} views={views} view='list' - query={{ + defaultQuery={{ filters: [{ name: 'status', operator: 'eq', value: 'active' }] }} > @@ -896,7 +899,7 @@ describe('DataView', () => { data={mockData} fields={mockFields} defaultSort={defaultSort} - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'eq', value: 'NonExistent' }] }} > @@ -927,7 +930,7 @@ describe('DataView', () => { defaultSort={defaultSort} mode='server' totalRowCount={10} - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'neq', value: 'John Doe' }] }} > @@ -947,7 +950,7 @@ describe('DataView', () => { data={mockData} fields={mockFields} defaultSort={defaultSort} - query={{ group_by: ['status'] }} + defaultQuery={{ group_by: ['status'] }} > @@ -966,7 +969,7 @@ describe('DataView', () => { data={dataWithDate} fields={mockFields} defaultSort={defaultSort} - query={{ group_by: ['name_first_letter'] }} + defaultQuery={{ group_by: ['name_first_letter'] }} groupByResolvers={{ name_first_letter: row => row.name.charAt(0).toUpperCase() }} @@ -981,8 +984,8 @@ describe('DataView', () => { }); describe('Wire-format translation', () => { - it('does NOT call onTableQueryChange in client mode', async () => { - const onTableQueryChange = vi.fn(); + it('calls onQueryChange in client mode on change, not on mount', async () => { + const onQueryChange = vi.fn(); const user = userEvent.setup(); render( { fields={mockFields} defaultSort={defaultSort} mode='client' - onTableQueryChange={onTableQueryChange} + onQueryChange={onQueryChange} > @@ -998,12 +1001,62 @@ describe('DataView', () => { ); - await user.type(screen.getByRole('searchbox'), 'jane'); - expect(onTableQueryChange).not.toHaveBeenCalled(); + expect(onQueryChange).not.toHaveBeenCalled(); + await user.type(screen.getByRole('searchbox'), 'j'); + expect(onQueryChange).toHaveBeenCalledWith( + expect.objectContaining({ search: 'j' }), + expect.objectContaining({ reason: 'none' }) + ); + }); + + it('follows a controlled query without calling onQueryChange', () => { + const onQueryChange = vi.fn(); + const renderWith = (search: string) => ( + + + + ); + const { rerender } = render(renderWith('John')); + expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument(); + + rerender(renderWith('Jane')); + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + expect(onQueryChange).not.toHaveBeenCalled(); + }); + + it('keeps a new filter with no value in a controlled query', async () => { + const user = userEvent.setup(); + function Controlled() { + const [query, setQuery] = useState({}); + return ( + + + + ); + } + render(); + await user.click(screen.getByRole('button', { name: /filter/i })); + await user.click(await screen.findByRole('menuitem', { name: 'Name' })); + expect( + document.querySelector('[data-slot="filter-chip"]') + ).toBeInTheDocument(); }); - it('calls onTableQueryChange in server mode after query change', async () => { - const onTableQueryChange = vi.fn(); + it('calls onQueryChange in server mode after query change', async () => { + const onQueryChange = vi.fn(); const user = userEvent.setup(); render( { fields={mockFields} defaultSort={defaultSort} mode='server' - onTableQueryChange={onTableQueryChange} + onQueryChange={onQueryChange} > @@ -1020,14 +1073,14 @@ describe('DataView', () => { ); await user.type(screen.getByRole('searchbox'), 'a'); - expect(onTableQueryChange).toHaveBeenCalled(); - const calls = onTableQueryChange.mock.calls; + expect(onQueryChange).toHaveBeenCalled(); + const calls = onQueryChange.mock.calls; const lastCall = calls[calls.length - 1]?.[0]; expect(lastCall?.search).toBe('a'); }); it('keeps the option type when a select filter value is picked', async () => { - const onTableQueryChange = vi.fn(); + const onQueryChange = vi.fn(); const user = userEvent.setup(); render( { ]} defaultSort={defaultSort} mode='server' - onTableQueryChange={onTableQueryChange} + onQueryChange={onQueryChange} > @@ -1062,7 +1115,7 @@ describe('DataView', () => { }); await user.click(screen.getByRole('option', { name: 'No' })); - const calls = onTableQueryChange.mock.calls; + const calls = onQueryChange.mock.calls; expect(calls[calls.length - 1]?.[0].filters).toEqual([ { name: 'status', operator: 'eq', value: false, boolValue: false } ]); @@ -1303,7 +1356,7 @@ describe('DataView', () => { data={mockData} fields={mockFields} defaultSort={defaultSort} - query={{ group_by: ['status'] }} + defaultQuery={{ group_by: ['status'] }} > { const renderRestored = ( filters: NonNullable, mode: 'client' | 'server' = 'client', - onTableQueryChange?: (query: DataViewQuery) => void + onQueryChange?: (query: DataViewQuery) => void ) => render( { fields={datedFields} defaultSort={{ name: 'id', order: 'asc' }} mode={mode} - query={{ filters }} - onTableQueryChange={onTableQueryChange} + defaultQuery={{ filters }} + onQueryChange={onQueryChange} > {ctx => ( @@ -1488,27 +1541,27 @@ describe('DataView', () => { }); it('emits a restored date filter as a day key', () => { - const onTableQueryChange = vi.fn(); + const onQueryChange = vi.fn(); renderRestored( [{ name: 'when', operator: 'eq', value: new Date(2026, 7, 15) }], 'server', - onTableQueryChange + onQueryChange ); - const last = onTableQueryChange.mock.calls[ - onTableQueryChange.mock.calls.length - 1 + const last = onQueryChange.mock.calls[ + onQueryChange.mock.calls.length - 1 ]?.[0] as DataViewQuery; expect(last.filters?.[0]?.stringValue).toBe('2026-08-15'); }); it('leaves a restored string filter as it was sent', () => { - const onTableQueryChange = vi.fn(); + const onQueryChange = vi.fn(); renderRestored( [{ name: 'id', operator: 'eq', value: 'd15' }], 'server', - onTableQueryChange + onQueryChange ); - const last = onTableQueryChange.mock.calls[ - onTableQueryChange.mock.calls.length - 1 + const last = onQueryChange.mock.calls[ + onQueryChange.mock.calls.length - 1 ]?.[0] as DataViewQuery; expect(last.filters?.[0]).toEqual({ name: 'id', diff --git a/packages/react/components/data-view/__tests__/timeline.test.tsx b/packages/react/components/data-view/__tests__/timeline.test.tsx index f000be845..85d22c468 100644 --- a/packages/react/components/data-view/__tests__/timeline.test.tsx +++ b/packages/react/components/data-view/__tests__/timeline.test.tsx @@ -343,7 +343,7 @@ function renderTimeline( fields={root.fields ?? fields} mode='client' defaultSort={root.sort ?? { name: 'title', order: 'asc' }} - query={root.query} + defaultQuery={root.query} getRowId={(row: Order) => row.id} > @@ -1669,7 +1669,7 @@ describe('DataView.Timeline grouping', () => { ]} mode='client' defaultSort={{ name: 'title', order: 'asc' }} - query={{ group_by: ['team'] }} + defaultQuery={{ group_by: ['team'] }} getRowId={(row: Order) => row.id} > ({ handleRemoveFilter(filter.name)} onValueChange={value => handleFilterValueChange(filter.name, value)} onOperationChange={operator => diff --git a/packages/react/components/data-view/data-view.tsx b/packages/react/components/data-view/data-view.tsx index d18f7820d..2dddd7209 100644 --- a/packages/react/components/data-view/data-view.tsx +++ b/packages/react/components/data-view/data-view.tsx @@ -10,7 +10,7 @@ import { useReactTable, VisibilityState } from '@tanstack/react-table'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { createChangeDetails } from '~/shared/change-details'; import { DataViewClearFilters } from './components/clear-filters'; @@ -29,6 +29,7 @@ import { DataViewContextType, DataViewField, DataViewProps, + DataViewQuery, DataViewViewChangeDetails, defaultGroupOption, GroupedData, @@ -53,13 +54,14 @@ function DataViewRoot({ data = [], fields, query, + defaultQuery, mode = 'client', isLoading = false, totalRowCount, loadingRowCount = 3, defaultSort, children, - onTableQueryChange, + onQueryChange, onLoadMore, onRowClick, onColumnVisibilityChange, @@ -72,8 +74,8 @@ function DataViewRoot({ groupByResolvers }: DataViewProps) { const defaultTableQuery = useMemo( - () => getDefaultTableQuery(defaultSort, query, fields), - [defaultSort, query, fields] + () => getDefaultTableQuery(defaultSort, defaultQuery, fields), + [defaultSort, defaultQuery, fields] ); // Active view (controlled / uncontrolled). @@ -158,10 +160,33 @@ function DataViewRoot({ [onRowSelectionChange] ); - const [tableQuery, setTableQuery] = - useState(defaultTableQuery); - - const oldQueryRef = useRef(null); + const [currentQuery, setCurrentQuery] = useControlled< + DataViewQuery | undefined + >({ + controlled: query, + default: defaultQuery, + name: 'DataView', + state: 'query' + }); + const [tableQuery, setTableQuery] = useState(() => + getDefaultTableQuery(defaultSort, currentQuery, fields) + ); + // The query `tableQuery` last matched. `tableQuery` also keeps filters that + // have no value yet, which the query leaves out, so it follows a new query + // only when the query differs from it. + const [syncedQuery, setSyncedQuery] = useState(currentQuery); + // Server mode reports the first query on mount, so the first page can load. + const [reportedQuery, setReportedQuery] = useState( + mode === 'server' ? null : tableQuery + ); + if (currentQuery !== syncedQuery) { + setSyncedQuery(currentQuery); + const next = getDefaultTableQuery(defaultSort, currentQuery, fields); + if (hasQueryChanged(tableQuery, next)) { + setTableQuery(next); + setReportedQuery(next); + } + } const reactTableState = useMemo( () => queryToTableState(tableQuery), @@ -198,16 +223,13 @@ function DataViewRoot({ ); useEffect(() => { - if ( - tableQuery && - onTableQueryChange && - hasQueryChanged(oldQueryRef.current, tableQuery) && - mode === 'server' - ) { - onTableQueryChange(transformToDataViewQuery(tableQuery)); - oldQueryRef.current = tableQuery; - } - }, [tableQuery, onTableQueryChange, mode]); + if (!hasQueryChanged(reportedQuery, tableQuery)) return; + const next = transformToDataViewQuery(tableQuery); + setReportedQuery(tableQuery); + setSyncedQuery(next); + setCurrentQuery(next); + onQueryChange?.(next, createChangeDetails('none')); + }, [tableQuery, reportedQuery, onQueryChange, setCurrentQuery]); const resolveRowId = useMemo(() => createRowIdResolver(getRowId), [getRowId]); @@ -248,13 +270,6 @@ function DataViewRoot({ if (mode === 'server' && onLoadMore) onLoadMore(); }, [mode, onLoadMore]); - const searchQuery = query?.search; - useEffect(() => { - if (searchQuery) { - setTableQuery(prev => ({ ...prev, search: searchQuery })); - } - }, [searchQuery]); - const rowCount = (() => { try { return table.getRowModel().rows.length; diff --git a/packages/react/components/data-view/data-view.types.tsx b/packages/react/components/data-view/data-view.types.tsx index 8cc2faea6..672d1a2bc 100644 --- a/packages/react/components/data-view/data-view.types.tsx +++ b/packages/react/components/data-view/data-view.types.tsx @@ -23,6 +23,8 @@ export type DataViewMode = 'client' | 'server'; export type DataViewViewChangeDetails = ChangeDetails; +export type DataViewQueryChangeDetails = ChangeDetails<'none'>; + export const SortOrders = { ASC: 'asc', DESC: 'desc' @@ -166,13 +168,19 @@ export interface DataViewProps { data: TData[]; /** Renderer-agnostic field metadata. Drives filter/sort/group/visibility. */ fields: DataViewField[]; - /** Initial query. Transformed to the internal shape on mount. */ + /** The query (controlled). */ query?: DataViewQuery; + /** The query on the first render (uncontrolled). */ + defaultQuery?: DataViewQuery; mode?: DataViewMode; isLoading?: boolean; totalRowCount?: number; loadingRowCount?: number; - onTableQueryChange?: (query: DataViewQuery) => void; + /** Called when the query changes. Server mode also calls it on mount. */ + onQueryChange?: ( + query: DataViewQuery, + details: DataViewQueryChangeDetails + ) => void; defaultSort: DataViewSort; onLoadMore?: () => Promise | void; onRowClick?: (row: TData) => void; diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index d13c87c03..8ba4e2560 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -127,6 +127,71 @@ describe('FilterChip', () => { ); expect(input).toHaveValue('initial value'); }); + + it('follows a controlled value', () => { + const onValueChange = vi.fn(); + const { container, rerender } = render( + + ); + const input = container.querySelector( + `.${styles.inputFieldWrapper} input` + ) as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'typed' } }); + expect(onValueChange).toHaveBeenCalledWith('typed', expect.anything()); + expect(input).toHaveValue('one'); + + rerender(); + expect(input).toHaveValue('two'); + }); + }); + + describe('Operation', () => { + it('starts at defaultOperation and reports a change with details', async () => { + const onOperationChange = vi.fn(); + render( + + ); + const trigger = getSlot(document.body, 'filter-chip-operation'); + expect(trigger).toHaveTextContent('is not'); + + fireEvent.click(trigger as Element); + await act(async () => { + await new Promise(r => setTimeout(r, 0)); + }); + await userEvent.click(screen.getByRole('option', { name: /is$/ })); + expect(onOperationChange).toHaveBeenCalledWith( + 'eq', + expect.objectContaining({ reason: expect.any(String) }) + ); + expect(trigger).toHaveTextContent(/^is$/); + }); + + it('shows a controlled operation', () => { + const { rerender } = render( + + ); + const trigger = getSlot(document.body, 'filter-chip-operation'); + expect(trigger).toHaveTextContent(/^is$/); + + rerender( + + ); + expect(trigger).toHaveTextContent('is not'); + }); }); describe('Select Filter Type', () => { @@ -487,7 +552,7 @@ describe('FilterChip', () => { ); diff --git a/packages/react/components/filter-chip/filter-chip-operation.tsx b/packages/react/components/filter-chip/filter-chip-operation.tsx index 94b113215..69ecf3595 100644 --- a/packages/react/components/filter-chip/filter-chip-operation.tsx +++ b/packages/react/components/filter-chip/filter-chip-operation.tsx @@ -4,13 +4,17 @@ import { cx } from 'class-variance-authority'; import { useCallback } from 'react'; import { FilterOperation, FilterOperator } from '~/types/filters'; import { Select } from '../select'; +import type { SelectChangeEventDetails } from '../select/select-root'; import { Text } from '../text'; import styles from './filter-chip.module.css'; interface OperationProps { label: string; value?: FilterOperation; - onChange: (operation: FilterOperation) => void; + onChange: ( + operation: FilterOperation, + eventDetails: SelectChangeEventDetails + ) => void; operations: FilterOperator[]; showAlternateLabel?: boolean; } @@ -25,9 +29,9 @@ export const Operation = ({ const operationValue = value?.value ?? ''; const handleValueChange = useCallback( - (newValue: string) => { + (newValue: string, eventDetails: SelectChangeEventDetails) => { const operation = operations.find(o => o.value === newValue); - if (operation) onChange(operation); + if (operation) onChange(operation, eventDetails); }, [operations, onChange] ); diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 5e6d96c3f..2ed4d1a02 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { TZDate } from '@date-fns/tz'; import { cva, cx, VariantProps } from 'class-variance-authority'; import { parseISO } from 'date-fns'; @@ -67,6 +68,10 @@ export type FilterChipChangeDetails = ChangeDetails< { operation: string } >; +export type FilterChipOperationChangeDetails = ChangeDetails< + SelectChangeEventDetails['reason'] +>; + /* The message `DatePicker` reports, so `onErrorChange` reads the same. */ const INVALID_DATE = 'Invalid date'; @@ -81,6 +86,24 @@ const toDateValue = (value: unknown, timeZone?: string): Date | undefined => { /** Checked on change, not keydown, so paste and IME input are covered. */ const PARTIAL_NUMBER = /^-?\d*\.?\d*$/; +/* A `number` column reports the text as is while it has no digits (`-`, `.`). */ +const toNumberValue = (text: string): FilterChipValue => { + const parsed = Number(text); + return text === '' || Number.isNaN(parsed) ? text : parsed; +}; + +/* Shows a number without an exponent (`1e-7`). A value that is not a number shows as empty. */ +const toNumberText = (value: FilterChipValue): string => { + const text = + typeof value === 'number' + ? value.toLocaleString('en-US', { + useGrouping: false, + maximumFractionDigits: 20 + }) + : String(value); + return PARTIAL_NUMBER.test(text) ? text : ''; +}; + /** * The `CalendarPreview` props that consumers may forward to the chip's * calendar via `calendarProps`. `FilterChip` owns the value and the parts. @@ -112,10 +135,13 @@ export type FilterChipCalendarProps = Pick< }; export interface FilterChipProps - extends ComponentProps<'div'>, + extends Omit, 'defaultValue'>, VariantProps { label: string; value?: FilterChipValue; + defaultValue?: FilterChipValue; + operation?: string; + defaultOperation?: string; onRemove?: () => void; columnType?: FilterTypes; options?: FilterSelectOption[]; @@ -123,7 +149,10 @@ export interface FilterChipProps value: FilterChipValue, details: FilterChipChangeDetails ) => void; - onOperationChange?: (operation: string) => void; + onOperationChange?: ( + operation: string, + details: FilterChipOperationChangeDetails + ) => void; leadingIcon?: ReactElement; operations?: FilterOperator[]; selectProps?: BaseSelectProps; @@ -142,6 +171,9 @@ export interface FilterChipProps export const FilterChip = ({ label, value, + defaultValue, + operation: operationProp, + defaultOperation, onRemove, className, ref, @@ -160,24 +192,28 @@ export const FilterChip = ({ ? operations : filterOperators[columnType]; - const [operation, setOperation] = useState( - computedOperations?.[0] + const [operationValue, setOperationValue] = useControlled( + { + controlled: operationProp, + default: defaultOperation ?? computedOperations?.[0]?.value, + name: 'FilterChip', + state: 'operation' + } ); + const operation = computedOperations?.find(o => o.value === operationValue); const isNumberColumn = columnType === FilterType.number; // `??` not `||`, since a falsy option value like `0` is a real selection. - // 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(() => { - 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 [filterValue, setFilterValue] = useControlled({ + controlled: value, + default: defaultValue ?? '', + name: 'FilterChip', + state: 'value' }); + // The typed text keeps `1.` and `-` editable. It shows while it matches the value. + const [numberText, setNumberText] = useState(() => toNumberText(filterValue)); + const shownNumberText = Object.is(toNumberValue(numberText), filterValue) + ? numberText + : toNumberText(filterValue); const [dateOpen, setDateOpen] = useState(false); const { @@ -203,31 +239,29 @@ export const FilterChip = ({ ); const handleOperationChange = useCallback( - (operation: FilterOperation) => { - setOperation(operation); - if (operation?.value) onOperationChange?.(operation.value); + (next: FilterOperation, eventDetails: SelectChangeEventDetails) => { + setOperationValue(next.value); + if (next.value) { + onOperationChange?.( + next.value, + createChangeDetails(eventDetails.reason, eventDetails.event) + ); + } }, - [onOperationChange] + [onOperationChange, setOperationValue] ); - const emitValueChange = useCallback( + const handleFilterValueChange = useCallback( (value: FilterChipValue, reason: FilterChipChangeReason, event?: Event) => { + setFilterValue(value); onValueChange?.( value, createChangeDetails(reason, event, { - operation: operation?.value ?? '' + operation: operationValue ?? '' }) ); }, - [operation, onValueChange] - ); - - const handleFilterValueChange = useCallback( - (value: FilterChipValue, reason: FilterChipChangeReason, event?: Event) => { - setFilterValue(value); - emitValueChange(value, reason, event); - }, - [emitValueChange] + [operationValue, onValueChange, setFilterValue] ); // Select works on strings, so map each pick back to its option's value. @@ -250,16 +284,15 @@ export const FilterChip = ({ handleFilterValueChange(raw, 'none', event); return; } - // Skipping setFilterValue makes React restore the controlled value. + // Skipping the state update makes React restore the shown text. if (!PARTIAL_NUMBER.test(raw)) return; const parsed = Number(raw); if (parsed === Infinity || parsed === -Infinity) return; - setFilterValue(raw); - const isIntermediate = raw === '' || Number.isNaN(parsed); - emitValueChange(isIntermediate ? raw : parsed, 'none', event); + setNumberText(raw); + handleFilterValueChange(toNumberValue(raw), 'none', event); }, - [isNumberColumn, handleFilterValueChange, emitValueChange] + [isNumberColumn, handleFilterValueChange] ); const renderValueInput = () => { @@ -365,7 +398,7 @@ export const FilterChip = ({ handleTextInputChange(e.target.value, e.nativeEvent) } diff --git a/packages/react/components/select/__tests__/select.test.tsx b/packages/react/components/select/__tests__/select.test.tsx index 9ad39f6fa..9382fb894 100644 --- a/packages/react/components/select/__tests__/select.test.tsx +++ b/packages/react/components/select/__tests__/select.test.tsx @@ -333,10 +333,14 @@ describe('Select', () => { expect(options[1]).toHaveTextContent('Pineapple'); }); - it('filters options by a controlled searchValue', async () => { - const onSearch = vi.fn(); + it('filters options by a controlled inputValue', async () => { + const onInputValueChange = vi.fn(); render( - @@ -357,15 +361,18 @@ describe('Select', () => { }); await flushMicrotasks(); - expect(onSearch).toHaveBeenCalledWith('b'); + expect(onInputValueChange).toHaveBeenCalledWith( + 'b', + expect.objectContaining({ reason: 'input-change' }) + ); const options = screen.getAllByRole('option'); expect(options.length).toBe(2); expect(options[0]).toHaveTextContent('Apple'); expect(options[1]).toHaveTextContent('Pineapple'); }); - it('shows a controlled searchValue in the search input', async () => { - render(); + it('shows a controlled inputValue in the search input', async () => { + render(); fireEvent.click(screen.getByLabelText('Select option')); await flushMicrotasks(); @@ -376,8 +383,8 @@ describe('Select', () => { expect(searchInput).toHaveValue('app'); }); - it('shows and filters by defaultSearchValue', async () => { - render(); + it('shows and filters by defaultInputValue', async () => { + render(); fireEvent.click(screen.getByLabelText('Select option')); await flushMicrotasks(); diff --git a/packages/react/components/select/select-item.tsx b/packages/react/components/select/select-item.tsx index 25e7abab7..c3fb0e670 100644 --- a/packages/react/components/select/select-item.tsx +++ b/packages/react/components/select/select-item.tsx @@ -29,7 +29,7 @@ export function SelectItem({ registerItem, unregisterItem, autocomplete, - searchValue, + inputValue, value: selectValue, shouldFilter, hasItems, @@ -39,7 +39,7 @@ export function SelectItem({ const isSelected = multiple ? selectValue?.includes(value) : value === selectValue; - const isMatched = getMatch(value, children, searchValue); + const isMatched = getMatch(value, children, inputValue); const isHidden = shouldFilter && !hasItems && isSelected && !isMatched; const element = diff --git a/packages/react/components/select/select-root.tsx b/packages/react/components/select/select-root.tsx index 7068255b8..a5a2d3056 100644 --- a/packages/react/components/select/select-root.tsx +++ b/packages/react/components/select/select-root.tsx @@ -19,13 +19,11 @@ import { ItemType } from './types'; interface CommonProps { autocomplete?: boolean; autocompleteMode?: 'auto' | 'manual'; - searchValue?: string; - onSearch?: (value: string) => void; - defaultSearchValue?: string; } interface SelectContextValue extends CommonProps { value?: string | string[]; + inputValue?: string; registerItem: (item: ItemType) => void; unregisterItem: (value: string) => void; multiple: boolean; @@ -51,7 +49,7 @@ export const useSelectContext = (): UseSelectContext => { const shouldFilter = !!( context?.autocomplete && context?.autocompleteMode === 'auto' && - context?.searchValue?.length + context?.inputValue?.length ); return { ...context, @@ -65,6 +63,12 @@ export type SelectChangeEventDetails = | ComboboxPrimitive.Root.ChangeEventDetails; export interface BaseSelectProps extends CommonProps { + inputValue?: string; + defaultInputValue?: string; + onInputValueChange?: ( + inputValue: string, + eventDetails: ComboboxPrimitive.Root.ChangeEventDetails + ) => void; children?: ReactNode; open?: boolean; defaultOpen?: boolean; @@ -108,9 +112,9 @@ export const SelectRoot = (props: SelectRootProps) => { defaultValue, autocomplete, autocompleteMode = 'auto', - searchValue: providedSearchValue, - onSearch, - defaultSearchValue = '', + inputValue: providedInputValue, + onInputValueChange, + defaultInputValue = '', open: providedOpen, defaultOpen = false, onOpenChange, @@ -133,11 +137,11 @@ export const SelectRoot = (props: SelectRootProps) => { name: 'Select', state: 'value' }); - const [searchValue, setSearchValueUnwrapped] = useControlled({ - controlled: providedSearchValue, - default: defaultSearchValue, + const [inputValue, setInputValueUnwrapped] = useControlled({ + controlled: providedInputValue, + default: defaultInputValue, name: 'Select', - state: 'searchValue' + state: 'inputValue' }); const [registeredItems, setRegisteredItems] = useState< SelectContextValue['items'] @@ -165,12 +169,16 @@ export const SelectRoot = (props: SelectRootProps) => { [multiple, onValueChange, setValueUnwrapped] ); - const handleSearchValueChange = useCallback( - (value: string) => { - setSearchValueUnwrapped(value); - onSearch?.(value); + const handleInputValueChange = useCallback( + ( + value: string, + eventDetails: ComboboxPrimitive.Root.ChangeEventDetails + ) => { + onInputValueChange?.(value, eventDetails); + if (eventDetails.isCanceled) return; + setInputValueUnwrapped(value); }, - [onSearch, setSearchValueUnwrapped] + [onInputValueChange, setInputValueUnwrapped] ); const registerItem = useCallback(item => { @@ -194,7 +202,7 @@ export const SelectRoot = (props: SelectRootProps) => { unregisterItem, autocomplete, autocompleteMode, - searchValue, + inputValue, multiple, items: registeredItems, hasItems: !!itemsProp @@ -205,7 +213,7 @@ export const SelectRoot = (props: SelectRootProps) => { unregisterItem, autocomplete, autocompleteMode, - searchValue, + inputValue, multiple, registeredItems, itemsProp @@ -232,9 +240,9 @@ export const SelectRoot = (props: SelectRootProps) => { { expect(onOpenChange).toHaveBeenCalledWith(false, { status: 'closed' }); }); + it('passes the new step in onStepIndexChange details', async () => { + const user = userEvent.setup(); + const onStepIndexChange = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Next' })); + expect(onStepIndexChange).toHaveBeenCalledWith( + 1, + expect.objectContaining({ reason: 'none', step: STEPS[1] }) + ); + }); + it('emits lifecycle events in order', async () => { const user = userEvent.setup(); const events: TourEvent[] = []; @@ -460,7 +472,7 @@ describe('Tour', () => { }); it('ignores navigation actions while closed', () => { - const onStepChange = vi.fn(); + const onStepIndexChange = vi.fn(); const Harness = () => { const actionsRef = useRef(null); return ( @@ -475,14 +487,14 @@ describe('Tour', () => { ); }; render(); fireEvent.click(screen.getByText('poke')); - expect(onStepChange).not.toHaveBeenCalled(); + expect(onStepIndexChange).not.toHaveBeenCalled(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); @@ -498,7 +510,12 @@ describe('Tour', () => { - + ); }; diff --git a/packages/react/components/tour/tour-root.tsx b/packages/react/components/tour/tour-root.tsx index 0112497ab..1418cb084 100644 --- a/packages/react/components/tour/tour-root.tsx +++ b/packages/react/components/tour/tour-root.tsx @@ -11,6 +11,10 @@ import { useRef, useState } from 'react'; +import { + type ChangeDetails, + createChangeDetails +} from '~/shared/change-details'; import { TourContent } from './tour-content'; import { TourContext, type TourContextValue } from './tour-context'; import { TourOverlay } from './tour-overlay'; @@ -31,6 +35,11 @@ const FADE_OUT_MS = 150; const REVEAL_TIMEOUT_MS = 2000; +export type TourStepIndexChangeDetails = ChangeDetails< + 'none', + { step: TourStep } +>; + function isElementInView(el: Element): boolean { const rect = el.getBoundingClientRect(); let node = el.parentElement; @@ -80,8 +89,11 @@ export interface TourRootProps { stepIndex?: number; /** Initially active step when uncontrolled. @default 0 */ defaultStepIndex?: number; - /** Called when the active step changes. */ - onStepChange?: (index: number, step: TourStep) => void; + /** Called when the active step changes. `details.step` is the new step. */ + onStepIndexChange?: ( + index: number, + details: TourStepIndexChangeDetails + ) => void; /** Receives every tour lifecycle event. */ onEvent?: (event: TourEvent) => void; /** A ref populated with the imperative tour controls. */ @@ -123,7 +135,7 @@ export function TourRoot({ onOpenChange, stepIndex: stepIndexProp, defaultStepIndex = 0, - onStepChange, + onStepIndexChange, onEvent, actionsRef, targetTimeout = 5000, @@ -158,8 +170,8 @@ export function TourRoot({ openRef.current = open; const onOpenChangeRef = useRef(onOpenChange); onOpenChangeRef.current = onOpenChange; - const onStepChangeRef = useRef(onStepChange); - onStepChangeRef.current = onStepChange; + const onStepIndexChangeRef = useRef(onStepIndexChange); + onStepIndexChangeRef.current = onStepIndexChange; const onEventRef = useRef(onEvent); onEventRef.current = onEvent; const targetNotFoundRef = useRef(targetNotFound); @@ -185,7 +197,12 @@ export function TourRoot({ const setIndex = useCallback( (nextIndex: number) => { setIndexUnwrapped(nextIndex); - onStepChangeRef.current?.(nextIndex, stepsRef.current[nextIndex]); + onStepIndexChangeRef.current?.( + nextIndex, + createChangeDetails('none', undefined, { + step: stepsRef.current[nextIndex] + }) + ); }, [setIndexUnwrapped] ); diff --git a/packages/react/figma/filter-chip.figma.ts b/packages/react/figma/filter-chip.figma.ts index 2a5fa37a7..4dc258f42 100644 --- a/packages/react/figma/filter-chip.figma.ts +++ b/packages/react/figma/filter-chip.figma.ts @@ -5,11 +5,11 @@ import figma from 'figma'; // The Figma component exposes no properties, so emit a minimal realistic -// example from the code API (required `label`, plus `columnType` and `value`). +// example from the code API (required `label`, plus `columnType` and `defaultValue`). export default { id: 'FilterChip', imports: ["import { FilterChip } from '@raystack/apsara'"], - example: figma.code``, + example: figma.code``, metadata: { nestable: true } }; From f4a07d0fa78a601e83d8c7e261b05340744372a3 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 22 Oct 2026 10:31:44 +0530 Subject: [PATCH 2/4] fix: [data-view] keep the query state for a copied controlled query A JSON copy of the reported query no longer resets the filters. Server mode reports the mount query once under StrictMode. onQueryChange runs before the state update. --- .../data-view/__tests__/data-view.test.tsx | 44 ++++++++++++++++--- .../react/components/data-view/data-view.tsx | 25 ++++++++--- .../components/data-view/utils/index.tsx | 4 ++ 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/react/components/data-view/__tests__/data-view.test.tsx b/packages/react/components/data-view/__tests__/data-view.test.tsx index 3de845a9a..27289103e 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; +import { StrictMode, useState } from 'react'; import { afterEach, beforeAll, @@ -1031,17 +1031,26 @@ describe('DataView', () => { expect(onQueryChange).not.toHaveBeenCalled(); }); - it('keeps a new filter with no value in a controlled query', async () => { + it('keeps a new filter with no value when the controlled query is a JSON copy', async () => { const user = userEvent.setup(); + const fields: DataViewField[] = [ + ...mockFields, + { + accessorKey: 'created', + label: 'Created', + filterable: true, + filterType: 'date' + } + ]; function Controlled() { const [query, setQuery] = useState({}); return ( setQuery(JSON.parse(JSON.stringify(next)))} > @@ -1050,9 +1059,14 @@ describe('DataView', () => { render(); await user.click(screen.getByRole('button', { name: /filter/i })); await user.click(await screen.findByRole('menuitem', { name: 'Name' })); + await user.click(screen.getByRole('button', { name: 'Add filter' })); + await user.click( + await screen.findByRole('menuitem', { name: 'Created' }) + ); + expect( - document.querySelector('[data-slot="filter-chip"]') - ).toBeInTheDocument(); + document.querySelectorAll('[data-slot="filter-chip"]') + ).toHaveLength(2); }); it('calls onQueryChange in server mode after query change', async () => { @@ -1079,6 +1093,24 @@ describe('DataView', () => { expect(lastCall?.search).toBe('a'); }); + it('calls onQueryChange once on mount in server mode under StrictMode', () => { + const onQueryChange = vi.fn(); + render( + + + + + + ); + expect(onQueryChange).toHaveBeenCalledTimes(1); + }); + it('keeps the option type when a select filter value is picked', async () => { const onQueryChange = vi.fn(); const user = userEvent.setup(); diff --git a/packages/react/components/data-view/data-view.tsx b/packages/react/components/data-view/data-view.tsx index 2dddd7209..0d9ee1a12 100644 --- a/packages/react/components/data-view/data-view.tsx +++ b/packages/react/components/data-view/data-view.tsx @@ -10,7 +10,7 @@ import { useReactTable, VisibilityState } from '@tanstack/react-table'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createChangeDetails } from '~/shared/change-details'; import { DataViewClearFilters } from './components/clear-filters'; @@ -46,6 +46,7 @@ import { groupData, hasQueryChanged, isGroupRowData, + isSameQuery, queryToTableState, transformToDataViewQuery } from './utils'; @@ -181,10 +182,13 @@ function DataViewRoot({ ); if (currentQuery !== syncedQuery) { setSyncedQuery(currentQuery); - const next = getDefaultTableQuery(defaultSort, currentQuery, fields); - if (hasQueryChanged(tableQuery, next)) { - setTableQuery(next); - setReportedQuery(next); + // A copy of the reported query, for example from the URL, keeps `tableQuery`. + if (!isSameQuery(currentQuery, transformToDataViewQuery(tableQuery))) { + const next = getDefaultTableQuery(defaultSort, currentQuery, fields); + if (hasQueryChanged(tableQuery, next)) { + setTableQuery(next); + setReportedQuery(next); + } } } @@ -222,13 +226,20 @@ function DataViewRoot({ [data, group_by, effectiveFields, groupByResolvers] ); + // StrictMode runs the mount effect twice with the same `reportedQuery`. + const lastReportRef = useRef(null); useEffect(() => { - if (!hasQueryChanged(reportedQuery, tableQuery)) return; + if ( + !hasQueryChanged(reportedQuery, tableQuery) || + lastReportRef.current === tableQuery + ) + return; + lastReportRef.current = tableQuery; const next = transformToDataViewQuery(tableQuery); + onQueryChange?.(next, createChangeDetails('none')); setReportedQuery(tableQuery); setSyncedQuery(next); setCurrentQuery(next); - onQueryChange?.(next, createChangeDetails('none')); }, [tableQuery, reportedQuery, onQueryChange, setCurrentQuery]); const resolveRowId = useMemo(() => createRowIdResolver(getRowId), [getRowId]); diff --git a/packages/react/components/data-view/utils/index.tsx b/packages/react/components/data-view/utils/index.tsx index 9384907cd..0b990ac1f 100644 --- a/packages/react/components/data-view/utils/index.tsx +++ b/packages/react/components/data-view/utils/index.tsx @@ -232,6 +232,10 @@ export const hasActiveQuery = ( return hasFilters || hasSearch || sortChanged || groupChanged; }; +/** Compares two wire queries by value, so a JSON copy matches its source. */ +export const isSameQuery = (a?: DataViewQuery, b?: DataViewQuery): boolean => + JSON.stringify(a) === JSON.stringify(b); + export const hasQueryChanged = ( oldQuery: InternalQuery | null, newQuery: InternalQuery From 704790906bd6944f3fa9df735938e6b4b4db04e3 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 22 Oct 2026 10:31:49 +0530 Subject: [PATCH 3/4] fix: [date-picker] accept null as a controlled empty value A controlled DatePicker can start empty with value={null}. onValueChange runs before the state update. --- apps/www/src/content/docs/(overview)/upgrading.mdx | 4 +++- .../www/src/content/docs/components/calendar/props.ts | 9 +++++---- .../calendar/__tests__/date-picker.test.tsx | 11 +++++++++++ packages/react/components/calendar/date-picker.tsx | 10 +++++++--- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 5d37d40ca..ba250d27e 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -63,7 +63,9 @@ A later change to `query.search` no longer updates the search on its own. Pass - `DataView` calls `onQueryChange` in client mode too. Server mode still calls it on mount, so the first page can load. - `DatePicker` with `value` shows only the `value` you pass. A clicked or typed - date shows after you pass it back. + date shows after you pass it back. To start a controlled `DatePicker` empty, + pass `value={null}`. A `value` that is `undefined` on the first render makes + it uncontrolled. - `DatePicker` calls `onValueChange` on Enter, blur or an outside click only when the typed date differs from the value. `onSelect` fired on every close. - `FilterChip` has `operation` and `defaultOperation`, and `onOperationChange` diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index 14e9f5be7..808638c75 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -237,11 +237,12 @@ export interface DatePickerProps { }; /** - * Controlled date value. Pair with `onValueChange`. Omit (along with - * `defaultValue`) to start the picker in an unselected state — the - * input shows its placeholder until the user selects a date. + * Controlled date value. Pair with `onValueChange`. Pass `null` for a + * controlled empty date. Omit (along with `defaultValue`) to start the + * picker in an unselected state — the input shows its placeholder until + * the user selects a date. */ - value?: Date; + value?: Date | null; /** * Initial (uncontrolled) date value. Ignored if `value` is set. Omit to diff --git a/packages/react/components/calendar/__tests__/date-picker.test.tsx b/packages/react/components/calendar/__tests__/date-picker.test.tsx index 60a05c65d..fddd50d16 100644 --- a/packages/react/components/calendar/__tests__/date-picker.test.tsx +++ b/packages/react/components/calendar/__tests__/date-picker.test.tsx @@ -195,6 +195,17 @@ describe('DatePicker', () => { expect(input.value).toBe('15 Jun 2026'); }); + it('follows a controlled value that starts as null', () => { + const { rerender } = render(); + const input = screen.getByPlaceholderText( + 'Select date' + ) as HTMLInputElement; + expect(input.value).toBe(''); + + rerender(); + expect(input.value).toBe('15 Jun 2026'); + }); + it('keeps a controlled value that the parent does not update', () => { const onValueChange = vi.fn(); render( diff --git a/packages/react/components/calendar/date-picker.tsx b/packages/react/components/calendar/date-picker.tsx index 3fa48bdc5..3b4ecb8e1 100644 --- a/packages/react/components/calendar/date-picker.tsx +++ b/packages/react/components/calendar/date-picker.tsx @@ -65,7 +65,8 @@ export interface DatePickerProps { * it; `Field` also wires `aria-invalid` onto the input. */ onErrorChange?: (error: string | undefined) => void; - value?: Date; + /** The date (controlled). `null` is a controlled empty date. */ + value?: Date | null; defaultValue?: Date; children?: | React.ReactNode @@ -98,12 +99,15 @@ export function DatePicker({ * to `Popover.Trigger` even when the input itself is `disabled`. */ const isDisabled = !!inputProps.disabled; - const [value, setValueUnwrapped] = useControlled({ + const [valueState, setValueUnwrapped] = useControlled< + Date | null | undefined + >({ controlled: valueProp, default: defaultValue, name: 'DatePicker', state: 'value' }); + const value = valueState ?? undefined; // A valid typed date. Enter, blur or an outside click commits it. const [typedDate, setTypedDate] = useState(); const selectedDate = typedDate ?? value; @@ -153,8 +157,8 @@ export function DatePicker({ }, [popover.isOpen, selectedDate?.getTime(), calendarProps?.defaultMonth]); function setValue(date: Date, details: DatePickerChangeDetails) { - setValueUnwrapped(date); onValueChange?.(date, details); + setValueUnwrapped(date); } function closePicker(event?: Event) { From de53b19a3b7ea6cdabde6983e44cbcc2b2ed0444 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Thu, 22 Oct 2026 10:31:53 +0530 Subject: [PATCH 4/4] refactor: [tour, filter-chip] call change callbacks before the state update --- packages/react/components/filter-chip/filter-chip.tsx | 4 ++-- packages/react/components/tour/tour-root.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 2ed4d1a02..0f470ee9b 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -240,26 +240,26 @@ export const FilterChip = ({ const handleOperationChange = useCallback( (next: FilterOperation, eventDetails: SelectChangeEventDetails) => { - setOperationValue(next.value); if (next.value) { onOperationChange?.( next.value, createChangeDetails(eventDetails.reason, eventDetails.event) ); } + setOperationValue(next.value); }, [onOperationChange, setOperationValue] ); const handleFilterValueChange = useCallback( (value: FilterChipValue, reason: FilterChipChangeReason, event?: Event) => { - setFilterValue(value); onValueChange?.( value, createChangeDetails(reason, event, { operation: operationValue ?? '' }) ); + setFilterValue(value); }, [operationValue, onValueChange, setFilterValue] ); diff --git a/packages/react/components/tour/tour-root.tsx b/packages/react/components/tour/tour-root.tsx index 1418cb084..2e3ded710 100644 --- a/packages/react/components/tour/tour-root.tsx +++ b/packages/react/components/tour/tour-root.tsx @@ -196,13 +196,13 @@ export function TourRoot({ const setIndex = useCallback( (nextIndex: number) => { - setIndexUnwrapped(nextIndex); onStepIndexChangeRef.current?.( nextIndex, createChangeDetails('none', undefined, { step: stepsRef.current[nextIndex] }) ); + setIndexUnwrapped(nextIndex); }, [setIndexUnwrapped] );