@birdycom/omniaui-react 0.11.0 → 0.11.2
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/LICENSE +21 -21
- package/README.md +21 -21
- package/package.json +71 -71
- package/src/blocks/BlockAboutTimeline.tsx +46 -46
- package/src/blocks/BlockAppHeader.tsx +52 -52
- package/src/blocks/BlockAppShell.tsx +56 -56
- package/src/blocks/BlockAuth.tsx +53 -53
- package/src/blocks/BlockAuthSplit.tsx +84 -84
- package/src/blocks/BlockContactGrid.tsx +71 -71
- package/src/blocks/BlockCta.tsx +23 -23
- package/src/blocks/BlockDataTable.tsx +57 -57
- package/src/blocks/BlockFaq.tsx +55 -55
- package/src/blocks/BlockFeatures.tsx +38 -38
- package/src/blocks/BlockFooter.tsx +50 -50
- package/src/blocks/BlockFormPage.tsx +26 -26
- package/src/blocks/BlockHero.tsx +27 -27
- package/src/blocks/BlockHeroCode.tsx +63 -63
- package/src/blocks/BlockHeroOrbit.tsx +73 -73
- package/src/blocks/BlockHeroSplit.tsx +28 -28
- package/src/blocks/BlockModuleHeader.tsx +34 -34
- package/src/blocks/BlockPageHero.tsx +25 -25
- package/src/blocks/BlockPortfolioGrid.tsx +50 -50
- package/src/blocks/BlockPricing.tsx +62 -62
- package/src/blocks/BlockProcess.tsx +50 -50
- package/src/blocks/BlockProductGrid.tsx +54 -54
- package/src/blocks/BlockProfileHeader.tsx +57 -57
- package/src/blocks/BlockShowcase.tsx +58 -58
- package/src/blocks/BlockSiteFooter.tsx +60 -60
- package/src/blocks/BlockSiteHeader.tsx +42 -42
- package/src/blocks/BlockSpotlight.tsx +58 -58
- package/src/blocks/BlockStats.tsx +26 -26
- package/src/blocks/BlockStatsCounter.tsx +31 -31
- package/src/blocks/BlockTeamList.tsx +48 -48
- package/src/blocks/BlockTwoFactor.tsx +57 -57
- package/src/blocks/BlockWizardDialog.tsx +101 -101
- package/src/components/Accordion.tsx +31 -31
- package/src/components/AccordionAccent.tsx +39 -39
- package/src/components/AffixInput.tsx +47 -47
- package/src/components/Alert.tsx +24 -24
- package/src/components/AlertDialog.tsx +60 -60
- package/src/components/AlphaTabs.tsx +63 -63
- package/src/components/Attachment.tsx +140 -140
- package/src/components/Avatar.tsx +32 -32
- package/src/components/BackLink.tsx +11 -11
- package/src/components/Badge.tsx +14 -14
- package/src/components/BarcodeInput.tsx +64 -64
- package/src/components/Breadcrumb.tsx +32 -32
- package/src/components/BrowserFrame.tsx +48 -48
- package/src/components/BusyOverlay.tsx +52 -52
- package/src/components/Button.tsx +49 -49
- package/src/components/Card.tsx +27 -27
- package/src/components/ChartLine.tsx +119 -119
- package/src/components/ChatComposer.tsx +73 -73
- package/src/components/Checkbox.tsx +14 -14
- package/src/components/Checklist.tsx +45 -45
- package/src/components/ChipFilter.tsx +60 -60
- package/src/components/Collapsible.tsx +43 -43
- package/src/components/ColorField.tsx +48 -48
- package/src/components/ColorSelect.tsx +49 -49
- package/src/components/ColumnMenu.tsx +66 -66
- package/src/components/Command.tsx +113 -113
- package/src/components/ConfirmDialog.tsx +95 -95
- package/src/components/CookieConsent.tsx +137 -137
- package/src/components/CopyField.tsx +44 -44
- package/src/components/Counter.tsx +21 -21
- package/src/components/DataTable.tsx +201 -201
- package/src/components/DecimalInput.tsx +87 -87
- package/src/components/DefinitionList.tsx +38 -38
- package/src/components/DocViewer.tsx +110 -110
- package/src/components/Dropdown.tsx +52 -52
- package/src/components/Dropzone.tsx +46 -46
- package/src/components/EmojiPicker.tsx +72 -72
- package/src/components/EmptyState.tsx +27 -27
- package/src/components/EntityCard.tsx +56 -56
- package/src/components/FactList.tsx +43 -43
- package/src/components/FilterCounts.tsx +50 -50
- package/src/components/FlipCard.tsx +55 -55
- package/src/components/FormField.tsx +54 -54
- package/src/components/Greeting.tsx +63 -63
- package/src/components/Heading.tsx +27 -27
- package/src/components/Icon.tsx +25 -25
- package/src/components/ImageZoom.tsx +51 -51
- package/src/components/Input.tsx +21 -21
- package/src/components/InputGroup.tsx +24 -24
- package/src/components/LanguageSelect.tsx +64 -64
- package/src/components/LauncherCard.tsx +50 -50
- package/src/components/Lightbox.tsx +83 -83
- package/src/components/LinkArrow.tsx +27 -27
- package/src/components/ListToolbar.tsx +67 -67
- package/src/components/Marquee.tsx +38 -38
- package/src/components/MediaCard.tsx +47 -47
- package/src/components/MediaRow.tsx +53 -53
- package/src/components/MediaThumb.tsx +49 -49
- package/src/components/Message.tsx +171 -171
- package/src/components/MessageThread.tsx +177 -177
- package/src/components/Modal.tsx +81 -81
- package/src/components/Navmenu.tsx +62 -62
- package/src/components/NextTeaser.tsx +29 -29
- package/src/components/NumberInput.tsx +64 -64
- package/src/components/OtpInput.tsx +48 -48
- package/src/components/PageHeader.tsx +43 -43
- package/src/components/Pagination.tsx +42 -42
- package/src/components/PasswordInput.tsx +36 -36
- package/src/components/PasswordRequirements.tsx +46 -46
- package/src/components/PhoneInput.tsx +93 -93
- package/src/components/Popover.tsx +34 -34
- package/src/components/PriceCard.tsx +83 -83
- package/src/components/ProductMark.tsx +35 -35
- package/src/components/Progress.tsx +32 -32
- package/src/components/ProjectCard.tsx +46 -46
- package/src/components/Prose.tsx +79 -79
- package/src/components/RecoveryCodes.tsx +65 -65
- package/src/components/Segmented.tsx +47 -47
- package/src/components/Select.tsx +27 -27
- package/src/components/SeverityCard.tsx +53 -53
- package/src/components/Sheet.tsx +62 -62
- package/src/components/Skeleton.tsx +12 -12
- package/src/components/SortableRow.tsx +46 -46
- package/src/components/StackCard.tsx +40 -40
- package/src/components/StackList.tsx +42 -42
- package/src/components/StatCard.tsx +49 -49
- package/src/components/Stepper.tsx +54 -54
- package/src/components/Switch.tsx +18 -18
- package/src/components/Table.tsx +75 -75
- package/src/components/TableActions.tsx +54 -54
- package/src/components/TableCompact.tsx +61 -61
- package/src/components/Tabs.tsx +45 -45
- package/src/components/TagInput.tsx +73 -73
- package/src/components/TerminalLog.tsx +63 -63
- package/src/components/Textarea.tsx +19 -19
- package/src/components/ThemeSwitch.tsx +45 -45
- package/src/components/TimeInput.tsx +76 -76
- package/src/components/Timeline.tsx +45 -45
- package/src/components/Toast.tsx +138 -138
- package/src/components/ToggleField.tsx +39 -39
- package/src/components/Tooltip.tsx +14 -14
- package/src/components/TopProgress.tsx +54 -54
- package/src/components/Tree.tsx +97 -97
- package/src/components/UpgradeBanner.tsx +62 -62
- package/src/components/User.tsx +31 -31
- package/src/components/VersionBanner.tsx +41 -41
- package/src/hooks.tsx +141 -141
- package/src/utils.ts +25 -25
|
@@ -1,177 +1,177 @@
|
|
|
1
|
-
// File: packages/react/src/components/MessageThread.tsx
|
|
2
|
-
// Map: types + demo -> MessageThread (state: messages, sending) -> header (back, heading, people, badge, menu) -> scroll (MessageList)
|
|
3
|
-
// -> footer (composer form / note / own composer) -> busy overlay
|
|
4
|
-
// Same markup as packages/ui/snippets/message-thread.html. Like the snippet script, a sent text is appended as an outgoing bubble
|
|
5
|
-
// (after onSend resolves; aria-busy shows the overlay meanwhile) and Ctrl/Cmd + Enter sends.
|
|
6
|
-
"use client";
|
|
7
|
-
|
|
8
|
-
import { forwardRef, useEffect, useId, useState } from "react";
|
|
9
|
-
import type { FormEvent, HTMLAttributes, KeyboardEvent, ReactNode } from "react";
|
|
10
|
-
import { attrs, cx } from "../utils";
|
|
11
|
-
import { MessageList } from "./Message";
|
|
12
|
-
import type { MessageData, MessageListItem } from "./Message";
|
|
13
|
-
|
|
14
|
-
export interface ThreadParticipant { initials: ReactNode; name?: string }
|
|
15
|
-
export type ThreadMenuItem = { name: string; label: ReactNode; destructive?: boolean } | { separator: true };
|
|
16
|
-
|
|
17
|
-
export interface MessageThreadProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
18
|
-
title?: ReactNode;
|
|
19
|
-
/** Bold first part of the meta line, e.g. the project. */
|
|
20
|
-
project?: ReactNode;
|
|
21
|
-
meta?: ReactNode;
|
|
22
|
-
participants?: ThreadParticipant[];
|
|
23
|
-
/** Badge in the header, e.g. { label: "Urgent", variant: "destructive" }. */
|
|
24
|
-
status?: { label: ReactNode; variant?: string } | null;
|
|
25
|
-
/** Actions menu; null hides the button. */
|
|
26
|
-
menu?: ThreadMenuItem[] | null;
|
|
27
|
-
onMenu?: (name: string) => void;
|
|
28
|
-
messages?: MessageListItem[];
|
|
29
|
-
/** aria-label of the scroll area (default "Messages"). */
|
|
30
|
-
scrollLabel?: string;
|
|
31
|
-
/** aria-live="polite" on the scroll area (default true). */
|
|
32
|
-
live?: boolean;
|
|
33
|
-
/** Show the sending overlay (aria-busy). */
|
|
34
|
-
busy?: boolean;
|
|
35
|
-
/** Overlay title; null leaves the overlay out. */
|
|
36
|
-
busyTitle?: ReactNode;
|
|
37
|
-
busyText?: ReactNode;
|
|
38
|
-
/** Replaces the default composer form. */
|
|
39
|
-
composer?: ReactNode;
|
|
40
|
-
/** Text instead of a composer, e.g. for a closed conversation. */
|
|
41
|
-
note?: ReactNode;
|
|
42
|
-
placeholder?: string;
|
|
43
|
-
composerLabel?: string;
|
|
44
|
-
inline?: boolean;
|
|
45
|
-
tools?: Array<"attach" | "emoji">;
|
|
46
|
-
onTool?: (tool: "attach" | "emoji") => void;
|
|
47
|
-
hint?: ReactNode;
|
|
48
|
-
/** Called with the text; the bubble is appended when it resolves (not when it throws). */
|
|
49
|
-
onSend?: (text: string) => void | Promise<void>;
|
|
50
|
-
/** Append sent messages yourself through `messages` (default false = append here). */
|
|
51
|
-
manual?: boolean;
|
|
52
|
-
/** Avatar of new outgoing messages (default: the last outgoing avatar, else "Me"). */
|
|
53
|
-
me?: ReactNode;
|
|
54
|
-
/** Element at the start of the header, e.g. a back link. */
|
|
55
|
-
back?: ReactNode;
|
|
56
|
-
/** No wallpaper (`--plain`). */
|
|
57
|
-
plain?: boolean;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const MENU: ThreadMenuItem[] = [
|
|
61
|
-
{ name: "unread", label: "Mark as unread" }, { name: "archive", label: "Move to archive" }, { separator: true },
|
|
62
|
-
{ name: "delete", label: "Delete conversation", destructive: true },
|
|
63
|
-
];
|
|
64
|
-
const DEMO: MessageListItem[] = [
|
|
65
|
-
{ divider: "Tuesday 14 October" },
|
|
66
|
-
{ sender: "Lisa Verbeke", avatar: "LV", time: "09:12", datetime: "2026-10-14T09:12", text: "The supplier dropped off two worktop samples at the workshop. Photo attached.",
|
|
67
|
-
attachments: [{ kind: "image", href: "#samples.jpg", alt: "Two worktop samples on the workbench", art: 6, caption: "samples.jpg · 2.1 MB" }] },
|
|
68
|
-
{ sender: "Lisa Verbeke", avatar: "LV", time: "09:13", datetime: "2026-10-14T09:13", continued: true,
|
|
69
|
-
text: <p>Which one should we order: <strong>Calacatta matt</strong> or <strong>Nero Marquina polished</strong>? The cutting slot is Friday.</p> },
|
|
70
|
-
{ direction: "out", avatar: "TP", time: "09:20", datetime: "2026-10-14T09:20", status: "read", text: "Calacatta matt, 20 mm. I'll send the updated quote to the client today.",
|
|
71
|
-
attachments: [{ name: "Quote kitchen Dewitte v2.pdf", size: "1.2 MB", href: "#quote.pdf" }], actions: [{ name: "delete", label: "Delete this message", destructive: true }] },
|
|
72
|
-
{ divider: "Today" },
|
|
73
|
-
{ sender: "Lisa Verbeke", avatar: "LV", time: "10:02", datetime: "2026-10-24T10:02", text: "Client signed. Can you confirm the edge profile before 16:00?" },
|
|
74
|
-
];
|
|
75
|
-
const isOut = (m?: MessageListItem): m is MessageData => !!m && !("divider" in m) && m.direction === "out";
|
|
76
|
-
|
|
77
|
-
/** `section.oc-message-thread`: header, scrolling messages (newest at the bottom), composer and sending overlay. */
|
|
78
|
-
export const MessageThread = forwardRef<HTMLElement, MessageThreadProps>(function MessageThread(
|
|
79
|
-
{
|
|
80
|
-
title = "Kitchen worktop: which finish?", project = "Villa Dewitte", meta = "Lisa Verbeke → Tom Peeters · 14 Oct 2026",
|
|
81
|
-
participants = [{ initials: "LV", name: "Lisa Verbeke" }, { initials: "TP", name: "Tom Peeters" }], status = { label: "Urgent", variant: "destructive" },
|
|
82
|
-
menu = MENU, onMenu, messages = DEMO, scrollLabel = "Messages", live = true, busy, busyTitle = "Sending message…",
|
|
83
|
-
busyText = "Please wait, do not close this conversation.", composer, note, placeholder = "Type your reply…", composerLabel = "Reply", inline,
|
|
84
|
-
tools = ["attach", "emoji"], onTool, hint = "Ctrl + Enter to send", onSend, manual, me, back, plain, id, className, ...rest
|
|
85
|
-
},
|
|
86
|
-
ref,
|
|
87
|
-
) {
|
|
88
|
-
const auto = useId().replace(/[^\w-]/g, "");
|
|
89
|
-
const base = id ?? `oc-thread-${auto}`;
|
|
90
|
-
const [list, setList] = useState(messages);
|
|
91
|
-
const [sending, setSending] = useState(false);
|
|
92
|
-
useEffect(() => setList(messages), [messages]);
|
|
93
|
-
|
|
94
|
-
const send = async (e: FormEvent<HTMLFormElement>) => {
|
|
95
|
-
e.preventDefault();
|
|
96
|
-
const field = e.currentTarget.elements.namedItem("message") as HTMLTextAreaElement;
|
|
97
|
-
const text = field.value.trim();
|
|
98
|
-
if (!text) return;
|
|
99
|
-
setSending(true);
|
|
100
|
-
try { await onSend?.(text); } catch { setSending(false); return; }
|
|
101
|
-
if (!manual) setList((l) => {
|
|
102
|
-
const last = l[l.length - 1];
|
|
103
|
-
const mine = me ?? [...l].reverse().find(isOut)?.avatar ?? "Me";
|
|
104
|
-
return [...l, { direction: "out", avatar: mine, continued: isOut(last), text, time: new Date().toTimeString().slice(0, 5), status: "sent" }];
|
|
105
|
-
});
|
|
106
|
-
field.value = "";
|
|
107
|
-
setSending(false);
|
|
108
|
-
field.focus();
|
|
109
|
-
};
|
|
110
|
-
const ctrlEnter = (e: KeyboardEvent<HTMLFormElement>) => {
|
|
111
|
-
if (e.key !== "Enter" || !(e.ctrlKey || e.metaKey)) return;
|
|
112
|
-
e.preventDefault();
|
|
113
|
-
e.currentTarget.requestSubmit();
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
<section ref={ref} className={cx("oc-message-thread", plain && "oc-message-thread--plain", className)} aria-labelledby={`${base}-title`} aria-busy={busy || sending || undefined} {...rest}>
|
|
118
|
-
<header className="oc-message-thread__header">
|
|
119
|
-
{back}
|
|
120
|
-
<div className="oc-message-thread__heading">
|
|
121
|
-
<h3 className="oc-message-thread__title" id={`${base}-title`}>{title}</h3>
|
|
122
|
-
{(project != null || meta != null) && <p className="oc-message-thread__meta">{project != null && <strong>{project}</strong>}{project != null && meta != null && " · "}{meta}</p>}
|
|
123
|
-
</div>
|
|
124
|
-
{participants.length > 0 && (
|
|
125
|
-
<div className="oc-avatar-group oc-message-thread__people" aria-label={`${participants.length} participants`}>
|
|
126
|
-
{participants.map((p, i) => <span key={i} className="oc-avatar" title={p.name}>{p.initials}</span>)}
|
|
127
|
-
</div>
|
|
128
|
-
)}
|
|
129
|
-
{(status || menu) && (
|
|
130
|
-
<div className="oc-message-thread__actions">
|
|
131
|
-
{status && <span className={cx("oc-badge", status.variant && `oc-badge--${status.variant}`)}>{status.label}</span>}
|
|
132
|
-
{menu && (
|
|
133
|
-
<>
|
|
134
|
-
<button type="button" className="oc-message-thread__icon-btn" {...attrs({ popovertarget: `${base}-menu` })} aria-label="Conversation actions">
|
|
135
|
-
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg>
|
|
136
|
-
</button>
|
|
137
|
-
<div className="oc-dropdown-menu" id={`${base}-menu`} {...attrs({ popover: "" })} role="menu">
|
|
138
|
-
{menu.map((m, i) => "separator" in m
|
|
139
|
-
? <div key={i} className="oc-dropdown-separator" role="separator" />
|
|
140
|
-
: <button key={i} type="button" className={cx("oc-dropdown-item", m.destructive && "oc-dropdown-item--destructive")} role="menuitem" onClick={() => onMenu?.(m.name)}>{m.label}</button>)}
|
|
141
|
-
</div>
|
|
142
|
-
</>
|
|
143
|
-
)}
|
|
144
|
-
</div>
|
|
145
|
-
)}
|
|
146
|
-
</header>
|
|
147
|
-
<div className="oc-message-thread__scroll" tabIndex={0} role="log" aria-label={scrollLabel} aria-live={live ? "polite" : undefined}>
|
|
148
|
-
<MessageList items={list} log={false} />
|
|
149
|
-
</div>
|
|
150
|
-
<div className="oc-message-thread__footer">
|
|
151
|
-
{note != null ? <p className="oc-message-thread__note">{note}</p> : composer ?? (
|
|
152
|
-
<form className={cx("oc-chat-composer", inline && "oc-chat-composer--inline")} action="#" aria-label={composerLabel} onSubmit={send} onKeyDown={ctrlEnter}>
|
|
153
|
-
<label className="oc-visually-hidden" htmlFor={`${base}-input`}>Message</label>
|
|
154
|
-
<textarea className="oc-chat-composer__input" id={`${base}-input`} name="message" rows={1} placeholder={placeholder} required />
|
|
155
|
-
<div className="oc-chat-composer__bar">
|
|
156
|
-
{tools.length > 0 && (
|
|
157
|
-
<div className="oc-chat-composer__tools">
|
|
158
|
-
{tools.includes("attach") && <button type="button" className="oc-chat-composer__tool" aria-label="Attach a file" onClick={() => onTool?.("attach")}>📎</button>}
|
|
159
|
-
{tools.includes("emoji") && <button type="button" className="oc-chat-composer__tool" aria-label="Insert emoji" onClick={() => onTool?.("emoji")}>🙂</button>}
|
|
160
|
-
</div>
|
|
161
|
-
)}
|
|
162
|
-
{hint != null && <span className="oc-chat-composer__hint">{hint}</span>}
|
|
163
|
-
<button type="submit" className="oc-chat-composer__send" aria-label="Send">➤</button>
|
|
164
|
-
</div>
|
|
165
|
-
</form>
|
|
166
|
-
)}
|
|
167
|
-
</div>
|
|
168
|
-
{busyTitle != null && (
|
|
169
|
-
<div className="oc-message-thread__busy" role="status">
|
|
170
|
-
<span className="oc-spinner oc-spinner--lg" aria-hidden="true" />
|
|
171
|
-
<strong>{busyTitle}</strong>
|
|
172
|
-
{busyText != null && <span>{busyText}</span>}
|
|
173
|
-
</div>
|
|
174
|
-
)}
|
|
175
|
-
</section>
|
|
176
|
-
);
|
|
177
|
-
});
|
|
1
|
+
// File: packages/react/src/components/MessageThread.tsx
|
|
2
|
+
// Map: types + demo -> MessageThread (state: messages, sending) -> header (back, heading, people, badge, menu) -> scroll (MessageList)
|
|
3
|
+
// -> footer (composer form / note / own composer) -> busy overlay
|
|
4
|
+
// Same markup as packages/ui/snippets/message-thread.html. Like the snippet script, a sent text is appended as an outgoing bubble
|
|
5
|
+
// (after onSend resolves; aria-busy shows the overlay meanwhile) and Ctrl/Cmd + Enter sends.
|
|
6
|
+
"use client";
|
|
7
|
+
|
|
8
|
+
import { forwardRef, useEffect, useId, useState } from "react";
|
|
9
|
+
import type { FormEvent, HTMLAttributes, KeyboardEvent, ReactNode } from "react";
|
|
10
|
+
import { attrs, cx } from "../utils";
|
|
11
|
+
import { MessageList } from "./Message";
|
|
12
|
+
import type { MessageData, MessageListItem } from "./Message";
|
|
13
|
+
|
|
14
|
+
export interface ThreadParticipant { initials: ReactNode; name?: string }
|
|
15
|
+
export type ThreadMenuItem = { name: string; label: ReactNode; destructive?: boolean } | { separator: true };
|
|
16
|
+
|
|
17
|
+
export interface MessageThreadProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
18
|
+
title?: ReactNode;
|
|
19
|
+
/** Bold first part of the meta line, e.g. the project. */
|
|
20
|
+
project?: ReactNode;
|
|
21
|
+
meta?: ReactNode;
|
|
22
|
+
participants?: ThreadParticipant[];
|
|
23
|
+
/** Badge in the header, e.g. { label: "Urgent", variant: "destructive" }. */
|
|
24
|
+
status?: { label: ReactNode; variant?: string } | null;
|
|
25
|
+
/** Actions menu; null hides the button. */
|
|
26
|
+
menu?: ThreadMenuItem[] | null;
|
|
27
|
+
onMenu?: (name: string) => void;
|
|
28
|
+
messages?: MessageListItem[];
|
|
29
|
+
/** aria-label of the scroll area (default "Messages"). */
|
|
30
|
+
scrollLabel?: string;
|
|
31
|
+
/** aria-live="polite" on the scroll area (default true). */
|
|
32
|
+
live?: boolean;
|
|
33
|
+
/** Show the sending overlay (aria-busy). */
|
|
34
|
+
busy?: boolean;
|
|
35
|
+
/** Overlay title; null leaves the overlay out. */
|
|
36
|
+
busyTitle?: ReactNode;
|
|
37
|
+
busyText?: ReactNode;
|
|
38
|
+
/** Replaces the default composer form. */
|
|
39
|
+
composer?: ReactNode;
|
|
40
|
+
/** Text instead of a composer, e.g. for a closed conversation. */
|
|
41
|
+
note?: ReactNode;
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
composerLabel?: string;
|
|
44
|
+
inline?: boolean;
|
|
45
|
+
tools?: Array<"attach" | "emoji">;
|
|
46
|
+
onTool?: (tool: "attach" | "emoji") => void;
|
|
47
|
+
hint?: ReactNode;
|
|
48
|
+
/** Called with the text; the bubble is appended when it resolves (not when it throws). */
|
|
49
|
+
onSend?: (text: string) => void | Promise<void>;
|
|
50
|
+
/** Append sent messages yourself through `messages` (default false = append here). */
|
|
51
|
+
manual?: boolean;
|
|
52
|
+
/** Avatar of new outgoing messages (default: the last outgoing avatar, else "Me"). */
|
|
53
|
+
me?: ReactNode;
|
|
54
|
+
/** Element at the start of the header, e.g. a back link. */
|
|
55
|
+
back?: ReactNode;
|
|
56
|
+
/** No wallpaper (`--plain`). */
|
|
57
|
+
plain?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MENU: ThreadMenuItem[] = [
|
|
61
|
+
{ name: "unread", label: "Mark as unread" }, { name: "archive", label: "Move to archive" }, { separator: true },
|
|
62
|
+
{ name: "delete", label: "Delete conversation", destructive: true },
|
|
63
|
+
];
|
|
64
|
+
const DEMO: MessageListItem[] = [
|
|
65
|
+
{ divider: "Tuesday 14 October" },
|
|
66
|
+
{ sender: "Lisa Verbeke", avatar: "LV", time: "09:12", datetime: "2026-10-14T09:12", text: "The supplier dropped off two worktop samples at the workshop. Photo attached.",
|
|
67
|
+
attachments: [{ kind: "image", href: "#samples.jpg", alt: "Two worktop samples on the workbench", art: 6, caption: "samples.jpg · 2.1 MB" }] },
|
|
68
|
+
{ sender: "Lisa Verbeke", avatar: "LV", time: "09:13", datetime: "2026-10-14T09:13", continued: true,
|
|
69
|
+
text: <p>Which one should we order: <strong>Calacatta matt</strong> or <strong>Nero Marquina polished</strong>? The cutting slot is Friday.</p> },
|
|
70
|
+
{ direction: "out", avatar: "TP", time: "09:20", datetime: "2026-10-14T09:20", status: "read", text: "Calacatta matt, 20 mm. I'll send the updated quote to the client today.",
|
|
71
|
+
attachments: [{ name: "Quote kitchen Dewitte v2.pdf", size: "1.2 MB", href: "#quote.pdf" }], actions: [{ name: "delete", label: "Delete this message", destructive: true }] },
|
|
72
|
+
{ divider: "Today" },
|
|
73
|
+
{ sender: "Lisa Verbeke", avatar: "LV", time: "10:02", datetime: "2026-10-24T10:02", text: "Client signed. Can you confirm the edge profile before 16:00?" },
|
|
74
|
+
];
|
|
75
|
+
const isOut = (m?: MessageListItem): m is MessageData => !!m && !("divider" in m) && m.direction === "out";
|
|
76
|
+
|
|
77
|
+
/** `section.oc-message-thread`: header, scrolling messages (newest at the bottom), composer and sending overlay. */
|
|
78
|
+
export const MessageThread = forwardRef<HTMLElement, MessageThreadProps>(function MessageThread(
|
|
79
|
+
{
|
|
80
|
+
title = "Kitchen worktop: which finish?", project = "Villa Dewitte", meta = "Lisa Verbeke → Tom Peeters · 14 Oct 2026",
|
|
81
|
+
participants = [{ initials: "LV", name: "Lisa Verbeke" }, { initials: "TP", name: "Tom Peeters" }], status = { label: "Urgent", variant: "destructive" },
|
|
82
|
+
menu = MENU, onMenu, messages = DEMO, scrollLabel = "Messages", live = true, busy, busyTitle = "Sending message…",
|
|
83
|
+
busyText = "Please wait, do not close this conversation.", composer, note, placeholder = "Type your reply…", composerLabel = "Reply", inline,
|
|
84
|
+
tools = ["attach", "emoji"], onTool, hint = "Ctrl + Enter to send", onSend, manual, me, back, plain, id, className, ...rest
|
|
85
|
+
},
|
|
86
|
+
ref,
|
|
87
|
+
) {
|
|
88
|
+
const auto = useId().replace(/[^\w-]/g, "");
|
|
89
|
+
const base = id ?? `oc-thread-${auto}`;
|
|
90
|
+
const [list, setList] = useState(messages);
|
|
91
|
+
const [sending, setSending] = useState(false);
|
|
92
|
+
useEffect(() => setList(messages), [messages]);
|
|
93
|
+
|
|
94
|
+
const send = async (e: FormEvent<HTMLFormElement>) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
const field = e.currentTarget.elements.namedItem("message") as HTMLTextAreaElement;
|
|
97
|
+
const text = field.value.trim();
|
|
98
|
+
if (!text) return;
|
|
99
|
+
setSending(true);
|
|
100
|
+
try { await onSend?.(text); } catch { setSending(false); return; }
|
|
101
|
+
if (!manual) setList((l) => {
|
|
102
|
+
const last = l[l.length - 1];
|
|
103
|
+
const mine = me ?? [...l].reverse().find(isOut)?.avatar ?? "Me";
|
|
104
|
+
return [...l, { direction: "out", avatar: mine, continued: isOut(last), text, time: new Date().toTimeString().slice(0, 5), status: "sent" }];
|
|
105
|
+
});
|
|
106
|
+
field.value = "";
|
|
107
|
+
setSending(false);
|
|
108
|
+
field.focus();
|
|
109
|
+
};
|
|
110
|
+
const ctrlEnter = (e: KeyboardEvent<HTMLFormElement>) => {
|
|
111
|
+
if (e.key !== "Enter" || !(e.ctrlKey || e.metaKey)) return;
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
e.currentTarget.requestSubmit();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<section ref={ref} className={cx("oc-message-thread", plain && "oc-message-thread--plain", className)} aria-labelledby={`${base}-title`} aria-busy={busy || sending || undefined} {...rest}>
|
|
118
|
+
<header className="oc-message-thread__header">
|
|
119
|
+
{back}
|
|
120
|
+
<div className="oc-message-thread__heading">
|
|
121
|
+
<h3 className="oc-message-thread__title" id={`${base}-title`}>{title}</h3>
|
|
122
|
+
{(project != null || meta != null) && <p className="oc-message-thread__meta">{project != null && <strong>{project}</strong>}{project != null && meta != null && " · "}{meta}</p>}
|
|
123
|
+
</div>
|
|
124
|
+
{participants.length > 0 && (
|
|
125
|
+
<div className="oc-avatar-group oc-message-thread__people" aria-label={`${participants.length} participants`}>
|
|
126
|
+
{participants.map((p, i) => <span key={i} className="oc-avatar" title={p.name}>{p.initials}</span>)}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
{(status || menu) && (
|
|
130
|
+
<div className="oc-message-thread__actions">
|
|
131
|
+
{status && <span className={cx("oc-badge", status.variant && `oc-badge--${status.variant}`)}>{status.label}</span>}
|
|
132
|
+
{menu && (
|
|
133
|
+
<>
|
|
134
|
+
<button type="button" className="oc-message-thread__icon-btn" {...attrs({ popovertarget: `${base}-menu` })} aria-label="Conversation actions">
|
|
135
|
+
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" /></svg>
|
|
136
|
+
</button>
|
|
137
|
+
<div className="oc-dropdown-menu" id={`${base}-menu`} {...attrs({ popover: "" })} role="menu">
|
|
138
|
+
{menu.map((m, i) => "separator" in m
|
|
139
|
+
? <div key={i} className="oc-dropdown-separator" role="separator" />
|
|
140
|
+
: <button key={i} type="button" className={cx("oc-dropdown-item", m.destructive && "oc-dropdown-item--destructive")} role="menuitem" onClick={() => onMenu?.(m.name)}>{m.label}</button>)}
|
|
141
|
+
</div>
|
|
142
|
+
</>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</header>
|
|
147
|
+
<div className="oc-message-thread__scroll" tabIndex={0} role="log" aria-label={scrollLabel} aria-live={live ? "polite" : undefined}>
|
|
148
|
+
<MessageList items={list} log={false} />
|
|
149
|
+
</div>
|
|
150
|
+
<div className="oc-message-thread__footer">
|
|
151
|
+
{note != null ? <p className="oc-message-thread__note">{note}</p> : composer ?? (
|
|
152
|
+
<form className={cx("oc-chat-composer", inline && "oc-chat-composer--inline")} action="#" aria-label={composerLabel} onSubmit={send} onKeyDown={ctrlEnter}>
|
|
153
|
+
<label className="oc-visually-hidden" htmlFor={`${base}-input`}>Message</label>
|
|
154
|
+
<textarea className="oc-chat-composer__input" id={`${base}-input`} name="message" rows={1} placeholder={placeholder} required />
|
|
155
|
+
<div className="oc-chat-composer__bar">
|
|
156
|
+
{tools.length > 0 && (
|
|
157
|
+
<div className="oc-chat-composer__tools">
|
|
158
|
+
{tools.includes("attach") && <button type="button" className="oc-chat-composer__tool" aria-label="Attach a file" onClick={() => onTool?.("attach")}>📎</button>}
|
|
159
|
+
{tools.includes("emoji") && <button type="button" className="oc-chat-composer__tool" aria-label="Insert emoji" onClick={() => onTool?.("emoji")}>🙂</button>}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
{hint != null && <span className="oc-chat-composer__hint">{hint}</span>}
|
|
163
|
+
<button type="submit" className="oc-chat-composer__send" aria-label="Send">➤</button>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
{busyTitle != null && (
|
|
169
|
+
<div className="oc-message-thread__busy" role="status">
|
|
170
|
+
<span className="oc-spinner oc-spinner--lg" aria-hidden="true" />
|
|
171
|
+
<strong>{busyTitle}</strong>
|
|
172
|
+
{busyText != null && <span>{busyText}</span>}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</section>
|
|
176
|
+
);
|
|
177
|
+
});
|
package/src/components/Modal.tsx
CHANGED
|
@@ -1,81 +1,81 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { forwardRef, useId, useRef } from "react";
|
|
4
|
-
import type { DialogHTMLAttributes, MouseEvent, ReactNode } from "react";
|
|
5
|
-
import { attrs, cx } from "../utils";
|
|
6
|
-
import { DialogContext, useDialog, useDialogContext, useMergedRef } from "../hooks";
|
|
7
|
-
import type { DialogState } from "../hooks";
|
|
8
|
-
import { Button } from "./Button";
|
|
9
|
-
import type { ButtonProps } from "./Button";
|
|
10
|
-
|
|
11
|
-
export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
|
12
|
-
|
|
13
|
-
export interface ModalProps extends Omit<DialogHTMLAttributes<HTMLDialogElement>, "title" | "open" | "onClose">, DialogState {
|
|
14
|
-
/** Heading (`oc-modal-title`). */
|
|
15
|
-
title?: ReactNode;
|
|
16
|
-
/** Muted text under the title (`oc-modal-description`). */
|
|
17
|
-
description?: ReactNode;
|
|
18
|
-
/** Button row (`oc-modal-footer`). Use `<ModalClose>` for buttons that close the dialog. */
|
|
19
|
-
footer?: ReactNode;
|
|
20
|
-
size?: ModalSize;
|
|
21
|
-
/** Close when the backdrop is clicked (native `closedby="any"`). Default true. */
|
|
22
|
-
closeOnBackdrop?: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* `<dialog class="oc-modal">`. The `open` prop is synced to showModal() / close() in an effect;
|
|
27
|
-
* Escape and backdrop clicks are reported through `onOpenChange(false)`.
|
|
28
|
-
*/
|
|
29
|
-
export const Modal = forwardRef<HTMLDialogElement, ModalProps>(function Modal(
|
|
30
|
-
{ open, defaultOpen, onOpenChange, title, description, footer, size = "md", closeOnBackdrop = true, className, children, ...rest },
|
|
31
|
-
forwardedRef,
|
|
32
|
-
) {
|
|
33
|
-
const inner = useRef<HTMLDialogElement>(null);
|
|
34
|
-
const ref = useMergedRef(inner, forwardedRef);
|
|
35
|
-
const titleId = useId();
|
|
36
|
-
const dlg = useDialog(inner, { open, defaultOpen, onOpenChange });
|
|
37
|
-
const onClick = (e: MouseEvent<HTMLDialogElement>) => {
|
|
38
|
-
rest.onClick?.(e);
|
|
39
|
-
if (closeOnBackdrop && e.target === e.currentTarget) dlg.close();
|
|
40
|
-
};
|
|
41
|
-
return (
|
|
42
|
-
<dialog
|
|
43
|
-
ref={ref}
|
|
44
|
-
className={cx("oc-modal", className)}
|
|
45
|
-
aria-labelledby={title != null ? titleId : undefined}
|
|
46
|
-
{...attrs({ closedby: closeOnBackdrop ? "any" : "closerequest" })}
|
|
47
|
-
{...rest}
|
|
48
|
-
onClick={onClick}
|
|
49
|
-
onClose={dlg.onClose}
|
|
50
|
-
>
|
|
51
|
-
<DialogContext.Provider value={{ close: dlg.close }}>
|
|
52
|
-
<div className={cx("oc-modal-content", size !== "md" && `oc-modal-content--${size}`)}>
|
|
53
|
-
{(title != null || description != null) && (
|
|
54
|
-
<div className="oc-modal-header">
|
|
55
|
-
{title != null && <h2 className="oc-modal-title" id={titleId}>{title}</h2>}
|
|
56
|
-
{description != null && <p className="oc-modal-description">{description}</p>}
|
|
57
|
-
</div>
|
|
58
|
-
)}
|
|
59
|
-
{children}
|
|
60
|
-
{footer != null && <div className="oc-modal-footer">{footer}</div>}
|
|
61
|
-
</div>
|
|
62
|
-
</DialogContext.Provider>
|
|
63
|
-
</dialog>
|
|
64
|
-
);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
/** A button that closes the surrounding Modal / AlertDialog / Sheet. */
|
|
68
|
-
export const ModalClose = forwardRef<HTMLButtonElement, ButtonProps>(function ModalClose({ onClick, ...rest }, ref) {
|
|
69
|
-
const ctx = useDialogContext();
|
|
70
|
-
return (
|
|
71
|
-
<Button
|
|
72
|
-
ref={ref}
|
|
73
|
-
type="button"
|
|
74
|
-
{...rest}
|
|
75
|
-
onClick={(e) => {
|
|
76
|
-
onClick?.(e);
|
|
77
|
-
if (!e.defaultPrevented) ctx?.close();
|
|
78
|
-
}}
|
|
79
|
-
/>
|
|
80
|
-
);
|
|
81
|
-
});
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useId, useRef } from "react";
|
|
4
|
+
import type { DialogHTMLAttributes, MouseEvent, ReactNode } from "react";
|
|
5
|
+
import { attrs, cx } from "../utils";
|
|
6
|
+
import { DialogContext, useDialog, useDialogContext, useMergedRef } from "../hooks";
|
|
7
|
+
import type { DialogState } from "../hooks";
|
|
8
|
+
import { Button } from "./Button";
|
|
9
|
+
import type { ButtonProps } from "./Button";
|
|
10
|
+
|
|
11
|
+
export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
|
12
|
+
|
|
13
|
+
export interface ModalProps extends Omit<DialogHTMLAttributes<HTMLDialogElement>, "title" | "open" | "onClose">, DialogState {
|
|
14
|
+
/** Heading (`oc-modal-title`). */
|
|
15
|
+
title?: ReactNode;
|
|
16
|
+
/** Muted text under the title (`oc-modal-description`). */
|
|
17
|
+
description?: ReactNode;
|
|
18
|
+
/** Button row (`oc-modal-footer`). Use `<ModalClose>` for buttons that close the dialog. */
|
|
19
|
+
footer?: ReactNode;
|
|
20
|
+
size?: ModalSize;
|
|
21
|
+
/** Close when the backdrop is clicked (native `closedby="any"`). Default true. */
|
|
22
|
+
closeOnBackdrop?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `<dialog class="oc-modal">`. The `open` prop is synced to showModal() / close() in an effect;
|
|
27
|
+
* Escape and backdrop clicks are reported through `onOpenChange(false)`.
|
|
28
|
+
*/
|
|
29
|
+
export const Modal = forwardRef<HTMLDialogElement, ModalProps>(function Modal(
|
|
30
|
+
{ open, defaultOpen, onOpenChange, title, description, footer, size = "md", closeOnBackdrop = true, className, children, ...rest },
|
|
31
|
+
forwardedRef,
|
|
32
|
+
) {
|
|
33
|
+
const inner = useRef<HTMLDialogElement>(null);
|
|
34
|
+
const ref = useMergedRef(inner, forwardedRef);
|
|
35
|
+
const titleId = useId();
|
|
36
|
+
const dlg = useDialog(inner, { open, defaultOpen, onOpenChange });
|
|
37
|
+
const onClick = (e: MouseEvent<HTMLDialogElement>) => {
|
|
38
|
+
rest.onClick?.(e);
|
|
39
|
+
if (closeOnBackdrop && e.target === e.currentTarget) dlg.close();
|
|
40
|
+
};
|
|
41
|
+
return (
|
|
42
|
+
<dialog
|
|
43
|
+
ref={ref}
|
|
44
|
+
className={cx("oc-modal", className)}
|
|
45
|
+
aria-labelledby={title != null ? titleId : undefined}
|
|
46
|
+
{...attrs({ closedby: closeOnBackdrop ? "any" : "closerequest" })}
|
|
47
|
+
{...rest}
|
|
48
|
+
onClick={onClick}
|
|
49
|
+
onClose={dlg.onClose}
|
|
50
|
+
>
|
|
51
|
+
<DialogContext.Provider value={{ close: dlg.close }}>
|
|
52
|
+
<div className={cx("oc-modal-content", size !== "md" && `oc-modal-content--${size}`)}>
|
|
53
|
+
{(title != null || description != null) && (
|
|
54
|
+
<div className="oc-modal-header">
|
|
55
|
+
{title != null && <h2 className="oc-modal-title" id={titleId}>{title}</h2>}
|
|
56
|
+
{description != null && <p className="oc-modal-description">{description}</p>}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
{children}
|
|
60
|
+
{footer != null && <div className="oc-modal-footer">{footer}</div>}
|
|
61
|
+
</div>
|
|
62
|
+
</DialogContext.Provider>
|
|
63
|
+
</dialog>
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** A button that closes the surrounding Modal / AlertDialog / Sheet. */
|
|
68
|
+
export const ModalClose = forwardRef<HTMLButtonElement, ButtonProps>(function ModalClose({ onClick, ...rest }, ref) {
|
|
69
|
+
const ctx = useDialogContext();
|
|
70
|
+
return (
|
|
71
|
+
<Button
|
|
72
|
+
ref={ref}
|
|
73
|
+
type="button"
|
|
74
|
+
{...rest}
|
|
75
|
+
onClick={(e) => {
|
|
76
|
+
onClick?.(e);
|
|
77
|
+
if (!e.defaultPrevented) ctx?.close();
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
});
|