Improve UX error message

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-05 17:35:15 +02:00
parent a1236f866b
commit caeac0ed1c
5 changed files with 216 additions and 91 deletions

View File

@@ -14,7 +14,12 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ErrorLayout } from "./ErrorLayout";
import { Button } from "../Atoms/Button/Button";
import {
ErrorDetailMessage,
ErrorDetails,
ErrorLayout,
} from "./ErrorLayout";
export default {
title: "Layouts/ErrorLayout",
@@ -26,7 +31,25 @@ type Story = StoryObj<typeof ErrorLayout>;
export const Default: Story = {
args: {
showLogo: true,
title: "Something went wrong",
description: "An unexpected error occurred",
description: "We hit an unexpected error. Head back home to continue.",
actions: <Button>Go home</Button>,
children: (
<ErrorDetails summary="Technical details">
<ErrorDetailMessage>
Relay: Missing @required value at path &apos;organization&apos; in
&apos;ViewerMembershipLayoutQuery&apos;.
</ErrorDetailMessage>
</ErrorDetails>
),
},
};
export const NotFound: Story = {
args: {
title: "Page not found",
description: "The page you are looking for does not exist.",
actions: <Button>Go home</Button>,
},
};

View File

@@ -12,22 +12,106 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { PropsWithChildren } from "react";
import type { PropsWithChildren, ReactNode } from "react";
import { tv, type VariantProps } from "tailwind-variants";
import { IconCircleInfo } from "../Atoms/Icons";
import { Card } from "../Atoms/Card/Card";
import { IconChevronDown } from "../Atoms/Icons";
import { Logo } from "../Atoms/Logo/Logo";
type Props = PropsWithChildren<{
title?: string;
description?: string;
}>;
const errorLayout = tv({
slots: {
root: "w-full flex items-center justify-center px-6 py-16 bg-level-0 text-txt-primary",
card: "w-full max-w-md flex flex-col items-center text-center px-8 py-10",
brand: "w-[110px] mb-8",
title: "text-xl font-semibold tracking-tight",
description: "text-sm text-txt-secondary mt-2 leading-relaxed max-w-xs",
details: "mt-6 w-full border-t border-border-mid pt-6",
actions: "mt-8 w-full flex justify-center",
},
variants: {
fullPage: {
true: {
root: "min-h-screen",
},
false: {
root: "py-12",
},
},
},
defaultVariants: {
fullPage: true,
},
});
const errorDetails = tv({
slots: {
root: "w-full text-start group",
summary:
"text-sm text-txt-tertiary cursor-pointer select-none list-none flex items-center justify-center gap-1 hover:text-txt-secondary transition-colors [&::-webkit-details-marker]:hidden",
marker: "transition-transform group-open:rotate-180",
panel: "mt-3 rounded-lg bg-subtle px-3 py-2.5",
message: "text-xs font-mono text-txt-tertiary break-all leading-relaxed",
},
});
type ErrorLayoutProps = PropsWithChildren<
{
title: string;
description?: string;
actions?: ReactNode;
showLogo?: boolean;
} & VariantProps<typeof errorLayout>
>;
export function ErrorLayout({
title,
description,
fullPage,
showLogo = false,
actions,
children,
}: ErrorLayoutProps) {
const classNames = errorLayout({ fullPage });
export function ErrorLayout({ title, description, children }: Props) {
return (
<div className="min-h-screen w-full gap-2 flex flex-col items-center justify-center">
<IconCircleInfo className="text-txt-danger" size={40} />
<h1 className="text-4xl font-bold">{title}</h1>
<p className="text-txt-secondary">{description}</p>
{children}
<div className={classNames.root()}>
<Card className={classNames.card()}>
{showLogo && (
<Logo withPicto className={classNames.brand()} />
)}
<h1 className={classNames.title()}>{title}</h1>
{description && (
<p className={classNames.description()}>{description}</p>
)}
{children && <div className={classNames.details()}>{children}</div>}
{actions && <div className={classNames.actions()}>{actions}</div>}
</Card>
</div>
);
}
type ErrorDetailsProps = {
summary: string;
children: ReactNode;
};
export function ErrorDetails({ summary, children }: ErrorDetailsProps) {
const classNames = errorDetails();
return (
<details className={classNames.root()}>
<summary className={classNames.summary()}>
{summary}
<IconChevronDown size={14} className={classNames.marker()} />
</summary>
<div className={classNames.panel()}>{children}</div>
</details>
);
}
export function ErrorDetailMessage({ children }: PropsWithChildren) {
const classNames = errorDetails();
return <p className={classNames.message()}>{children}</p>;
}

View File

@@ -14,7 +14,11 @@
// Layouts
export { Drawer, Layout } from "./Layouts/Layout";
export { ErrorLayout } from "./Layouts/ErrorLayout";
export {
ErrorDetailMessage,
ErrorDetails,
ErrorLayout,
} from "./Layouts/ErrorLayout";
export {
CenteredLayout,
CenteredLayoutSkeleton,