diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index 59a142675..d7e8e81af 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -60,6 +60,7 @@ const Page = () => { const [selectValue1, setSelectValue1] = useState(''); const [selectValue2, setSelectValue2] = useState(''); const [inputValue, setInputValue] = useState(''); + const [calloutDismissed, setCalloutDismissed] = useState(false); const [rangeValue, setRangeValue] = useState({ from: dayjs('2027-11-15').toDate(), to: dayjs('2027-12-10').toDate() @@ -2764,6 +2765,281 @@ const Page = () => { + + Callout + + + + {/* Types */} + + + Types + + + + Grey callout (default) + + + Success callout + + + Alert callout + + + Gradient callout + + + Accent callout + + + Attention callout + + + Normal callout + + + + + {/* Outline */} + + + Outline + + + Without outline + + + With outline + + + + {/* High contrast */} + + + High contrast + + + Normal contrast + + + High contrast + + + Outline + high contrast + + + + {/* Custom icon */} + + + Custom icon + + }> + Callout with a custom bell icon + + + Callout with no icon + + + + {/* With action */} + + + With action + + + Upgrade + + } + > + You're on the free plan + + + + {/* Dismissible (controlled) */} + + + Dismissible (controlled — consumer removes it in onDismiss) + + {calloutDismissed ? ( + setCalloutDismissed(false)} + > + Restore callout + + ) : ( + setCalloutDismissed(true)} + > + Dismiss me + + )} + + + {/* Custom width */} + + + Custom width + + + width = 240 + + + width = 480 + + + + {/*Figma replicas*/} + + + Figma replicas + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + Button + + } + > + A short message to attract user’s attention + + + A short message to attract user’s attention + + + Button + + } + > + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + A short message to attract user’s attention + + + + Submit button diff --git a/apps/www/src/content/docs/components/callout/index.mdx b/apps/www/src/content/docs/components/callout/index.mdx index d9f19e78b..3e0fe9c86 100644 --- a/apps/www/src/content/docs/components/callout/index.mdx +++ b/apps/www/src/content/docs/components/callout/index.mdx @@ -54,7 +54,9 @@ The Callout component supports high contrast mode for better visibility: ### Dismissible -The Callout component can be made dismissible: +The Callout component can be made dismissible. When `onDismiss` is provided, the +consumer controls removal (the callout stays mounted); when it is omitted, the +callout hides itself. @@ -77,3 +79,4 @@ The Callout component includes appropriate ARIA attributes for accessibility: - Uses semantic HTML elements for proper structure - Dismiss button includes `aria-label` for screen readers - Interactive elements are keyboard accessible +- Dismiss button shows a visible focus ring on keyboard focus diff --git a/apps/www/src/content/docs/components/callout/props.ts b/apps/www/src/content/docs/components/callout/props.ts index d8b30fb1e..606c2d8a7 100644 --- a/apps/www/src/content/docs/components/callout/props.ts +++ b/apps/www/src/content/docs/components/callout/props.ts @@ -39,13 +39,19 @@ export interface CalloutProps { */ icon?: React.ReactNode; - /** Callback function when dismiss button is clicked */ + /** + * Called when the dismiss button is clicked. When provided, the consumer owns + * removal (the callout stays mounted). When omitted, the callout hides itself. + */ onDismiss?: () => void; /** Text content of the callout */ children?: React.ReactNode; - /** Custom width for the callout */ + /** + * Custom width for the callout + * @defaultValue 400 + */ width?: string | number; /** Additional CSS class names */ diff --git a/packages/raystack/components/callout/__tests__/callout.test.tsx b/packages/raystack/components/callout/__tests__/callout.test.tsx index a585ff910..20debd031 100644 --- a/packages/raystack/components/callout/__tests__/callout.test.tsx +++ b/packages/raystack/components/callout/__tests__/callout.test.tsx @@ -126,6 +126,31 @@ describe('Callout', () => { expect(onDismiss).toHaveBeenCalledTimes(1); }); + it('stays mounted after dismiss when onDismiss is provided (controlled)', () => { + const onDismiss = vi.fn(); + render( + + Controlled message + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss message' })); + + // Consumer owns removal — the callout must not hide itself. + expect(screen.getByText('Controlled message')).toBeInTheDocument(); + }); + + it('hides itself after dismiss when onDismiss is omitted (uncontrolled)', () => { + render(Uncontrolled message); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss message' })); + + expect( + screen.queryByText('Uncontrolled message') + ).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + it('renders both action and dismissible button', () => { const onDismiss = vi.fn(); render( @@ -159,6 +184,22 @@ describe('Callout', () => { const callout = screen.getByRole('status'); expect(callout).toHaveStyle({ width: '50%' }); }); + + it('applies width={0} instead of dropping it', () => { + render(Zero width message); + + const callout = screen.getByRole('status'); + expect(callout).toHaveStyle({ width: '0px' }); + }); + + it('falls back to style.width when no width prop is given', () => { + render( + Styled width message + ); + + const callout = screen.getByRole('status'); + expect(callout).toHaveStyle({ width: '200px' }); + }); }); describe('Accessibility', () => { @@ -189,9 +230,11 @@ describe('Callout', () => { expect(dismissButton).toHaveAttribute('type', 'button'); expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss message'); + // The icon is decorative: IconButton wraps it in an aria-hidden element, + // so the button is announced only by its aria-label. const svg = dismissButton.querySelector('svg'); - expect(svg).toHaveAttribute('aria-hidden', 'true'); - expect(svg).toHaveAttribute('role', 'presentation'); + expect(svg).toBeInTheDocument(); + expect(svg?.closest('[aria-hidden="true"]')).toBeInTheDocument(); }); }); }); diff --git a/packages/raystack/components/callout/callout.module.css b/packages/raystack/components/callout/callout.module.css index 1dd78953d..3c73da0e6 100644 --- a/packages/raystack/components/callout/callout.module.css +++ b/packages/raystack/components/callout/callout.module.css @@ -1,6 +1,7 @@ .callout { position: relative; - width: 318px; + width: 400px; + max-width: 100%; padding: var(--rs-space-3); border-radius: var(--rs-radius-2); font-style: normal; @@ -43,12 +44,14 @@ .message { flex-shrink: 1; + min-width: 0; + overflow-wrap: break-word; } .actionsContainer { display: flex; align-items: center; - gap: var(--rs-space-2); + gap: var(--rs-space-3); flex-shrink: 0; } @@ -57,30 +60,22 @@ align-items: center; } -.dismiss { - display: flex; - align-items: center; - justify-content: center; - padding: var(--rs-space-1); - border: none; - background: transparent; - cursor: pointer; - border-radius: var(--rs-radius-1); - color: inherit; -} - -.dismiss:hover { - opacity: 0.7; +/* Dismiss button stays flat: keep the transparent background and base colour on + hover/active (overrides IconButton's hover feedback). The descendant selector + out-specifies `.iconButton:hover` regardless of stylesheet order. */ +.callout .dismiss:hover:not(:disabled), +.callout .dismiss:active:not(:disabled) { + background-color: transparent; + color: var(--rs-color-foreground-base-secondary); } /* Type Variants */ .callout-grey { background: var(--rs-color-background-neutral-primary); - color: var(--rs-color-foreground-base-primary); + color: var(--rs-color-foreground-base-secondary); } .callout-success { - gap: var(--rs-space-10); background: var(--rs-color-background-success-primary); color: var(--rs-color-foreground-success-primary); } @@ -101,7 +96,11 @@ } .callout-gradient { - background: radial-gradient(circle, oklch(0.5674 0.2831 312.58 / 0.2) 0%, oklch(0.5988 0.2445 29.12 / 0.2) 100%); + background: radial-gradient( + circle, + oklch(0.5674 0.2831 312.58 / 0.2) 0%, + oklch(0.5988 0.2445 29.12 / 0.2) 100% + ); color: var(--rs-color-foreground-base-primary); } @@ -112,7 +111,7 @@ .callout-outline.callout-grey { background: var(--rs-color-background-neutral-primary); - border: 0.5px solid var(--rs-color-border-base-primary); + border: 0.5px solid var(--rs-color-border-base-tertiary); } .callout-outline.callout-success { @@ -165,7 +164,7 @@ /* Success + Outline + High Contrast */ .callout-outline.callout-high-contrast.callout-success { background: var(--rs-color-background-success-primary); - border: 0.5px solid var(--rs-color-border-success-emphasis); + border: 0.5px solid var(--rs-color-border-success-primary); color: var(--rs-color-foreground-base-primary); } @@ -189,17 +188,33 @@ .callout-outline.callout-high-contrast.callout-attention { background: var(--rs-color-background-attention-primary); - border: 0.5px solid var(--rs-color-border-base-tertiary); + border: 0.5px solid var(--rs-color-border-attention-primary); color: var(--rs-color-foreground-base-primary); } +/* + * Gradient + outline (no high contrast): keep the gradient fill and add a + * translucent border tinted to the gradient's warm endpoint. Intended behavior. + * The border is a one-off literal (no token) because the gradient itself is + * untokenized — keep the two in sync if the gradient stops ever change. + */ .callout-outline.callout-gradient { - background: radial-gradient(circle, oklch(0.5674 0.2831 312.58 / 0.2) 0%, oklch(0.5988 0.2445 29.12 / 0.2) 100%); + background: radial-gradient( + circle, + oklch(0.5674 0.2831 312.58 / 0.2) 0%, + oklch(0.5988 0.2445 29.12 / 0.2) 100% + ); border: 0.5px solid oklch(0.5988 0.2445 29.12 / 0.2667); color: var(--rs-color-foreground-base-primary); } -/* High contrast gradient should match normal high contrast */ +/* + * High contrast gradient should match normal high contrast. + * Figma defines only one gradient variant (no outline/high-contrast states), so + * in high contrast — both on its own and combined with outline — we intentionally + * drop the gradient for the solid "normal" high-contrast surface. A gradient + * callout in high-contrast mode therefore reads identically to a normal one. + */ .callout-high-contrast.callout-gradient { background: var(--rs-color-background-base-primary); color: var(--rs-color-foreground-base-primary); @@ -214,13 +229,13 @@ /* Normal variants */ .callout-normal { background: var(--rs-color-background-base-primary); - color: var(--rs-color-foreground-base-primary); + color: var(--rs-color-foreground-base-secondary); } .callout-outline.callout-normal { background: var(--rs-color-background-base-primary); - border: 0.5px solid var(--rs-color-border-base-primary); - color: var(--rs-color-foreground-base-primary); + border: 0.5px solid var(--rs-color-border-base-tertiary); + color: var(--rs-color-foreground-base-secondary); } .callout-high-contrast.callout-normal { @@ -232,4 +247,4 @@ background: var(--rs-color-background-base-primary); border: 0.5px solid var(--rs-color-border-base-tertiary); color: var(--rs-color-foreground-base-primary); -} \ No newline at end of file +} diff --git a/packages/raystack/components/callout/callout.tsx b/packages/raystack/components/callout/callout.tsx index 2594eed18..bf88830a0 100644 --- a/packages/raystack/components/callout/callout.tsx +++ b/packages/raystack/components/callout/callout.tsx @@ -1,9 +1,15 @@ 'use client'; -import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { Cross1Icon, InfoCircledIcon } from '@radix-ui/react-icons'; import { cva, type VariantProps } from 'class-variance-authority'; -import { type ComponentProps, type CSSProperties, type ReactNode } from 'react'; +import { + type ComponentProps, + type CSSProperties, + type ReactNode, + useState +} from 'react'; +import { IconButton } from '../icon-button'; import styles from './callout.module.css'; const callout = cva(styles.callout, { @@ -23,9 +29,6 @@ const callout = cva(styles.callout, { highContrast: { true: styles['callout-high-contrast'] } - }, - defaultVariants: { - type: 'grey' } }); @@ -34,7 +37,12 @@ export interface CalloutProps VariantProps { children: ReactNode; action?: ReactNode; + /** Show a dismiss (close) button. */ dismissible?: boolean; + /** + * Called when the dismiss button is clicked. When provided, the consumer owns + * removal (the callout stays mounted). When omitted, the callout hides itself. + */ onDismiss?: () => void; width?: string | number; style?: CSSProperties; @@ -55,27 +63,29 @@ export function Callout({ icon = , ...props }: CalloutProps) { + // Dismissal is controlled when `onDismiss` is given; otherwise fall back to + // uncontrolled and hide the callout internally. + const [dismissed, setDismissed] = useState(false); + const handleDismiss = () => { + onDismiss?.(); + if (!onDismiss) setDismissed(true); + }; + if (dismissed) return null; + + // Resolve up front so `width={0}` is kept (a `width && …` guard would drop it). + const resolvedWidth = typeof width === 'number' ? `${width}px` : width; const combinedStyle = { ...style, - ...(width && { width: typeof width === 'number' ? `${width}px` : width }) + width: resolvedWidth ?? style?.width }; - const getRole = () => { - switch (type) { - case 'alert': - return 'alert'; - case 'success': - return 'status'; - default: - return 'status'; - } - }; + const role = type === 'alert' ? 'alert' : 'status'; return ( @@ -92,29 +102,14 @@ export function Callout({ {action && {action}} {dismissible && ( - - - - - + + )}