@birdycom/omniaui-react 0.12.4 → 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.
Files changed (38) hide show
  1. package/dist/index.d.ts +329 -39
  2. package/dist/index.js +2647 -1723
  3. package/manifest.json +90 -6
  4. package/package.json +1 -1
  5. package/src/blocks/BlockAppHeader.tsx +1 -1
  6. package/src/blocks/BlockAppShell.tsx +1 -1
  7. package/src/blocks/BlockProfileHeader.tsx +20 -3
  8. package/src/components/Attachment.tsx +1 -1
  9. package/src/components/AvatarPicker.tsx +68 -0
  10. package/src/components/Barcode.tsx +42 -0
  11. package/src/components/Breadcrumb.tsx +8 -4
  12. package/src/components/Button.tsx +22 -7
  13. package/src/components/ChatComposer.tsx +1 -1
  14. package/src/components/DataTable.tsx +182 -33
  15. package/src/components/DateField.tsx +41 -0
  16. package/src/components/EmojiPicker.tsx +2 -2
  17. package/src/components/FormSection.tsx +32 -0
  18. package/src/components/Gallery.tsx +56 -0
  19. package/src/components/Glass.tsx +5 -3
  20. package/src/components/GlassBar.tsx +42 -0
  21. package/src/components/Icon.tsx +9 -14
  22. package/src/components/LanguageSelect.tsx +1 -4
  23. package/src/components/LazyImage.tsx +45 -0
  24. package/src/components/Message.tsx +12 -11
  25. package/src/components/MessageThread.tsx +11 -11
  26. package/src/components/MeterRow.tsx +38 -0
  27. package/src/components/NameCard.tsx +59 -0
  28. package/src/components/Price.tsx +45 -0
  29. package/src/components/Prose.tsx +3 -3
  30. package/src/components/Quantity.tsx +55 -0
  31. package/src/components/Rating.tsx +69 -0
  32. package/src/components/Ribbon.tsx +30 -0
  33. package/src/components/SpecList.tsx +36 -0
  34. package/src/components/Stock.tsx +28 -0
  35. package/src/components/User.tsx +3 -3
  36. package/src/index.ts +15 -1
  37. package/src/utils.ts +7 -0
  38. package/src/components/ColumnMenu.tsx +0 -66
@@ -0,0 +1,45 @@
1
+ // File: packages/react/src/components/LazyImage.tsx | Map: props -> figure.oc-lazy (ratio, preview) -> img loading=lazy -> loaded / error state
2
+ // Same markup as packages/ui/snippets/lazy.html.
3
+ import { forwardRef, useState } from "react";
4
+ import type { CSSProperties, HTMLAttributes, ImgHTMLAttributes, ReactNode } from "react";
5
+ import { cx } from "../utils";
6
+
7
+ export interface LazyImageProps extends HTMLAttributes<HTMLElement> {
8
+ src: string;
9
+ alt: string;
10
+ width?: number;
11
+ height?: number;
12
+ /** Aspect ratio of the box, e.g. "16 / 10" (default) or "1". */
13
+ ratio?: string;
14
+ /** URL of a tiny copy, shown blurred until the photo is there (instead of the shimmer). */
15
+ preview?: string;
16
+ caption?: ReactNode;
17
+ /** "lazy" (default) or "eager" for images in the first screen. */
18
+ loading?: "lazy" | "eager";
19
+ imgProps?: ImgHTMLAttributes<HTMLImageElement>;
20
+ }
21
+
22
+ /** `.oc-lazy`: an image that keeps its place, shows a placeholder and fades in once loaded. */
23
+ export const LazyImage = forwardRef<HTMLElement, LazyImageProps>(function LazyImage(
24
+ { src, alt, width, height, ratio, preview, caption, loading = "lazy", imgProps, className, style, ...rest },
25
+ ref,
26
+ ) {
27
+ const [state, setState] = useState<"" | "is-loaded" | "is-error">("");
28
+ const vars = { ...(ratio ? { "--oc-lazy-ratio": ratio } : {}), ...(preview ? { "--oc-lazy-preview": `url(${preview})` } : {}), ...style } as CSSProperties;
29
+ return (
30
+ <figure ref={ref} className={cx("oc-lazy", state, className)} style={vars} {...rest}>
31
+ <img
32
+ src={src}
33
+ alt={alt}
34
+ width={width}
35
+ height={height}
36
+ loading={loading}
37
+ decoding="async"
38
+ onLoad={() => setState("is-loaded")}
39
+ onError={() => setState("is-error")}
40
+ {...imgProps}
41
+ />
42
+ {caption != null && <figcaption>{caption}</figcaption>}
43
+ </figure>
44
+ );
45
+ });
@@ -43,15 +43,16 @@ export interface MessageData {
43
43
  text?: ReactNode;
44
44
  attachments?: MessageAttachment[];
45
45
  actions?: MessageAction[];
46
- /** aria-label of the row, e.g. "Lisa Verbeke, 09:12". */
46
+ /** aria-label of the row, e.g. "Customer 1, 09:12". */
47
47
  label?: string;
48
48
  id?: string;
49
49
  }
50
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>
51
+ /** A webicon (MDI path data, 24x24). */
52
+ const svg = (d: string) => (
53
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={d} /></svg>
53
54
  );
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 ICONS: Record<string, string> = { reply: "M8 9.8v.9l1.7.3c2.6.4 4.5 1.4 5.9 2.7c-1.7-.5-3.5-.8-5.6-.8H8v1.3L5.8 12zM10 5l-7 7l7 7v-4.1c5 0 8.5 1.6 11 5.1c-1-5-4-10-11-11", delete: "M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3zM7 6h10v13H7zm2 2v9h2V8zm4 0v9h2V8z" };
55
56
  const STATUS = { sent: "Sent", read: "Read", failed: "Failed" };
56
57
 
57
58
  /** `.oc-message__attachments`: thumbnails share one `.oc-attachment-list`, files and images stand alone. */
@@ -93,7 +94,7 @@ export const Message = forwardRef<HTMLElement, MessageProps>(function Message(
93
94
  {time != null && <time dateTime={datetime}>{time}</time>}
94
95
  {status && (
95
96
  <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
+ {statusIcon && svg("M.41 13.41L6 19l1.41-1.42L1.83 12m20.41-6.42L11.66 16.17L7.5 12l-1.43 1.41L11.66 19l12-12M18 7l-1.41-1.42l-6.35 6.35l1.42 1.41z")}{statusLabel ?? STATUS[status]}
97
98
  </span>
98
99
  )}
99
100
  </p>
@@ -134,9 +135,9 @@ export interface MessageListProps extends HTMLAttributes<HTMLDivElement> {
134
135
 
135
136
  const DEMO: MessageListItem[] = [
136
137
  { 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
+ { 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." },
138
139
  {
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
+ 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",
140
141
  text: (
141
142
  <>
142
143
  <p>Which finish should we order?</p>
@@ -146,17 +147,17 @@ const DEMO: MessageListItem[] = [
146
147
  ),
147
148
  },
148
149
  {
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" },
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" },
151
152
  text: "Calacatta matt. The client wants the lighter look next to the oak fronts.",
152
153
  actions: [{ name: "reply", label: "Reply to this message" }, { name: "delete", label: "Delete this message", destructive: true }],
153
154
  },
154
- { divider: "Lisa Verbeke marked this question as answered", system: true },
155
+ { divider: "Customer 1 marked this question as answered", system: true },
155
156
  ];
156
157
 
157
158
  /** `.oc-message-list`: column of messages and dividers. */
158
159
  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
+ { items = DEMO, log = true, onAction, className, children, "aria-label": label = log ? "Conversation with Customer 1" : undefined, ...rest },
160
161
  ref,
161
162
  ) {
162
163
  return (
@@ -63,22 +63,22 @@ const MENU: ThreadMenuItem[] = [
63
63
  ];
64
64
  const DEMO: MessageListItem[] = [
65
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.",
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: "Lisa Verbeke", avatar: "LV", time: "09:13", datetime: "2026-10-14T09:13", continued: true,
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: "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 }] },
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: "Lisa Verbeke", avatar: "LV", time: "10:02", datetime: "2026-10-24T10:02", text: "Client signed. Can you confirm the edge profile before 16:00?" },
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 = "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" },
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
@@ -132,7 +132,7 @@ export const MessageThread = forwardRef<HTMLElement, MessageThreadProps>(functio
132
132
  {menu && (
133
133
  <>
134
134
  <button type="button" className="oc-message-thread__icon-btn" 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>
135
+ <svg data-icon="mdi:dots-vertical" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2"/></svg>
136
136
  </button>
137
137
  <div className="oc-dropdown-menu" id={`${base}-menu`} {...attrs({ popover: "" })} role="menu">
138
138
  {menu.map((m, i) => "separator" in m
@@ -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")}>&#128206;</button>}
159
- {tools.includes("emoji") && <button type="button" className="oc-chat-composer__tool" aria-label="Insert emoji" onClick={() => onTool?.("emoji")}>&#128578;</button>}
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">&#10148;</button>
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
+ });
@@ -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>Villa Dewitte: oak fronts with a Calacatta matt worktop, finished in October 2026.</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>— Ann Dewitte, client in Ghent</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 Villa Dewitte\nRoom: Kitchen, ground floor\nWall A: 412 cm (window at 160 cm)\nWall B: 298 cm (door at the left)"}</code></pre>
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
+ });
@@ -1,13 +1,13 @@
1
1
  import { forwardRef } from "react";
2
2
  import type { HTMLAttributes, ReactNode } from "react";
3
- import { cx } from "../utils";
3
+ import { cx, initialsOf } from "../utils";
4
4
  import { Avatar } from "./Avatar";
5
5
  import type { AvatarSize } from "./Avatar";
6
6
 
7
7
  export interface UserProps extends HTMLAttributes<HTMLDivElement> {
8
8
  name: ReactNode;
9
9
  email?: ReactNode;
10
- /** Initials shown in the avatar. */
10
+ /** Initials shown in the avatar (default: from `name` when it is a string). */
11
11
  initials?: string;
12
12
  /** Avatar image URL (wins over initials). */
13
13
  avatarSrc?: string;
@@ -21,7 +21,7 @@ export const User = forwardRef<HTMLDivElement, UserProps>(function User(
21
21
  ) {
22
22
  return (
23
23
  <div ref={ref} className={cx("oc-user", className)} {...rest}>
24
- <Avatar size={avatarSize} src={avatarSrc}>{initials}</Avatar>
24
+ <Avatar size={avatarSize} src={avatarSrc}>{initials ?? initialsOf(name)}</Avatar>
25
25
  <span className="oc-user-text">
26
26
  <span className="oc-user-name">{name}</span>
27
27
  {email != null && <span className="oc-user-email">{email}</span>}