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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/apsara/references/composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/www/src/app/examples/timeline-stress/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
<DataView.Toolbar>
Expand Down
8 changes: 4 additions & 4 deletions apps/www/src/components/dataview-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -436,7 +436,7 @@ export function DataViewGroupingDemo() {
data={groupedPeople}
fields={fields}
defaultSort={defaultSort}
query={{ group_by: ['team'] }}
defaultQuery={{ group_by: ['team'] }}
>
<DataView.Toolbar>
<DataView.Filters />
Expand Down Expand Up @@ -468,7 +468,7 @@ export function DataViewVirtualizedGroupingDemo() {
data={data}
fields={fields}
defaultSort={defaultSort}
query={{ group_by: ['team'] }}
defaultQuery={{ group_by: ['team'] }}
>
<DataView.Toolbar>
<DataView.Filters />
Expand Down Expand Up @@ -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}
>
<DataView.Toolbar>
Expand Down
65 changes: 65 additions & 0 deletions apps/www/src/content/docs/(overview)/upgrading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Select autocomplete onSearch={setSearch} />
<DatePicker onSelect={setDate} />
<Tour steps={steps} onStepChange={(index, step) => track(step.id)} />
<DataView query={query} onTableQueryChange={setQuery} />

// After
<Select autocomplete onInputValueChange={setSearch} />
<DatePicker onValueChange={setDate} />
<Tour steps={steps} onStepIndexChange={(index, details) => track(details.step.id)} />
<DataView query={query} onQueryChange={setQuery} />
```

### 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
<DataView query={{ group_by: ["team"] }} />
<FilterChip label="Status" value="active" />

// After
<DataView defaultQuery={{ group_by: ["team"] }} />
<FilterChip label="Status" defaultValue="active" />
```

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
Expand Down
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/calendar/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const rangePickerDemo = {
code: `
<RangePicker
dateFormat="DD/MM/YYYY"
onSelect={(range) => 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)
Expand Down
4 changes: 2 additions & 2 deletions apps/www/src/content/docs/components/calendar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Demo data={rangePickerDemo} />

### 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`.

<Demo data={datePickerDemo} />

Expand Down
28 changes: 19 additions & 9 deletions apps/www/src/content/docs/components/calendar/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -234,20 +237,27 @@ 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
* start unselected.
*/
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
Expand Down
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/filter-chip/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<Demo data={controlledDemo} />

Expand Down
34 changes: 28 additions & 6 deletions apps/www/src/content/docs/components/filter-chip/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | number | boolean> | Date;

/** The filter value on the first render (uncontrolled). Takes the same
* types as `value`. */
defaultValue?:
| string
| number
| boolean
| Array<string | number | boolean>
| 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"
*/
Expand Down Expand Up @@ -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;
Expand All @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/select/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export const autocompleteDemo = {
];

const [simpleSearchQuery, setSimpleSearchQuery] = React.useState("");
return <Select autocomplete autocompleteMode="manual" onSearch={value => setSimpleSearchQuery(value)}>
return <Select autocomplete autocompleteMode="manual" onInputValueChange={value => setSimpleSearchQuery(value)}>
<Select.Trigger>
<Select.Value placeholder="Select..." />
</Select.Trigger>
Expand Down
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/select/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

<Demo data={autocompleteDemo} />

Expand Down
17 changes: 10 additions & 7 deletions apps/www/src/content/docs/components/select/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/tour/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const [index, setIndex] = useState(0);
setOpen(open);
if (!open) analytics.track("tour_end", { status });
}}
onStepChange={setIndex}
onStepIndexChange={setIndex}
/>;
```

Expand Down
7 changes: 5 additions & 2 deletions apps/www/src/content/docs/components/tour/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions apps/www/src/content/docs/dataview/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,15 @@ 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. */
<DataView
data={data}
fields={fields}
defaultSort={{ name: "name", order: "asc" }}
query={{ group_by: ["team"] }}
defaultQuery={{ group_by: ["team"] }}
>
<DataView.Toolbar>
<DataView.Filters />
Expand Down Expand Up @@ -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"] }}
>
<DataView.Toolbar>
<DataView.Filters />
Expand Down Expand Up @@ -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}>
<DataView.Toolbar>
<DataView.Filters />
Expand Down
Loading
Loading