feat(tools): add Alert component (#43835)
* feat(alert): initialize component * feat(alert): add children prop * feat(alert): add className prop * feat(alert): add variant prop * feat(alert): add close button and handle onDismiss click * feat(alert): place all alert related css in alert.css * feat: define state color variables and use them instead of bootstrap ones * chore: remove unused classes * feat: replace base alert styles with tailwind classes * feat: extract close button to separate component * chore: remove unused css * test: add close button tests * refactor: use more tailwind-like approach for adding colors to theme * refactor: use more expressive prop name for close button label * refactor: use semantic color names * feat: add stories with/without close button * chore: add missing variants
This commit is contained in:
62
tools/ui-components/src/alert/alert.stories.tsx
Normal file
62
tools/ui-components/src/alert/alert.stories.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Story } from '@storybook/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert, AlertProps } from './alert';
|
||||||
|
|
||||||
|
const story = {
|
||||||
|
title: 'Example/Alert',
|
||||||
|
component: Alert,
|
||||||
|
argTypes: {
|
||||||
|
children: { control: { type: 'text' } },
|
||||||
|
className: { control: { type: 'text' } },
|
||||||
|
dismissLabel: { control: { type: 'text' } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Template: Story<AlertProps> = args => <Alert {...args} />;
|
||||||
|
|
||||||
|
export const Basic = Template.bind({});
|
||||||
|
Basic.args = {
|
||||||
|
children: 'Hello, Alert!',
|
||||||
|
className: '',
|
||||||
|
variant: 'success',
|
||||||
|
dismissLabel: 'Close alert',
|
||||||
|
onDismiss: () => console.log('Close alert!')
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LongText = (): JSX.Element => (
|
||||||
|
<Alert variant='success'>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Amet animi commodi
|
||||||
|
cumque dicta ducimus eum iure, maiores mollitia, odit porro quas quod rerum
|
||||||
|
soluta sunt tempora unde, vel voluptas voluptates.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WithHeadingAndParagraphs = (): JSX.Element => (
|
||||||
|
<Alert variant='info'>
|
||||||
|
<h4>
|
||||||
|
<strong>Some Heading Text</strong>
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Amet animi
|
||||||
|
commodi cumque dicta ducimus eum iure, maiores mollitia, odit porro quas
|
||||||
|
quod rerum soluta sunt tempora unde, vel voluptas voluptates.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Amet animi
|
||||||
|
commodi cumque dicta ducimus eum iure, maiores mollitia, odit porro quas
|
||||||
|
quod rerum soluta sunt tempora unde, vel voluptas voluptates.
|
||||||
|
</p>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WithCloseButton = (): JSX.Element => (
|
||||||
|
<Alert onDismiss={() => console.log('Alert closed')} variant='success'>
|
||||||
|
Hello, Alert!
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WithoutCloseButton = (): JSX.Element => (
|
||||||
|
<Alert variant='success'>Hello, Alert without close button!</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default story;
|
71
tools/ui-components/src/alert/alert.test.tsx
Normal file
71
tools/ui-components/src/alert/alert.test.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert } from './alert';
|
||||||
|
|
||||||
|
describe('<Alert>', () => {
|
||||||
|
it('should have an "alert" role', () => {
|
||||||
|
render(<Alert variant='info'>Hello</Alert>);
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children', () => {
|
||||||
|
const expectedText = 'Hello';
|
||||||
|
render(
|
||||||
|
<Alert variant='info'>
|
||||||
|
<p>{expectedText}</p>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends className', () => {
|
||||||
|
const expectedClass = 'basic';
|
||||||
|
render(
|
||||||
|
<Alert className={expectedClass} variant='info'>
|
||||||
|
Hello
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveClass(expectedClass);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`renders a button when "onDismiss" prop is present, and calls "onDismiss" when the button is clicked`, () => {
|
||||||
|
const onDismiss = jest.fn();
|
||||||
|
render(
|
||||||
|
<Alert onDismiss={onDismiss} variant='info'>
|
||||||
|
Hello
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
const closeButton = screen.getByRole('button');
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
userEvent.click(closeButton);
|
||||||
|
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT render a close button, when "onDismiss" prop is NOT used', () => {
|
||||||
|
render(<Alert variant='info'>Hello</Alert>);
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets "aria-label" of close button to "dismissText" prop', () => {
|
||||||
|
const expectedLabel = 'custom dismiss alert message';
|
||||||
|
render(
|
||||||
|
<Alert dismissLabel={expectedLabel} onDismiss={jest.fn()} variant='info'>
|
||||||
|
Hello
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: expectedLabel })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
53
tools/ui-components/src/alert/alert.tsx
Normal file
53
tools/ui-components/src/alert/alert.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CloseButton } from '../close-button';
|
||||||
|
|
||||||
|
type AlertVariant = 'success' | 'info' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export interface AlertProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
variant: AlertVariant;
|
||||||
|
dismissLabel?: string;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<AlertVariant, string> = {
|
||||||
|
success: 'text-green-700 bg-green-50 border-green-100',
|
||||||
|
info: 'text-blue-700 bg-blue-50 border-blue-100',
|
||||||
|
warning: 'text-yellow-700 bg-yellow-50 border-yellow-100',
|
||||||
|
danger: 'text-red-700 bg-red-50 border-red-100'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic UI component that provides contextual feedback
|
||||||
|
*/
|
||||||
|
export function Alert({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
dismissLabel = 'Close',
|
||||||
|
onDismiss
|
||||||
|
}: AlertProps): JSX.Element {
|
||||||
|
const isDismissable = !!onDismiss;
|
||||||
|
const variantClass = variantClasses[variant];
|
||||||
|
|
||||||
|
const classes = [
|
||||||
|
'relative p-4 mb-6 border border-transparent break-words',
|
||||||
|
variantClass,
|
||||||
|
isDismissable ? 'pr-10' : '',
|
||||||
|
className
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes} role='alert'>
|
||||||
|
{isDismissable && (
|
||||||
|
<CloseButton
|
||||||
|
className='absolute right-4'
|
||||||
|
label={dismissLabel}
|
||||||
|
onClick={onDismiss}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
tools/ui-components/src/alert/index.tsx
Normal file
1
tools/ui-components/src/alert/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { Alert } from './alert';
|
@ -0,0 +1,18 @@
|
|||||||
|
import { Story } from '@storybook/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { CloseButton, CloseButtonProps } from './close-button';
|
||||||
|
|
||||||
|
const story = {
|
||||||
|
title: 'Example/CloseButton',
|
||||||
|
component: CloseButton
|
||||||
|
};
|
||||||
|
|
||||||
|
const Template: Story<CloseButtonProps> = args => <CloseButton {...args} />;
|
||||||
|
|
||||||
|
export const Basic = Template.bind({});
|
||||||
|
Basic.args = {
|
||||||
|
className: '',
|
||||||
|
label: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
export default story;
|
30
tools/ui-components/src/close-button/close-button.test.tsx
Normal file
30
tools/ui-components/src/close-button/close-button.test.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { CloseButton } from './close-button';
|
||||||
|
|
||||||
|
describe('<CloseButton>', () => {
|
||||||
|
it('should render', () => {
|
||||||
|
render(<CloseButton onClick={jest.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set "aria-label" to "label" prop', () => {
|
||||||
|
const expectedLabel = 'Close me please';
|
||||||
|
render(<CloseButton label={expectedLabel} onClick={jest.fn()} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: expectedLabel })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call "onClick" handler on button click', () => {
|
||||||
|
const onClick = jest.fn();
|
||||||
|
render(<CloseButton onClick={onClick} />);
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
32
tools/ui-components/src/close-button/close-button.tsx
Normal file
32
tools/ui-components/src/close-button/close-button.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface CloseButtonProps {
|
||||||
|
className?: string;
|
||||||
|
label?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic UI component for closing modals, alerts, etc.
|
||||||
|
*/
|
||||||
|
export function CloseButton({
|
||||||
|
className,
|
||||||
|
label = 'Close',
|
||||||
|
onClick
|
||||||
|
}: CloseButtonProps): JSX.Element {
|
||||||
|
const classes = [
|
||||||
|
'text-xl font-bold leading-none appearance-none opacity-20',
|
||||||
|
'hover:opacity-50 focus:opacity-50',
|
||||||
|
className
|
||||||
|
].join(' ');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className={classes}
|
||||||
|
onClick={onClick}
|
||||||
|
type='button'
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
1
tools/ui-components/src/close-button/index.tsx
Normal file
1
tools/ui-components/src/close-button/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { CloseButton } from './close-button';
|
@ -13,22 +13,32 @@
|
|||||||
--purple50: #9400d3;
|
--purple50: #9400d3;
|
||||||
--purple90: #5a01a7;
|
--purple90: #5a01a7;
|
||||||
|
|
||||||
--yellow10: #ffc300;
|
--yellow05: #fcf8e3;
|
||||||
--yellow20: #ffbf00;
|
--yellow10: #faebcc;
|
||||||
|
--yellow40: #ffc300;
|
||||||
|
--yellow45: #ffbf00;
|
||||||
--yellow50: #f1be32;
|
--yellow50: #f1be32;
|
||||||
|
--yellow70: #8a6d3b;
|
||||||
--yellow90: #4d3800;
|
--yellow90: #4d3800;
|
||||||
|
|
||||||
--blue10: rgba(153, 201, 255, 0.3);
|
--blue05: #d9edf7;
|
||||||
--blue20: rgba(0, 46, 173, 0.3);
|
--blue10: #bce8f1;
|
||||||
--blue30: #99c9ff;
|
--blue30: #99c9ff;
|
||||||
--blue50: #198eee;
|
--blue50: #198eee;
|
||||||
|
--blue70: #31708f;
|
||||||
--blue90: #002ead;
|
--blue90: #002ead;
|
||||||
|
|
||||||
--green10: #acd157;
|
--green05: #dff0d8;
|
||||||
|
--green10: #d6e9c6;
|
||||||
|
--green40: #acd157;
|
||||||
|
--green70: #3c763d;
|
||||||
--green90: #00471b;
|
--green90: #00471b;
|
||||||
|
|
||||||
--red10: #ffadad;
|
--red05: #f2dede;
|
||||||
--red20: #f8577c;
|
--red10: #ebccd1;
|
||||||
|
--red15: #ffadad;
|
||||||
|
--red30: #f8577c;
|
||||||
|
--red70: #a94442;
|
||||||
--red80: #f82153;
|
--red80: #f82153;
|
||||||
--red90: #850000;
|
--red90: #850000;
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
// Use this file as the entry point for component export
|
// Use this file as the entry point for component export
|
||||||
export { Button } from './button';
|
export { Button } from './button';
|
||||||
|
export { Alert } from './alert';
|
||||||
|
@ -3,6 +3,7 @@ module.exports = {
|
|||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
colors: {
|
colors: {
|
||||||
|
transparent: 'transparent',
|
||||||
'dark-theme-background': 'var(--gray90)',
|
'dark-theme-background': 'var(--gray90)',
|
||||||
'light-theme-background': 'var(--gray00)',
|
'light-theme-background': 'var(--gray00)',
|
||||||
'default-foreground-primary': 'var(--default-foreground-primary)',
|
'default-foreground-primary': 'var(--default-foreground-primary)',
|
||||||
@ -12,11 +13,46 @@ module.exports = {
|
|||||||
'default-background-primary': 'var(--default-background-primary)',
|
'default-background-primary': 'var(--default-background-primary)',
|
||||||
'default-background-secondary': 'var(--default-background-secondary)',
|
'default-background-secondary': 'var(--default-background-secondary)',
|
||||||
'default-background-tertiary': 'var(--default-background-tertiary)',
|
'default-background-tertiary': 'var(--default-background-tertiary)',
|
||||||
'default-background-quaternary': 'var(--default-background-quaternary)'
|
'default-background-quaternary': 'var(--default-background-quaternary)',
|
||||||
|
green: {
|
||||||
|
50: 'var(--green05)',
|
||||||
|
100: 'var(--green10)',
|
||||||
|
400: 'var(--green40)',
|
||||||
|
700: 'var(--green70)',
|
||||||
|
900: 'var(--green90)'
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
50: 'var(--blue05)',
|
||||||
|
100: 'var(--blue10)',
|
||||||
|
300: 'var(--blue30)',
|
||||||
|
500: 'var(--blue50)',
|
||||||
|
700: 'var(--blue70)',
|
||||||
|
900: 'var(--blue90)'
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
50: 'var(--yellow05)',
|
||||||
|
100: 'var(--yellow10)',
|
||||||
|
400: 'var(--yellow40)',
|
||||||
|
450: 'var(--yellow45)',
|
||||||
|
500: 'var(--yellow50)',
|
||||||
|
700: 'var(--yellow70)',
|
||||||
|
900: 'var(--yellow90)'
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
50: 'var(--red05)',
|
||||||
|
100: 'var(--red10)',
|
||||||
|
150: 'var(--red15)',
|
||||||
|
300: 'var(--red30)',
|
||||||
|
700: 'var(--red70)',
|
||||||
|
800: 'var(--red80)',
|
||||||
|
900: 'var(--red90)'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
extend: {}
|
extend: {
|
||||||
|
opacity: ['hover', 'focus']
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: []
|
plugins: []
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user