@tribepad/themis 1.3.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/elements/ChatInterface/ChatInputArea.d.ts +37 -0
  3. package/dist/elements/ChatInterface/ChatInputArea.d.ts.map +1 -0
  4. package/dist/elements/ChatInterface/ChatInterface.d.ts +24 -0
  5. package/dist/elements/ChatInterface/ChatInterface.d.ts.map +1 -0
  6. package/dist/elements/ChatInterface/ChatInterface.hooks.d.ts +46 -0
  7. package/dist/elements/ChatInterface/ChatInterface.hooks.d.ts.map +1 -0
  8. package/dist/elements/ChatInterface/ChatInterface.styles.d.ts +83 -0
  9. package/dist/elements/ChatInterface/ChatInterface.styles.d.ts.map +1 -0
  10. package/dist/elements/ChatInterface/ChatInterface.types.d.ts +340 -0
  11. package/dist/elements/ChatInterface/ChatInterface.types.d.ts.map +1 -0
  12. package/dist/elements/ChatInterface/ChatLiveRegions.d.ts +13 -0
  13. package/dist/elements/ChatInterface/ChatLiveRegions.d.ts.map +1 -0
  14. package/dist/elements/ChatInterface/ChatMessageBubble.d.ts +24 -0
  15. package/dist/elements/ChatInterface/ChatMessageBubble.d.ts.map +1 -0
  16. package/dist/elements/ChatInterface/ChatMessageList.d.ts +32 -0
  17. package/dist/elements/ChatInterface/ChatMessageList.d.ts.map +1 -0
  18. package/dist/elements/ChatInterface/ChatThinkingDisplay.d.ts +20 -0
  19. package/dist/elements/ChatInterface/ChatThinkingDisplay.d.ts.map +1 -0
  20. package/dist/elements/ChatInterface/ChatTypingIndicator.d.ts +11 -0
  21. package/dist/elements/ChatInterface/ChatTypingIndicator.d.ts.map +1 -0
  22. package/dist/elements/ChatInterface/DefaultChatInput.d.ts +18 -0
  23. package/dist/elements/ChatInterface/DefaultChatInput.d.ts.map +1 -0
  24. package/dist/elements/ChatInterface/index.d.ts +7 -0
  25. package/dist/elements/ChatInterface/index.d.ts.map +1 -0
  26. package/dist/elements/ChatInterface/index.js +3 -0
  27. package/dist/elements/ChatInterface/index.js.map +1 -0
  28. package/dist/elements/ChatInterface/index.mjs +3 -0
  29. package/dist/elements/ChatInterface/index.mjs.map +1 -0
  30. package/dist/elements/DatePicker/Calendar.d.ts +1 -1
  31. package/dist/elements/DatePicker/DateField.d.ts +1 -1
  32. package/dist/elements/DatePicker/DateField.d.ts.map +1 -1
  33. package/dist/elements/DatePicker/DatePicker.d.ts +1 -1
  34. package/dist/elements/DatePicker/DatePicker.d.ts.map +1 -1
  35. package/dist/elements/DatePicker/DatePicker.types.d.ts +1 -1
  36. package/dist/elements/DatePicker/DateRangePicker.d.ts +1 -1
  37. package/dist/elements/DatePicker/DateRangePicker.d.ts.map +1 -1
  38. package/dist/elements/FileField/FileField.types.d.ts +1 -1
  39. package/dist/elements/FileField/FileProgress.variants.d.ts +1 -1
  40. package/dist/elements/OTPInput/index.js +1 -1
  41. package/dist/elements/OTPInput/index.js.map +1 -1
  42. package/dist/elements/OTPInput/index.mjs +1 -1
  43. package/dist/elements/OTPInput/index.mjs.map +1 -1
  44. package/dist/elements/Resizable/index.js +1 -1
  45. package/dist/elements/Resizable/index.js.map +1 -1
  46. package/dist/elements/Resizable/index.mjs +1 -1
  47. package/dist/elements/Resizable/index.mjs.map +1 -1
  48. package/dist/elements/TimeField/TimeField.d.ts +1 -1
  49. package/dist/elements/TimeField/TimeField.d.ts.map +1 -1
  50. package/dist/elements/Tree/Tree.d.ts +62 -0
  51. package/dist/elements/Tree/Tree.d.ts.map +1 -0
  52. package/dist/elements/Tree/Tree.styles.d.ts +28 -0
  53. package/dist/elements/Tree/Tree.styles.d.ts.map +1 -0
  54. package/dist/elements/Tree/Tree.types.d.ts +161 -0
  55. package/dist/elements/Tree/Tree.types.d.ts.map +1 -0
  56. package/dist/elements/Tree/index.d.ts +33 -0
  57. package/dist/elements/Tree/index.d.ts.map +1 -0
  58. package/dist/elements/Tree/index.js +3 -0
  59. package/dist/elements/Tree/index.js.map +1 -0
  60. package/dist/elements/Tree/index.mjs +3 -0
  61. package/dist/elements/Tree/index.mjs.map +1 -0
  62. package/dist/elements/index.d.ts +5 -0
  63. package/dist/elements/index.d.ts.map +1 -1
  64. package/dist/elements/index.js +1 -1
  65. package/dist/elements/index.js.map +1 -1
  66. package/dist/elements/index.mjs +1 -1
  67. package/dist/elements/index.mjs.map +1 -1
  68. package/dist/index.js +2 -2
  69. package/dist/index.js.map +1 -1
  70. package/dist/index.mjs +2 -2
  71. package/dist/index.mjs.map +1 -1
  72. package/dist/styles/defaults.css +3 -0
  73. package/package.json +29 -21
  74. package/src/elements/ChatInterface/ChatInterface.stories.tsx +495 -0
  75. package/src/elements/ChatInterface/README.md +408 -0
  76. package/src/elements/Tree/Tree.stories.tsx +407 -0
@@ -108,6 +108,9 @@
108
108
  --radius-lg: 0.5rem;
109
109
  --radius-full: 9999px;
110
110
 
111
+ /* ChatInterface */
112
+ --chat-input-max-lines: 6;
113
+
111
114
  /* Shadows */
112
115
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
113
116
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribepad/themis",
3
- "version": "1.3.4",
3
+ "version": "1.5.0",
4
4
  "description": "Accessible React component library built on React Aria primitives",
5
5
  "author": "Tribepad <mbasford@tribepad.com>",
6
6
  "license": "MIT",
@@ -149,55 +149,58 @@
149
149
  }
150
150
  },
151
151
  "dependencies": {
152
- "@internationalized/date": "^3.12.0",
152
+ "@internationalized/date": "^3.12.1",
153
153
  "class-variance-authority": "^0.7.1",
154
154
  "clsx": "^2.1.1",
155
- "react-aria": "^3.47.0",
156
- "react-aria-components": "^1.16.0",
157
- "react-stately": "^3.45.0",
155
+ "react-aria": "^3.48.0",
156
+ "react-aria-components": "^1.17.0",
157
+ "react-stately": "^3.46.0",
158
158
  "tailwind-merge": "^3.5.0"
159
159
  },
160
160
  "devDependencies": {
161
- "@eslint/js": "^9.39.4",
162
- "@size-limit/preset-small-lib": "^11.2.0",
161
+ "@eslint/js": "^10.0.1",
162
+ "@size-limit/preset-small-lib": "^12.1.0",
163
163
  "@storybook/addon-a11y": "^10.3.5",
164
- "@storybook/addon-mcp": "^0.4.2",
164
+ "@storybook/addon-mcp": "^0.6.0",
165
165
  "@storybook/react-vite": "^10.3.5",
166
- "@tailwindcss/vite": "^4.2.2",
166
+ "@tailwindcss/vite": "^4.2.4",
167
167
  "@testing-library/jest-dom": "^6.9.1",
168
168
  "@testing-library/react": "^16.3.2",
169
169
  "@testing-library/user-event": "^14.6.1",
170
170
  "@types/node": "^25.6.0",
171
171
  "@types/react": "^19.2.14",
172
172
  "@types/react-dom": "^19.2.3",
173
- "@typescript-eslint/eslint-plugin": "^8.58.2",
174
- "@typescript-eslint/parser": "^8.58.2",
175
- "@vitejs/plugin-react": "^5.2.0",
176
- "@vitest/coverage-v8": "^4.1.4",
177
- "eslint": "^9.39.4",
173
+ "@typescript-eslint/eslint-plugin": "^8.59.0",
174
+ "@typescript-eslint/parser": "^8.59.0",
175
+ "@vitejs/plugin-react": "^6.0.1",
176
+ "@vitest/coverage-v8": "^4.1.5",
177
+ "eslint": "^10.2.1",
178
178
  "eslint-plugin-jsx-a11y": "^6.10.2",
179
- "eslint-plugin-storybook": "10.3.3",
179
+ "eslint-plugin-storybook": "10.3.5",
180
180
  "globals": "^17.5.0",
181
181
  "happy-dom": "^20.9.0",
182
182
  "jest-axe": "^10.0.0",
183
- "jsdom": "^28.1.0",
184
- "lucide-react": "^1.8.0",
183
+ "jsdom": "^29.1.0",
184
+ "lucide-react": "^1.11.0",
185
185
  "motion": "^12.38.0",
186
186
  "react": "^19.2.5",
187
187
  "react-dom": "^19.2.5",
188
- "size-limit": "^11.2.0",
188
+ "size-limit": "^12.1.0",
189
189
  "storybook": "^10.3.5",
190
190
  "tsup": "^8.5.1",
191
191
  "tsx": "^4.21.0",
192
192
  "typescript": "^5.9.3",
193
- "vite": "^8.0.8",
194
- "vitest": "^4.1.4",
193
+ "vite": "^8.0.10",
194
+ "vitest": "^4.1.5",
195
195
  "zod": "^4.3.6"
196
196
  },
197
197
  "pnpm": {
198
198
  "onlyBuiltDependencies": [
199
199
  "esbuild"
200
- ]
200
+ ],
201
+ "overrides": {
202
+ "postcss@<8.5.10": "^8.5.12"
203
+ }
201
204
  },
202
205
  "engines": {
203
206
  "node": ">=18.0.0"
@@ -255,6 +258,11 @@
255
258
  "name": "PageTitle only",
256
259
  "path": "dist/elements/PageTitle/index.mjs",
257
260
  "limit": "60 KB"
261
+ },
262
+ {
263
+ "name": "ChatInterface only",
264
+ "path": "dist/elements/ChatInterface/index.mjs",
265
+ "limit": "28 KB"
258
266
  }
259
267
  ],
260
268
  "keywords": [
@@ -0,0 +1,495 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import {
3
+ forwardRef,
4
+ useCallback,
5
+ useEffect,
6
+ useImperativeHandle,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactElement,
11
+ } from 'react';
12
+ import { Bot, User } from 'lucide-react';
13
+ import { ChatInterface } from './ChatInterface';
14
+ import type {
15
+ ChatInputComponent,
16
+ ChatInputComponentHandle,
17
+ ChatInterfaceHandle,
18
+ ChatMessage,
19
+ ChatTopLevelStatus,
20
+ } from './ChatInterface.types';
21
+
22
+ const meta = {
23
+ title: 'Elements/ChatInterface',
24
+ component: ChatInterface,
25
+ parameters: {
26
+ layout: 'fullscreen',
27
+ docs: {
28
+ description: {
29
+ component:
30
+ 'WCAG 2.2 AAA-compliant chatbot UI. Stateless from the library\'s perspective — the consumer owns messages, status, and websocket. Markdown is rendered by the consumer via render props (see "BYO markdown" story for `react-markdown` + `rehype-sanitize` setup). The `<div role="log">` is visual + navigable; a single sr-only `<div role="status">` is the only AT announcement channel (completion, stop, error, bounded "still working" pings).',
31
+ },
32
+ },
33
+ },
34
+ tags: ['autodocs'],
35
+ } satisfies Meta<typeof ChatInterface>;
36
+
37
+ export default meta;
38
+ type Story = StoryObj<typeof meta>;
39
+
40
+ const ASSISTANT_AVATAR = (
41
+ <span className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--primary-action)] text-[var(--primary-action-foreground)]">
42
+ <Bot className="h-4 w-4" aria-hidden="true" />
43
+ </span>
44
+ );
45
+
46
+ const USER_AVATAR = (
47
+ <span className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--muted)]">
48
+ <User className="h-4 w-4" aria-hidden="true" />
49
+ </span>
50
+ );
51
+
52
+ const DEFAULT_EMPTY_STATE = (
53
+ <div className="text-center text-[var(--muted-foreground)]">
54
+ <p className="text-lg font-medium">How can we help?</p>
55
+ <p className="mt-2 text-sm">Ask a question to get started.</p>
56
+ </div>
57
+ );
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Empty state
61
+ // ---------------------------------------------------------------------------
62
+ export const Empty: Story = {
63
+ render: () => (
64
+ <div className="h-screen mx-auto max-w-2xl">
65
+ <ChatInterface
66
+ messages={[]}
67
+ status="idle"
68
+ onSendMessage={() => {}}
69
+ emptyState={DEFAULT_EMPTY_STATE}
70
+ assistantAvatar={ASSISTANT_AVATAR}
71
+ userAvatar={USER_AVATAR}
72
+ />
73
+ </div>
74
+ ),
75
+ };
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Plain conversation
79
+ // ---------------------------------------------------------------------------
80
+ function PlainHarness(): ReactElement {
81
+ const [messages, setMessages] = useState<ChatMessage[]>([
82
+ { id: '1', role: 'user', status: 'complete', content: 'Hi, can you help me reset my password?' },
83
+ { id: '2', role: 'assistant', status: 'complete', content: 'Of course! Visit Settings → Security → Reset password. You\'ll get an email with a link.' },
84
+ { id: '3', role: 'user', status: 'complete', content: 'Thanks!' },
85
+ ]);
86
+ const handleSend = (text: string): void => {
87
+ setMessages((m) => [
88
+ ...m,
89
+ { id: `${m.length + 1}`, role: 'user', status: 'complete', content: text },
90
+ {
91
+ id: `${m.length + 2}`,
92
+ role: 'assistant',
93
+ status: 'complete',
94
+ content: 'Got it — I\'ll look into that for you.',
95
+ },
96
+ ]);
97
+ };
98
+ return (
99
+ <div className="h-screen mx-auto max-w-2xl">
100
+ <ChatInterface
101
+ messages={messages}
102
+ status="idle"
103
+ onSendMessage={handleSend}
104
+ assistantAvatar={ASSISTANT_AVATAR}
105
+ userAvatar={USER_AVATAR}
106
+ />
107
+ </div>
108
+ );
109
+ }
110
+ export const PlainConversation: Story = {
111
+ render: () => <PlainHarness />,
112
+ };
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Streaming response (mock token-by-token mutation, new object per token)
116
+ // ---------------------------------------------------------------------------
117
+ function StreamingHarness(): ReactElement {
118
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
119
+ const [status, setStatus] = useState<ChatTopLevelStatus>('idle');
120
+ const handleSend = useCallback((text: string): void => {
121
+ const userId = crypto.randomUUID();
122
+ const aid = crypto.randomUUID();
123
+ setMessages((m) => [
124
+ ...m,
125
+ { id: userId, role: 'user', status: 'complete', content: text },
126
+ { id: aid, role: 'assistant', status: 'streaming', content: '' },
127
+ ]);
128
+ setStatus('streaming');
129
+ const reply = 'Thanks for your question! Here is a streamed response that demonstrates the bubble updating token by token, while the screen reader stays silent. Once complete, the status region announces a preview.';
130
+ let idx = 0;
131
+ const tick = (): void => {
132
+ idx += Math.floor(Math.random() * 5) + 1;
133
+ if (idx >= reply.length) {
134
+ setMessages((m) =>
135
+ m.map((msg) =>
136
+ msg.id === aid && msg.role === 'assistant'
137
+ ? { ...msg, status: 'complete', content: reply }
138
+ : msg
139
+ )
140
+ );
141
+ setStatus('idle');
142
+ return;
143
+ }
144
+ setMessages((m) =>
145
+ m.map((msg) =>
146
+ msg.id === aid && msg.role === 'assistant'
147
+ ? { ...msg, content: reply.slice(0, idx) }
148
+ : msg
149
+ )
150
+ );
151
+ setTimeout(tick, 50);
152
+ };
153
+ setTimeout(tick, 200);
154
+ }, []);
155
+ const handleStop = useCallback((): void => {
156
+ setStatus('idle');
157
+ setMessages((m) =>
158
+ m.map((msg) =>
159
+ msg.role === 'assistant' && msg.status === 'streaming'
160
+ ? { ...msg, status: 'complete' }
161
+ : msg
162
+ )
163
+ );
164
+ }, []);
165
+ return (
166
+ <div className="h-screen mx-auto max-w-2xl">
167
+ <ChatInterface
168
+ messages={messages}
169
+ status={status}
170
+ onSendMessage={handleSend}
171
+ onStopGeneration={handleStop}
172
+ emptyState={DEFAULT_EMPTY_STATE}
173
+ assistantAvatar={ASSISTANT_AVATAR}
174
+ userAvatar={USER_AVATAR}
175
+ />
176
+ </div>
177
+ );
178
+ }
179
+ export const Streaming: Story = {
180
+ render: () => <StreamingHarness />,
181
+ };
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Thinking with tool calls
185
+ // ---------------------------------------------------------------------------
186
+ function ThinkingHarness(): ReactElement {
187
+ const [messages, setMessages] = useState<ChatMessage[]>([
188
+ { id: 'u1', role: 'user', status: 'complete', content: 'Find me last quarter\'s sales numbers.' },
189
+ {
190
+ id: 'a1',
191
+ role: 'assistant',
192
+ status: 'thinking',
193
+ content: '',
194
+ parts: [
195
+ { kind: 'tool_call', name: 'search_kb', status: 'done', summary: '3 documents found' },
196
+ { kind: 'tool_call', name: 'fetch_report', status: 'running' },
197
+ ],
198
+ },
199
+ ]);
200
+ const [status, setStatus] = useState<ChatTopLevelStatus>('streaming');
201
+ // Promote to complete after a few seconds for demo purposes.
202
+ useEffect(() => {
203
+ const t = setTimeout(() => {
204
+ setMessages((m) =>
205
+ m.map((msg) =>
206
+ msg.id === 'a1' && msg.role === 'assistant'
207
+ ? {
208
+ ...msg,
209
+ status: 'complete',
210
+ content: 'Q3 revenue was £2.4m, up 14% YoY. Top markets: UK, US, DE.',
211
+ parts: [
212
+ { kind: 'tool_call', name: 'search_kb', status: 'done', summary: '3 documents' },
213
+ { kind: 'tool_call', name: 'fetch_report', status: 'done' },
214
+ ],
215
+ }
216
+ : msg
217
+ )
218
+ );
219
+ setStatus('idle');
220
+ }, 3000);
221
+ return () => clearTimeout(t);
222
+ }, []);
223
+ return (
224
+ <div className="h-screen mx-auto max-w-2xl">
225
+ <ChatInterface
226
+ messages={messages}
227
+ status={status}
228
+ onSendMessage={() => {}}
229
+ onStopGeneration={() => setStatus('idle')}
230
+ assistantAvatar={ASSISTANT_AVATAR}
231
+ userAvatar={USER_AVATAR}
232
+ />
233
+ </div>
234
+ );
235
+ }
236
+ export const Thinking: Story = {
237
+ render: () => <ThinkingHarness />,
238
+ };
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Error + retry + escalate
242
+ // ---------------------------------------------------------------------------
243
+ export const ErrorState: Story = {
244
+ render: () => (
245
+ <div className="h-screen mx-auto max-w-2xl">
246
+ <ChatInterface
247
+ messages={[
248
+ { id: 'u1', role: 'user', status: 'complete', content: 'Tell me about Themis.' },
249
+ {
250
+ id: 'a1',
251
+ role: 'assistant',
252
+ status: 'error',
253
+ content: 'Themis is a',
254
+ errorMessage: 'Connection lost. Please try again or contact support.',
255
+ },
256
+ ]}
257
+ status="error"
258
+ onSendMessage={() => {}}
259
+ onRetry={() => {}}
260
+ onEscalate={() => {}}
261
+ assistantAvatar={ASSISTANT_AVATAR}
262
+ userAvatar={USER_AVATAR}
263
+ />
264
+ </div>
265
+ ),
266
+ };
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // BYO markdown — DEMO ONLY. Consumers integrating this pattern MUST configure
270
+ // rehype-sanitize, urlTransform stripping `javascript:`/`data:` schemes,
271
+ // `<a>` rel="noopener noreferrer nofollow", and disable images by default
272
+ // unless the assistant content origin is fully trusted.
273
+ // (react-markdown is a devDependency only — not bundled.)
274
+ // ---------------------------------------------------------------------------
275
+ //
276
+ // SECURITY: this story uses a minimal "safe-as-text" renderer to keep the
277
+ // Storybook demo dependency-free. Real consumers should adopt the snippet
278
+ // in the README's "Markdown rendering: security" section.
279
+ function safeMarkdownRender(content: string): ReactElement {
280
+ // Naive renderer: paragraphs by blank lines, **bold** and *italic*.
281
+ // Any <, >, & in source is JSX-escaped automatically.
282
+ const paragraphs = content.split(/\n{2,}/);
283
+ return (
284
+ <div className="text-sm leading-relaxed">
285
+ {paragraphs.map((p, i) => {
286
+ const parts = p.split(/(\*\*[^*]+\*\*|\*[^*]+\*)/g).filter(Boolean);
287
+ return (
288
+ <p key={i} className={i > 0 ? 'mt-2' : ''}>
289
+ {parts.map((part, j) => {
290
+ if (/^\*\*[^*]+\*\*$/.test(part)) {
291
+ return <strong key={j}>{part.slice(2, -2)}</strong>;
292
+ }
293
+ if (/^\*[^*]+\*$/.test(part)) {
294
+ return <em key={j}>{part.slice(1, -1)}</em>;
295
+ }
296
+ return <span key={j}>{part}</span>;
297
+ })}
298
+ </p>
299
+ );
300
+ })}
301
+ </div>
302
+ );
303
+ }
304
+ export const ByoMarkdown: Story = {
305
+ render: () => (
306
+ <div className="h-screen mx-auto max-w-2xl">
307
+ <ChatInterface
308
+ messages={[
309
+ { id: 'u1', role: 'user', status: 'complete', content: 'How do I get started?' },
310
+ {
311
+ id: 'a1',
312
+ role: 'assistant',
313
+ status: 'complete',
314
+ content:
315
+ 'Welcome! Here are **three quick steps** to get you started:\n\n1. Sign in to your account.\n2. Visit *Settings* and configure your preferences.\n3. Start exploring!\n\nLet me know if you need anything else.',
316
+ },
317
+ ]}
318
+ status="idle"
319
+ onSendMessage={() => {}}
320
+ renderAssistantContent={(m) => safeMarkdownRender(m.content)}
321
+ renderUserContent={(m) => <span className="text-sm">{m.content}</span>}
322
+ assistantAvatar={ASSISTANT_AVATAR}
323
+ userAvatar={USER_AVATAR}
324
+ />
325
+ </div>
326
+ ),
327
+ };
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Custom input — minimal contenteditable RTE that outputs markdown.
331
+ // Demonstrates the ChatInputComponent contract.
332
+ // ---------------------------------------------------------------------------
333
+ const RichInput = forwardRef<
334
+ ChatInputComponentHandle,
335
+ {
336
+ isDisabled: boolean;
337
+ placeholder?: string;
338
+ 'aria-label'?: string;
339
+ onSubmit: (markdown: string) => void;
340
+ onChange?: (markdown: string) => void;
341
+ }
342
+ >(function RichInput({ isDisabled, placeholder, 'aria-label': ariaLabel, onSubmit, onChange }, ref) {
343
+ const editorRef = useRef<HTMLDivElement>(null);
344
+ const valueRef = useRef('');
345
+
346
+ const readMarkdown = (): string => {
347
+ const el = editorRef.current;
348
+ if (!el) return '';
349
+ // Naive: read innerText and treat <strong>/<em> as **/**. Real RTEs use proper serializers.
350
+ return el.innerText.trim();
351
+ };
352
+
353
+ useImperativeHandle(ref, () => ({
354
+ focus: () => editorRef.current?.focus(),
355
+ clear: () => {
356
+ if (editorRef.current) editorRef.current.innerHTML = '';
357
+ valueRef.current = '';
358
+ onChange?.('');
359
+ },
360
+ submit: () => {
361
+ const md = readMarkdown();
362
+ if (!md) return;
363
+ onSubmit(md);
364
+ },
365
+ setValue: (md: string) => {
366
+ if (editorRef.current) editorRef.current.innerText = md;
367
+ valueRef.current = md;
368
+ onChange?.(md);
369
+ },
370
+ }));
371
+
372
+ return (
373
+ <div
374
+ ref={editorRef}
375
+ role="textbox"
376
+ tabIndex={isDisabled ? -1 : 0}
377
+ contentEditable={!isDisabled}
378
+ aria-label={ariaLabel ?? 'Message'}
379
+ aria-multiline="true"
380
+ data-placeholder={placeholder}
381
+ onInput={(): void => {
382
+ valueRef.current = readMarkdown();
383
+ onChange?.(valueRef.current);
384
+ }}
385
+ onKeyDown={(e): void => {
386
+ if (
387
+ e.key === 'Enter' &&
388
+ !e.shiftKey &&
389
+ !(
390
+ (e.nativeEvent as KeyboardEvent & { isComposing?: boolean }).isComposing ||
391
+ e.keyCode === 229
392
+ )
393
+ ) {
394
+ e.preventDefault();
395
+ if (!isDisabled && readMarkdown()) onSubmit(readMarkdown());
396
+ }
397
+ }}
398
+ className="min-h-[44px] max-h-40 overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--input-border)] bg-[var(--input-background)] p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
399
+ style={{ aspectRatio: 'auto' }}
400
+ />
401
+ );
402
+ });
403
+ RichInput.displayName = 'RichInput';
404
+
405
+ function CustomInputHarness(): ReactElement {
406
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
407
+ const handleSend = (md: string): void => {
408
+ setMessages((m) => [
409
+ ...m,
410
+ { id: crypto.randomUUID(), role: 'user', status: 'complete', content: md },
411
+ { id: crypto.randomUUID(), role: 'assistant', status: 'complete', content: 'Got it.' },
412
+ ]);
413
+ };
414
+ return (
415
+ <div className="h-screen mx-auto max-w-2xl">
416
+ <ChatInterface
417
+ messages={messages}
418
+ status="idle"
419
+ onSendMessage={handleSend}
420
+ inputComponent={RichInput as unknown as ChatInputComponent}
421
+ renderUserContent={(m) => <span className="text-sm whitespace-pre-wrap">{m.content}</span>}
422
+ emptyState={DEFAULT_EMPTY_STATE}
423
+ assistantAvatar={ASSISTANT_AVATAR}
424
+ userAvatar={USER_AVATAR}
425
+ />
426
+ </div>
427
+ );
428
+ }
429
+ export const CustomInput: Story = {
430
+ render: () => <CustomInputHarness />,
431
+ };
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Long conversation — demonstrates content-visibility skipping offscreen bubbles
435
+ // ---------------------------------------------------------------------------
436
+ function LongHarness(): ReactElement {
437
+ const messages = useMemo<ChatMessage[]>(() => {
438
+ const out: ChatMessage[] = [];
439
+ for (let i = 0; i < 200; i++) {
440
+ out.push({
441
+ id: `u${i}`,
442
+ role: 'user',
443
+ status: 'complete',
444
+ content: `Question ${i + 1}: how does this work?`,
445
+ });
446
+ out.push({
447
+ id: `a${i}`,
448
+ role: 'assistant',
449
+ status: 'complete',
450
+ content: `Response ${i + 1}: it works because of the magic of memoised rendering and content-visibility.`,
451
+ });
452
+ }
453
+ return out;
454
+ }, []);
455
+ const ref = useRef<ChatInterfaceHandle>(null);
456
+ return (
457
+ <div className="h-screen mx-auto max-w-2xl">
458
+ <ChatInterface
459
+ ref={ref}
460
+ messages={messages}
461
+ status="idle"
462
+ onSendMessage={() => {}}
463
+ assistantAvatar={ASSISTANT_AVATAR}
464
+ userAvatar={USER_AVATAR}
465
+ />
466
+ </div>
467
+ );
468
+ }
469
+ export const LongConversation: Story = {
470
+ render: () => <LongHarness />,
471
+ };
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Reduced motion (forced via Storybook a11y addon parameters)
475
+ // ---------------------------------------------------------------------------
476
+ export const ReducedMotion: Story = {
477
+ parameters: {
478
+ a11y: { config: { rules: [{ id: 'duplicate-id', enabled: false }] } },
479
+ },
480
+ render: () => (
481
+ <div className="h-screen mx-auto max-w-2xl">
482
+ <ChatInterface
483
+ messages={[
484
+ { id: 'u1', role: 'user', status: 'complete', content: 'Animation off?' },
485
+ { id: 'a1', role: 'assistant', status: 'streaming', content: 'partial response, no pulse' },
486
+ ]}
487
+ status="streaming"
488
+ onSendMessage={() => {}}
489
+ onStopGeneration={() => {}}
490
+ assistantAvatar={ASSISTANT_AVATAR}
491
+ userAvatar={USER_AVATAR}
492
+ />
493
+ </div>
494
+ ),
495
+ };