From d025abae170fb266bd7941bea4c1c64b51cd8caf Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 20 Oct 2026 03:05:47 +0530 Subject: [PATCH 1/4] refactor: use leadingIcon and slotProps for icons and inner parts --- apps/www/src/components/dataview-demo.tsx | 2 +- apps/www/src/components/docs/sidebar.tsx | 18 +-- .../src/content/docs/(overview)/upgrading.mdx | 58 ++++++++ .../docs/ai-elements/prompt-input/demo.ts | 22 +-- .../docs/ai-elements/prompt-input/props.ts | 6 +- .../components/announcement-bar/index.mdx | 2 +- .../src/content/docs/components/badge/demo.ts | 8 +- .../content/docs/components/badge/index.mdx | 2 +- .../content/docs/components/badge/props.ts | 2 +- .../content/docs/components/callout/demo.ts | 2 +- .../content/docs/components/callout/index.mdx | 2 +- .../content/docs/components/callout/props.ts | 2 +- .../content/docs/components/combobox/demo.ts | 2 +- .../docs/components/combobox/index.mdx | 5 +- .../content/docs/components/combobox/props.ts | 3 + .../content/docs/components/editor/demo.ts | 2 +- .../content/docs/components/editor/props.ts | 8 +- .../docs/components/empty-state/props.ts | 9 ++ .../docs/components/indicator/props.ts | 5 + .../content/docs/components/input/props.ts | 5 + .../content/docs/components/select/demo.ts | 2 +- .../content/docs/components/select/index.mdx | 7 +- .../content/docs/components/select/props.ts | 6 + .../content/docs/components/sidebar/props.ts | 35 +++-- .../content/docs/components/sidepanel/demo.ts | 2 +- .../docs/components/sidepanel/props.ts | 2 +- .../content/docs/components/table/props.ts | 5 + .../content/docs/components/tabs/index.mdx | 2 +- apps/www/src/content/docs/dataview/list.mdx | 4 +- apps/www/src/content/docs/dataview/props.ts | 40 +++++- .../src/content/docs/dataview/timeline.mdx | 2 +- .../__tests__/data-slots.test.tsx | 4 +- .../announcement-bar/announcement-bar.tsx | 2 +- .../components/badge/__tests__/badge.test.tsx | 4 +- .../badge/__tests__/data-slots.test.tsx | 10 +- packages/react/components/badge/badge.tsx | 10 +- .../callout/__tests__/callout.test.tsx | 4 +- .../callout/__tests__/data-slots.test.tsx | 8 +- packages/react/components/callout/callout.tsx | 10 +- .../combobox/__tests__/data-slots.test.tsx | 15 +- .../components/combobox/combobox-item.tsx | 15 +- .../components/combobox/combobox.module.css | 4 + .../components/command/command-input.tsx | 4 +- .../data-view/__tests__/data-slots.test.tsx | 47 ++++++ .../data-view/__tests__/timeline.test.tsx | 14 ++ .../data-view/components/filters.tsx | 66 +++++---- .../components/data-view/components/list.tsx | 129 +++++++++++------ .../data-view/components/timeline.tsx | 136 +++++++++++++----- .../components/data-view/data-view.types.tsx | 75 ++++++---- packages/react/components/data-view/index.ts | 3 + .../editor/core/mention-node-view.ts | 2 +- .../editor/core/mention-registry.ts | 6 +- .../editor/core/suggestion-menu.tsx | 8 +- .../editor/core/use-suggestion-menu.ts | 2 +- .../components/editor/editor-content.tsx | 8 +- .../components/editor/editor-mentions.tsx | 2 +- .../components/editor/editor-slash-items.tsx | 20 +-- .../components/editor/editor-slash-menu.tsx | 6 +- .../react/components/editor/editor-types.ts | 2 +- .../__tests__/empty-state.test.tsx | 14 ++ .../components/empty-state/empty-state.tsx | 77 +++++++--- .../components/filter-chip/filter-chip.tsx | 22 +-- .../indicator/__tests__/indicator.test.tsx | 14 ++ .../react/components/indicator/indicator.tsx | 16 ++- .../components/input/__tests__/input.test.tsx | 21 +++ packages/react/components/input/input.tsx | 32 +++-- .../__tests__/prompt-input-mentions.test.tsx | 2 +- .../prompt-input/prompt-input-editor.tsx | 8 +- .../prompt-input/prompt-input-mentions.tsx | 2 +- .../select/__tests__/data-slots.test.tsx | 11 +- .../react/components/select/select-item.tsx | 12 +- .../react/components/select/select-value.tsx | 5 +- .../react/components/select/select.module.css | 4 + .../side-panel/__tests__/data-slots.test.tsx | 2 +- .../side-panel/__tests__/side-panel.test.tsx | 6 +- .../components/side-panel/side-panel.tsx | 6 +- .../sidebar/__tests__/sidebar.test.tsx | 58 ++++++++ .../components/sidebar/sidebar-group.tsx | 87 ++++++++--- .../react/components/sidebar/sidebar-item.tsx | 38 +++-- .../sidebar/sidebar-leading-visual.tsx | 23 ++- .../react/components/sidebar/sidebar-more.tsx | 58 +++++--- .../sidebar/sidebar-trailing-visual.tsx | 13 +- .../components/table/__tests__/table.test.tsx | 24 ++++ packages/react/components/table/table.tsx | 27 ++-- .../tabs/__tests__/data-slots.test.tsx | 4 +- packages/react/components/tabs/tabs.tsx | 2 +- packages/react/figma/badge.figma.ts | 6 +- .../react/figma/side-panel-header.figma.ts | 2 +- 88 files changed, 1106 insertions(+), 378 deletions(-) diff --git a/apps/www/src/components/dataview-demo.tsx b/apps/www/src/components/dataview-demo.tsx index 473258d9b..3d8948473 100644 --- a/apps/www/src/components/dataview-demo.tsx +++ b/apps/www/src/components/dataview-demo.tsx @@ -670,7 +670,7 @@ export function DataViewSelectionDemo() { diff --git a/apps/www/src/components/docs/sidebar.tsx b/apps/www/src/components/docs/sidebar.tsx index 01eea327f..3516ae0a6 100644 --- a/apps/www/src/components/docs/sidebar.tsx +++ b/apps/www/src/components/docs/sidebar.tsx @@ -33,9 +33,9 @@ function renderNode(node: Node, pathname: string): ReactNode { label={node.name as string} key={node?.$id} leadingIcon={node?.icon} - classNames={{ - items: styles.items, - label: styles.label + slotProps={{ + items: { className: styles.items }, + label: { className: styles.label } }} > @@ -60,9 +60,9 @@ function renderNode(node: Node, pathname: string): ReactNode { label={node.name as string} key={node?.$id} leadingIcon={node?.icon} - classNames={{ - items: styles.items, - label: styles.label + slotProps={{ + items: { className: styles.items }, + label: { className: styles.label } }} > {node.children.map(child => renderNode(child, pathname))} @@ -90,9 +90,9 @@ function SidebarItem({ item, pathname }: { item: Item; pathname: string }) { leadingIcon={item?.icon} render={} active={isActiveUrl(item.url, pathname, false)} - classNames={{ - root: styles.item, - text: styles.itemText + slotProps={{ + root: { className: styles.item }, + text: { className: styles.itemText } }} > diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 82f756aad..2080af1eb 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,64 @@ 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: icon props and `slotProps` + +Icon props are `leadingIcon` and `trailingIcon` on every component, and each +leading icon has a `-leading-icon` slot. Inner parts take props through +`slotProps.`. `classNames` still works, but it is deprecated and goes +away in the next major. + +### 1. Rename the icon props + +| Component | Before | After | +| --- | --- | --- | +| `Badge` | `icon` | `leadingIcon` | +| `Callout` | `icon` | `leadingIcon` | +| `SidePanel.Header` | `icon` | `leadingIcon` | +| `DataView` fields (`DataViewField`) | `icon` | `leadingIcon` | +| `Editor.SlashMenu` items (`EditorSlashItem`) | `icon` | `leadingIcon` | +| `Editor.Mentions` and `PromptInput.Mentions` items | `icon`, `trailing` | `leadingIcon`, `trailingIcon` | + +```tsx +// Before +}>New +Saved +const items = [{ id: 'ada', label: 'Ada', icon: , trailing: Agent }]; + +// After +}>New +Saved +const items = [{ id: 'ada', label: 'Ada', leadingIcon: , trailingIcon: Agent }]; +``` + +### 2. Update `data-slot` selectors + +| Before | After | +| --- | --- | +| `announcement-bar-icon` | `announcement-bar-leading-icon` | +| `badge-icon` | `badge-leading-icon` | +| `callout-icon` | `callout-leading-icon` | +| `combobox-item-icon` | `combobox-item-leading-icon` | +| `select-item-icon` | `select-item-leading-icon` | +| `select-value-icon` | `select-value-leading-icon` | +| `tabs-tab-icon` | `tabs-tab-leading-icon` | + +### 3. Move `classNames` to `slotProps` + +Each component that has `classNames` also has `slotProps`, with the same part +names: `Indicator`, `Input`, `EmptyState`, `Table.SectionHeader`, +`Sidebar.Item`, `Sidebar.Group`, `Sidebar.More`, `DataView.List` and its +columns, `DataView.Timeline` and `DataView.Filters`. A `slotProps` entry takes +the props of that part. When you pass both, the part gets both class names. + +```tsx +// Before + + +// After + +``` + ## Unreleased: date filters compare whole days `DataView`, `DataTable` and `FilterChip` read and compare dates as calendar diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts index fbdbbc0d1..0f74d823b 100644 --- a/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts +++ b/apps/www/src/content/docs/ai-elements/prompt-input/demo.ts @@ -138,7 +138,7 @@ export const editorCapabilitiesDemo = { id: 'page', label: 'This page', type: 'page', - icon: , + leadingIcon: , data: { path: '/docs/ai-elements/prompt-input' } }, { @@ -146,7 +146,7 @@ export const editorCapabilitiesDemo = { label: 'Button', type: 'component', group: 'Components', - icon: , + leadingIcon: , data: { status: 'stable' } }, { @@ -154,7 +154,7 @@ export const editorCapabilitiesDemo = { label: 'DataTable', type: 'component', group: 'Components', - icon: , + leadingIcon: , data: { status: 'stable' } }, { @@ -162,7 +162,7 @@ export const editorCapabilitiesDemo = { label: 'PromptInput', type: 'component', group: 'Components', - icon: , + leadingIcon: , data: { status: 'beta' } }, { @@ -170,22 +170,22 @@ export const editorCapabilitiesDemo = { label: 'Maya Chen', type: 'user', group: 'Users', - icon: + leadingIcon: }, { id: 'u2', label: 'Apsara Assistant', type: 'user', group: 'Users', - icon: , - trailing: Agent + leadingIcon: , + trailingIcon: Agent }, { id: 'u3', label: 'Dana Whitfield', type: 'user', group: 'Users', - icon: , + leadingIcon: , disabled: true } ]; @@ -240,7 +240,7 @@ export const editorCapabilitiesDemo = { id: 'data-table', label: 'DataTable', type: 'component', - icon: , + leadingIcon: , data: { status: 'stable' } }) } @@ -309,7 +309,7 @@ export const mentionsDemo = { label: 'Apsara Assistant', type: 'user', group: 'Users', - trailing: Agent + trailingIcon: Agent }, { id: 'u3', label: 'Dana Whitfield', type: 'user', group: 'Users' } ]; @@ -427,7 +427,7 @@ export const insertMentionDemo = { id: 'prompt-input', label: 'PromptInput', type: 'component', - icon: + leadingIcon: }) } > diff --git a/apps/www/src/content/docs/ai-elements/prompt-input/props.ts b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts index c88e7a582..97cfda3c0 100644 --- a/apps/www/src/content/docs/ai-elements/prompt-input/props.ts +++ b/apps/www/src/content/docs/ai-elements/prompt-input/props.ts @@ -159,10 +159,10 @@ export interface PromptInputMentionItem { */ type?: string; - icon?: React.ReactNode; + leadingIcon?: React.ReactNode; /** Shown at the end of the row — a badge, a shortcut, a timestamp. */ - trailing?: React.ReactNode; + trailingIcon?: React.ReactNode; /** Section heading. Sections appear in the order they first show up. */ group?: string; @@ -190,7 +190,7 @@ export interface PromptInputMentionsProps { ) => Promise; /** - * Looks up the icon, trailing content and `data` for chips restored from + * Looks up the leading icon, trailing icon and `data` for chips restored from * `value` / `defaultValue`, which can't carry them, and refreshes their * labels. Calls are batched and cached. */ diff --git a/apps/www/src/content/docs/components/announcement-bar/index.mdx b/apps/www/src/content/docs/components/announcement-bar/index.mdx index 54e11122b..61fb2afd8 100644 --- a/apps/www/src/content/docs/components/announcement-bar/index.mdx +++ b/apps/www/src/content/docs/components/announcement-bar/index.mdx @@ -56,7 +56,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | Slot | Element | |------|---------| | `announcement-bar` | The root `
` element | -| `announcement-bar-icon` | Wrapper around the leading icon (when `leadingIcon` is set) | +| `announcement-bar-leading-icon` | Wrapper around the leading icon (when `leadingIcon` is set) | | `announcement-bar-text` | The main text | | `announcement-bar-action` | The action `]} /> + } description="This is a description." actions={[]} /> User Information diff --git a/apps/www/src/content/docs/components/sidepanel/props.ts b/apps/www/src/content/docs/components/sidepanel/props.ts index 8c01c1e5f..10150b566 100644 --- a/apps/www/src/content/docs/components/sidepanel/props.ts +++ b/apps/www/src/content/docs/components/sidepanel/props.ts @@ -30,7 +30,7 @@ export interface SidePanelHeaderProps { /** * Optional icon element to display before the title. */ - icon?: React.ReactNode; + leadingIcon?: React.ReactNode; /** * Array of action elements to display in the header. diff --git a/apps/www/src/content/docs/components/table/props.ts b/apps/www/src/content/docs/components/table/props.ts index d8df35a40..5ae1d6629 100644 --- a/apps/www/src/content/docs/components/table/props.ts +++ b/apps/www/src/content/docs/components/table/props.ts @@ -44,4 +44,9 @@ export interface TableSectionHeaderProps { colSpan: number; /** Additional CSS class names. */ className?: string; + /** Props for the inner parts. */ + slotProps?: { + row?: React.ComponentProps<'tr'>; + cell?: React.ComponentProps<'th'>; + }; } diff --git a/apps/www/src/content/docs/components/tabs/index.mdx b/apps/www/src/content/docs/components/tabs/index.mdx index 08b340e4d..f42555a6f 100644 --- a/apps/www/src/content/docs/components/tabs/index.mdx +++ b/apps/www/src/content/docs/components/tabs/index.mdx @@ -109,7 +109,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `tabs-list` | `Tabs.List` container | | `tabs-indicator` | The animated active-tab indicator | | `tabs-tab` | `Tabs.Tab` button | -| `tabs-tab-icon` | A tab's leading icon (when `leadingIcon` is set) | +| `tabs-tab-leading-icon` | A tab's leading icon (when `leadingIcon` is set) | | `tabs-content` | `Tabs.Content` panel | ## Accessibility diff --git a/apps/www/src/content/docs/dataview/list.mdx b/apps/www/src/content/docs/dataview/list.mdx index 2919fef24..8e1c7ba7b 100644 --- a/apps/www/src/content/docs/dataview/list.mdx +++ b/apps/www/src/content/docs/dataview/list.mdx @@ -93,7 +93,7 @@ DataView doesn't ship a selection toolbar, but the underlying TanStack table ins - **Selection state lives on the table instance.** Read it with `table.getSelectedRowModel()`, clear it with `table.resetRowSelection()`, and mirror it outside the tree with `onRowSelectionChange` on the root. - **The TanStack [row selection API](https://tanstack.com/table/v8/docs/guide/row-selection) works as documented**: `row.getIsSelected()`, `row.toggleSelected()`, `row.getIsSomeSelected()`, `table.getIsAllRowsSelected()`, `getIsSomeRowsSelected()`, `toggleAllRowsSelected()`, `setRowSelection()`, `resetRowSelection()`. The header helpers are computed over the **filtered** rows, so select-all tracks what the user can see rather than the whole dataset. - **Skip the two handler getters from that guide.** `row.getToggleSelectedHandler()` and `table.getToggleAllRowsSelectedHandler()` adapt a native `` and read `event.target.checked`. Apsara's [`Checkbox`](/docs/components/checkbox) reports a boolean through `onCheckedChange`, so the table-level getter throws on `undefined.checked` and the row-level one only works through its "no value means invert" fallback. Call `toggleSelected` and `toggleAllRowsSelected` with the boolean instead. -- **Position the bar through `FloatingActions`.** It defaults to `variant="floating"`, meaning `position: fixed` and bottom-center, so the call site needs no positioning CSS. To scope the bar to the view instead of the viewport, give an ancestor `transform`, `filter`, or `contain: paint` so it becomes the containing block. Add `padding-bottom` via `classNames.root` if rows would otherwise sit behind it. +- **Position the bar through `FloatingActions`.** It defaults to `variant="floating"`, meaning `position: fixed` and bottom-center, so the call site needs no positioning CSS. To scope the bar to the view instead of the viewport, give an ancestor `transform`, `filter`, or `contain: paint` so it becomes the containing block. Add `padding-bottom` via `slotProps.root` if rows would otherwise sit behind it. - **Stop propagation in the checkbox's `onClick`** when the root has an `onRowClick`, otherwise ticking a checkbox also activates the row. - **Grouping works alongside selection.** Group header rows are keyed in their own id space and are not selectable, so `rowSelection` holds one key per data row and select-all covers the visible data rows without flagging the bands. Read the count off `getSelectedRowModel().flatRows`, not `.rows`, since the latter only walks selected *top-level* rows, which is empty while grouping puts every data row one level down. @@ -127,7 +127,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `data-view-list-loader-row` | Skeleton row while `isLoading` | | `data-view-list-sentinel` | Infinite-scroll sentinel | -Header cells, body cells, and loader cells also carry `data-column="{accessorKey}"`, so a single column can be targeted without a per-column `classNames` entry: +Header cells, body cells, and loader cells also carry `data-column="{accessorKey}"`, so a single column can be targeted without a per-column `slotProps` entry: ```css /* Right-align every cell in the "amount" column, header included */ diff --git a/apps/www/src/content/docs/dataview/props.ts b/apps/www/src/content/docs/dataview/props.ts index 39513c39b..bc2566c37 100644 --- a/apps/www/src/content/docs/dataview/props.ts +++ b/apps/www/src/content/docs/dataview/props.ts @@ -1,4 +1,5 @@ -import { ReactNode, RefObject } from 'react'; +import { ComponentProps, ReactNode, RefObject } from 'react'; +import type { FilterChipProps } from '../components/filter-chip/props'; // Documentation-only placeholders so `auto-type-table` can render the public // API without surfacing real generics (TData is shown as `T` for readability). @@ -84,7 +85,7 @@ export interface DataViewField { label: string; /** Optional icon (e.g. for grouping menu). */ - icon?: ReactNode; + leadingIcon?: ReactNode; /** Allow filtering on this field. */ filterable?: boolean; @@ -163,6 +164,16 @@ export interface DataViewListProps { /** When true, the active group header sticks under the table header while scrolling. */ stickyGroupHeader?: boolean; + + /** Props for the rendered parts. Repeated parts get the same props on every instance. */ + slotProps?: { + root?: ComponentProps<'div'>; + header?: ComponentProps<'div'>; + headerCell?: ComponentProps<'div'>; + row?: ComponentProps<'div'>; + cell?: ComponentProps<'div'>; + groupHeader?: ComponentProps<'div'>; + }; } export interface DataViewListColumn { @@ -181,6 +192,12 @@ export interface DataViewListColumn { * @defaultValue "1fr" */ width?: string | number; + + /** Props for this column's body cells and header cell. They win over the List's `slotProps`. */ + slotProps?: { + cell?: ComponentProps<'div'>; + header?: ComponentProps<'div'>; + }; } /** Date inputs accepted by Timeline props and row fields. */ @@ -337,6 +354,20 @@ export interface DataViewTimelineProps { * @defaultValue true */ showGroupHeaders?: boolean; + + /** Props for the rendered parts. Repeated parts get the same props on every instance. */ + slotProps?: { + root?: ComponentProps<'div'>; + axis?: ComponentProps<'div'>; + band?: ComponentProps<'div'>; + tick?: ComponentProps<'div'>; + marker?: ComponentProps<'div'>; + gridline?: ComponentProps<'div'>; + cursor?: ComponentProps<'div'>; + canvas?: ComponentProps<'div'>; + card?: ComponentProps<'div'>; + groupHeader?: ComponentProps<'div'>; + }; } export interface TimelineCardContext { @@ -443,6 +474,11 @@ export interface DataViewFiltersProps { }) => ReactNode); /** Class applied to the filters row. */ className?: string; + /** Props for the filter chips and the default add-filter trigger. */ + slotProps?: { + filterChips?: Partial; + addFilter?: ComponentProps<'button'>; + }; /** * Alignment of the add-filter menu against its trigger. * @default "start" diff --git a/apps/www/src/content/docs/dataview/timeline.mdx b/apps/www/src/content/docs/dataview/timeline.mdx index 019ecc686..67e005083 100644 --- a/apps/www/src/content/docs/dataview/timeline.mdx +++ b/apps/www/src/content/docs/dataview/timeline.mdx @@ -138,7 +138,7 @@ Section order is the field's `groupOrder` where it declares one (`['High', 'Medi - **Packing is per section.** A card only ever shares a lane with cards in its own group, and `context.laneIndex` is section-relative. Every section starts at lane 0. `lanePacking="one-per-row"` and `"one-per-sort-value"` apply within each section too. - **Bands pin while their section is in view.** The active band sticks directly under the time axis and is pushed off by the next section's band; its label sticks to the left edge so it stays readable while you pan to a distant month. Always on, in pure CSS, with no prop. - **Empty sections disappear.** A group whose cards all fall outside an explicit `range`, or that has no valid `startField` values, renders nothing: no band, no empty strip. A band that *does* render shows the full group count, even when some of its cards are culled, matching List. -- **`showGroupHeaders={false}`** hides the bands but keeps the sections, the same semantics as the prop on `DataView.List`. Style a band with `classNames.groupHeader`. +- **`showGroupHeaders={false}`** hides the bands but keeps the sections, the same semantics as the prop on `DataView.List`. Style a band with `slotProps.groupHeader`. Bands are labels only in this release: no chevron, no collapsing. diff --git a/packages/react/components/announcement-bar/__tests__/data-slots.test.tsx b/packages/react/components/announcement-bar/__tests__/data-slots.test.tsx index eed53a4e1..0ef86ea7e 100644 --- a/packages/react/components/announcement-bar/__tests__/data-slots.test.tsx +++ b/packages/react/components/announcement-bar/__tests__/data-slots.test.tsx @@ -16,7 +16,7 @@ describe('AnnouncementBar data-slot contract', () => { ); expectSlots(container, [ 'announcement-bar', - 'announcement-bar-icon', + 'announcement-bar-leading-icon', 'announcement-bar-text', 'announcement-bar-action', 'announcement-bar-action-label', @@ -27,7 +27,7 @@ describe('AnnouncementBar data-slot contract', () => { it('omits icon, action, and dismiss slots when not provided', () => { const { container } = render(); - expect(getSlot(container, 'announcement-bar-icon')).toBeNull(); + expect(getSlot(container, 'announcement-bar-leading-icon')).toBeNull(); expect(getSlot(container, 'announcement-bar-action')).toBeNull(); expect(getSlot(container, 'announcement-bar-action-icon')).toBeNull(); expect(getSlot(container, 'announcement-bar-dismiss')).toBeNull(); diff --git a/packages/react/components/announcement-bar/announcement-bar.tsx b/packages/react/components/announcement-bar/announcement-bar.tsx index 8f794f99d..579031734 100644 --- a/packages/react/components/announcement-bar/announcement-bar.tsx +++ b/packages/react/components/announcement-bar/announcement-bar.tsx @@ -59,7 +59,7 @@ export const AnnouncementBar = ({ diff --git a/packages/react/components/badge/__tests__/badge.test.tsx b/packages/react/components/badge/__tests__/badge.test.tsx index 1300c6dde..cb8e8fb2b 100644 --- a/packages/react/components/badge/__tests__/badge.test.tsx +++ b/packages/react/components/badge/__tests__/badge.test.tsx @@ -72,7 +72,7 @@ describe('Badge', () => { describe('Icon Support', () => { it('renders with icon', () => { const icon = ; - render(With Icon); + render(With Icon); expect(screen.getByTestId('badge-icon')).toBeInTheDocument(); expect(screen.getByText('With Icon')).toBeInTheDocument(); @@ -86,7 +86,7 @@ describe('Badge', () => { it('renders complex icon components', () => { const ComplexIcon = () => ★; - render(}>Badge); + render(}>Badge); expect(screen.getByTestId('complex-icon')).toBeInTheDocument(); expect(screen.getByText('★')).toBeInTheDocument(); diff --git a/packages/react/components/badge/__tests__/data-slots.test.tsx b/packages/react/components/badge/__tests__/data-slots.test.tsx index 73a248051..223624b54 100644 --- a/packages/react/components/badge/__tests__/data-slots.test.tsx +++ b/packages/react/components/badge/__tests__/data-slots.test.tsx @@ -6,17 +6,21 @@ import { Badge } from '../badge'; describe('Badge data-slot contract', () => { it('exposes slots for every rendered part', () => { const { container } = render( - i} screenReaderText='3 unread messages'> + i} screenReaderText='3 unread messages'> 3 ); - expectSlots(container, ['badge', 'badge-icon', 'badge-screen-reader-text']); + expectSlots(container, [ + 'badge', + 'badge-leading-icon', + 'badge-screen-reader-text' + ]); }); it('omits optional slots when their parts are absent', () => { const { container } = render(New); expect(getSlot(container, 'badge')).not.toBeNull(); - expect(getSlot(container, 'badge-icon')).toBeNull(); + expect(getSlot(container, 'badge-leading-icon')).toBeNull(); expect(getSlot(container, 'badge-screen-reader-text')).toBeNull(); }); }); diff --git a/packages/react/components/badge/badge.tsx b/packages/react/components/badge/badge.tsx index f3d338e49..06db1188d 100644 --- a/packages/react/components/badge/badge.tsx +++ b/packages/react/components/badge/badge.tsx @@ -29,7 +29,7 @@ const badge = cva(styles['badge'], { export type BadgeProps = VariantProps & ComponentProps<'span'> & { - icon?: ReactNode; + leadingIcon?: ReactNode; screenReaderText?: string; }; @@ -37,7 +37,7 @@ export const Badge = ({ variant, size, radius, - icon, + leadingIcon, children, className, screenReaderText, @@ -49,9 +49,9 @@ export const Badge = ({ data-slot='badge' {...props} > - {icon && ( - - {icon} + {leadingIcon && ( + + {leadingIcon} )} {screenReaderText && ( diff --git a/packages/react/components/callout/__tests__/callout.test.tsx b/packages/react/components/callout/__tests__/callout.test.tsx index 776130e89..42b9fdb91 100644 --- a/packages/react/components/callout/__tests__/callout.test.tsx +++ b/packages/react/components/callout/__tests__/callout.test.tsx @@ -14,7 +14,7 @@ describe('Callout', () => { it('renders with custom icon', () => { render( - 🔔}> + 🔔}> Message with custom icon ); @@ -25,7 +25,7 @@ describe('Callout', () => { it('renders without icon when icon is null', () => { const { container } = render( - Message without icon + Message without icon ); expect( diff --git a/packages/react/components/callout/__tests__/data-slots.test.tsx b/packages/react/components/callout/__tests__/data-slots.test.tsx index 39b74f463..747b46d1b 100644 --- a/packages/react/components/callout/__tests__/data-slots.test.tsx +++ b/packages/react/components/callout/__tests__/data-slots.test.tsx @@ -16,7 +16,7 @@ describe('Callout data-slot contract', () => { 'callout', 'callout-container', 'callout-message-container', - 'callout-icon', + 'callout-leading-icon', 'callout-message', 'callout-actions', 'callout-action', @@ -25,8 +25,10 @@ describe('Callout data-slot contract', () => { }); it('omits the icon, action, and dismiss slots when their props are absent', () => { - const { container } = render(Plain message); - expect(getSlot(container, 'callout-icon')).toBeNull(); + const { container } = render( + Plain message + ); + expect(getSlot(container, 'callout-leading-icon')).toBeNull(); expect(getSlot(container, 'callout-action')).toBeNull(); expect(getSlot(container, 'callout-dismiss')).toBeNull(); }); diff --git a/packages/react/components/callout/callout.tsx b/packages/react/components/callout/callout.tsx index 4c4bd54ae..79a325fe4 100644 --- a/packages/react/components/callout/callout.tsx +++ b/packages/react/components/callout/callout.tsx @@ -54,7 +54,7 @@ export interface CalloutProps */ onDismiss?: () => void; style?: CSSProperties; - icon?: ReactNode; + leadingIcon?: ReactNode; } export function Callout({ @@ -67,7 +67,7 @@ export function Callout({ action, dismissible, onDismiss, - icon = , + leadingIcon = , ...props }: CalloutProps) { // Dismissal is controlled when `onDismiss` is given; otherwise fall back to @@ -116,13 +116,13 @@ export function Callout({ className={styles.messageContainer} data-slot='callout-message-container' > - {icon && ( + {leadingIcon && ( )}
diff --git a/packages/react/components/combobox/__tests__/data-slots.test.tsx b/packages/react/components/combobox/__tests__/data-slots.test.tsx index fd6424385..8d8d95c29 100644 --- a/packages/react/components/combobox/__tests__/data-slots.test.tsx +++ b/packages/react/components/combobox/__tests__/data-slots.test.tsx @@ -71,13 +71,17 @@ describe('Combobox data-slot contract', () => { ]); }); - it('exposes the leading icon slot on items when provided', async () => { + it('exposes the leading and trailing icon slots on items when provided', async () => { const user = userEvent.setup(); render( - i}> + i} + trailingIcon={t} + > Apple @@ -87,7 +91,12 @@ describe('Combobox data-slot contract', () => { await user.click(input); await waitFor(() => { - expect(getSlot(document.body, 'combobox-item-icon')).not.toBeNull(); + expect( + getSlot(document.body, 'combobox-item-leading-icon') + ).not.toBeNull(); + expect( + getSlot(document.body, 'combobox-item-trailing-icon') + ).not.toBeNull(); }); }); }); diff --git a/packages/react/components/combobox/combobox-item.tsx b/packages/react/components/combobox/combobox-item.tsx index 7403a0bda..4a74ee6b0 100644 --- a/packages/react/components/combobox/combobox-item.tsx +++ b/packages/react/components/combobox/combobox-item.tsx @@ -11,6 +11,7 @@ import { useComboboxContext } from './combobox-root'; export interface ComboboxItemProps extends ComboboxPrimitive.Item.Props { leadingIcon?: ReactNode; + trailingIcon?: ReactNode; } export const ComboboxItem = ({ @@ -18,6 +19,7 @@ export const ComboboxItem = ({ children, value: providedValue, leadingIcon, + trailingIcon, disabled, render, ...props @@ -40,11 +42,22 @@ export const ComboboxItem = ({ typeof children === 'string' ? ( <> {leadingIcon && ( -
+
{leadingIcon}
)} {children} + {trailingIcon && ( +
+ {trailingIcon} +
+ )} ) : ( children diff --git a/packages/react/components/combobox/combobox.module.css b/packages/react/components/combobox/combobox.module.css index 44640aa08..a9d739719 100644 --- a/packages/react/components/combobox/combobox.module.css +++ b/packages/react/components/combobox/combobox.module.css @@ -55,6 +55,10 @@ height: var(--rs-space-5); } +.itemTrailingIcon { + margin-left: auto; +} + .menuitem { display: flex; align-items: center; diff --git a/packages/react/components/command/command-input.tsx b/packages/react/components/command/command-input.tsx index 4543e18b7..757d46f4d 100644 --- a/packages/react/components/command/command-input.tsx +++ b/packages/react/components/command/command-input.tsx @@ -32,7 +32,9 @@ export const CommandInput = ({ { expect(container.querySelector('.my-add-filter')).not.toBeNull(); }); + it('merges slotProps with classNames on List and Filters parts', async () => { + const user = userEvent.setup(); + const onRowClick = vi.fn(); + const onRowSlotClick = vi.fn(); + const { container } = render( + + + + + ); + expect(getSlot(container, 'filter-chip')).toHaveClass( + 'old-chip', + 'new-chip' + ); + expect(getSlot(container, 'data-view-add-filter')).toHaveClass( + 'new-add-filter' + ); + const row = getSlot(container, 'data-view-list-row') as HTMLElement; + expect(row).toHaveClass('old-row', 'new-row'); + expect(row.querySelector('[data-column="name"]')).toHaveClass('name-cell'); + await user.click(row); + expect(onRowSlotClick).toHaveBeenCalledTimes(1); + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + it('exposes slots inside the display controls popover', async () => { const user = userEvent.setup(); render( diff --git a/packages/react/components/data-view/__tests__/timeline.test.tsx b/packages/react/components/data-view/__tests__/timeline.test.tsx index f000be845..041fb233e 100644 --- a/packages/react/components/data-view/__tests__/timeline.test.tsx +++ b/packages/react/components/data-view/__tests__/timeline.test.tsx @@ -539,6 +539,20 @@ describe('DataView.Timeline', () => { expect(onRowClick).toHaveBeenCalledWith(orders[0]); }); + it('merges slotProps with classNames on the parts', () => { + const { container } = renderTimeline({ + classNames: { card: 'old-card' }, + slotProps: { + root: { className: 'new-root' }, + card: { className: 'new-card', title: 'Order' } + } + }); + expect(container.firstElementChild).toHaveClass('new-root'); + const card = screen.getByTestId('card-o1').parentElement; + expect(card).toHaveClass('old-card', 'new-card'); + expect(card).toHaveAttribute('title', 'Order'); + }); + it('gates itself on the active view when `name` is set', () => { const views = [ { value: 'gantt', label: 'Timeline' }, diff --git a/packages/react/components/data-view/components/filters.tsx b/packages/react/components/data-view/components/filters.tsx index 28f6a5cdc..82ec1d680 100644 --- a/packages/react/components/data-view/components/filters.tsx +++ b/packages/react/components/data-view/components/filters.tsx @@ -1,11 +1,12 @@ 'use client'; +import { mergeProps } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { isValidElement, ReactNode, useMemo } from 'react'; +import { ComponentProps, isValidElement, ReactNode, useMemo } from 'react'; import { FilterIcon } from '~/icons'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; -import { FilterChip } from '../../filter-chip'; +import { FilterChip, type FilterChipProps } from '../../filter-chip'; import { Flex } from '../../flex'; import { IconButton } from '../../icon-button'; import { Menu } from '../../menu'; @@ -29,8 +30,8 @@ interface AddFilterProps extends PositionerProps { appliedFiltersSet: Set; onAddFilter: (field: DataViewField) => void; children?: Trigger; - /** Applied to the default trigger only. A custom `trigger`/`children` owns its own className. */ - className?: string; + /** Applied to the default trigger only. A custom `trigger`/`children` owns its own props. */ + triggerProps?: Omit, 'color'>; } function AddFilter({ @@ -38,7 +39,7 @@ function AddFilter({ appliedFiltersSet, onAddFilter, children, - className, + triggerProps, ...positionerProps }: AddFilterProps) { const unappliedFields = fieldList.filter( @@ -59,9 +60,9 @@ function AddFilter({ return ( @@ -73,13 +74,13 @@ function AddFilter({ size='small' leadingIcon={} color='neutral' - className={className} data-slot='data-view-add-filter' + {...triggerProps} > Filter ); - }, [children, appliedFiltersSet, availableFilters, className]); + }, [children, appliedFiltersSet, availableFilters, triggerProps]); return unappliedFields.length > 0 ? ( @@ -103,10 +104,16 @@ function AddFilter({ } export interface DataViewFiltersProps extends PositionerProps { + /** Props for the inner parts. `addFilter` applies to the default trigger only. */ + slotProps?: { + filterChips?: Partial; + addFilter?: Omit, 'color'>; + }; + /** @deprecated Use `slotProps` instead. */ classNames?: { - /** @deprecated Use `[data-slot="filter-chip"]` instead. */ + /** @deprecated Use `slotProps.filterChips` instead. */ filterChips?: string; - /** @deprecated Use `[data-slot="data-view-add-filter"]` instead. */ + /** @deprecated Use `slotProps.addFilter` instead. */ addFilter?: string; }; className?: string; @@ -114,6 +121,7 @@ export interface DataViewFiltersProps extends PositionerProps { } export function Filters({ + slotProps, classNames, className, trigger, @@ -159,27 +167,35 @@ export function Filters({ {appliedFilters.map(filter => ( handleRemoveFilter(filter.name)} - onValueChange={value => handleFilterValueChange(filter.name, value)} - onOperationChange={operator => - handleFilterOperationChange( - filter.name, - operator as FilterOperatorTypes - ) - } - columnType={filter.filterType} - options={filter.options} - selectProps={filter.selectProps} - className={classNames?.filterChips} + {...mergeProps( + { + label: filter.label, + value: filter.value ?? undefined, + onRemove: () => handleRemoveFilter(filter.name), + onValueChange: value => + handleFilterValueChange(filter.name, value), + onOperationChange: operator => + handleFilterOperationChange( + filter.name, + operator as FilterOperatorTypes + ), + columnType: filter.filterType, + options: filter.options, + selectProps: filter.selectProps, + className: classNames?.filterChips + }, + slotProps?.filterChips as FilterChipProps + )} /> ))} ( + { className: classNames?.addFilter }, + slotProps?.addFilter + )} {...positionerProps} > {trigger} diff --git a/packages/react/components/data-view/components/list.tsx b/packages/react/components/data-view/components/list.tsx index 3c196462c..a04929c49 100644 --- a/packages/react/components/data-view/components/list.tsx +++ b/packages/react/components/data-view/components/list.tsx @@ -1,5 +1,6 @@ 'use client'; +import { mergeProps } from '@base-ui/react'; import type { Header, Row } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table'; import { cx } from 'class-variance-authority'; @@ -50,6 +51,7 @@ export function DataViewList({ showDividers, showGroupHeaders = true, stickyGroupHeader = false, + slotProps = {}, classNames = {} }: DataViewListProps) { const { @@ -228,10 +230,13 @@ export function DataViewList({ if (!headersVisible) return null; return (
( + { className: cx(styles.listHeader, classNames.header) }, + slotProps.header + )} + ref={headerMeasureRef} >
({
( + { + className: cx( + styles.listHeaderCell, + spec?.classNames?.header, + classNames.headerCell + ), + style: spec?.styles?.header + }, + slotProps.headerCell, + spec?.slotProps?.header + )} > {content}
@@ -308,14 +319,20 @@ export function DataViewList({
( + { + className: cx( + styles.listCell, + spec?.classNames?.cell, + classNames.cell + ), + style: spec?.styles?.cell + }, + slotProps.cell, + spec?.slotProps?.cell + )} > {content}
@@ -346,16 +363,23 @@ export function DataViewList({ return (
( + { + className: cx( + isVirtual + ? styles.listGroupHeaderVirtual + : styles.listGroupHeader, + isStickyInFlow && styles.listGroupHeaderSticky, + classNames.groupHeader + ), + style: composedStyle + }, + slotProps.groupHeader + )} + ref={measure} > {data?.label} {data?.showGroupCount ? ( @@ -389,19 +413,24 @@ export function DataViewList({ // keydown. Swapping to role="button" would break that association. role={ariaRole === 'table' ? 'row' : 'listitem'} tabIndex={onRowClick ? 0 : undefined} - onKeyDown={onRowClick ? handleRowKeyDown : undefined} key={key ?? row.id} - ref={measure} data-index={dataIndex} - className={cx( - isVirtual ? styles.listRowVirtual : styles.listRow, - dividers && styles.listRowDivider, - onRowClick && styles.clickable, - classNames.row - )} - style={composedStyle} - onClick={handleRowActivate} data-slot='data-view-list-row' + {...mergeProps<'div'>( + { + className: cx( + isVirtual ? styles.listRowVirtual : styles.listRow, + dividers && styles.listRowDivider, + onRowClick && styles.clickable, + classNames.row + ), + style: composedStyle, + onClick: handleRowActivate, + onKeyDown: onRowClick ? handleRowKeyDown : undefined + }, + slotProps.row + )} + ref={measure} > {renderRowCells(row)}
@@ -426,14 +455,20 @@ export function DataViewList({
( + { + className: cx( + styles.listCell, + spec?.classNames?.cell, + classNames.cell + ), + style: spec?.styles?.cell + }, + slotProps.cell, + spec?.slotProps?.cell + )} >
@@ -453,9 +488,14 @@ export function DataViewList({ {stickyGroup && stickyGroupHeader ? (