@checkstack/ui 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @checkstack/ui
2
2
 
3
+ ## 1.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f23f3c9: Add five additive shared UI primitives for list / query state surfaces:
8
+
9
+ - `ListEmptyState` - thin wrapper around `EmptyState` with the
10
+ canonical `"No {resource} yet"` headline and an `Inbox` default icon.
11
+ - `QueryErrorState` - inline error UI for failed queries; renders an
12
+ `error`-variant `Alert` with `extractErrorMessage` + a Retry button.
13
+ - `Skeleton` - pulsing placeholder block that drops its animation when
14
+ `usePerformance().isLowPower` is true.
15
+ - `ResponsiveTable` + `MobileCardList` - dual-layout pair for tabular
16
+ data that swaps to a stacked card layout below the `sm` breakpoint
17
+ (pure CSS, no JS media-query gating).
18
+ - `toastSuccess` / `toastError` - canonical verb-phrase and
19
+ `{action}: {message}` (truncated at 100 chars) toast helpers.
20
+
21
+ Each primitive ships with Storybook stories and unit tests. No
22
+ existing component or behaviour is changed - Phases 5-7 of the v1
23
+ polishing plan will retrofit consumer pages onto these primitives in
24
+ follow-up PRs. Phase 7 will use the existing `usePerformance()` hook
25
+ directly for low-power gating rather than introducing a separate
26
+ className-composition helper.
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies [f23f3c9]
31
+ - Updated dependencies [f23f3c9]
32
+ - Updated dependencies [f23f3c9]
33
+ - @checkstack/common@0.11.0
34
+ - @checkstack/frontend-api@0.5.2
35
+
3
36
  ## 1.9.0
4
37
 
5
38
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/ui",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import { Inbox } from "lucide-react";
3
+ import { EmptyState } from "./EmptyState";
4
+
5
+ interface ListEmptyStateProps {
6
+ /**
7
+ * The name of the resource type the list would display (e.g. `"checks"`,
8
+ * `"incidents"`). Drives the default `"No {resource} yet"` headline.
9
+ */
10
+ resource: string;
11
+ /**
12
+ * Optional supplemental description rendered beneath the headline.
13
+ */
14
+ description?: React.ReactNode;
15
+ /**
16
+ * Optional action area, typically a primary CTA button such as
17
+ * "Create your first check".
18
+ */
19
+ actions?: React.ReactNode;
20
+ /**
21
+ * Optional icon override. Defaults to the lucide `Inbox` glyph so callers
22
+ * don't have to pick one for every list.
23
+ */
24
+ icon?: React.ReactNode;
25
+ }
26
+
27
+ /**
28
+ * ListEmptyState - the canonical empty state for a list-shaped resource.
29
+ *
30
+ * Thin wrapper around {@link EmptyState} that supplies a consistent
31
+ * "No {resource} yet" headline and a sensible default icon. Use this on
32
+ * any page that renders a list and may have zero items so the UX stays
33
+ * uniform across plugins.
34
+ */
35
+ export const ListEmptyState: React.FC<ListEmptyStateProps> = ({
36
+ resource,
37
+ description,
38
+ actions,
39
+ icon,
40
+ }) => {
41
+ const resolvedIcon = icon ?? <Inbox className="h-10 w-10" />;
42
+
43
+ return (
44
+ <EmptyState
45
+ title={`No ${resource} yet`}
46
+ description={description}
47
+ icon={resolvedIcon}
48
+ actions={actions}
49
+ />
50
+ );
51
+ };
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import { AlertCircle } from "lucide-react";
3
+ import { extractErrorMessage } from "@checkstack/common";
4
+ import {
5
+ Alert,
6
+ AlertContent,
7
+ AlertDescription,
8
+ AlertIcon,
9
+ AlertTitle,
10
+ } from "./Alert";
11
+ import { Button } from "./Button";
12
+
13
+ interface QueryErrorStateProps {
14
+ /**
15
+ * The error captured from a failed query (e.g. TanStack Query's `error`).
16
+ * Funnelled through {@link extractErrorMessage} so callers don't have to
17
+ * narrow the type at every call site.
18
+ */
19
+ error: unknown;
20
+ /**
21
+ * Invoked when the user clicks the "Retry" button. Wire this to the
22
+ * underlying `refetch()` of the failing query.
23
+ */
24
+ onRetry: () => void;
25
+ /**
26
+ * Optional resource name to personalise the headline, e.g.
27
+ * `resource="checks"` -> "Could not load checks".
28
+ */
29
+ resource?: string;
30
+ }
31
+
32
+ /**
33
+ * QueryErrorState - canonical inline error UI for failed list / detail
34
+ * queries. Renders an `error`-variant {@link Alert} with the extracted
35
+ * error message and a Retry button.
36
+ */
37
+ export const QueryErrorState: React.FC<QueryErrorStateProps> = ({
38
+ error,
39
+ onRetry,
40
+ resource,
41
+ }) => {
42
+ const message = extractErrorMessage(error);
43
+ const title = resource ? `Could not load ${resource}` : "Something went wrong";
44
+
45
+ return (
46
+ <Alert variant="error">
47
+ <AlertIcon>
48
+ <AlertCircle className="h-4 w-4" />
49
+ </AlertIcon>
50
+ <AlertContent>
51
+ <AlertTitle>{title}</AlertTitle>
52
+ <AlertDescription>{message}</AlertDescription>
53
+ </AlertContent>
54
+ <Button
55
+ variant="outline"
56
+ size="sm"
57
+ onClick={onRetry}
58
+ className="shrink-0"
59
+ >
60
+ Retry
61
+ </Button>
62
+ </Alert>
63
+ );
64
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ResponsiveTable - dual-layout primitive for tabular data that must
3
+ * degrade gracefully on narrow viewports.
4
+ *
5
+ * # API decision
6
+ *
7
+ * The original plan considered a context-driven `priority` prop on a
8
+ * special `ResponsiveTableHead` so cells could declare which columns
9
+ * disappear on mobile. Implementing that without `any` requires either
10
+ * (a) cloning every `TableCell` child to inject a context-derived
11
+ * `data-priority` attribute, or (b) maintaining a parallel index of
12
+ * `TableHead` children to wire their priorities into the cells by
13
+ * position. Both shapes leak the matching responsibility into the
14
+ * primitive and produce gnarly typings around the `Table*` re-exports.
15
+ *
16
+ * Instead this file ships the simpler, fully type-safe fallback:
17
+ *
18
+ * - `<ResponsiveTable>` - a wrapper that renders its children inside
19
+ * the standard {@link Table} layout on `sm` viewports and up, and
20
+ * hides them on smaller screens.
21
+ * - `<MobileCardList>` - a sibling wrapper consumers render alongside
22
+ * the table. It is only visible below `sm`, so callers compose the
23
+ * two side-by-side and decide per-row what the mobile presentation
24
+ * looks like (typically a stacked card with high-priority fields).
25
+ *
26
+ * The two wrappers use Tailwind's `hidden sm:block` / `sm:hidden`
27
+ * utilities, so they swap purely in CSS - no JS media-query gating, no
28
+ * SSR/CSR mismatch risk, and consumers keep full control over which
29
+ * fields surface on mobile.
30
+ *
31
+ * Re-export the standard `Table*` primitives from `@checkstack/ui` for
32
+ * the desktop branch; do NOT use `<table>` markup inside
33
+ * `<MobileCardList>`.
34
+ */
35
+ import React from "react";
36
+ import { cn } from "../utils";
37
+
38
+ interface ResponsiveTableProps extends React.HTMLAttributes<HTMLDivElement> {
39
+ /**
40
+ * The desktop tabular layout. Compose with the existing `Table`,
41
+ * `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`
42
+ * primitives.
43
+ */
44
+ children: React.ReactNode;
45
+ }
46
+
47
+ /**
48
+ * Desktop branch of the dual-layout pattern. Hidden below the `sm`
49
+ * breakpoint; render a {@link MobileCardList} alongside it to cover
50
+ * narrow viewports.
51
+ */
52
+ export const ResponsiveTable = React.forwardRef<
53
+ HTMLDivElement,
54
+ ResponsiveTableProps
55
+ >(({ children, className, ...props }, ref) => (
56
+ <div
57
+ ref={ref}
58
+ className={cn("hidden sm:block", className)}
59
+ {...props}
60
+ >
61
+ {children}
62
+ </div>
63
+ ));
64
+
65
+ ResponsiveTable.displayName = "ResponsiveTable";
66
+
67
+ interface MobileCardListProps extends React.HTMLAttributes<HTMLDivElement> {
68
+ /**
69
+ * The stacked, card-shaped layout for narrow viewports. One item per
70
+ * row; consumers decide which fields are surfaced.
71
+ */
72
+ children: React.ReactNode;
73
+ }
74
+
75
+ /**
76
+ * Mobile branch of the dual-layout pattern. Visible only below the `sm`
77
+ * breakpoint. Pairs with {@link ResponsiveTable}.
78
+ */
79
+ export const MobileCardList = React.forwardRef<
80
+ HTMLDivElement,
81
+ MobileCardListProps
82
+ >(({ children, className, ...props }, ref) => (
83
+ <div
84
+ ref={ref}
85
+ className={cn("flex flex-col gap-2 sm:hidden", className)}
86
+ {...props}
87
+ >
88
+ {children}
89
+ </div>
90
+ ));
91
+
92
+ MobileCardList.displayName = "MobileCardList";
@@ -0,0 +1,39 @@
1
+ import React from "react";
2
+ import { cn } from "../utils";
3
+ import { usePerformance } from "./PerformanceProvider";
4
+
5
+ interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ /**
7
+ * Override the default sizing / shape. The component already renders a
8
+ * muted background; pass dimensions via classes like `h-4 w-32 rounded`.
9
+ */
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * Skeleton - a pulsing placeholder block for loading states.
15
+ *
16
+ * Honours {@link usePerformance}: when `isLowPower` is true the pulse
17
+ * animation is dropped, leaving a static `bg-muted` block so low-power
18
+ * devices aren't forced through an infinite animation loop.
19
+ */
20
+ export const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
21
+ ({ className, ...props }, ref) => {
22
+ const { isLowPower } = usePerformance();
23
+
24
+ return (
25
+ <div
26
+ ref={ref}
27
+ aria-hidden="true"
28
+ className={cn(
29
+ "rounded-md bg-muted",
30
+ !isLowPower && "animate-pulse",
31
+ className,
32
+ )}
33
+ {...props}
34
+ />
35
+ );
36
+ },
37
+ );
38
+
39
+ Skeleton.displayName = "Skeleton";
package/src/index.ts CHANGED
@@ -64,3 +64,8 @@ export * from "./components/Sheet";
64
64
  export * from "./components/Popover";
65
65
  export * from "./hooks/useIsMobile";
66
66
  export * from "./hooks/useInitOnceForKey";
67
+ export * from "./components/ListEmptyState";
68
+ export * from "./components/QueryErrorState";
69
+ export * from "./components/Skeleton";
70
+ export * from "./components/ResponsiveTable";
71
+ export * from "./utils/toastTemplates";
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it, mock, beforeEach } from "bun:test";
2
+ import {
3
+ toastError,
4
+ toastSuccess,
5
+ type ToastApi,
6
+ } from "./toastTemplates";
7
+
8
+ const makeToastMock = (): ToastApi => ({
9
+ show: mock(),
10
+ success: mock(),
11
+ error: mock(),
12
+ warning: mock(),
13
+ info: mock(),
14
+ });
15
+
16
+ describe("toastSuccess", () => {
17
+ let toast: ToastApi;
18
+
19
+ beforeEach(() => {
20
+ toast = makeToastMock();
21
+ });
22
+
23
+ it("passes the action through to toast.success", () => {
24
+ toastSuccess(toast, "Check created");
25
+
26
+ expect(toast.success).toHaveBeenCalledTimes(1);
27
+ expect(toast.success).toHaveBeenCalledWith("Check created");
28
+ });
29
+ });
30
+
31
+ describe("toastError", () => {
32
+ let toast: ToastApi;
33
+
34
+ beforeEach(() => {
35
+ toast = makeToastMock();
36
+ });
37
+
38
+ it("prefixes the action and includes the extracted error message", () => {
39
+ toastError(toast, "Failed to create check", new Error("Backend unreachable"));
40
+
41
+ expect(toast.error).toHaveBeenCalledTimes(1);
42
+ expect(toast.error).toHaveBeenCalledWith(
43
+ "Failed to create check: Backend unreachable",
44
+ );
45
+ });
46
+
47
+ it("uses the fallback message when error is not an Error or string", () => {
48
+ toastError(toast, "Failed to delete check", null);
49
+
50
+ expect(toast.error).toHaveBeenCalledWith(
51
+ "Failed to delete check: An error occurred",
52
+ );
53
+ });
54
+
55
+ it("accepts a string error and includes it verbatim", () => {
56
+ toastError(toast, "Save failed", "validation: bad payload");
57
+
58
+ expect(toast.error).toHaveBeenCalledWith(
59
+ "Save failed: validation: bad payload",
60
+ );
61
+ });
62
+
63
+ it("truncates the message to 100 characters with an ellipsis", () => {
64
+ const longMessage = "x".repeat(200);
65
+ toastError(toast, "Failed", new Error(longMessage));
66
+
67
+ const mockedError = toast.error as ReturnType<typeof mock>;
68
+ const call = mockedError.mock.calls[0];
69
+ const arg = call[0];
70
+ expect(typeof arg).toBe("string");
71
+ if (typeof arg !== "string") throw new Error("expected string toast message");
72
+ expect(arg.length).toBe(100);
73
+ expect(arg.endsWith("…")).toBe(true);
74
+ });
75
+
76
+ it("leaves messages at or under 100 characters intact", () => {
77
+ const shortMessage = "boom";
78
+ toastError(toast, "Failed", new Error(shortMessage));
79
+
80
+ expect(toast.error).toHaveBeenCalledWith("Failed: boom");
81
+ });
82
+ });
@@ -0,0 +1,47 @@
1
+ import { extractErrorMessage } from "@checkstack/common";
2
+ import type { useToast } from "../components/ToastProvider";
3
+
4
+ /**
5
+ * Shape of the toast API exposed by {@link useToast}. Re-derived from
6
+ * the hook's return type so the helpers stay in lock-step with any
7
+ * future additions to the toast surface.
8
+ */
9
+ export type ToastApi = ReturnType<typeof useToast>;
10
+
11
+ const MAX_TOAST_MESSAGE_LENGTH = 100;
12
+
13
+ /**
14
+ * Truncate `message` to `MAX_TOAST_MESSAGE_LENGTH` characters, replacing
15
+ * the tail with an ellipsis. Used to keep error toasts readable when
16
+ * upstream payloads carry verbose stack traces.
17
+ */
18
+ function truncate(message: string): string {
19
+ if (message.length <= MAX_TOAST_MESSAGE_LENGTH) return message;
20
+ return `${message.slice(0, MAX_TOAST_MESSAGE_LENGTH - 1)}…`;
21
+ }
22
+
23
+ /**
24
+ * toastSuccess - shorthand for `toast.success(action)`.
25
+ *
26
+ * Use a verb-phrase for `action` so the toast reads naturally, e.g.
27
+ * `"Check created"`, `"Configuration saved"`.
28
+ */
29
+ export function toastSuccess(toast: ToastApi, action: string): void {
30
+ toast.success(action);
31
+ }
32
+
33
+ /**
34
+ * toastError - render an error toast in the canonical
35
+ * `"{action}: {message}"` format, truncated to 100 characters.
36
+ *
37
+ * Funnels the unknown `error` through {@link extractErrorMessage} so
38
+ * callers don't have to narrow at every call site.
39
+ */
40
+ export function toastError(
41
+ toast: ToastApi,
42
+ action: string,
43
+ error: unknown,
44
+ ): void {
45
+ const message = extractErrorMessage(error);
46
+ toast.error(truncate(`${action}: ${message}`));
47
+ }
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Plus, ServerCog } from "lucide-react";
3
+ import { Button } from "../src/components/Button";
4
+ import { ListEmptyState } from "../src/components/ListEmptyState";
5
+
6
+ const meta: Meta<typeof ListEmptyState> = {
7
+ title: "Components/Feedback/ListEmptyState",
8
+ component: ListEmptyState,
9
+ tags: ["autodocs"],
10
+ args: {
11
+ resource: "checks",
12
+ },
13
+ };
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof ListEmptyState>;
17
+
18
+ export const Default: Story = {};
19
+
20
+ export const WithDescription: Story = {
21
+ args: {
22
+ resource: "incidents",
23
+ description:
24
+ "Incidents are reported by operators and tracked through resolution updates.",
25
+ },
26
+ };
27
+
28
+ export const WithAction: Story = {
29
+ args: {
30
+ resource: "checks",
31
+ description:
32
+ "Create a health check to start monitoring an endpoint.",
33
+ actions: (
34
+ <Button>
35
+ <Plus className="h-4 w-4 mr-2" />
36
+ Create your first check
37
+ </Button>
38
+ ),
39
+ },
40
+ };
41
+
42
+ export const CustomIcon: Story = {
43
+ args: {
44
+ resource: "satellites",
45
+ description: "Satellites probe remote networks Checkstack can't reach directly.",
46
+ icon: <ServerCog className="h-10 w-10" />,
47
+ },
48
+ };
@@ -0,0 +1,40 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { QueryErrorState } from "../src/components/QueryErrorState";
3
+
4
+ const meta: Meta<typeof QueryErrorState> = {
5
+ title: "Components/Feedback/QueryErrorState",
6
+ component: QueryErrorState,
7
+ tags: ["autodocs"],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof QueryErrorState>;
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ error: new Error("Failed to reach the backend at /api/health-checks"),
16
+ onRetry: () => {
17
+ // Storybook noop - wires up to a real refetch() in the app
18
+ console.log("retry clicked");
19
+ },
20
+ },
21
+ };
22
+
23
+ export const WithResource: Story = {
24
+ args: {
25
+ error: new Error("Network request failed (504)"),
26
+ resource: "checks",
27
+ onRetry: () => {
28
+ console.log("retry clicked");
29
+ },
30
+ },
31
+ };
32
+
33
+ export const NonErrorPayload: Story = {
34
+ args: {
35
+ error: null,
36
+ onRetry: () => {
37
+ console.log("retry clicked");
38
+ },
39
+ },
40
+ };
@@ -0,0 +1,93 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Badge } from "../src/components/Badge";
3
+ import {
4
+ MobileCardList,
5
+ ResponsiveTable,
6
+ } from "../src/components/ResponsiveTable";
7
+ import {
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow,
14
+ } from "../src/components/Table";
15
+
16
+ const meta: Meta = {
17
+ title: "Components/Data/ResponsiveTable",
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj;
22
+
23
+ interface Row {
24
+ name: string;
25
+ strategy: string;
26
+ status: "healthy" | "degraded" | "down";
27
+ latency: string;
28
+ }
29
+
30
+ const rows: Row[] = [
31
+ { name: "api.checkstack.io", strategy: "HTTP probe", status: "healthy", latency: "42 ms" },
32
+ { name: "db-primary", strategy: "TCP probe", status: "degraded", latency: "340 ms" },
33
+ { name: "cache", strategy: "HTTP probe", status: "down", latency: "—" },
34
+ ];
35
+
36
+ const variantFor = (status: Row["status"]) => {
37
+ if (status === "healthy") return "success" as const;
38
+ if (status === "degraded") return "warning" as const;
39
+ return "destructive" as const;
40
+ };
41
+
42
+ export const DualLayout: Story = {
43
+ render: () => (
44
+ <div className="w-full">
45
+ <ResponsiveTable>
46
+ <Table>
47
+ <TableHeader>
48
+ <TableRow>
49
+ <TableHead>Name</TableHead>
50
+ <TableHead>Strategy</TableHead>
51
+ <TableHead>Status</TableHead>
52
+ <TableHead className="text-right">Latency</TableHead>
53
+ </TableRow>
54
+ </TableHeader>
55
+ <TableBody>
56
+ {rows.map((row) => (
57
+ <TableRow key={row.name}>
58
+ <TableCell>{row.name}</TableCell>
59
+ <TableCell>{row.strategy}</TableCell>
60
+ <TableCell>
61
+ <Badge variant={variantFor(row.status)}>{row.status}</Badge>
62
+ </TableCell>
63
+ <TableCell className="text-right">{row.latency}</TableCell>
64
+ </TableRow>
65
+ ))}
66
+ </TableBody>
67
+ </Table>
68
+ </ResponsiveTable>
69
+
70
+ <MobileCardList>
71
+ {rows.map((row) => (
72
+ <div
73
+ key={row.name}
74
+ className="rounded-md border border-border bg-card p-3 flex flex-col gap-1"
75
+ >
76
+ <div className="flex items-center justify-between">
77
+ <span className="font-medium">{row.name}</span>
78
+ <Badge variant={variantFor(row.status)}>{row.status}</Badge>
79
+ </div>
80
+ <div className="text-xs text-muted-foreground">
81
+ {row.strategy} · {row.latency}
82
+ </div>
83
+ </div>
84
+ ))}
85
+ </MobileCardList>
86
+
87
+ <p className="text-xs text-muted-foreground mt-4">
88
+ Resize the viewport: the table appears on <code>sm</code> and up, the
89
+ stacked card list shows on smaller screens.
90
+ </p>
91
+ </div>
92
+ ),
93
+ };
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Skeleton } from "../src/components/Skeleton";
3
+
4
+ const meta: Meta<typeof Skeleton> = {
5
+ title: "Components/Feedback/Skeleton",
6
+ component: Skeleton,
7
+ tags: ["autodocs"],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof Skeleton>;
12
+
13
+ export const Default: Story = {
14
+ args: {
15
+ className: "h-4 w-48",
16
+ },
17
+ };
18
+
19
+ export const TextBlock: Story = {
20
+ render: () => (
21
+ <div className="space-y-2 max-w-md">
22
+ <Skeleton className="h-4 w-3/4" />
23
+ <Skeleton className="h-4 w-full" />
24
+ <Skeleton className="h-4 w-5/6" />
25
+ </div>
26
+ ),
27
+ };
28
+
29
+ export const ListRow: Story = {
30
+ render: () => (
31
+ <div className="flex items-center gap-3 max-w-md">
32
+ <Skeleton className="h-10 w-10 rounded-full" />
33
+ <div className="flex-1 space-y-2">
34
+ <Skeleton className="h-4 w-1/2" />
35
+ <Skeleton className="h-3 w-1/3" />
36
+ </div>
37
+ </div>
38
+ ),
39
+ };
40
+
41
+ export const CardGrid: Story = {
42
+ render: () => (
43
+ <div className="grid grid-cols-2 gap-3 max-w-xl">
44
+ {Array.from({ length: 4 }, (_, index) => (
45
+ <div key={index} className="rounded-md border border-border p-4 space-y-3">
46
+ <Skeleton className="h-5 w-2/3" />
47
+ <Skeleton className="h-4 w-full" />
48
+ <Skeleton className="h-4 w-5/6" />
49
+ </div>
50
+ ))}
51
+ </div>
52
+ ),
53
+ };
@@ -0,0 +1,60 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Button } from "../src/components/Button";
3
+ import { useToast } from "../src/components/ToastProvider";
4
+ import { toastError, toastSuccess } from "../src/utils/toastTemplates";
5
+
6
+ const meta: Meta = {
7
+ title: "Foundations/toastTemplates",
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj;
12
+
13
+ const Demo = () => {
14
+ const toast = useToast();
15
+
16
+ return (
17
+ <div className="space-y-3 max-w-md">
18
+ <p className="text-sm text-muted-foreground">
19
+ Fire the canonical success / error toasts. The error helper
20
+ prefixes the action and truncates long messages to 100 characters.
21
+ </p>
22
+
23
+ <div className="flex flex-wrap gap-2">
24
+ <Button onClick={() => toastSuccess(toast, "Check created")}>
25
+ Fire success
26
+ </Button>
27
+
28
+ <Button
29
+ variant="destructive"
30
+ onClick={() =>
31
+ toastError(
32
+ toast,
33
+ "Failed to create check",
34
+ new Error("Backend unreachable"),
35
+ )
36
+ }
37
+ >
38
+ Fire error
39
+ </Button>
40
+
41
+ <Button
42
+ variant="outline"
43
+ onClick={() =>
44
+ toastError(
45
+ toast,
46
+ "Failed to import config",
47
+ new Error(
48
+ "Validation failed: " + "x".repeat(300),
49
+ ),
50
+ )
51
+ }
52
+ >
53
+ Fire truncated error
54
+ </Button>
55
+ </div>
56
+ </div>
57
+ );
58
+ };
59
+
60
+ export const Helpers: Story = { render: () => <Demo /> };