sendou.ink/app/components/elements/Button.tsx
Kalle cb254c6e31 Fix mobile menu not closing when clicking See all notifications
The LinkButton component wasn't passing onClick to internal Link
components, only to external anchor tags. This prevented the onClose
callback from firing when navigating via the notifications footer link.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:23:20 +02:00

181 lines
3.9 KiB
TypeScript

import clsx from "clsx";
import type { JSX } from "react";
import * as React from "react";
import {
Button as ReactAriaButton,
type ButtonProps as ReactAriaButtonProps,
} from "react-aria-components";
import { Link, type LinkProps } from "react-router";
import { assertUnreachable } from "~/utils/types";
import styles from "./Button.module.css";
type ButtonVariant =
| "primary"
| "success"
| "destructive"
| "outlined"
| "outlined-success"
| "outlined-destructive"
| "minimal"
| "minimal-success"
| "minimal-destructive";
export interface SendouButtonProps
extends Omit<ReactAriaButtonProps, "onClick" | "className"> {
className?: string;
variant?: ButtonVariant;
size?: "miniscule" | "small" | "medium" | "big";
shape?: "circle" | "square";
icon?: JSX.Element;
children?: React.ReactNode;
}
export function SendouButton({
children,
variant,
size,
shape,
className,
icon,
...rest
}: SendouButtonProps) {
return (
<ReactAriaButton
{...rest}
className={buttonClassName({ className, variant, size, shape })}
>
{icon &&
React.cloneElement(icon, {
className: iconClassName(icon.props.className, children, size),
})}
{children}
</ReactAriaButton>
);
}
export interface LinkButtonProps {
to: LinkProps["to"];
prefetch?: LinkProps["prefetch"];
preventScrollReset?: LinkProps["preventScrollReset"];
isExternal?: boolean;
className?: string;
variant?: SendouButtonProps["variant"];
size?: SendouButtonProps["size"];
shape?: SendouButtonProps["shape"];
icon?: JSX.Element;
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
testId?: string;
}
export function LinkButton({
to,
prefetch,
preventScrollReset,
isExternal,
className,
variant,
size,
shape,
icon,
children,
onClick,
testId,
}: LinkButtonProps) {
if (isExternal) {
return (
<a
className={buttonClassName({ className, variant, size, shape })}
href={to as string}
target="_blank"
rel="noreferrer"
onClick={onClick}
data-testid={testId}
>
{icon &&
React.cloneElement(icon, {
className: iconClassName(icon.props.className, children, size),
})}
{children}
</a>
);
}
return (
<Link
className={buttonClassName({ className, variant, size, shape })}
to={to}
data-testid={testId}
prefetch={prefetch}
preventScrollReset={preventScrollReset}
onClick={onClick}
>
{icon &&
React.cloneElement(icon, {
className: iconClassName(icon.props.className, children, size),
})}
{children}
</Link>
);
}
function buttonClassName({
className,
variant,
size,
shape,
}: Pick<SendouButtonProps, "className" | "variant" | "size" | "shape">) {
const variantToClassname = (variant: ButtonVariant) => {
switch (variant) {
case "primary":
return styles.primary;
case "success":
return styles.success;
case "destructive":
return styles.destructive;
case "outlined":
return styles.outlined;
case "outlined-success":
return styles.outlinedSuccess;
case "outlined-destructive":
return styles.outlinedDestructive;
case "minimal":
return styles.minimal;
case "minimal-success":
return styles.minimalSuccess;
case "minimal-destructive":
return styles.minimalDestructive;
default:
return assertUnreachable(variant);
}
};
return clsx(
className,
variant ? variantToClassname(variant) : null,
styles.button,
{
[styles.small]: size === "small",
[styles.big]: size === "big",
[styles.miniscule]: size === "miniscule",
},
{
[styles.circle]: shape === "circle",
[styles.square]: shape === "square",
},
);
}
function iconClassName(
baseClassName: string | undefined,
children: React.ReactNode,
size: SendouButtonProps["size"],
) {
return clsx(baseClassName, styles.buttonIcon, {
[styles.lonely]: !children,
[styles.buttonIconSmall]: size === "small",
[styles.buttonIconMiniscule]: size === "miniscule",
[styles.buttonIconBig]: size === "big",
});
}