diff --git a/src/design-system/components/alert/Alert.scss b/src/design-system/components/alert/Alert.scss new file mode 100644 index 000000000..55c4bb01b --- /dev/null +++ b/src/design-system/components/alert/Alert.scss @@ -0,0 +1,121 @@ +@use '~scss/utilities' as *; + +.#{$prefix}alert { + --#{$prefix}alert-padding-x: #{get-var('space-2')}; + --#{$prefix}alert-padding-y: #{get-var('space-1-5')}; + --#{$prefix}alert-inner-gap: #{get-var('space-0')}; + --#{$prefix}alert-font-size: #{get-var('font-size-sm')}; + --#{$prefix}alert-font-weight: #{get-var('font-weight-regular')}; + --#{$prefix}alert-line-height: #{get-var('line-height-md')}; + --#{$prefix}alert-border-width: #{get-var('border-width-xs')}; + --#{$prefix}alert-border-radius: #{get-var('border-radius-xs')}; + --#{$prefix}alert-shadow: #{get-var('box-shadow-xs')}; + --#{$prefix}alert-color: #{get-var('blue-gray-800')}; + + position: relative; + padding: get-var('alert-padding-y') get-var('alert-padding-x'); + font-size: get-var('alert-font-size'); + line-height: get-var('alert-line-height'); + font-weight: get-var('alert-font-weight'); + border-width: get-var('alert-border-width'); + border-radius: get-var('alert-border-radius'); + box-shadow: get-var('alert-shadow'); + gap: get-var('alert-inner-gap'); + color: get-var('alert-color'); + + & .MuiAlertTitle-root { + font-weight: $font-weight-medium; + font-size: $font-size-md; + line-height: $line-height-lg; + } + + &.MuiAlert-filled { + color: $neutral-0; + + .s2s-alert-close-button { + color: $blue-gray-50; + } + } + + &.MuiAlert-outlined { + color: $neutral-0; + + .s2s-alert-close-button { + color: $blue-gray-800; + } + } + + &.MuiAlert-colorError { + border-color: $red-500; + } + + &.MuiAlert-colorError.MuiAlert-filled { + color: $red-50; + background-color: $red-500; + } + + &.MuiAlert-colorError.MuiAlert-outlined { + color: $red-800; + background-color: $red-50; + } + + &.MuiAlert-colorWarning { + border-color: $yellow-500; + } + + &.MuiAlert-colorWarning.MuiAlert-filled { + background-color: $yellow-500; + } + + &.MuiAlert-colorWarning.MuiAlert-outlined { + color: $yellow-800; + background-color: $yellow-100; + } + + &.MuiAlert-colorInfo { + border-color: $blue-500; + } + + &.MuiAlert-colorInfo.MuiAlert-filled { + background-color: $blue-500; + } + + &.MuiAlert-colorInfo.MuiAlert-outlined { + color: $blue-800; + background-color: $blue-100; + } + + &.MuiAlert-colorSuccess { + border-color: $green-600; + } + + &.MuiAlert-colorSuccess.MuiAlert-filled { + background-color: $green-600; + } + + &.MuiAlert-colorSuccess.MuiAlert-outlined { + color: $green-800; + background-color: $green-100; + } + + & .s2s-alert-close-button { + background: transparent; + border: none; + border-radius: $border-radius-md; + font-weight: $font-weight-medium; + font-size: $font-size-sm; + line-height: $line-height-sm; + cursor: pointer; + display: flex; + align-items: center; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } + } + + & .s2s-alert-close-button-label { + margin: 0 0.5rem; + height: auto; + } +} diff --git a/src/design-system/components/alert/Alert.tsx b/src/design-system/components/alert/Alert.tsx new file mode 100644 index 000000000..87ad71ff3 --- /dev/null +++ b/src/design-system/components/alert/Alert.tsx @@ -0,0 +1,87 @@ +import { SyntheticEvent, forwardRef, ButtonHTMLAttributes } from 'react' +import { + Alert as MuiAlert, + AlertProps as MuiAlertProps, + AlertTitle as MuiAlertTitle, + AlertTitleProps +} from '@mui/material' +import { + ErrorOutline, + WarningAmberOutlined, + InfoOutlined, + CheckCircleOutline, + CloseRounded +} from '@mui/icons-material' + +import { cn } from '~/utils/cn' + +import '~scss-components/alert/Alert.scss' + +export const AlertTitle = ({ children, ...props }: AlertTitleProps) => { + return {children} +} + +AlertTitle.displayName = 'AlertTitle' + +interface CloseButtonProps extends ButtonHTMLAttributes { + label?: string +} + +const CloseButton = ({ label }: CloseButtonProps) => { + return ( + + ) +} + +CloseButton.displayName = 'CloseButton' + +const iconMapping = { + error: , + warning: , + info: , + success: +} + +interface AlertProps extends MuiAlertProps { + title?: string + description?: string + label?: string +} + +const Alert = forwardRef( + ( + { title, label, description, children, icon, onClose, className, ...props }, + ref + ) => { + const handleClose = (event: SyntheticEvent) => { + if (onClose) { + onClose(event) + } + } + + return ( + + }} + {...props} + > + {title && {title}} + {description &&

{description}

} + {children} +
+ ) + } +) + +Alert.displayName = 'Alert' + +export default Alert diff --git a/src/design-system/stories/Alert.stories.tsx b/src/design-system/stories/Alert.stories.tsx new file mode 100644 index 000000000..b53a58557 --- /dev/null +++ b/src/design-system/stories/Alert.stories.tsx @@ -0,0 +1,307 @@ +import { Meta, StoryObj } from '@storybook/react' + +import Alert from '~scss-components/alert/Alert' + +const meta: Meta = { + title: 'Components/Alert', + component: Alert, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + "The `Alert` displays a short, important message in a way that attracts the user's attention without interrupting the user's task." + } + } + }, + args: { + variant: 'standard', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'success' + }, + argTypes: { + variant: { + description: 'The variant to use.', + control: 'radio', + options: ['filled', 'outlined', 'standard'] + }, + label: { + description: 'The label to display in the alert.', + control: 'text' + }, + title: { + description: 'The title to display in the alert.', + control: 'text' + }, + description: { + description: 'The description to display in the alert.', + control: 'text' + }, + severity: { + description: + 'The severity of the alert. This defines the color and icon used.', + control: 'radio', + options: ['error', 'info', 'success', 'warning'] + }, + icon: { + description: + 'Override the icon displayed before the children. Unless provided, the icon is mapped to the value of the severity prop.' + }, + children: { + description: 'The content of the alert.' + } + } +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + label: 'Label', + title: 'Title', + description: 'Description' + }, + parameters: { + docs: { + description: { + story: + 'This story displays a default alert with the `standard` variant and `success` severity.' + } + } + } +} + +export const All: Story = { + render: (args) => ( +
+ + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: + 'This story showcases all alert variants in two columns for easy comparison.' + } + } + } +} + +export const FilledError: Story = { + args: { + variant: 'filled', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'error' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `filled` variant and `error` severity. Use this style for critical error messages.' + } + } + } +} + +export const FilledWarning: Story = { + args: { + variant: 'filled', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'warning' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `filled` variant and `warning` severity. Ideal for cautionary messages.' + } + } + } +} + +export const FilledInfo: Story = { + args: { + variant: 'filled', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'info' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `filled` variant and `info` severity. Use this for informational messages.' + } + } + } +} + +export const FilledSuccess: Story = { + args: { + variant: 'filled', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'success' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `filled` variant and `success` severity. Use this for success confirmation messages.' + } + } + } +} + +export const OutlinedError: Story = { + args: { + variant: 'outlined', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'error' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `outlined` variant and `error` severity. Use this style for subtle but critical error messages.' + } + } + } +} + +export const OutlinedWarning: Story = { + args: { + variant: 'outlined', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'warning' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `outlined` variant and `warning` severity. Ideal for understated cautionary messages.' + } + } + } +} + +export const OutlinedInfo: Story = { + args: { + variant: 'outlined', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'info' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `outlined` variant and `info` severity. Use this for understated informational messages.' + } + } + } +} + +export const OutlinedSuccess: Story = { + args: { + variant: 'outlined', + label: 'Label', + title: 'Title', + description: 'Description', + severity: 'success' + }, + parameters: { + docs: { + description: { + story: + 'This story displays an alert with the `outlined` variant and `success` severity. Use this for subtle success confirmation messages.' + } + } + } +} diff --git a/tests/unit/design-system/components/Alert/Alert.spec.jsx b/tests/unit/design-system/components/Alert/Alert.spec.jsx new file mode 100644 index 000000000..2037d7ed9 --- /dev/null +++ b/tests/unit/design-system/components/Alert/Alert.spec.jsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react' +import { InfoOutlined } from '@mui/icons-material' + +import Alert from '~scss-components/alert/Alert' + +describe('Alert Component', () => { + test('renders the Alert component with the correct title and description', () => { + render() + + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test Description')).toBeInTheDocument() + }) + + test('renders children content', () => { + render( + + Child Content + + ) + + expect(screen.getByText('Child Content')).toBeInTheDocument() + }) + + test('renders the icon provided via the icon prop', () => { + render(} />) + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument() + }) + + test('renders the close button when the onClose prop is provided', () => { + const handleClose = vi.fn() + render() + + const closeButton = screen.getByRole('button', { name: /.*close.*/i }) + expect(closeButton).toBeInTheDocument() + }) + + test('renders the close button with a label when provided', () => { + render( {}} />) + + expect(screen.getByText('Close')).toBeInTheDocument() + }) + + test('applies custom className to the Alert component', () => { + render() + + const alertElement = screen.getByRole('alert') + expect(alertElement).toHaveClass('custom-class') + }) + + test('renders description as a paragraph', () => { + render() + + expect(screen.getByText('Test Description').tagName).toBe('P') + }) +})