@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.
- package/package.json +78 -0
- package/src/components/ui/accordion.tsx +79 -0
- package/src/components/ui/alert-dialog.tsx +198 -0
- package/src/components/ui/alert.tsx +105 -0
- package/src/components/ui/aspect-ratio.tsx +30 -0
- package/src/components/ui/avatar.tsx +125 -0
- package/src/components/ui/badge-variants.ts +166 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/banner.tsx +84 -0
- package/src/components/ui/breadcrumb.tsx +127 -0
- package/src/components/ui/button-group.tsx +99 -0
- package/src/components/ui/button-variants.ts +108 -0
- package/src/components/ui/button.tsx +54 -0
- package/src/components/ui/calendar.tsx +266 -0
- package/src/components/ui/card.tsx +155 -0
- package/src/components/ui/carousel.tsx +319 -0
- package/src/components/ui/chat.tsx +896 -0
- package/src/components/ui/checkbox-group.tsx +44 -0
- package/src/components/ui/checkbox.tsx +34 -0
- package/src/components/ui/chip.tsx +60 -0
- package/src/components/ui/collapsible.tsx +44 -0
- package/src/components/ui/color-picker.tsx +191 -0
- package/src/components/ui/combobox.tsx +317 -0
- package/src/components/ui/command.tsx +187 -0
- package/src/components/ui/confirm-dialog.tsx +125 -0
- package/src/components/ui/context-menu.tsx +254 -0
- package/src/components/ui/copyable-text.tsx +65 -0
- package/src/components/ui/date-picker.tsx +316 -0
- package/src/components/ui/date-time-picker.tsx +1071 -0
- package/src/components/ui/dialog.tsx +176 -0
- package/src/components/ui/direction.tsx +15 -0
- package/src/components/ui/drawer.tsx +178 -0
- package/src/components/ui/dropdown-menu.tsx +247 -0
- package/src/components/ui/empty.tsx +131 -0
- package/src/components/ui/field.tsx +257 -0
- package/src/components/ui/formatted-input.tsx +219 -0
- package/src/components/ui/gallery.tsx +161 -0
- package/src/components/ui/hover-card.tsx +77 -0
- package/src/components/ui/icon-picker.tsx +193 -0
- package/src/components/ui/image-cropper.tsx +787 -0
- package/src/components/ui/image-upload-field.tsx +344 -0
- package/src/components/ui/input-group.tsx +175 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/item.tsx +212 -0
- package/src/components/ui/kbd.tsx +62 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/list.tsx +46 -0
- package/src/components/ui/menu-variants.ts +71 -0
- package/src/components/ui/menubar.tsx +226 -0
- package/src/components/ui/money-input.tsx +96 -0
- package/src/components/ui/native-date-picker.tsx +120 -0
- package/src/components/ui/native-select.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +183 -0
- package/src/components/ui/pagination.tsx +150 -0
- package/src/components/ui/popover.tsx +104 -0
- package/src/components/ui/profile-header.tsx +196 -0
- package/src/components/ui/progress.tsx +71 -0
- package/src/components/ui/radio-group.tsx +115 -0
- package/src/components/ui/rating.tsx +200 -0
- package/src/components/ui/resizable.tsx +61 -0
- package/src/components/ui/responsive-dialog.tsx +278 -0
- package/src/components/ui/responsive-select.tsx +289 -0
- package/src/components/ui/responsive-sheet.tsx +225 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/section.tsx +106 -0
- package/src/components/ui/segmented-control.tsx +148 -0
- package/src/components/ui/select.tsx +216 -0
- package/src/components/ui/separator.tsx +69 -0
- package/src/components/ui/sheet.tsx +154 -0
- package/src/components/ui/shortcut.tsx +41 -0
- package/src/components/ui/sidebar.tsx +739 -0
- package/src/components/ui/skeleton.tsx +23 -0
- package/src/components/ui/slider.tsx +74 -0
- package/src/components/ui/sonner.tsx +64 -0
- package/src/components/ui/spinner.tsx +19 -0
- package/src/components/ui/stat-card.tsx +138 -0
- package/src/components/ui/stepper.tsx +181 -0
- package/src/components/ui/sticky-action-bar.tsx +42 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +132 -0
- package/src/components/ui/tabs.tsx +77 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/time-picker.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +79 -0
- package/src/components/ui/translation-input.tsx +126 -0
- package/src/components/ui/user-avatar.tsx +74 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/use-command-palette.ts +28 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-pagination.ts +81 -0
- package/src/hooks/use-picker-state.ts +44 -0
- package/src/lib/cva.ts +18 -0
- package/src/lib/time-math.ts +62 -0
- package/src/lib/utils.ts +14 -0
- package/src/styles/globals.css +36 -0
- 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
|
+
};
|