diff --git a/.agents/skills/apsara/references/composition.md b/.agents/skills/apsara/references/composition.md index c6c679642..a028c89c7 100644 --- a/.agents/skills/apsara/references/composition.md +++ b/.agents/skills/apsara/references/composition.md @@ -89,7 +89,7 @@ import { Select } from "@raystack/apsara"; ``` - Put `placeholder` on `Select.Value`, not the trigger. -- `Select.Item` supports `leadingIcon`; `disabled` marks unavailable rows. +- `Select.Item` supports `leadingIcon` and `trailingIcon`; `disabled` marks unavailable rows. - Sub-parts: `Select.Trigger`, `Select.Value`, `Select.Content`, `Select.Item`, `Select.Group`, `Select.Label`, `Select.Separator`, `Select.List`. ## Menu / ContextMenu diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 82f756aad..0700e4412 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,47 @@ 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 are `leadingIcon` and `trailingIcon` + +An icon before or after the content is set with `leadingIcon` or +`trailingIcon`, and each leading icon has a `-leading-icon` slot. + +### 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` | +| `chat-jump-button-icon` | `chat-jump-button-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` | + ## 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/chat/index.mdx b/apps/www/src/content/docs/ai-elements/chat/index.mdx index ea9da454e..0ac639b62 100644 --- a/apps/www/src/content/docs/ai-elements/chat/index.mdx +++ b/apps/www/src/content/docs/ai-elements/chat/index.mdx @@ -156,7 +156,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `chat-separator` | Divider between messages | | `chat-composer` | The input area below the list | | `chat-jump-button` | "Jump to latest" button | -| `chat-jump-button-icon` | Icon inside that button | +| `chat-jump-button-leading-icon` | Leading icon inside that button (unless `leadingIcon` is `null`) | | `chat-attachment` | An attachment card | | `chat-attachment-media` | Its thumbnail or preview | | `chat-attachment-body` | Its text column | 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/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/props.ts b/apps/www/src/content/docs/dataview/props.ts index 39513c39b..870ff2eaf 100644 --- a/apps/www/src/content/docs/dataview/props.ts +++ b/apps/www/src/content/docs/dataview/props.ts @@ -84,7 +84,7 @@ export interface DataViewField { label: string; /** Optional icon (e.g. for grouping menu). */ - icon?: ReactNode; + leadingIcon?: ReactNode; /** Allow filtering on this field. */ filterable?: boolean; 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/chat/__tests__/data-slots.test.tsx b/packages/react/components/chat/__tests__/data-slots.test.tsx index 23f6d4a80..cdb8f8f61 100644 --- a/packages/react/components/chat/__tests__/data-slots.test.tsx +++ b/packages/react/components/chat/__tests__/data-slots.test.tsx @@ -34,7 +34,7 @@ describe('Chat data-slot contract', () => { 'chat-separator', 'chat-item', 'chat-jump-button', - 'chat-jump-button-icon', + 'chat-jump-button-leading-icon', 'chat-composer', 'chat-attachment', 'chat-attachment-media', @@ -60,6 +60,6 @@ describe('Chat data-slot contract', () => { ); - expect(getSlot(container, 'chat-jump-button-icon')).toBeNull(); + expect(getSlot(container, 'chat-jump-button-leading-icon')).toBeNull(); }); }); diff --git a/packages/react/components/chat/chat-messages.tsx b/packages/react/components/chat/chat-messages.tsx index 58c477479..7cf1f5995 100644 --- a/packages/react/components/chat/chat-messages.tsx +++ b/packages/react/components/chat/chat-messages.tsx @@ -467,7 +467,7 @@ export function ChatJumpButton({ 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/data-view/data-view.types.tsx b/packages/react/components/data-view/data-view.types.tsx index 8ec4dff2b..f2284a34d 100644 --- a/packages/react/components/data-view/data-view.types.tsx +++ b/packages/react/components/data-view/data-view.types.tsx @@ -81,7 +81,7 @@ export interface DataViewField { accessorKey: Extract | (string & {}); /** Human-readable label shown in filter chips, Display controls, and the default Table header. */ label: string; - icon?: React.ReactNode; + leadingIcon?: React.ReactNode; // filter capability filterable?: boolean; diff --git a/packages/react/components/editor/core/mention-node-view.ts b/packages/react/components/editor/core/mention-node-view.ts index 2668bc125..c70c05410 100644 --- a/packages/react/components/editor/core/mention-node-view.ts +++ b/packages/react/components/editor/core/mention-node-view.ts @@ -6,8 +6,8 @@ import type { MentionAttrs } from './mention'; /** * One live chip. The node view owns the element and writes the label into it * synchronously, so there is never an empty chip frame, while React portals the - * consumer's `icon` and `trailing` nodes into the two slots, so those render in - * the host tree and see Theme and any other provider context. + * consumer's `leadingIcon` and `trailingIcon` nodes into the two slots, so those + * render in the host tree and see Theme and any other provider context. */ export interface MentionPortal { /** Stable React key for the lifetime of this node view. */ diff --git a/packages/react/components/editor/core/mention-registry.ts b/packages/react/components/editor/core/mention-registry.ts index 6ab1163aa..74691ee6b 100644 --- a/packages/react/components/editor/core/mention-registry.ts +++ b/packages/react/components/editor/core/mention-registry.ts @@ -12,9 +12,9 @@ export interface EditorMentionItem { * @defaultValue "mention" */ type?: string; - icon?: ReactNode; + leadingIcon?: ReactNode; /** Trailing metadata: a badge, a shortcut, a timestamp. */ - trailing?: ReactNode; + trailingIcon?: ReactNode; /** Section heading. Groups render in first-appearance order. */ group?: string; disabled?: boolean; @@ -62,9 +62,9 @@ function sameData(a: MentionsData, b: MentionsData): boolean { * drives the menu and decorates chips, and the root (which reads `data` back * when assembling a message). PromptInput and Editor each own one. * - * `icon`, `trailing` and `data` cannot survive serialization, so they live here - * rather than on the document, keyed by `trigger|type|id` and filled in when an - * item is picked from the menu or returned by `resolveMentions`. + * `leadingIcon`, `trailingIcon` and `data` cannot survive serialization, so they + * live here rather than on the document, keyed by `trigger|type|id` and filled + * in when an item is picked from the menu or returned by `resolveMentions`. * * Registration is split from data on purpose. The trigger is established once, * while the data is pushed after every `Mentions` render and compared field by diff --git a/packages/react/components/editor/core/mention.ts b/packages/react/components/editor/core/mention.ts index 1ef20de0e..a66c93e47 100644 --- a/packages/react/components/editor/core/mention.ts +++ b/packages/react/components/editor/core/mention.ts @@ -24,7 +24,7 @@ export interface EditorMention extends MentionAttrs { end: number; } -/** Cache key for the item store that backs a chip's icon, trailing and data. */ +/** Cache key for the item store that backs a chip's icons and data. */ export function mentionKey(trigger: string, type: string, id: string): string { return `${trigger}|${type}|${id}`; } diff --git a/packages/react/components/editor/core/suggestion-menu.tsx b/packages/react/components/editor/core/suggestion-menu.tsx index ac8902da1..c19bef54a 100644 --- a/packages/react/components/editor/core/suggestion-menu.tsx +++ b/packages/react/components/editor/core/suggestion-menu.tsx @@ -13,8 +13,8 @@ export interface SuggestionMenuItem { /** Shown under the label. */ description?: string; type?: string; - icon?: ReactNode; - trailing?: ReactNode; + leadingIcon?: ReactNode; + trailingIcon?: ReactNode; group?: string; disabled?: boolean; data?: unknown; @@ -146,8 +146,8 @@ export function SuggestionMenu({ aria-disabled={item.disabled || undefined} data-highlighted={highlighted ? '' : undefined} className={cx(styles.suggestionRow)} - leadingIcon={item.icon} - trailingIcon={item.trailing} + leadingIcon={item.leadingIcon} + trailingIcon={item.trailingIcon} onPointerMove={() => { if (!item.disabled) onHighlightChange(index); }} diff --git a/packages/react/components/editor/core/use-suggestion-menu.ts b/packages/react/components/editor/core/use-suggestion-menu.ts index c3d28db3a..c9bdc5dbb 100644 --- a/packages/react/components/editor/core/use-suggestion-menu.ts +++ b/packages/react/components/editor/core/use-suggestion-menu.ts @@ -424,12 +424,12 @@ export function useSuggestionMenu({ } /** - * `icon`, `trailing` and `data` cannot survive serialization, so a chip parsed - * from `defaultValue` starts label-only and fills in when the consumer's - * `resolveMentions` resolves, the same progressive enhancement `Select.Value` - * uses when it falls back to the raw value until an item registers. A rejection - * or a missing item leaves the chip label-only; it is never an error state and - * the chip is never removed. + * `leadingIcon`, `trailingIcon` and `data` cannot survive serialization, so a + * chip parsed from `defaultValue` starts label-only and fills in when the + * consumer's `resolveMentions` resolves, the same progressive enhancement + * `Select.Value` uses when it falls back to the raw value until an item + * registers. A rejection or a missing item leaves the chip label-only; it is + * never an error state and the chip is never removed. */ export function useMentionResolution( registry: MentionRegistry, diff --git a/packages/react/components/editor/editor-content.tsx b/packages/react/components/editor/editor-content.tsx index 674afa914..9d79bff90 100644 --- a/packages/react/components/editor/editor-content.tsx +++ b/packages/react/components/editor/editor-content.tsx @@ -120,9 +120,11 @@ export function EditorContent({ ); return ( - {item?.icon ? createPortal(item.icon, portal.iconTarget) : null} - {item?.trailing - ? createPortal(item.trailing, portal.trailingTarget) + {item?.leadingIcon + ? createPortal(item.leadingIcon, portal.iconTarget) + : null} + {item?.trailingIcon + ? createPortal(item.trailingIcon, portal.trailingTarget) : null} ); diff --git a/packages/react/components/editor/editor-mentions.tsx b/packages/react/components/editor/editor-mentions.tsx index c8d1953ea..98407b0e8 100644 --- a/packages/react/components/editor/editor-mentions.tsx +++ b/packages/react/components/editor/editor-mentions.tsx @@ -26,7 +26,7 @@ export interface EditorMentionsProps { query: string, context: { trigger: string; signal: AbortSignal } ) => Promise; - /** Fills in the icon, trailing content and label of chips loaded from `value` or `defaultValue`. */ + /** Fills in the leading icon, trailing icon and label of chips loaded from `value` or `defaultValue`. */ resolveMentions?: (refs: EditorMentionRef[]) => Promise; /** * Shown when nothing matches the query. diff --git a/packages/react/components/editor/editor-slash-items.tsx b/packages/react/components/editor/editor-slash-items.tsx index 53fcf2f60..047b84750 100644 --- a/packages/react/components/editor/editor-slash-items.tsx +++ b/packages/react/components/editor/editor-slash-items.tsx @@ -109,7 +109,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Text', group: 'Text', keywords: ['paragraph', 'plain'], - icon: , + leadingIcon: , run: editor => editor.commands.setParagraph() }, { @@ -117,7 +117,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Heading 1', group: 'Text', keywords: ['h1', 'title'], - icon: , + leadingIcon: , run: editor => editor.commands.setHeading(1) }, { @@ -125,7 +125,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Heading 2', group: 'Text', keywords: ['h2', 'subtitle'], - icon: , + leadingIcon: , run: editor => editor.commands.setHeading(2) }, { @@ -133,7 +133,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Heading 3', group: 'Text', keywords: ['h3'], - icon: , + leadingIcon: , run: editor => editor.commands.setHeading(3) }, { @@ -141,7 +141,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Bulleted list', group: 'Lists', keywords: ['unordered', 'ul', 'bullet'], - icon: , + leadingIcon: , run: editor => editor.commands.toggleList('bulletList') }, { @@ -149,7 +149,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Numbered list', group: 'Lists', keywords: ['ordered', 'ol'], - icon: , + leadingIcon: , run: editor => editor.commands.toggleList('orderedList') }, { @@ -157,7 +157,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Checklist', group: 'Lists', keywords: ['todo', 'task', 'checkbox'], - icon: , + leadingIcon: , run: editor => editor.commands.toggleList('taskList') }, { @@ -165,7 +165,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Quote', group: 'Blocks', keywords: ['blockquote', 'citation'], - icon: , + leadingIcon: , run: editor => editor.commands.toggleBlock('blockquote') }, { @@ -173,7 +173,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Code block', group: 'Blocks', keywords: ['code', 'pre', 'snippet'], - icon: , + leadingIcon: , run: editor => editor.commands.toggleBlock('codeBlock') }, { @@ -181,7 +181,7 @@ export const defaultSlashItems: EditorSlashItem[] = [ label: 'Divider', group: 'Blocks', keywords: ['hr', 'rule', 'separator', 'line'], - icon: , + leadingIcon: , run: editor => editor.commands.insertHorizontalRule() } ]; diff --git a/packages/react/components/editor/editor-slash-menu.tsx b/packages/react/components/editor/editor-slash-menu.tsx index 64be8f9bd..9bfb5dfc1 100644 --- a/packages/react/components/editor/editor-slash-menu.tsx +++ b/packages/react/components/editor/editor-slash-menu.tsx @@ -124,9 +124,11 @@ export function EditorSlashMenu({ id: item.id, label: item.label, description: item.description, - icon: item.icon, + leadingIcon: item.leadingIcon, disabled: item.disabled, - trailing: shortcut ? : null + trailingIcon: shortcut ? ( + + ) : null }; }) })); diff --git a/packages/react/components/editor/editor-types.ts b/packages/react/components/editor/editor-types.ts index 7c0855d8f..4af054d9d 100644 --- a/packages/react/components/editor/editor-types.ts +++ b/packages/react/components/editor/editor-types.ts @@ -86,7 +86,7 @@ export interface EditorSlashItem { label: string; /** Shown under the label. The filter matches it at the start of a word. */ description?: string; - icon?: ReactNode; + leadingIcon?: ReactNode; group?: string; keywords?: string[]; /** Shortcut shown on the row, in ProseMirror keymap syntax. Built-in commands show their default key. */ diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index ee5b34a71..50cd10053 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -5,7 +5,7 @@ import { cva, cx, VariantProps } from 'class-variance-authority'; import { parseISO } from 'date-fns'; import { ComponentProps, - ReactElement, + ReactNode, useCallback, useMemo, useState @@ -103,7 +103,7 @@ export interface FilterChipProps options?: FilterSelectOption[]; onValueChange?: (value: FilterChipValue, operation: string) => void; onOperationChange?: (operation: string) => void; - leadingIcon?: ReactElement; + leadingIcon?: ReactNode; operations?: FilterOperator[]; selectProps?: BaseSelectProps; /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */ diff --git a/packages/react/components/prompt-input/__tests__/prompt-input-mentions.test.tsx b/packages/react/components/prompt-input/__tests__/prompt-input-mentions.test.tsx index 9b48f29d1..f2cd53160 100644 --- a/packages/react/components/prompt-input/__tests__/prompt-input-mentions.test.tsx +++ b/packages/react/components/prompt-input/__tests__/prompt-input-mentions.test.tsx @@ -697,7 +697,7 @@ describe('PromptInput.Mentions', () => { id: 'data-table', label: 'DataTable v2', type: 'component', - icon: ◆ + leadingIcon: ◆ } ]); const { container } = render( diff --git a/packages/react/components/prompt-input/prompt-input-editor.tsx b/packages/react/components/prompt-input/prompt-input-editor.tsx index 42e83894b..53688797d 100644 --- a/packages/react/components/prompt-input/prompt-input-editor.tsx +++ b/packages/react/components/prompt-input/prompt-input-editor.tsx @@ -221,9 +221,11 @@ export function PromptInputEditor({ ); return ( - {item?.icon ? createPortal(item.icon, portal.iconTarget) : null} - {item?.trailing - ? createPortal(item.trailing, portal.trailingTarget) + {item?.leadingIcon + ? createPortal(item.leadingIcon, portal.iconTarget) + : null} + {item?.trailingIcon + ? createPortal(item.trailingIcon, portal.trailingTarget) : null} ); diff --git a/packages/react/components/prompt-input/prompt-input-mentions.tsx b/packages/react/components/prompt-input/prompt-input-mentions.tsx index 9a0473ad3..dea247f45 100644 --- a/packages/react/components/prompt-input/prompt-input-mentions.tsx +++ b/packages/react/components/prompt-input/prompt-input-mentions.tsx @@ -25,7 +25,7 @@ export interface PromptInputMentionsProps { context: { trigger: string; signal: AbortSignal } ) => Promise; /** - * Fills in the icon, trailing content, `data` and a fresh label for chips + * Fills in the leading icon, trailing icon, `data` and a fresh label for chips * parsed out of `value` / `defaultValue`, which cannot carry them. Batched * into one call per trigger and cached by `trigger|type|id`. */ diff --git a/packages/react/components/select/__tests__/data-slots.test.tsx b/packages/react/components/select/__tests__/data-slots.test.tsx index d26d23883..40661d2fa 100644 --- a/packages/react/components/select/__tests__/data-slots.test.tsx +++ b/packages/react/components/select/__tests__/data-slots.test.tsx @@ -80,7 +80,7 @@ describe('Select 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( ); await openSelect(user); - expect(getSlot(document.body, 'select-item-icon')).not.toBeNull(); + expect(getSlot(document.body, 'select-item-leading-icon')).not.toBeNull(); + expect(getSlot(document.body, 'select-item-trailing-icon')).not.toBeNull(); }); it('exposes the multiple-value slot once items are selected', async () => { diff --git a/packages/react/components/select/select-item.tsx b/packages/react/components/select/select-item.tsx index 25e7abab7..7b021ec7e 100644 --- a/packages/react/components/select/select-item.tsx +++ b/packages/react/components/select/select-item.tsx @@ -14,6 +14,7 @@ import { useSelectContext } from './select-root'; export interface SelectItemProps extends SelectPrimitive.Item.Props { leadingIcon?: ReactNode; + trailingIcon?: ReactNode; } export function SelectItem({ @@ -21,6 +22,7 @@ export function SelectItem({ children, value: providedValue, leadingIcon, + trailingIcon, disabled, ...props }: SelectItemProps) { @@ -46,11 +48,19 @@ export function SelectItem({ typeof children === 'string' ? ( <> {leadingIcon && ( -
+
{leadingIcon}
)} {children} + {trailingIcon && ( +
+ {trailingIcon} +
+ )} ) : ( children diff --git a/packages/react/components/select/select-value.tsx b/packages/react/components/select/select-value.tsx index 5a5ceb136..575b3bb13 100644 --- a/packages/react/components/select/select-value.tsx +++ b/packages/react/components/select/select-value.tsx @@ -74,7 +74,10 @@ export function SelectValue({
{typeof item?.children === 'string' && item?.leadingIcon && ( -
+
{item.leadingIcon}
)} diff --git a/packages/react/components/select/select.module.css b/packages/react/components/select/select.module.css index b5f5ae2fb..39c5a2fb6 100644 --- a/packages/react/components/select/select.module.css +++ b/packages/react/components/select/select.module.css @@ -219,6 +219,10 @@ height: var(--rs-space-5); } +.itemTrailingIcon { + margin-left: auto; +} + .trigger-small .valueContent .itemIcon svg { width: var(--rs-space-4); height: var(--rs-space-4); diff --git a/packages/react/components/side-panel/__tests__/data-slots.test.tsx b/packages/react/components/side-panel/__tests__/data-slots.test.tsx index 09269c1af..2ac9a5bf6 100644 --- a/packages/react/components/side-panel/__tests__/data-slots.test.tsx +++ b/packages/react/components/side-panel/__tests__/data-slots.test.tsx @@ -9,7 +9,7 @@ describe('SidePanel data-slot contract', () => { icon} + leadingIcon={icon} description='Description' actions={[]} /> diff --git a/packages/react/components/side-panel/__tests__/side-panel.test.tsx b/packages/react/components/side-panel/__tests__/side-panel.test.tsx index efdffd0b9..20b909fad 100644 --- a/packages/react/components/side-panel/__tests__/side-panel.test.tsx +++ b/packages/react/components/side-panel/__tests__/side-panel.test.tsx @@ -64,7 +64,7 @@ describe('SidePanel', () => { const icon = 📋; render( - + ); @@ -112,7 +112,7 @@ describe('SidePanel', () => { @@ -175,7 +175,7 @@ describe('SidePanel', () => { diff --git a/packages/react/components/side-panel/side-panel.tsx b/packages/react/components/side-panel/side-panel.tsx index 6734fcb20..a5d32b52a 100644 --- a/packages/react/components/side-panel/side-panel.tsx +++ b/packages/react/components/side-panel/side-panel.tsx @@ -38,7 +38,7 @@ SidePanelRoot.displayName = 'SidePanel'; export interface SidePanelHeaderProps extends ComponentProps<'div'> { title: string; - icon?: ReactNode; + leadingIcon?: ReactNode; actions?: Array; description?: string; titleId?: string; @@ -46,7 +46,7 @@ export interface SidePanelHeaderProps extends ComponentProps<'div'> { const SidePanelHeader = ({ title, - icon, + leadingIcon, actions = [], description, titleId, @@ -67,7 +67,7 @@ const SidePanelHeader = ({ data-slot='side-panel-header-content' > - {icon} + {leadingIcon} } diff --git a/packages/react/components/tabs/__tests__/data-slots.test.tsx b/packages/react/components/tabs/__tests__/data-slots.test.tsx index 257ba1bf8..c33423273 100644 --- a/packages/react/components/tabs/__tests__/data-slots.test.tsx +++ b/packages/react/components/tabs/__tests__/data-slots.test.tsx @@ -22,7 +22,7 @@ describe('Tabs data-slot contract', () => { 'tabs-list', 'tabs-indicator', 'tabs-tab', - 'tabs-tab-icon', + 'tabs-tab-leading-icon', 'tabs-content' ]); }); @@ -36,7 +36,7 @@ describe('Tabs data-slot contract', () => { Panel one ); - expect(getSlot(container, 'tabs-tab-icon')).toBeNull(); + expect(getSlot(container, 'tabs-tab-leading-icon')).toBeNull(); }); it('lets a caller-provided data-slot win over the default (DataView contract)', () => { diff --git a/packages/react/components/tabs/tabs.tsx b/packages/react/components/tabs/tabs.tsx index c66592136..4a2c6d413 100644 --- a/packages/react/components/tabs/tabs.tsx +++ b/packages/react/components/tabs/tabs.tsx @@ -68,7 +68,7 @@ function TabsTab({ className, leadingIcon, children, ...props }: TabsTabProps) { {leadingIcon} diff --git a/packages/react/figma/badge.figma.ts b/packages/react/figma/badge.figma.ts index 7fb6f28b0..9d46e23c0 100644 --- a/packages/react/figma/badge.figma.ts +++ b/packages/react/figma/badge.figma.ts @@ -17,7 +17,7 @@ const size = figma.selectedInstance.getEnum('Size', { }); // "Icon" BOOLEAN toggles the leading icon; resolve the child instance named "Icon" // when enabled (guarded, since findInstance returns an ErrorHandle when absent). -const icon = figma.selectedInstance.getBoolean('Icon', { +const leadingIcon = figma.selectedInstance.getBoolean('Icon', { true: (function () { const i = figma.selectedInstance.findInstance('Icon'); return i && i.type === 'INSTANCE' ? i.executeTemplate().example : undefined; @@ -38,8 +38,8 @@ export default { 'size', size )}${figma.helpers.react.renderProp( - 'icon', - icon + 'leadingIcon', + leadingIcon )}>${figma.helpers.react.renderChildren(children)}`, metadata: { nestable: true } }; diff --git a/packages/react/figma/side-panel-header.figma.ts b/packages/react/figma/side-panel-header.figma.ts index 48ebb5292..7dd9e7859 100644 --- a/packages/react/figma/side-panel-header.figma.ts +++ b/packages/react/figma/side-panel-header.figma.ts @@ -11,7 +11,7 @@ const description = figma.selectedInstance.getBoolean('Sub Heading', { }); // Unmapped (intentional): -// - Dismiss / Avatar / Download: these populate the `icon` / `actions` +// - Dismiss / Avatar / Download: these populate the `leadingIcon` / `actions` // ReactNode props, but Figma exposes them only as BOOLEANs (no instance // swap) so there is no extractable node to render. // - Variant (Text/Tabs): SidePanel.Header has no variant prop in code.