@dowel-ui/react 0.1.0 → 0.2.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.
Files changed (195) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +187 -0
  3. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts +77 -0
  4. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts.map +1 -0
  5. package/dist/components/ai-action-ledger/ai-action-ledger.js +224 -0
  6. package/dist/components/ai-action-ledger/ai-action-ledger.js.map +1 -0
  7. package/dist/components/ai-action-ledger/index.d.ts +2 -0
  8. package/dist/components/ai-action-ledger/index.js +2 -0
  9. package/dist/components/ai-action-ledger/meta.js +17 -0
  10. package/dist/components/ai-action-ledger/meta.js.map +1 -0
  11. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  12. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts +45 -0
  13. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
  14. package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
  15. package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
  16. package/dist/components/ai-inline-completion/index.d.ts +2 -0
  17. package/dist/components/ai-inline-completion/index.js +2 -0
  18. package/dist/components/ai-inline-completion/meta.js +17 -0
  19. package/dist/components/ai-inline-completion/meta.js.map +1 -0
  20. package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
  21. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
  22. package/dist/components/ai-structured-output/ai-structured-output.js +126 -0
  23. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
  24. package/dist/components/ai-structured-output/index.d.ts +2 -0
  25. package/dist/components/ai-structured-output/index.js +2 -0
  26. package/dist/components/ai-structured-output/meta.js +17 -0
  27. package/dist/components/ai-structured-output/meta.js.map +1 -0
  28. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  29. package/dist/components/alert/alert.d.ts +1 -1
  30. package/dist/components/avatar/avatar.d.ts +1 -1
  31. package/dist/components/badge/badge.d.ts +1 -1
  32. package/dist/components/button/button.d.ts +1 -1
  33. package/dist/components/meter/index.d.ts +2 -0
  34. package/dist/components/meter/index.js +2 -0
  35. package/dist/components/meter/meta.js +17 -0
  36. package/dist/components/meter/meta.js.map +1 -0
  37. package/dist/components/meter/meter.d.ts +69 -0
  38. package/dist/components/meter/meter.d.ts.map +1 -0
  39. package/dist/components/meter/meter.js +144 -0
  40. package/dist/components/meter/meter.js.map +1 -0
  41. package/dist/components/metric-delta/index.d.ts +2 -0
  42. package/dist/components/metric-delta/index.js +2 -0
  43. package/dist/components/metric-delta/meta.js +17 -0
  44. package/dist/components/metric-delta/meta.js.map +1 -0
  45. package/dist/components/metric-delta/metric-delta.d.ts +59 -0
  46. package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
  47. package/dist/components/metric-delta/metric-delta.js +82 -0
  48. package/dist/components/metric-delta/metric-delta.js.map +1 -0
  49. package/dist/components/progress/progress.d.ts +1 -1
  50. package/dist/components/record-diff/index.d.ts +2 -0
  51. package/dist/components/record-diff/index.js +2 -0
  52. package/dist/components/record-diff/meta.js +17 -0
  53. package/dist/components/record-diff/meta.js.map +1 -0
  54. package/dist/components/record-diff/record-diff.d.ts +56 -0
  55. package/dist/components/record-diff/record-diff.d.ts.map +1 -0
  56. package/dist/components/record-diff/record-diff.js +199 -0
  57. package/dist/components/record-diff/record-diff.js.map +1 -0
  58. package/dist/components/spinner/spinner.d.ts +1 -1
  59. package/dist/components/toast/toast.d.ts +1 -1
  60. package/dist/index.d.ts +13 -1
  61. package/dist/index.js +7 -1
  62. package/dist/registry/components.d.ts.map +1 -1
  63. package/dist/registry/components.js +57 -45
  64. package/dist/registry/components.js.map +1 -1
  65. package/package.json +40 -5
  66. package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
  67. package/src/components/ai-action-ledger/index.ts +14 -0
  68. package/src/components/ai-action-ledger/meta.ts +19 -0
  69. package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
  70. package/src/components/ai-inline-completion/index.ts +1 -0
  71. package/src/components/ai-inline-completion/meta.ts +20 -0
  72. package/src/components/ai-structured-output/ai-structured-output.tsx +244 -0
  73. package/src/components/ai-structured-output/index.ts +10 -0
  74. package/src/components/ai-structured-output/meta.ts +18 -0
  75. package/src/components/meter/index.ts +8 -0
  76. package/src/components/meter/meta.ts +20 -0
  77. package/src/components/meter/meter.tsx +219 -0
  78. package/src/components/metric-delta/index.ts +6 -0
  79. package/src/components/metric-delta/meta.ts +18 -0
  80. package/src/components/metric-delta/metric-delta.tsx +180 -0
  81. package/src/components/record-diff/index.ts +10 -0
  82. package/src/components/record-diff/meta.ts +19 -0
  83. package/src/components/record-diff/record-diff.tsx +341 -0
  84. package/src/index.ts +6 -0
  85. package/src/registry/components.ts +12 -0
  86. package/src/blocks/admin-users/admin-users.stories.tsx +0 -83
  87. package/src/blocks/admin-users/admin-users.test.tsx +0 -159
  88. package/src/blocks/ai-chat/ai-chat.stories.tsx +0 -149
  89. package/src/blocks/ai-chat/ai-chat.test.tsx +0 -166
  90. package/src/blocks/dashboard/dashboard.stories.tsx +0 -106
  91. package/src/blocks/dashboard/dashboard.test.tsx +0 -94
  92. package/src/blocks/forgot-password/forgot-password.stories.tsx +0 -41
  93. package/src/blocks/forgot-password/forgot-password.test.tsx +0 -62
  94. package/src/blocks/login/login.stories.tsx +0 -46
  95. package/src/blocks/login/login.test.tsx +0 -100
  96. package/src/blocks/pricing/pricing.stories.tsx +0 -62
  97. package/src/blocks/pricing/pricing.test.tsx +0 -101
  98. package/src/blocks/settings/settings.stories.tsx +0 -94
  99. package/src/blocks/settings/settings.test.tsx +0 -167
  100. package/src/blocks/signup/signup.stories.tsx +0 -27
  101. package/src/blocks/signup/signup.test.tsx +0 -97
  102. package/src/components/accordion/accordion.stories.tsx +0 -90
  103. package/src/components/accordion/accordion.test.tsx +0 -156
  104. package/src/components/activity-feed/activity-feed.stories.tsx +0 -163
  105. package/src/components/activity-feed/activity-feed.test.tsx +0 -125
  106. package/src/components/ai-agent-status/ai-agent-status.stories.tsx +0 -86
  107. package/src/components/ai-agent-status/ai-agent-status.test.tsx +0 -66
  108. package/src/components/ai-conversation/ai-conversation.stories.tsx +0 -126
  109. package/src/components/ai-conversation/ai-conversation.test.tsx +0 -144
  110. package/src/components/ai-message/ai-message.stories.tsx +0 -106
  111. package/src/components/ai-message/ai-message.test.tsx +0 -160
  112. package/src/components/ai-model-selector/ai-model-selector.stories.tsx +0 -66
  113. package/src/components/ai-model-selector/ai-model-selector.test.tsx +0 -120
  114. package/src/components/ai-prompt-input/ai-prompt-input.stories.tsx +0 -145
  115. package/src/components/ai-prompt-input/ai-prompt-input.test.tsx +0 -273
  116. package/src/components/ai-reasoning/ai-reasoning.stories.tsx +0 -60
  117. package/src/components/ai-reasoning/ai-reasoning.test.tsx +0 -95
  118. package/src/components/ai-response/ai-response.stories.tsx +0 -75
  119. package/src/components/ai-response/ai-response.test.tsx +0 -96
  120. package/src/components/ai-sources/ai-sources.stories.tsx +0 -93
  121. package/src/components/ai-sources/ai-sources.test.tsx +0 -102
  122. package/src/components/ai-token-usage/ai-token-usage.stories.tsx +0 -59
  123. package/src/components/ai-token-usage/ai-token-usage.test.tsx +0 -83
  124. package/src/components/ai-tool/ai-tool.stories.tsx +0 -96
  125. package/src/components/ai-tool/ai-tool.test.tsx +0 -137
  126. package/src/components/alert/alert.stories.tsx +0 -101
  127. package/src/components/alert/alert.test.tsx +0 -94
  128. package/src/components/avatar/avatar.stories.tsx +0 -58
  129. package/src/components/avatar/avatar.test.tsx +0 -64
  130. package/src/components/badge/badge.stories.tsx +0 -68
  131. package/src/components/badge/badge.test.tsx +0 -61
  132. package/src/components/button/button.stories.tsx +0 -128
  133. package/src/components/button/button.test.tsx +0 -224
  134. package/src/components/calendar/calendar.stories.tsx +0 -105
  135. package/src/components/calendar/calendar.test.tsx +0 -255
  136. package/src/components/card/card.stories.tsx +0 -61
  137. package/src/components/card/card.test.tsx +0 -66
  138. package/src/components/checkbox/checkbox.stories.tsx +0 -124
  139. package/src/components/checkbox/checkbox.test.tsx +0 -121
  140. package/src/components/code-block/code-block.stories.tsx +0 -62
  141. package/src/components/code-block/code-block.test.tsx +0 -139
  142. package/src/components/combobox/combobox.stories.tsx +0 -171
  143. package/src/components/combobox/combobox.test.tsx +0 -429
  144. package/src/components/command/command.stories.tsx +0 -152
  145. package/src/components/command/command.test.tsx +0 -380
  146. package/src/components/data-table/data-table.stories.tsx +0 -151
  147. package/src/components/data-table/data-table.test.tsx +0 -288
  148. package/src/components/date-picker/date-picker.stories.tsx +0 -103
  149. package/src/components/date-picker/date-picker.test.tsx +0 -205
  150. package/src/components/dialog/dialog.stories.tsx +0 -140
  151. package/src/components/dialog/dialog.test.tsx +0 -186
  152. package/src/components/drawer/drawer.stories.tsx +0 -111
  153. package/src/components/drawer/drawer.test.tsx +0 -187
  154. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +0 -128
  155. package/src/components/dropdown-menu/dropdown-menu.test.tsx +0 -321
  156. package/src/components/empty-state/empty-state.stories.tsx +0 -70
  157. package/src/components/empty-state/empty-state.test.tsx +0 -97
  158. package/src/components/form/form.stories.tsx +0 -144
  159. package/src/components/form/form.test.tsx +0 -203
  160. package/src/components/input/input.stories.tsx +0 -84
  161. package/src/components/input/input.test.tsx +0 -108
  162. package/src/components/label/label.stories.tsx +0 -51
  163. package/src/components/label/label.test.tsx +0 -55
  164. package/src/components/pagination/pagination.stories.tsx +0 -129
  165. package/src/components/pagination/pagination.test.tsx +0 -119
  166. package/src/components/popover/popover.stories.tsx +0 -84
  167. package/src/components/popover/popover.test.tsx +0 -163
  168. package/src/components/progress/progress.stories.tsx +0 -90
  169. package/src/components/progress/progress.test.tsx +0 -78
  170. package/src/components/radio-group/radio-group.stories.tsx +0 -102
  171. package/src/components/radio-group/radio-group.test.tsx +0 -152
  172. package/src/components/select/select.stories.tsx +0 -137
  173. package/src/components/select/select.test.tsx +0 -209
  174. package/src/components/separator/separator.stories.tsx +0 -42
  175. package/src/components/separator/separator.test.tsx +0 -46
  176. package/src/components/sheet/sheet.stories.tsx +0 -107
  177. package/src/components/sheet/sheet.test.tsx +0 -152
  178. package/src/components/skeleton/skeleton.stories.tsx +0 -46
  179. package/src/components/skeleton/skeleton.test.tsx +0 -57
  180. package/src/components/slider/slider.stories.tsx +0 -87
  181. package/src/components/slider/slider.test.tsx +0 -148
  182. package/src/components/spinner/spinner.stories.tsx +0 -47
  183. package/src/components/spinner/spinner.test.tsx +0 -41
  184. package/src/components/switch/switch.stories.tsx +0 -70
  185. package/src/components/switch/switch.test.tsx +0 -101
  186. package/src/components/table/table.stories.tsx +0 -131
  187. package/src/components/table/table.test.tsx +0 -153
  188. package/src/components/tabs/tabs.stories.tsx +0 -139
  189. package/src/components/tabs/tabs.test.tsx +0 -148
  190. package/src/components/toast/toast.stories.tsx +0 -167
  191. package/src/components/toast/toast.test.tsx +0 -306
  192. package/src/components/tooltip/tooltip.stories.tsx +0 -89
  193. package/src/components/tooltip/tooltip.test.tsx +0 -120
  194. package/src/lib/utils.test.ts +0 -32
  195. package/src/registry/meta.test.ts +0 -234
@@ -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
- });
@@ -1,94 +0,0 @@
1
- import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
2
- import { useState } from "react";
3
-
4
- import { SettingsBlock, type SettingsNotification } from "./settings";
5
-
6
- /** Named so its type is nameable in declaration output (TS2883). */
7
- const withPageWidth: Decorator = (Story) => (
8
- <div className="w-[48rem] max-w-full">
9
- <Story />
10
- </div>
11
- );
12
-
13
- const PROFILE = {
14
- name: "Ada Lovelace",
15
- email: "ada@example.com",
16
- bio: "Working on analytical engines.",
17
- };
18
-
19
- const NOTIFICATIONS: SettingsNotification[] = [
20
- {
21
- id: "deploys",
22
- label: "Deployment updates",
23
- description: "When a deploy starts or finishes.",
24
- enabled: true,
25
- },
26
- {
27
- id: "failures",
28
- label: "Build failures",
29
- description: "Only when something breaks.",
30
- enabled: true,
31
- },
32
- {
33
- id: "digest",
34
- label: "Weekly digest",
35
- description: "A summary every Monday morning.",
36
- enabled: false,
37
- },
38
- ];
39
-
40
- const meta = {
41
- title: "Blocks/Settings",
42
- component: SettingsBlock,
43
- args: { profile: PROFILE, notifications: NOTIFICATIONS },
44
- parameters: { controls: { disable: true } },
45
- decorators: [withPageWidth],
46
- } satisfies Meta<typeof SettingsBlock>;
47
-
48
- export default meta;
49
- type Story = StoryObj<typeof meta>;
50
-
51
- export const Default: Story = {};
52
-
53
- /** Switches apply immediately. There is no Save button, and that is the point. */
54
- export const Interactive: Story = {
55
- render: function Interactive() {
56
- const [notifications, setNotifications] = useState(NOTIFICATIONS);
57
- const [pending, setPending] = useState(false);
58
-
59
- return (
60
- <SettingsBlock
61
- profile={PROFILE}
62
- notifications={notifications}
63
- pending={pending}
64
- onToggleNotification={(id, enabled) => {
65
- setNotifications((current) =>
66
- current.map((item) => (item.id === id ? { ...item, enabled } : item)),
67
- );
68
- }}
69
- onSaveProfile={() => {
70
- setPending(true);
71
- window.setTimeout(() => {
72
- setPending(false);
73
- }, 1000);
74
- }}
75
- />
76
- );
77
- },
78
- };
79
-
80
- export const WithExtraTab: Story = {
81
- args: {
82
- extraTabs: [
83
- {
84
- value: "billing",
85
- label: "Billing",
86
- content: (
87
- <p className="p-6 text-sm text-muted-foreground">
88
- You are on the Pro plan, billed yearly. Next invoice 1 October.
89
- </p>
90
- ),
91
- },
92
- ],
93
- },
94
- };