@dowel-ui/react 0.1.0 → 0.1.1
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/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
- package/dist/components/ai-tool/ai-tool.d.ts +1 -1
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/toast/toast.d.ts +1 -1
- package/package.json +7 -4
- package/src/blocks/admin-users/admin-users.stories.tsx +0 -83
- package/src/blocks/admin-users/admin-users.test.tsx +0 -159
- package/src/blocks/ai-chat/ai-chat.stories.tsx +0 -149
- package/src/blocks/ai-chat/ai-chat.test.tsx +0 -166
- package/src/blocks/dashboard/dashboard.stories.tsx +0 -106
- package/src/blocks/dashboard/dashboard.test.tsx +0 -94
- package/src/blocks/forgot-password/forgot-password.stories.tsx +0 -41
- package/src/blocks/forgot-password/forgot-password.test.tsx +0 -62
- package/src/blocks/login/login.stories.tsx +0 -46
- package/src/blocks/login/login.test.tsx +0 -100
- package/src/blocks/pricing/pricing.stories.tsx +0 -62
- package/src/blocks/pricing/pricing.test.tsx +0 -101
- package/src/blocks/settings/settings.stories.tsx +0 -94
- package/src/blocks/settings/settings.test.tsx +0 -167
- package/src/blocks/signup/signup.stories.tsx +0 -27
- package/src/blocks/signup/signup.test.tsx +0 -97
- package/src/components/accordion/accordion.stories.tsx +0 -90
- package/src/components/accordion/accordion.test.tsx +0 -156
- package/src/components/activity-feed/activity-feed.stories.tsx +0 -163
- package/src/components/activity-feed/activity-feed.test.tsx +0 -125
- package/src/components/ai-agent-status/ai-agent-status.stories.tsx +0 -86
- package/src/components/ai-agent-status/ai-agent-status.test.tsx +0 -66
- package/src/components/ai-conversation/ai-conversation.stories.tsx +0 -126
- package/src/components/ai-conversation/ai-conversation.test.tsx +0 -144
- package/src/components/ai-message/ai-message.stories.tsx +0 -106
- package/src/components/ai-message/ai-message.test.tsx +0 -160
- package/src/components/ai-model-selector/ai-model-selector.stories.tsx +0 -66
- package/src/components/ai-model-selector/ai-model-selector.test.tsx +0 -120
- package/src/components/ai-prompt-input/ai-prompt-input.stories.tsx +0 -145
- package/src/components/ai-prompt-input/ai-prompt-input.test.tsx +0 -273
- package/src/components/ai-reasoning/ai-reasoning.stories.tsx +0 -60
- package/src/components/ai-reasoning/ai-reasoning.test.tsx +0 -95
- package/src/components/ai-response/ai-response.stories.tsx +0 -75
- package/src/components/ai-response/ai-response.test.tsx +0 -96
- package/src/components/ai-sources/ai-sources.stories.tsx +0 -93
- package/src/components/ai-sources/ai-sources.test.tsx +0 -102
- package/src/components/ai-token-usage/ai-token-usage.stories.tsx +0 -59
- package/src/components/ai-token-usage/ai-token-usage.test.tsx +0 -83
- package/src/components/ai-tool/ai-tool.stories.tsx +0 -96
- package/src/components/ai-tool/ai-tool.test.tsx +0 -137
- package/src/components/alert/alert.stories.tsx +0 -101
- package/src/components/alert/alert.test.tsx +0 -94
- package/src/components/avatar/avatar.stories.tsx +0 -58
- package/src/components/avatar/avatar.test.tsx +0 -64
- package/src/components/badge/badge.stories.tsx +0 -68
- package/src/components/badge/badge.test.tsx +0 -61
- package/src/components/button/button.stories.tsx +0 -128
- package/src/components/button/button.test.tsx +0 -224
- package/src/components/calendar/calendar.stories.tsx +0 -105
- package/src/components/calendar/calendar.test.tsx +0 -255
- package/src/components/card/card.stories.tsx +0 -61
- package/src/components/card/card.test.tsx +0 -66
- package/src/components/checkbox/checkbox.stories.tsx +0 -124
- package/src/components/checkbox/checkbox.test.tsx +0 -121
- package/src/components/code-block/code-block.stories.tsx +0 -62
- package/src/components/code-block/code-block.test.tsx +0 -139
- package/src/components/combobox/combobox.stories.tsx +0 -171
- package/src/components/combobox/combobox.test.tsx +0 -429
- package/src/components/command/command.stories.tsx +0 -152
- package/src/components/command/command.test.tsx +0 -380
- package/src/components/data-table/data-table.stories.tsx +0 -151
- package/src/components/data-table/data-table.test.tsx +0 -288
- package/src/components/date-picker/date-picker.stories.tsx +0 -103
- package/src/components/date-picker/date-picker.test.tsx +0 -205
- package/src/components/dialog/dialog.stories.tsx +0 -140
- package/src/components/dialog/dialog.test.tsx +0 -186
- package/src/components/drawer/drawer.stories.tsx +0 -111
- package/src/components/drawer/drawer.test.tsx +0 -187
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +0 -128
- package/src/components/dropdown-menu/dropdown-menu.test.tsx +0 -321
- package/src/components/empty-state/empty-state.stories.tsx +0 -70
- package/src/components/empty-state/empty-state.test.tsx +0 -97
- package/src/components/form/form.stories.tsx +0 -144
- package/src/components/form/form.test.tsx +0 -203
- package/src/components/input/input.stories.tsx +0 -84
- package/src/components/input/input.test.tsx +0 -108
- package/src/components/label/label.stories.tsx +0 -51
- package/src/components/label/label.test.tsx +0 -55
- package/src/components/pagination/pagination.stories.tsx +0 -129
- package/src/components/pagination/pagination.test.tsx +0 -119
- package/src/components/popover/popover.stories.tsx +0 -84
- package/src/components/popover/popover.test.tsx +0 -163
- package/src/components/progress/progress.stories.tsx +0 -90
- package/src/components/progress/progress.test.tsx +0 -78
- package/src/components/radio-group/radio-group.stories.tsx +0 -102
- package/src/components/radio-group/radio-group.test.tsx +0 -152
- package/src/components/select/select.stories.tsx +0 -137
- package/src/components/select/select.test.tsx +0 -209
- package/src/components/separator/separator.stories.tsx +0 -42
- package/src/components/separator/separator.test.tsx +0 -46
- package/src/components/sheet/sheet.stories.tsx +0 -107
- package/src/components/sheet/sheet.test.tsx +0 -152
- package/src/components/skeleton/skeleton.stories.tsx +0 -46
- package/src/components/skeleton/skeleton.test.tsx +0 -57
- package/src/components/slider/slider.stories.tsx +0 -87
- package/src/components/slider/slider.test.tsx +0 -148
- package/src/components/spinner/spinner.stories.tsx +0 -47
- package/src/components/spinner/spinner.test.tsx +0 -41
- package/src/components/switch/switch.stories.tsx +0 -70
- package/src/components/switch/switch.test.tsx +0 -101
- package/src/components/table/table.stories.tsx +0 -131
- package/src/components/table/table.test.tsx +0 -153
- package/src/components/tabs/tabs.stories.tsx +0 -139
- package/src/components/tabs/tabs.test.tsx +0 -148
- package/src/components/toast/toast.stories.tsx +0 -167
- package/src/components/toast/toast.test.tsx +0 -306
- package/src/components/tooltip/tooltip.stories.tsx +0 -89
- package/src/components/tooltip/tooltip.test.tsx +0 -120
- package/src/lib/utils.test.ts +0 -32
- package/src/registry/meta.test.ts +0 -234
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { Button } from "@/components/button";
|
|
4
|
-
|
|
5
|
-
import { DashboardBlock, type DashboardEvent, type DashboardStat } from "./dashboard";
|
|
6
|
-
|
|
7
|
-
/** Named so its type is nameable in declaration output (TS2883). */
|
|
8
|
-
const withPageWidth: Decorator = (Story) => (
|
|
9
|
-
<div className="w-[64rem] max-w-full">
|
|
10
|
-
<Story />
|
|
11
|
-
</div>
|
|
12
|
-
);
|
|
13
|
-
|
|
14
|
-
const STATS: DashboardStat[] = [
|
|
15
|
-
{
|
|
16
|
-
id: "mrr",
|
|
17
|
-
label: "Monthly revenue",
|
|
18
|
-
value: "$48,120",
|
|
19
|
-
change: 12.4,
|
|
20
|
-
comparison: "on last month",
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
id: "users",
|
|
24
|
-
label: "Active users",
|
|
25
|
-
value: "2,410",
|
|
26
|
-
change: 4.2,
|
|
27
|
-
comparison: "on last week",
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
id: "churn",
|
|
31
|
-
label: "Churn",
|
|
32
|
-
value: "1.8%",
|
|
33
|
-
change: 0.4,
|
|
34
|
-
comparison: "on last month",
|
|
35
|
-
higherIsBetter: false,
|
|
36
|
-
},
|
|
37
|
-
{ id: "uptime", label: "Uptime", value: "99.98%" },
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const EVENTS: DashboardEvent[] = [
|
|
41
|
-
{
|
|
42
|
-
id: "1",
|
|
43
|
-
title: "Deployed to production",
|
|
44
|
-
detail: "Build 1420 · main",
|
|
45
|
-
at: "2026-09-01T09:12:00Z",
|
|
46
|
-
label: "12 minutes ago",
|
|
47
|
-
tone: "success",
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
id: "2",
|
|
51
|
-
title: "Build finished with warnings",
|
|
52
|
-
detail: "3 unused exports",
|
|
53
|
-
at: "2026-09-01T09:04:00Z",
|
|
54
|
-
label: "20 minutes ago",
|
|
55
|
-
tone: "warning",
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
id: "3",
|
|
59
|
-
title: "Build failed",
|
|
60
|
-
detail: "Type error in table.tsx",
|
|
61
|
-
at: "2026-09-01T08:31:00Z",
|
|
62
|
-
label: "53 minutes ago",
|
|
63
|
-
tone: "destructive",
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
id: "4",
|
|
67
|
-
title: "Opened pull request #482",
|
|
68
|
-
at: "2026-08-31T17:20:00Z",
|
|
69
|
-
label: "Yesterday",
|
|
70
|
-
},
|
|
71
|
-
];
|
|
72
|
-
|
|
73
|
-
const meta = {
|
|
74
|
-
title: "Blocks/Dashboard",
|
|
75
|
-
component: DashboardBlock,
|
|
76
|
-
args: { stats: STATS, events: EVENTS },
|
|
77
|
-
parameters: { controls: { disable: true } },
|
|
78
|
-
decorators: [withPageWidth],
|
|
79
|
-
} satisfies Meta<typeof DashboardBlock>;
|
|
80
|
-
|
|
81
|
-
export default meta;
|
|
82
|
-
type Story = StoryObj<typeof meta>;
|
|
83
|
-
|
|
84
|
-
/** Every change is stated in words — "up 12.4%", not a green triangle. */
|
|
85
|
-
export const Default: Story = {};
|
|
86
|
-
|
|
87
|
-
export const Loading: Story = {
|
|
88
|
-
args: { loading: true },
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
export const MetricsOnly: Story = {
|
|
92
|
-
args: { events: [] },
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export const WithActions: Story = {
|
|
96
|
-
args: {
|
|
97
|
-
actions: (
|
|
98
|
-
<div className="flex gap-2">
|
|
99
|
-
<Button variant="outline" size="sm">
|
|
100
|
-
Last 30 days
|
|
101
|
-
</Button>
|
|
102
|
-
<Button size="sm">Export</Button>
|
|
103
|
-
</div>
|
|
104
|
-
),
|
|
105
|
-
},
|
|
106
|
-
};
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { render, screen, within } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { expectNoA11yViolations } from "../../../test/a11y";
|
|
5
|
-
import { DashboardBlock, type DashboardEvent, type DashboardStat } from "./dashboard";
|
|
6
|
-
|
|
7
|
-
const STATS: DashboardStat[] = [
|
|
8
|
-
{
|
|
9
|
-
id: "mrr",
|
|
10
|
-
label: "Monthly revenue",
|
|
11
|
-
value: "$48,120",
|
|
12
|
-
change: 12.4,
|
|
13
|
-
comparison: "on last month",
|
|
14
|
-
},
|
|
15
|
-
{ id: "churn", label: "Churn", value: "1.8%", change: 0.4, higherIsBetter: false },
|
|
16
|
-
{ id: "users", label: "Active users", value: "2,410", change: -3.1 },
|
|
17
|
-
{ id: "uptime", label: "Uptime", value: "99.98%" },
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
const EVENTS: DashboardEvent[] = [
|
|
21
|
-
{
|
|
22
|
-
id: "1",
|
|
23
|
-
title: "Deployed to production",
|
|
24
|
-
at: "2026-09-01T09:12:00Z",
|
|
25
|
-
label: "12 minutes ago",
|
|
26
|
-
tone: "success",
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
id: "2",
|
|
30
|
-
title: "Build failed",
|
|
31
|
-
detail: "Type error in table.tsx",
|
|
32
|
-
at: "2026-09-01T08:40:00Z",
|
|
33
|
-
label: "44 minutes ago",
|
|
34
|
-
tone: "destructive",
|
|
35
|
-
},
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
describe("DashboardBlock", () => {
|
|
39
|
-
it("renders each metric", () => {
|
|
40
|
-
render(<DashboardBlock stats={STATS} />);
|
|
41
|
-
expect(screen.getByText("$48,120")).toBeInTheDocument();
|
|
42
|
-
expect(screen.getByText("Active users")).toBeInTheDocument();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("puts the metrics in a named region", () => {
|
|
46
|
-
render(<DashboardBlock stats={STATS} />);
|
|
47
|
-
expect(screen.getByRole("region", { name: "Key metrics" })).toBeInTheDocument();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("states each change in words, not by a coloured arrow", () => {
|
|
51
|
-
render(<DashboardBlock stats={STATS} />);
|
|
52
|
-
expect(screen.getByText(/up 12\.4%/)).toBeInTheDocument();
|
|
53
|
-
expect(screen.getByText(/down 3\.1%/)).toBeInTheDocument();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("knows a rise is not always good", () => {
|
|
57
|
-
render(<DashboardBlock stats={STATS} />);
|
|
58
|
-
|
|
59
|
-
// Churn rising is bad; revenue rising is good. Same sign, opposite meaning.
|
|
60
|
-
const churn = screen.getByText(/up 0\.4%/);
|
|
61
|
-
const revenue = screen.getByText(/up 12\.4%/);
|
|
62
|
-
expect(churn).toHaveClass("text-destructive");
|
|
63
|
-
expect(revenue).toHaveClass("text-success");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("omits the change when there is nothing to compare to", () => {
|
|
67
|
-
render(<DashboardBlock stats={[STATS[3]!]} />);
|
|
68
|
-
expect(screen.queryByText(/up |down /)).not.toBeInTheDocument();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("renders the activity feed as an ordered list", () => {
|
|
72
|
-
render(<DashboardBlock stats={STATS} events={EVENTS} />);
|
|
73
|
-
|
|
74
|
-
const list = screen.getByRole("list");
|
|
75
|
-
expect(list.tagName).toBe("OL");
|
|
76
|
-
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("shows placeholders while loading, with no numbers to misread", () => {
|
|
80
|
-
render(<DashboardBlock stats={STATS} events={EVENTS} loading />);
|
|
81
|
-
expect(screen.queryByText("$48,120")).not.toBeInTheDocument();
|
|
82
|
-
expect(screen.queryByText(/up 12\.4%/)).not.toBeInTheDocument();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("renders actions beside the heading", () => {
|
|
86
|
-
render(<DashboardBlock stats={STATS} actions={<button type="button">Export</button>} />);
|
|
87
|
-
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("has no accessibility violations", async () => {
|
|
91
|
-
const { container } = render(<DashboardBlock stats={STATS} events={EVENTS} />);
|
|
92
|
-
await expectNoA11yViolations(container);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
|
|
4
|
-
import { ForgotPasswordBlock } from "./forgot-password";
|
|
5
|
-
|
|
6
|
-
const meta = {
|
|
7
|
-
title: "Blocks/Forgot password",
|
|
8
|
-
component: ForgotPasswordBlock,
|
|
9
|
-
parameters: { layout: "centered", controls: { disable: true } },
|
|
10
|
-
} satisfies Meta<typeof ForgotPasswordBlock>;
|
|
11
|
-
|
|
12
|
-
export default meta;
|
|
13
|
-
type Story = StoryObj<typeof meta>;
|
|
14
|
-
|
|
15
|
-
export const Default: Story = {};
|
|
16
|
-
|
|
17
|
-
/** Neutral about whether the address exists — otherwise this form enumerates accounts. */
|
|
18
|
-
export const Sent: Story = {
|
|
19
|
-
args: { sent: true },
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export const Interactive: Story = {
|
|
23
|
-
render: function Interactive() {
|
|
24
|
-
const [pending, setPending] = useState(false);
|
|
25
|
-
const [sent, setSent] = useState(false);
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<ForgotPasswordBlock
|
|
29
|
-
pending={pending}
|
|
30
|
-
sent={sent}
|
|
31
|
-
onSubmit={() => {
|
|
32
|
-
setPending(true);
|
|
33
|
-
window.setTimeout(() => {
|
|
34
|
-
setPending(false);
|
|
35
|
-
setSent(true);
|
|
36
|
-
}, 900);
|
|
37
|
-
}}
|
|
38
|
-
/>
|
|
39
|
-
);
|
|
40
|
-
},
|
|
41
|
-
};
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { expectNoA11yViolations } from "../../../test/a11y";
|
|
6
|
-
import { ForgotPasswordBlock } from "./forgot-password";
|
|
7
|
-
|
|
8
|
-
describe("ForgotPasswordBlock", () => {
|
|
9
|
-
it("asks for an email", () => {
|
|
10
|
-
render(<ForgotPasswordBlock />);
|
|
11
|
-
expect(screen.getByLabelText("Email")).toHaveAttribute("type", "email");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("submits a valid address", async () => {
|
|
15
|
-
const onSubmit = vi.fn();
|
|
16
|
-
const user = userEvent.setup();
|
|
17
|
-
render(<ForgotPasswordBlock onSubmit={onSubmit} />);
|
|
18
|
-
|
|
19
|
-
await user.type(screen.getByLabelText("Email"), "ada@example.com");
|
|
20
|
-
await user.click(screen.getByRole("button", { name: "Send reset link" }));
|
|
21
|
-
|
|
22
|
-
expect(onSubmit).toHaveBeenCalledWith({ email: "ada@example.com" });
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("refuses an invalid address", async () => {
|
|
26
|
-
const onSubmit = vi.fn();
|
|
27
|
-
const user = userEvent.setup();
|
|
28
|
-
render(<ForgotPasswordBlock onSubmit={onSubmit} />);
|
|
29
|
-
|
|
30
|
-
await user.type(screen.getByLabelText("Email"), "nope");
|
|
31
|
-
await user.click(screen.getByRole("button", { name: "Send reset link" }));
|
|
32
|
-
|
|
33
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("the confirmation", () => {
|
|
37
|
-
it("announces itself, because it replaces the form", () => {
|
|
38
|
-
render(<ForgotPasswordBlock sent />);
|
|
39
|
-
expect(screen.getByRole("status")).toHaveTextContent("Check your inbox");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("does not reveal whether the account exists", () => {
|
|
43
|
-
render(<ForgotPasswordBlock sent />);
|
|
44
|
-
// Confirming it would turn this form into an account enumeration oracle.
|
|
45
|
-
expect(screen.getByText(/If an account exists/)).toBeInTheDocument();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("replaces the form entirely", () => {
|
|
49
|
-
render(<ForgotPasswordBlock sent />);
|
|
50
|
-
expect(screen.queryByLabelText("Email")).not.toBeInTheDocument();
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("has no accessibility violations in either state", async () => {
|
|
55
|
-
const form = render(<ForgotPasswordBlock />);
|
|
56
|
-
await expectNoA11yViolations(form.container);
|
|
57
|
-
form.unmount();
|
|
58
|
-
|
|
59
|
-
const sent = render(<ForgotPasswordBlock sent />);
|
|
60
|
-
await expectNoA11yViolations(sent.container);
|
|
61
|
-
});
|
|
62
|
-
});
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
|
|
4
|
-
import { LoginBlock } from "./login";
|
|
5
|
-
|
|
6
|
-
const meta = {
|
|
7
|
-
title: "Blocks/Login",
|
|
8
|
-
component: LoginBlock,
|
|
9
|
-
parameters: { layout: "centered", controls: { disable: true } },
|
|
10
|
-
} satisfies Meta<typeof LoginBlock>;
|
|
11
|
-
|
|
12
|
-
export default meta;
|
|
13
|
-
type Story = StoryObj<typeof meta>;
|
|
14
|
-
|
|
15
|
-
export const Default: Story = {};
|
|
16
|
-
|
|
17
|
-
export const WithServerError: Story = {
|
|
18
|
-
args: { error: "Those credentials did not match our records." },
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export const Submitting: Story = {
|
|
22
|
-
args: { pending: true },
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/** The whole round trip: submit, wait, fail. */
|
|
26
|
-
export const Interactive: Story = {
|
|
27
|
-
render: function Interactive() {
|
|
28
|
-
const [pending, setPending] = useState(false);
|
|
29
|
-
const [error, setError] = useState<string | undefined>(undefined);
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<LoginBlock
|
|
33
|
-
pending={pending}
|
|
34
|
-
error={error}
|
|
35
|
-
onSubmit={() => {
|
|
36
|
-
setError(undefined);
|
|
37
|
-
setPending(true);
|
|
38
|
-
window.setTimeout(() => {
|
|
39
|
-
setPending(false);
|
|
40
|
-
setError("Those credentials did not match our records.");
|
|
41
|
-
}, 1200);
|
|
42
|
-
}}
|
|
43
|
-
/>
|
|
44
|
-
);
|
|
45
|
-
},
|
|
46
|
-
};
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { expectNoA11yViolations } from "../../../test/a11y";
|
|
6
|
-
import { LoginBlock } from "./login";
|
|
7
|
-
|
|
8
|
-
describe("LoginBlock", () => {
|
|
9
|
-
it("labels every field", () => {
|
|
10
|
-
render(<LoginBlock />);
|
|
11
|
-
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
|
12
|
-
expect(screen.getByLabelText("Password")).toBeInTheDocument();
|
|
13
|
-
expect(screen.getByLabelText("Keep me signed in")).toBeInTheDocument();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("uses the right input types and autocomplete", () => {
|
|
17
|
-
render(<LoginBlock />);
|
|
18
|
-
|
|
19
|
-
const email = screen.getByLabelText("Email");
|
|
20
|
-
expect(email).toHaveAttribute("type", "email");
|
|
21
|
-
expect(email).toHaveAttribute("autocomplete", "email");
|
|
22
|
-
expect(screen.getByLabelText("Password")).toHaveAttribute(
|
|
23
|
-
"autocomplete",
|
|
24
|
-
"current-password",
|
|
25
|
-
);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("submits valid credentials", async () => {
|
|
29
|
-
const onSubmit = vi.fn();
|
|
30
|
-
const user = userEvent.setup();
|
|
31
|
-
render(<LoginBlock onSubmit={onSubmit} />);
|
|
32
|
-
|
|
33
|
-
await user.type(screen.getByLabelText("Email"), "ada@example.com");
|
|
34
|
-
await user.type(screen.getByLabelText("Password"), "correct horse");
|
|
35
|
-
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
|
36
|
-
|
|
37
|
-
expect(onSubmit).toHaveBeenCalledWith({
|
|
38
|
-
email: "ada@example.com",
|
|
39
|
-
password: "correct horse",
|
|
40
|
-
remember: false,
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("reports the remember choice", async () => {
|
|
45
|
-
const onSubmit = vi.fn();
|
|
46
|
-
const user = userEvent.setup();
|
|
47
|
-
render(<LoginBlock onSubmit={onSubmit} />);
|
|
48
|
-
|
|
49
|
-
await user.type(screen.getByLabelText("Email"), "ada@example.com");
|
|
50
|
-
await user.type(screen.getByLabelText("Password"), "secret");
|
|
51
|
-
await user.click(screen.getByLabelText("Keep me signed in"));
|
|
52
|
-
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
|
53
|
-
|
|
54
|
-
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ remember: true }));
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("does not submit an invalid form, and ties the error to the field", async () => {
|
|
58
|
-
const onSubmit = vi.fn();
|
|
59
|
-
const user = userEvent.setup();
|
|
60
|
-
render(<LoginBlock onSubmit={onSubmit} />);
|
|
61
|
-
|
|
62
|
-
await user.type(screen.getByLabelText("Email"), "nope");
|
|
63
|
-
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
|
64
|
-
|
|
65
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
66
|
-
const email = screen.getByLabelText("Email");
|
|
67
|
-
expect(email).toHaveAttribute("aria-invalid", "true");
|
|
68
|
-
expect(email).toHaveAccessibleDescription("Enter a valid email address.");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("announces a server error assertively", () => {
|
|
72
|
-
render(<LoginBlock error="Those credentials did not match." />);
|
|
73
|
-
// It arrived after the user acted and explains why nothing happened.
|
|
74
|
-
expect(screen.getByRole("alert")).toHaveTextContent("Those credentials did not match.");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("reports its busy state on the submit button", () => {
|
|
78
|
-
render(<LoginBlock pending />);
|
|
79
|
-
const submit = screen.getByRole("button", { name: "Sign in" });
|
|
80
|
-
expect(submit).toHaveAttribute("aria-busy", "true");
|
|
81
|
-
expect(screen.getByLabelText("Email")).toBeDisabled();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("links to password recovery and sign-up", () => {
|
|
85
|
-
render(<LoginBlock forgotHref="/reset" signupHref="/register" />);
|
|
86
|
-
expect(screen.getByRole("link", { name: "Forgot password?" })).toHaveAttribute(
|
|
87
|
-
"href",
|
|
88
|
-
"/reset",
|
|
89
|
-
);
|
|
90
|
-
expect(screen.getByRole("link", { name: "Create one" })).toHaveAttribute(
|
|
91
|
-
"href",
|
|
92
|
-
"/register",
|
|
93
|
-
);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("has no accessibility violations", async () => {
|
|
97
|
-
const { container } = render(<LoginBlock error="Those credentials did not match." />);
|
|
98
|
-
await expectNoA11yViolations(container);
|
|
99
|
-
});
|
|
100
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { PricingBlock, type PricingPlan } from "./pricing";
|
|
4
|
-
|
|
5
|
-
/** Named so its type is nameable in declaration output (TS2883). */
|
|
6
|
-
const withPageWidth: Decorator = (Story) => (
|
|
7
|
-
<div className="w-[64rem] max-w-full">
|
|
8
|
-
<Story />
|
|
9
|
-
</div>
|
|
10
|
-
);
|
|
11
|
-
|
|
12
|
-
const PLANS: PricingPlan[] = [
|
|
13
|
-
{
|
|
14
|
-
id: "hobby",
|
|
15
|
-
name: "Hobby",
|
|
16
|
-
description: "For personal projects.",
|
|
17
|
-
monthly: 0,
|
|
18
|
-
features: ["1 project", "Community support", "Deploy from Git"],
|
|
19
|
-
cta: "Start free",
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
id: "pro",
|
|
23
|
-
name: "Pro",
|
|
24
|
-
description: "For teams shipping to production.",
|
|
25
|
-
monthly: 20,
|
|
26
|
-
yearlyPerMonth: 16,
|
|
27
|
-
features: ["Unlimited projects", "Priority support", "Preview deployments", "Analytics"],
|
|
28
|
-
featured: true,
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
id: "enterprise",
|
|
32
|
-
name: "Enterprise",
|
|
33
|
-
description: "For organisations with compliance needs.",
|
|
34
|
-
monthly: null,
|
|
35
|
-
features: ["SSO and SCIM", "Audit logs", "Dedicated support", "Custom contracts"],
|
|
36
|
-
cta: "Talk to sales",
|
|
37
|
-
},
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const meta = {
|
|
41
|
-
title: "Blocks/Pricing",
|
|
42
|
-
component: PricingBlock,
|
|
43
|
-
args: { plans: PLANS },
|
|
44
|
-
parameters: { controls: { disable: true } },
|
|
45
|
-
decorators: [withPageWidth],
|
|
46
|
-
} satisfies Meta<typeof PricingBlock>;
|
|
47
|
-
|
|
48
|
-
export default meta;
|
|
49
|
-
type Story = StoryObj<typeof meta>;
|
|
50
|
-
|
|
51
|
-
/** Flip the period: every price changes, and the change is announced. */
|
|
52
|
-
export const Default: Story = {};
|
|
53
|
-
|
|
54
|
-
export const WithoutPeriodToggle: Story = {
|
|
55
|
-
args: { showPeriodToggle: false },
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export const WithFootnote: Story = {
|
|
59
|
-
args: {
|
|
60
|
-
footnote: "All prices exclude VAT. Cancel at any time.",
|
|
61
|
-
},
|
|
62
|
-
};
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { expectNoA11yViolations } from "../../../test/a11y";
|
|
6
|
-
import { PricingBlock, type PricingPlan } from "./pricing";
|
|
7
|
-
|
|
8
|
-
const PLANS: PricingPlan[] = [
|
|
9
|
-
{
|
|
10
|
-
id: "hobby",
|
|
11
|
-
name: "Hobby",
|
|
12
|
-
description: "Personal projects.",
|
|
13
|
-
monthly: 0,
|
|
14
|
-
features: ["1 project"],
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
id: "pro",
|
|
18
|
-
name: "Pro",
|
|
19
|
-
description: "Teams shipping to production.",
|
|
20
|
-
monthly: 20,
|
|
21
|
-
yearlyPerMonth: 16,
|
|
22
|
-
features: ["Unlimited projects", "Priority support"],
|
|
23
|
-
featured: true,
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
id: "enterprise",
|
|
27
|
-
name: "Enterprise",
|
|
28
|
-
description: "SSO and audit logs.",
|
|
29
|
-
monthly: null,
|
|
30
|
-
features: ["SSO"],
|
|
31
|
-
},
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
describe("PricingBlock", () => {
|
|
35
|
-
it("renders each plan with its price", () => {
|
|
36
|
-
render(<PricingBlock plans={PLANS} />);
|
|
37
|
-
expect(screen.getByText("$20")).toBeInTheDocument();
|
|
38
|
-
expect(screen.getByText("$0")).toBeInTheDocument();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("shows a custom price as words, not a number", () => {
|
|
42
|
-
render(<PricingBlock plans={PLANS} />);
|
|
43
|
-
expect(screen.getByText("Custom")).toBeInTheDocument();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("names each call to action after its plan", () => {
|
|
47
|
-
render(<PricingBlock plans={PLANS} />);
|
|
48
|
-
// Three buttons all reading "Choose" are indistinguishable out of context.
|
|
49
|
-
expect(screen.getByRole("button", { name: "Choose Pro" })).toBeInTheDocument();
|
|
50
|
-
expect(screen.getByRole("button", { name: "Choose Hobby" })).toBeInTheDocument();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("marks the recommended plan in text", () => {
|
|
54
|
-
render(<PricingBlock plans={PLANS} />);
|
|
55
|
-
expect(screen.getByText("Recommended")).toBeInTheDocument();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("switches every price when the period changes", async () => {
|
|
59
|
-
const user = userEvent.setup();
|
|
60
|
-
render(<PricingBlock plans={PLANS} />);
|
|
61
|
-
|
|
62
|
-
expect(screen.getByText("$20")).toBeInTheDocument();
|
|
63
|
-
await user.click(screen.getByRole("switch", { name: "Bill yearly" }));
|
|
64
|
-
expect(screen.getByText("$16")).toBeInTheDocument();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("announces the period change, since it rewrites the whole page", async () => {
|
|
68
|
-
const user = userEvent.setup();
|
|
69
|
-
render(<PricingBlock plans={PLANS} />);
|
|
70
|
-
|
|
71
|
-
expect(screen.getByText("Showing monthly pricing.")).toBeInTheDocument();
|
|
72
|
-
await user.click(screen.getByRole("switch", { name: "Bill yearly" }));
|
|
73
|
-
expect(screen.getByText("Showing yearly pricing.")).toBeInTheDocument();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("can hide the period toggle", () => {
|
|
77
|
-
render(<PricingBlock plans={PLANS} showPeriodToggle={false} />);
|
|
78
|
-
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("reports which plan was chosen", async () => {
|
|
82
|
-
const onSelect = vi.fn();
|
|
83
|
-
const user = userEvent.setup();
|
|
84
|
-
render(
|
|
85
|
-
<PricingBlock plans={PLANS.map((p) => (p.id === "pro" ? { ...p, onSelect } : p))} />,
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
await user.click(screen.getByRole("button", { name: "Choose Pro" }));
|
|
89
|
-
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("is a named section", () => {
|
|
93
|
-
render(<PricingBlock plans={PLANS} title="Plans" />);
|
|
94
|
-
expect(screen.getByRole("region", { name: "Plans" })).toBeInTheDocument();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("has no accessibility violations", async () => {
|
|
98
|
-
const { container } = render(<PricingBlock plans={PLANS} />);
|
|
99
|
-
await expectNoA11yViolations(container);
|
|
100
|
-
});
|
|
101
|
-
});
|