mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(experience): support loading state for buttons (#6232)
This commit is contained in:
parent
d203c8d2ff
commit
6bf3bebb40
8 changed files with 101 additions and 13 deletions
3
packages/experience/src/assets/icons/loading-ring.svg
Normal file
3
packages/experience/src/assets/icons/loading-ring.svg
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.8357 16.8714C11.941 17.4135 11.5865 17.9451 11.0355 17.9821C9.9048 18.0579 8.7669 17.8929 7.69834 17.4934C6.31639 16.9767 5.10425 16.0879 4.19591 14.9253C3.28758 13.7627 2.71844 12.3715 2.55143 10.9056C2.38441 9.43973 2.62602 7.9562 3.24954 6.61905C3.87306 5.28191 4.85421 4.14323 6.0845 3.32891C7.3148 2.5146 8.74632 2.05636 10.2208 2.00487C11.6953 1.95338 13.1553 2.31064 14.4394 3.03715C15.4323 3.59891 16.2901 4.36452 16.9588 5.27942C17.2847 5.72531 17.1054 6.33858 16.6223 6.60633C16.1393 6.87408 15.5366 6.69278 15.1924 6.26086C14.7154 5.66218 14.1262 5.15785 13.4545 4.77787C12.4915 4.23298 11.3965 3.96504 10.2906 4.00366C9.18474 4.04227 8.1111 4.38595 7.18838 4.99669C6.26565 5.60742 5.52979 6.46143 5.06215 7.46429C4.59451 8.46715 4.41331 9.5798 4.53857 10.6792C4.66383 11.7786 5.09069 12.822 5.77194 13.694C6.45319 14.5659 7.3623 15.2325 8.39876 15.62C9.12154 15.8903 9.88663 16.0175 10.6519 15.9981C11.204 15.9841 11.7303 16.3293 11.8357 16.8714Z" fill="currentColor"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,14 @@
|
||||||
|
.icon {
|
||||||
|
animation: rotating 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes rotating {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import Ring from '@/assets/icons/loading-ring.svg';
|
||||||
|
|
||||||
|
import * as RotatingRingIconStyles from './RotatingRingIcon.module.scss';
|
||||||
|
|
||||||
|
const RotatingRingIcon = () => <Ring className={RotatingRingIconStyles.icon} />;
|
||||||
|
|
||||||
|
export default RotatingRingIcon;
|
|
@ -12,6 +12,15 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loadingIcon {
|
||||||
|
display: block;
|
||||||
|
// To avoid the layout shift, add padding manually (keep the same size as the icon)
|
||||||
|
padding: 0 1.5px;
|
||||||
|
color: var(--color-brand-70);
|
||||||
|
font-size: 0;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDebouncedLoader } from 'use-debounced-loader';
|
||||||
|
|
||||||
|
import RotatingRingIcon from './RotatingRingIcon';
|
||||||
import * as socialLinkButtonStyles from './SocialLinkButton.module.scss';
|
import * as socialLinkButtonStyles from './SocialLinkButton.module.scss';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
readonly isDisabled?: boolean;
|
readonly isDisabled?: boolean;
|
||||||
|
readonly isLoading?: boolean;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly target: string;
|
readonly target: string;
|
||||||
readonly logo: string;
|
readonly logo: string;
|
||||||
|
@ -13,7 +16,15 @@ export type Props = {
|
||||||
readonly onClick?: () => void;
|
readonly onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SocialLinkButton = ({ isDisabled, className, target, name, logo, onClick }: Props) => {
|
const SocialLinkButton = ({
|
||||||
|
isDisabled,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
target,
|
||||||
|
name,
|
||||||
|
logo,
|
||||||
|
onClick,
|
||||||
|
}: Props) => {
|
||||||
const {
|
const {
|
||||||
t,
|
t,
|
||||||
i18n: { language },
|
i18n: { language },
|
||||||
|
@ -21,6 +32,8 @@ const SocialLinkButton = ({ isDisabled, className, target, name, logo, onClick }
|
||||||
|
|
||||||
const localName = name[language] ?? name.en;
|
const localName = name[language] ?? name.en;
|
||||||
|
|
||||||
|
const isLoadingActive = useDebouncedLoader(isLoading, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
@ -29,13 +42,13 @@ const SocialLinkButton = ({ isDisabled, className, target, name, logo, onClick }
|
||||||
styles.secondary,
|
styles.secondary,
|
||||||
styles.large,
|
styles.large,
|
||||||
socialLinkButtonStyles.socialButton,
|
socialLinkButtonStyles.socialButton,
|
||||||
isDisabled && styles.disabled,
|
(isDisabled ?? isLoadingActive) && styles.disabled,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{logo && (
|
{logo && !isLoadingActive && (
|
||||||
<img
|
<img
|
||||||
src={logo}
|
src={logo}
|
||||||
alt={target}
|
alt={target}
|
||||||
|
@ -43,6 +56,11 @@ const SocialLinkButton = ({ isDisabled, className, target, name, logo, onClick }
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isLoadingActive && (
|
||||||
|
<span className={socialLinkButtonStyles.loadingIcon}>
|
||||||
|
<RotatingRingIcon />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className={socialLinkButtonStyles.name}>
|
<div className={socialLinkButtonStyles.name}>
|
||||||
<div className={socialLinkButtonStyles.placeHolder} />
|
<div className={socialLinkButtonStyles.placeHolder} />
|
||||||
<span>{t('action.sign_in_with', { name: localName })}</span>
|
<span>{t('action.sign_in_with', { name: localName })}</span>
|
||||||
|
|
|
@ -16,10 +16,28 @@
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.icon {
|
.content {
|
||||||
font-size: 0;
|
position: relative;
|
||||||
line-height: normal;
|
transition: padding-left 0.2s ease;
|
||||||
margin-right: _.unit(2);
|
|
||||||
|
.icon {
|
||||||
|
font-size: 0;
|
||||||
|
line-height: normal;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0%;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.iconVisible {
|
||||||
|
padding-left: _.unit(7);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
left: 0;
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +63,10 @@
|
||||||
&:active {
|
&:active {
|
||||||
background: var(--color-brand-pressed);
|
background: var(--color-brand-pressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
background-color: var(--color-brand-70);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
|
@ -74,7 +96,7 @@
|
||||||
outline: 3px solid var(--color-overlay-brand-focused);
|
outline: 3px solid var(--color-overlay-brand-focused);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:disabled):not(:active):hover {
|
&:not(:disabled):not(:active):not(.loading):hover {
|
||||||
background: var(--color-brand-hover);
|
background: var(--color-brand-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,7 +106,7 @@
|
||||||
outline: 3px solid var(--color-overlay-neutral-focused);
|
outline: 3px solid var(--color-overlay-neutral-focused);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:disabled):not(:active):hover {
|
&:not(:disabled):not(:active):not(.loading):hover {
|
||||||
background: var(--color-overlay-neutral-hover);
|
background: var(--color-overlay-neutral-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
import type { HTMLProps } from 'react';
|
import { type HTMLProps } from 'react';
|
||||||
|
import { useDebouncedLoader } from 'use-debounced-loader';
|
||||||
|
|
||||||
import DynamicT from '../DynamicT';
|
import DynamicT from '../DynamicT';
|
||||||
|
|
||||||
|
import RotatingRingIcon from './RotatingRingIcon';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
export type ButtonType = 'primary' | 'secondary';
|
export type ButtonType = 'primary' | 'secondary';
|
||||||
|
@ -13,6 +15,7 @@ type BaseProps = Omit<HTMLProps<HTMLButtonElement>, 'type' | 'size' | 'title'> &
|
||||||
readonly type?: ButtonType;
|
readonly type?: ButtonType;
|
||||||
readonly size?: 'small' | 'large';
|
readonly size?: 'small' | 'large';
|
||||||
readonly isDisabled?: boolean;
|
readonly isDisabled?: boolean;
|
||||||
|
readonly isLoading?: boolean;
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly onClick?: React.MouseEventHandler;
|
readonly onClick?: React.MouseEventHandler;
|
||||||
};
|
};
|
||||||
|
@ -31,10 +34,13 @@ const Button = ({
|
||||||
i18nProps,
|
i18nProps,
|
||||||
className,
|
className,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
|
isLoading = false,
|
||||||
icon,
|
icon,
|
||||||
onClick,
|
onClick,
|
||||||
...rest
|
...rest
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const isLoadingActive = useDebouncedLoader(isLoading, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
@ -42,15 +48,23 @@ const Button = ({
|
||||||
styles.button,
|
styles.button,
|
||||||
styles[type],
|
styles[type],
|
||||||
styles[size],
|
styles[size],
|
||||||
isDisabled && styles.isDisabled,
|
isDisabled && styles.disabled,
|
||||||
|
isLoadingActive && styles.loading,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
type={htmlType}
|
type={htmlType}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{icon && <span className={styles.icon}>{icon}</span>}
|
<span
|
||||||
<DynamicT forKey={title} interpolation={i18nProps} />
|
className={classNames(
|
||||||
|
styles.content,
|
||||||
|
(isLoadingActive || Boolean(icon)) && styles.iconVisible
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>{isLoadingActive ? <RotatingRingIcon /> : icon}</span>
|
||||||
|
<DynamicT forKey={title} interpolation={i18nProps} />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -38,6 +38,7 @@
|
||||||
--color-brand-30: #4300da;
|
--color-brand-30: #4300da;
|
||||||
--color-brand-40: #5d34f2;
|
--color-brand-40: #5d34f2;
|
||||||
--color-brand-50: #7958ff;
|
--color-brand-50: #7958ff;
|
||||||
|
--color-brand-70: #af9eff;
|
||||||
|
|
||||||
--color-alert-60: #ca8000;
|
--color-alert-60: #ca8000;
|
||||||
--color-alert-70: #eb9918;
|
--color-alert-70: #eb9918;
|
||||||
|
|
Loading…
Reference in a new issue