diff --git a/.agents/skills/apsara/references/composition.md b/.agents/skills/apsara/references/composition.md index 48c016542..ac55fdef8 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 418285cd0..770c01d52 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 a3d40c80a..f45969a0c 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'] }} > @@ -1075,7 +1075,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 4bd59b244..a06074f87 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,71 @@ 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. 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` + 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 0dfb2c93f..c92c83802 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 50f3af764..a228da8f9 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`. `onValidityChange` reports typed text that is not a date (`reason: 'unparseable'`) or is outside `startMonth` and `endMonth` (`reason: 'out-of-bounds'`), with the message `'Invalid date'`. It reports `{ valid: true }` when the text is valid again, or when the picker commits or closes. DatePicker shows no error and sets no `aria-invalid`. To show the error, pass `message` to the `error` prop of `Field`, as in the With Field example. `Field` also sets `aria-invalid` on the input. diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index 51a43d123..cdb4b60f9 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,11 +237,12 @@ export interface DatePickerProps { }; /** - * Controlled date value. Pair with `onSelect`. 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 @@ -246,8 +250,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; /** Called when the typed date becomes invalid or valid again. */ onValidityChange?: (validity: { 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 11c14caa3..b2d28ab64 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -71,7 +71,7 @@ when you pass both. A value the list does not have falls back to the first one. ### 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 13434f54c..814ad824d 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,30 @@ 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; + + /** Active operation, by its `value`. A value `operations` does not have falls back to the first one. + * @default the first entry of `operations` + */ + operation?: string; + + /** Operation to start on, by its `value`, when `operation` is not set. + * @default the first entry of `operations` + */ + defaultOperation?: string; + /** Type of input for the filter * @default "string" */ @@ -26,16 +45,6 @@ export interface FilterChipProps { /** Optional array of operations for the type of filter operation */ operations?: { label: string; value: string }[]; - /** Active operation, by its `value`. A value `operations` does not have falls back to the first one. - * @default the first entry of `operations` - */ - operation?: string; - - /** Operation to start on, by its `value`, when `operation` is not set. - * @default the first entry of `operations` - */ - defaultOperation?: string; - /** Callback when the filter value changes. `details.operation` is the active * operation. For `select` and `multiselect`, the value is the picked * option's `value`, with its type kept. For `number`, non-numeric input is @@ -47,8 +56,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; @@ -60,9 +72,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 ed1fec42a..9b5edc3a4 100644 --- a/apps/www/src/content/docs/components/select/demo.ts +++ b/apps/www/src/content/docs/components/select/demo.ts @@ -213,7 +213,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 a04ac39a4..32d3865b8 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -94,7 +94,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 fe05fc36e..6750f48ea 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 34bb03d6e..cb2654538 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 687f64297..3be5ad7d5 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"] }} > @@ -484,7 +484,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 1cf607d5f..e72da047f 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 0bdc3249c..b7b9e71c2 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 { */ loading?: 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 19902ac31..9235ec58f 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 29c134c5e..a822aaf15 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,40 @@ describe('DatePicker', () => { rerender(); 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( + + ); + 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 +239,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 +306,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 +325,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'); }); }); @@ -385,13 +424,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( ); @@ -404,8 +443,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); } @@ -448,12 +487,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( ); @@ -480,13 +519,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 onValidityChange = vi.fn(); render( ); @@ -497,14 +536,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(onValidityChange).toHaveBeenLastCalledWith({ valid: true }); - 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 d1657a457..0bfc4c78b 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 574368568..db9f92797 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 type { CalendarPreviewInputValidity } from '../calendar-preview/calendar-preview-input'; import { Input } from '../input'; import { InputProps } from '../input/input'; @@ -28,6 +33,10 @@ dayjs.extend(isSameOrBefore); */ type DatePickerCalendarSlot = Omit & CalendarPropsExtended; +export type DatePickerChangeDetails = ChangeDetails< + 'item-press' | 'input-change' +>; + const VALID: CalendarPreviewInputValidity = { valid: true }; export interface DatePickerSlotProps { @@ -49,10 +58,11 @@ export interface DatePickerProps { calendarProps?: DatePickerCalendarSlot; /** @deprecated Use `slotProps.popover` instead. */ popoverProps?: PopoverContentProps; - onSelect?: (date: Date) => void; + onValueChange?: (date: Date, details: DatePickerChangeDetails) => void; /** Called when the typed date becomes invalid or valid again. */ onValidityChange?: (validity: CalendarPreviewInputValidity) => void; - value?: Date; + /** The date (controlled). `null` is a controlled empty date. */ + value?: Date | null; defaultValue?: Date; children?: | React.ReactNode @@ -73,7 +83,7 @@ export function DatePicker({ popoverProps: legacyPopoverProps, value: valueProp, defaultValue, - onSelect = () => undefined, + onValueChange, onValidityChange, children, hideCalendarIcon = false, @@ -89,14 +99,18 @@ 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 [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; const validityRef = useRef(VALID); @@ -108,12 +122,6 @@ export function DatePicker({ } } - // 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) : ''; @@ -129,13 +137,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); @@ -151,36 +152,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) { + onValueChange?.(date, details); + setValueUnwrapped(date); + } + + function closePicker(event?: Event) { popover.disengage(); - const committedDate = selectedDateRef.current; const hadError = !validityRef.current.valid; - setInputValue(committedDate ? dayjs(committedDate).format(dateFormat) : ''); + const nextDate = hadError ? value : selectedDate; + setTypedDate(undefined); + setInputValue(nextDate ? dayjs(nextDate).format(dateFormat) : ''); updateValidity(VALID); - /* - * 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); + } updateValidity(VALID); popover.disengage(); } @@ -188,7 +197,7 @@ export function DatePicker({ function handleKeyUp(event: React.KeyboardEvent) { if (event.code === 'Enter' && popover.inputRef.current) { popover.inputRef.current.blur(); - closePicker(); + closePicker(event.nativeEvent); } } @@ -220,7 +229,7 @@ export function DatePicker({ const isValid = isValidDate && isAfter && isBefore; if (isValid) { - setSelectedDate(date.toDate()); + setTypedDate(date.toDate()); updateValidity(VALID); } else { updateValidity({ diff --git a/packages/react/components/calendar/range-picker.tsx b/packages/react/components/calendar/range-picker.tsx index 40f2f93a3..dcaf78169 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; @@ -66,7 +72,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 @@ -189,10 +195,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'; @@ -217,8 +228,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 d1ca55a17..650077c7d 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 6fe5ea91f..b7778cdd0 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 { StrictMode, 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' }] + }} > @@ -690,7 +693,7 @@ describe('DataView', () => { defaultSort={defaultSort} views={views} view='table' - query={{ + defaultQuery={{ filters: [{ name: 'status', operator: 'eq', value: 'active' }] }} > @@ -709,7 +712,7 @@ describe('DataView', () => { defaultSort={defaultSort} views={views} view='list' - query={{ + defaultQuery={{ filters: [{ name: 'status', operator: 'eq', value: 'active' }] }} > @@ -921,7 +924,7 @@ describe('DataView', () => { data={mockData} fields={mockFields} defaultSort={defaultSort} - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'eq', value: 'NonExistent' }] }} > @@ -952,7 +955,7 @@ describe('DataView', () => { defaultSort={defaultSort} mode='server' totalRowCount={10} - query={{ + defaultQuery={{ filters: [{ name: 'name', operator: 'neq', value: 'John Doe' }] }} > @@ -972,7 +975,7 @@ describe('DataView', () => { data={mockData} fields={mockFields} defaultSort={defaultSort} - query={{ group_by: ['status'] }} + defaultQuery={{ group_by: ['status'] }} > @@ -991,7 +994,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() }} @@ -1006,8 +1009,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} > @@ -1023,12 +1026,76 @@ 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('calls onTableQueryChange in server mode after query change', async () => { - const onTableQueryChange = vi.fn(); + 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)))} + > + + + ); + } + 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.querySelectorAll('[data-slot="filter-chip"]') + ).toHaveLength(2); + }); + + 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} > @@ -1045,14 +1112,32 @@ 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('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 onTableQueryChange = vi.fn(); + const onQueryChange = vi.fn(); const user = userEvent.setup(); render( { ]} defaultSort={defaultSort} mode='server' - onTableQueryChange={onTableQueryChange} + onQueryChange={onQueryChange} > @@ -1087,7 +1172,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 } ]); @@ -1328,7 +1413,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 => ( @@ -1513,27 +1598,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 9cd87c33c..3d0d1b64d 100644 --- a/packages/react/components/data-view/__tests__/timeline.test.tsx +++ b/packages/react/components/data-view/__tests__/timeline.test.tsx @@ -344,7 +344,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} > @@ -1670,7 +1670,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 => @@ -170,7 +171,6 @@ export function Filters({ ) } columnType={filter.filterType} - operation={filter.operator} options={filter.options} selectProps={filter.selectProps} className={classNames?.filterChips} diff --git a/packages/react/components/data-view/data-view.tsx b/packages/react/components/data-view/data-view.tsx index 33d53a485..ea2fc44ad 100644 --- a/packages/react/components/data-view/data-view.tsx +++ b/packages/react/components/data-view/data-view.tsx @@ -29,6 +29,7 @@ import { DataViewContextType, DataViewField, DataViewProps, + DataViewQuery, DataViewViewChangeDetails, defaultGroupOption, GroupedData, @@ -45,6 +46,7 @@ import { groupData, hasQueryChanged, isGroupRowData, + isSameQuery, queryToTableState, transformToDataViewQuery } from './utils'; @@ -53,13 +55,14 @@ function DataViewRoot({ data = [], fields, query, + defaultQuery, mode = 'client', loading = false, totalRowCount, loadingRowCount = 3, defaultSort, children, - onTableQueryChange, + onQueryChange, onLoadMore, onRowClick, onColumnVisibilityChange, @@ -72,8 +75,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 +161,36 @@ 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); + // 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); + } + } + } const reactTableState = useMemo( () => queryToTableState(tableQuery), @@ -197,17 +226,21 @@ function DataViewRoot({ [data, group_by, effectiveFields, groupByResolvers] ); + // StrictMode runs the mount effect twice with the same `reportedQuery`. + const lastReportRef = useRef(null); useEffect(() => { if ( - tableQuery && - onTableQueryChange && - hasQueryChanged(oldQueryRef.current, tableQuery) && - mode === 'server' - ) { - onTableQueryChange(transformToDataViewQuery(tableQuery)); - oldQueryRef.current = tableQuery; - } - }, [tableQuery, onTableQueryChange, mode]); + !hasQueryChanged(reportedQuery, tableQuery) || + lastReportRef.current === tableQuery + ) + return; + lastReportRef.current = tableQuery; + const next = transformToDataViewQuery(tableQuery); + onQueryChange?.(next, createChangeDetails('none')); + setReportedQuery(tableQuery); + setSyncedQuery(next); + setCurrentQuery(next); + }, [tableQuery, reportedQuery, onQueryChange, setCurrentQuery]); const resolveRowId = useMemo(() => createRowIdResolver(getRowId), [getRowId]); @@ -248,13 +281,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 d3484973a..bfa1db0fd 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; loading?: 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/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 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 8e2dfb224..2ca8d9dc3 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -126,7 +126,10 @@ describe('FilterChip', () => { fireEvent.click(item); await act(flushMicrotasks); - expect(onOperationChange).toHaveBeenCalledWith('contains'); + expect(onOperationChange).toHaveBeenCalledWith( + 'contains', + expect.objectContaining({ reason: expect.any(String) }) + ); expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( 'contains' ); @@ -213,6 +216,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', () => { @@ -573,7 +641,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 0041793f8..cd4907007 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 77aada795..4475025c3 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -68,6 +68,10 @@ export type FilterChipChangeDetails = ChangeDetails< { operation: string } >; +export type FilterChipOperationChangeDetails = ChangeDetails< + SelectChangeEventDetails['reason'] +>; + /* The message `DatePicker` reports, so `onValidityChange` reads the same. */ const INVALID_DATE = 'Invalid date'; @@ -82,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. @@ -111,20 +133,11 @@ export type FilterChipCalendarProps = Pick< }; export interface FilterChipProps - extends ComponentProps<'div'>, + extends Omit, 'defaultValue'>, VariantProps { label: string; value?: FilterChipValue; - onRemove?: () => void; - columnType?: FilterTypes; - options?: FilterSelectOption[]; - onValueChange?: ( - value: FilterChipValue, - details: FilterChipChangeDetails - ) => void; - onOperationChange?: (operation: string) => void; - leadingIcon?: ReactNode; - operations?: FilterOperator[]; + defaultValue?: FilterChipValue; /** * Active operation, by its `value`. A value `operations` does not have falls * back to the first one. @@ -136,6 +149,19 @@ export interface FilterChipProps * @default the first entry of `operations` */ defaultOperation?: string; + onRemove?: () => void; + columnType?: FilterTypes; + options?: FilterSelectOption[]; + onValueChange?: ( + value: FilterChipValue, + details: FilterChipChangeDetails + ) => void; + onOperationChange?: ( + operation: string, + details: FilterChipOperationChangeDetails + ) => void; + leadingIcon?: ReactNode; + operations?: FilterOperator[]; selectProps?: BaseSelectProps; /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */ calendarProps?: FilterChipCalendarProps; @@ -152,6 +178,9 @@ export interface FilterChipProps export const FilterChip = ({ label, value, + defaultValue, + operation: operationProp, + defaultOperation, onRemove, className, ref, @@ -162,8 +191,6 @@ export const FilterChip = ({ leadingIcon, variant, operations, - operation: operationProp, - defaultOperation, selectProps, calendarProps, ...props @@ -172,33 +199,30 @@ export const FilterChip = ({ ? operations : filterOperators[columnType]; - const toOperation = (value?: string) => - value === undefined - ? undefined - : (computedOperations?.find(option => option.value === value) ?? - computedOperations?.[0]); - - const [operation, setOperation] = useControlled({ - controlled: toOperation(operationProp), - default: toOperation(defaultOperation) ?? computedOperations?.[0], - name: 'FilterChip', - state: 'operation' - }); + const [operationValue, setOperationValue] = useControlled( + { + controlled: operationProp, + default: defaultOperation ?? computedOperations?.[0]?.value, + name: 'FilterChip', + state: 'operation' + } + ); + const operation = + computedOperations?.find(o => o.value === operationValue) ?? + computedOperations?.[0]; 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 { @@ -223,14 +247,19 @@ export const FilterChip = ({ ); const handleOperationChange = useCallback( - (operation: FilterOperation) => { - setOperation(operation); - if (operation?.value) onOperationChange?.(operation.value); + (next: FilterOperation, eventDetails: SelectChangeEventDetails) => { + if (next.value) { + onOperationChange?.( + next.value, + createChangeDetails(eventDetails.reason, eventDetails.event) + ); + } + setOperationValue(next.value); }, - [onOperationChange, setOperation] + [onOperationChange, setOperationValue] ); - const emitValueChange = useCallback( + const handleFilterValueChange = useCallback( (value: FilterChipValue, reason: FilterChipChangeReason, event?: Event) => { onValueChange?.( value, @@ -238,16 +267,9 @@ export const FilterChip = ({ operation: operation?.value ?? '' }) ); - }, - [operation, onValueChange] - ); - - const handleFilterValueChange = useCallback( - (value: FilterChipValue, reason: FilterChipChangeReason, event?: Event) => { setFilterValue(value); - emitValueChange(value, reason, event); }, - [emitValueChange] + [operation, onValueChange, setFilterValue] ); // Select works on strings, so map each pick back to its option's value. @@ -270,16 +292,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 = () => { @@ -379,7 +400,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 aafbd44d9..f5f6d1b00 100644 --- a/packages/react/components/select/__tests__/select.test.tsx +++ b/packages/react/components/select/__tests__/select.test.tsx @@ -450,10 +450,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( - @@ -474,15 +478,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(); @@ -493,8 +500,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 b10f8430f..0f0568448 100644 --- a/packages/react/components/select/select-item.tsx +++ b/packages/react/components/select/select-item.tsx @@ -31,7 +31,7 @@ export function SelectItem({ registerItem, unregisterItem, autocomplete, - searchValue, + inputValue, value: selectValue, shouldFilter, hasItems, @@ -41,7 +41,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-misc.tsx b/packages/react/components/select/select-misc.tsx index f655aea94..fab945ef3 100644 --- a/packages/react/components/select/select-misc.tsx +++ b/packages/react/components/select/select-misc.tsx @@ -70,13 +70,13 @@ export function SelectEmpty({ children, ...props }: SelectEmptyProps) { - const { autocomplete, autocompleteMode, items, searchValue } = + const { autocomplete, autocompleteMode, items, inputValue } = useSelectContext(); const hasMatch = Object.values(items).some( item => autocompleteMode === 'manual' || - getMatch(item.value, item.children, searchValue) + getMatch(item.value, item.children, inputValue) ); // Base UI's Empty counts only root `items`, so the match check happens here. 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..2e3ded710 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); @@ -184,8 +196,13 @@ export function TourRoot({ const setIndex = useCallback( (nextIndex: number) => { + onStepIndexChangeRef.current?.( + nextIndex, + createChangeDetails('none', undefined, { + step: stepsRef.current[nextIndex] + }) + ); setIndexUnwrapped(nextIndex); - onStepChangeRef.current?.(nextIndex, 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 } };