@birdycom/omniaui-react 0.0.0-stage → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/index.d.ts +2640 -0
- package/dist/index.js +4813 -0
- package/manifest.json +838 -0
- package/package.json +69 -4
- package/src/blocks/BlockAboutTimeline.tsx +46 -0
- package/src/blocks/BlockAppHeader.tsx +52 -0
- package/src/blocks/BlockAppShell.tsx +56 -0
- package/src/blocks/BlockAuth.tsx +53 -0
- package/src/blocks/BlockAuthSplit.tsx +84 -0
- package/src/blocks/BlockContactGrid.tsx +71 -0
- package/src/blocks/BlockCta.tsx +23 -0
- package/src/blocks/BlockDataTable.tsx +57 -0
- package/src/blocks/BlockFaq.tsx +55 -0
- package/src/blocks/BlockFeatures.tsx +38 -0
- package/src/blocks/BlockFooter.tsx +50 -0
- package/src/blocks/BlockFormPage.tsx +26 -0
- package/src/blocks/BlockHero.tsx +27 -0
- package/src/blocks/BlockHeroCode.tsx +63 -0
- package/src/blocks/BlockHeroOrbit.tsx +73 -0
- package/src/blocks/BlockHeroSplit.tsx +28 -0
- package/src/blocks/BlockModuleHeader.tsx +34 -0
- package/src/blocks/BlockPageHero.tsx +25 -0
- package/src/blocks/BlockPortfolioGrid.tsx +50 -0
- package/src/blocks/BlockPricing.tsx +62 -0
- package/src/blocks/BlockProcess.tsx +50 -0
- package/src/blocks/BlockProductGrid.tsx +54 -0
- package/src/blocks/BlockProfileHeader.tsx +57 -0
- package/src/blocks/BlockShowcase.tsx +58 -0
- package/src/blocks/BlockSiteFooter.tsx +60 -0
- package/src/blocks/BlockSiteHeader.tsx +42 -0
- package/src/blocks/BlockSpotlight.tsx +58 -0
- package/src/blocks/BlockStats.tsx +26 -0
- package/src/blocks/BlockStatsCounter.tsx +31 -0
- package/src/blocks/BlockTeamList.tsx +48 -0
- package/src/blocks/BlockTwoFactor.tsx +57 -0
- package/src/blocks/BlockWizardDialog.tsx +101 -0
- package/src/components/Accordion.tsx +31 -0
- package/src/components/AccordionAccent.tsx +39 -0
- package/src/components/AffixInput.tsx +47 -0
- package/src/components/Alert.tsx +24 -0
- package/src/components/AlertDialog.tsx +60 -0
- package/src/components/AlphaTabs.tsx +63 -0
- package/src/components/Attachment.tsx +140 -0
- package/src/components/Avatar.tsx +32 -0
- package/src/components/BackLink.tsx +11 -0
- package/src/components/Badge.tsx +14 -0
- package/src/components/BarcodeInput.tsx +64 -0
- package/src/components/Breadcrumb.tsx +32 -0
- package/src/components/BrowserFrame.tsx +48 -0
- package/src/components/BusyOverlay.tsx +52 -0
- package/src/components/Button.tsx +49 -0
- package/src/components/Card.tsx +27 -0
- package/src/components/ChartLine.tsx +119 -0
- package/src/components/ChatComposer.tsx +73 -0
- package/src/components/Checkbox.tsx +14 -0
- package/src/components/Checklist.tsx +45 -0
- package/src/components/ChipFilter.tsx +60 -0
- package/src/components/Collapsible.tsx +43 -0
- package/src/components/ColorField.tsx +48 -0
- package/src/components/ColorSelect.tsx +49 -0
- package/src/components/ColumnMenu.tsx +66 -0
- package/src/components/Command.tsx +113 -0
- package/src/components/ConfirmDialog.tsx +95 -0
- package/src/components/CookieConsent.tsx +137 -0
- package/src/components/CopyField.tsx +44 -0
- package/src/components/Counter.tsx +21 -0
- package/src/components/DataTable.tsx +201 -0
- package/src/components/DecimalInput.tsx +87 -0
- package/src/components/DefinitionList.tsx +38 -0
- package/src/components/DocViewer.tsx +110 -0
- package/src/components/Dropdown.tsx +52 -0
- package/src/components/Dropzone.tsx +46 -0
- package/src/components/EmojiPicker.tsx +72 -0
- package/src/components/EmptyState.tsx +27 -0
- package/src/components/EntityCard.tsx +56 -0
- package/src/components/FactList.tsx +43 -0
- package/src/components/FilterCounts.tsx +50 -0
- package/src/components/FlipCard.tsx +55 -0
- package/src/components/FormField.tsx +54 -0
- package/src/components/Greeting.tsx +63 -0
- package/src/components/Heading.tsx +27 -0
- package/src/components/Icon.tsx +25 -0
- package/src/components/ImageZoom.tsx +51 -0
- package/src/components/Input.tsx +21 -0
- package/src/components/InputGroup.tsx +24 -0
- package/src/components/LanguageSelect.tsx +64 -0
- package/src/components/LauncherCard.tsx +50 -0
- package/src/components/Lightbox.tsx +83 -0
- package/src/components/LinkArrow.tsx +27 -0
- package/src/components/ListToolbar.tsx +67 -0
- package/src/components/Marquee.tsx +38 -0
- package/src/components/MediaCard.tsx +47 -0
- package/src/components/MediaRow.tsx +53 -0
- package/src/components/MediaThumb.tsx +49 -0
- package/src/components/Message.tsx +171 -0
- package/src/components/MessageThread.tsx +177 -0
- package/src/components/Modal.tsx +81 -0
- package/src/components/Navmenu.tsx +62 -0
- package/src/components/NextTeaser.tsx +29 -0
- package/src/components/NumberInput.tsx +64 -0
- package/src/components/OtpInput.tsx +48 -0
- package/src/components/PageHeader.tsx +43 -0
- package/src/components/Pagination.tsx +42 -0
- package/src/components/PasswordInput.tsx +36 -0
- package/src/components/PasswordRequirements.tsx +46 -0
- package/src/components/PhoneInput.tsx +93 -0
- package/src/components/Popover.tsx +34 -0
- package/src/components/PriceCard.tsx +83 -0
- package/src/components/ProductMark.tsx +35 -0
- package/src/components/Progress.tsx +32 -0
- package/src/components/ProjectCard.tsx +46 -0
- package/src/components/Prose.tsx +79 -0
- package/src/components/RecoveryCodes.tsx +65 -0
- package/src/components/Segmented.tsx +47 -0
- package/src/components/Select.tsx +27 -0
- package/src/components/SeverityCard.tsx +53 -0
- package/src/components/Sheet.tsx +62 -0
- package/src/components/Sidebar.tsx +145 -0
- package/src/components/Skeleton.tsx +12 -0
- package/src/components/SortableRow.tsx +46 -0
- package/src/components/StackCard.tsx +40 -0
- package/src/components/StackList.tsx +42 -0
- package/src/components/StatCard.tsx +49 -0
- package/src/components/Stepper.tsx +54 -0
- package/src/components/Switch.tsx +18 -0
- package/src/components/Table.tsx +75 -0
- package/src/components/TableActions.tsx +54 -0
- package/src/components/TableCompact.tsx +61 -0
- package/src/components/Tabs.tsx +45 -0
- package/src/components/TagInput.tsx +73 -0
- package/src/components/TerminalLog.tsx +63 -0
- package/src/components/Textarea.tsx +19 -0
- package/src/components/ThemeSwitch.tsx +45 -0
- package/src/components/TimeInput.tsx +76 -0
- package/src/components/Timeline.tsx +45 -0
- package/src/components/Toast.tsx +138 -0
- package/src/components/ToggleField.tsx +39 -0
- package/src/components/Tooltip.tsx +14 -0
- package/src/components/TopProgress.tsx +54 -0
- package/src/components/Tree.tsx +97 -0
- package/src/components/UpgradeBanner.tsx +62 -0
- package/src/components/User.tsx +31 -0
- package/src/components/VersionBanner.tsx +41 -0
- package/src/hooks.tsx +141 -0
- package/src/index.ts +142 -0
- package/src/utils.ts +25 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// File: packages/react/src/components/Message.tsx
|
|
2
|
+
// Map: types + icons -> MessageAttachments -> Message (avatar, bubble: sender, quote, text, attachments, meta; actions)
|
|
3
|
+
// -> MessageDivider -> MessageList (demo conversation)
|
|
4
|
+
// Same markup as packages/ui/snippets/message.html. No JavaScript: grouping (--continued) and hover actions are CSS.
|
|
5
|
+
import { forwardRef } from "react";
|
|
6
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
7
|
+
import { cx } from "../utils";
|
|
8
|
+
import { Attachment, AttachmentImage, AttachmentThumb } from "./Attachment";
|
|
9
|
+
import type { AttachmentFile } from "./Attachment";
|
|
10
|
+
|
|
11
|
+
/** File chip (default), image preview or square thumbnail inside a message. */
|
|
12
|
+
export type MessageAttachment =
|
|
13
|
+
| (AttachmentFile & { kind?: "file" })
|
|
14
|
+
| { kind: "image"; href?: string; src?: string; alt: string; art?: number; caption?: ReactNode }
|
|
15
|
+
| { kind: "thumb"; src?: string; alt: string; art?: number };
|
|
16
|
+
|
|
17
|
+
export interface MessageAction {
|
|
18
|
+
/** Passed to onAction, e.g. "reply". */
|
|
19
|
+
name: string;
|
|
20
|
+
/** aria-label, e.g. "Reply to this message". */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Built-in "reply" / "delete" icon, or your own svg. */
|
|
23
|
+
icon?: "reply" | "delete" | ReactNode;
|
|
24
|
+
destructive?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MessageData {
|
|
28
|
+
direction?: "in" | "out";
|
|
29
|
+
sender?: ReactNode;
|
|
30
|
+
/** Initials in the avatar. */
|
|
31
|
+
avatar?: ReactNode;
|
|
32
|
+
time?: ReactNode;
|
|
33
|
+
datetime?: string;
|
|
34
|
+
status?: "sent" | "read" | "failed";
|
|
35
|
+
/** Default "Sent" / "Read" / "Failed". */
|
|
36
|
+
statusLabel?: ReactNode;
|
|
37
|
+
/** Double check icon before the status. */
|
|
38
|
+
statusIcon?: boolean;
|
|
39
|
+
/** Follows a message of the same sender (no name; avatar on the last one). */
|
|
40
|
+
continued?: boolean;
|
|
41
|
+
quote?: { href?: string; sender: ReactNode; text: ReactNode };
|
|
42
|
+
/** A string becomes one paragraph; pass elements for lists, links and more paragraphs. */
|
|
43
|
+
text?: ReactNode;
|
|
44
|
+
attachments?: MessageAttachment[];
|
|
45
|
+
actions?: MessageAction[];
|
|
46
|
+
/** aria-label of the row, e.g. "Lisa Verbeke, 09:12". */
|
|
47
|
+
label?: string;
|
|
48
|
+
id?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const svg = (d: string, width = "2") => (
|
|
52
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={width} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
|
|
53
|
+
);
|
|
54
|
+
const ICONS: Record<string, string> = { reply: "M9 14 4 9l5-5M4 9h10a6 6 0 0 1 6 6v4", delete: "M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6" };
|
|
55
|
+
const STATUS = { sent: "Sent", read: "Read", failed: "Failed" };
|
|
56
|
+
|
|
57
|
+
/** `.oc-message__attachments`: thumbnails share one `.oc-attachment-list`, files and images stand alone. */
|
|
58
|
+
export function MessageAttachments({ items }: { items: MessageAttachment[] }) {
|
|
59
|
+
const thumbs = items.every((a) => a.kind === "thumb");
|
|
60
|
+
return (
|
|
61
|
+
<div className="oc-message__attachments">
|
|
62
|
+
{thumbs ? (
|
|
63
|
+
<div className="oc-attachment-list">{items.map((a, i) => <AttachmentThumb key={i} as="span" {...(a as Extract<MessageAttachment, { kind: "thumb" }>)} />)}</div>
|
|
64
|
+
) : items.map((a, i) => {
|
|
65
|
+
if (a.kind === "image") { const { kind: _k, ...img } = a; return <AttachmentImage key={i} {...img} />; }
|
|
66
|
+
if (a.kind === "thumb") { const { kind: _k, ...t } = a; return <AttachmentThumb key={i} as="span" {...t} />; }
|
|
67
|
+
const { kind: _k, ...file } = a;
|
|
68
|
+
return <Attachment key={i} {...file} />;
|
|
69
|
+
})}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface MessageProps extends Omit<HTMLAttributes<HTMLElement>, "id">, MessageData {
|
|
75
|
+
onAction?: (name: string) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `article.oc-message`: one chat bubble with avatar, meta and hover actions. */
|
|
79
|
+
export const Message = forwardRef<HTMLElement, MessageProps>(function Message(
|
|
80
|
+
{ direction = "in", sender, avatar, time, datetime, status, statusLabel, statusIcon, continued, quote, text, attachments, actions, label, onAction, className, children, ...rest },
|
|
81
|
+
ref,
|
|
82
|
+
) {
|
|
83
|
+
return (
|
|
84
|
+
<article ref={ref} className={cx("oc-message", direction === "out" && "oc-message--out", continued && "oc-message--continued", className)} aria-label={label} {...rest}>
|
|
85
|
+
<span className="oc-avatar oc-message__avatar">{avatar}</span>
|
|
86
|
+
<div className="oc-message__bubble">
|
|
87
|
+
{sender != null && !continued && <p className="oc-message__sender">{sender}</p>}
|
|
88
|
+
{quote && <a className="oc-message__quote" href={quote.href}><strong>{quote.sender}</strong><span>{quote.text}</span></a>}
|
|
89
|
+
{(text != null || children != null) && <div className="oc-message__text">{typeof text === "string" ? <p>{text}</p> : text}{children}</div>}
|
|
90
|
+
{attachments?.length ? <MessageAttachments items={attachments} /> : null}
|
|
91
|
+
{(time != null || status) && (
|
|
92
|
+
<p className="oc-message__meta">
|
|
93
|
+
{time != null && <time dateTime={datetime}>{time}</time>}
|
|
94
|
+
{status && (
|
|
95
|
+
<span className={cx("oc-message__status", status !== "sent" && `oc-message__status--${status}`)}>
|
|
96
|
+
{statusIcon && svg("M2 12.5 7 17.5 18 6.5M13 16.5l1 1L22.5 9", "2.2")}{statusLabel ?? STATUS[status]}
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
</p>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
{actions?.length ? (
|
|
103
|
+
<div className="oc-message__actions">
|
|
104
|
+
{actions.map((a) => (
|
|
105
|
+
<button key={a.name} type="button" className={cx("oc-message__action", a.destructive && "oc-message__action--destructive")} aria-label={a.label} onClick={() => onAction?.(a.name)}>
|
|
106
|
+
{typeof a.icon === "string" || a.icon == null ? svg(ICONS[(a.icon as string) ?? a.name] ?? ICONS.reply) : a.icon}
|
|
107
|
+
</button>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
111
|
+
</article>
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export interface MessageDividerProps extends HTMLAttributes<HTMLDivElement> {
|
|
116
|
+
/** Centred notice without lines. */
|
|
117
|
+
system?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `.oc-message-divider`: date divider, or a system notice. */
|
|
121
|
+
export function MessageDivider({ system, className, children, ...rest }: MessageDividerProps) {
|
|
122
|
+
return <div className={cx("oc-message-divider", system && "oc-message-divider--system", className)} role={system ? "note" : undefined} {...rest}><span>{children}</span></div>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type MessageListItem = MessageData | { divider: ReactNode; system?: boolean };
|
|
126
|
+
|
|
127
|
+
export interface MessageListProps extends HTMLAttributes<HTMLDivElement> {
|
|
128
|
+
/** Messages and dividers in order; `children` replace them. */
|
|
129
|
+
items?: MessageListItem[];
|
|
130
|
+
/** role="log" for a list that updates live (default true). */
|
|
131
|
+
log?: boolean;
|
|
132
|
+
onAction?: (name: string, message: MessageData, index: number) => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const DEMO: MessageListItem[] = [
|
|
136
|
+
{ divider: "Tuesday 14 October" },
|
|
137
|
+
{ sender: "Lisa Verbeke", avatar: "LV", time: "09:12", datetime: "2026-10-14T09:12", label: "Lisa Verbeke, 09:12", text: "Morning Tom! The worktop supplier sent two samples for Villa Dewitte." },
|
|
138
|
+
{
|
|
139
|
+
sender: "Lisa Verbeke", avatar: "LV", time: "09:13", datetime: "2026-10-14T09:13", label: "Lisa Verbeke, 09:13", continued: true, id: "oc-message-demo-question",
|
|
140
|
+
text: (
|
|
141
|
+
<>
|
|
142
|
+
<p>Which finish should we order?</p>
|
|
143
|
+
<ul><li><strong>Calacatta</strong> matt, 20 mm</li><li><strong>Nero Marquina</strong> polished, 12 mm</li></ul>
|
|
144
|
+
<p>Details are on the <a href="#supplier-sheet">supplier sheet</a>.</p>
|
|
145
|
+
</>
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
direction: "out", avatar: "TP", time: "09:20", datetime: "2026-10-14T09:20", label: "You, 09:20", status: "read", statusIcon: true,
|
|
150
|
+
quote: { href: "#oc-message-demo-question", sender: "Lisa Verbeke", text: "Which finish should we order? Calacatta matt, 20 mm or Nero Marquina polished, 12 mm" },
|
|
151
|
+
text: "Calacatta matt. The client wants the lighter look next to the oak fronts.",
|
|
152
|
+
actions: [{ name: "reply", label: "Reply to this message" }, { name: "delete", label: "Delete this message", destructive: true }],
|
|
153
|
+
},
|
|
154
|
+
{ divider: "Lisa Verbeke marked this question as answered", system: true },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/** `.oc-message-list`: column of messages and dividers. */
|
|
158
|
+
export const MessageList = forwardRef<HTMLDivElement, MessageListProps>(function MessageList(
|
|
159
|
+
{ items = DEMO, log = true, onAction, className, children, "aria-label": label = log ? "Conversation with Lisa Verbeke" : undefined, ...rest },
|
|
160
|
+
ref,
|
|
161
|
+
) {
|
|
162
|
+
return (
|
|
163
|
+
<div ref={ref} className={cx("oc-message-list", className)} role={log ? "log" : undefined} aria-label={label} {...rest}>
|
|
164
|
+
{children ?? items.map((item, i) =>
|
|
165
|
+
"divider" in item
|
|
166
|
+
? <MessageDivider key={i} system={item.system}>{item.divider}</MessageDivider>
|
|
167
|
+
: <Message key={i} {...item} onAction={(name) => onAction?.(name, item, i)} />,
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
});
|
|
@@ -0,0 +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
|
+
});
|
|
@@ -0,0 +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
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { forwardRef, useId } from "react";
|
|
2
|
+
import type { AnchorHTMLAttributes, HTMLAttributes, LiHTMLAttributes, ReactNode } from "react";
|
|
3
|
+
import { attrs, cx } from "../utils";
|
|
4
|
+
|
|
5
|
+
export interface NavmenuProps extends HTMLAttributes<HTMLUListElement> {
|
|
6
|
+
/** `aria-label` of the wrapping <nav>. */
|
|
7
|
+
label?: string;
|
|
8
|
+
/** Class for the wrapping <nav> (e.g. `oc-block-appbar-nav`). */
|
|
9
|
+
navClassName?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `<nav><ul class="oc-navmenu">`: horizontal navigation links. */
|
|
13
|
+
export const Navmenu = forwardRef<HTMLUListElement, NavmenuProps>(function Navmenu(
|
|
14
|
+
{ label = "Navigation", navClassName, className, ...rest },
|
|
15
|
+
ref,
|
|
16
|
+
) {
|
|
17
|
+
return (
|
|
18
|
+
<nav aria-label={label} className={navClassName}>
|
|
19
|
+
<ul ref={ref} className={cx("oc-navmenu", className)} {...rest} />
|
|
20
|
+
</nav>
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** `<li>` of a Navmenu. */
|
|
25
|
+
export function NavmenuItem(props: LiHTMLAttributes<HTMLLIElement>) {
|
|
26
|
+
return <li {...props} />;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NavmenuLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
30
|
+
/** Current page (`aria-current="page"`). */
|
|
31
|
+
current?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `<a class="oc-navmenu-link">` */
|
|
35
|
+
export const NavmenuLink = forwardRef<HTMLAnchorElement, NavmenuLinkProps>(function NavmenuLink(
|
|
36
|
+
{ current, className, ...rest },
|
|
37
|
+
ref,
|
|
38
|
+
) {
|
|
39
|
+
return <a ref={ref} className={cx("oc-navmenu-link", className)} aria-current={current ? "page" : undefined} {...rest} />;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export interface NavmenuDropdownProps extends HTMLAttributes<HTMLDivElement> {
|
|
43
|
+
/** Trigger text (button with a chevron). */
|
|
44
|
+
label: ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `<li>` with a caret trigger button and a `.oc-dropdown-menu[popover]` of children. */
|
|
48
|
+
export const NavmenuDropdown = forwardRef<HTMLDivElement, NavmenuDropdownProps>(function NavmenuDropdown(
|
|
49
|
+
{ label, className, id, children, ...rest },
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
52
|
+
const auto = useId();
|
|
53
|
+
const menuId = id ?? `oc-navmenu-${auto.replace(/:/g, "")}`;
|
|
54
|
+
return (
|
|
55
|
+
<li>
|
|
56
|
+
<button className="oc-navmenu-link oc-navmenu-link--caret" type="button" {...attrs({ popovertarget: menuId })}>{label}</button>
|
|
57
|
+
<div ref={ref} className={cx("oc-dropdown-menu", className)} id={menuId} {...attrs({ popover: "" })} {...rest}>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
</li>
|
|
61
|
+
);
|
|
62
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// File: packages/react/src/components/NextTeaser.tsx | Map: props -> NextTeaser (a -> label -> name (+ mark) -> tagline)
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
export interface NextTeaserProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
7
|
+
/** Small uppercase label (default "Next product"). */
|
|
8
|
+
label?: ReactNode;
|
|
9
|
+
name: ReactNode;
|
|
10
|
+
tagline?: ReactNode;
|
|
11
|
+
/** Element before the name, usually a <ProductMark size="sm" />. */
|
|
12
|
+
mark?: ReactNode;
|
|
13
|
+
/** "Previous" link: arrow left. */
|
|
14
|
+
prev?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `<a class="oc-next-teaser">`: "Next product" teaser card. */
|
|
18
|
+
export const NextTeaser = forwardRef<HTMLAnchorElement, NextTeaserProps>(function NextTeaser(
|
|
19
|
+
{ label = "Next product", name, tagline, mark, prev = false, className, ...rest },
|
|
20
|
+
ref,
|
|
21
|
+
) {
|
|
22
|
+
return (
|
|
23
|
+
<a ref={ref} className={cx("oc-next-teaser", prev && "oc-next-teaser--prev", className)} {...rest}>
|
|
24
|
+
{label != null && <span className="oc-next-teaser__label">{label}</span>}
|
|
25
|
+
<span className="oc-next-teaser__name">{mark}{name}</span>
|
|
26
|
+
{tagline != null && <span className="oc-next-teaser__tagline">{tagline}</span>}
|
|
27
|
+
</a>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// File: packages/react/src/components/NumberInput.tsx
|
|
2
|
+
// Map: props -> clamp helper -> component (minus button, number field, plus button)
|
|
3
|
+
// Same markup as packages/ui/snippets/number-input.html. The buttons are handled here (no page script needed).
|
|
4
|
+
"use client";
|
|
5
|
+
|
|
6
|
+
import { forwardRef } from "react";
|
|
7
|
+
import type { InputHTMLAttributes } from "react";
|
|
8
|
+
import { cx } from "../utils";
|
|
9
|
+
import { useControllable } from "../hooks";
|
|
10
|
+
|
|
11
|
+
export interface NumberInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "size" | "type" | "min" | "max" | "step"> {
|
|
12
|
+
/** Current number; null = empty field. */
|
|
13
|
+
value?: number | null;
|
|
14
|
+
defaultValue?: number | null;
|
|
15
|
+
onChange?: (value: number | null) => void;
|
|
16
|
+
min?: number;
|
|
17
|
+
max?: number;
|
|
18
|
+
step?: number;
|
|
19
|
+
size?: "sm" | "md";
|
|
20
|
+
/** Full width. */
|
|
21
|
+
block?: boolean;
|
|
22
|
+
error?: boolean;
|
|
23
|
+
decrementLabel?: string;
|
|
24
|
+
incrementLabel?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep a number inside min/max and drop float noise (0.1 + 0.2). */
|
|
28
|
+
function clamp(n: number, min?: number, max?: number) {
|
|
29
|
+
const rounded = Math.round(n * 1e6) / 1e6;
|
|
30
|
+
return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, rounded));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `.oc-number-input`: number field with - / + buttons. Buttons disable themselves at min / max. */
|
|
34
|
+
export const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(function NumberInput(
|
|
35
|
+
{ value, defaultValue, onChange, min, max, step = 1, size = "md", block, error, decrementLabel = "Decrease", incrementLabel = "Increase", className, disabled, ...rest },
|
|
36
|
+
ref,
|
|
37
|
+
) {
|
|
38
|
+
const [current, setCurrent] = useControllable<number | null>(value, defaultValue ?? null, onChange);
|
|
39
|
+
const stepBy = (dir: 1 | -1) => setCurrent(clamp((current ?? min ?? 0) + dir * step, min, max));
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className={cx("oc-number-input", size === "sm" && "oc-number-input--sm", block && "oc-number-input--block", error && "oc-number-input--error", className)}>
|
|
43
|
+
<button className="oc-number-input-step" type="button" aria-label={decrementLabel} disabled={disabled || (min != null && current != null && current <= min)} onClick={() => stepBy(-1)}>
|
|
44
|
+
−
|
|
45
|
+
</button>
|
|
46
|
+
<input
|
|
47
|
+
ref={ref}
|
|
48
|
+
className="oc-number-input-field"
|
|
49
|
+
type="number"
|
|
50
|
+
min={min}
|
|
51
|
+
max={max}
|
|
52
|
+
step={step}
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
aria-invalid={error ? true : undefined}
|
|
55
|
+
value={current ?? ""}
|
|
56
|
+
onChange={(e) => setCurrent(e.target.value === "" ? null : Number(e.target.value))}
|
|
57
|
+
{...rest}
|
|
58
|
+
/>
|
|
59
|
+
<button className="oc-number-input-step" type="button" aria-label={incrementLabel} disabled={disabled || (max != null && current != null && current >= max)} onClick={() => stepBy(1)}>
|
|
60
|
+
+
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
import type { CSSProperties, InputHTMLAttributes } from "react";
|
|
5
|
+
import { cx } from "../utils";
|
|
6
|
+
import { useControllable } from "../hooks";
|
|
7
|
+
|
|
8
|
+
export interface OtpInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type" | "maxLength"> {
|
|
9
|
+
/** Code length (default 6). */
|
|
10
|
+
length?: number;
|
|
11
|
+
value?: string;
|
|
12
|
+
defaultValue?: string;
|
|
13
|
+
onChange?: (value: string) => void;
|
|
14
|
+
/** Called once when `length` characters have been entered. */
|
|
15
|
+
onComplete?: (value: string) => void;
|
|
16
|
+
/** Error state (`aria-invalid`). */
|
|
17
|
+
invalid?: boolean;
|
|
18
|
+
/** Allow letters as well as digits. */
|
|
19
|
+
alphanumeric?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `<input class="oc-otp">`: separate cells drawn on a single real input. */
|
|
23
|
+
export const OtpInput = forwardRef<HTMLInputElement, OtpInputProps>(function OtpInput(
|
|
24
|
+
{ length = 6, value, defaultValue = "", onChange, onComplete, invalid, alphanumeric, className, style, ...rest },
|
|
25
|
+
ref,
|
|
26
|
+
) {
|
|
27
|
+
const [code, setCode] = useControllable<string>(value, defaultValue, onChange);
|
|
28
|
+
return (
|
|
29
|
+
<input
|
|
30
|
+
ref={ref}
|
|
31
|
+
className={cx("oc-otp", className)}
|
|
32
|
+
type="text"
|
|
33
|
+
inputMode={alphanumeric ? "text" : "numeric"}
|
|
34
|
+
autoComplete="one-time-code"
|
|
35
|
+
maxLength={length}
|
|
36
|
+
pattern={alphanumeric ? undefined : "[0-9]*"}
|
|
37
|
+
style={{ "--oc-otp-length": length, ...style } as CSSProperties}
|
|
38
|
+
aria-invalid={invalid ? true : undefined}
|
|
39
|
+
value={code}
|
|
40
|
+
onChange={(e) => {
|
|
41
|
+
const next = alphanumeric ? e.target.value : e.target.value.replace(/\D/g, "");
|
|
42
|
+
setCode(next);
|
|
43
|
+
if (next.length === length) onComplete?.(next);
|
|
44
|
+
}}
|
|
45
|
+
{...rest}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
});
|