From 508bd1adbfcc8cd9867da843299d0bda70ed0ed9 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 26 Jan 2026 09:01:49 +0000 Subject: [PATCH] First pass introducing the Alert component for BUI Signed-off-by: Charles de Dreuille --- .changeset/plenty-monkeys-share.md | 7 + packages/ui/report.api.md | 65 ++++ .../ui/src/components/Alert/Alert.module.css | 143 ++++++++ .../ui/src/components/Alert/Alert.stories.tsx | 334 ++++++++++++++++++ packages/ui/src/components/Alert/Alert.tsx | 143 ++++++++ .../ui/src/components/Alert/definition.ts | 61 ++++ packages/ui/src/components/Alert/index.ts | 19 + packages/ui/src/components/Alert/types.ts | 40 +++ packages/ui/src/index.ts | 3 +- 9 files changed, 814 insertions(+), 1 deletion(-) create mode 100644 .changeset/plenty-monkeys-share.md create mode 100644 packages/ui/src/components/Alert/Alert.module.css create mode 100644 packages/ui/src/components/Alert/Alert.stories.tsx create mode 100644 packages/ui/src/components/Alert/Alert.tsx create mode 100644 packages/ui/src/components/Alert/definition.ts create mode 100644 packages/ui/src/components/Alert/index.ts create mode 100644 packages/ui/src/components/Alert/types.ts diff --git a/.changeset/plenty-monkeys-share.md b/.changeset/plenty-monkeys-share.md new file mode 100644 index 0000000000..11c8d0a0fa --- /dev/null +++ b/.changeset/plenty-monkeys-share.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added new `Alert` component with support for status variants, icons, loading states, and custom actions. + +**Affected components**: Alert diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index fdd781c80a..ec48899993 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -124,6 +124,71 @@ export interface AccordionTriggerProps extends HeadingProps { title?: string; } +// @public +export const Alert: ForwardRefExoticComponent< + AlertProps & RefAttributes +>; + +// @public +export const AlertDefinition: { + readonly styles: { + readonly [key: string]: string; + }; + readonly classNames: { + readonly root: 'bui-Alert'; + readonly content: 'bui-AlertContent'; + readonly icon: 'bui-AlertIcon'; + readonly spinner: 'bui-AlertSpinner'; + readonly actions: 'bui-AlertActions'; + }; + readonly surface: 'container'; + readonly propDefs: { + readonly status: { + readonly dataAttribute: true; + readonly default: 'info'; + }; + readonly loading: { + readonly dataAttribute: true; + }; + readonly icon: {}; + readonly customActions: {}; + readonly surface: {}; + readonly children: {}; + readonly className: {}; + readonly style: {}; + }; + readonly utilityProps: readonly [ + 'm', + 'mb', + 'ml', + 'mr', + 'mt', + 'mx', + 'my', + 'p', + 'pb', + 'pl', + 'pr', + 'pt', + 'px', + 'py', + ]; +}; + +// @public (undocumented) +export type AlertOwnProps = ContainerSurfaceProps & { + status?: Responsive<'info' | 'success' | 'warning' | 'danger'>; + icon?: boolean | ReactElement; + loading?: boolean; + customActions?: ReactNode; + children?: ReactNode; + className?: string; + style?: CSSProperties; +}; + +// @public +export interface AlertProps extends SpaceProps, AlertOwnProps {} + // @public (undocumented) export type AlignItems = 'stretch' | 'start' | 'center' | 'end'; diff --git a/packages/ui/src/components/Alert/Alert.module.css b/packages/ui/src/components/Alert/Alert.module.css new file mode 100644 index 0000000000..2fcfdb8b43 --- /dev/null +++ b/packages/ui/src/components/Alert/Alert.module.css @@ -0,0 +1,143 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .bui-Alert { + --loading-duration: 200ms; + --alert-bg: var(--bui-bg-surface-1); + --alert-fg: var(--bui-fg-primary); + + position: relative; + display: flex; + align-items: flex-start; + gap: var(--bui-space-3); + padding: var(--bui-space-3) var(--bui-space-4); + border-radius: var(--bui-radius-2); + font-family: var(--bui-font-regular); + font-size: var(--bui-font-size-3); + line-height: 1.5; + transition: opacity var(--loading-duration) ease-out; + + /* Apply variables */ + background-color: var(--alert-bg); + color: var(--alert-fg); + + &[data-loading='true'] { + opacity: 0.7; + } + } + + .bui-Alert[data-status='info'] { + --alert-bg: var(--bui-bg-surface-1); + --alert-fg: var(--bui-fg-primary); + } + + .bui-Alert[data-status='success'] { + --alert-bg: var(--bui-bg-success); + --alert-fg: var(--bui-fg-success); + } + + .bui-Alert[data-status='warning'] { + --alert-bg: var(--bui-bg-warning); + --alert-fg: var(--bui-fg-warning); + } + + .bui-Alert[data-status='danger'] { + --alert-bg: var(--bui-bg-danger); + --alert-fg: var(--bui-fg-danger); + } + + .bui-AlertIcon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + margin-top: 0.125rem; + transition: opacity var(--loading-duration) ease-out; + + svg { + width: 1.25rem; + height: 1.25rem; + } + + .bui-Alert[data-loading='true'] & { + opacity: 0; + } + } + + .bui-AlertContent { + flex: 1; + min-width: 0; + word-wrap: break-word; + } + + .bui-AlertSpinner { + position: absolute; + top: var(--bui-space-3); + left: var(--bui-space-4); + display: flex; + opacity: 0; + transition: opacity var(--loading-duration) ease-in; + + .bui-Alert[data-loading='true'] & { + opacity: 1; + } + + & svg { + width: 1.25rem; + height: 1.25rem; + animation: bui-spin 1s linear infinite; + } + } + + .bui-AlertActions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--bui-space-2); + margin-left: auto; + } + + @media (prefers-reduced-motion: reduce) { + .bui-Alert { + transition-duration: 50ms; + } + + .bui-AlertIcon { + transition-duration: 50ms; + } + + .bui-AlertSpinner { + transition-duration: 50ms; + } + + .bui-AlertSpinner svg { + animation: none; + } + } + + @keyframes bui-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + } +} diff --git a/packages/ui/src/components/Alert/Alert.stories.tsx b/packages/ui/src/components/Alert/Alert.stories.tsx new file mode 100644 index 0000000000..fa27321d62 --- /dev/null +++ b/packages/ui/src/components/Alert/Alert.stories.tsx @@ -0,0 +1,334 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 preview from '../../../../../.storybook/preview'; +import { Alert } from './Alert'; +import { Flex } from '../Flex'; +import { Box } from '../Box'; +import { Text } from '../Text'; +import { Button } from '../Button'; +import { RiCloudLine } from '@remixicon/react'; +import { useState } from 'react'; + +const meta = preview.meta({ + title: 'Backstage UI/Alert', + component: Alert, + argTypes: { + status: { + control: 'select', + options: ['info', 'success', 'warning', 'danger'], + }, + icon: { + control: 'boolean', + }, + loading: { + control: 'boolean', + }, + }, +}); + +export const Default = meta.story({ + args: { + children: 'This is an alert message', + icon: true, + }, +}); + +export const StatusVariants = meta.story({ + args: { + children: 'This is an alert message', + }, + parameters: { + argTypes: { + status: { + control: false, + }, + }, + }, + render: () => ( + + + This is an informational alert with helpful information. + + + Your changes have been saved successfully. + + + This action may have unintended consequences. + + + An error occurred while processing your request. + + + ), +}); + +export const WithoutIcons = meta.story({ + render: () => ( + + + This is an informational alert without an icon. + + + Your changes have been saved successfully. + + + This action may have unintended consequences. + + + An error occurred while processing your request. + + + ), +}); + +export const CustomIcon = meta.story({ + render: () => ( + + }> + This alert uses a custom cloud icon instead of the default info icon. + + }> + Custom icons work with any status variant. + + + ), +}); + +export const WithActions = meta.story({ + render: () => ( + + + + + } + > + This alert has a dismiss action on the right. + + + + + + } + > + Your changes have been saved. Would you like to continue? + + + + + } + > + An error occurred while processing your request. Please try again. + + + ), +}); + +export const Loading = meta.story({ + render: () => { + const [isLoading, setIsLoading] = useState(false); + + const handleLoad = () => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 3000); + }; + + return ( + + + Load + + } + > + Click the button to see the loading state + + + ); + }, +}); + +export const LoadingVariants = meta.story({ + render: () => ( + + Info + + Processing your request... + + + Success + + Saving changes... + + + Warning + + Checking for issues... + + + Danger + + Attempting recovery... + + + ), +}); + +export const LongContent = meta.story({ + render: () => ( + + + This is a longer alert message that demonstrates how the component + handles multiple lines of text. The content will wrap naturally and + maintain proper spacing with the icon and any actions. This is useful + for providing detailed information to users when necessary. + + + Dismiss + + } + > + This alert combines long content with actions. The actions remain + aligned to the right even when the content wraps to multiple lines. This + ensures a consistent and predictable layout regardless of content + length. + + + ), +}); + +export const OnDifferentSurfaces = meta.story({ + render: () => ( + + + Default Surface + + + Alert on default surface + + + Alert on default surface + + + + + + On Surface 0 + + + Alert on surface 0 + + + Alert on surface 0 + + + + + + On Surface 1 + + + Alert on surface 1 + + + Alert on surface 1 + + + + + + On Surface 2 + + + Alert on surface 2 + + + Alert on surface 2 + + + + + + On Surface 3 + + + Alert on surface 3 + + + Alert on surface 3 + + + + + ), +}); + +export const Responsive = meta.story({ + args: { + children: 'This alert changes status responsively', + icon: true, + status: { + initial: 'info', + sm: 'success', + md: 'warning', + lg: 'danger', + }, + }, +}); + +export const WithUtilityProps = meta.story({ + render: () => ( + + + Alert with custom margin and padding using utility props + + + + Alert with zero margin bottom + + + + ), +}); diff --git a/packages/ui/src/components/Alert/Alert.tsx b/packages/ui/src/components/Alert/Alert.tsx new file mode 100644 index 0000000000..180e23313f --- /dev/null +++ b/packages/ui/src/components/Alert/Alert.tsx @@ -0,0 +1,143 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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, Ref, isValidElement, ReactElement } from 'react'; +import { ProgressBar } from 'react-aria-components'; +import { + RiLoader4Line, + RiInformationLine, + RiCheckLine, + RiErrorWarningLine, + RiAlertLine, +} from '@remixicon/react'; +import type { AlertProps } from './types'; +import { useDefinition } from '../../hooks/useDefinition'; +import { AlertDefinition } from './definition'; + +/** + * A component for displaying alert messages with different status levels. + * + * @remarks + * The Alert component supports multiple status variants (info, success, warning, danger) + * and can display icons, loading states, and custom actions. It automatically handles + * icon selection based on status when the icon prop is set to true. + * + * @example + * Basic usage: + * ```tsx + * This is an informational message + * ``` + * + * @example + * With custom actions and loading state: + * ```tsx + * + * + * + * + * } + * > + * Operation completed successfully + * + * ``` + * + * @public + */ +export const Alert = forwardRef( + (props: AlertProps, ref: Ref) => { + const { ownProps, restProps, dataAttributes, utilityStyle } = useDefinition( + AlertDefinition, + props, + ); + const { + classes, + status, + icon, + loading, + customActions, + style, + surfaceChildren: children, + } = ownProps; + + // Determine which icon to render + const getStatusIcon = (): ReactElement | null => { + // If icon is explicitly false, don't render any icon + if (icon === false) { + return null; + } + + // If icon is a custom React element, use it + if (isValidElement(icon)) { + return icon; + } + + // If icon is true, auto-select based on status + if (icon === true) { + switch (status) { + case 'success': + return ; + case 'warning': + return ; + case 'danger': + return ; + case 'info': + default: + return ; + } + } + + // Default: no icon + return null; + }; + + const statusIcon = getStatusIcon(); + + return ( +
+ {statusIcon &&
{statusIcon}
} + + {loading && ( + + + )} + +
{children}
+ + {customActions && ( +
{customActions}
+ )} +
+ ); + }, +); + +Alert.displayName = 'Alert'; diff --git a/packages/ui/src/components/Alert/definition.ts b/packages/ui/src/components/Alert/definition.ts new file mode 100644 index 0000000000..8919151cf3 --- /dev/null +++ b/packages/ui/src/components/Alert/definition.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { defineComponent } from '../../hooks/useDefinition'; +import type { AlertOwnProps } from './types'; +import styles from './Alert.module.css'; + +/** + * Component definition for Alert + * @public + */ +export const AlertDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-Alert', + content: 'bui-AlertContent', + icon: 'bui-AlertIcon', + spinner: 'bui-AlertSpinner', + actions: 'bui-AlertActions', + }, + surface: 'container', + propDefs: { + status: { dataAttribute: true, default: 'info' }, + loading: { dataAttribute: true }, + icon: {}, + customActions: {}, + surface: {}, + children: {}, + className: {}, + style: {}, + }, + utilityProps: [ + 'm', + 'mb', + 'ml', + 'mr', + 'mt', + 'mx', + 'my', + 'p', + 'pb', + 'pl', + 'pr', + 'pt', + 'px', + 'py', + ], +}); diff --git a/packages/ui/src/components/Alert/index.ts b/packages/ui/src/components/Alert/index.ts new file mode 100644 index 0000000000..b04e1b29f5 --- /dev/null +++ b/packages/ui/src/components/Alert/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 * from './Alert'; +export * from './types'; +export { AlertDefinition } from './definition'; diff --git a/packages/ui/src/components/Alert/types.ts b/packages/ui/src/components/Alert/types.ts new file mode 100644 index 0000000000..ba386dfb57 --- /dev/null +++ b/packages/ui/src/components/Alert/types.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 type { ReactElement, ReactNode, CSSProperties } from 'react'; +import type { + ContainerSurfaceProps, + Responsive, + SpaceProps, +} from '../../types'; + +/** @public */ +export type AlertOwnProps = ContainerSurfaceProps & { + status?: Responsive<'info' | 'success' | 'warning' | 'danger'>; + icon?: boolean | ReactElement; + loading?: boolean; + customActions?: ReactNode; + children?: ReactNode; + className?: string; + style?: CSSProperties; +}; + +/** + * Properties for {@link Alert} + * + * @public + */ +export interface AlertProps extends SpaceProps, AlertOwnProps {} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a721b856a9..2782c0b3ad 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -27,10 +27,11 @@ export * from './components/Flex'; export * from './components/Container'; // UI components +export * from './components/Accordion'; +export * from './components/Alert'; export * from './components/Avatar'; export * from './components/Button'; export * from './components/Card'; -export * from './components/Accordion'; export * from './components/Dialog'; export * from './components/FieldLabel'; export * from './components/Header';