@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.
Files changed (120) hide show
  1. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  2. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  3. package/dist/components/alert/alert.d.ts +1 -1
  4. package/dist/components/avatar/avatar.d.ts +1 -1
  5. package/dist/components/badge/badge.d.ts +1 -1
  6. package/dist/components/button/button.d.ts +1 -1
  7. package/dist/components/progress/progress.d.ts +1 -1
  8. package/dist/components/spinner/spinner.d.ts +1 -1
  9. package/dist/components/toast/toast.d.ts +1 -1
  10. package/package.json +7 -4
  11. package/src/blocks/admin-users/admin-users.stories.tsx +0 -83
  12. package/src/blocks/admin-users/admin-users.test.tsx +0 -159
  13. package/src/blocks/ai-chat/ai-chat.stories.tsx +0 -149
  14. package/src/blocks/ai-chat/ai-chat.test.tsx +0 -166
  15. package/src/blocks/dashboard/dashboard.stories.tsx +0 -106
  16. package/src/blocks/dashboard/dashboard.test.tsx +0 -94
  17. package/src/blocks/forgot-password/forgot-password.stories.tsx +0 -41
  18. package/src/blocks/forgot-password/forgot-password.test.tsx +0 -62
  19. package/src/blocks/login/login.stories.tsx +0 -46
  20. package/src/blocks/login/login.test.tsx +0 -100
  21. package/src/blocks/pricing/pricing.stories.tsx +0 -62
  22. package/src/blocks/pricing/pricing.test.tsx +0 -101
  23. package/src/blocks/settings/settings.stories.tsx +0 -94
  24. package/src/blocks/settings/settings.test.tsx +0 -167
  25. package/src/blocks/signup/signup.stories.tsx +0 -27
  26. package/src/blocks/signup/signup.test.tsx +0 -97
  27. package/src/components/accordion/accordion.stories.tsx +0 -90
  28. package/src/components/accordion/accordion.test.tsx +0 -156
  29. package/src/components/activity-feed/activity-feed.stories.tsx +0 -163
  30. package/src/components/activity-feed/activity-feed.test.tsx +0 -125
  31. package/src/components/ai-agent-status/ai-agent-status.stories.tsx +0 -86
  32. package/src/components/ai-agent-status/ai-agent-status.test.tsx +0 -66
  33. package/src/components/ai-conversation/ai-conversation.stories.tsx +0 -126
  34. package/src/components/ai-conversation/ai-conversation.test.tsx +0 -144
  35. package/src/components/ai-message/ai-message.stories.tsx +0 -106
  36. package/src/components/ai-message/ai-message.test.tsx +0 -160
  37. package/src/components/ai-model-selector/ai-model-selector.stories.tsx +0 -66
  38. package/src/components/ai-model-selector/ai-model-selector.test.tsx +0 -120
  39. package/src/components/ai-prompt-input/ai-prompt-input.stories.tsx +0 -145
  40. package/src/components/ai-prompt-input/ai-prompt-input.test.tsx +0 -273
  41. package/src/components/ai-reasoning/ai-reasoning.stories.tsx +0 -60
  42. package/src/components/ai-reasoning/ai-reasoning.test.tsx +0 -95
  43. package/src/components/ai-response/ai-response.stories.tsx +0 -75
  44. package/src/components/ai-response/ai-response.test.tsx +0 -96
  45. package/src/components/ai-sources/ai-sources.stories.tsx +0 -93
  46. package/src/components/ai-sources/ai-sources.test.tsx +0 -102
  47. package/src/components/ai-token-usage/ai-token-usage.stories.tsx +0 -59
  48. package/src/components/ai-token-usage/ai-token-usage.test.tsx +0 -83
  49. package/src/components/ai-tool/ai-tool.stories.tsx +0 -96
  50. package/src/components/ai-tool/ai-tool.test.tsx +0 -137
  51. package/src/components/alert/alert.stories.tsx +0 -101
  52. package/src/components/alert/alert.test.tsx +0 -94
  53. package/src/components/avatar/avatar.stories.tsx +0 -58
  54. package/src/components/avatar/avatar.test.tsx +0 -64
  55. package/src/components/badge/badge.stories.tsx +0 -68
  56. package/src/components/badge/badge.test.tsx +0 -61
  57. package/src/components/button/button.stories.tsx +0 -128
  58. package/src/components/button/button.test.tsx +0 -224
  59. package/src/components/calendar/calendar.stories.tsx +0 -105
  60. package/src/components/calendar/calendar.test.tsx +0 -255
  61. package/src/components/card/card.stories.tsx +0 -61
  62. package/src/components/card/card.test.tsx +0 -66
  63. package/src/components/checkbox/checkbox.stories.tsx +0 -124
  64. package/src/components/checkbox/checkbox.test.tsx +0 -121
  65. package/src/components/code-block/code-block.stories.tsx +0 -62
  66. package/src/components/code-block/code-block.test.tsx +0 -139
  67. package/src/components/combobox/combobox.stories.tsx +0 -171
  68. package/src/components/combobox/combobox.test.tsx +0 -429
  69. package/src/components/command/command.stories.tsx +0 -152
  70. package/src/components/command/command.test.tsx +0 -380
  71. package/src/components/data-table/data-table.stories.tsx +0 -151
  72. package/src/components/data-table/data-table.test.tsx +0 -288
  73. package/src/components/date-picker/date-picker.stories.tsx +0 -103
  74. package/src/components/date-picker/date-picker.test.tsx +0 -205
  75. package/src/components/dialog/dialog.stories.tsx +0 -140
  76. package/src/components/dialog/dialog.test.tsx +0 -186
  77. package/src/components/drawer/drawer.stories.tsx +0 -111
  78. package/src/components/drawer/drawer.test.tsx +0 -187
  79. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +0 -128
  80. package/src/components/dropdown-menu/dropdown-menu.test.tsx +0 -321
  81. package/src/components/empty-state/empty-state.stories.tsx +0 -70
  82. package/src/components/empty-state/empty-state.test.tsx +0 -97
  83. package/src/components/form/form.stories.tsx +0 -144
  84. package/src/components/form/form.test.tsx +0 -203
  85. package/src/components/input/input.stories.tsx +0 -84
  86. package/src/components/input/input.test.tsx +0 -108
  87. package/src/components/label/label.stories.tsx +0 -51
  88. package/src/components/label/label.test.tsx +0 -55
  89. package/src/components/pagination/pagination.stories.tsx +0 -129
  90. package/src/components/pagination/pagination.test.tsx +0 -119
  91. package/src/components/popover/popover.stories.tsx +0 -84
  92. package/src/components/popover/popover.test.tsx +0 -163
  93. package/src/components/progress/progress.stories.tsx +0 -90
  94. package/src/components/progress/progress.test.tsx +0 -78
  95. package/src/components/radio-group/radio-group.stories.tsx +0 -102
  96. package/src/components/radio-group/radio-group.test.tsx +0 -152
  97. package/src/components/select/select.stories.tsx +0 -137
  98. package/src/components/select/select.test.tsx +0 -209
  99. package/src/components/separator/separator.stories.tsx +0 -42
  100. package/src/components/separator/separator.test.tsx +0 -46
  101. package/src/components/sheet/sheet.stories.tsx +0 -107
  102. package/src/components/sheet/sheet.test.tsx +0 -152
  103. package/src/components/skeleton/skeleton.stories.tsx +0 -46
  104. package/src/components/skeleton/skeleton.test.tsx +0 -57
  105. package/src/components/slider/slider.stories.tsx +0 -87
  106. package/src/components/slider/slider.test.tsx +0 -148
  107. package/src/components/spinner/spinner.stories.tsx +0 -47
  108. package/src/components/spinner/spinner.test.tsx +0 -41
  109. package/src/components/switch/switch.stories.tsx +0 -70
  110. package/src/components/switch/switch.test.tsx +0 -101
  111. package/src/components/table/table.stories.tsx +0 -131
  112. package/src/components/table/table.test.tsx +0 -153
  113. package/src/components/tabs/tabs.stories.tsx +0 -139
  114. package/src/components/tabs/tabs.test.tsx +0 -148
  115. package/src/components/toast/toast.stories.tsx +0 -167
  116. package/src/components/toast/toast.test.tsx +0 -306
  117. package/src/components/tooltip/tooltip.stories.tsx +0 -89
  118. package/src/components/tooltip/tooltip.test.tsx +0 -120
  119. package/src/lib/utils.test.ts +0 -32
  120. package/src/registry/meta.test.ts +0 -234
@@ -10,7 +10,7 @@ import { ComponentPropsWithRef } from "react";
10
10
  */
11
11
  type AgentState = "idle" | "thinking" | "working" | "waiting" | "done" | "error";
12
12
  declare const agentStatusVariants: (props?: ({
13
- state?: "done" | "error" | "idle" | "thinking" | "working" | "waiting" | null | undefined;
13
+ state?: "done" | "idle" | "thinking" | "working" | "waiting" | "error" | null | undefined;
14
14
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
15
15
  interface AgentStatusProps extends ComponentPropsWithRef<"span">, VariantProps<typeof agentStatusVariants> {
16
16
  state?: AgentState;
@@ -14,7 +14,7 @@ import { ComponentPropsWithRef, ReactNode } from "react";
14
14
  */
15
15
  type ToolStatus = "pending" | "running" | "success" | "error";
16
16
  declare const toolVariants: (props?: ({
17
- status?: "success" | "pending" | "running" | "error" | null | undefined;
17
+ status?: "error" | "pending" | "running" | "success" | null | undefined;
18
18
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
19
19
  interface ToolProps extends ComponentPropsWithRef<typeof Collapsible.Root>, VariantProps<typeof toolVariants> {
20
20
  status?: ToolStatus;
@@ -2,7 +2,7 @@ import { VariantProps } from "class-variance-authority";
2
2
  import { ComponentPropsWithRef } from "react";
3
3
  //#region src/components/alert/alert.d.ts
4
4
  declare const alertVariants: (props?: ({
5
- variant?: "default" | "destructive" | "success" | "warning" | "info" | null | undefined;
5
+ variant?: "success" | "warning" | "destructive" | "default" | "info" | null | undefined;
6
6
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
7
7
  /**
8
8
  * How urgently the alert should interrupt a screen reader.
@@ -3,7 +3,7 @@ import { VariantProps } from "class-variance-authority";
3
3
  import { ComponentPropsWithRef } from "react";
4
4
  //#region src/components/avatar/avatar.d.ts
5
5
  declare const avatarVariants: (props?: ({
6
- size?: "xs" | "sm" | "md" | "lg" | "xl" | null | undefined;
6
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | null | undefined;
7
7
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
8
  interface AvatarProps extends ComponentPropsWithRef<typeof Avatar.Root>, VariantProps<typeof avatarVariants> {}
9
9
  /** Image representation of a user or entity, with a text fallback. */
@@ -2,7 +2,7 @@ import { VariantProps } from "class-variance-authority";
2
2
  import { ComponentPropsWithRef } from "react";
3
3
  //#region src/components/badge/badge.d.ts
4
4
  declare const badgeVariants: (props?: ({
5
- variant?: "default" | "destructive" | "success" | "warning" | "info" | "secondary" | "outline" | null | undefined;
5
+ variant?: "success" | "warning" | "destructive" | "default" | "info" | "secondary" | "outline" | null | undefined;
6
6
  size?: "sm" | "md" | null | undefined;
7
7
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
8
  interface BadgeProps extends ComponentPropsWithRef<"span">, VariantProps<typeof badgeVariants> {
@@ -2,7 +2,7 @@ import { VariantProps } from "class-variance-authority";
2
2
  import { ComponentPropsWithRef } from "react";
3
3
  //#region src/components/button/button.d.ts
4
4
  declare const buttonVariants: (props?: ({
5
- variant?: "link" | "destructive" | "primary" | "secondary" | "outline" | "ghost" | null | undefined;
5
+ variant?: "link" | "primary" | "destructive" | "secondary" | "outline" | "ghost" | null | undefined;
6
6
  size?: "sm" | "md" | "lg" | "icon" | "icon-sm" | null | undefined;
7
7
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
8
  interface ButtonProps extends ComponentPropsWithRef<"button">, VariantProps<typeof buttonVariants> {
@@ -4,7 +4,7 @@ import { ComponentPropsWithRef } from "react";
4
4
  //#region src/components/progress/progress.d.ts
5
5
  declare const progressVariants: (props?: ({
6
6
  size?: "sm" | "md" | "lg" | null | undefined;
7
- tone?: "destructive" | "success" | "warning" | "primary" | null | undefined;
7
+ tone?: "success" | "primary" | "warning" | "destructive" | null | undefined;
8
8
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
9
9
  interface ProgressProps extends ComponentPropsWithRef<typeof Progress.Root>, VariantProps<typeof progressVariants> {}
10
10
  /**
@@ -2,7 +2,7 @@ import { VariantProps } from "class-variance-authority";
2
2
  import { ComponentPropsWithRef } from "react";
3
3
  //#region src/components/spinner/spinner.d.ts
4
4
  declare const spinnerVariants: (props?: ({
5
- size?: "xs" | "sm" | "md" | "lg" | "xl" | null | undefined;
5
+ size?: "sm" | "md" | "lg" | "xs" | "xl" | null | undefined;
6
6
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
7
7
  interface SpinnerProps extends Omit<ComponentPropsWithRef<"svg">, "children">, VariantProps<typeof spinnerVariants> {
8
8
  /**
@@ -12,7 +12,7 @@ import { ComponentPropsWithRef } from "react";
12
12
  * toast list from anywhere on the page.
13
13
  */
14
14
  declare const toastVariants: (props?: ({
15
- variant?: "default" | "destructive" | "success" | "warning" | "info" | null | undefined;
15
+ variant?: "success" | "warning" | "destructive" | "default" | "info" | null | undefined;
16
16
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
17
17
  declare const ToastProvider: import("react").FC<Toast.ToastProviderProps>;
18
18
  interface ToastProps extends ComponentPropsWithRef<typeof Toast.Root>, VariantProps<typeof toastVariants> {}
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@dowel-ui/react",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Source-first React components.",
6
6
  "sideEffects": false,
7
7
  "files": [
8
8
  "dist",
9
- "src"
9
+ "src",
10
+ "!src/**/*.test.ts",
11
+ "!src/**/*.test.tsx",
12
+ "!src/**/*.stories.tsx"
10
13
  ],
11
14
  "exports": {
12
15
  ".": {
@@ -58,8 +61,8 @@
58
61
  "vite": "8.2.2",
59
62
  "vitest": "4.1.10",
60
63
  "zod": "4.5.4",
61
- "@dowel-ui/config": "0.1.0",
62
- "@dowel-ui/themes": "0.1.0"
64
+ "@dowel-ui/themes": "0.1.1",
65
+ "@dowel-ui/config": "0.1.1"
63
66
  },
64
67
  "scripts": {
65
68
  "build": "tsdown",
@@ -1,83 +0,0 @@
1
- import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
2
-
3
- import { AdminUsersBlock, type AdminUser } from "./admin-users";
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 USERS: AdminUser[] = [
13
- {
14
- id: "1",
15
- name: "Ada Lovelace",
16
- email: "ada@example.com",
17
- role: "Owner",
18
- status: "Active",
19
- lastActive: "2 hours ago",
20
- },
21
- {
22
- id: "2",
23
- name: "Grace Hopper",
24
- email: "grace@example.com",
25
- role: "Admin",
26
- status: "Active",
27
- lastActive: "Yesterday",
28
- },
29
- {
30
- id: "3",
31
- name: "Katherine Johnson",
32
- email: "kj@example.com",
33
- role: "Member",
34
- status: "Invited",
35
- lastActive: "Never",
36
- },
37
- {
38
- id: "4",
39
- name: "Radia Perlman",
40
- email: "radia@example.com",
41
- role: "Viewer",
42
- status: "Suspended",
43
- lastActive: "3 weeks ago",
44
- },
45
- {
46
- id: "5",
47
- name: "Barbara Liskov",
48
- email: "barbara@example.com",
49
- role: "Member",
50
- status: "Active",
51
- lastActive: "5 minutes ago",
52
- },
53
- {
54
- id: "6",
55
- name: "Margaret Hamilton",
56
- email: "margaret@example.com",
57
- role: "Admin",
58
- status: "Active",
59
- lastActive: "Last week",
60
- },
61
- ];
62
-
63
- const meta = {
64
- title: "Blocks/Admin — users",
65
- component: AdminUsersBlock,
66
- args: { users: USERS },
67
- parameters: { controls: { disable: true } },
68
- decorators: [withPageWidth],
69
- } satisfies Meta<typeof AdminUsersBlock>;
70
-
71
- export default meta;
72
- type Story = StoryObj<typeof meta>;
73
-
74
- /** Filter, sort and hide columns. The matching count is announced as you type. */
75
- export const Default: Story = {};
76
-
77
- export const Empty: Story = {
78
- args: { users: [] },
79
- };
80
-
81
- export const SinglePerson: Story = {
82
- args: { users: [USERS[0]!] },
83
- };
@@ -1,159 +0,0 @@
1
- import { render, screen, waitFor, within } 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 { AdminUsersBlock, type AdminUser } from "./admin-users";
7
-
8
- const USERS: AdminUser[] = [
9
- {
10
- id: "1",
11
- name: "Ada Lovelace",
12
- email: "ada@example.com",
13
- role: "Owner",
14
- status: "Active",
15
- lastActive: "2 hours ago",
16
- },
17
- {
18
- id: "2",
19
- name: "Grace Hopper",
20
- email: "grace@example.com",
21
- role: "Admin",
22
- status: "Active",
23
- lastActive: "Yesterday",
24
- },
25
- {
26
- id: "3",
27
- name: "Katherine Johnson",
28
- email: "kj@example.com",
29
- role: "Member",
30
- status: "Invited",
31
- lastActive: "Never",
32
- },
33
- {
34
- id: "4",
35
- name: "Radia Perlman",
36
- email: "radia@example.com",
37
- role: "Viewer",
38
- status: "Suspended",
39
- lastActive: "3 weeks ago",
40
- },
41
- ];
42
-
43
- function rowCount(): number {
44
- const body = screen.getAllByRole("rowgroup")[1];
45
- return body ? within(body).queryAllByRole("row").length : 0;
46
- }
47
-
48
- describe("AdminUsersBlock", () => {
49
- it("renders a row per user", () => {
50
- render(<AdminUsersBlock users={USERS} />);
51
- expect(rowCount()).toBe(4);
52
- });
53
-
54
- it("gives the filter a real label, not just a placeholder", () => {
55
- render(<AdminUsersBlock users={USERS} />);
56
- // A placeholder disappears the moment anyone types, taking the field's name
57
- // with it.
58
- expect(screen.getByLabelText("Filter by name or email")).toBeInTheDocument();
59
- });
60
-
61
- it("filters by name and by email", async () => {
62
- const user = userEvent.setup();
63
- render(<AdminUsersBlock users={USERS} />);
64
-
65
- await user.type(screen.getByLabelText("Filter by name or email"), "grace");
66
- await waitFor(() => {
67
- expect(rowCount()).toBe(1);
68
- });
69
- expect(screen.getByText("Grace Hopper")).toBeInTheDocument();
70
-
71
- await user.clear(screen.getByLabelText("Filter by name or email"));
72
- await user.type(screen.getByLabelText("Filter by name or email"), "kj@example");
73
- await waitFor(() => {
74
- expect(screen.getByText("Katherine Johnson")).toBeInTheDocument();
75
- });
76
- });
77
-
78
- it("announces the matching count, since results change unbidden", async () => {
79
- const user = userEvent.setup();
80
- const { container } = render(<AdminUsersBlock users={USERS} />);
81
-
82
- const status = container.querySelector("[aria-live='polite']");
83
- expect(status).toHaveTextContent("4 people match the filter.");
84
-
85
- await user.type(screen.getByLabelText("Filter by name or email"), "grace");
86
- await waitFor(() => {
87
- expect(status).toHaveTextContent("1 person match");
88
- });
89
- });
90
-
91
- it("shows an empty state naming the query that found nothing", async () => {
92
- const user = userEvent.setup();
93
- render(<AdminUsersBlock users={USERS} />);
94
-
95
- await user.type(screen.getByLabelText("Filter by name or email"), "zzzz");
96
- expect(await screen.findByText(/No one matches/)).toBeInTheDocument();
97
- });
98
-
99
- it("names each row's action button after its row", () => {
100
- render(<AdminUsersBlock users={USERS} />);
101
- // Four identical "Actions" buttons say nothing about which row you are on.
102
- expect(
103
- screen.getByRole("button", { name: "Actions for Ada Lovelace" }),
104
- ).toBeInTheDocument();
105
- expect(
106
- screen.getByRole("button", { name: "Actions for Grace Hopper" }),
107
- ).toBeInTheDocument();
108
- });
109
-
110
- it("runs the row actions", async () => {
111
- const onEdit = vi.fn();
112
- const onRemove = vi.fn();
113
- const user = userEvent.setup();
114
- render(<AdminUsersBlock users={USERS} onEdit={onEdit} onRemove={onRemove} />);
115
-
116
- await user.click(screen.getByRole("button", { name: "Actions for Ada Lovelace" }));
117
- await user.click(await screen.findByRole("menuitem", { name: "Edit member" }));
118
- expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ name: "Ada Lovelace" }));
119
-
120
- await user.click(screen.getByRole("button", { name: "Actions for Grace Hopper" }));
121
- await user.click(await screen.findByRole("menuitem", { name: "Remove from team" }));
122
- expect(onRemove).toHaveBeenCalledWith(expect.objectContaining({ name: "Grace Hopper" }));
123
- });
124
-
125
- it("states each status in words", () => {
126
- render(<AdminUsersBlock users={USERS} />);
127
- expect(screen.getByText("Suspended")).toBeInTheDocument();
128
- expect(screen.getByText("Invited")).toBeInTheDocument();
129
- });
130
-
131
- it("sorts by a column", async () => {
132
- const user = userEvent.setup();
133
- render(<AdminUsersBlock users={USERS} />);
134
-
135
- await user.click(screen.getByRole("button", { name: /User, not sorted/ }));
136
- await user.click(await screen.findByRole("menuitem", { name: "Sort descending" }));
137
-
138
- await waitFor(() => {
139
- expect(screen.getByRole("columnheader", { name: /User/ })).toHaveAttribute(
140
- "aria-sort",
141
- "descending",
142
- );
143
- });
144
- });
145
-
146
- it("invites people", async () => {
147
- const onInvite = vi.fn();
148
- const user = userEvent.setup();
149
- render(<AdminUsersBlock users={USERS} onInvite={onInvite} />);
150
-
151
- await user.click(screen.getByRole("button", { name: "Invite people" }));
152
- expect(onInvite).toHaveBeenCalledTimes(1);
153
- });
154
-
155
- it("has no accessibility violations", async () => {
156
- const { container } = render(<AdminUsersBlock users={USERS} />);
157
- await expectNoA11yViolations(container);
158
- });
159
- });
@@ -1,149 +0,0 @@
1
- import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
2
- import { useState } from "react";
3
-
4
- import { AiChatBlock, type ChatMessage } from "./ai-chat";
5
-
6
- /** Named so its type is nameable in declaration output (TS2883). */
7
- const withChatFrame: Decorator = (Story) => (
8
- <div className="h-[36rem] w-[42rem] max-w-full overflow-hidden rounded-xl border border-border">
9
- <Story />
10
- </div>
11
- );
12
-
13
- const MODELS = [
14
- { id: "fast", name: "Fast", description: "Lowest latency." },
15
- { id: "balanced", name: "Balanced", description: "The default." },
16
- { id: "deep", name: "Deep Research", description: "Multi-step, with citations." },
17
- ];
18
-
19
- const CONVERSATION: ChatMessage[] = [
20
- { id: "1", from: "user", content: "How do I make an overflowing table keyboard-scrollable?" },
21
- {
22
- id: "2",
23
- from: "assistant",
24
- content:
25
- 'Give the scrolling wrapper tabindex="0", a role of "region" and an accessible name. Without a focus stop, the columns past the edge are unreachable without a pointer.',
26
- reasoning:
27
- "The question is about keyboard access to overflowing tables.\n\nA scroll container is not focusable by default, so its content past the edge cannot be reached without a pointer. Adding a focus stop with a role and a name fixes both the reachability and the unlabelled-stop problem.",
28
- tools: [
29
- {
30
- id: "t1",
31
- name: "search_docs",
32
- status: "success",
33
- arguments: JSON.stringify({ query: "scrollable region keyboard", limit: 5 }, null, 2),
34
- result: JSON.stringify({ hits: 3, top: "ARIA Authoring Practices" }, null, 2),
35
- },
36
- ],
37
- sources: [
38
- {
39
- index: 1,
40
- title: "ARIA Authoring Practices — scrollable regions",
41
- origin: "w3.org",
42
- href: "#1",
43
- },
44
- { index: 2, title: "Accessible data tables", origin: "example.org", href: "#2" },
45
- ],
46
- tokens: 148,
47
- },
48
- ];
49
-
50
- const meta = {
51
- title: "Blocks/AI Chat",
52
- component: AiChatBlock,
53
- args: { messages: CONVERSATION, models: MODELS, model: "balanced" },
54
- parameters: { controls: { disable: true } },
55
- decorators: [withChatFrame],
56
- } satisfies Meta<typeof AiChatBlock>;
57
-
58
- export default meta;
59
- type Story = StoryObj<typeof meta>;
60
-
61
- export const Default: Story = {};
62
-
63
- export const Empty: Story = {
64
- args: { messages: [] },
65
- };
66
-
67
- export const Waiting: Story = {
68
- args: { messages: [CONVERSATION[0]!], waiting: true },
69
- };
70
-
71
- export const Streaming: Story = {
72
- args: {
73
- messages: [
74
- CONVERSATION[0]!,
75
- {
76
- id: "2",
77
- from: "assistant",
78
- content: 'Give the scrolling wrapper tabindex="0", a role',
79
- streaming: true,
80
- },
81
- ],
82
- busy: true,
83
- },
84
- };
85
-
86
- /** Send a message and watch it stream back. */
87
- export const Interactive: Story = {
88
- render: function Interactive() {
89
- const [messages, setMessages] = useState<ChatMessage[]>([]);
90
- const [waiting, setWaiting] = useState(false);
91
- const [busy, setBusy] = useState(false);
92
-
93
- function reply(text: string) {
94
- const answer = `You asked: “${text}”. In a real product this is where the response would stream in, one token at a time.`;
95
- let shown = 0;
96
-
97
- const id = String(Date.now());
98
- setWaiting(false);
99
- setBusy(true);
100
- setMessages((current) => [
101
- ...current,
102
- { id, from: "assistant", content: "", streaming: true },
103
- ]);
104
-
105
- const timer = window.setInterval(() => {
106
- shown += 2;
107
- const done = shown >= answer.length;
108
-
109
- setMessages((current) =>
110
- current.map((message) =>
111
- message.id === id
112
- ? {
113
- ...message,
114
- content: answer.slice(0, shown),
115
- streaming: !done,
116
- tokens: done ? 42 : undefined,
117
- }
118
- : message,
119
- ),
120
- );
121
-
122
- if (done) {
123
- window.clearInterval(timer);
124
- setBusy(false);
125
- }
126
- }, 30);
127
- }
128
-
129
- return (
130
- <AiChatBlock
131
- messages={messages}
132
- models={MODELS}
133
- model="balanced"
134
- busy={busy}
135
- waiting={waiting}
136
- onSend={(text) => {
137
- setMessages((current) => [
138
- ...current,
139
- { id: `u${String(current.length)}`, from: "user", content: text },
140
- ]);
141
- setWaiting(true);
142
- window.setTimeout(() => {
143
- reply(text);
144
- }, 700);
145
- }}
146
- />
147
- );
148
- },
149
- };
@@ -1,166 +0,0 @@
1
- import { render, screen, waitFor } 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 { AiChatBlock, type ChatMessage } from "./ai-chat";
7
-
8
- const MESSAGES: ChatMessage[] = [
9
- { id: "1", from: "user", content: "How do I make an overflowing table keyboard-scrollable?" },
10
- {
11
- id: "2",
12
- from: "assistant",
13
- content: 'Give the scrolling wrapper tabindex="0", a role and an accessible name.',
14
- reasoning: "The container is not focusable by default, so its overflow is unreachable.",
15
- tools: [
16
- {
17
- id: "t1",
18
- name: "search_docs",
19
- status: "success",
20
- arguments: '{ "query": "scrollable region" }',
21
- result: '{ "hits": 3 }',
22
- },
23
- ],
24
- sources: [{ index: 1, title: "ARIA Authoring Practices", origin: "w3.org", href: "#1" }],
25
- tokens: 148,
26
- },
27
- ];
28
-
29
- describe("AiChatBlock", () => {
30
- it("shows an empty state before anything is said", () => {
31
- render(<AiChatBlock messages={[]} />);
32
- expect(screen.getByText("Start a conversation")).toBeInTheDocument();
33
- });
34
-
35
- it("renders the transcript as a list, never a live region", () => {
36
- const { container } = render(<AiChatBlock messages={MESSAGES} />);
37
-
38
- const list = container.querySelector("[data-slot='conversation-messages']");
39
- expect(list?.tagName).toBe("OL");
40
- // The decision the whole AI layer rests on: announcing streamed text token
41
- // by token is unusable with a screen reader.
42
- expect(list).not.toHaveAttribute("aria-live");
43
- });
44
-
45
- it("names the speaker of every turn in text", () => {
46
- render(<AiChatBlock messages={MESSAGES} />);
47
- expect(screen.getByText("You said:")).toBeInTheDocument();
48
- expect(screen.getByText("Assistant said:")).toBeInTheDocument();
49
- });
50
-
51
- it("announces state, not content", () => {
52
- const { container } = render(<AiChatBlock messages={MESSAGES} busy />);
53
-
54
- const status = container.querySelector("[data-slot='conversation-status']");
55
- expect(status).toHaveAttribute("aria-live", "polite");
56
- expect(status).toHaveTextContent("Generating response");
57
- expect(status).not.toHaveTextContent("tabindex");
58
- });
59
-
60
- it("distinguishes waiting from generating", () => {
61
- const { container } = render(<AiChatBlock messages={MESSAGES} waiting />);
62
- expect(container.querySelector("[data-slot='conversation-status']")).toHaveTextContent(
63
- "Waiting for a response",
64
- );
65
- expect(screen.getByText("Thinking")).toBeInTheDocument();
66
- });
67
-
68
- it("collapses reasoning and tool calls by default", () => {
69
- render(<AiChatBlock messages={MESSAGES} />);
70
-
71
- expect(screen.getByRole("button", { name: "Reasoning" })).toHaveAttribute(
72
- "aria-expanded",
73
- "false",
74
- );
75
- expect(screen.getByRole("button", { name: /search_docs/ })).toHaveAttribute(
76
- "aria-expanded",
77
- "false",
78
- );
79
- });
80
-
81
- it("names tool payloads per tool, so two open tools do not collide", async () => {
82
- const user = userEvent.setup();
83
- render(<AiChatBlock messages={MESSAGES} />);
84
-
85
- await user.click(screen.getByRole("button", { name: /search_docs/ }));
86
- expect(
87
- await screen.findByRole("region", { name: "search_docs arguments" }),
88
- ).toBeInTheDocument();
89
- expect(screen.getByRole("region", { name: "search_docs result" })).toBeInTheDocument();
90
- });
91
-
92
- it("lists sources behind a disclosure", async () => {
93
- const user = userEvent.setup();
94
- render(<AiChatBlock messages={MESSAGES} />);
95
-
96
- await user.click(screen.getByRole("button", { name: "1 source" }));
97
- expect(await screen.findByText("ARIA Authoring Practices")).toBeInTheDocument();
98
- });
99
-
100
- it("shows the token count for a finished response", () => {
101
- render(<AiChatBlock messages={MESSAGES} />);
102
- expect(screen.getByText("148 tokens")).toBeInTheDocument();
103
- });
104
-
105
- it("sends a message and clears the composer", async () => {
106
- const onSend = vi.fn();
107
- const user = userEvent.setup();
108
- render(<AiChatBlock messages={MESSAGES} onSend={onSend} />);
109
-
110
- const composer = screen.getByRole("textbox", { name: "Message" });
111
- await user.type(composer, "Thanks{Enter}");
112
-
113
- expect(onSend).toHaveBeenCalledWith("Thanks");
114
- await waitFor(() => {
115
- expect(composer).toHaveValue("");
116
- });
117
- });
118
-
119
- it("does not send an empty message", async () => {
120
- const onSend = vi.fn();
121
- const user = userEvent.setup();
122
- render(<AiChatBlock messages={MESSAGES} onSend={onSend} />);
123
-
124
- await user.type(screen.getByRole("textbox", { name: "Message" }), " {Enter}");
125
- expect(onSend).not.toHaveBeenCalled();
126
- });
127
-
128
- it("offers Stop while a response is in flight", async () => {
129
- const onStop = vi.fn();
130
- const user = userEvent.setup();
131
- render(<AiChatBlock messages={MESSAGES} busy onStop={onStop} />);
132
-
133
- await user.click(screen.getByRole("button", { name: "Stop generating" }));
134
- expect(onStop).toHaveBeenCalledTimes(1);
135
- });
136
-
137
- it("offers a model picker when models are given", async () => {
138
- const onModelChange = vi.fn();
139
- const user = userEvent.setup();
140
- render(
141
- <AiChatBlock
142
- messages={MESSAGES}
143
- model="fast"
144
- onModelChange={onModelChange}
145
- models={[
146
- { id: "fast", name: "Fast" },
147
- { id: "deep", name: "Deep" },
148
- ]}
149
- />,
150
- );
151
-
152
- await user.click(screen.getByRole("combobox", { name: "Model" }));
153
- await user.click(await screen.findByRole("option", { name: "Deep" }));
154
- expect(onModelChange).toHaveBeenCalledWith("deep");
155
- });
156
-
157
- it("hides the model picker when there are no models", () => {
158
- render(<AiChatBlock messages={MESSAGES} />);
159
- expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument();
160
- });
161
-
162
- it("has no accessibility violations", async () => {
163
- const { container } = render(<AiChatBlock messages={MESSAGES} />);
164
- await expectNoA11yViolations(container);
165
- });
166
- });