Made LoadingIndicator component more flexible (#18145)

refs https://github.com/TryGhost/Product/issues/3849

- Added small and medium variants of `LoadingIndicator` component
- Added `Loading` prop to `Button` component, which uses `LoadingIndicator`
- Updated instances of `Modal` component so they use the new `LoadingIndicator`
This commit is contained in:
Djordje Vlaisavljevic 2023-09-14 15:57:41 +01:00 committed by GitHub
parent a79de45392
commit 82e9aae4cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 169 additions and 27 deletions

View File

@ -82,3 +82,11 @@ export const IconSmall: Story = {
iconColorClass: 'text-white' iconColorClass: 'text-white'
} }
}; };
export const Loading: Story = {
args: {
loading: true,
color: 'green',
label: 'Button'
}
};

View File

@ -1,5 +1,6 @@
import Icon from './Icon'; import Icon from './Icon';
import React, {HTMLProps} from 'react'; import React, {HTMLProps} from 'react';
import {LoadingIndicator, LoadingIndicatorColor, LoadingIndicatorSize} from './LoadingIndicator';
export type ButtonColor = 'clear' | 'grey' | 'black' | 'green' | 'red' | 'white' | 'outline'; export type ButtonColor = 'clear' | 'grey' | 'black' | 'green' | 'red' | 'white' | 'outline';
export type ButtonSize = 'sm' | 'md'; export type ButtonSize = 'sm' | 'md';
@ -18,6 +19,9 @@ export interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, 'label'
unstyled?: boolean; unstyled?: boolean;
className?: string; className?: string;
tag?: string; tag?: string;
loading?: boolean;
loadingIndicatorSize?: LoadingIndicatorSize;
loadingIndicatorColor?: LoadingIndicatorColor;
onClick?: (e?:React.MouseEvent<HTMLElement>) => void; onClick?: (e?:React.MouseEvent<HTMLElement>) => void;
} }
@ -34,6 +38,8 @@ const Button: React.FC<ButtonProps> = ({
unstyled = false, unstyled = false,
className = '', className = '',
tag = 'button', tag = 'button',
loading = false,
loadingIndicatorColor,
onClick, onClick,
...props ...props
}) => { }) => {
@ -51,24 +57,31 @@ const Button: React.FC<ButtonProps> = ({
switch (color) { switch (color) {
case 'black': case 'black':
styles += link ? ' text-black dark:text-white hover:text-grey-800' : ` bg-black text-white dark:bg-white dark:text-black ${!disabled && 'hover:bg-grey-900'}`; styles += link ? ' text-black dark:text-white hover:text-grey-800' : ` bg-black text-white dark:bg-white dark:text-black ${!disabled && 'hover:bg-grey-900'}`;
loadingIndicatorColor = 'light';
break; break;
case 'grey': case 'grey':
styles += link ? ' text-black dark:text-white hover:text-grey-800' : ` bg-grey-100 text-black dark:bg-grey-900 dark:text-white ${!disabled && 'hover:!bg-grey-300 dark:hover:!bg-grey-800'}`; styles += link ? ' text-black dark:text-white hover:text-grey-800' : ` bg-grey-100 text-black dark:bg-grey-900 dark:text-white ${!disabled && 'hover:!bg-grey-300 dark:hover:!bg-grey-800'}`;
loadingIndicatorColor = 'dark';
break; break;
case 'green': case 'green':
styles += link ? ' text-green hover:text-green-400' : ` bg-green text-white ${!disabled && 'hover:bg-green-400'}`; styles += link ? ' text-green hover:text-green-400' : ` bg-green text-white ${!disabled && 'hover:bg-green-400'}`;
loadingIndicatorColor = 'light';
break; break;
case 'red': case 'red':
styles += link ? ' text-red hover:text-red-400' : ` bg-red text-white ${!disabled && 'hover:bg-red-400'}`; styles += link ? ' text-red hover:text-red-400' : ` bg-red text-white ${!disabled && 'hover:bg-red-400'}`;
loadingIndicatorColor = 'light';
break; break;
case 'white': case 'white':
styles += link ? ' text-white hover:text-white dark:text-black dark:hover:text-grey-800' : ` bg-white dark:bg-black text-black dark:text-white`; styles += link ? ' text-white hover:text-white dark:text-black dark:hover:text-grey-800' : ` bg-white dark:bg-black text-black dark:text-white`;
loadingIndicatorColor = 'dark';
break; break;
case 'outline': case 'outline':
styles += link ? ' text-black dark:text-white hover:text-grey-800' : `text-black border border-grey-300 bg-transparent dark:border-grey-800 dark:text-white ${!disabled && 'hover:!border-black dark:hover:!border-white'}`; styles += link ? ' text-black dark:text-white hover:text-grey-800' : `text-black border border-grey-300 bg-transparent dark:border-grey-800 dark:text-white ${!disabled && 'hover:!border-black dark:hover:!border-white'}`;
loadingIndicatorColor = 'dark';
break; break;
default: default:
styles += link ? ' text-black dark:text-white hover:text-grey-800' : ` text-black dark:text-white dark:hover:bg-grey-900 ${!disabled && 'hover:bg-grey-200'}`; styles += link ? ' text-black dark:text-white hover:text-grey-800' : ` text-black dark:text-white dark:hover:bg-grey-900 ${!disabled && 'hover:bg-grey-200'}`;
loadingIndicatorColor = 'dark';
break; break;
} }
@ -80,10 +93,16 @@ const Button: React.FC<ButtonProps> = ({
const iconClasses = label && icon && !hideLabel ? 'mr-1.5' : ''; const iconClasses = label && icon && !hideLabel ? 'mr-1.5' : '';
let labelClasses = '';
labelClasses += (label && hideLabel) ? 'sr-only' : '';
labelClasses += loading ? 'invisible' : '';
const buttonChildren = <> const buttonChildren = <>
{icon && <Icon className={iconClasses} colorClass={iconColorClass} name={icon} size={size === 'sm' ? 'sm' : 'md'} />} {icon && <Icon className={iconClasses} colorClass={iconColorClass} name={icon} size={size === 'sm' ? 'sm' : 'md'} />}
{(label && hideLabel) ? <span className="sr-only">{label}</span> : label} <span className={labelClasses}>{label}</span>
{loading && <div className='absolute flex'><LoadingIndicator color={loadingIndicatorColor} size={size}/><span className='sr-only'>Loading...</span></div>}
</>; </>;
const buttonElement = React.createElement(tag, {className: styles, const buttonElement = React.createElement(tag, {className: styles,
disabled: disabled, disabled: disabled,
type: 'button', type: 'button',

View File

@ -1,15 +1,15 @@
import type {Meta, StoryObj} from '@storybook/react'; import type {Meta, StoryObj} from '@storybook/react';
import {CenteredLoadingIndicator} from './LoadingIndicator'; import {LoadingIndicator} from './LoadingIndicator';
const meta = { const meta = {
title: 'Global / Loading indicator', title: 'Global / Loading indicator',
component: CenteredLoadingIndicator, component: LoadingIndicator,
tags: ['autodocs'] tags: ['autodocs']
} satisfies Meta<typeof CenteredLoadingIndicator>; } satisfies Meta<typeof LoadingIndicator>;
export default meta; export default meta;
type Story = StoryObj<typeof CenteredLoadingIndicator>; type Story = StoryObj<typeof LoadingIndicator>;
export const Default: Story = { export const Default: Story = {
args: { args: {
@ -19,3 +19,48 @@ export const Default: Story = {
} }
} }
}; };
export const Small: Story = {
args: {
delay: 1000,
size: 'sm',
color: 'dark',
style: {
height: '400px'
}
}
};
export const Medium: Story = {
args: {
delay: 1000,
size: 'md',
color: 'dark',
style: {
height: '400px'
}
}
};
export const Large: Story = {
args: {
delay: 1000,
size: 'lg',
color: 'dark',
style: {
height: '400px'
}
}
};
export const LightColor: Story = {
args: {
delay: 1000,
size: 'lg',
color: 'light',
style: {
height: '400px',
backgroundColor: 'tomato'
}
}
};

View File

@ -1,19 +1,16 @@
import React from 'react'; import React from 'react';
export const LoadingIndicator: React.FC = () => { export type LoadingIndicatorSize = 'sm' | 'md' | 'lg';
return ( export type LoadingIndicatorColor = 'light' | 'dark';
<div>
<div className="relative mx-0 my-[-0.5] box-border inline-block h-[50px] w-[50px] animate-spin rounded-full border border-[rgba(0,0,0,0.1)] before:z-10 before:mt-[7px] before:block before:h-[7px] before:w-[7px] before:rounded-full before:bg-[#4C5156] before:content-['']"></div>
</div>
);
};
type CenteredLoadingIndicatorProps = { type LoadingIndicatorProps = {
size?: LoadingIndicatorSize;
color?: LoadingIndicatorColor;
delay?: number; delay?: number;
style?: React.CSSProperties; style?: React.CSSProperties;
}; };
export const CenteredLoadingIndicator: React.FC<CenteredLoadingIndicatorProps> = ({delay, style}) => { export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({size, color, delay, style}) => {
const [show, setShow] = React.useState(!delay); const [show, setShow] = React.useState(!delay);
React.useEffect(() => { React.useEffect(() => {
@ -27,9 +24,40 @@ export const CenteredLoadingIndicator: React.FC<CenteredLoadingIndicatorProps> =
} }
}, [delay]); }, [delay]);
let styles = `relative mx-0 my-[-0.5] box-border inline-block animate-spin rounded-full before:z-10 before:block before:rounded-full before:content-[''] `;
switch (size) {
case 'sm':
styles += ' h-[16px] w-[16px] border-2 before:mt-[10px] before:h-[3px] before:w-[3px] ';
break;
case 'md':
styles += ' h-[20px] w-[20px] border-2 before:mt-[13px] before:h-[3px] before:w-[3px] ';
break;
case 'lg':
default:
styles += ' h-[50px] w-[50px] border before:mt-[7px] before:h-[7px] before:w-[7px] ';
break;
}
switch (color) {
case 'light':
styles += ' border-white/20 before:bg-white ';
break;
case 'dark':
default:
styles += ' border-black/10 before:bg-black ';
break;
}
if (size === 'lg') {
return ( return (
<div className={`flex h-64 items-center justify-center transition-opacity ${show ? 'opacity-100' : 'opacity-0'}`} style={style}> <div className={`flex h-64 items-center justify-center transition-opacity ${show ? 'opacity-100' : 'opacity-0'}`} style={style}>
<LoadingIndicator /> <div className={styles}></div>
</div> </div>
); );
} else {
return (
<div className={styles}></div>
);
}
}; };

View File

@ -30,6 +30,22 @@ const tableRows = (
<TableCell>Jamie Larson</TableCell> <TableCell>Jamie Larson</TableCell>
<TableCell>jamie@example.com</TableCell> <TableCell>jamie@example.com</TableCell>
</TableRow> </TableRow>
<TableRow>
<TableCell>Jamie Larson</TableCell>
<TableCell>jamie@example.com</TableCell>
</TableRow>
<TableRow>
<TableCell>Jamie Larson</TableCell>
<TableCell>jamie@example.com</TableCell>
</TableRow>
<TableRow>
<TableCell>Jamie Larson</TableCell>
<TableCell>jamie@example.com</TableCell>
</TableRow>
<TableRow>
<TableCell>Jamie Larson</TableCell>
<TableCell>jamie@example.com</TableCell>
</TableRow>
</> </>
); );
@ -42,3 +58,19 @@ export const Default: Story = {
}, },
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)] decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
}; };
export const WithPageTitle: Story = {
args: {
pageTitle: 'This is a page title',
children: tableRows
}
};
export const Loading: Story = {
args: {
children: tableRows,
isLoading: true,
hint: 'This is a hint',
hintSeparator: true
}
};

View File

@ -4,7 +4,7 @@ import Pagination from './Pagination';
import React from 'react'; import React from 'react';
import Separator from './Separator'; import Separator from './Separator';
import clsx from 'clsx'; import clsx from 'clsx';
import {CenteredLoadingIndicator} from './LoadingIndicator'; import {LoadingIndicator} from './LoadingIndicator';
import {PaginationData} from '../../hooks/usePagination'; import {PaginationData} from '../../hooks/usePagination';
interface TableProps { interface TableProps {
@ -69,6 +69,7 @@ const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator,
<> <>
<div className='w-full overflow-x-auto'> <div className='w-full overflow-x-auto'>
{pageTitle && <Heading>{pageTitle}</Heading>} {pageTitle && <Heading>{pageTitle}</Heading>}
{/* TODO: make this div have the same height across all pages */} {/* TODO: make this div have the same height across all pages */}
<div> <div>
{!isLoading && <table ref={table} className={tableClasses}> {!isLoading && <table ref={table} className={tableClasses}>
@ -76,12 +77,14 @@ const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator,
{children} {children}
</tbody> </tbody>
</table>} </table>}
{isLoading && <CenteredLoadingIndicator delay={200} style={loadingStyle} />}
</div> </div>
{isLoading && <LoadingIndicator delay={200} size='lg' style={loadingStyle} />}
{(hint || pagination) && {(hint || pagination) &&
<div className="-mt-px"> <div className="-mt-px">
{(hintSeparator || pagination) && <Separator />} {(hintSeparator || pagination) && <Separator />}
<div className="flex flex-col-reverse items-start justify-between gap-1 pt-2 md:flex-row md:items-center md:gap-0 md:pt-0 "> <div className="flex flex-col-reverse items-start justify-between gap-1 pt-2 md:flex-row md:items-center md:gap-0 md:pt-0">
<Hint>{hint ?? ' '}</Hint> <Hint>{hint ?? ' '}</Hint>
<OptionalPagination pagination={pagination} /> <OptionalPagination pagination={pagination} />
</div> </div>

View File

@ -21,6 +21,7 @@ export interface ModalProps {
title?: string; title?: string;
okLabel?: string; okLabel?: string;
okColor?: ButtonColor; okColor?: ButtonColor;
okLoading?: boolean;
cancelLabel?: string; cancelLabel?: string;
leftButtonProps?: ButtonProps; leftButtonProps?: ButtonProps;
buttonsDisabled?: boolean; buttonsDisabled?: boolean;
@ -48,6 +49,7 @@ const Modal: React.FC<ModalProps> = ({
testId, testId,
title, title,
okLabel = 'OK', okLabel = 'OK',
okLoading = false,
cancelLabel = 'Cancel', cancelLabel = 'Cancel',
footer, footer,
leftButtonProps, leftButtonProps,
@ -137,7 +139,8 @@ const Modal: React.FC<ModalProps> = ({
color: okColor, color: okColor,
className: 'min-w-[80px]', className: 'min-w-[80px]',
onClick: onOk, onClick: onOk,
disabled: buttonsDisabled disabled: buttonsDisabled,
loading: okLoading
}); });
} }
} }

View File

@ -106,9 +106,10 @@ const AddRecommendationModal: React.FC<AddRecommendationModalProps> = ({recommen
}); });
let okLabel = 'Next'; let okLabel = 'Next';
let loadingState = false;
if (saveState === 'saving') { if (saveState === 'saving') {
okLabel = 'Checking...'; loadingState = true;
} }
return <Modal return <Modal
@ -120,6 +121,7 @@ const AddRecommendationModal: React.FC<AddRecommendationModalProps> = ({recommen
backDropClick={false} backDropClick={false}
okColor='black' okColor='black'
okLabel={okLabel} okLabel={okLabel}
okLoading={loadingState}
size='sm' size='sm'
testId='add-recommendation-modal' testId='add-recommendation-modal'
title='Add recommendation' title='Add recommendation'

View File

@ -41,9 +41,10 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
}); });
let okLabel = 'Add'; let okLabel = 'Add';
let loadingState = false;
if (saveState === 'saving') { if (saveState === 'saving') {
okLabel = 'Adding...'; loadingState = true;
} else if (saveState === 'saved') { } else if (saveState === 'saved') {
okLabel = 'Added'; okLabel = 'Added';
} }
@ -81,6 +82,7 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
leftButtonProps={leftButtonProps} leftButtonProps={leftButtonProps}
okColor='black' okColor='black'
okLabel={okLabel} okLabel={okLabel}
okLoading={loadingState}
size='sm' size='sm'
testId='add-recommendation-modal' testId='add-recommendation-modal'
title={'Add recommendation'} title={'Add recommendation'}