First pass introducing the Alert component for BUI

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-01-26 09:01:49 +00:00
parent 4ad63b8d9f
commit 508bd1adbf
9 changed files with 814 additions and 1 deletions
+7
View File
@@ -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
+65
View File
@@ -124,6 +124,71 @@ export interface AccordionTriggerProps extends HeadingProps {
title?: string;
}
// @public
export const Alert: ForwardRefExoticComponent<
AlertProps & RefAttributes<HTMLDivElement>
>;
// @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';
@@ -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);
}
}
}
@@ -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: () => (
<Flex direction="column" gap="4">
<Alert status="info" icon={true}>
This is an informational alert with helpful information.
</Alert>
<Alert status="success" icon={true}>
Your changes have been saved successfully.
</Alert>
<Alert status="warning" icon={true}>
This action may have unintended consequences.
</Alert>
<Alert status="danger" icon={true}>
An error occurred while processing your request.
</Alert>
</Flex>
),
});
export const WithoutIcons = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Alert status="info" icon={false}>
This is an informational alert without an icon.
</Alert>
<Alert status="success" icon={false}>
Your changes have been saved successfully.
</Alert>
<Alert status="warning" icon={false}>
This action may have unintended consequences.
</Alert>
<Alert status="danger" icon={false}>
An error occurred while processing your request.
</Alert>
</Flex>
),
});
export const CustomIcon = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Alert status="info" icon={<RiCloudLine />}>
This alert uses a custom cloud icon instead of the default info icon.
</Alert>
<Alert status="success" icon={<RiCloudLine />}>
Custom icons work with any status variant.
</Alert>
</Flex>
),
});
export const WithActions = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Alert
status="info"
icon={true}
customActions={
<>
<Button size="small" variant="tertiary">
Dismiss
</Button>
</>
}
>
This alert has a dismiss action on the right.
</Alert>
<Alert
status="success"
icon={true}
customActions={
<>
<Button size="small" variant="tertiary">
Cancel
</Button>
<Button size="small" variant="primary">
Continue
</Button>
</>
}
>
Your changes have been saved. Would you like to continue?
</Alert>
<Alert
status="danger"
icon={true}
customActions={
<>
<Button size="small" variant="primary">
Retry
</Button>
</>
}
>
An error occurred while processing your request. Please try again.
</Alert>
</Flex>
),
});
export const Loading = meta.story({
render: () => {
const [isLoading, setIsLoading] = useState(false);
const handleLoad = () => {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
};
return (
<Flex direction="column" gap="4">
<Alert
status="info"
icon={true}
loading={isLoading}
customActions={
<Button size="small" variant="primary" onPress={handleLoad}>
Load
</Button>
}
>
Click the button to see the loading state
</Alert>
</Flex>
);
},
});
export const LoadingVariants = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Text>Info</Text>
<Alert status="info" icon={true} loading>
Processing your request...
</Alert>
<Text>Success</Text>
<Alert status="success" icon={true} loading>
Saving changes...
</Alert>
<Text>Warning</Text>
<Alert status="warning" icon={true} loading>
Checking for issues...
</Alert>
<Text>Danger</Text>
<Alert status="danger" icon={true} loading>
Attempting recovery...
</Alert>
</Flex>
),
});
export const LongContent = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Alert status="info" icon={true}>
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.
</Alert>
<Alert
status="warning"
icon={true}
customActions={
<Button size="small" variant="tertiary">
Dismiss
</Button>
}
>
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.
</Alert>
</Flex>
),
});
export const OnDifferentSurfaces = meta.story({
render: () => (
<Flex direction="column" gap="4">
<Flex direction="column" gap="4">
<Text>Default Surface</Text>
<Flex direction="column" gap="2" p="4">
<Alert status="info" icon={true}>
Alert on default surface
</Alert>
<Alert status="success" icon={true}>
Alert on default surface
</Alert>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>On Surface 0</Text>
<Flex direction="column" gap="2" surface="0" p="4">
<Alert status="info" icon={true}>
Alert on surface 0
</Alert>
<Alert status="success" icon={true}>
Alert on surface 0
</Alert>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>On Surface 1</Text>
<Flex direction="column" gap="2" surface="1" p="4">
<Alert status="info" icon={true}>
Alert on surface 1
</Alert>
<Alert status="success" icon={true}>
Alert on surface 1
</Alert>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>On Surface 2</Text>
<Flex direction="column" gap="2" surface="2" p="4">
<Alert status="info" icon={true}>
Alert on surface 2
</Alert>
<Alert status="success" icon={true}>
Alert on surface 2
</Alert>
</Flex>
</Flex>
<Flex direction="column" gap="4">
<Text>On Surface 3</Text>
<Flex direction="column" gap="2" surface="3" p="4">
<Alert status="info" icon={true}>
Alert on surface 3
</Alert>
<Alert status="success" icon={true}>
Alert on surface 3
</Alert>
</Flex>
</Flex>
</Flex>
),
});
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: () => (
<Flex direction="column" gap="4">
<Alert status="info" icon={true} mb="4" p="5">
Alert with custom margin and padding using utility props
</Alert>
<Box surface="1" p="4">
<Alert status="success" icon={true} mb="0">
Alert with zero margin bottom
</Alert>
</Box>
</Flex>
),
});
+143
View File
@@ -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
* <Alert status="info">This is an informational message</Alert>
* ```
*
* @example
* With custom actions and loading state:
* ```tsx
* <Alert
* status="success"
* icon={true}
* loading={isProcessing}
* customActions={
* <>
* <Button size="small" variant="tertiary">Dismiss</Button>
* <Button size="small" variant="primary">Action</Button>
* </>
* }
* >
* Operation completed successfully
* </Alert>
* ```
*
* @public
*/
export const Alert = forwardRef(
(props: AlertProps, ref: Ref<HTMLDivElement>) => {
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 <RiCheckLine />;
case 'warning':
return <RiErrorWarningLine />;
case 'danger':
return <RiAlertLine />;
case 'info':
default:
return <RiInformationLine />;
}
}
// Default: no icon
return null;
};
const statusIcon = getStatusIcon();
return (
<div
className={classes.root}
ref={ref}
style={{ ...style, ...utilityStyle }}
{...dataAttributes}
{...restProps}
>
{statusIcon && <div className={classes.icon}>{statusIcon}</div>}
{loading && (
<ProgressBar
aria-label="Loading"
isIndeterminate
className={classes.spinner}
>
<RiLoader4Line aria-hidden="true" />
</ProgressBar>
)}
<div className={classes.content}>{children}</div>
{customActions && (
<div className={classes.actions}>{customActions}</div>
)}
</div>
);
},
);
Alert.displayName = 'Alert';
@@ -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<AlertOwnProps>()({
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',
],
});
+19
View File
@@ -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';
+40
View File
@@ -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 {}
+2 -1
View File
@@ -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';