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

@ -81,4 +81,12 @@ export const IconSmall: Story = {
color: 'green',
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 React, {HTMLProps} from 'react';
import {LoadingIndicator, LoadingIndicatorColor, LoadingIndicatorSize} from './LoadingIndicator';
export type ButtonColor = 'clear' | 'grey' | 'black' | 'green' | 'red' | 'white' | 'outline';
export type ButtonSize = 'sm' | 'md';
@ -18,6 +19,9 @@ export interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, 'label'
unstyled?: boolean;
className?: string;
tag?: string;
loading?: boolean;
loadingIndicatorSize?: LoadingIndicatorSize;
loadingIndicatorColor?: LoadingIndicatorColor;
onClick?: (e?:React.MouseEvent<HTMLElement>) => void;
}
@ -34,6 +38,8 @@ const Button: React.FC<ButtonProps> = ({
unstyled = false,
className = '',
tag = 'button',
loading = false,
loadingIndicatorColor,
onClick,
...props
}) => {
@ -51,24 +57,31 @@ const Button: React.FC<ButtonProps> = ({
switch (color) {
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'}`;
loadingIndicatorColor = 'light';
break;
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'}`;
loadingIndicatorColor = 'dark';
break;
case 'green':
styles += link ? ' text-green hover:text-green-400' : ` bg-green text-white ${!disabled && 'hover:bg-green-400'}`;
loadingIndicatorColor = 'light';
break;
case 'red':
styles += link ? ' text-red hover:text-red-400' : ` bg-red text-white ${!disabled && 'hover:bg-red-400'}`;
loadingIndicatorColor = 'light';
break;
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`;
loadingIndicatorColor = 'dark';
break;
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'}`;
loadingIndicatorColor = 'dark';
break;
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'}`;
loadingIndicatorColor = 'dark';
break;
}
@ -80,10 +93,16 @@ const Button: React.FC<ButtonProps> = ({
const iconClasses = label && icon && !hideLabel ? 'mr-1.5' : '';
let labelClasses = '';
labelClasses += (label && hideLabel) ? 'sr-only' : '';
labelClasses += loading ? 'invisible' : '';
const buttonChildren = <>
{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,
disabled: disabled,
type: 'button',

View File

@ -1,21 +1,66 @@
import type {Meta, StoryObj} from '@storybook/react';
import {CenteredLoadingIndicator} from './LoadingIndicator';
import {LoadingIndicator} from './LoadingIndicator';
const meta = {
title: 'Global / Loading indicator',
component: CenteredLoadingIndicator,
component: LoadingIndicator,
tags: ['autodocs']
} satisfies Meta<typeof CenteredLoadingIndicator>;
} satisfies Meta<typeof LoadingIndicator>;
export default meta;
type Story = StoryObj<typeof CenteredLoadingIndicator>;
type Story = StoryObj<typeof LoadingIndicator>;
export const Default: Story = {
args: {
delay: 1000,
delay: 1000,
style: {
height: '400px'
}
}
};
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';
export const LoadingIndicator: React.FC = () => {
return (
<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>
);
};
export type LoadingIndicatorSize = 'sm' | 'md' | 'lg';
export type LoadingIndicatorColor = 'light' | 'dark';
type CenteredLoadingIndicatorProps = {
type LoadingIndicatorProps = {
size?: LoadingIndicatorSize;
color?: LoadingIndicatorColor;
delay?: number;
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);
React.useEffect(() => {
@ -27,9 +24,40 @@ export const CenteredLoadingIndicator: React.FC<CenteredLoadingIndicatorProps> =
}
}, [delay]);
return (
<div className={`flex h-64 items-center justify-center transition-opacity ${show ? 'opacity-100' : 'opacity-0'}`} style={style}>
<LoadingIndicator />
</div>
);
};
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 (
<div className={`flex h-64 items-center justify-center transition-opacity ${show ? 'opacity-100' : 'opacity-0'}`} style={style}>
<div className={styles}></div>
</div>
);
} else {
return (
<div className={styles}></div>
);
}
};

View File

@ -30,6 +30,22 @@ const tableRows = (
<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>
<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>)]
};
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 Separator from './Separator';
import clsx from 'clsx';
import {CenteredLoadingIndicator} from './LoadingIndicator';
import {LoadingIndicator} from './LoadingIndicator';
import {PaginationData} from '../../hooks/usePagination';
interface TableProps {
@ -69,6 +69,7 @@ const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator,
<>
<div className='w-full overflow-x-auto'>
{pageTitle && <Heading>{pageTitle}</Heading>}
{/* TODO: make this div have the same height across all pages */}
<div>
{!isLoading && <table ref={table} className={tableClasses}>
@ -76,12 +77,14 @@ const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator,
{children}
</tbody>
</table>}
{isLoading && <CenteredLoadingIndicator delay={200} style={loadingStyle} />}
</div>
{isLoading && <LoadingIndicator delay={200} size='lg' style={loadingStyle} />}
{(hint || pagination) &&
<div className="-mt-px">
{(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>
<OptionalPagination pagination={pagination} />
</div>

View File

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

View File

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

View File

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