@voila.dev/ui 1.1.9

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 (99) hide show
  1. package/package.json +78 -0
  2. package/src/components/ui/accordion.tsx +79 -0
  3. package/src/components/ui/alert-dialog.tsx +198 -0
  4. package/src/components/ui/alert.tsx +105 -0
  5. package/src/components/ui/aspect-ratio.tsx +30 -0
  6. package/src/components/ui/avatar.tsx +125 -0
  7. package/src/components/ui/badge-variants.ts +166 -0
  8. package/src/components/ui/badge.tsx +45 -0
  9. package/src/components/ui/banner.tsx +84 -0
  10. package/src/components/ui/breadcrumb.tsx +127 -0
  11. package/src/components/ui/button-group.tsx +99 -0
  12. package/src/components/ui/button-variants.ts +108 -0
  13. package/src/components/ui/button.tsx +54 -0
  14. package/src/components/ui/calendar.tsx +266 -0
  15. package/src/components/ui/card.tsx +155 -0
  16. package/src/components/ui/carousel.tsx +319 -0
  17. package/src/components/ui/chat.tsx +896 -0
  18. package/src/components/ui/checkbox-group.tsx +44 -0
  19. package/src/components/ui/checkbox.tsx +34 -0
  20. package/src/components/ui/chip.tsx +60 -0
  21. package/src/components/ui/collapsible.tsx +44 -0
  22. package/src/components/ui/color-picker.tsx +191 -0
  23. package/src/components/ui/combobox.tsx +317 -0
  24. package/src/components/ui/command.tsx +187 -0
  25. package/src/components/ui/confirm-dialog.tsx +125 -0
  26. package/src/components/ui/context-menu.tsx +254 -0
  27. package/src/components/ui/copyable-text.tsx +65 -0
  28. package/src/components/ui/date-picker.tsx +316 -0
  29. package/src/components/ui/date-time-picker.tsx +1071 -0
  30. package/src/components/ui/dialog.tsx +176 -0
  31. package/src/components/ui/direction.tsx +15 -0
  32. package/src/components/ui/drawer.tsx +178 -0
  33. package/src/components/ui/dropdown-menu.tsx +247 -0
  34. package/src/components/ui/empty.tsx +131 -0
  35. package/src/components/ui/field.tsx +257 -0
  36. package/src/components/ui/formatted-input.tsx +219 -0
  37. package/src/components/ui/gallery.tsx +161 -0
  38. package/src/components/ui/hover-card.tsx +77 -0
  39. package/src/components/ui/icon-picker.tsx +193 -0
  40. package/src/components/ui/image-cropper.tsx +787 -0
  41. package/src/components/ui/image-upload-field.tsx +344 -0
  42. package/src/components/ui/input-group.tsx +175 -0
  43. package/src/components/ui/input-otp.tsx +83 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/item.tsx +212 -0
  46. package/src/components/ui/kbd.tsx +62 -0
  47. package/src/components/ui/label.tsx +19 -0
  48. package/src/components/ui/list.tsx +46 -0
  49. package/src/components/ui/menu-variants.ts +71 -0
  50. package/src/components/ui/menubar.tsx +226 -0
  51. package/src/components/ui/money-input.tsx +96 -0
  52. package/src/components/ui/native-date-picker.tsx +120 -0
  53. package/src/components/ui/native-select.tsx +74 -0
  54. package/src/components/ui/navigation-menu.tsx +183 -0
  55. package/src/components/ui/pagination.tsx +150 -0
  56. package/src/components/ui/popover.tsx +104 -0
  57. package/src/components/ui/profile-header.tsx +196 -0
  58. package/src/components/ui/progress.tsx +71 -0
  59. package/src/components/ui/radio-group.tsx +115 -0
  60. package/src/components/ui/rating.tsx +200 -0
  61. package/src/components/ui/resizable.tsx +61 -0
  62. package/src/components/ui/responsive-dialog.tsx +278 -0
  63. package/src/components/ui/responsive-select.tsx +289 -0
  64. package/src/components/ui/responsive-sheet.tsx +225 -0
  65. package/src/components/ui/scroll-area.tsx +51 -0
  66. package/src/components/ui/section.tsx +106 -0
  67. package/src/components/ui/segmented-control.tsx +148 -0
  68. package/src/components/ui/select.tsx +216 -0
  69. package/src/components/ui/separator.tsx +69 -0
  70. package/src/components/ui/sheet.tsx +154 -0
  71. package/src/components/ui/shortcut.tsx +41 -0
  72. package/src/components/ui/sidebar.tsx +739 -0
  73. package/src/components/ui/skeleton.tsx +23 -0
  74. package/src/components/ui/slider.tsx +74 -0
  75. package/src/components/ui/sonner.tsx +64 -0
  76. package/src/components/ui/spinner.tsx +19 -0
  77. package/src/components/ui/stat-card.tsx +138 -0
  78. package/src/components/ui/stepper.tsx +181 -0
  79. package/src/components/ui/sticky-action-bar.tsx +42 -0
  80. package/src/components/ui/switch.tsx +30 -0
  81. package/src/components/ui/table.tsx +132 -0
  82. package/src/components/ui/tabs.tsx +77 -0
  83. package/src/components/ui/textarea.tsx +24 -0
  84. package/src/components/ui/time-picker.tsx +221 -0
  85. package/src/components/ui/toggle-group.tsx +86 -0
  86. package/src/components/ui/toggle.tsx +43 -0
  87. package/src/components/ui/tooltip.tsx +79 -0
  88. package/src/components/ui/translation-input.tsx +126 -0
  89. package/src/components/ui/user-avatar.tsx +74 -0
  90. package/src/css-modules.d.ts +4 -0
  91. package/src/hooks/use-command-palette.ts +28 -0
  92. package/src/hooks/use-mobile.ts +21 -0
  93. package/src/hooks/use-pagination.ts +81 -0
  94. package/src/hooks/use-picker-state.ts +44 -0
  95. package/src/lib/cva.ts +18 -0
  96. package/src/lib/time-math.ts +62 -0
  97. package/src/lib/utils.ts +14 -0
  98. package/src/styles/globals.css +36 -0
  99. package/src/styles.css +10 -0
@@ -0,0 +1,896 @@
1
+ import { mergeProps } from "@base-ui/react/merge-props";
2
+ import { useRender } from "@base-ui/react/use-render";
3
+ import { ArrowDownIcon, PaperPlaneRightIcon } from "@phosphor-icons/react";
4
+ import * as React from "react";
5
+ import { Badge } from "#/components/ui/badge.tsx";
6
+ import { Button } from "#/components/ui/button.tsx";
7
+ import { ConfirmDialog } from "#/components/ui/confirm-dialog.tsx";
8
+ import { Spinner } from "#/components/ui/spinner.tsx";
9
+ import { Textarea } from "#/components/ui/textarea.tsx";
10
+ import { cva, type VariantProps } from "#/lib/cva.ts";
11
+ import { cn } from "#/lib/utils.ts";
12
+
13
+ /**
14
+ * Chat primitives. Purely presentational: every string (labels, names,
15
+ * placeholders) comes in via props so the kit stays i18n- and domain-agnostic.
16
+ *
17
+ * Composition:
18
+ * <ChatMessageList header={loadOlderButton} jumpToLatestLabel="Nouveaux messages">
19
+ * <ChatDateSeparator>Today</ChatDateSeparator>
20
+ * <ChatMessageGroup align="start">
21
+ * <ChatMessageSender avatar={…} name="Jeanne D." badge={<Badge>…</Badge>} />
22
+ * <ChatMessage variant="other">Bonjour !<ChatMessageTime dateTime="2026-06-12T09:12">09:12</ChatMessageTime></ChatMessage>
23
+ * </ChatMessageGroup>
24
+ * <ChatUnreadSeparator>Nouveaux messages</ChatUnreadSeparator>
25
+ * <ChatMessageGroup align="end">
26
+ * <ChatMessage variant="own">Salut !<ChatMessageTime>09:13</ChatMessageTime></ChatMessage>
27
+ * </ChatMessageGroup>
28
+ * </ChatMessageList>
29
+ * <ChatComposer … />
30
+ */
31
+
32
+ /** How close to the bottom (px) still counts as "following the conversation". */
33
+ const FOLLOW_THRESHOLD = 48;
34
+
35
+ // Chromium/Firefox keep prepended history visually in place via native scroll
36
+ // anchoring (`overflow-anchor`); Safari does not, so the list compensates for
37
+ // prepends manually there (see the layout effect below).
38
+ const supportsScrollAnchoring = (() => {
39
+ try {
40
+ return CSS.supports("overflow-anchor: auto");
41
+ } catch {
42
+ return false;
43
+ }
44
+ })();
45
+
46
+ /** The last message element, ignoring the floating jump-to-latest overlay. */
47
+ function lastMessageElement(node: HTMLDivElement): Element | null {
48
+ let element = node.lastElementChild;
49
+ if (element?.getAttribute("data-slot") === "chat-jump-to-latest") {
50
+ element = element.previousElementSibling;
51
+ }
52
+ return element;
53
+ }
54
+
55
+ /**
56
+ * Keeps a following reader pinned to the latest message: instantly on first
57
+ * mount, smooth-scrolling afterwards (where supported), and not at all when
58
+ * the content did not grow.
59
+ */
60
+ function scrollFollowingReaderToLatest(
61
+ node: HTMLDivElement,
62
+ input: { hasMounted: boolean; previousScrollHeight: number },
63
+ ): void {
64
+ if (!input.hasMounted) {
65
+ node.scrollTop = node.scrollHeight;
66
+ return;
67
+ }
68
+ // Parent re-rendered without the content growing: nothing to scroll.
69
+ if (node.scrollHeight === input.previousScrollHeight) {
70
+ return;
71
+ }
72
+ if (typeof node.scrollTo === "function") {
73
+ node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
74
+ return;
75
+ }
76
+ node.scrollTop = node.scrollHeight;
77
+ }
78
+
79
+ /**
80
+ * A prepend (load-older) keeps the last message's DOM node and only grows
81
+ * content above it.
82
+ */
83
+ function grewByPrepend(
84
+ node: HTMLDivElement,
85
+ input: {
86
+ previousScrollHeight: number;
87
+ previousLastMessage: Element | null;
88
+ currentLastMessage: Element | null;
89
+ },
90
+ ): boolean {
91
+ if (input.previousLastMessage === null) {
92
+ return false;
93
+ }
94
+ if (input.currentLastMessage !== input.previousLastMessage) {
95
+ return false;
96
+ }
97
+ return node.scrollHeight > input.previousScrollHeight;
98
+ }
99
+
100
+ /**
101
+ * Without native scroll anchoring (Safari) a prepend would jump the viewport
102
+ * while the reader is in history, so compensate by the height delta.
103
+ */
104
+ function compensateForPrepend(
105
+ node: HTMLDivElement,
106
+ input: {
107
+ previousScrollHeight: number;
108
+ previousLastMessage: Element | null;
109
+ currentLastMessage: Element | null;
110
+ },
111
+ ): void {
112
+ if (supportsScrollAnchoring) {
113
+ return;
114
+ }
115
+ if (!grewByPrepend(node, input)) {
116
+ return;
117
+ }
118
+ node.scrollTop += node.scrollHeight - input.previousScrollHeight;
119
+ }
120
+
121
+ function ChatMessageList({
122
+ header,
123
+ onFollowChange,
124
+ followThreshold = FOLLOW_THRESHOLD,
125
+ jumpToLatestLabel,
126
+ className,
127
+ children,
128
+ ...props
129
+ }: React.ComponentProps<"div"> & {
130
+ /** Pinned above the messages inside the scroll area (e.g. "load older"). */
131
+ header?: React.ReactNode;
132
+ /**
133
+ * Fired when the reader starts or stops following the bottom of the thread.
134
+ * Lets consumers react to the internal follow state (analytics, unread
135
+ * markers, …) beyond the built-in jump-to-latest affordance.
136
+ */
137
+ onFollowChange?: (following: boolean) => void;
138
+ /** How close to the bottom (px) still counts as "following". */
139
+ followThreshold?: number;
140
+ /**
141
+ * Label for the floating "jump to latest ↓" button shown while the reader
142
+ * is away from the bottom and new content may arrive. Omit to disable the
143
+ * affordance.
144
+ */
145
+ jumpToLatestLabel?: React.ReactNode;
146
+ }) {
147
+ const scrollRef = React.useRef<HTMLDivElement>(null);
148
+ // Follow the bottom while the reader is there; never yank them up while they
149
+ // are reading history.
150
+ const followingRef = React.useRef(true);
151
+ // Land instantly on the latest message on first mount; smooth-scroll appends
152
+ // afterwards while the reader is following along.
153
+ const hasMountedRef = React.useRef(false);
154
+ const lastScrollHeightRef = React.useRef(0);
155
+ const lastMessageElementRef = React.useRef<Element | null>(null);
156
+ // Mirrors followingRef so the jump-to-latest button can render off it.
157
+ const [following, setFollowing] = React.useState(true);
158
+
159
+ // `children` is the trigger (content changed), not a value read inside.
160
+ React.useLayoutEffect(() => {
161
+ const node = scrollRef.current;
162
+ if (node === null) {
163
+ return;
164
+ }
165
+ const previousScrollHeight = lastScrollHeightRef.current;
166
+ const previousLastMessage = lastMessageElementRef.current;
167
+ lastScrollHeightRef.current = node.scrollHeight;
168
+ lastMessageElementRef.current = lastMessageElement(node);
169
+ if (followingRef.current) {
170
+ scrollFollowingReaderToLatest(node, {
171
+ hasMounted: hasMountedRef.current,
172
+ previousScrollHeight,
173
+ });
174
+ hasMountedRef.current = true;
175
+ return;
176
+ }
177
+ hasMountedRef.current = true;
178
+ // Reading history: keep the viewport where it is.
179
+ compensateForPrepend(node, {
180
+ previousScrollHeight,
181
+ previousLastMessage,
182
+ currentLastMessage: lastMessageElementRef.current,
183
+ });
184
+ }, [children]);
185
+
186
+ return (
187
+ <div
188
+ ref={scrollRef}
189
+ data-slot="chat-message-list"
190
+ // New messages are announced politely to screen readers.
191
+ role="log"
192
+ aria-live="polite"
193
+ onScroll={(scrollEvent) => {
194
+ const node = scrollEvent.currentTarget;
195
+ const nextFollowing =
196
+ node.scrollHeight - node.scrollTop - node.clientHeight <
197
+ followThreshold;
198
+ if (nextFollowing !== followingRef.current) {
199
+ followingRef.current = nextFollowing;
200
+ setFollowing(nextFollowing);
201
+ onFollowChange?.(nextFollowing);
202
+ }
203
+ }}
204
+ className={cn(
205
+ "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pr-1 dark:scheme-dark",
206
+ className,
207
+ )}
208
+ {...props}
209
+ >
210
+ {header}
211
+ {children}
212
+ {jumpToLatestLabel !== undefined && !following ? (
213
+ <div
214
+ data-slot="chat-jump-to-latest"
215
+ className="pointer-events-none sticky bottom-2 z-10 flex justify-center"
216
+ >
217
+ <Button
218
+ type="button"
219
+ size="sm"
220
+ variant="outline"
221
+ className="pointer-events-auto rounded-full shadow-md"
222
+ onClick={() => {
223
+ const node = scrollRef.current;
224
+ if (node === null) {
225
+ return;
226
+ }
227
+ followingRef.current = true;
228
+ setFollowing(true);
229
+ onFollowChange?.(true);
230
+ // Instant (not smooth) so the follow state settles in one
231
+ // step instead of flickering through scroll events.
232
+ node.scrollTop = node.scrollHeight;
233
+ }}
234
+ >
235
+ <ArrowDownIcon />
236
+ {jumpToLatestLabel}
237
+ </Button>
238
+ </div>
239
+ ) : null}
240
+ </div>
241
+ );
242
+ }
243
+
244
+ function ChatDateSeparator({
245
+ className,
246
+ children,
247
+ ...props
248
+ }: React.ComponentProps<"div">) {
249
+ return (
250
+ <div
251
+ data-slot="chat-date-separator"
252
+ role="separator"
253
+ className={cn(
254
+ "flex items-center gap-3 py-1 text-xs text-muted-foreground",
255
+ "before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border",
256
+ className,
257
+ )}
258
+ {...props}
259
+ >
260
+ {children}
261
+ </div>
262
+ );
263
+ }
264
+
265
+ /**
266
+ * "New messages" rule shown when opening a thread with unread messages.
267
+ * Same anatomy as ChatDateSeparator, in the destructive accent.
268
+ */
269
+ function ChatUnreadSeparator({
270
+ className,
271
+ children,
272
+ ...props
273
+ }: React.ComponentProps<"div">) {
274
+ return (
275
+ <div
276
+ data-slot="chat-unread-separator"
277
+ role="separator"
278
+ className={cn(
279
+ "flex items-center gap-3 py-1 font-medium text-destructive text-xs",
280
+ "before:h-px before:flex-1 before:bg-destructive/40 after:h-px after:flex-1 after:bg-destructive/40",
281
+ className,
282
+ )}
283
+ {...props}
284
+ >
285
+ {children}
286
+ </div>
287
+ );
288
+ }
289
+
290
+ function ChatMessageGroup({
291
+ align,
292
+ className,
293
+ ...props
294
+ }: React.ComponentProps<"div"> & { align: "start" | "end" }) {
295
+ return (
296
+ <div
297
+ data-slot="chat-message-group"
298
+ data-align={align}
299
+ className={cn(
300
+ // `group` so descendants (e.g. ChatMessageTime) can react to the
301
+ // group's `data-align` via `group-data-[align=end]:*`.
302
+ "group flex flex-col gap-1",
303
+ align === "end" ? "items-end" : "items-start",
304
+ className,
305
+ )}
306
+ {...props}
307
+ />
308
+ );
309
+ }
310
+
311
+ function ChatMessageSender({
312
+ avatar,
313
+ name,
314
+ badge,
315
+ className,
316
+ ...props
317
+ }: React.ComponentProps<"div"> & {
318
+ avatar?: React.ReactNode;
319
+ name: React.ReactNode;
320
+ badge?: React.ReactNode;
321
+ }) {
322
+ return (
323
+ <div
324
+ data-slot="chat-message-sender"
325
+ className={cn("flex items-center gap-1.5 pt-1", className)}
326
+ {...props}
327
+ >
328
+ {avatar}
329
+ <span className="font-medium text-foreground text-xs">{name}</span>
330
+ {badge}
331
+ </div>
332
+ );
333
+ }
334
+
335
+ const chatMessageVariants = cva({
336
+ // `min-w-0` + `overflow-wrap:anywhere` keep an unbroken word/URL inside the
337
+ // bubble instead of overflowing the whole list horizontally.
338
+ // Subtle enter animation for appended messages; respect reduced-motion.
339
+ // Content stays left-aligned in both variants so short messages read from the
340
+ // left edge of the bubble; the bubble itself is aligned end/start by the
341
+ // enclosing `ChatMessageGroup`.
342
+ base: "flex min-w-0 max-w-[85%] flex-col items-start gap-0.5 rounded-lg px-3 py-2 text-sm whitespace-pre-wrap [overflow-wrap:anywhere] animate-in fade-in slide-in-from-bottom-1 motion-reduce:animate-none sm:max-w-[75%]",
343
+ variants: {
344
+ variant: {
345
+ own: "rounded-br-sm bg-primary text-primary-foreground",
346
+ other: "rounded-bl-sm bg-muted text-foreground",
347
+ },
348
+ },
349
+ });
350
+
351
+ function ChatMessage({
352
+ variant,
353
+ className,
354
+ ...props
355
+ }: React.ComponentProps<"div"> &
356
+ Required<Pick<VariantProps<typeof chatMessageVariants>, "variant">>) {
357
+ return (
358
+ <div
359
+ data-slot="chat-message"
360
+ data-variant={variant}
361
+ className={cn(chatMessageVariants({ variant, className }))}
362
+ {...props}
363
+ />
364
+ );
365
+ }
366
+
367
+ const CHAT_LINK_PATTERN = /https?:\/\/[^\s<>"']+/g;
368
+
369
+ /**
370
+ * Message body for plain text that may contain URLs: each URL becomes a link
371
+ * opening in a new tab. Pass `onLinkClick` to intercept activation instead —
372
+ * typically to confirm leaving the site via `ChatExternalLinkDialog`.
373
+ */
374
+ function ChatMessageText({
375
+ children,
376
+ onLinkClick,
377
+ className,
378
+ ...props
379
+ }: Omit<React.ComponentProps<"span">, "children"> & {
380
+ children: string;
381
+ onLinkClick?: (url: string) => void;
382
+ }) {
383
+ const nodes: React.ReactNode[] = [];
384
+ let lastIndex = 0;
385
+ for (const match of children.matchAll(CHAT_LINK_PATTERN)) {
386
+ // Trailing punctuation belongs to the sentence, not the URL.
387
+ const url = match[0].replace(/[.,;:!?)\]]+$/, "");
388
+ if (match.index > lastIndex) {
389
+ nodes.push(children.slice(lastIndex, match.index));
390
+ }
391
+ nodes.push(
392
+ <a
393
+ key={`${match.index}-${url}`}
394
+ href={url}
395
+ target="_blank"
396
+ rel="noopener noreferrer"
397
+ className="underline underline-offset-2 hover:opacity-80"
398
+ onClick={(clickEvent) => {
399
+ if (onLinkClick === undefined) {
400
+ return;
401
+ }
402
+ clickEvent.preventDefault();
403
+ onLinkClick(url);
404
+ }}
405
+ >
406
+ {url}
407
+ </a>,
408
+ );
409
+ lastIndex = match.index + url.length;
410
+ }
411
+ if (lastIndex < children.length) {
412
+ nodes.push(children.slice(lastIndex));
413
+ }
414
+ return (
415
+ // A single wrapping element so the linkified fragments stay one inline
416
+ // flow inside the bubble's flex column.
417
+ <span data-slot="chat-message-text" className={className} {...props}>
418
+ {nodes}
419
+ </span>
420
+ );
421
+ }
422
+
423
+ /**
424
+ * "You are about to leave the site" confirmation for external links. Open it
425
+ * by setting `url` (typically from `ChatMessageText`'s `onLinkClick`); on
426
+ * confirm the URL opens in a new tab. Purely presentational: all labels come
427
+ * in via props.
428
+ */
429
+ function ChatExternalLinkDialog({
430
+ url,
431
+ onClose,
432
+ title,
433
+ description,
434
+ confirmLabel,
435
+ cancelLabel,
436
+ }: {
437
+ /** The pending external URL; `null` keeps the dialog closed. */
438
+ url: string | null;
439
+ onClose: () => void;
440
+ title: React.ReactNode;
441
+ /** Warning copy shown above the URL, e.g. "Do you trust this link?". */
442
+ description?: React.ReactNode;
443
+ confirmLabel: React.ReactNode;
444
+ cancelLabel: React.ReactNode;
445
+ }) {
446
+ return (
447
+ <ConfirmDialog
448
+ open={url !== null}
449
+ onOpenChange={(open) => {
450
+ if (!open) {
451
+ onClose();
452
+ }
453
+ }}
454
+ title={title}
455
+ description={
456
+ <>
457
+ {description}
458
+ <span
459
+ data-slot="chat-external-link-url"
460
+ className="mt-2 block break-all rounded-md bg-muted px-2 py-1 font-mono text-foreground text-xs"
461
+ >
462
+ {url}
463
+ </span>
464
+ </>
465
+ }
466
+ confirmLabel={confirmLabel}
467
+ cancelLabel={cancelLabel}
468
+ onConfirm={() => {
469
+ if (url !== null) {
470
+ window.open(url, "_blank", "noopener,noreferrer");
471
+ }
472
+ return undefined;
473
+ }}
474
+ />
475
+ );
476
+ }
477
+
478
+ function ChatMessageTime({
479
+ className,
480
+ ...props
481
+ }: React.ComponentProps<"time">) {
482
+ return (
483
+ <time
484
+ data-slot="chat-message-time"
485
+ className={cn(
486
+ "select-none text-xs opacity-70 group-data-[align=end]:text-primary-foreground",
487
+ className,
488
+ )}
489
+ {...props}
490
+ />
491
+ );
492
+ }
493
+
494
+ /**
495
+ * The Enter-key send policy: Cmd/Ctrl+Enter always sends, a plain Enter sends
496
+ * only under `submitOnEnter` (Shift+Enter still inserts a newline), and an
497
+ * Enter that confirms an IME composition (Japanese, Chinese, dead keys…) never
498
+ * sends the message.
499
+ */
500
+ function isSubmitKey(
501
+ keyEvent: React.KeyboardEvent<HTMLTextAreaElement>,
502
+ submitOnEnter: boolean,
503
+ ): boolean {
504
+ if (keyEvent.key !== "Enter" || keyEvent.nativeEvent.isComposing) {
505
+ return false;
506
+ }
507
+ const withModifier = keyEvent.metaKey || keyEvent.ctrlKey;
508
+ const plainEnterSends = submitOnEnter && !keyEvent.shiftKey;
509
+ return withModifier || plainEnterSends;
510
+ }
511
+
512
+ /** The composer's growing textarea beside its inline send button. */
513
+ function ChatComposerInput({
514
+ value,
515
+ onValueChange,
516
+ onSubmit,
517
+ placeholder,
518
+ disabled,
519
+ sending,
520
+ submitOnEnter,
521
+ canSend,
522
+ invalid,
523
+ describedBy,
524
+ sendLabel,
525
+ }: {
526
+ value: string;
527
+ onValueChange: (value: string) => void;
528
+ onSubmit: () => void;
529
+ placeholder: string | undefined;
530
+ disabled: boolean;
531
+ sending: boolean;
532
+ submitOnEnter: boolean;
533
+ canSend: boolean;
534
+ invalid: boolean;
535
+ describedBy: string | undefined;
536
+ sendLabel: string;
537
+ }) {
538
+ return (
539
+ <div className="flex items-end gap-2">
540
+ <Textarea
541
+ value={value}
542
+ onChange={(changeEvent) => onValueChange(changeEvent.target.value)}
543
+ onKeyDown={(keyEvent) => {
544
+ if (!isSubmitKey(keyEvent, submitOnEnter)) {
545
+ return;
546
+ }
547
+ keyEvent.preventDefault();
548
+ if (canSend) {
549
+ onSubmit();
550
+ }
551
+ }}
552
+ placeholder={placeholder}
553
+ disabled={disabled}
554
+ aria-invalid={invalid ? true : undefined}
555
+ aria-describedby={describedBy}
556
+ className="max-h-40 min-h-10 flex-1 resize-none"
557
+ />
558
+ {/* Inline circular send button, bottom-aligned so it stays put as the
559
+ textarea grows. The visible label is dropped for the icon-only
560
+ affordance but preserved as the accessible name. */}
561
+ <Button
562
+ type="submit"
563
+ size="icon"
564
+ disabled={!canSend}
565
+ aria-label={sendLabel}
566
+ className="size-9 rounded-full"
567
+ >
568
+ {sending ? <Spinner /> : <PaperPlaneRightIcon />}
569
+ </Button>
570
+ </div>
571
+ );
572
+ }
573
+
574
+ /** The composer's optional hint + character counter row. */
575
+ function ChatComposerFooter({
576
+ hint,
577
+ length,
578
+ maxLength,
579
+ overLimit,
580
+ }: {
581
+ hint: React.ReactNode;
582
+ length: number;
583
+ maxLength: number | undefined;
584
+ overLimit: boolean;
585
+ }) {
586
+ const hasHint = hint !== undefined && hint !== null;
587
+ if (!hasHint && maxLength === undefined) {
588
+ return null;
589
+ }
590
+ return (
591
+ <div className="flex items-center justify-between gap-2">
592
+ {hasHint ? (
593
+ <span
594
+ data-slot="chat-composer-hint"
595
+ className="min-w-0 truncate text-xs text-muted-foreground"
596
+ >
597
+ {hint}
598
+ </span>
599
+ ) : null}
600
+ {maxLength !== undefined ? (
601
+ <span
602
+ data-slot="chat-composer-counter"
603
+ className={cn(
604
+ "ml-auto shrink-0 text-xs tabular-nums",
605
+ overLimit ? "text-destructive" : "text-muted-foreground",
606
+ )}
607
+ >
608
+ {length}/{maxLength}
609
+ </span>
610
+ ) : null}
611
+ </div>
612
+ );
613
+ }
614
+
615
+ function ChatComposer({
616
+ value,
617
+ onValueChange,
618
+ onSubmit,
619
+ placeholder,
620
+ disabled = false,
621
+ sending = false,
622
+ submitOnEnter = false,
623
+ maxLength,
624
+ sendLabel,
625
+ error,
626
+ hint,
627
+ className,
628
+ ...props
629
+ }: Omit<React.ComponentProps<"form">, "onSubmit"> & {
630
+ value: string;
631
+ onValueChange: (value: string) => void;
632
+ /** Fired on send button click, on (Cmd|Ctrl)+Enter, and — when
633
+ * `submitOnEnter` is set — on plain Enter. */
634
+ onSubmit: () => void;
635
+ placeholder?: string;
636
+ /** Composer unusable (e.g. archived conversation). */
637
+ disabled?: boolean;
638
+ /**
639
+ * A post is in flight: button shows a spinner, submit is suppressed. The
640
+ * textarea stays enabled so the writer keeps focus and can draft the next
641
+ * message.
642
+ */
643
+ sending?: boolean;
644
+ /**
645
+ * Send on a plain Enter (Shift+Enter still inserts a newline), matching the
646
+ * mobile/messenger convention. Cmd/Ctrl+Enter always sends regardless.
647
+ */
648
+ submitOnEnter?: boolean;
649
+ /**
650
+ * Soft character limit: shows a live counter and disables send once
651
+ * exceeded. The textarea itself is not capped so the writer can trim an
652
+ * over-long draft instead of having input silently dropped.
653
+ */
654
+ maxLength?: number;
655
+ sendLabel: string;
656
+ error?: React.ReactNode;
657
+ hint?: React.ReactNode;
658
+ }) {
659
+ const errorId = React.useId();
660
+ const hasError = error !== undefined && error !== null;
661
+ const overLimit = maxLength !== undefined && value.length > maxLength;
662
+ const canSend =
663
+ !disabled && !sending && !overLimit && value.trim().length > 0;
664
+
665
+ return (
666
+ <form
667
+ data-slot="chat-composer"
668
+ onSubmit={(formEvent) => {
669
+ formEvent.preventDefault();
670
+ if (canSend) {
671
+ onSubmit();
672
+ }
673
+ }}
674
+ className={cn("flex flex-col gap-2", className)}
675
+ {...props}
676
+ >
677
+ <ChatComposerInput
678
+ value={value}
679
+ onValueChange={onValueChange}
680
+ onSubmit={onSubmit}
681
+ placeholder={placeholder}
682
+ disabled={disabled}
683
+ sending={sending}
684
+ submitOnEnter={submitOnEnter}
685
+ canSend={canSend}
686
+ invalid={hasError || overLimit}
687
+ describedBy={hasError ? errorId : undefined}
688
+ sendLabel={sendLabel}
689
+ />
690
+ {hasError ? (
691
+ <p id={errorId} role="alert" className="text-xs text-destructive">
692
+ {error}
693
+ </p>
694
+ ) : null}
695
+ <ChatComposerFooter
696
+ hint={hint}
697
+ length={value.length}
698
+ maxLength={maxLength}
699
+ overLimit={overLimit}
700
+ />
701
+ </form>
702
+ );
703
+ }
704
+
705
+ /**
706
+ * A row in a conversation list. Polymorphic via `render`: pass
707
+ * `render={<a/>}` or `render={<button/>}` to make the row a real link/button.
708
+ * The default `div` is inert and intentionally carries no hover/focus
709
+ * affordances — those only apply once `render` supplies an interactive
710
+ * element.
711
+ */
712
+ function ChatConversationItem({
713
+ title,
714
+ description,
715
+ timestamp,
716
+ unreadCount = 0,
717
+ unreadLabel,
718
+ badges,
719
+ leading,
720
+ className,
721
+ render,
722
+ ...props
723
+ }: useRender.ComponentProps<"div"> & {
724
+ title: React.ReactNode;
725
+ /** Secondary line: last-message preview or "no messages yet" copy. */
726
+ description?: React.ReactNode;
727
+ timestamp?: React.ReactNode;
728
+ /** Messages the reader has not seen; > 0 bolds the row and shows a count. */
729
+ unreadCount?: number;
730
+ /**
731
+ * Accessible label for the unread badge (the visible glyph is a bare count,
732
+ * capped at "99+"). Spell out the true total here, e.g. "3 unread messages",
733
+ * so screen readers don't announce a contextless number.
734
+ */
735
+ unreadLabel?: string;
736
+ /** Subject/status badges, rendered after the title. */
737
+ badges?: React.ReactNode;
738
+ /** Leading visual (typically an avatar), rendered before the text column. */
739
+ leading?: React.ReactNode;
740
+ }) {
741
+ const unread = unreadCount > 0;
742
+ const interactive = render !== undefined;
743
+ return useRender({
744
+ defaultTagName: "div",
745
+ props: mergeProps<"div">(
746
+ {
747
+ className: cn(
748
+ "group/chat-conversation-item flex w-full items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm outline-none",
749
+ interactive &&
750
+ "transition-colors duration-100 hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
751
+ className,
752
+ ),
753
+ children: (
754
+ <>
755
+ {leading}
756
+ <ChatConversationItemText
757
+ title={title}
758
+ description={description}
759
+ badges={badges}
760
+ unread={unread}
761
+ />
762
+ <ChatConversationItemMeta
763
+ timestamp={timestamp}
764
+ unread={unread}
765
+ unreadCount={unreadCount}
766
+ unreadLabel={unreadLabel}
767
+ />
768
+ </>
769
+ ),
770
+ },
771
+ props,
772
+ ),
773
+ render,
774
+ state: {
775
+ slot: "chat-conversation-item",
776
+ unread,
777
+ },
778
+ });
779
+ }
780
+
781
+ /** The row's text column: title with trailing badges, then the description. */
782
+ function ChatConversationItemText({
783
+ title,
784
+ description,
785
+ badges,
786
+ unread,
787
+ }: {
788
+ title: React.ReactNode;
789
+ description?: React.ReactNode;
790
+ badges?: React.ReactNode;
791
+ unread: boolean;
792
+ }) {
793
+ return (
794
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
795
+ {/* Wrap so badges drop below the title instead of overflowing
796
+ onto the timestamp column when space runs out. */}
797
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
798
+ <span
799
+ className={cn("truncate", unread ? "font-semibold" : "font-medium")}
800
+ >
801
+ {title}
802
+ </span>
803
+ {badges}
804
+ </div>
805
+ {description !== undefined && description !== null ? (
806
+ <span className="truncate text-xs text-muted-foreground">
807
+ {description}
808
+ </span>
809
+ ) : null}
810
+ </div>
811
+ );
812
+ }
813
+
814
+ /** The row's trailing column: timestamp over the unread badge. */
815
+ function ChatConversationItemMeta({
816
+ timestamp,
817
+ unread,
818
+ unreadCount,
819
+ unreadLabel,
820
+ }: {
821
+ timestamp?: React.ReactNode;
822
+ unread: boolean;
823
+ unreadCount: number;
824
+ unreadLabel?: string;
825
+ }) {
826
+ return (
827
+ // Top-align the meta column so timestamps line up across rows whether or
828
+ // not an unread badge sits below them.
829
+ <div className="flex shrink-0 flex-col items-end gap-1 self-start">
830
+ <ChatConversationItemTimestamp timestamp={timestamp} unread={unread} />
831
+ {unread ? (
832
+ <ChatConversationItemUnreadBadge
833
+ unreadCount={unreadCount}
834
+ unreadLabel={unreadLabel}
835
+ />
836
+ ) : null}
837
+ </div>
838
+ );
839
+ }
840
+
841
+ function ChatConversationItemTimestamp({
842
+ timestamp,
843
+ unread,
844
+ }: {
845
+ timestamp?: React.ReactNode;
846
+ unread: boolean;
847
+ }) {
848
+ if (timestamp === undefined || timestamp === null) {
849
+ return null;
850
+ }
851
+ return (
852
+ <span
853
+ className={cn(
854
+ "text-xs",
855
+ unread ? "font-medium text-primary" : "text-muted-foreground",
856
+ )}
857
+ >
858
+ {timestamp}
859
+ </span>
860
+ );
861
+ }
862
+
863
+ function ChatConversationItemUnreadBadge({
864
+ unreadCount,
865
+ unreadLabel,
866
+ }: {
867
+ unreadCount: number;
868
+ unreadLabel?: string;
869
+ }) {
870
+ return (
871
+ <Badge
872
+ // Labeled graphic so screen readers announce the true count with
873
+ // context ("3 unread messages") rather than the bare, possibly-capped
874
+ // ("99+") glyph.
875
+ role={unreadLabel === undefined ? undefined : "img"}
876
+ aria-label={unreadLabel}
877
+ className="h-5 min-w-5 justify-center rounded-full px-1.5 tabular-nums"
878
+ >
879
+ {unreadCount > 99 ? "99+" : unreadCount}
880
+ </Badge>
881
+ );
882
+ }
883
+
884
+ export {
885
+ ChatComposer,
886
+ ChatConversationItem,
887
+ ChatDateSeparator,
888
+ ChatExternalLinkDialog,
889
+ ChatMessage,
890
+ ChatMessageGroup,
891
+ ChatMessageList,
892
+ ChatMessageSender,
893
+ ChatMessageText,
894
+ ChatMessageTime,
895
+ ChatUnreadSeparator,
896
+ };