@birdycom/omniaui-react 0.12.5 → 0.12.6
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/dist/index.d.ts +323 -35
- package/dist/index.js +2642 -1701
- package/manifest.json +90 -6
- package/package.json +1 -1
- package/src/blocks/BlockAppShell.tsx +1 -1
- package/src/blocks/BlockProfileHeader.tsx +20 -3
- package/src/components/Attachment.tsx +1 -1
- package/src/components/AvatarPicker.tsx +68 -0
- package/src/components/Barcode.tsx +42 -0
- package/src/components/Breadcrumb.tsx +8 -4
- package/src/components/Button.tsx +22 -7
- package/src/components/ChatComposer.tsx +1 -1
- package/src/components/DataTable.tsx +182 -33
- package/src/components/DateField.tsx +41 -0
- package/src/components/EmojiPicker.tsx +2 -2
- package/src/components/FormSection.tsx +32 -0
- package/src/components/Gallery.tsx +56 -0
- package/src/components/Glass.tsx +5 -3
- package/src/components/GlassBar.tsx +42 -0
- package/src/components/LazyImage.tsx +45 -0
- package/src/components/Message.tsx +7 -7
- package/src/components/MessageThread.tsx +10 -10
- package/src/components/MeterRow.tsx +38 -0
- package/src/components/NameCard.tsx +59 -0
- package/src/components/Price.tsx +45 -0
- package/src/components/Prose.tsx +3 -3
- package/src/components/Quantity.tsx +55 -0
- package/src/components/Rating.tsx +69 -0
- package/src/components/Ribbon.tsx +30 -0
- package/src/components/SpecList.tsx +36 -0
- package/src/components/Stock.tsx +28 -0
- package/src/index.ts +15 -1
- package/src/utils.ts +1 -1
- package/src/components/ColumnMenu.tsx +0 -66
|
@@ -43,7 +43,7 @@ export interface MessageData {
|
|
|
43
43
|
text?: ReactNode;
|
|
44
44
|
attachments?: MessageAttachment[];
|
|
45
45
|
actions?: MessageAction[];
|
|
46
|
-
/** aria-label of the row, e.g. "
|
|
46
|
+
/** aria-label of the row, e.g. "Customer 1, 09:12". */
|
|
47
47
|
label?: string;
|
|
48
48
|
id?: string;
|
|
49
49
|
}
|
|
@@ -135,9 +135,9 @@ export interface MessageListProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
135
135
|
|
|
136
136
|
const DEMO: MessageListItem[] = [
|
|
137
137
|
{ divider: "Tuesday 14 October" },
|
|
138
|
-
{ sender: "
|
|
138
|
+
{ sender: "Customer 1", avatar: "C1", time: "09:12", datetime: "2026-10-14T09:12", label: "Customer 1, 09:12", text: "Morning Tom! The worktop supplier sent two samples for Project 1." },
|
|
139
139
|
{
|
|
140
|
-
sender: "
|
|
140
|
+
sender: "Customer 1", avatar: "C1", time: "09:13", datetime: "2026-10-14T09:13", label: "Customer 1, 09:13", continued: true, id: "oc-message-demo-question",
|
|
141
141
|
text: (
|
|
142
142
|
<>
|
|
143
143
|
<p>Which finish should we order?</p>
|
|
@@ -147,17 +147,17 @@ const DEMO: MessageListItem[] = [
|
|
|
147
147
|
),
|
|
148
148
|
},
|
|
149
149
|
{
|
|
150
|
-
direction: "out", avatar: "
|
|
151
|
-
quote: { href: "#oc-message-demo-question", sender: "
|
|
150
|
+
direction: "out", avatar: "C6", time: "09:20", datetime: "2026-10-14T09:20", label: "You, 09:20", status: "read", statusIcon: true,
|
|
151
|
+
quote: { href: "#oc-message-demo-question", sender: "Customer 1", text: "Which finish should we order? Calacatta matt, 20 mm or Nero Marquina polished, 12 mm" },
|
|
152
152
|
text: "Calacatta matt. The client wants the lighter look next to the oak fronts.",
|
|
153
153
|
actions: [{ name: "reply", label: "Reply to this message" }, { name: "delete", label: "Delete this message", destructive: true }],
|
|
154
154
|
},
|
|
155
|
-
{ divider: "
|
|
155
|
+
{ divider: "Customer 1 marked this question as answered", system: true },
|
|
156
156
|
];
|
|
157
157
|
|
|
158
158
|
/** `.oc-message-list`: column of messages and dividers. */
|
|
159
159
|
export const MessageList = forwardRef<HTMLDivElement, MessageListProps>(function MessageList(
|
|
160
|
-
{ items = DEMO, log = true, onAction, className, children, "aria-label": label = log ? "Conversation with
|
|
160
|
+
{ items = DEMO, log = true, onAction, className, children, "aria-label": label = log ? "Conversation with Customer 1" : undefined, ...rest },
|
|
161
161
|
ref,
|
|
162
162
|
) {
|
|
163
163
|
return (
|
|
@@ -63,22 +63,22 @@ const MENU: ThreadMenuItem[] = [
|
|
|
63
63
|
];
|
|
64
64
|
const DEMO: MessageListItem[] = [
|
|
65
65
|
{ divider: "Tuesday 14 October" },
|
|
66
|
-
{ sender: "
|
|
66
|
+
{ sender: "Customer 1", avatar: "C1", time: "09:12", datetime: "2026-10-14T09:12", text: "The supplier dropped off two worktop samples at the workshop. Photo attached.",
|
|
67
67
|
attachments: [{ kind: "image", href: "#samples.jpg", alt: "Two worktop samples on the workbench", art: 6, caption: "samples.jpg · 2.1 MB" }] },
|
|
68
|
-
{ sender: "
|
|
68
|
+
{ sender: "Customer 1", avatar: "C1", time: "09:13", datetime: "2026-10-14T09:13", continued: true,
|
|
69
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: "
|
|
71
|
-
attachments: [{ name: "Quote kitchen
|
|
70
|
+
{ direction: "out", avatar: "C6", 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 Project 1 v2.pdf", size: "1.2 MB", href: "#quote.pdf" }], actions: [{ name: "delete", label: "Delete this message", destructive: true }] },
|
|
72
72
|
{ divider: "Today" },
|
|
73
|
-
{ sender: "
|
|
73
|
+
{ sender: "Customer 1", avatar: "C1", time: "10:02", datetime: "2026-10-24T10:02", text: "Client signed. Can you confirm the edge profile before 16:00?" },
|
|
74
74
|
];
|
|
75
75
|
const isOut = (m?: MessageListItem): m is MessageData => !!m && !("divider" in m) && m.direction === "out";
|
|
76
76
|
|
|
77
77
|
/** `section.oc-message-thread`: header, scrolling messages (newest at the bottom), composer and sending overlay. */
|
|
78
78
|
export const MessageThread = forwardRef<HTMLElement, MessageThreadProps>(function MessageThread(
|
|
79
79
|
{
|
|
80
|
-
title = "Kitchen worktop: which finish?", project = "
|
|
81
|
-
participants = [{ initials: "
|
|
80
|
+
title = "Kitchen worktop: which finish?", project = "Project 1", meta = "Customer 1 → Customer 6 · 14 Oct 2026",
|
|
81
|
+
participants = [{ initials: "C1", name: "Customer 1" }, { initials: "C6", name: "Customer 6" }], status = { label: "Urgent", variant: "destructive" },
|
|
82
82
|
menu = MENU, onMenu, messages = DEMO, scrollLabel = "Messages", live = true, busy, busyTitle = "Sending message…",
|
|
83
83
|
busyText = "Please wait, do not close this conversation.", composer, note, placeholder = "Type your reply…", composerLabel = "Reply", inline,
|
|
84
84
|
tools = ["attach", "emoji"], onTool, hint = "Ctrl + Enter to send", onSend, manual, me, back, plain, id, className, ...rest
|
|
@@ -155,12 +155,12 @@ export const MessageThread = forwardRef<HTMLElement, MessageThreadProps>(functio
|
|
|
155
155
|
<div className="oc-chat-composer__bar">
|
|
156
156
|
{tools.length > 0 && (
|
|
157
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")}
|
|
159
|
-
{tools.includes("emoji") && <button type="button" className="oc-chat-composer__tool" aria-label="Insert emoji" onClick={() => onTool?.("emoji")}
|
|
158
|
+
{tools.includes("attach") && <button type="button" className="oc-chat-composer__tool" aria-label="Attach a file" onClick={() => onTool?.("attach")}><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.5 6v11.5a4 4 0 0 1-4 4a4 4 0 0 1-4-4V5a2.5 2.5 0 0 1 2.5-2.5A2.5 2.5 0 0 1 13.5 5v10.5a1 1 0 0 1-1 1a1 1 0 0 1-1-1V6H10v9.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5V5a4 4 0 0 0-4-4a4 4 0 0 0-4 4v12.5a5.5 5.5 0 0 0 5.5 5.5a5.5 5.5 0 0 0 5.5-5.5V6z"/></svg></button>}
|
|
159
|
+
{tools.includes("emoji") && <button type="button" className="oc-chat-composer__tool" aria-label="Insert emoji" onClick={() => onTool?.("emoji")}><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20 12a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8a8 8 0 0 0 8-8m2 0a10 10 0 0 1-10 10A10 10 0 0 1 2 12A10 10 0 0 1 12 2a10 10 0 0 1 10 10m-12-3.5c0 .8-.7 1.5-1.5 1.5S7 9.3 7 8.5S7.7 7 8.5 7s1.5.7 1.5 1.5m7 0c0 .8-.7 1.5-1.5 1.5S14 9.3 14 8.5S14.7 7 15.5 7s1.5.7 1.5 1.5m-5 9.73c-1.75 0-3.29-.73-4.19-1.81L9.23 14c.45.72 1.52 1.23 2.77 1.23s2.32-.51 2.77-1.23l1.42 1.42c-.9 1.08-2.44 1.81-4.19 1.81"/></svg></button>}
|
|
160
160
|
</div>
|
|
161
161
|
)}
|
|
162
162
|
{hint != null && <span className="oc-chat-composer__hint">{hint}</span>}
|
|
163
|
-
<button type="submit" className="oc-chat-composer__send" aria-label="Send"
|
|
163
|
+
<button type="submit" className="oc-chat-composer__send" aria-label="Send"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M2 21l21-9L2 3v7l15 2l-15 2z"/></svg></button>
|
|
164
164
|
</div>
|
|
165
165
|
</form>
|
|
166
166
|
)}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
|
|
3
|
+
import { cx } from "../utils";
|
|
4
|
+
|
|
5
|
+
export interface MeterRowProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
/** 0–100. */
|
|
8
|
+
percent: number;
|
|
9
|
+
/** The amount as text after the percentage ("31k / 50k tokens"). */
|
|
10
|
+
value?: ReactNode;
|
|
11
|
+
/** When it resets ("Resets in 2 h"). */
|
|
12
|
+
reset?: ReactNode;
|
|
13
|
+
/** Color; default from the percentage: warn from 75, critical from 90. */
|
|
14
|
+
level?: "ok" | "warn" | "critical";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Usage line: label, percentage and amount, a colored bar and the reset line (`.oc-meter-row`). */
|
|
18
|
+
export const MeterRow = forwardRef<HTMLDivElement, MeterRowProps>(function MeterRow(
|
|
19
|
+
{ label, percent, value, reset, level, className, style, ...rest },
|
|
20
|
+
ref,
|
|
21
|
+
) {
|
|
22
|
+
const pct = Math.max(0, Math.min(100, Math.round(percent)));
|
|
23
|
+
const tone = level ?? (pct >= 90 ? "critical" : pct >= 75 ? "warn" : "ok");
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
ref={ref}
|
|
27
|
+
className={cx("oc-meter-row", className)}
|
|
28
|
+
data-level={tone === "ok" ? undefined : tone}
|
|
29
|
+
style={{ ...style, ["--oc-meter" as string]: `${pct}%` } as CSSProperties}
|
|
30
|
+
{...rest}
|
|
31
|
+
>
|
|
32
|
+
<span className="oc-meter-row__label">{label}</span>
|
|
33
|
+
<span className="oc-meter-row__value"><strong>{pct}%</strong>{value != null && <> · {value}</>}</span>
|
|
34
|
+
<span className="oc-meter-row__bar" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct} aria-label={typeof label === "string" ? label : undefined} />
|
|
35
|
+
{reset != null && <span className="oc-meter-row__reset">{reset}</span>}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useEffect, useRef } from "react";
|
|
4
|
+
import type { DetailsHTMLAttributes, ReactNode } from "react";
|
|
5
|
+
import { bindNameCard } from "../../../ui/src/form-extras.js";
|
|
6
|
+
import { cx } from "../utils";
|
|
7
|
+
import { Icon } from "./Icon";
|
|
8
|
+
|
|
9
|
+
export interface NameCardProps extends Omit<DetailsHTMLAttributes<HTMLDetailsElement>, "title"> {
|
|
10
|
+
name: string;
|
|
11
|
+
/** Amount on the right (already formatted, e.g. "€ 1.940,00"). */
|
|
12
|
+
amount?: ReactNode;
|
|
13
|
+
/** Quiet extra after the name ("6 articles"). */
|
|
14
|
+
meta?: ReactNode;
|
|
15
|
+
/** Buttons on the right (e.g. a soft destructive delete). */
|
|
16
|
+
actions?: ReactNode;
|
|
17
|
+
/** Show the rename pencil; onRename gets the new name. */
|
|
18
|
+
onRename?: (name: string) => void;
|
|
19
|
+
/** Amount in the primary color (a total). */
|
|
20
|
+
total?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `<details class="oc-name-card">`: name, rename pencil, amount and actions in the header; children are the content. */
|
|
24
|
+
export const NameCard = forwardRef<HTMLDetailsElement, NameCardProps>(function NameCard(
|
|
25
|
+
{ name, amount, meta, actions, onRename, total, className, children, ...rest },
|
|
26
|
+
ref,
|
|
27
|
+
) {
|
|
28
|
+
const own = useRef<HTMLDetailsElement | null>(null);
|
|
29
|
+
const renameRef = useRef(onRename);
|
|
30
|
+
renameRef.current = onRename;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const el = own.current;
|
|
33
|
+
if (!el || !onRename) return;
|
|
34
|
+
bindNameCard(el);
|
|
35
|
+
const handler = (event: Event) => renameRef.current?.((event as CustomEvent<{ name: string }>).detail.name);
|
|
36
|
+
el.addEventListener("oc-namecard-rename", handler);
|
|
37
|
+
return () => el.removeEventListener("oc-namecard-rename", handler);
|
|
38
|
+
}, [Boolean(onRename)]);
|
|
39
|
+
return (
|
|
40
|
+
<details
|
|
41
|
+
ref={(node) => { own.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }}
|
|
42
|
+
className={cx("oc-name-card", total && "oc-name-card--total", className)}
|
|
43
|
+
{...rest}
|
|
44
|
+
>
|
|
45
|
+
<summary className="oc-name-card__head">
|
|
46
|
+
<span className="oc-name-card__title">{name}</span>
|
|
47
|
+
{onRename && (
|
|
48
|
+
<button type="button" className="oc-btn oc-btn--plain" data-oc-rename="" aria-label={`Rename ${name}`} title="Rename">
|
|
49
|
+
<Icon d="m14.06 9l.94.94L5.92 19H5v-.92zm3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94z" />
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
{meta != null && <span className="oc-name-card__meta">{meta}</span>}
|
|
53
|
+
{amount != null && <span className="oc-name-card__amount">{amount}</span>}
|
|
54
|
+
{actions && <span className="oc-name-card__actions">{actions}</span>}
|
|
55
|
+
</summary>
|
|
56
|
+
<div className="oc-name-card__body">{children}</div>
|
|
57
|
+
</details>
|
|
58
|
+
);
|
|
59
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Same markup as packages/ui/snippets/price.html.
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
/** Format an amount with Intl (locale and currency), or pass a string through. */
|
|
7
|
+
export function formatPrice(amount: number | string, currency = "EUR", locale = "nl-BE"): string {
|
|
8
|
+
return typeof amount === "number" ? new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount) : amount;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PriceProps extends HTMLAttributes<HTMLDivElement> {
|
|
12
|
+
amount: number | string;
|
|
13
|
+
/** Old price (struck through). */
|
|
14
|
+
old?: number | string;
|
|
15
|
+
/** Discount badge text ("-20%"). */
|
|
16
|
+
discount?: ReactNode;
|
|
17
|
+
/** Quiet note: "excl. VAT". */
|
|
18
|
+
note?: ReactNode;
|
|
19
|
+
/** The other amount: "€ 180,29 incl. VAT". */
|
|
20
|
+
alt?: ReactNode;
|
|
21
|
+
currency?: string;
|
|
22
|
+
locale?: string;
|
|
23
|
+
size?: "sm" | "md" | "lg";
|
|
24
|
+
sale?: boolean;
|
|
25
|
+
inline?: boolean;
|
|
26
|
+
end?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A price with old price, discount, VAT note and the other amount (`.oc-price`). */
|
|
30
|
+
export const Price = forwardRef<HTMLDivElement, PriceProps>(function Price(
|
|
31
|
+
{ amount, old, discount, note, alt, currency = "EUR", locale = "nl-BE", size = "md", sale, inline, end, className, ...rest },
|
|
32
|
+
ref,
|
|
33
|
+
) {
|
|
34
|
+
return (
|
|
35
|
+
<div ref={ref} className={cx("oc-price", size !== "md" && `oc-price--${size}`, sale && "oc-price--sale", inline && "oc-price--inline", end && "oc-price--end", className)} {...rest}>
|
|
36
|
+
<div className="oc-price__main">
|
|
37
|
+
<span className="oc-price__amount">{formatPrice(amount, currency, locale)}</span>
|
|
38
|
+
{old != null && <s className="oc-price__old">{formatPrice(old, currency, locale)}</s>}
|
|
39
|
+
{discount != null && <span className="oc-badge oc-badge--destructive">{discount}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
{note != null && <span className="oc-price__note">{note}</span>}
|
|
42
|
+
{alt != null && <span className="oc-price__alt">{alt}</span>}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
});
|
package/src/components/Prose.tsx
CHANGED
|
@@ -35,7 +35,7 @@ const DEMO = (
|
|
|
35
35
|
</ul>
|
|
36
36
|
<figure>
|
|
37
37
|
<div className="oc-media-art oc-media-art--6" role="img" aria-label="Kitchen with oak fronts and a light stone worktop" />
|
|
38
|
-
<figcaption>
|
|
38
|
+
<figcaption>Project 1: oak fronts with a Calacatta matt worktop, finished in October 2026.</figcaption>
|
|
39
39
|
</figure>
|
|
40
40
|
<h3>From design to installation</h3>
|
|
41
41
|
<ol>
|
|
@@ -46,7 +46,7 @@ const DEMO = (
|
|
|
46
46
|
</ol>
|
|
47
47
|
<blockquote>
|
|
48
48
|
<p>The planning was clear from day one. We always knew who was coming and when, and the kitchen was ready a week early.</p>
|
|
49
|
-
<footer>—
|
|
49
|
+
<footer>— Customer 8, client in Ghent</footer>
|
|
50
50
|
</blockquote>
|
|
51
51
|
<h2>Worktop materials compared</h2>
|
|
52
52
|
<table>
|
|
@@ -60,7 +60,7 @@ const DEMO = (
|
|
|
60
60
|
</table>
|
|
61
61
|
<h3>Sharing your floor plan</h3>
|
|
62
62
|
<p>Upload a PDF or a photo in the client portal, or send it by e-mail. Press <kbd>Ctrl</kbd> + <kbd>V</kbd> in the chat to paste a screenshot. Files are stored as <code>project-id/plans/</code> and only your project team can see them.</p>
|
|
63
|
-
<pre><code>{"Project: 2026-114
|
|
63
|
+
<pre><code>{"Project: 2026-114 Project 1\nRoom: Kitchen, ground floor\nWall A: 412 cm (window at 160 cm)\nWall B: 298 cm (door at the left)"}</code></pre>
|
|
64
64
|
<hr />
|
|
65
65
|
<p><small>Lead times are an estimate. <mark>Natural stone</mark> can take two weeks longer in summer.</small></p>
|
|
66
66
|
</>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Same markup as packages/ui/snippets/quantity.html.
|
|
4
|
+
import { forwardRef, useEffect, useState } from "react";
|
|
5
|
+
import type { InputHTMLAttributes } from "react";
|
|
6
|
+
import { stepQuantity } from "../../../ui/src/form-extras.js";
|
|
7
|
+
import { cx } from "../utils";
|
|
8
|
+
import { Icon } from "./Icon";
|
|
9
|
+
|
|
10
|
+
export interface QuantityProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "size" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step"> {
|
|
11
|
+
value?: number;
|
|
12
|
+
defaultValue?: number;
|
|
13
|
+
onChange?: (value: number) => void;
|
|
14
|
+
min?: number;
|
|
15
|
+
max?: number;
|
|
16
|
+
step?: number;
|
|
17
|
+
size?: "sm" | "md";
|
|
18
|
+
/** Accessible name of the field (also used for the buttons). Default "Quantity". */
|
|
19
|
+
label?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Minus, number and plus in one frame (`.oc-quantity`). */
|
|
23
|
+
export const Quantity = forwardRef<HTMLInputElement, QuantityProps>(function Quantity(
|
|
24
|
+
{ value, defaultValue = 1, onChange, min = 0, max, step = 1, size = "md", label = "Quantity", disabled, className, ...rest },
|
|
25
|
+
ref,
|
|
26
|
+
) {
|
|
27
|
+
const [current, setCurrent] = useState(value ?? defaultValue);
|
|
28
|
+
useEffect(() => { if (value !== undefined) setCurrent(value); }, [value]);
|
|
29
|
+
const limits = { min, max: max ?? Infinity, step };
|
|
30
|
+
const set = (next: number) => { setCurrent(next); onChange?.(next); };
|
|
31
|
+
return (
|
|
32
|
+
<div className={cx("oc-quantity", size === "sm" && "oc-quantity--sm", className)}>
|
|
33
|
+
<button className="oc-quantity__step" type="button" aria-label={`Decrease ${label.toLowerCase()}`} disabled={disabled || current <= min} onClick={() => set(stepQuantity(current, -1, limits))}>
|
|
34
|
+
<Icon d="M19 13H5v-2h14z" />
|
|
35
|
+
</button>
|
|
36
|
+
<input
|
|
37
|
+
ref={ref}
|
|
38
|
+
className="oc-quantity__input"
|
|
39
|
+
type="number"
|
|
40
|
+
inputMode="numeric"
|
|
41
|
+
value={Number.isFinite(current) ? current : ""}
|
|
42
|
+
min={min}
|
|
43
|
+
max={max}
|
|
44
|
+
step={step}
|
|
45
|
+
aria-label={label}
|
|
46
|
+
disabled={disabled}
|
|
47
|
+
onChange={(event) => set(event.target.value === "" ? NaN : Number(event.target.value))}
|
|
48
|
+
{...rest}
|
|
49
|
+
/>
|
|
50
|
+
<button className="oc-quantity__step" type="button" aria-label={`Increase ${label.toLowerCase()}`} disabled={disabled || (max != null && current >= max)} onClick={() => set(stepQuantity(current, 1, limits))}>
|
|
51
|
+
<Icon d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Same markup as packages/ui/snippets/rating.html.
|
|
4
|
+
import { forwardRef, useEffect, useId, useState } from "react";
|
|
5
|
+
import type { CSSProperties, HTMLAttributes } from "react";
|
|
6
|
+
import { cx } from "../utils";
|
|
7
|
+
|
|
8
|
+
export interface RatingProps extends HTMLAttributes<HTMLSpanElement> {
|
|
9
|
+
/** The score, e.g. 4.5. */
|
|
10
|
+
value: number;
|
|
11
|
+
/** Number of stars. Default 5. */
|
|
12
|
+
max?: number;
|
|
13
|
+
size?: "sm" | "md" | "lg";
|
|
14
|
+
/** Accessible text; default "4.5 out of 5". */
|
|
15
|
+
label?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Stars of a score (`.oc-rating`), halves and decimals included. */
|
|
19
|
+
export const Rating = forwardRef<HTMLSpanElement, RatingProps>(function Rating(
|
|
20
|
+
{ value, max = 5, size = "md", label, className, style, ...rest },
|
|
21
|
+
ref,
|
|
22
|
+
) {
|
|
23
|
+
const score = Math.max(0, Math.min(max, value));
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
ref={ref}
|
|
27
|
+
className={cx("oc-rating", size !== "md" && `oc-rating--${size}`, className)}
|
|
28
|
+
style={{ ...style, ["--oc-rating" as string]: String(score), ...(max !== 5 ? { ["--oc-rating-max" as string]: String(max) } : {}) } as CSSProperties}
|
|
29
|
+
role="img"
|
|
30
|
+
aria-label={label ?? `${score} out of ${max}`}
|
|
31
|
+
{...rest}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export interface RatingInputProps extends Omit<HTMLAttributes<HTMLFieldSetElement>, "onChange" | "defaultValue"> {
|
|
37
|
+
/** Radio name (form value 1–5). Default: a generated name. */
|
|
38
|
+
name?: string;
|
|
39
|
+
value?: number;
|
|
40
|
+
defaultValue?: number;
|
|
41
|
+
onChange?: (value: number) => void;
|
|
42
|
+
legend?: string;
|
|
43
|
+
/** Text per star; default "1 star", "2 stars" … */
|
|
44
|
+
starLabel?: (n: number) => string;
|
|
45
|
+
size?: "sm" | "md" | "lg";
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Five radio buttons drawn as stars (`.oc-rating-input`), CSS hover preview. */
|
|
50
|
+
export const RatingInput = forwardRef<HTMLFieldSetElement, RatingInputProps>(function RatingInput(
|
|
51
|
+
{ name, value, defaultValue, onChange, legend = "Rating", starLabel = (n) => (n === 1 ? "1 star" : `${n} stars`), size = "md", disabled, className, ...rest },
|
|
52
|
+
ref,
|
|
53
|
+
) {
|
|
54
|
+
const auto = useId();
|
|
55
|
+
const group = name ?? `oc-rating-${auto.replace(/:/g, "")}`;
|
|
56
|
+
const [current, setCurrent] = useState(value ?? defaultValue ?? 0);
|
|
57
|
+
useEffect(() => { if (value !== undefined) setCurrent(value); }, [value]);
|
|
58
|
+
return (
|
|
59
|
+
<fieldset ref={ref} className={cx("oc-rating-input", size !== "md" && `oc-rating-input--${size}`, className)} disabled={disabled} {...rest}>
|
|
60
|
+
<legend>{legend}</legend>
|
|
61
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
62
|
+
<label key={n}>
|
|
63
|
+
<input type="radio" name={group} value={n} checked={current === n} onChange={() => { setCurrent(n); onChange?.(n); }} />
|
|
64
|
+
<span>{starLabel(n)}</span>
|
|
65
|
+
</label>
|
|
66
|
+
))}
|
|
67
|
+
</fieldset>
|
|
68
|
+
);
|
|
69
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Same markup as packages/ui/snippets/ribbon.html.
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
export type RibbonCorner = "top-start" | "top-end" | "bottom-start" | "bottom-end";
|
|
7
|
+
|
|
8
|
+
export interface RibbonProps extends HTMLAttributes<HTMLSpanElement> {
|
|
9
|
+
/** Corner of the parent box (default top-end). */
|
|
10
|
+
corner?: RibbonCorner;
|
|
11
|
+
tone?: "primary" | "success" | "warning" | "destructive" | "muted";
|
|
12
|
+
size?: "sm" | "md" | "lg";
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Diagonal band across a corner of its parent (`.oc-ribbon`): Paid, New, Sale. */
|
|
17
|
+
export const Ribbon = forwardRef<HTMLSpanElement, RibbonProps>(function Ribbon(
|
|
18
|
+
{ corner = "top-end", tone = "primary", size = "md", className, children, ...rest },
|
|
19
|
+
ref,
|
|
20
|
+
) {
|
|
21
|
+
return (
|
|
22
|
+
<span
|
|
23
|
+
ref={ref}
|
|
24
|
+
className={cx("oc-ribbon", corner !== "top-end" && `oc-ribbon--${corner}`, tone !== "primary" && `oc-ribbon--${tone}`, size !== "md" && `oc-ribbon--${size}`, className)}
|
|
25
|
+
{...rest}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Same markup as packages/ui/snippets/spec-list.html.
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
export interface SpecGroup {
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
rows: { label: ReactNode; value: ReactNode; note?: ReactNode }[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SpecListProps extends HTMLAttributes<HTMLDivElement> {
|
|
13
|
+
groups: SpecGroup[];
|
|
14
|
+
stack?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Technical specifications in named groups (`.oc-spec-list`). */
|
|
18
|
+
export const SpecList = forwardRef<HTMLDivElement, SpecListProps>(function SpecList(
|
|
19
|
+
{ groups, stack, className, ...rest },
|
|
20
|
+
ref,
|
|
21
|
+
) {
|
|
22
|
+
return (
|
|
23
|
+
<div ref={ref} className={cx("oc-spec-list", stack && "oc-spec-list--stack", className)} {...rest}>
|
|
24
|
+
{groups.map((group, g) => (
|
|
25
|
+
<section key={g} className="oc-spec-group">
|
|
26
|
+
<h3 className="oc-spec-group__title">{group.icon}{group.title}</h3>
|
|
27
|
+
<dl className="oc-spec-group__rows">
|
|
28
|
+
{group.rows.map((row, r) => (
|
|
29
|
+
<div key={r} className="oc-spec-row"><dt>{row.label}</dt><dd>{row.value}{row.note != null && <small>{row.note}</small>}</dd></div>
|
|
30
|
+
))}
|
|
31
|
+
</dl>
|
|
32
|
+
</section>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Same markup as packages/ui/snippets/stock.html.
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
export interface StockProps extends HTMLAttributes<HTMLSpanElement> {
|
|
7
|
+
level?: "ok" | "low" | "out" | "order";
|
|
8
|
+
badge?: boolean;
|
|
9
|
+
/** The text; default per level ("In stock", "Low stock", "Out of stock", "On order"). */
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
/** A quiet extra after the text. */
|
|
12
|
+
note?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STOCK_TEXT = { ok: "In stock", low: "Low stock", out: "Out of stock", order: "On order" } as const;
|
|
16
|
+
|
|
17
|
+
/** Stock status with a colored dot (`.oc-stock`). */
|
|
18
|
+
export const Stock = forwardRef<HTMLSpanElement, StockProps>(function Stock(
|
|
19
|
+
{ level = "ok", badge, note, children, className, ...rest },
|
|
20
|
+
ref,
|
|
21
|
+
) {
|
|
22
|
+
return (
|
|
23
|
+
<span ref={ref} className={cx("oc-stock", badge && "oc-stock--badge", className)} data-level={level === "ok" ? undefined : level} {...rest}>
|
|
24
|
+
{children ?? STOCK_TEXT[level]}
|
|
25
|
+
{note != null && <span className="oc-stock__note"> · {note}</span>}
|
|
26
|
+
</span>
|
|
27
|
+
);
|
|
28
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -9,8 +9,10 @@ export * from "./components/AlertDialog";
|
|
|
9
9
|
export * from "./components/AlphaTabs";
|
|
10
10
|
export * from "./components/Attachment";
|
|
11
11
|
export * from "./components/Avatar";
|
|
12
|
+
export * from "./components/AvatarPicker";
|
|
12
13
|
export * from "./components/BackLink";
|
|
13
14
|
export * from "./components/Badge";
|
|
15
|
+
export * from "./components/Barcode";
|
|
14
16
|
export * from "./components/BarcodeInput";
|
|
15
17
|
export * from "./components/Breadcrumb";
|
|
16
18
|
export * from "./components/BrowserFrame";
|
|
@@ -25,13 +27,13 @@ export * from "./components/ChipFilter";
|
|
|
25
27
|
export * from "./components/Collapsible";
|
|
26
28
|
export * from "./components/ColorField";
|
|
27
29
|
export * from "./components/ColorSelect";
|
|
28
|
-
export * from "./components/ColumnMenu";
|
|
29
30
|
export * from "./components/Command";
|
|
30
31
|
export * from "./components/ConfirmDialog";
|
|
31
32
|
export * from "./components/CookieConsent";
|
|
32
33
|
export * from "./components/CopyField";
|
|
33
34
|
export * from "./components/Counter";
|
|
34
35
|
export * from "./components/DataTable";
|
|
36
|
+
export * from "./components/DateField";
|
|
35
37
|
export * from "./components/DecimalInput";
|
|
36
38
|
export * from "./components/DefinitionList";
|
|
37
39
|
export * from "./components/DocViewer";
|
|
@@ -44,7 +46,10 @@ export * from "./components/FactList";
|
|
|
44
46
|
export * from "./components/FilterCounts";
|
|
45
47
|
export * from "./components/FlipCard";
|
|
46
48
|
export * from "./components/FormField";
|
|
49
|
+
export * from "./components/FormSection";
|
|
50
|
+
export * from "./components/Gallery";
|
|
47
51
|
export * from "./components/Glass";
|
|
52
|
+
export * from "./components/GlassBar";
|
|
48
53
|
export * from "./components/Greeting";
|
|
49
54
|
export * from "./components/Heading";
|
|
50
55
|
export * from "./components/Icon";
|
|
@@ -53,6 +58,7 @@ export * from "./components/Input";
|
|
|
53
58
|
export * from "./components/InputGroup";
|
|
54
59
|
export * from "./components/LanguageSelect";
|
|
55
60
|
export * from "./components/LauncherCard";
|
|
61
|
+
export * from "./components/LazyImage";
|
|
56
62
|
export * from "./components/Lightbox";
|
|
57
63
|
export * from "./components/LinkArrow";
|
|
58
64
|
export * from "./components/LinkRow";
|
|
@@ -64,7 +70,9 @@ export * from "./components/MediaRow";
|
|
|
64
70
|
export * from "./components/MediaThumb";
|
|
65
71
|
export * from "./components/Message";
|
|
66
72
|
export * from "./components/MessageThread";
|
|
73
|
+
export * from "./components/MeterRow";
|
|
67
74
|
export * from "./components/Modal";
|
|
75
|
+
export * from "./components/NameCard";
|
|
68
76
|
export * from "./components/Navmenu";
|
|
69
77
|
export * from "./components/NextTeaser";
|
|
70
78
|
export * from "./components/NumberInput";
|
|
@@ -75,12 +83,16 @@ export * from "./components/PasswordInput";
|
|
|
75
83
|
export * from "./components/PasswordRequirements";
|
|
76
84
|
export * from "./components/PhoneInput";
|
|
77
85
|
export * from "./components/Popover";
|
|
86
|
+
export * from "./components/Price";
|
|
78
87
|
export * from "./components/PriceCard";
|
|
79
88
|
export * from "./components/ProductMark";
|
|
80
89
|
export * from "./components/Progress";
|
|
81
90
|
export * from "./components/ProjectCard";
|
|
82
91
|
export * from "./components/Prose";
|
|
92
|
+
export * from "./components/Quantity";
|
|
93
|
+
export * from "./components/Rating";
|
|
83
94
|
export * from "./components/RecoveryCodes";
|
|
95
|
+
export * from "./components/Ribbon";
|
|
84
96
|
export * from "./components/Segmented";
|
|
85
97
|
export * from "./components/Select";
|
|
86
98
|
export * from "./components/SeverityCard";
|
|
@@ -88,11 +100,13 @@ export * from "./components/Sheet";
|
|
|
88
100
|
export * from "./components/Sidebar";
|
|
89
101
|
export * from "./components/Skeleton";
|
|
90
102
|
export * from "./components/SortableRow";
|
|
103
|
+
export * from "./components/SpecList";
|
|
91
104
|
export * from "./components/StackCard";
|
|
92
105
|
export * from "./components/StackList";
|
|
93
106
|
export * from "./components/StatCard";
|
|
94
107
|
export * from "./components/StatusDot";
|
|
95
108
|
export * from "./components/Stepper";
|
|
109
|
+
export * from "./components/Stock";
|
|
96
110
|
export * from "./components/Switch";
|
|
97
111
|
export * from "./components/Table";
|
|
98
112
|
export * from "./components/TableActions";
|
package/src/utils.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function attrs(record: Record<string, string | undefined>): object {
|
|
|
19
19
|
|
|
20
20
|
export type Slot = ReactNode;
|
|
21
21
|
|
|
22
|
-
/** "
|
|
22
|
+
/** "Contact 1" -> "AL": the first letters of the first two words (as the Blade components do). */
|
|
23
23
|
export function initialsOf(name: unknown): string {
|
|
24
24
|
return typeof name === "string"
|
|
25
25
|
? name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("")
|