feat: Toast support for leadingIcon and update default styles#764
feat: Toast support for leadingIcon and update default styles#764rohanchkrabrty merged 7 commits intomainfrom
Conversation
- Add `leadingIcon` prop to toastManager.add/update and Toast.createToastManager by lifting it onto Base UI's typed `data` slot via a wrapper. - Render the leading icon before the title; color is driven by toast `type` (success/error/warning/info), the toast container itself stays neutral. - Drop typed background/border/text-color overrides so success/error/info/ warning toasts share the default surface — only the icon color changes. - Tighten content alignment: center title-only toasts, top-align when a description is present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Map success/error/warning/info/loading toast types to Radix icons (CheckCircled / CrossCircled / ExclamationTriangle / InfoCircled) and the apsara Spinner for loading. Untyped toasts fall back to InfoCircledIcon with the existing base-secondary color. Explicit `leadingIcon` still wins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Description text color: base-secondary -> base-primary (Figma node 3594:25050 specifies foreground-base-primary). - Restructure content into a header row (icon + title + actions) followed by a separate description row indented 24px (rs-space-7), matching the Figma column layout instead of stacking title/desc next to the icon. - Header row gets gap=5 between left and actions, gap=3 between icon and title; min-height = rs-space-7 to keep title-only toasts consistent. - Title now always uses .title styling (was swapping to .description styling for title-only toasts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a toast has a title but no description, render the title with .description style (12px regular) rather than .title style (14px medium) so the toast doesn't look outsized for a single short message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a playground demo with controls for title, description, type, and an actionButton boolean toggle. Empty title/description strings are omitted from the toastManager.add call so users can test partial configurations. Replaces the static preview Demo at the top of the toast docs page with the playground. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis pull request enhances the Toast component system by introducing a Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/www/src/content/docs/components/toast/props.ts (1)
20-33:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winDocument the new
toastManagerprovider prop.
ToastProviderPropsstill omitstoastManager, even thoughpackages/raystack/components/toast/toast-provider.tsxnow accepts it. That means the docs won't surface the new scoped-manager API added in this PR.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/www/src/content/docs/components/toast/props.ts` around lines 20 - 33, The ToastProviderProps interface is missing the new toastManager prop that toast-provider.tsx now accepts; add a documented optional toastManager property to ToastProviderProps (matching the type used/expected by the provider in packages/raystack/components/toast/toast-provider.tsx) and include a JSDoc comment explaining it provides a scoped manager for adding/removing toasts so the docs surface the new scoped-manager API. Ensure the prop name is exactly toastManager and its type aligns with the provider's expected manager interface/constructor.
🧹 Nitpick comments (1)
apps/www/src/content/docs/components/toast/demo.ts (1)
338-360: ⚡ Quick winInline component definition in
hookDemoteaches a React anti-pattern.
Inneris defined insideHookDemo, so React sees a brand-new function reference on every render ofHookDemoand unmounts + remountsInnerevery time. Users copying this snippet verbatim will carry the pattern into production.The comment
// Hook usage lives in an inner component so it runs inside the Provider.is correct in intent; movingInneroutside preserves the teaching point while being idiomatic.♻️ Proposed fix
export const hookDemo = { type: 'code', code: ` + function Inner() { + const { add, toasts } = useToastManager(); + return ( + <Flex direction="column" gap="medium"> + <Button onClick={() => add({ + title: "Triggered via hook", + description: "Same leadingIcon-aware API as the singleton manager.", + type: "success" + })}> + Show toast + </Button> + <span>Active toasts: {toasts.length}</span> + </Flex> + ) + } + function HookDemo() { - // Hook usage lives in an inner component so it runs inside the Provider. - function Inner() { - const { add, toasts } = useToastManager(); - return ( - <Flex direction="column" gap="medium"> - <Button onClick={() => add({ - title: "Triggered via hook", - description: "Same leadingIcon-aware API as the singleton manager.", - type: "success" - })}> - Show toast - </Button> - <span>Active toasts: {toasts.length}</span> - </Flex> - ) - } + // Inner is a sibling component so it can use useToastManager() inside the Provider. return ( <Toast.Provider> <Inner /> </Toast.Provider> ) }` };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/www/src/content/docs/components/toast/demo.ts` around lines 338 - 360, The Inner component should be moved out of HookDemo to avoid recreating the function on every render; define Inner as a top-level component that calls useToastManager (keeping the same JSX and Button handler) and then have HookDemo simply return <Toast.Provider><Inner /></Toast.Provider>; ensure Inner still uses the same identifiers (Inner, HookDemo, useToastManager, Toast.Provider) so the demo behavior and hook usage inside the provider remain identical while eliminating the inline-component anti-pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/www/src/content/docs/components/toast/demo.ts`:
- Around line 9-11: The generated code snippets are malformed when title or
description contain quotes/backslashes; change the string interpolation in the
opts.push calls so the values are safely escaped (e.g., use
JSON.stringify(title) and JSON.stringify(description) instead of
`"${title}"`/`"${description}"`) when building the options array (the lines that
call opts.push for title and description), so the produced snippet contains a
properly escaped JS string.
In `@apps/www/src/content/docs/components/toast/index.mdx`:
- Around line 45-53: The docs import wrongly assumes a top-level named export
createToastManager from '@raystack/apsara'; fix by either updating the example
to use the actual API (import { Toast } from '@raystack/apsara' and call
Toast.createToastManager()) or, if you prefer a top-level helper, add a named
re-export: export createToastManager from the toast implementation (where
createToastManager is defined) and re-export it from the package entry so
consumers can import { createToastManager } from '@raystack/apsara'; ensure the
symbol createToastManager is exported alongside Toast to keep both usages
working.
In `@packages/raystack/components/toast/__tests__/toast.test.tsx`:
- Around line 228-250: The tests around leading icons in toast need to assert
the correct contract of ToastRoot: when leadingIcon is omitted the component
should render the default icon, and when leadingIcon is explicitly null it
should not render the leading-icon slot; update the two specs that call
toastManager.add(...) to target the leading-icon slot explicitly (e.g. via the
test id or data attribute used for the slot such as "leading-icon" or a specific
slot selector) instead of using a broad aria-hidden selector, so the first test
expects the leading-icon element to exist and the null-case test asserts that
querying the explicit leading-icon element returns null/not present.
In `@packages/raystack/components/toast/toast-root.tsx`:
- Around line 59-67: Replace truthy boolean coercion with nullish checks for
ReactNode props: change hasBoth from "!!toast.title && !!toast.description" to
"toast.title != null && toast.description != null" and update any conditional
renders that use "title && ..." or "description && ..." (including the block
around lines 95-100) to use "title != null" / "description != null" so valid
values like 0 or '' still render; similarly, where code distinguishes
leadingIcon omission vs explicit opt-out, ensure checks distinguish undefined vs
null (use "userIcon === null" to opt-out and "userIcon === undefined" to fall
back) rather than truthy checks.
---
Outside diff comments:
In `@apps/www/src/content/docs/components/toast/props.ts`:
- Around line 20-33: The ToastProviderProps interface is missing the new
toastManager prop that toast-provider.tsx now accepts; add a documented optional
toastManager property to ToastProviderProps (matching the type used/expected by
the provider in packages/raystack/components/toast/toast-provider.tsx) and
include a JSDoc comment explaining it provides a scoped manager for
adding/removing toasts so the docs surface the new scoped-manager API. Ensure
the prop name is exactly toastManager and its type aligns with the provider's
expected manager interface/constructor.
---
Nitpick comments:
In `@apps/www/src/content/docs/components/toast/demo.ts`:
- Around line 338-360: The Inner component should be moved out of HookDemo to
avoid recreating the function on every render; define Inner as a top-level
component that calls useToastManager (keeping the same JSX and Button handler)
and then have HookDemo simply return <Toast.Provider><Inner /></Toast.Provider>;
ensure Inner still uses the same identifiers (Inner, HookDemo, useToastManager,
Toast.Provider) so the demo behavior and hook usage inside the provider remain
identical while eliminating the inline-component anti-pattern.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 4303d83f-d254-4174-ade2-e3a6fd36c146
📒 Files selected for processing (14)
apps/www/src/app/examples/combobox/page.tsxapps/www/src/content/docs/components/toast/demo.tsapps/www/src/content/docs/components/toast/index.mdxapps/www/src/content/docs/components/toast/props.tsdocs/V1-migration.mdpackages/raystack/components/toast/__tests__/toast.test.tsxpackages/raystack/components/toast/index.tspackages/raystack/components/toast/toast-manager.tspackages/raystack/components/toast/toast-provider.tsxpackages/raystack/components/toast/toast-root.tsxpackages/raystack/components/toast/toast.module.csspackages/raystack/components/toast/toast.tsxpackages/raystack/index.tsxpackages/raystack/styles/primitives/z-index.css
| if (title && title !== '') opts.push(`title: "${title}"`); | ||
| if (description && description !== '') | ||
| opts.push(`description: "${description}"`); |
There was a problem hiding this comment.
Unescaped interpolation in getCode will produce malformed code snippets.
If a user types a double-quote in the title or description playground controls (e.g., He said "hello"), the generated snippet becomes title: "He said "hello"" — syntactically invalid JavaScript. A backslash has the same effect.
🛠️ Proposed fix
- if (title && title !== '') opts.push(`title: "${title}"`);
- if (description && description !== '')
- opts.push(`description: "${description}"`);
+ const escape = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ if (title && title !== '') opts.push(`title: "${escape(title)}"`);
+ if (description && description !== '')
+ opts.push(`description: "${escape(description)}"`);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (title && title !== '') opts.push(`title: "${title}"`); | |
| if (description && description !== '') | |
| opts.push(`description: "${description}"`); | |
| const escape = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); | |
| if (title && title !== '') opts.push(`title: "${escape(title)}"`); | |
| if (description && description !== '') | |
| opts.push(`description: "${escape(description)}"`); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/www/src/content/docs/components/toast/demo.ts` around lines 9 - 11, The
generated code snippets are malformed when title or description contain
quotes/backslashes; change the string interpolation in the opts.push calls so
the values are safely escaped (e.g., use JSON.stringify(title) and
JSON.stringify(description) instead of `"${title}"`/`"${description}"`) when
building the options array (the lines that call opts.push for title and
description), so the produced snippet contains a properly escaped JS string.
| ```tsx | ||
| import { Toast, createToastManager } from '@raystack/apsara' | ||
|
|
||
| const manager = createToastManager() | ||
|
|
||
| <Toast.Provider toastManager={manager}> | ||
| <App /> | ||
| </Toast.Provider> | ||
| ``` |
There was a problem hiding this comment.
createToastManager is not a named export — docs import will fail for users.
createToastManager is only accessible as Toast.createToastManager; neither packages/raystack/index.tsx nor packages/raystack/components/toast/index.ts expose it as a standalone named export. A user who copies this import will get a TypeScript/bundler error. The position demo code snippets (e.g., const manager = Toast.createToastManager()) already use the correct form.
Either fix the docs or add createToastManager to the named exports.
📝 Option A — Fix the docs to match the actual API
-```tsx
-import { Toast, createToastManager } from '@raystack/apsara'
-
-const manager = createToastManager()
-
-<Toast.Provider toastManager={manager}>
- <App />
-</Toast.Provider>
-```
+```tsx
+import { Toast } from '@raystack/apsara'
+
+const manager = Toast.createToastManager()
+
+<Toast.Provider toastManager={manager}>
+ <App />
+</Toast.Provider>
+```📝 Option B — Add the named export (if the design intent is a top-level export)
In packages/raystack/components/toast/index.ts:
-export { Toast, toastManager, useToastManager } from './toast';
+export { Toast, toastManager, useToastManager, createToastManager } from './toast';And in packages/raystack/index.tsx:
-export { Toast, toastManager, useToastManager } from './components/toast';
+export { Toast, toastManager, useToastManager, createToastManager } from './components/toast';Then in packages/raystack/components/toast/toast.tsx add the named re-export:
-export { toastManager, useToastManager } from './toast-manager';
+export { toastManager, useToastManager, createToastManager } from './toast-manager';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/www/src/content/docs/components/toast/index.mdx` around lines 45 - 53,
The docs import wrongly assumes a top-level named export createToastManager from
'@raystack/apsara'; fix by either updating the example to use the actual API
(import { Toast } from '@raystack/apsara' and call Toast.createToastManager())
or, if you prefer a top-level helper, add a named re-export: export
createToastManager from the toast implementation (where createToastManager is
defined) and re-export it from the package entry so consumers can import {
createToastManager } from '@raystack/apsara'; ensure the symbol
createToastManager is exported alongside Toast to keep both usages working.
| it('does not render leading-icon slot when leadingIcon is omitted', async () => { | ||
| act(() => { | ||
| toastManager.add({ title: 'No icon' }); | ||
| }); | ||
|
|
||
| expect(await screen.findByText('No icon')).toBeInTheDocument(); | ||
| expect(screen.queryByTestId('leading-icon')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('renders no icon when leadingIcon is explicitly null, even with a type default', async () => { | ||
| act(() => { | ||
| toastManager.add({ | ||
| title: 'No icon success', | ||
| type: 'success', | ||
| leadingIcon: null | ||
| }); | ||
| }); | ||
|
|
||
| const toastEl = await screen.findByText('No icon success'); | ||
| const root = toastEl.closest('[data-type="success"]'); | ||
| expect(root).toBeInTheDocument(); | ||
| // The leading-icon wrapper (the only aria-hidden span in the toast) should not render. | ||
| expect(root!.querySelector('[aria-hidden="true"]')).toBeNull(); |
There was a problem hiding this comment.
These assertions don't protect the actual leading-icon contract.
When leadingIcon is omitted, ToastRoot falls back to the default info icon, so the "does not render leading-icon slot" test is asserting the wrong behavior. Also, [aria-hidden="true"] is too broad for the null case and can match unrelated hidden elements inside the toast. Please target the leading-icon slot explicitly in both cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/toast/__tests__/toast.test.tsx` around lines 228
- 250, The tests around leading icons in toast need to assert the correct
contract of ToastRoot: when leadingIcon is omitted the component should render
the default icon, and when leadingIcon is explicitly null it should not render
the leading-icon slot; update the two specs that call toastManager.add(...) to
target the leading-icon slot explicitly (e.g. via the test id or data attribute
used for the slot such as "leading-icon" or a specific slot selector) instead of
using a broad aria-hidden selector, so the first test expects the leading-icon
element to exist and the null-case test asserts that querying the explicit
leading-icon element returns null/not present.
| // Promote description into the title slot when title is missing so the icon | ||
| // and headline sit on the same row. The second row only renders when both | ||
| // are present. | ||
| const title = toast.title ?? toast.description; | ||
| const hasBoth = !!toast.title && !!toast.description; | ||
| // `leadingIcon: undefined` (omitted) → fall back to the type default. | ||
| // `leadingIcon: null` → explicit opt-out, render nothing. | ||
| // anything else → use what the user provided. | ||
| const userIcon = (toast.data as ToastData | undefined)?.leadingIcon; |
There was a problem hiding this comment.
Use nullish checks here instead of truthiness.
title/description are ReactNode, so valid falsy values like 0 or '' currently disappear: hasBoth becomes false and {title && ...} skips rendering entirely. Drive this with != null checks instead.
💡 Suggested fix
- const title = toast.title ?? toast.description;
- const hasBoth = !!toast.title && !!toast.description;
+ const hasTitle = toast.title != null;
+ const hasDescription = toast.description != null;
+ const title = hasTitle ? toast.title : toast.description;
+ const hasBoth = hasTitle && hasDescription;
@@
- {title && (
+ {title != null && (
<ToastPrimitive.Title
className={hasBoth ? styles.title : styles.description}
>Also applies to: 95-100
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/raystack/components/toast/toast-root.tsx` around lines 59 - 67,
Replace truthy boolean coercion with nullish checks for ReactNode props: change
hasBoth from "!!toast.title && !!toast.description" to "toast.title != null &&
toast.description != null" and update any conditional renders that use "title &&
..." or "description && ..." (including the block around lines 95-100) to use
"title != null" / "description != null" so valid values like 0 or '' still
render; similarly, where code distinguishes leadingIcon omission vs explicit
opt-out, ensure checks distinguish undefined vs null (use "userIcon === null" to
opt-out and "userIcon === undefined" to fall back) rather than truthy checks.
Summary
leadingIconprop totoastManager.add/updateandToast.createToastManager, lifting it onto Base UI's typeddataslot via a wrapper.Spinnerfor loading); explicitleadingIconstill wins.foreground-base-primary.