diff --git a/tools/ui-components/src/alert/alert.stories.tsx b/tools/ui-components/src/alert/alert.stories.tsx new file mode 100644 index 0000000000..f0d4c2bad3 --- /dev/null +++ b/tools/ui-components/src/alert/alert.stories.tsx @@ -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 = 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 => ( + + 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. + +); + +export const WithHeadingAndParagraphs = (): JSX.Element => ( + +

+ Some Heading Text +

+

+ 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. +

+

+ 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. +

+
+); + +export const WithCloseButton = (): JSX.Element => ( + console.log('Alert closed')} variant='success'> + Hello, Alert! + +); + +export const WithoutCloseButton = (): JSX.Element => ( + Hello, Alert without close button! +); + +export default story; diff --git a/tools/ui-components/src/alert/alert.test.tsx b/tools/ui-components/src/alert/alert.test.tsx new file mode 100644 index 0000000000..66df359e38 --- /dev/null +++ b/tools/ui-components/src/alert/alert.test.tsx @@ -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('', () => { + it('should have an "alert" role', () => { + render(Hello); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders children', () => { + const expectedText = 'Hello'; + render( + +

{expectedText}

+
+ ); + + expect(screen.getByText(expectedText)).toBeInTheDocument(); + }); + + it('appends className', () => { + const expectedClass = 'basic'; + render( + + Hello + + ); + + 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( + + Hello + + ); + 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(Hello); + + 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( + + Hello + + ); + + expect( + screen.getByRole('button', { name: expectedLabel }) + ).toBeInTheDocument(); + }); +}); diff --git a/tools/ui-components/src/alert/alert.tsx b/tools/ui-components/src/alert/alert.tsx new file mode 100644 index 0000000000..9491da0fa2 --- /dev/null +++ b/tools/ui-components/src/alert/alert.tsx @@ -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 = { + 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 ( +
+ {isDismissable && ( + + )} + {children} +
+ ); +} diff --git a/tools/ui-components/src/alert/index.tsx b/tools/ui-components/src/alert/index.tsx new file mode 100644 index 0000000000..d0f69c4052 --- /dev/null +++ b/tools/ui-components/src/alert/index.tsx @@ -0,0 +1 @@ +export { Alert } from './alert'; diff --git a/tools/ui-components/src/close-button/close-button.stories.tsx b/tools/ui-components/src/close-button/close-button.stories.tsx new file mode 100644 index 0000000000..bf4fe943e2 --- /dev/null +++ b/tools/ui-components/src/close-button/close-button.stories.tsx @@ -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 = args => ; + +export const Basic = Template.bind({}); +Basic.args = { + className: '', + label: '' +}; + +export default story; diff --git a/tools/ui-components/src/close-button/close-button.test.tsx b/tools/ui-components/src/close-button/close-button.test.tsx new file mode 100644 index 0000000000..79628b8cdf --- /dev/null +++ b/tools/ui-components/src/close-button/close-button.test.tsx @@ -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('', () => { + it('should render', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should set "aria-label" to "label" prop', () => { + const expectedLabel = 'Close me please'; + render(); + + expect( + screen.getByRole('button', { name: expectedLabel }) + ).toBeInTheDocument(); + }); + + it('should call "onClick" handler on button click', () => { + const onClick = jest.fn(); + render(); + + userEvent.click(screen.getByRole('button')); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tools/ui-components/src/close-button/close-button.tsx b/tools/ui-components/src/close-button/close-button.tsx new file mode 100644 index 0000000000..af92ff8b58 --- /dev/null +++ b/tools/ui-components/src/close-button/close-button.tsx @@ -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 ( + + ); +} diff --git a/tools/ui-components/src/close-button/index.tsx b/tools/ui-components/src/close-button/index.tsx new file mode 100644 index 0000000000..5a67b959f2 --- /dev/null +++ b/tools/ui-components/src/close-button/index.tsx @@ -0,0 +1 @@ +export { CloseButton } from './close-button'; diff --git a/tools/ui-components/src/colors.css b/tools/ui-components/src/colors.css index b63a2091a3..6be0d3ac95 100644 --- a/tools/ui-components/src/colors.css +++ b/tools/ui-components/src/colors.css @@ -13,22 +13,32 @@ --purple50: #9400d3; --purple90: #5a01a7; - --yellow10: #ffc300; - --yellow20: #ffbf00; + --yellow05: #fcf8e3; + --yellow10: #faebcc; + --yellow40: #ffc300; + --yellow45: #ffbf00; --yellow50: #f1be32; + --yellow70: #8a6d3b; --yellow90: #4d3800; - --blue10: rgba(153, 201, 255, 0.3); - --blue20: rgba(0, 46, 173, 0.3); + --blue05: #d9edf7; + --blue10: #bce8f1; --blue30: #99c9ff; --blue50: #198eee; + --blue70: #31708f; --blue90: #002ead; - --green10: #acd157; + --green05: #dff0d8; + --green10: #d6e9c6; + --green40: #acd157; + --green70: #3c763d; --green90: #00471b; - --red10: #ffadad; - --red20: #f8577c; + --red05: #f2dede; + --red10: #ebccd1; + --red15: #ffadad; + --red30: #f8577c; + --red70: #a94442; --red80: #f82153; --red90: #850000; } diff --git a/tools/ui-components/src/index.ts b/tools/ui-components/src/index.ts index bf3249f81a..e8addc8656 100644 --- a/tools/ui-components/src/index.ts +++ b/tools/ui-components/src/index.ts @@ -1,2 +1,3 @@ // Use this file as the entry point for component export export { Button } from './button'; +export { Alert } from './alert'; diff --git a/tools/ui-components/tailwind.config.js b/tools/ui-components/tailwind.config.js index 0819f142ab..aa0f5d1892 100644 --- a/tools/ui-components/tailwind.config.js +++ b/tools/ui-components/tailwind.config.js @@ -3,6 +3,7 @@ module.exports = { darkMode: 'class', theme: { colors: { + transparent: 'transparent', 'dark-theme-background': 'var(--gray90)', 'light-theme-background': 'var(--gray00)', 'default-foreground-primary': 'var(--default-foreground-primary)', @@ -12,11 +13,46 @@ module.exports = { 'default-background-primary': 'var(--default-background-primary)', 'default-background-secondary': 'var(--default-background-secondary)', '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: { - extend: {} + extend: { + opacity: ['hover', 'focus'] + } }, plugins: [] };