diff --git a/.changeset/curvy-plums-call.md b/.changeset/curvy-plums-call.md new file mode 100644 index 0000000000..349a8f23ac --- /dev/null +++ b/.changeset/curvy-plums-call.md @@ -0,0 +1,5 @@ +--- +'@sumup-oss/circuit-ui': minor +--- + +Added the experimental Callout component for static inline guidance or emphasis. diff --git a/packages/circuit-ui/components/Callout/Callout.mdx b/packages/circuit-ui/components/Callout/Callout.mdx new file mode 100644 index 0000000000..d3f95cafaa --- /dev/null +++ b/packages/circuit-ui/components/Callout/Callout.mdx @@ -0,0 +1,49 @@ +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; +import * as Stories from './Callout.stories'; + + + +# Callout + + + +The `Callout` component renders static guidance or emphasis content within the content flow. + + + + +## When to use it + +Use callouts to highlight persistent guidance, contextual tips, or extra information that belongs next to related content. + +Keep callout copy concise and static. For semantic feedback to user actions, use [`NotificationInline`](Notifications/NotificationInline). For dismissible content or content with a call to action, use [`NotificationBanner`](Notifications/NotificationBanner). + +## How to use it + +### Variants + + + +- Use the 'info' variant for informational, neutral messages. +- Use the 'success' variant for successful messages. +- Use the 'warning' variant for warning messages, and other information a user should be aware of. +- Use the 'danger' variant for error messages. Be descriptive and give users clear next steps. +- Use the 'promo' variant for promotional or upgrade-related messages. + +### Links + +The callout body supports links to related content. Avoid placing other interactive content inside a callout. + + + +## Accessibility + +### Best practices + +#### Place callouts close to their related content + +Callouts are part of the content flow, and should be placed near their related content. Make use of headings and semantic markup to programmatically group content into meaningful sections. + +#### Avoid dynamically rendering a callout + +The `Callout` component is meant for static content. If you need to render a message dynamically, you most likely want to use the [`NotificationInline`](Notifications/NotificationInline) component instead. diff --git a/packages/circuit-ui/components/Callout/Callout.module.css b/packages/circuit-ui/components/Callout/Callout.module.css new file mode 100644 index 0000000000..bf14a68e3a --- /dev/null +++ b/packages/circuit-ui/components/Callout/Callout.module.css @@ -0,0 +1,68 @@ +.base { + display: flex; + flex-direction: row; + align-items: center; + padding: var(--cui-spacings-kilo) var(--cui-spacings-mega); + border-radius: var(--cui-border-radius-kilo); +} + +.icon { + position: relative; + flex-grow: 0; + flex-shrink: 0; + align-self: flex-start; + line-height: 0; +} + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; + padding-left: var(--cui-spacings-mega); + font-size: var(--cui-body-m-font-size); + font-weight: var(--cui-font-weight-regular); + line-height: var(--cui-body-m-line-height); + letter-spacing: var(--cui-letter-spacing); +} + +/* Variants */ + +.info { + background-color: var(--cui-bg-subtle); +} + +.info .icon { + color: var(--cui-fg-accent); +} + +.success { + background-color: var(--cui-bg-success); +} + +.success .icon { + color: var(--cui-fg-success); +} + +.warning { + background-color: var(--cui-bg-warning); +} + +.warning .icon { + color: var(--cui-fg-warning); +} + +.danger { + background-color: var(--cui-bg-danger); +} + +.danger .icon { + color: var(--cui-fg-danger); +} + +.promo { + background-color: var(--cui-bg-promo); +} + +.promo .icon { + color: var(--cui-fg-promo); +} diff --git a/packages/circuit-ui/components/Callout/Callout.spec.tsx b/packages/circuit-ui/components/Callout/Callout.spec.tsx new file mode 100644 index 0000000000..931c2d84d9 --- /dev/null +++ b/packages/circuit-ui/components/Callout/Callout.spec.tsx @@ -0,0 +1,81 @@ +/** + * Copyright 2026, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { IconProps } from '@sumup-oss/icons'; + +import { axe, render, screen } from '../../util/test-utils.js'; + +import { Callout, type CalloutProps } from './Callout.js'; + +describe('Callout', () => { + const renderCallout = (props: CalloutProps) => render(); + + const baseProps: CalloutProps = { + body: 'This is a callout', + }; + + it('should render the callout', () => { + renderCallout(baseProps); + + expect(screen.getByText('This is a callout')).toBeVisible(); + }); + + it.each([ + 'info', + 'success', + 'warning', + 'danger', + 'promo', + ] as const)('should render the %s variant', (variant) => { + const { container } = renderCallout({ ...baseProps, variant }); + + expect(container.firstElementChild?.className).toContain(`_${variant}_`); + }); + + it('should render a custom icon', () => { + const CustomIcon = (props: IconProps) => ( + + ); + + renderCallout({ ...baseProps, icon: CustomIcon }); + + const icon = screen.getByTestId('custom-icon'); + + expect(icon).toBeInTheDocument(); + }); + + it('should render the icon label visually hidden', () => { + renderCallout({ ...baseProps, iconLabel: 'Information' }); + + expect(screen.getByText('Information')).toBeInTheDocument(); + }); + + it('should forward a ref', () => { + const ref = createRef(); + const { container } = render(); + + expect(ref.current).toBe(container.firstChild); + }); + + it('should have no accessibility violations', async () => { + const { container } = renderCallout(baseProps); + const actual = await axe(container); + + expect(actual).toHaveNoViolations(); + }); +}); diff --git a/packages/circuit-ui/components/Callout/Callout.stories.tsx b/packages/circuit-ui/components/Callout/Callout.stories.tsx new file mode 100644 index 0000000000..12eaa5c1d9 --- /dev/null +++ b/packages/circuit-ui/components/Callout/Callout.stories.tsx @@ -0,0 +1,65 @@ +/** + * Copyright 2026, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Stack } from '../../../../.storybook/components/index.js'; +import { Anchor } from '../Anchor/Anchor.js'; + +import { Callout, type CalloutProps } from './Callout.js'; + +export default { + title: 'Notifications/Callout', + component: Callout, + tags: ['status:experimental'], +}; + +const variants = ['info', 'success', 'warning', 'danger', 'promo'] as const; + +export const Base = (args: CalloutProps) => ; + +Base.args = { + body: 'Callout is a newly available component for static inline guidance or emphasis.', + variant: 'promo', +} as CalloutProps; + +Base.parameters = { + chromatic: { + // covered in the Variants story + disableSnapshot: true, + }, +}; + +export const Variants = (args: CalloutProps) => ( + + {variants.map((variant) => ( + + ))} + +); + +Variants.args = { + body: 'This is a callout message', +} as CalloutProps; + +export const WithRichContent = (args: CalloutProps) => ; + +WithRichContent.args = { + body: ( +
+ Callouts can include{' '} + links to related content. +
+ ), + variant: 'promo', +} as CalloutProps; diff --git a/packages/circuit-ui/components/Callout/Callout.tsx b/packages/circuit-ui/components/Callout/Callout.tsx new file mode 100644 index 0000000000..6ec12bf53d --- /dev/null +++ b/packages/circuit-ui/components/Callout/Callout.tsx @@ -0,0 +1,95 @@ +/** + * Copyright 2026, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; + +import { + Alert, + Confirm, + Info, + Notify, + Sparkles, + type IconComponentType, +} from '@sumup-oss/icons'; + +import { clsx } from '../../styles/clsx.js'; +import { utilClasses } from '../../styles/utility.js'; + +import classes from './Callout.module.css'; + +export type CalloutVariant = + | 'info' + | 'success' + | 'warning' + | 'danger' + | 'promo'; + +const CALLOUT_ICONS: Record> = { + info: Info, + success: Confirm, + warning: Notify, + danger: Alert, + promo: Sparkles, +}; + +export interface CalloutProps extends HTMLAttributes { + /** + * The callout's variant. + * @default 'info' + */ + variant?: CalloutVariant; + /** + * The callout's body content. + */ + body: ReactNode; + /** + * The icon displayed next to the body. + */ + icon?: IconComponentType<'24'>; + /** + * A text replacement for the icon in the context of the callout, if its body + * copy isn't self-explanatory. Defaults to an empty string. + */ + iconLabel?: string; +} + +/** + * The `Callout` component renders static inline guidance or emphasis within + * the content flow. + */ +export const Callout = forwardRef( + ( + { variant = 'info', body, icon, iconLabel = '', className, ...props }, + ref, + ) => { + const Icon = icon || CALLOUT_ICONS[variant]; + + return ( +
+
+
+ {iconLabel} +
{body}
+
+ ); + }, +); + +Callout.displayName = 'Callout'; diff --git a/packages/circuit-ui/components/Callout/index.tsx b/packages/circuit-ui/components/Callout/index.tsx new file mode 100644 index 0000000000..42957dee56 --- /dev/null +++ b/packages/circuit-ui/components/Callout/index.tsx @@ -0,0 +1,17 @@ +/** + * Copyright 2026, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Callout } from './Callout.js'; +export type { CalloutProps, CalloutVariant } from './Callout.js'; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index e989748ec9..124005d0bf 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -86,6 +86,11 @@ export type { SelectorGroupProps } from './components/SelectorGroup/index.js'; export type { ClickEvent } from './types/events.js'; // Notifications +export { Callout } from './components/Callout/index.js'; +export type { + CalloutProps, + CalloutVariant, +} from './components/Callout/index.js'; export { NotificationBanner } from './components/NotificationBanner/index.js'; export type { NotificationBannerProps } from './components/NotificationBanner/index.js'; export { NotificationFullscreen } from './components/NotificationFullscreen/index.js'; diff --git a/skills/circuit-ui/references/components.md b/skills/circuit-ui/references/components.md index 8773e01db1..8fc429f231 100644 --- a/skills/circuit-ui/references/components.md +++ b/skills/circuit-ui/references/components.md @@ -91,6 +91,7 @@ | Component | Status | Package | Source export | Usage reference | | --- | --- | --- | --- | --- | +| `Callout` | `experimental` | `@sumup-oss/circuit-ui` | `./components/Callout/index.js` | [Read MDX reference](components/Callout.mdx) | | `NotificationBanner` | `stable` | `@sumup-oss/circuit-ui` | `./components/NotificationBanner/index.js` | [Read MDX reference](components/NotificationBanner.mdx) | | `NotificationFullscreen` | `stable` | `@sumup-oss/circuit-ui` | `./components/NotificationFullscreen/index.js` | [Read MDX reference](components/NotificationFullscreen.mdx) | | `NotificationInline` | `stable` | `@sumup-oss/circuit-ui` | `./components/NotificationInline/index.js` | [Read MDX reference](components/NotificationInline.mdx) | diff --git a/skills/circuit-ui/references/components/Callout.mdx b/skills/circuit-ui/references/components/Callout.mdx new file mode 100644 index 0000000000..d3f95cafaa --- /dev/null +++ b/skills/circuit-ui/references/components/Callout.mdx @@ -0,0 +1,49 @@ +import { Meta, Status, Props, Story } from '../../../../.storybook/components'; +import * as Stories from './Callout.stories'; + + + +# Callout + + + +The `Callout` component renders static guidance or emphasis content within the content flow. + + + + +## When to use it + +Use callouts to highlight persistent guidance, contextual tips, or extra information that belongs next to related content. + +Keep callout copy concise and static. For semantic feedback to user actions, use [`NotificationInline`](Notifications/NotificationInline). For dismissible content or content with a call to action, use [`NotificationBanner`](Notifications/NotificationBanner). + +## How to use it + +### Variants + + + +- Use the 'info' variant for informational, neutral messages. +- Use the 'success' variant for successful messages. +- Use the 'warning' variant for warning messages, and other information a user should be aware of. +- Use the 'danger' variant for error messages. Be descriptive and give users clear next steps. +- Use the 'promo' variant for promotional or upgrade-related messages. + +### Links + +The callout body supports links to related content. Avoid placing other interactive content inside a callout. + + + +## Accessibility + +### Best practices + +#### Place callouts close to their related content + +Callouts are part of the content flow, and should be placed near their related content. Make use of headings and semantic markup to programmatically group content into meaningful sections. + +#### Avoid dynamically rendering a callout + +The `Callout` component is meant for static content. If you need to render a message dynamically, you most likely want to use the [`NotificationInline`](Notifications/NotificationInline) component instead.