improvement: loading state added to buttons who interact with backend.
This commit is contained in:
@@ -1,14 +1,25 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
type ButtonClickHandler = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** External loading (e.g. form-level busy). ORed with auto-pending from async onClick. */
|
||||
isLoading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* If the handler returns a Promise, the button stays loading/disabled until it
|
||||
* settles — prevents double-clicks without a separate busy flag per call site.
|
||||
* Prefer `onClick={() => doThing()}` over `onClick={() => void doThing()}`.
|
||||
*/
|
||||
onClick?: ButtonClickHandler;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
@@ -19,15 +30,20 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const [autoPending, setAutoPending] = useState(false);
|
||||
const autoPendingRef = useRef(false);
|
||||
|
||||
const pending = isLoading || autoPending;
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
|
||||
primary: 'bg-primary text-white hover:opacity-90 disabled:opacity-60',
|
||||
|
||||
secondary:
|
||||
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
|
||||
@@ -48,14 +64,34 @@ export const Button: React.FC<ButtonProps> = ({
|
||||
};
|
||||
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : '';
|
||||
const loadingClass = pending ? 'opacity-70 animate-pulse pointer-events-none' : '';
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled || pending || autoPendingRef.current) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!onClick) return;
|
||||
|
||||
const result = onClick(event);
|
||||
if (result != null && typeof (result as Promise<void>).then === 'function') {
|
||||
autoPendingRef.current = true;
|
||||
setAutoPending(true);
|
||||
void Promise.resolve(result).finally(() => {
|
||||
autoPendingRef.current = false;
|
||||
setAutoPending(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
aria-busy={isLoading || undefined}
|
||||
type="button"
|
||||
{...props}
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
|
||||
disabled={disabled || pending}
|
||||
aria-busy={pending || undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -79,7 +79,7 @@ export function LanguageToggle() {
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-text-primary hover:bg-background-card/70"
|
||||
onClick={() => void switchLocale(option)}
|
||||
onClick={() => switchLocale(option)}
|
||||
>
|
||||
<span>{t(option)}</span>
|
||||
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
|
||||
Reference in New Issue
Block a user