improvement: loading state added to buttons who interact with backend.

This commit is contained in:
2026-07-17 10:58:04 +03:30
parent 68fc9d5d6d
commit d2ad1fd7b6
23 changed files with 240 additions and 144 deletions

View File

@@ -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>

View File

@@ -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" />}