diff --git a/.agents/skills/apsara/references/tokens.md b/.agents/skills/apsara/references/tokens.md
index 4a22655ad..c39cef8fa 100644
--- a/.agents/skills/apsara/references/tokens.md
+++ b/.agents/skills/apsara/references/tokens.md
@@ -113,7 +113,7 @@ Use for padding, margin, and gap. Values are in `px`.
| `--rs-space-8` | 28 | | `--rs-space-17` | 120 |
| `--rs-space-9` | 32 | | | |
-> Layout components (`Flex`, `Grid`, `Box`) accept numeric `gap`/`padding` props that map to this scale (e.g. `gap={4}` → `--rs-space-4` = 12px).
+> Layout components (`Flex`, `Grid`, `Box`) accept numeric `gap`/`padding` props that map to this scale (e.g. `gap={4}` → `--rs-space-4` = 12px). `gap={0}` removes the gap.
---
diff --git a/apps/www/src/components/docs/navbar.tsx b/apps/www/src/components/docs/navbar.tsx
index fe10bd3e3..700c13bea 100644
--- a/apps/www/src/components/docs/navbar.tsx
+++ b/apps/www/src/components/docs/navbar.tsx
@@ -78,7 +78,7 @@ export default function DocsNavbar({
/>
))}
- {title}
+ {title}
)}
diff --git a/apps/www/src/components/docs/search.module.css b/apps/www/src/components/docs/search.module.css
index 4b44bd1e0..1e16b47dc 100644
--- a/apps/www/src/components/docs/search.module.css
+++ b/apps/www/src/components/docs/search.module.css
@@ -1,4 +1,5 @@
.searchContainer {
+ width: 512px;
border-radius: var(--rs-radius-5);
overflow: hidden;
}
diff --git a/apps/www/src/components/docs/search.tsx b/apps/www/src/components/docs/search.tsx
index e66779c03..7c6b733cc 100644
--- a/apps/www/src/components/docs/search.tsx
+++ b/apps/www/src/components/docs/search.tsx
@@ -198,7 +198,7 @@ export default function DocsSearch({ pageTree }: { pageTree: Root }) {
>
-
+ …
+
+
+Profile
+
+// After
+…
+
+
+
+
+Profile
+```
+
+`Skeleton` keeps its default corner radius. For a value outside the radius
+scale, set `style={{ borderRadius }}`.
+
+The active `Breadcrumb.Item` has `data-active="true"` in place of
+`data-current="true"`. Update CSS that targets the old attribute.
+
+### One callback for typed dates
+
+`DatePicker` and `CalendarPreview.Input` report the validity of typed text with
+`onValidityChange`. `FilterChip` takes it on `calendarProps.slotProps.input`,
+which is its `CalendarPreview.Input`. The callback receives
+`{ valid, reason, message }`.
+`message` is `undefined` while the text is valid, so read `message` where you
+used the old error string:
+
+```tsx
+// Before
+
+
+// After
+ setError(message)} />
+```
+
+`DatePicker` reports the reason `unparseable` or `out-of-bounds`, with the
+message `'Invalid date'`. The callback also fires when the reason changes and
+the text stays invalid.
+
## 2.0: `Theme` is rewritten
`Theme` used to put its tokens on `` from an effect, so it could not
diff --git a/apps/www/src/content/docs/components/breadcrumb/demo.ts b/apps/www/src/content/docs/components/breadcrumb/demo.ts
index ad8ce10b7..a76b9792f 100644
--- a/apps/www/src/content/docs/components/breadcrumb/demo.ts
+++ b/apps/www/src/content/docs/components/breadcrumb/demo.ts
@@ -9,7 +9,7 @@ export const getCode = (props: ComponentPropsType) => {
Products
- Shoes
+ Shoes
`;
};
@@ -36,7 +36,7 @@ export const sizeDemo = {
Products
- Shoes
+ Shoes
`
},
{
@@ -47,7 +47,7 @@ export const sizeDemo = {
Products
- Shoes
+ Shoes
`
}
]
@@ -62,14 +62,14 @@ export const separatorDemo = {
|Products|
- Shoes
+ ShoesHome-Products-
- Shoes
+ Shoes
`
};
@@ -82,7 +82,7 @@ export const ellipsisDemo = {
- Shoes
+ Shoes
`
};
@@ -115,7 +115,7 @@ export const dropdownLinksDemo = {
{ children: 'Books', onClick: () => {console.log('Books')}}
]}>Categories
- Current
+ Current
`
};
@@ -127,7 +127,7 @@ export const asDemo = {
}>Playground
- Docs
+ Docs
`
};
@@ -139,7 +139,7 @@ export const disabledDemo = {
Loading…
- Products
+ Products
`
};
diff --git a/apps/www/src/content/docs/components/breadcrumb/index.mdx b/apps/www/src/content/docs/components/breadcrumb/index.mdx
index cc8b5e010..ced74a5f4 100644
--- a/apps/www/src/content/docs/components/breadcrumb/index.mdx
+++ b/apps/www/src/content/docs/components/breadcrumb/index.mdx
@@ -100,19 +100,19 @@ Groups all parts of the breadcrumb navigation.
### Item
-Renders an individual breadcrumb link. Use the `current` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`). Use the `disabled` prop for non-clickable, visually muted items (e.g. loading or no access).
+Renders an individual breadcrumb link. Use the `active` prop on the item that represents the current page so it is styled and exposed to assistive tech (e.g. `aria-current="page"`). Use the `disabled` prop for non-clickable, visually muted items (e.g. loading or no access).
-Item elements expose data attributes for CSS state targeting so you can style current and disabled states without relying on internal class names:
+Item elements expose data attributes for CSS state targeting so you can style active and disabled states without relying on internal class names:
| Attribute | When present |
|-----------|----------------|
-| `data-current="true"` | Item is the current page (`current` prop) |
+| `data-active="true"` | Item is the current page (`active` prop) |
| `data-disabled="true"` | Item is disabled (`disabled` prop) |
Example:
```css
-[data-current="true"] {
+[data-active="true"] {
color: var(--my-brand);
}
[data-disabled="true"] {
diff --git a/apps/www/src/content/docs/components/breadcrumb/props.ts b/apps/www/src/content/docs/components/breadcrumb/props.ts
index eba73e64f..63e94c876 100644
--- a/apps/www/src/content/docs/components/breadcrumb/props.ts
+++ b/apps/www/src/content/docs/components/breadcrumb/props.ts
@@ -17,7 +17,7 @@ export interface BreadcrumbItem {
* Whether the item is the current page
* @defaultValue false
*/
- current?: boolean;
+ active?: boolean;
/**
* When true, the item is non-clickable and visually muted (e.g. loading or no access).
diff --git a/apps/www/src/content/docs/components/calendar/demo.ts b/apps/www/src/content/docs/components/calendar/demo.ts
index 42b611b85..f1b8fbbe6 100644
--- a/apps/www/src/content/docs/components/calendar/demo.ts
+++ b/apps/www/src/content/docs/components/calendar/demo.ts
@@ -197,7 +197,7 @@ function DatePickerFieldExample() {
style={{ maxWidth: 240 }}
>
setError(message)}
slotProps={{ input: { size: "medium" } }}
/>
diff --git a/apps/www/src/content/docs/components/calendar/index.mdx b/apps/www/src/content/docs/components/calendar/index.mdx
index c642b2afe..50f3af764 100644
--- a/apps/www/src/content/docs/components/calendar/index.mdx
+++ b/apps/www/src/content/docs/components/calendar/index.mdx
@@ -68,6 +68,8 @@ Selects a date range across two inputs. The grid uses a state machine that branc
Single-date selection with a typable input. The popover opens on focus; pressing Enter, blurring, or clicking outside commits the value and fires `onSelect`.
+`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.
+
### Controlled
diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts
index e3f047615..de1296681 100644
--- a/apps/www/src/content/docs/components/calendar/props.ts
+++ b/apps/www/src/content/docs/components/calendar/props.ts
@@ -249,15 +249,12 @@ export interface DatePickerProps {
/** Callback function when date is selected */
onSelect?: (date: Date) => void;
- /**
- * Fires when the typed-input validation state changes: with a message when
- * the typed text stops parsing as a valid in-bounds date, and with
- * `undefined` when it becomes valid again (or the picker commits/closes).
- * DatePicker renders no error UI of its own — not even `aria-invalid`.
- * Lift this into `Field`'s `error` prop (or your form library) to display
- * it; `Field` also wires `aria-invalid` onto the input.
- */
- onErrorChange?: (error: string | undefined) => void;
+ /** Called when the typed date becomes invalid or valid again. */
+ onValidityChange?: (validity: {
+ valid: boolean;
+ reason?: 'unparseable' | 'out-of-bounds' | 'unavailable' | 'out-of-order';
+ message?: string;
+ }) => void;
/** @deprecated Use `slotProps.calendar` instead. */
calendarProps?: CalendarProps;
diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx
index 89159adf4..3cff61ad8 100644
--- a/apps/www/src/content/docs/components/command/index.mdx
+++ b/apps/www/src/content/docs/components/command/index.mdx
@@ -148,6 +148,12 @@ Visual divider between groups. The separator is hidden automatically while the u
Renders the portal, viewport, and popup for the command palette. There is no backdrop overlay. Unlike `Dialog.Content`, there is no close button; users dismiss the palette via `Escape` or by clicking outside it.
+The popup is 540px wide. To change the width, pass `className` or `style`, as on `Dialog.Content`:
+
+```tsx
+
+```
+
### Slots
diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts
index d98976187..56f184f65 100644
--- a/apps/www/src/content/docs/components/command/props.ts
+++ b/apps/www/src/content/docs/components/command/props.ts
@@ -155,8 +155,8 @@ export interface CommandDialogContentProps {
/** Additional CSS class names applied to the dialog popup. */
className?: string;
- /** Explicit width for the dialog popup. */
- width?: string | number;
+ /** Inline styles applied to the dialog popup. */
+ style?: React.CSSProperties;
/**
* Corner radius for this palette only. Overrides the theme's `radius`.
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 161f1f715..bbd8d2d6b 100644
--- a/apps/www/src/content/docs/components/filter-chip/props.ts
+++ b/apps/www/src/content/docs/components/filter-chip/props.ts
@@ -67,7 +67,7 @@ export interface FilterChipProps {
yearRange?: { from: number; to: number };
defaultMonth?: Date;
today?: Date;
- /** Props for the date input (`CalendarPreview.Input`) and its popup (`CalendarPreview.Content`). `input.disabled` and `input.readOnly` also apply to the calendar. */
+ /** Props for the date input (`CalendarPreview.Input`) and its popup (`CalendarPreview.Content`). `input.disabled` and `input.readOnly` also apply to the calendar. `input.onValidityChange` reports typed-date errors. */
slotProps?: {
input?: Omit;
popover?: Omit;
@@ -77,8 +77,6 @@ export interface FilterChipProps {
* @default false
*/
showCalendarIcon?: boolean;
- /** Called with a message when the typed date is invalid, and with `undefined` when it is valid again. */
- onErrorChange?: (error: string | undefined) => void;
};
/** Additional CSS class names */
diff --git a/apps/www/src/content/docs/components/flex/demo.ts b/apps/www/src/content/docs/components/flex/demo.ts
index d01d5cc5d..84653cda2 100644
--- a/apps/www/src/content/docs/components/flex/demo.ts
+++ b/apps/www/src/content/docs/components/flex/demo.ts
@@ -16,7 +16,7 @@ export const playground = {
controls: {
gap: {
type: 'select',
- options: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
+ options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
initialValue: 3
},
wrap: {
diff --git a/apps/www/src/content/docs/components/flex/props.ts b/apps/www/src/content/docs/components/flex/props.ts
index 27a2e4853..83ba0a6b2 100644
--- a/apps/www/src/content/docs/components/flex/props.ts
+++ b/apps/www/src/content/docs/components/flex/props.ts
@@ -1,9 +1,10 @@
export interface FlexProps {
/**
* Sets the gutter space between rows and columns.
- * Each step maps directly to `--rs-space-1` through `--rs-space-17`.
+ * `0` removes the gap. Steps 1 to 17 map to `--rs-space-1` through `--rs-space-17`.
*/
gap?:
+ | 0
| 1
| 2
| 3
diff --git a/apps/www/src/content/docs/components/grid/demo.ts b/apps/www/src/content/docs/components/grid/demo.ts
index 6f7eb6644..4c1ad2cfa 100644
--- a/apps/www/src/content/docs/components/grid/demo.ts
+++ b/apps/www/src/content/docs/components/grid/demo.ts
@@ -21,7 +21,7 @@ export const playground = {
controls: {
gap: {
type: 'select',
- options: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
+ options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
initialValue: 3
},
rows: {
diff --git a/apps/www/src/content/docs/components/grid/props.ts b/apps/www/src/content/docs/components/grid/props.ts
index f909e3c5b..69d2f253e 100644
--- a/apps/www/src/content/docs/components/grid/props.ts
+++ b/apps/www/src/content/docs/components/grid/props.ts
@@ -40,9 +40,10 @@ export interface GridProps {
/**
* Sets the gap between grid items.
- * Each step maps directly to `--rs-space-1` through `--rs-space-17`.
+ * `0` removes the gap. Steps 1 to 17 map to `--rs-space-1` through `--rs-space-17`.
*/
gap?:
+ | 0
| 1
| 2
| 3
@@ -65,6 +66,7 @@ export interface GridProps {
* Sets the gap between grid columns. Uses the same scale as `gap`.
*/
columnGap?:
+ | 0
| 1
| 2
| 3
@@ -87,6 +89,7 @@ export interface GridProps {
* Sets the gap between grid rows. Uses the same scale as `gap`.
*/
rowGap?:
+ | 0
| 1
| 2
| 3
diff --git a/apps/www/src/content/docs/components/search/index.mdx b/apps/www/src/content/docs/components/search/index.mdx
index febe8ef1e..7c68f5009 100644
--- a/apps/www/src/content/docs/components/search/index.mdx
+++ b/apps/www/src/content/docs/components/search/index.mdx
@@ -28,6 +28,16 @@ Two sizes. `large` is the default; `small` fits a toolbar or a table header.
+### Width
+
+Search fills the width of its parent. To set a width, put it in a sized element:
+
+```tsx
+
+
+
+```
+
### Leading icon
Pass a node to `leadingIcon` to replace the search icon, or `null` to hide it.
diff --git a/apps/www/src/content/docs/components/skeleton/demo.ts b/apps/www/src/content/docs/components/skeleton/demo.ts
index 573c3c594..685285920 100644
--- a/apps/www/src/content/docs/components/skeleton/demo.ts
+++ b/apps/www/src/content/docs/components/skeleton/demo.ts
@@ -95,7 +95,7 @@ export const providerDemo = {
duration={2}
>
-
+
diff --git a/apps/www/src/content/docs/components/skeleton/index.mdx b/apps/www/src/content/docs/components/skeleton/index.mdx
index ee712d151..9920f85ae 100644
--- a/apps/www/src/content/docs/components/skeleton/index.mdx
+++ b/apps/www/src/content/docs/components/skeleton/index.mdx
@@ -80,7 +80,7 @@ Create a card-like loading state by combining multiple skeletons.
{/* Inherits provider props, overrides width */}
{/* Overrides specific props */}
```
diff --git a/apps/www/src/content/docs/components/skeleton/props.ts b/apps/www/src/content/docs/components/skeleton/props.ts
index 412955b17..23c7bbf7b 100644
--- a/apps/www/src/content/docs/components/skeleton/props.ts
+++ b/apps/www/src/content/docs/components/skeleton/props.ts
@@ -24,10 +24,10 @@ export interface SkeletonProps {
highlightColor?: string;
/**
- * Border radius of the skeleton
- * @defaultValue "var(--rs-radius-2)"
+ * Corner radius for this skeleton only. Overrides the theme's `radius`.
+ * @defaultValue The theme's `radius`
*/
- borderRadius?: string | number;
+ radius?: 'none' | 'small' | 'medium' | 'large' | 'full';
/**
* Whether to display as inline-block
diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx
index e5386a71c..7907adde1 100644
--- a/apps/www/src/content/docs/components/toast/index.mdx
+++ b/apps/www/src/content/docs/components/toast/index.mdx
@@ -94,7 +94,7 @@ The provider that renders toasts, and the manager APIs that add them, from a mod
### Toast.Provider
-Renders the toast viewport and manages the toast lifecycle. Place once at the root of your app.
+Renders the toast viewport and manages the toast lifecycle. Place once at the root of your app. `position` and `radius` apply to every toast it renders.
diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts
index ebf6970a0..d63a97a5d 100644
--- a/apps/www/src/content/docs/components/toast/props.ts
+++ b/apps/www/src/content/docs/components/toast/props.ts
@@ -11,6 +11,12 @@ export interface ToastProviderProps {
| 'top-center'
| 'bottom-center';
+ /**
+ * Corner radius for these toasts only. Overrides the theme's `radius`.
+ * @defaultValue The theme's `radius`
+ */
+ radius?: 'none' | 'small' | 'medium' | 'large' | 'full';
+
/**
* Maximum number of visible toasts.
* @default 3
diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx
index b53f21d28..f937bbdda 100644
--- a/apps/www/src/content/docs/theme/overview/index.mdx
+++ b/apps/www/src/content/docs/theme/overview/index.mdx
@@ -234,7 +234,7 @@ Components accept a `radius` prop with the theme's five values. It affects only
-Available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, and on the portalled parts `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. `Menu.SubmenuContent` and `ContextMenu.SubContent` take it too. It goes on the portalled part, not the root: ``.
+Available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, `Skeleton`, and on the portalled parts `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. `Menu.SubmenuContent` and `ContextMenu.SubContent` take it too. It goes on the portalled part, not the root: ``. `Toast.Provider` takes it for every toast it renders.
## Tokens
diff --git a/packages/react/components/breadcrumb/__tests__/breadcrumb.test.tsx b/packages/react/components/breadcrumb/__tests__/breadcrumb.test.tsx
index 07c316b0a..372b255f8 100644
--- a/packages/react/components/breadcrumb/__tests__/breadcrumb.test.tsx
+++ b/packages/react/components/breadcrumb/__tests__/breadcrumb.test.tsx
@@ -178,10 +178,10 @@ describe('Breadcrumb', () => {
expect(link?.textContent).toMatch(/L\s*Label\s*T/);
});
- it('applies current/active state and renders as span with aria-current', () => {
+ it('applies active state and renders as span with aria-current', () => {
const { container } = render(
- Current Page
+ Current Page
);
@@ -193,7 +193,7 @@ describe('Breadcrumb', () => {
expect(currentEl).toHaveAttribute('aria-current', 'page');
expect(currentEl).toHaveClass(styles['breadcrumb-link']);
expect(currentEl).toHaveClass(styles['breadcrumb-link-active']);
- expect(currentEl).toHaveAttribute('data-current', 'true');
+ expect(currentEl).toHaveAttribute('data-active', 'true');
expect(currentEl).toHaveTextContent('Current Page');
expect(container.querySelector('a')).not.toBeInTheDocument();
});
@@ -549,7 +549,7 @@ describe('Breadcrumb', () => {
Electronics
/
- Laptop
+ Laptop
);
@@ -596,7 +596,7 @@ describe('Breadcrumb', () => {
Categories
- Product
+ Product
);
diff --git a/packages/react/components/breadcrumb/__tests__/data-slots.test.tsx b/packages/react/components/breadcrumb/__tests__/data-slots.test.tsx
index 7ad4ca26c..166be94ed 100644
--- a/packages/react/components/breadcrumb/__tests__/data-slots.test.tsx
+++ b/packages/react/components/breadcrumb/__tests__/data-slots.test.tsx
@@ -16,7 +16,7 @@ describe('Breadcrumb data-slot contract', () => {
- Current
+ Current
);
expectSlots(container, [
@@ -42,7 +42,7 @@ describe('Breadcrumb data-slot contract', () => {
expect(getSlot(container, 'breadcrumb-trailing-icon')).toBeNull();
});
- it('exposes a link slot on disabled/current items rendered as spans', () => {
+ it('exposes a link slot on disabled/active items rendered as spans', () => {
const { container } = render(
Disabled
diff --git a/packages/react/components/breadcrumb/breadcrumb-item.tsx b/packages/react/components/breadcrumb/breadcrumb-item.tsx
index 7a841967e..efc6363fd 100644
--- a/packages/react/components/breadcrumb/breadcrumb-item.tsx
+++ b/packages/react/components/breadcrumb/breadcrumb-item.tsx
@@ -19,7 +19,7 @@ export type BreadcrumbDropdownItem = ComponentProps & {
export interface BreadcrumbItemProps extends useRender.ComponentProps<'a'> {
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
- current?: boolean;
+ active?: boolean;
/** When true, the item is non-clickable and visually muted (e.g. loading or no access). */
disabled?: boolean;
dropdownItems?: BreadcrumbDropdownItem[];
@@ -32,7 +32,7 @@ export const BreadcrumbItem = ({
className,
leadingIcon,
trailingIcon,
- current,
+ active,
disabled,
href,
dropdownItems,
@@ -136,7 +136,7 @@ export const BreadcrumbItem = ({
);
}
- if (disabled || current) {
+ if (disabled || active) {
return (
{label}
diff --git a/packages/react/components/calendar/__tests__/date-picker.test.tsx b/packages/react/components/calendar/__tests__/date-picker.test.tsx
index 1a3f37329..4e3c74912 100644
--- a/packages/react/components/calendar/__tests__/date-picker.test.tsx
+++ b/packages/react/components/calendar/__tests__/date-picker.test.tsx
@@ -300,9 +300,12 @@ describe('DatePicker', () => {
* are now allowed; bounds come from `startMonth` / `endMonth`.
*/
it('accepts a future date when no calendarProps bounds are set', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
-
+
);
const input = screen.getByPlaceholderText(
@@ -310,16 +313,16 @@ describe('DatePicker', () => {
) as HTMLInputElement;
// Pick a date far in the future to avoid clock skew in tests.
fireEvent.change(input, { target: { value: '15/06/2099' } });
- expect(onErrorChange).not.toHaveBeenCalled();
+ expect(onValidityChange).not.toHaveBeenCalled();
});
it('accepts a future date within endMonth', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
);
@@ -327,16 +330,16 @@ describe('DatePicker', () => {
'Select date'
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '15/06/2099' } });
- expect(onErrorChange).not.toHaveBeenCalled();
+ expect(onValidityChange).not.toHaveBeenCalled();
});
it('rejects a date past endMonth', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
);
@@ -344,16 +347,20 @@ describe('DatePicker', () => {
'Select date'
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '15/06/2099' } });
- expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date');
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'out-of-bounds',
+ message: 'Invalid date'
+ });
});
it('rejects a date before startMonth', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
);
@@ -361,7 +368,11 @@ describe('DatePicker', () => {
'Select date'
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '15/06/2020' } });
- expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date');
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'out-of-bounds',
+ message: 'Invalid date'
+ });
});
});
@@ -414,9 +425,12 @@ describe('DatePicker', () => {
});
it('accepts a fully-typed valid date matching dateFormat', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
-
+
);
const input = screen.getByPlaceholderText(
@@ -425,7 +439,7 @@ describe('DatePicker', () => {
fireEvent.change(input, { target: { value: '15/06/2025' } });
// The change handler accepted it, so no error transition fired.
- expect(onErrorChange).not.toHaveBeenCalled();
+ expect(onValidityChange).not.toHaveBeenCalled();
});
/*
@@ -468,12 +482,12 @@ 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();
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
);
@@ -489,34 +503,44 @@ describe('DatePicker', () => {
// commit only happens via Enter / blur / outside-click (see the
// dedicated single-fire test below for that path).
fireEvent.change(input, { target: { value: '15/06/2025' } });
- expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
+ expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true });
expect(onSelect).not.toHaveBeenCalled();
});
});
- describe('onErrorChange', () => {
+ describe('onValidityChange', () => {
/*
* DatePicker renders no error message itself, and consumers lift validity
* into `Field`'s `error` prop (or a form library) via this callback.
* It fires on transitions only, like `onOpenChange`.
*/
it('fires with a message when typed text stops parsing as a date', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
-
+
);
const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: 'not a date' } });
- expect(onErrorChange).toHaveBeenCalledTimes(1);
- expect(onErrorChange).toHaveBeenCalledWith('Invalid date');
+ expect(onValidityChange).toHaveBeenCalledTimes(1);
+ expect(onValidityChange).toHaveBeenCalledWith({
+ valid: false,
+ reason: 'unparseable',
+ message: 'Invalid date'
+ });
});
it('does not re-fire on consecutive invalid keystrokes', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
-
+
);
const input = screen.getByPlaceholderText('Select date');
@@ -524,39 +548,48 @@ describe('DatePicker', () => {
fireEvent.change(input, { target: { value: '15/0' } });
fireEvent.change(input, { target: { value: '15/06' } });
- expect(onErrorChange).toHaveBeenCalledTimes(1);
+ expect(onValidityChange).toHaveBeenCalledTimes(1);
});
- it('fires with undefined when the typed text becomes valid again', () => {
- const onErrorChange = vi.fn();
+ it('fires with valid when the typed text becomes valid again', () => {
+ const onValidityChange = vi.fn();
render(
-
+
);
const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: '15/06' } });
fireEvent.change(input, { target: { value: '15/06/2025' } });
- expect(onErrorChange).toHaveBeenCalledTimes(2);
- expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
+ expect(onValidityChange).toHaveBeenCalledTimes(2);
+ expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true });
});
it('never fires while typing stays valid', () => {
- const onErrorChange = vi.fn();
+ const onValidityChange = vi.fn();
render(
-
+
);
const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: '15/06/2025' } });
- expect(onErrorChange).not.toHaveBeenCalled();
+ expect(onValidityChange).not.toHaveBeenCalled();
});
- it('fires with undefined when the picker commits via Enter with invalid text', () => {
- const onErrorChange = vi.fn();
+ it('fires with valid when the picker commits via Enter with invalid text', () => {
+ const onValidityChange = vi.fn();
render(
-
+
);
const input = screen.getByPlaceholderText('Select date');
@@ -564,7 +597,7 @@ describe('DatePicker', () => {
fireEvent.change(input, { target: { value: 'garbage' } });
fireEvent.keyUp(input, { code: 'Enter' });
- expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
+ expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true });
});
it('renders no error presentation of its own, with no message and no aria-invalid', () => {
@@ -573,7 +606,7 @@ describe('DatePicker', () => {
const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: 'not a date' } });
- // Presentation is fully consumer-owned: `onErrorChange` lifted into
+ // Presentation is fully consumer-owned: `onValidityChange` lifted into
// `Field`'s `error` prop renders the message and wires aria-invalid.
expect(input.getAttribute('aria-invalid')).toBeNull();
expect(screen.queryByRole('alert')).toBeNull();
diff --git a/packages/react/components/calendar/date-picker.tsx b/packages/react/components/calendar/date-picker.tsx
index aeb153c86..d4b73219b 100644
--- a/packages/react/components/calendar/date-picker.tsx
+++ b/packages/react/components/calendar/date-picker.tsx
@@ -8,6 +8,7 @@ import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import { useEffect, useRef, useState } from 'react';
import { PropsBase } from 'react-day-picker';
import { CalendarIcon } from '~/icons';
+import type { CalendarPreviewInputValidity } from '../calendar-preview/calendar-preview-input';
import { Input } from '../input';
import { InputProps } from '../input/input';
import { Popover } from '../popover';
@@ -27,6 +28,8 @@ dayjs.extend(isSameOrBefore);
*/
type DatePickerCalendarSlot = Omit & CalendarPropsExtended;
+const VALID: CalendarPreviewInputValidity = { valid: true };
+
export interface DatePickerSlotProps {
input?: InputProps;
calendar?: DatePickerCalendarSlot;
@@ -47,15 +50,8 @@ export interface DatePickerProps {
/** @deprecated Use `slotProps.popover` instead. */
popoverProps?: PopoverContentProps;
onSelect?: (date: Date) => void;
- /**
- * Fires when the typed-input validation state changes: with a message when
- * the typed text stops parsing as a valid in-bounds date, and with
- * `undefined` when it becomes valid again (or the picker commits/closes).
- * DatePicker renders no error UI of its own, not even `aria-invalid`.
- * Lift this into `Field`'s `error` prop (or your form library) to display
- * it; `Field` also wires `aria-invalid` onto the input.
- */
- onErrorChange?: (error: string | undefined) => void;
+ /** Called when the typed date becomes invalid or valid again. */
+ onValidityChange?: (validity: CalendarPreviewInputValidity) => void;
value?: Date;
defaultValue?: Date;
children?:
@@ -74,7 +70,7 @@ export function DatePicker({
value: valueProp,
defaultValue,
onSelect = () => undefined,
- onErrorChange,
+ onValidityChange,
children,
showCalendarIcon = true,
timeZone
@@ -98,11 +94,14 @@ export function DatePicker({
valueProp ?? defaultValue
);
- const errorRef = useRef(undefined);
+ const validityRef = useRef(VALID);
- function updateError(next: string | undefined) {
- if (next !== errorRef.current) onErrorChange?.(next);
- errorRef.current = next;
+ function updateValidity(next: CalendarPreviewInputValidity) {
+ const prev = validityRef.current;
+ validityRef.current = next;
+ if (next.valid !== prev.valid || next.reason !== prev.reason) {
+ onValidityChange?.(next);
+ }
}
// Sync only when controlled, since uncontrolled mode keeps its own state.
@@ -157,9 +156,9 @@ export function DatePicker({
function closePicker() {
popover.disengage();
const committedDate = selectedDateRef.current;
- const hadError = errorRef.current !== undefined;
+ const hadError = !validityRef.current.valid;
setInputValue(committedDate ? dayjs(committedDate).format(dateFormat) : '');
- updateError(undefined);
+ updateValidity(VALID);
/*
* Emit the committed Date directly. Going through
* `dayjs(formattedString).toDate()` re-parses the formatted string without
@@ -178,7 +177,7 @@ export function DatePicker({
// clicks the currently-selected day (deselect). Only forward defined
// dates to consumer `onSelect`, which keeps the prop type narrow.
if (day) onSelect(day);
- updateError(undefined);
+ updateValidity(VALID);
popover.disengage();
}
@@ -218,9 +217,13 @@ export function DatePicker({
if (isValid) {
setSelectedDate(date.toDate());
- updateError(undefined);
+ updateValidity(VALID);
} else {
- updateError('Invalid date');
+ updateValidity({
+ valid: false,
+ reason: isValidDate ? 'out-of-bounds' : 'unparseable',
+ message: 'Invalid date'
+ });
}
}
diff --git a/packages/react/components/command/command-dialog.tsx b/packages/react/components/command/command-dialog.tsx
index b252d7444..e03521fa5 100644
--- a/packages/react/components/command/command-dialog.tsx
+++ b/packages/react/components/command/command-dialog.tsx
@@ -25,7 +25,6 @@ export const CommandDialogTrigger = forwardRef<
CommandDialogTrigger.displayName = 'Command.DialogTrigger';
export interface CommandDialogContentProps extends DialogPrimitive.Popup.Props {
- width?: string | number;
/** Corner radius for this palette only. Overrides the theme's `radius`. */
radius?: Radius;
}
@@ -33,8 +32,6 @@ export interface CommandDialogContentProps extends DialogPrimitive.Popup.Props {
export function CommandDialogContent({
className,
children,
- width,
- style,
radius,
...props
}: CommandDialogContentProps) {
@@ -64,7 +61,6 @@ export function CommandDialogContent({
radiusStyle({ radius }),
className
)}
- style={{ width, ...style }}
initialFocus={openType => {
/* Runs before focus moves into the popup, so activeElement is
still the element focused before opening. */
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 ef8061735..c82920a60 100644
--- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx
+++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx
@@ -565,21 +565,25 @@ describe('FilterChip', () => {
expect(icons(true)).toBe(1);
});
- it('reports a typed error through onErrorChange', () => {
- const onErrorChange = vi.fn();
+ it('reports a typed error through onValidityChange', () => {
+ const onValidityChange = vi.fn();
render(
);
const input = screen.getByPlaceholderText('Select date');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'not a date' } });
- expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date');
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'unparseable',
+ message: 'Invalid date'
+ });
fireEvent.change(input, { target: { value: '27 May 2026' } });
- expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
+ expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true });
});
it('keeps a typed error when a string value rerenders the chip', () => {
@@ -592,7 +596,13 @@ describe('FilterChip', () => {
label='Created'
columnType={FilterType.date}
value='2026-05-27'
- calendarProps={{ onErrorChange: setError }}
+ calendarProps={{
+ slotProps: {
+ input: {
+ onValidityChange: validity => setError(validity.message)
+ }
+ }
+ }}
/>
>
);
diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx
index ee5b34a71..9e93e1c48 100644
--- a/packages/react/components/filter-chip/filter-chip.tsx
+++ b/packages/react/components/filter-chip/filter-chip.tsx
@@ -49,7 +49,7 @@ const chip = cva(styles.chip, {
export type FilterChipValue = FilterValueType | FilterValueType[] | Date;
-/* The message `DatePicker` reports, so `onErrorChange` reads the same. */
+/* The message `DatePicker` reports, so `onValidityChange` reads the same. */
const INVALID_DATE = 'Invalid date';
/* A year the calendar cannot show leaves the field empty. */
@@ -89,8 +89,6 @@ export type FilterChipCalendarProps = Pick<
* @default false
*/
showCalendarIcon?: boolean;
- /** Called with a message when the typed date is invalid, and with `undefined` when it is valid again. */
- onErrorChange?: (error: string | undefined) => void;
};
export interface FilterChipProps
@@ -163,7 +161,6 @@ export const FilterChip = ({
formatValue: formatDate,
slotProps: dateSlotProps,
showCalendarIcon = false,
- onErrorChange,
...calendarRest
} = calendarProps ?? {};
const { classNames: inputClassNames, ...inputProps } =
@@ -307,12 +304,6 @@ export const FilterChip = ({
unavailable: INVALID_DATE,
...inputProps.errorMessages
}}
- onValidityChange={validity => {
- inputProps.onValidityChange?.(validity);
- onErrorChange?.(
- validity.valid ? undefined : validity.message
- );
- }}
/>
diff --git a/packages/react/components/flex/__tests__/flex.test.tsx b/packages/react/components/flex/__tests__/flex.test.tsx
index 9a20c2409..470869d66 100644
--- a/packages/react/components/flex/__tests__/flex.test.tsx
+++ b/packages/react/components/flex/__tests__/flex.test.tsx
@@ -125,7 +125,7 @@ describe('Flex', () => {
describe('Gap', () => {
const numericGaps = [
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17
] as const;
it.each(numericGaps)('renders gap %s correctly', gap => {
diff --git a/packages/react/components/grid/__tests__/grid.test.tsx b/packages/react/components/grid/__tests__/grid.test.tsx
index e4f264086..df7a86222 100644
--- a/packages/react/components/grid/__tests__/grid.test.tsx
+++ b/packages/react/components/grid/__tests__/grid.test.tsx
@@ -91,7 +91,7 @@ describe('Grid', () => {
describe('Gap Properties', () => {
const gaps = [
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17
] as const;
it.each(gaps)('renders %s gap correctly', gap => {
diff --git a/packages/react/components/search/__tests__/search.test.tsx b/packages/react/components/search/__tests__/search.test.tsx
index beb941d91..58ea71fa5 100644
--- a/packages/react/components/search/__tests__/search.test.tsx
+++ b/packages/react/components/search/__tests__/search.test.tsx
@@ -2,7 +2,6 @@ import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Search } from '../search';
-import styles from '../search.module.css';
describe('Search', () => {
describe('Basic Rendering', () => {
@@ -194,20 +193,6 @@ describe('Search', () => {
});
});
- describe('Width', () => {
- it('defaults to 100% width', () => {
- const { container } = render();
- const searchContainer = container.querySelector(`.${styles.container}`);
- expect(searchContainer).toHaveStyle({ width: '100%' });
- });
-
- it('sets custom width', () => {
- const { container } = render();
- const searchContainer = container.querySelector(`.${styles.container}`);
- expect(searchContainer).toHaveStyle({ width: '300px' });
- });
- });
-
describe('Disabled State', () => {
it('disables input when disabled', () => {
render();
diff --git a/packages/react/components/search/search.tsx b/packages/react/components/search/search.tsx
index fc0390275..51aa44683 100644
--- a/packages/react/components/search/search.tsx
+++ b/packages/react/components/search/search.tsx
@@ -9,7 +9,7 @@ import { InputProps } from '../input/input';
import styles from './search.module.css';
-export interface SearchProps extends InputProps {
+export interface SearchProps extends Omit {
showClearButton?: boolean;
onClear?: (
event: MouseEvent | KeyboardEvent
@@ -46,7 +46,6 @@ export function Search({
onClear,
onKeyDown,
value,
- width = '100%',
variant = 'default',
ref,
leadingIcon = ,
@@ -103,12 +102,7 @@ export function Search({
) : undefined;
return (
-