@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,145 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { Paperclip } from "lucide-react";
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
|
|
5
|
-
import { ModelSelector } from "@/components/ai-model-selector";
|
|
6
|
-
import { Button } from "@/components/button";
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
PromptInput,
|
|
10
|
-
PromptInputCounter,
|
|
11
|
-
PromptInputSubmit,
|
|
12
|
-
PromptInputTextarea,
|
|
13
|
-
PromptInputToolbar,
|
|
14
|
-
} from "./ai-prompt-input";
|
|
15
|
-
|
|
16
|
-
const meta: Meta<typeof PromptInput> = {
|
|
17
|
-
title: "AI/Prompt Input",
|
|
18
|
-
component: PromptInput,
|
|
19
|
-
parameters: { controls: { disable: true } },
|
|
20
|
-
decorators: [
|
|
21
|
-
(Story) => (
|
|
22
|
-
<div className="w-[34rem]">
|
|
23
|
-
<Story />
|
|
24
|
-
</div>
|
|
25
|
-
),
|
|
26
|
-
],
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export default meta;
|
|
30
|
-
type Story = StoryObj<typeof PromptInput>;
|
|
31
|
-
|
|
32
|
-
/** Enter sends, Shift+Enter adds a line — and neither fires mid-IME-composition. */
|
|
33
|
-
export const Default: Story = {
|
|
34
|
-
render: function Default() {
|
|
35
|
-
const [sent, setSent] = useState<string[]>([]);
|
|
36
|
-
const [value, setValue] = useState("");
|
|
37
|
-
|
|
38
|
-
return (
|
|
39
|
-
<div className="grid gap-3">
|
|
40
|
-
<PromptInput
|
|
41
|
-
onSubmit={(event) => {
|
|
42
|
-
event.preventDefault();
|
|
43
|
-
if (!value.trim()) return;
|
|
44
|
-
setSent((current) => [...current, value.trim()]);
|
|
45
|
-
setValue("");
|
|
46
|
-
}}
|
|
47
|
-
>
|
|
48
|
-
<PromptInputTextarea
|
|
49
|
-
aria-label="Message"
|
|
50
|
-
placeholder="Ask anything…"
|
|
51
|
-
value={value}
|
|
52
|
-
onChange={(event) => {
|
|
53
|
-
setValue(event.target.value);
|
|
54
|
-
}}
|
|
55
|
-
/>
|
|
56
|
-
<PromptInputToolbar>
|
|
57
|
-
<Button variant="ghost" size="icon-sm" aria-label="Attach a file">
|
|
58
|
-
<Paperclip />
|
|
59
|
-
</Button>
|
|
60
|
-
<PromptInputSubmit />
|
|
61
|
-
</PromptInputToolbar>
|
|
62
|
-
</PromptInput>
|
|
63
|
-
{sent.length > 0 ? (
|
|
64
|
-
<ul className="text-xs text-muted-foreground">
|
|
65
|
-
{sent.map((message, index) => (
|
|
66
|
-
<li key={index}>Sent: {message}</li>
|
|
67
|
-
))}
|
|
68
|
-
</ul>
|
|
69
|
-
) : null}
|
|
70
|
-
</div>
|
|
71
|
-
);
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/** While a response streams, the one control becomes Stop — name included. */
|
|
76
|
-
export const Busy: Story = {
|
|
77
|
-
render: function Busy() {
|
|
78
|
-
const [busy, setBusy] = useState(true);
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<PromptInput
|
|
82
|
-
busy={busy}
|
|
83
|
-
onSubmit={(event) => {
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
}}
|
|
86
|
-
>
|
|
87
|
-
<PromptInputTextarea aria-label="Message" placeholder="Generating…" />
|
|
88
|
-
<PromptInputToolbar>
|
|
89
|
-
<PromptInputSubmit
|
|
90
|
-
onStop={() => {
|
|
91
|
-
setBusy(false);
|
|
92
|
-
}}
|
|
93
|
-
/>
|
|
94
|
-
</PromptInputToolbar>
|
|
95
|
-
</PromptInput>
|
|
96
|
-
);
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
export const WithModelAndCounter: Story = {
|
|
101
|
-
render: function WithModelAndCounter() {
|
|
102
|
-
const [value, setValue] = useState("");
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<PromptInput
|
|
106
|
-
onSubmit={(event) => {
|
|
107
|
-
event.preventDefault();
|
|
108
|
-
}}
|
|
109
|
-
>
|
|
110
|
-
<PromptInputTextarea
|
|
111
|
-
aria-label="Message"
|
|
112
|
-
placeholder="Ask anything…"
|
|
113
|
-
value={value}
|
|
114
|
-
onChange={(event) => {
|
|
115
|
-
setValue(event.target.value);
|
|
116
|
-
}}
|
|
117
|
-
/>
|
|
118
|
-
<PromptInputToolbar>
|
|
119
|
-
<ModelSelector
|
|
120
|
-
aria-label="Model"
|
|
121
|
-
defaultValue="balanced"
|
|
122
|
-
triggerSize="sm"
|
|
123
|
-
models={[
|
|
124
|
-
{ id: "fast", name: "Fast", description: "Quick answers." },
|
|
125
|
-
{ id: "balanced", name: "Balanced", description: "The default." },
|
|
126
|
-
]}
|
|
127
|
-
/>
|
|
128
|
-
<PromptInputCounter value={value.length} max={280} />
|
|
129
|
-
<PromptInputSubmit />
|
|
130
|
-
</PromptInputToolbar>
|
|
131
|
-
</PromptInput>
|
|
132
|
-
);
|
|
133
|
-
},
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
export const Disabled: Story = {
|
|
137
|
-
render: () => (
|
|
138
|
-
<PromptInput disabled>
|
|
139
|
-
<PromptInputTextarea aria-label="Message" placeholder="Sign in to send a message" />
|
|
140
|
-
<PromptInputToolbar>
|
|
141
|
-
<PromptInputSubmit />
|
|
142
|
-
</PromptInputToolbar>
|
|
143
|
-
</PromptInput>
|
|
144
|
-
),
|
|
145
|
-
};
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { useState } from "react";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { expectNoA11yViolations } from "../../../test/a11y";
|
|
7
|
-
import {
|
|
8
|
-
PromptInput,
|
|
9
|
-
PromptInputCounter,
|
|
10
|
-
PromptInputSubmit,
|
|
11
|
-
PromptInputTextarea,
|
|
12
|
-
PromptInputToolbar,
|
|
13
|
-
} from "./ai-prompt-input";
|
|
14
|
-
|
|
15
|
-
function Example({
|
|
16
|
-
onSubmit,
|
|
17
|
-
onStop,
|
|
18
|
-
busy = false,
|
|
19
|
-
disabled = false,
|
|
20
|
-
submitOnEnter = true,
|
|
21
|
-
}: {
|
|
22
|
-
onSubmit?: () => void;
|
|
23
|
-
onStop?: () => void;
|
|
24
|
-
busy?: boolean;
|
|
25
|
-
disabled?: boolean;
|
|
26
|
-
submitOnEnter?: boolean;
|
|
27
|
-
} = {}) {
|
|
28
|
-
return (
|
|
29
|
-
<PromptInput
|
|
30
|
-
busy={busy}
|
|
31
|
-
disabled={disabled}
|
|
32
|
-
onSubmit={(event) => {
|
|
33
|
-
event.preventDefault();
|
|
34
|
-
onSubmit?.();
|
|
35
|
-
}}
|
|
36
|
-
>
|
|
37
|
-
<PromptInputTextarea
|
|
38
|
-
aria-label="Message"
|
|
39
|
-
placeholder="Ask anything…"
|
|
40
|
-
submitOnEnter={submitOnEnter}
|
|
41
|
-
/>
|
|
42
|
-
<PromptInputToolbar>
|
|
43
|
-
<PromptInputSubmit onStop={onStop} />
|
|
44
|
-
</PromptInputToolbar>
|
|
45
|
-
</PromptInput>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
describe("PromptInput", () => {
|
|
50
|
-
it("submits on Enter", async () => {
|
|
51
|
-
const onSubmit = vi.fn();
|
|
52
|
-
const user = userEvent.setup();
|
|
53
|
-
render(<Example onSubmit={onSubmit} />);
|
|
54
|
-
|
|
55
|
-
await user.click(screen.getByRole("textbox"));
|
|
56
|
-
await user.keyboard("Hello{Enter}");
|
|
57
|
-
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("inserts a newline on Shift+Enter instead of submitting", async () => {
|
|
61
|
-
const onSubmit = vi.fn();
|
|
62
|
-
const user = userEvent.setup();
|
|
63
|
-
render(<Example onSubmit={onSubmit} />);
|
|
64
|
-
|
|
65
|
-
const textarea = screen.getByRole<HTMLTextAreaElement>("textbox");
|
|
66
|
-
await user.click(textarea);
|
|
67
|
-
await user.keyboard("Hello{Shift>}{Enter}{/Shift}world");
|
|
68
|
-
|
|
69
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
70
|
-
expect(textarea.value).toBe("Hello\nworld");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("does not submit mid-IME-composition", () => {
|
|
74
|
-
const onSubmit = vi.fn();
|
|
75
|
-
render(<Example onSubmit={onSubmit} />);
|
|
76
|
-
|
|
77
|
-
const textarea = screen.getByRole("textbox");
|
|
78
|
-
// Enter while an IME composition is active confirms the candidate — for
|
|
79
|
-
// Japanese, Chinese and Korean input it is part of typing a word. Sending
|
|
80
|
-
// there truncates the sentence, and it is the most common way a chat
|
|
81
|
-
// composer breaks for those users.
|
|
82
|
-
fireEvent.keyDown(textarea, { key: "Enter", isComposing: true });
|
|
83
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
84
|
-
|
|
85
|
-
fireEvent.keyDown(textarea, { key: "Enter", isComposing: false });
|
|
86
|
-
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("can leave Enter as a newline entirely", async () => {
|
|
90
|
-
const onSubmit = vi.fn();
|
|
91
|
-
const user = userEvent.setup();
|
|
92
|
-
render(<Example onSubmit={onSubmit} submitOnEnter={false} />);
|
|
93
|
-
|
|
94
|
-
await user.click(screen.getByRole("textbox"));
|
|
95
|
-
await user.keyboard("Hello{Enter}");
|
|
96
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("submits from the send button", async () => {
|
|
100
|
-
const onSubmit = vi.fn();
|
|
101
|
-
const user = userEvent.setup();
|
|
102
|
-
render(<Example onSubmit={onSubmit} />);
|
|
103
|
-
|
|
104
|
-
await user.click(screen.getByRole("button", { name: "Send message" }));
|
|
105
|
-
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe("while busy", () => {
|
|
109
|
-
it("renames the control to say what it now does", () => {
|
|
110
|
-
render(<Example busy />);
|
|
111
|
-
expect(screen.getByRole("button", { name: "Stop generating" })).toBeInTheDocument();
|
|
112
|
-
expect(screen.queryByRole("button", { name: "Send message" })).not.toBeInTheDocument();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("stops instead of submitting", async () => {
|
|
116
|
-
const onSubmit = vi.fn();
|
|
117
|
-
const onStop = vi.fn();
|
|
118
|
-
const user = userEvent.setup();
|
|
119
|
-
render(<Example busy onSubmit={onSubmit} onStop={onStop} />);
|
|
120
|
-
|
|
121
|
-
await user.click(screen.getByRole("button", { name: "Stop generating" }));
|
|
122
|
-
expect(onStop).toHaveBeenCalledTimes(1);
|
|
123
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("ignores Enter", async () => {
|
|
127
|
-
const onSubmit = vi.fn();
|
|
128
|
-
const user = userEvent.setup();
|
|
129
|
-
render(<Example busy onSubmit={onSubmit} />);
|
|
130
|
-
|
|
131
|
-
await user.click(screen.getByRole("textbox"));
|
|
132
|
-
await user.keyboard("Hi{Enter}");
|
|
133
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("does not submit while disabled", async () => {
|
|
138
|
-
const onSubmit = vi.fn();
|
|
139
|
-
const user = userEvent.setup();
|
|
140
|
-
render(<Example disabled onSubmit={onSubmit} />);
|
|
141
|
-
|
|
142
|
-
expect(screen.getByRole("textbox")).toBeDisabled();
|
|
143
|
-
await user.click(screen.getByRole("button", { name: "Send message" }));
|
|
144
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe("auto-growing", () => {
|
|
148
|
-
/**
|
|
149
|
-
* jsdom performs no layout, so scrollHeight is always 0. Stubbing the
|
|
150
|
-
* prototype getter before render is what lets the sizing logic run at all;
|
|
151
|
-
* without it this would assert against a stub of nothing.
|
|
152
|
-
*/
|
|
153
|
-
function withScrollHeight(height: number, run: () => void) {
|
|
154
|
-
const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
|
155
|
-
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
|
156
|
-
configurable: true,
|
|
157
|
-
get: () => height,
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
run();
|
|
162
|
-
} finally {
|
|
163
|
-
if (original) Object.defineProperty(HTMLElement.prototype, "scrollHeight", original);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
it("sizes itself to its content", () => {
|
|
168
|
-
withScrollHeight(120, () => {
|
|
169
|
-
render(<Example />);
|
|
170
|
-
expect(screen.getByRole<HTMLTextAreaElement>("textbox").style.height).toBe("120px");
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("stops growing at maxRows and scrolls instead", () => {
|
|
175
|
-
withScrollHeight(4000, () => {
|
|
176
|
-
render(
|
|
177
|
-
<PromptInput>
|
|
178
|
-
<PromptInputTextarea aria-label="Message" maxRows={4} />
|
|
179
|
-
</PromptInput>,
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
const textarea = screen.getByRole<HTMLTextAreaElement>("textbox");
|
|
183
|
-
// jsdom reports no line-height, so the component falls back to 20px.
|
|
184
|
-
expect(textarea.style.height).toBe("80px");
|
|
185
|
-
expect(textarea.style.overflowY).toBe("auto");
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("does not scroll while it still fits", () => {
|
|
190
|
-
withScrollHeight(40, () => {
|
|
191
|
-
render(<Example />);
|
|
192
|
-
expect(screen.getByRole<HTMLTextAreaElement>("textbox").style.overflowY).toBe("hidden");
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("throws a useful error if a part is used outside the root", () => {
|
|
198
|
-
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
199
|
-
expect(() => render(<PromptInputSubmit />)).toThrow(
|
|
200
|
-
/must be rendered inside <PromptInput>/,
|
|
201
|
-
);
|
|
202
|
-
consoleError.mockRestore();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("has no accessibility violations", async () => {
|
|
206
|
-
const { container } = render(<Example />);
|
|
207
|
-
await expectNoA11yViolations(container);
|
|
208
|
-
});
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
describe("PromptInputCounter", () => {
|
|
212
|
-
function Counter({ value, max = 100 }: { value: number; max?: number }) {
|
|
213
|
-
return <PromptInputCounter value={value} max={max} />;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
it("shows the count", () => {
|
|
217
|
-
render(<Counter value={12} />);
|
|
218
|
-
expect(screen.getByText("12")).toBeInTheDocument();
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("stays silent during ordinary typing", () => {
|
|
222
|
-
render(<Counter value={12} />);
|
|
223
|
-
// The region exists and is live from the start, but has nothing in it —
|
|
224
|
-
// announcing every keystroke would be unusable.
|
|
225
|
-
expect(screen.getByRole("status")).toHaveTextContent("");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("announces once the limit is close", () => {
|
|
229
|
-
render(<Counter value={95} />);
|
|
230
|
-
expect(screen.getByRole("status")).toHaveTextContent("5 characters remaining");
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("announces going over the limit", () => {
|
|
234
|
-
render(<Counter value={110} />);
|
|
235
|
-
expect(screen.getByRole("status")).toHaveTextContent("Over the limit by 10 characters");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("is live from the start, so the first warning is not missed", () => {
|
|
239
|
-
// A region that only becomes live when it matters misses the very change
|
|
240
|
-
// that made it matter.
|
|
241
|
-
render(<Counter value={1} />);
|
|
242
|
-
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it("shows the limit visually once warning", () => {
|
|
246
|
-
render(<Counter value={95} />);
|
|
247
|
-
expect(screen.getByText("95 / 100")).toBeInTheDocument();
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("updates as the value changes", async () => {
|
|
251
|
-
const user = userEvent.setup();
|
|
252
|
-
|
|
253
|
-
function Live() {
|
|
254
|
-
const [value, setValue] = useState("");
|
|
255
|
-
return (
|
|
256
|
-
<>
|
|
257
|
-
<textarea
|
|
258
|
-
aria-label="Message"
|
|
259
|
-
value={value}
|
|
260
|
-
onChange={(event) => {
|
|
261
|
-
setValue(event.target.value);
|
|
262
|
-
}}
|
|
263
|
-
/>
|
|
264
|
-
<PromptInputCounter value={value.length} max={10} />
|
|
265
|
-
</>
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
render(<Live />);
|
|
270
|
-
await user.type(screen.getByRole("textbox"), "abcdefghij");
|
|
271
|
-
expect(screen.getByRole("status")).toHaveTextContent("0 characters remaining");
|
|
272
|
-
});
|
|
273
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
|
|
3
|
-
import { Response } from "@/components/ai-response";
|
|
4
|
-
|
|
5
|
-
import { Reasoning, ReasoningContent, ReasoningTrigger } from "./ai-reasoning";
|
|
6
|
-
|
|
7
|
-
/** Named so its type is nameable in declaration output (TS2883). */
|
|
8
|
-
const withFixedWidth: Decorator = (Story) => (
|
|
9
|
-
<div className="w-[32rem]">
|
|
10
|
-
<Story />
|
|
11
|
-
</div>
|
|
12
|
-
);
|
|
13
|
-
|
|
14
|
-
const meta = {
|
|
15
|
-
title: "AI/Reasoning",
|
|
16
|
-
component: Reasoning,
|
|
17
|
-
parameters: { controls: { disable: true } },
|
|
18
|
-
decorators: [withFixedWidth],
|
|
19
|
-
} satisfies Meta<typeof Reasoning>;
|
|
20
|
-
|
|
21
|
-
export default meta;
|
|
22
|
-
type Story = StoryObj<typeof meta>;
|
|
23
|
-
|
|
24
|
-
const WORKING =
|
|
25
|
-
'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 tabindex="0" makes it a focus stop, and a role plus a name stops it being an unlabelled one.';
|
|
26
|
-
|
|
27
|
-
/** Collapsed by default: reasoning is supporting material, not the answer. */
|
|
28
|
-
export const Default: Story = {
|
|
29
|
-
render: () => (
|
|
30
|
-
<div className="grid gap-2">
|
|
31
|
-
<Reasoning>
|
|
32
|
-
<ReasoningTrigger />
|
|
33
|
-
<ReasoningContent>{WORKING}</ReasoningContent>
|
|
34
|
-
</Reasoning>
|
|
35
|
-
<Response>
|
|
36
|
-
Give the scrolling wrapper `tabindex="0"`, a role and an accessible name.
|
|
37
|
-
</Response>
|
|
38
|
-
</div>
|
|
39
|
-
),
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export const Streaming: Story = {
|
|
43
|
-
render: () => (
|
|
44
|
-
<Reasoning defaultOpen>
|
|
45
|
-
<ReasoningTrigger streaming />
|
|
46
|
-
<ReasoningContent>
|
|
47
|
-
The question is about keyboard access to overflowing tables. A scroll container
|
|
48
|
-
</ReasoningContent>
|
|
49
|
-
</Reasoning>
|
|
50
|
-
),
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export const Expanded: Story = {
|
|
54
|
-
render: () => (
|
|
55
|
-
<Reasoning defaultOpen>
|
|
56
|
-
<ReasoningTrigger />
|
|
57
|
-
<ReasoningContent>{WORKING}</ReasoningContent>
|
|
58
|
-
</Reasoning>
|
|
59
|
-
),
|
|
60
|
-
};
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { render, screen, waitFor } 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 { Reasoning, ReasoningContent, ReasoningTrigger } from "./ai-reasoning";
|
|
7
|
-
|
|
8
|
-
describe("Reasoning", () => {
|
|
9
|
-
it("is collapsed by default, so the answer is not buried", () => {
|
|
10
|
-
render(
|
|
11
|
-
<Reasoning>
|
|
12
|
-
<ReasoningTrigger />
|
|
13
|
-
<ReasoningContent>Step one, then step two.</ReasoningContent>
|
|
14
|
-
</Reasoning>,
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
expect(screen.getByRole("button", { name: "Reasoning" })).toHaveAttribute(
|
|
18
|
-
"aria-expanded",
|
|
19
|
-
"false",
|
|
20
|
-
);
|
|
21
|
-
expect(screen.queryByText("Step one, then step two.")).not.toBeInTheDocument();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("opens on click", async () => {
|
|
25
|
-
const user = userEvent.setup();
|
|
26
|
-
render(
|
|
27
|
-
<Reasoning>
|
|
28
|
-
<ReasoningTrigger />
|
|
29
|
-
<ReasoningContent>Step one, then step two.</ReasoningContent>
|
|
30
|
-
</Reasoning>,
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
await user.click(screen.getByRole("button", { name: "Reasoning" }));
|
|
34
|
-
expect(await screen.findByText("Step one, then step two.")).toBeInTheDocument();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("says it is thinking while reasoning streams", () => {
|
|
38
|
-
render(
|
|
39
|
-
<Reasoning>
|
|
40
|
-
<ReasoningTrigger streaming />
|
|
41
|
-
<ReasoningContent>Partial…</ReasoningContent>
|
|
42
|
-
</Reasoning>,
|
|
43
|
-
);
|
|
44
|
-
expect(screen.getByRole("button", { name: "Thinking…" })).toBeInTheDocument();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("accepts custom labels", () => {
|
|
48
|
-
render(
|
|
49
|
-
<Reasoning>
|
|
50
|
-
<ReasoningTrigger label="Show working" />
|
|
51
|
-
<ReasoningContent>Work</ReasoningContent>
|
|
52
|
-
</Reasoning>,
|
|
53
|
-
);
|
|
54
|
-
expect(screen.getByRole("button", { name: "Show working" })).toBeInTheDocument();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("can be opened by default", () => {
|
|
58
|
-
render(
|
|
59
|
-
<Reasoning defaultOpen>
|
|
60
|
-
<ReasoningTrigger />
|
|
61
|
-
<ReasoningContent>Visible</ReasoningContent>
|
|
62
|
-
</Reasoning>,
|
|
63
|
-
);
|
|
64
|
-
expect(screen.getByText("Visible")).toBeInTheDocument();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("toggles from the keyboard", async () => {
|
|
68
|
-
const user = userEvent.setup();
|
|
69
|
-
render(
|
|
70
|
-
<Reasoning>
|
|
71
|
-
<ReasoningTrigger />
|
|
72
|
-
<ReasoningContent>Work</ReasoningContent>
|
|
73
|
-
</Reasoning>,
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
await user.tab();
|
|
77
|
-
await user.keyboard("{Enter}");
|
|
78
|
-
await waitFor(() => {
|
|
79
|
-
expect(screen.getByRole("button", { name: "Reasoning" })).toHaveAttribute(
|
|
80
|
-
"aria-expanded",
|
|
81
|
-
"true",
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("has no accessibility violations", async () => {
|
|
87
|
-
const { container } = render(
|
|
88
|
-
<Reasoning defaultOpen>
|
|
89
|
-
<ReasoningTrigger />
|
|
90
|
-
<ReasoningContent>Step one, then step two.</ReasoningContent>
|
|
91
|
-
</Reasoning>,
|
|
92
|
-
);
|
|
93
|
-
await expectNoA11yViolations(container);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
3
|
-
|
|
4
|
-
import { Response, ThinkingIndicator } from "./ai-response";
|
|
5
|
-
|
|
6
|
-
const FULL =
|
|
7
|
-
"Streaming works one token at a time. The caret marks where the text stops, and nothing is announced as it arrives — a live region firing on every token is unusable with a screen reader.";
|
|
8
|
-
|
|
9
|
-
/** Named so its type is nameable in declaration output (TS2883). */
|
|
10
|
-
const withFixedWidth: Decorator = (Story) => (
|
|
11
|
-
<div className="w-[32rem]">
|
|
12
|
-
<Story />
|
|
13
|
-
</div>
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
const meta = {
|
|
17
|
-
title: "AI/Response",
|
|
18
|
-
component: Response,
|
|
19
|
-
args: { streaming: false, children: FULL },
|
|
20
|
-
argTypes: { streaming: { control: "boolean" } },
|
|
21
|
-
decorators: [withFixedWidth],
|
|
22
|
-
} satisfies Meta<typeof Response>;
|
|
23
|
-
|
|
24
|
-
export default meta;
|
|
25
|
-
type Story = StoryObj<typeof meta>;
|
|
26
|
-
|
|
27
|
-
export const Default: Story = {};
|
|
28
|
-
|
|
29
|
-
export const Streaming: Story = {
|
|
30
|
-
args: { streaming: true, children: "Streaming works one token at a time. The caret" },
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
/** The real thing: text arriving, then settling. */
|
|
34
|
-
export const Live: Story = {
|
|
35
|
-
parameters: { controls: { disable: true } },
|
|
36
|
-
render: function Live() {
|
|
37
|
-
const [length, setLength] = useState(0);
|
|
38
|
-
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
const timer = setInterval(() => {
|
|
41
|
-
setLength((current) => (current >= FULL.length ? 0 : current + 2));
|
|
42
|
-
}, 40);
|
|
43
|
-
return () => {
|
|
44
|
-
clearInterval(timer);
|
|
45
|
-
};
|
|
46
|
-
}, []);
|
|
47
|
-
|
|
48
|
-
const done = length >= FULL.length;
|
|
49
|
-
return <Response streaming={!done}>{FULL.slice(0, length)}</Response>;
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/** Before the first token there is nothing to put a caret after. */
|
|
54
|
-
export const Thinking: Story = {
|
|
55
|
-
parameters: { controls: { disable: true } },
|
|
56
|
-
render: () => <ThinkingIndicator />,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export const WithRenderedMarkdown: Story = {
|
|
60
|
-
parameters: { controls: { disable: true } },
|
|
61
|
-
render: () => (
|
|
62
|
-
<Response>
|
|
63
|
-
<p>Three things matter here:</p>
|
|
64
|
-
<ul>
|
|
65
|
-
<li>
|
|
66
|
-
The transcript is a <code>list</code>, not a live region.
|
|
67
|
-
</li>
|
|
68
|
-
<li>State is announced; content is not.</li>
|
|
69
|
-
<li>
|
|
70
|
-
See <a href="#a11y">the accessibility notes</a>.
|
|
71
|
-
</li>
|
|
72
|
-
</ul>
|
|
73
|
-
</Response>
|
|
74
|
-
),
|
|
75
|
-
};
|