@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,144 +0,0 @@
|
|
|
1
|
-
import { fireEvent, 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 { Message, MessageBody } from "../ai-message";
|
|
7
|
-
import {
|
|
8
|
-
Conversation,
|
|
9
|
-
ConversationMessages,
|
|
10
|
-
ConversationScrollButton,
|
|
11
|
-
ConversationStatus,
|
|
12
|
-
} from "./ai-conversation";
|
|
13
|
-
|
|
14
|
-
function Example({ status }: { status?: string } = {}) {
|
|
15
|
-
return (
|
|
16
|
-
<Conversation>
|
|
17
|
-
<ConversationMessages>
|
|
18
|
-
<Message from="user">
|
|
19
|
-
<MessageBody from="user">What is the capital of France?</MessageBody>
|
|
20
|
-
</Message>
|
|
21
|
-
<Message from="assistant">
|
|
22
|
-
<MessageBody from="assistant">Paris.</MessageBody>
|
|
23
|
-
</Message>
|
|
24
|
-
</ConversationMessages>
|
|
25
|
-
{status ? <ConversationStatus>{status}</ConversationStatus> : null}
|
|
26
|
-
<ConversationScrollButton />
|
|
27
|
-
</Conversation>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** jsdom has no layout, so scroll geometry is stubbed to drive the logic. */
|
|
32
|
-
function stubScroll(
|
|
33
|
-
viewport: HTMLElement,
|
|
34
|
-
{ scrollTop = 0, scrollHeight = 1000, clientHeight = 400 } = {},
|
|
35
|
-
) {
|
|
36
|
-
Object.defineProperty(viewport, "scrollHeight", { value: scrollHeight, configurable: true });
|
|
37
|
-
Object.defineProperty(viewport, "clientHeight", { value: clientHeight, configurable: true });
|
|
38
|
-
Object.defineProperty(viewport, "scrollTop", {
|
|
39
|
-
value: scrollTop,
|
|
40
|
-
writable: true,
|
|
41
|
-
configurable: true,
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function viewportOf(container: HTMLElement): HTMLElement {
|
|
46
|
-
const viewport = container.querySelector<HTMLElement>("[data-slot='conversation-viewport']");
|
|
47
|
-
if (!viewport) throw new Error("no viewport");
|
|
48
|
-
return viewport;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe("Conversation", () => {
|
|
52
|
-
it("renders the transcript as an ordered list", () => {
|
|
53
|
-
render(<Example />);
|
|
54
|
-
expect(screen.getByRole("list").tagName).toBe("OL");
|
|
55
|
-
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("does NOT make the transcript a live region", () => {
|
|
59
|
-
// The central accessibility decision of this component. A live region that
|
|
60
|
-
// updates per streamed token is unusable with a screen reader.
|
|
61
|
-
const list = render(<Example />).container.querySelector(
|
|
62
|
-
"[data-slot='conversation-messages']",
|
|
63
|
-
);
|
|
64
|
-
expect(list).not.toHaveAttribute("aria-live");
|
|
65
|
-
expect(list).not.toHaveAttribute("role", "log");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("announces state, not content, through a separate status region", () => {
|
|
69
|
-
render(<Example status="Generating response" />);
|
|
70
|
-
|
|
71
|
-
const status = screen.getByRole("status");
|
|
72
|
-
expect(status).toHaveAttribute("aria-live", "polite");
|
|
73
|
-
expect(status).toHaveTextContent("Generating response");
|
|
74
|
-
// The transcript text must not be inside the live region.
|
|
75
|
-
expect(status).not.toHaveTextContent("Paris.");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe("the jump-to-latest control", () => {
|
|
79
|
-
it("is absent while the reader is at the bottom", () => {
|
|
80
|
-
render(<Example />);
|
|
81
|
-
expect(screen.queryByRole("button", { name: "Jump to latest" })).not.toBeInTheDocument();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("appears once the reader scrolls away", async () => {
|
|
85
|
-
const { container } = render(<Example />);
|
|
86
|
-
const viewport = viewportOf(container);
|
|
87
|
-
|
|
88
|
-
stubScroll(viewport, { scrollTop: 0 });
|
|
89
|
-
fireEvent.scroll(viewport);
|
|
90
|
-
|
|
91
|
-
expect(await screen.findByRole("button", { name: "Jump to latest" })).toBeInTheDocument();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("scrolls back down and then disappears", async () => {
|
|
95
|
-
const user = userEvent.setup();
|
|
96
|
-
const { container } = render(<Example />);
|
|
97
|
-
const viewport = viewportOf(container);
|
|
98
|
-
const scrollTo = vi.fn();
|
|
99
|
-
viewport.scrollTo = scrollTo;
|
|
100
|
-
|
|
101
|
-
stubScroll(viewport, { scrollTop: 0 });
|
|
102
|
-
fireEvent.scroll(viewport);
|
|
103
|
-
await user.click(await screen.findByRole("button", { name: "Jump to latest" }));
|
|
104
|
-
|
|
105
|
-
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
|
106
|
-
|
|
107
|
-
stubScroll(viewport, { scrollTop: 600 });
|
|
108
|
-
fireEvent.scroll(viewport);
|
|
109
|
-
await waitFor(() => {
|
|
110
|
-
expect(
|
|
111
|
-
screen.queryByRole("button", { name: "Jump to latest" }),
|
|
112
|
-
).not.toBeInTheDocument();
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("counts near-the-bottom as the bottom, so a pixel of drift is not a prompt", async () => {
|
|
117
|
-
const { container } = render(<Example />);
|
|
118
|
-
const viewport = viewportOf(container);
|
|
119
|
-
|
|
120
|
-
stubScroll(viewport, { scrollTop: 580 });
|
|
121
|
-
fireEvent.scroll(viewport);
|
|
122
|
-
|
|
123
|
-
await waitFor(() => {
|
|
124
|
-
expect(container.querySelector("[data-slot='conversation']")).toHaveAttribute(
|
|
125
|
-
"data-at-bottom",
|
|
126
|
-
"true",
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("throws a useful error if the scroll button is used outside the root", () => {
|
|
133
|
-
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
134
|
-
expect(() => render(<ConversationScrollButton />)).toThrow(
|
|
135
|
-
/must be rendered inside <Conversation>/,
|
|
136
|
-
);
|
|
137
|
-
consoleError.mockRestore();
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("has no accessibility violations", async () => {
|
|
141
|
-
const { container } = render(<Example status="Response complete" />);
|
|
142
|
-
await expectNoA11yViolations(container);
|
|
143
|
-
});
|
|
144
|
-
});
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { Copy, RefreshCw, ThumbsDown, ThumbsUp } from "lucide-react";
|
|
3
|
-
|
|
4
|
-
import { Response } from "@/components/ai-response";
|
|
5
|
-
import { Button } from "@/components/button";
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
Message,
|
|
9
|
-
MessageActions,
|
|
10
|
-
MessageAvatar,
|
|
11
|
-
MessageBody,
|
|
12
|
-
MessageFooter,
|
|
13
|
-
} from "./ai-message";
|
|
14
|
-
|
|
15
|
-
/** Named so its type is nameable in declaration output (TS2883). */
|
|
16
|
-
const withFixedWidth: Decorator = (Story) => (
|
|
17
|
-
<ol className="flex w-[32rem] flex-col gap-6">
|
|
18
|
-
<Story />
|
|
19
|
-
</ol>
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
const meta = {
|
|
23
|
-
title: "AI/Message",
|
|
24
|
-
component: Message,
|
|
25
|
-
args: { from: "assistant" },
|
|
26
|
-
argTypes: { from: { control: "inline-radio", options: ["user", "assistant", "system"] } },
|
|
27
|
-
decorators: [withFixedWidth],
|
|
28
|
-
} satisfies Meta<typeof Message>;
|
|
29
|
-
|
|
30
|
-
export default meta;
|
|
31
|
-
type Story = StoryObj<typeof meta>;
|
|
32
|
-
|
|
33
|
-
export const Default: Story = {
|
|
34
|
-
render: (args) => (
|
|
35
|
-
<Message {...args}>
|
|
36
|
-
{args.from === "assistant" ? <MessageAvatar>AI</MessageAvatar> : null}
|
|
37
|
-
<MessageBody from={args.from}>
|
|
38
|
-
{args.from === "user"
|
|
39
|
-
? "How do I make a table keyboard-scrollable?"
|
|
40
|
-
: 'Give the scrolling wrapper tabindex="0" and an accessible name. Without it, columns past the edge are unreachable by keyboard.'}
|
|
41
|
-
</MessageBody>
|
|
42
|
-
</Message>
|
|
43
|
-
),
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export const Roles: Story = {
|
|
47
|
-
parameters: { controls: { disable: true } },
|
|
48
|
-
render: () => (
|
|
49
|
-
<>
|
|
50
|
-
<Message from="system">
|
|
51
|
-
<MessageBody from="system">Conversation started with the Balanced model.</MessageBody>
|
|
52
|
-
</Message>
|
|
53
|
-
<Message from="user">
|
|
54
|
-
<MessageBody from="user">What is the capital of France?</MessageBody>
|
|
55
|
-
</Message>
|
|
56
|
-
<Message from="assistant">
|
|
57
|
-
<MessageAvatar>AI</MessageAvatar>
|
|
58
|
-
<MessageBody from="assistant">
|
|
59
|
-
<Response>Paris.</Response>
|
|
60
|
-
</MessageBody>
|
|
61
|
-
</Message>
|
|
62
|
-
</>
|
|
63
|
-
),
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/** Actions fade in on hover but stay in the tab order the whole time. */
|
|
67
|
-
export const WithActions: Story = {
|
|
68
|
-
parameters: { controls: { disable: true } },
|
|
69
|
-
render: () => (
|
|
70
|
-
<Message from="assistant">
|
|
71
|
-
<MessageAvatar>AI</MessageAvatar>
|
|
72
|
-
<MessageBody from="assistant">
|
|
73
|
-
<Response>
|
|
74
|
-
A Sheet slides in from an edge; a Drawer is bottom-anchored and can be dragged away.
|
|
75
|
-
</Response>
|
|
76
|
-
<MessageActions>
|
|
77
|
-
{[
|
|
78
|
-
{ icon: Copy, label: "Copy" },
|
|
79
|
-
{ icon: RefreshCw, label: "Regenerate" },
|
|
80
|
-
{ icon: ThumbsUp, label: "Good response" },
|
|
81
|
-
{ icon: ThumbsDown, label: "Bad response" },
|
|
82
|
-
].map(({ icon: Icon, label }) => (
|
|
83
|
-
<Button key={label} variant="ghost" size="icon-sm" aria-label={label}>
|
|
84
|
-
<Icon />
|
|
85
|
-
</Button>
|
|
86
|
-
))}
|
|
87
|
-
</MessageActions>
|
|
88
|
-
</MessageBody>
|
|
89
|
-
</Message>
|
|
90
|
-
),
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
export const WithFooter: Story = {
|
|
94
|
-
parameters: { controls: { disable: true } },
|
|
95
|
-
render: () => (
|
|
96
|
-
<Message from="assistant">
|
|
97
|
-
<MessageAvatar>AI</MessageAvatar>
|
|
98
|
-
<MessageBody from="assistant">
|
|
99
|
-
<Response>Paris has been the capital since 987.</Response>
|
|
100
|
-
<MessageFooter>
|
|
101
|
-
<span className="text-2xs text-muted-foreground">2 sources · 148 tokens</span>
|
|
102
|
-
</MessageFooter>
|
|
103
|
-
</MessageBody>
|
|
104
|
-
</Message>
|
|
105
|
-
),
|
|
106
|
-
};
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { expectNoA11yViolations } from "../../../test/a11y";
|
|
6
|
-
import { Button } from "../button";
|
|
7
|
-
import {
|
|
8
|
-
Message,
|
|
9
|
-
MessageActions,
|
|
10
|
-
MessageAvatar,
|
|
11
|
-
MessageBody,
|
|
12
|
-
MessageFooter,
|
|
13
|
-
} from "./ai-message";
|
|
14
|
-
|
|
15
|
-
describe("Message", () => {
|
|
16
|
-
it("renders as a list item, for a transcript", () => {
|
|
17
|
-
render(
|
|
18
|
-
<ol>
|
|
19
|
-
<Message from="user">
|
|
20
|
-
<MessageBody from="user">Hello</MessageBody>
|
|
21
|
-
</Message>
|
|
22
|
-
</ol>,
|
|
23
|
-
);
|
|
24
|
-
expect(screen.getByRole("listitem")).toBeInTheDocument();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it.each([
|
|
28
|
-
["user", "You said"],
|
|
29
|
-
["assistant", "Assistant said"],
|
|
30
|
-
["system", "System"],
|
|
31
|
-
] as const)("names the %s speaker in text, not only by layout", (role, label) => {
|
|
32
|
-
render(
|
|
33
|
-
<ol>
|
|
34
|
-
<Message from={role}>
|
|
35
|
-
<MessageBody from={role}>Content</MessageBody>
|
|
36
|
-
</Message>
|
|
37
|
-
</ol>,
|
|
38
|
-
);
|
|
39
|
-
// Colour and alignment convey the speaker to a sighted reader and nothing
|
|
40
|
-
// to anyone else, so the role has to be readable.
|
|
41
|
-
expect(screen.getByRole("listitem")).toHaveTextContent(label);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("accepts a custom speaker label", () => {
|
|
45
|
-
render(
|
|
46
|
-
<ol>
|
|
47
|
-
<Message from="assistant" fromLabel="Claude said">
|
|
48
|
-
<MessageBody from="assistant">Hi</MessageBody>
|
|
49
|
-
</Message>
|
|
50
|
-
</ol>,
|
|
51
|
-
);
|
|
52
|
-
expect(screen.getByRole("listitem")).toHaveTextContent("Claude said");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("marks the speaker for styling", () => {
|
|
56
|
-
render(
|
|
57
|
-
<ol>
|
|
58
|
-
<Message from="user">
|
|
59
|
-
<MessageBody from="user">Hello</MessageBody>
|
|
60
|
-
</Message>
|
|
61
|
-
</ol>,
|
|
62
|
-
);
|
|
63
|
-
expect(screen.getByRole("listitem")).toHaveAttribute("data-from", "user");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("does not shadow the ARIA role attribute", () => {
|
|
67
|
-
// `from` names the speaker; `role` still reaches the element as the real
|
|
68
|
-
// ARIA attribute, so a consumer can override the list-item semantics.
|
|
69
|
-
render(
|
|
70
|
-
<ol>
|
|
71
|
-
<Message from="assistant" role="presentation">
|
|
72
|
-
<MessageBody from="assistant">Hi</MessageBody>
|
|
73
|
-
</Message>
|
|
74
|
-
</ol>,
|
|
75
|
-
);
|
|
76
|
-
expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("hides the avatar from assistive technology", () => {
|
|
80
|
-
const { container } = render(
|
|
81
|
-
<ol>
|
|
82
|
-
<Message from="assistant">
|
|
83
|
-
<MessageAvatar>AI</MessageAvatar>
|
|
84
|
-
<MessageBody from="assistant">Hi</MessageBody>
|
|
85
|
-
</Message>
|
|
86
|
-
</ol>,
|
|
87
|
-
);
|
|
88
|
-
expect(container.querySelector("[data-slot='message-avatar']")).toHaveAttribute(
|
|
89
|
-
"aria-hidden",
|
|
90
|
-
"true",
|
|
91
|
-
);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("keeps hover-revealed actions in the tab order", async () => {
|
|
95
|
-
const user = userEvent.setup();
|
|
96
|
-
render(
|
|
97
|
-
<ol>
|
|
98
|
-
<Message from="assistant">
|
|
99
|
-
<MessageBody from="assistant">
|
|
100
|
-
Hi
|
|
101
|
-
<MessageActions>
|
|
102
|
-
<Button size="icon-sm" variant="ghost" aria-label="Copy">
|
|
103
|
-
C
|
|
104
|
-
</Button>
|
|
105
|
-
</MessageActions>
|
|
106
|
-
</MessageBody>
|
|
107
|
-
</Message>
|
|
108
|
-
</ol>,
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
// Visually faded until hover, but reachable — a control that only exists on
|
|
112
|
-
// hover is unusable by keyboard and invisible on touch.
|
|
113
|
-
await user.tab();
|
|
114
|
-
expect(screen.getByRole("button", { name: "Copy" })).toHaveFocus();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("renders a footer for attachments and citations", () => {
|
|
118
|
-
render(
|
|
119
|
-
<ol>
|
|
120
|
-
<Message from="assistant">
|
|
121
|
-
<MessageBody from="assistant">
|
|
122
|
-
Hi
|
|
123
|
-
<MessageFooter>
|
|
124
|
-
<span>2 sources</span>
|
|
125
|
-
</MessageFooter>
|
|
126
|
-
</MessageBody>
|
|
127
|
-
</Message>
|
|
128
|
-
</ol>,
|
|
129
|
-
);
|
|
130
|
-
expect(screen.getByText("2 sources")).toBeInTheDocument();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("lets a consumer className override a conflicting utility", () => {
|
|
134
|
-
render(
|
|
135
|
-
<ol>
|
|
136
|
-
<Message from="user" className="gap-8">
|
|
137
|
-
<MessageBody from="user">Hi</MessageBody>
|
|
138
|
-
</Message>
|
|
139
|
-
</ol>,
|
|
140
|
-
);
|
|
141
|
-
const item = screen.getByRole("listitem");
|
|
142
|
-
expect(item).toHaveClass("gap-8");
|
|
143
|
-
expect(item).not.toHaveClass("gap-3");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("has no accessibility violations", async () => {
|
|
147
|
-
const { container } = render(
|
|
148
|
-
<ol>
|
|
149
|
-
<Message from="user">
|
|
150
|
-
<MessageBody from="user">What is the capital of France?</MessageBody>
|
|
151
|
-
</Message>
|
|
152
|
-
<Message from="assistant">
|
|
153
|
-
<MessageAvatar>AI</MessageAvatar>
|
|
154
|
-
<MessageBody from="assistant">Paris.</MessageBody>
|
|
155
|
-
</Message>
|
|
156
|
-
</ol>,
|
|
157
|
-
);
|
|
158
|
-
await expectNoA11yViolations(container);
|
|
159
|
-
});
|
|
160
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { Badge } from "@/components/badge";
|
|
4
|
-
|
|
5
|
-
import { ModelSelector, type ModelOption } from "./ai-model-selector";
|
|
6
|
-
|
|
7
|
-
const MODELS: ModelOption[] = [
|
|
8
|
-
{
|
|
9
|
-
id: "fast",
|
|
10
|
-
name: "Fast",
|
|
11
|
-
description: "Lowest latency. Good for short answers.",
|
|
12
|
-
group: "General",
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
id: "balanced",
|
|
16
|
-
name: "Balanced",
|
|
17
|
-
description: "The default for most work.",
|
|
18
|
-
group: "General",
|
|
19
|
-
badge: (
|
|
20
|
-
<Badge size="sm" variant="secondary">
|
|
21
|
-
Default
|
|
22
|
-
</Badge>
|
|
23
|
-
),
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
id: "deep",
|
|
27
|
-
name: "Deep Research",
|
|
28
|
-
description: "Multi-step research with citations.",
|
|
29
|
-
group: "Advanced",
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
id: "vision",
|
|
33
|
-
name: "Vision Pro",
|
|
34
|
-
group: "Advanced",
|
|
35
|
-
disabled: true,
|
|
36
|
-
disabledReason: "Available on the Team plan",
|
|
37
|
-
},
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const meta = {
|
|
41
|
-
title: "AI/Model Selector",
|
|
42
|
-
component: ModelSelector,
|
|
43
|
-
args: { models: MODELS, defaultValue: "balanced", "aria-label": "Model" },
|
|
44
|
-
parameters: { controls: { disable: true } },
|
|
45
|
-
} satisfies Meta<typeof ModelSelector>;
|
|
46
|
-
|
|
47
|
-
export default meta;
|
|
48
|
-
type Story = StoryObj<typeof meta>;
|
|
49
|
-
|
|
50
|
-
/** The trigger shows only the model name; descriptions stay in the list. */
|
|
51
|
-
export const Default: Story = {};
|
|
52
|
-
|
|
53
|
-
export const Ungrouped: Story = {
|
|
54
|
-
args: {
|
|
55
|
-
models: [
|
|
56
|
-
{ id: "a", name: "Model A", description: "Fast and cheap." },
|
|
57
|
-
{ id: "b", name: "Model B", description: "Slower and stronger." },
|
|
58
|
-
],
|
|
59
|
-
defaultValue: "a",
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
/** A disabled model always says why. Otherwise it reads as a broken interface. */
|
|
64
|
-
export const WithUnavailableModel: Story = {
|
|
65
|
-
args: { defaultValue: "fast" },
|
|
66
|
-
};
|
|
@@ -1,120 +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 { ModelSelector, type ModelOption } from "./ai-model-selector";
|
|
7
|
-
|
|
8
|
-
const MODELS: ModelOption[] = [
|
|
9
|
-
{ id: "fast", name: "Fast", description: "Quick answers.", group: "General" },
|
|
10
|
-
{ id: "balanced", name: "Balanced", description: "The default.", group: "General" },
|
|
11
|
-
{
|
|
12
|
-
id: "deep",
|
|
13
|
-
name: "Deep Research",
|
|
14
|
-
group: "Advanced",
|
|
15
|
-
disabled: true,
|
|
16
|
-
disabledReason: "Available on the Pro plan",
|
|
17
|
-
},
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
describe("ModelSelector", () => {
|
|
21
|
-
it("shows a placeholder until a model is chosen", () => {
|
|
22
|
-
render(<ModelSelector models={MODELS} aria-label="Model" />);
|
|
23
|
-
expect(screen.getByRole("combobox")).toHaveTextContent("Select a model");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("lists the models, grouped", async () => {
|
|
27
|
-
const user = userEvent.setup();
|
|
28
|
-
render(<ModelSelector models={MODELS} aria-label="Model" />);
|
|
29
|
-
|
|
30
|
-
await user.click(screen.getByRole("combobox"));
|
|
31
|
-
expect(await screen.findByRole("option", { name: /Fast/ })).toBeInTheDocument();
|
|
32
|
-
expect(screen.getByText("General")).toBeInTheDocument();
|
|
33
|
-
expect(screen.getByText("Advanced")).toBeInTheDocument();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("shows each model's description", async () => {
|
|
37
|
-
const user = userEvent.setup();
|
|
38
|
-
render(<ModelSelector models={MODELS} aria-label="Model" />);
|
|
39
|
-
|
|
40
|
-
await user.click(screen.getByRole("combobox"));
|
|
41
|
-
expect(await screen.findByText("Quick answers.")).toBeInTheDocument();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("says why an unavailable model cannot be chosen", async () => {
|
|
45
|
-
const user = userEvent.setup();
|
|
46
|
-
render(<ModelSelector models={MODELS} aria-label="Model" />);
|
|
47
|
-
|
|
48
|
-
await user.click(screen.getByRole("combobox"));
|
|
49
|
-
// A disabled option with no reason reads as a broken interface.
|
|
50
|
-
expect(await screen.findByText("Available on the Pro plan")).toBeInTheDocument();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("does not select a disabled model", async () => {
|
|
54
|
-
const onValueChange = vi.fn();
|
|
55
|
-
const user = userEvent.setup();
|
|
56
|
-
render(<ModelSelector models={MODELS} aria-label="Model" onValueChange={onValueChange} />);
|
|
57
|
-
|
|
58
|
-
await user.click(screen.getByRole("combobox"));
|
|
59
|
-
await user.click(await screen.findByRole("option", { name: /Deep Research/ }));
|
|
60
|
-
expect(onValueChange).not.toHaveBeenCalled();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("reports the chosen model", async () => {
|
|
64
|
-
const onValueChange = vi.fn();
|
|
65
|
-
const user = userEvent.setup();
|
|
66
|
-
render(<ModelSelector models={MODELS} aria-label="Model" onValueChange={onValueChange} />);
|
|
67
|
-
|
|
68
|
-
await user.click(screen.getByRole("combobox"));
|
|
69
|
-
await user.click(await screen.findByRole("option", { name: /Balanced/ }));
|
|
70
|
-
expect(onValueChange).toHaveBeenCalledWith("balanced");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("shows the chosen model's name, not its description", async () => {
|
|
74
|
-
render(<ModelSelector models={MODELS} aria-label="Model" defaultValue="fast" />);
|
|
75
|
-
|
|
76
|
-
await waitFor(() => {
|
|
77
|
-
expect(screen.getByRole("combobox")).toHaveTextContent("Fast");
|
|
78
|
-
});
|
|
79
|
-
expect(screen.getByRole("combobox")).not.toHaveTextContent("Quick answers.");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("keeps the order it was given rather than sorting groups", async () => {
|
|
83
|
-
const user = userEvent.setup();
|
|
84
|
-
render(<ModelSelector models={MODELS} aria-label="Model" />);
|
|
85
|
-
|
|
86
|
-
await user.click(screen.getByRole("combobox"));
|
|
87
|
-
const options = await screen.findAllByRole("option");
|
|
88
|
-
expect(options.map((option) => option.textContent?.slice(0, 4))).toEqual([
|
|
89
|
-
"Fast",
|
|
90
|
-
"Bala",
|
|
91
|
-
"Deep",
|
|
92
|
-
]);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("works without groups", async () => {
|
|
96
|
-
const user = userEvent.setup();
|
|
97
|
-
render(
|
|
98
|
-
<ModelSelector
|
|
99
|
-
models={[
|
|
100
|
-
{ id: "a", name: "Model A" },
|
|
101
|
-
{ id: "b", name: "Model B" },
|
|
102
|
-
]}
|
|
103
|
-
aria-label="Model"
|
|
104
|
-
/>,
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
await user.click(screen.getByRole("combobox"));
|
|
108
|
-
expect(await screen.findByRole("option", { name: "Model A" })).toBeInTheDocument();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("has no accessibility violations", async () => {
|
|
112
|
-
const { container } = render(
|
|
113
|
-
<>
|
|
114
|
-
<span id="model-label">Model</span>
|
|
115
|
-
<ModelSelector models={MODELS} aria-labelledby="model-label" />
|
|
116
|
-
</>,
|
|
117
|
-
);
|
|
118
|
-
await expectNoA11yViolations(container);
|
|
119
|
-
});
|
|
120
|
-
});
|