@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,43 @@
|
|
|
1
|
+
// File: packages/react/src/components/PageHeader.tsx
|
|
2
|
+
// Map: PageHeader = header -> back row -> main (text: title, subtitle, meta | actions)
|
|
3
|
+
// The header is a size container: the actions move next to the title from a 36rem header width.
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
6
|
+
import { cx } from "../utils";
|
|
7
|
+
|
|
8
|
+
export interface PageHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
subtitle?: ReactNode;
|
|
11
|
+
/** Meta row items (owner, updated, ...). */
|
|
12
|
+
meta?: ReactNode;
|
|
13
|
+
/** Back link, e.g. `<BackLink href="/projects">Projects</BackLink>`. */
|
|
14
|
+
back?: ReactNode;
|
|
15
|
+
actions?: ReactNode;
|
|
16
|
+
/** Heading level of the title (default 1). */
|
|
17
|
+
level?: 1 | 2 | 3;
|
|
18
|
+
bordered?: boolean;
|
|
19
|
+
sticky?: boolean;
|
|
20
|
+
size?: "sm";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `.oc-page-header`: title row of an app page. */
|
|
24
|
+
export const PageHeader = forwardRef<HTMLElement, PageHeaderProps>(function PageHeader(
|
|
25
|
+
{ title, subtitle, meta, back, actions, level = 1, bordered, sticky, size, className, ...rest },
|
|
26
|
+
ref,
|
|
27
|
+
) {
|
|
28
|
+
const Heading = `h${level}` as "h1";
|
|
29
|
+
const cls = cx("oc-page-header", bordered && "oc-page-header--bordered", sticky && "oc-page-header--sticky", size && `oc-page-header--${size}`, className);
|
|
30
|
+
return (
|
|
31
|
+
<header ref={ref} className={cls} {...rest}>
|
|
32
|
+
{back != null && <div className="oc-page-header-back">{back}</div>}
|
|
33
|
+
<div className="oc-page-header-main">
|
|
34
|
+
<div className="oc-page-header-text">
|
|
35
|
+
<Heading className="oc-page-header-title">{title}</Heading>
|
|
36
|
+
{subtitle != null && <p className="oc-page-header-subtitle">{subtitle}</p>}
|
|
37
|
+
{meta != null && <div className="oc-page-header-meta">{meta}</div>}
|
|
38
|
+
</div>
|
|
39
|
+
{actions != null && <div className="oc-page-header-actions">{actions}</div>}
|
|
40
|
+
</div>
|
|
41
|
+
</header>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import type { HTMLAttributes } from "react";
|
|
3
|
+
import { cx } from "../utils";
|
|
4
|
+
|
|
5
|
+
export interface PaginationProps extends Omit<HTMLAttributes<HTMLElement>, "onChange"> {
|
|
6
|
+
/** Current page (1-based). */
|
|
7
|
+
page: number;
|
|
8
|
+
pageCount: number;
|
|
9
|
+
/** Called with the new page; when set, link navigation is prevented. */
|
|
10
|
+
onPageChange?: (page: number) => void;
|
|
11
|
+
/** Builds the href of a page (default `?page=n`). */
|
|
12
|
+
hrefFor?: (page: number) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `<nav class="oc-pagination">` with numbered links and `aria-current="page"`. */
|
|
16
|
+
export const Pagination = forwardRef<HTMLElement, PaginationProps>(function Pagination(
|
|
17
|
+
{ page, pageCount, onPageChange, hrefFor = (n) => `?page=${n}`, className, "aria-label": ariaLabel = "Pagination", ...rest },
|
|
18
|
+
ref,
|
|
19
|
+
) {
|
|
20
|
+
return (
|
|
21
|
+
<nav ref={ref} className={cx("oc-pagination", className)} aria-label={ariaLabel} {...rest}>
|
|
22
|
+
{Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => (
|
|
23
|
+
<a
|
|
24
|
+
key={n}
|
|
25
|
+
className="oc-pagination-item"
|
|
26
|
+
href={hrefFor(n)}
|
|
27
|
+
aria-current={n === page ? "page" : undefined}
|
|
28
|
+
onClick={
|
|
29
|
+
onPageChange
|
|
30
|
+
? (e) => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
onPageChange(n);
|
|
33
|
+
}
|
|
34
|
+
: undefined
|
|
35
|
+
}
|
|
36
|
+
>
|
|
37
|
+
{n}
|
|
38
|
+
</a>
|
|
39
|
+
))}
|
|
40
|
+
</nav>
|
|
41
|
+
);
|
|
42
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useState } from "react";
|
|
4
|
+
import type { InputHTMLAttributes } from "react";
|
|
5
|
+
import { cx } from "../utils";
|
|
6
|
+
|
|
7
|
+
export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
8
|
+
showLabel?: string;
|
|
9
|
+
hideLabel?: string;
|
|
10
|
+
/** Accessible name of the toggle button. */
|
|
11
|
+
toggleLabel?: string;
|
|
12
|
+
error?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Password field with a show / hide button (`aria-pressed`), inside `.oc-input-reveal`. */
|
|
16
|
+
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(function PasswordInput(
|
|
17
|
+
{ showLabel = "Show", hideLabel = "Hide", toggleLabel = "Toggle password visibility", error, className, ...rest },
|
|
18
|
+
ref,
|
|
19
|
+
) {
|
|
20
|
+
const [shown, setShown] = useState(false);
|
|
21
|
+
return (
|
|
22
|
+
<div className="oc-input-reveal">
|
|
23
|
+
<input
|
|
24
|
+
ref={ref}
|
|
25
|
+
className={cx("oc-input", error && "oc-input--error", className)}
|
|
26
|
+
type={shown ? "text" : "password"}
|
|
27
|
+
autoComplete="current-password"
|
|
28
|
+
aria-invalid={error ? true : undefined}
|
|
29
|
+
{...rest}
|
|
30
|
+
/>
|
|
31
|
+
<button className="oc-input-reveal-button" type="button" aria-pressed={shown} aria-label={toggleLabel} onClick={() => setShown((s) => !s)}>
|
|
32
|
+
{shown ? hideLabel : showLabel}
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// File: packages/react/src/components/PasswordRequirements.tsx
|
|
2
|
+
// Map: rule type + default rules -> component (live list, --met per rule)
|
|
3
|
+
// Same markup as packages/ui/snippets/password-requirements.html.
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
6
|
+
import { cx } from "../utils";
|
|
7
|
+
|
|
8
|
+
export interface PasswordRule {
|
|
9
|
+
label: ReactNode;
|
|
10
|
+
/** True when the password meets the rule. */
|
|
11
|
+
test: (password: string) => boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Common rules: length, uppercase, number, symbol. Pass your own list to change them. */
|
|
15
|
+
export const defaultPasswordRules: PasswordRule[] = [
|
|
16
|
+
{ label: "At least 8 characters", test: (p) => p.length >= 8 },
|
|
17
|
+
{ label: "One uppercase letter", test: (p) => /[A-Z]/.test(p) },
|
|
18
|
+
{ label: "One number", test: (p) => /[0-9]/.test(p) },
|
|
19
|
+
{ label: "One symbol", test: (p) => /[^A-Za-z0-9]/.test(p) },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export interface PasswordRequirementsProps extends HTMLAttributes<HTMLUListElement> {
|
|
23
|
+
password: string;
|
|
24
|
+
rules?: PasswordRule[];
|
|
25
|
+
/** Rules side by side. */
|
|
26
|
+
inline?: boolean;
|
|
27
|
+
/** Render nothing until the user types. */
|
|
28
|
+
hideWhenEmpty?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `.oc-password-requirements`: each rule turns green (--met) as soon as it passes. */
|
|
32
|
+
export const PasswordRequirements = forwardRef<HTMLUListElement, PasswordRequirementsProps>(function PasswordRequirements(
|
|
33
|
+
{ password, rules = defaultPasswordRules, inline, hideWhenEmpty, className, ...rest },
|
|
34
|
+
ref,
|
|
35
|
+
) {
|
|
36
|
+
if (hideWhenEmpty && !password) return null;
|
|
37
|
+
return (
|
|
38
|
+
<ul ref={ref} className={cx("oc-password-requirements", inline && "oc-password-requirements--inline", className)} aria-live="polite" {...rest}>
|
|
39
|
+
{rules.map((rule, i) => (
|
|
40
|
+
<li key={i} className={cx("oc-password-requirement", rule.test(password) && "oc-password-requirement--met")}>
|
|
41
|
+
{rule.label}
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
);
|
|
46
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// File: packages/react/src/components/PhoneInput.tsx
|
|
2
|
+
// Map: types -> helpers (split value, group digits) -> component (dial select + tel input)
|
|
3
|
+
// Same markup as packages/ui/snippets/phone-input.html. The only JS: digit grouping and joining dial + number.
|
|
4
|
+
"use client";
|
|
5
|
+
|
|
6
|
+
import { forwardRef, useState } from "react";
|
|
7
|
+
import type { InputHTMLAttributes } from "react";
|
|
8
|
+
import { cx } from "../utils";
|
|
9
|
+
import { useControllable } from "../hooks";
|
|
10
|
+
|
|
11
|
+
export interface PhoneCountry {
|
|
12
|
+
/** Dial code with plus sign, e.g. "+32". */
|
|
13
|
+
dial: string;
|
|
14
|
+
/** Text in the select, e.g. "BE +32". */
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "size"> {
|
|
19
|
+
/** Full value without spaces, e.g. "+32470123456" (empty string = no number). */
|
|
20
|
+
value?: string;
|
|
21
|
+
defaultValue?: string;
|
|
22
|
+
onChange?: (value: string) => void;
|
|
23
|
+
countries?: PhoneCountry[];
|
|
24
|
+
/** Dial code used while the value is empty. */
|
|
25
|
+
defaultDial?: string;
|
|
26
|
+
size?: "sm" | "md";
|
|
27
|
+
error?: boolean;
|
|
28
|
+
/** Accessible name of the dial code select. */
|
|
29
|
+
dialLabel?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_COUNTRIES: PhoneCountry[] = [
|
|
33
|
+
{ dial: "+32", label: "BE +32" },
|
|
34
|
+
{ dial: "+31", label: "NL +31" },
|
|
35
|
+
{ dial: "+33", label: "FR +33" },
|
|
36
|
+
{ dial: "+49", label: "DE +49" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** "470123456" -> "470 12 34 56": a group of 3, then pairs (readable for most European numbers). */
|
|
40
|
+
export function formatPhoneNational(digits: string): string {
|
|
41
|
+
const clean = digits.replace(/\D/g, "");
|
|
42
|
+
const head = clean.slice(0, 3);
|
|
43
|
+
const pairs = clean.slice(3).match(/.{1,2}/g) ?? [];
|
|
44
|
+
return [head, ...pairs].filter(Boolean).join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Split "+32470..." into the known dial code (longest match) and the national digits. */
|
|
48
|
+
function splitPhone(full: string, countries: PhoneCountry[]) {
|
|
49
|
+
const match = [...countries].sort((a, b) => b.dial.length - a.dial.length).find((c) => full.startsWith(c.dial));
|
|
50
|
+
return match ? { dial: match.dial, national: full.slice(match.dial.length) } : { dial: "", national: full.replace(/^\+/, "") };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `.oc-phone-input`: country dial code + national number. Emits one value: dial + digits. */
|
|
54
|
+
export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(
|
|
55
|
+
{ value, defaultValue, onChange, countries = DEFAULT_COUNTRIES, defaultDial, size = "md", error, dialLabel = "Country code", className, disabled, ...rest },
|
|
56
|
+
ref,
|
|
57
|
+
) {
|
|
58
|
+
const [full, setFull] = useControllable<string>(value, defaultValue ?? "", onChange);
|
|
59
|
+
const parsed = splitPhone(full, countries);
|
|
60
|
+
// Remember the chosen dial code, also while the number is still empty.
|
|
61
|
+
const [dialState, setDialState] = useState(parsed.dial || defaultDial || countries[0]?.dial || "");
|
|
62
|
+
const dial = parsed.dial || dialState;
|
|
63
|
+
|
|
64
|
+
const emit = (nextDial: string, national: string) => {
|
|
65
|
+
setDialState(nextDial);
|
|
66
|
+
const digits = national.replace(/\D/g, "");
|
|
67
|
+
setFull(digits ? nextDial + digits : "");
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className={cx("oc-phone-input", size === "sm" && "oc-phone-input--sm", error && "oc-phone-input--error", className)}>
|
|
72
|
+
<select className="oc-phone-input-dial" aria-label={dialLabel} value={dial} disabled={disabled} onChange={(e) => emit(e.target.value, parsed.national)}>
|
|
73
|
+
{countries.map((c) => (
|
|
74
|
+
<option key={c.dial} value={c.dial}>
|
|
75
|
+
{c.label}
|
|
76
|
+
</option>
|
|
77
|
+
))}
|
|
78
|
+
</select>
|
|
79
|
+
<input
|
|
80
|
+
ref={ref}
|
|
81
|
+
className="oc-phone-input-number"
|
|
82
|
+
type="tel"
|
|
83
|
+
inputMode="tel"
|
|
84
|
+
autoComplete="tel-national"
|
|
85
|
+
aria-invalid={error ? true : undefined}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
value={formatPhoneNational(parsed.national)}
|
|
88
|
+
onChange={(e) => emit(dial, e.target.value)}
|
|
89
|
+
{...rest}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { forwardRef, useId } from "react";
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
3
|
+
import { attrs, cx } from "../utils";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import type { ButtonProps } from "./Button";
|
|
6
|
+
|
|
7
|
+
export interface PopoverProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
8
|
+
/** Content of the trigger button. */
|
|
9
|
+
trigger: ReactNode;
|
|
10
|
+
triggerProps?: ButtonProps;
|
|
11
|
+
size?: "sm" | "md" | "lg";
|
|
12
|
+
title?: ReactNode;
|
|
13
|
+
/** Short body text (`oc-popover-text`). */
|
|
14
|
+
text?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Trigger + `<div class="oc-popover" popover>` floating panel. */
|
|
18
|
+
export const Popover = forwardRef<HTMLDivElement, PopoverProps>(function Popover(
|
|
19
|
+
{ trigger, triggerProps, size = "md", title, text, className, id, children, ...rest },
|
|
20
|
+
ref,
|
|
21
|
+
) {
|
|
22
|
+
const auto = useId();
|
|
23
|
+
const panelId = id ?? `oc-popover-${auto.replace(/:/g, "")}`;
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<Button variant="outline" type="button" {...triggerProps} {...attrs({ popovertarget: panelId })}>{trigger}</Button>
|
|
27
|
+
<div ref={ref} className={cx("oc-popover", size !== "md" && `oc-popover--${size}`, className)} id={panelId} {...attrs({ popover: "" })} {...rest}>
|
|
28
|
+
{title != null && <h3 className="oc-popover-title">{title}</h3>}
|
|
29
|
+
{text != null && <p className="oc-popover-text">{text}</p>}
|
|
30
|
+
{children}
|
|
31
|
+
</div>
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// File: packages/react/src/components/PriceCard.tsx | Map: types -> PriceCard (head -> toggle (radios) -> prices -> features -> cta)
|
|
2
|
+
import { forwardRef, useId } from "react";
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
export interface PriceCardFeature {
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
/** Shown with a cross and muted text. */
|
|
9
|
+
excluded?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PriceCardPrice {
|
|
13
|
+
amount: ReactNode;
|
|
14
|
+
period?: ReactNode;
|
|
15
|
+
/** Old price (struck through). */
|
|
16
|
+
was?: ReactNode;
|
|
17
|
+
note?: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PriceCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
21
|
+
name: ReactNode;
|
|
22
|
+
description?: ReactNode;
|
|
23
|
+
/** Badge such as "Save 20%". */
|
|
24
|
+
discount?: ReactNode;
|
|
25
|
+
monthly: PriceCardPrice;
|
|
26
|
+
/** Without a yearly price the toggle is not rendered. */
|
|
27
|
+
yearly?: PriceCardPrice;
|
|
28
|
+
/** Period that is selected first (uncontrolled radio). */
|
|
29
|
+
defaultPeriod?: "monthly" | "yearly";
|
|
30
|
+
features?: PriceCardFeature[];
|
|
31
|
+
/** Call to action, e.g. <a className="oc-btn oc-price-card__cta">. */
|
|
32
|
+
cta?: ReactNode;
|
|
33
|
+
featured?: boolean;
|
|
34
|
+
/** Labels of the toggle. */
|
|
35
|
+
monthlyLabel?: ReactNode;
|
|
36
|
+
yearlyLabel?: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Price({ kind, price }: { kind: "monthly" | "yearly"; price: PriceCardPrice }) {
|
|
40
|
+
return (
|
|
41
|
+
<p className={`oc-price-card__price oc-price-card__price--${kind}`}>
|
|
42
|
+
<span className="oc-price-card__amount">{price.amount}</span>
|
|
43
|
+
{price.period != null && <span className="oc-price-card__period">{price.period}</span>}
|
|
44
|
+
{price.was != null && <s className="oc-price-card__was">{price.was}</s>}
|
|
45
|
+
{price.note != null && <span className="oc-price-card__note">{price.note}</span>}
|
|
46
|
+
</p>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `<article class="oc-price-card">`: plan card with a CSS-only monthly / yearly switch. */
|
|
51
|
+
export const PriceCard = forwardRef<HTMLElement, PriceCardProps>(function PriceCard(
|
|
52
|
+
{ name, description, discount, monthly, yearly, defaultPeriod = "monthly", features = [], cta, featured = false,
|
|
53
|
+
monthlyLabel = "Monthly", yearlyLabel = "Yearly", className, ...rest },
|
|
54
|
+
ref,
|
|
55
|
+
) {
|
|
56
|
+
// Every card needs its own radio group name.
|
|
57
|
+
const group = `oc-price-${useId().replace(/[^\w-]/g, "")}`;
|
|
58
|
+
return (
|
|
59
|
+
<article ref={ref} className={cx("oc-price-card", featured && "oc-price-card--featured", className)} {...rest}>
|
|
60
|
+
<div className="oc-price-card__head">
|
|
61
|
+
<h3 className="oc-price-card__name">{name}</h3>
|
|
62
|
+
{discount != null && <span className="oc-price-card__discount">{discount}</span>}
|
|
63
|
+
</div>
|
|
64
|
+
{description != null && <p className="oc-price-card__desc">{description}</p>}
|
|
65
|
+
{yearly && (
|
|
66
|
+
<div className="oc-segmented oc-price-card__toggle" role="radiogroup" aria-label="Billing period">
|
|
67
|
+
<label className="oc-segmented-item"><input type="radio" name={group} value="monthly" defaultChecked={defaultPeriod === "monthly"} /> {monthlyLabel}</label>
|
|
68
|
+
<label className="oc-segmented-item"><input type="radio" name={group} value="yearly" defaultChecked={defaultPeriod === "yearly"} /> {yearlyLabel}</label>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
<Price kind="monthly" price={monthly} />
|
|
72
|
+
{yearly && <Price kind="yearly" price={yearly} />}
|
|
73
|
+
{features.length > 0 && (
|
|
74
|
+
<ul className="oc-price-card__features">
|
|
75
|
+
{features.map((f, i) => (
|
|
76
|
+
<li key={i} className={cx("oc-price-card__feature", f.excluded && "oc-price-card__feature--excluded")}>{f.label}</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
)}
|
|
80
|
+
{cta}
|
|
81
|
+
</article>
|
|
82
|
+
);
|
|
83
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// File: packages/react/src/components/ProductMark.tsx | Map: types -> ProductMark (span.oc-product-mark + modifiers)
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import type { HTMLAttributes } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
export type ProductMarkSize = "sm" | "default" | "lg" | "xl";
|
|
7
|
+
export type ProductMarkAccent = "blue" | "cyan" | "green" | "orange" | "red" | "violet" | "yellow";
|
|
8
|
+
|
|
9
|
+
export interface ProductMarkProps extends HTMLAttributes<HTMLSpanElement> {
|
|
10
|
+
size?: ProductMarkSize;
|
|
11
|
+
/** Accent preset (adds .oc-accent--{accent}). */
|
|
12
|
+
accent?: ProductMarkAccent;
|
|
13
|
+
/** Tinted background with an accent glyph. */
|
|
14
|
+
soft?: boolean;
|
|
15
|
+
glow?: boolean;
|
|
16
|
+
/** Full-bleed image inside. */
|
|
17
|
+
cover?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `<span class="oc-product-mark">`: squircle app-icon tile. Decorative by default (aria-hidden). */
|
|
21
|
+
export const ProductMark = forwardRef<HTMLSpanElement, ProductMarkProps>(function ProductMark(
|
|
22
|
+
{ size = "default", accent, soft = false, glow = false, cover = false, className, ...rest },
|
|
23
|
+
ref,
|
|
24
|
+
) {
|
|
25
|
+
const classes = cx(
|
|
26
|
+
"oc-product-mark",
|
|
27
|
+
size !== "default" && `oc-product-mark--${size}`,
|
|
28
|
+
soft && "oc-product-mark--soft",
|
|
29
|
+
glow && "oc-product-mark--glow",
|
|
30
|
+
cover && "oc-product-mark--cover",
|
|
31
|
+
accent && `oc-accent--${accent}`,
|
|
32
|
+
className,
|
|
33
|
+
);
|
|
34
|
+
return <span ref={ref} className={classes} aria-hidden="true" {...rest} />;
|
|
35
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import type { HTMLAttributes } from "react";
|
|
3
|
+
import { cx } from "../utils";
|
|
4
|
+
|
|
5
|
+
export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
value: number;
|
|
7
|
+
/** Maximum (default 100). */
|
|
8
|
+
max?: number;
|
|
9
|
+
/** Bar color. */
|
|
10
|
+
variant?: "default" | "success" | "warning" | "danger";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `<div class="oc-progress" role="progressbar">` with an `oc-progress-bar` sized in percent. */
|
|
14
|
+
export const Progress = forwardRef<HTMLDivElement, ProgressProps>(function Progress(
|
|
15
|
+
{ value, max = 100, variant = "default", className, ...rest },
|
|
16
|
+
ref,
|
|
17
|
+
) {
|
|
18
|
+
const pct = max > 0 ? Math.min(100, Math.max(0, Math.round((value / max) * 100))) : 0;
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
ref={ref}
|
|
22
|
+
className={cx("oc-progress", className)}
|
|
23
|
+
role="progressbar"
|
|
24
|
+
aria-valuenow={value}
|
|
25
|
+
aria-valuemin={0}
|
|
26
|
+
aria-valuemax={max}
|
|
27
|
+
{...rest}
|
|
28
|
+
>
|
|
29
|
+
<div className={cx("oc-progress-bar", variant !== "default" && `oc-progress-bar--${variant}`)} style={{ width: `${pct}%` }} />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// File: packages/react/src/components/ProjectCard.tsx | Map: props -> ProjectCard (a -> frame (bar + shot) -> body)
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import type { AnchorHTMLAttributes, ReactNode } from "react";
|
|
4
|
+
import { cx } from "../utils";
|
|
5
|
+
|
|
6
|
+
// The native anchor "type" (MIME hint) is replaced by the badge text prop below.
|
|
7
|
+
export interface ProjectCardProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "type"> {
|
|
8
|
+
name: ReactNode;
|
|
9
|
+
/** Badge text ("Website"). */
|
|
10
|
+
type?: ReactNode;
|
|
11
|
+
/** Sector / location line. */
|
|
12
|
+
meta?: ReactNode;
|
|
13
|
+
/** Text of the arrow link (default "View project"). */
|
|
14
|
+
more?: ReactNode;
|
|
15
|
+
/** Screenshot URL; without it the preview is an accent gradient. */
|
|
16
|
+
image?: string;
|
|
17
|
+
/** Accessible description of the screenshot. */
|
|
18
|
+
imageAlt?: string;
|
|
19
|
+
/** Edge-to-edge preview. */
|
|
20
|
+
flat?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `<a class="oc-project-card">`: portfolio card with a browser-like preview. */
|
|
24
|
+
export const ProjectCard = forwardRef<HTMLAnchorElement, ProjectCardProps>(function ProjectCard(
|
|
25
|
+
{ name, type, meta, more = "View project", image, imageAlt = "", flat = false, className, ...rest },
|
|
26
|
+
ref,
|
|
27
|
+
) {
|
|
28
|
+
return (
|
|
29
|
+
<a ref={ref} className={cx("oc-project-card", flat && "oc-project-card--flat", className)} {...rest}>
|
|
30
|
+
<span className="oc-project-card__frame">
|
|
31
|
+
<span className="oc-project-card__bar" aria-hidden="true"><i /><i /><i /></span>
|
|
32
|
+
{image ? (
|
|
33
|
+
<img className="oc-project-card__shot" src={image} alt={imageAlt} loading="lazy" />
|
|
34
|
+
) : (
|
|
35
|
+
<span className="oc-project-card__shot" role="img" aria-label={imageAlt || undefined} aria-hidden={imageAlt ? undefined : true} />
|
|
36
|
+
)}
|
|
37
|
+
</span>
|
|
38
|
+
<span className="oc-project-card__body">
|
|
39
|
+
{type != null && <span className="oc-project-card__type">{type}</span>}
|
|
40
|
+
<span className="oc-project-card__name">{name}</span>
|
|
41
|
+
{meta != null && <span className="oc-project-card__meta">{meta}</span>}
|
|
42
|
+
{more != null && <span className="oc-project-card__more">{more}</span>}
|
|
43
|
+
</span>
|
|
44
|
+
</a>
|
|
45
|
+
);
|
|
46
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// File: packages/react/src/components/Prose.tsx
|
|
2
|
+
// Map: types -> demo article (same text as the snippet) -> Prose (wrapper with size modifiers) -> ProseLead
|
|
3
|
+
// Same markup as packages/ui/snippets/prose.html. Put plain HTML (h1-h4, p, lists, blockquote, table, figure, pre) inside.
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
6
|
+
import { cx } from "../utils";
|
|
7
|
+
|
|
8
|
+
export interface ProseProps extends HTMLAttributes<HTMLElement> {
|
|
9
|
+
/** All spacing scales along. */
|
|
10
|
+
size?: "sm" | "md" | "lg";
|
|
11
|
+
/** No max width (default 68ch). */
|
|
12
|
+
full?: boolean;
|
|
13
|
+
as?: "article" | "div" | "section";
|
|
14
|
+
/** The text; the snippet's demo article when empty. */
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `p.oc-prose-lead`: larger intro paragraph. */
|
|
19
|
+
export function ProseLead({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
|
20
|
+
return <p className={cx("oc-prose-lead", className)} {...rest} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEMO = (
|
|
24
|
+
<>
|
|
25
|
+
<h1>Planning a kitchen renovation</h1>
|
|
26
|
+
<ProseLead>A new kitchen takes eight to twelve weeks from the first visit to the last cabinet handle. This guide explains every step, so you know what to expect and when we need you.</ProseLead>
|
|
27
|
+
<h2>Before the first visit</h2>
|
|
28
|
+
<p>Collect a few photos of kitchens you like and measure the room roughly. Exact measurements are not needed yet: our site manager does that during the visit. Read the <a href="#checklist">preparation checklist</a> if you want to be fully ready.</p>
|
|
29
|
+
<ul>
|
|
30
|
+
<li>The <strong>budget range</strong> you have in mind, including appliances</li>
|
|
31
|
+
<li>Which walls, doors and windows can move, and which cannot
|
|
32
|
+
<ul><li>Load-bearing walls need a structural engineer</li><li>Moving a window needs a permit in most towns</li></ul>
|
|
33
|
+
</li>
|
|
34
|
+
<li>Appliances you want to keep</li>
|
|
35
|
+
</ul>
|
|
36
|
+
<figure>
|
|
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>
|
|
39
|
+
</figure>
|
|
40
|
+
<h3>From design to installation</h3>
|
|
41
|
+
<ol>
|
|
42
|
+
<li>Site visit and measurements (week 1)</li>
|
|
43
|
+
<li>Design and quote, with two revision rounds (weeks 2–3)</li>
|
|
44
|
+
<li>Order and production of the cabinets (weeks 4–9)</li>
|
|
45
|
+
<li>Installation, worktop and finishing (weeks 10–11)</li>
|
|
46
|
+
</ol>
|
|
47
|
+
<blockquote>
|
|
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>
|
|
50
|
+
</blockquote>
|
|
51
|
+
<h2>Worktop materials compared</h2>
|
|
52
|
+
<table>
|
|
53
|
+
<caption>Prices per running metre, 60 cm deep, installed.</caption>
|
|
54
|
+
<thead><tr><th>Material</th><th>Thickness</th><th>Heat resistant</th><th>Price</th></tr></thead>
|
|
55
|
+
<tbody>
|
|
56
|
+
<tr><td>Compact laminate</td><td>12 mm</td><td>Up to 180 °C</td><td>€ 290</td></tr>
|
|
57
|
+
<tr><td>Quartz composite</td><td>20 mm</td><td>Up to 150 °C</td><td>€ 540</td></tr>
|
|
58
|
+
<tr><td>Natural stone</td><td>20 mm</td><td>Yes</td><td>€ 720</td></tr>
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
<h3>Sharing your floor plan</h3>
|
|
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>
|
|
64
|
+
<hr />
|
|
65
|
+
<p><small>Lead times are an estimate. <mark>Natural stone</mark> can take two weeks longer in summer.</small></p>
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
/** `.oc-prose`: typography for long-form text written without classes. */
|
|
70
|
+
export const Prose = forwardRef<HTMLElement, ProseProps>(function Prose(
|
|
71
|
+
{ size = "md", full, as: Tag = "article", className, children, ...rest },
|
|
72
|
+
ref,
|
|
73
|
+
) {
|
|
74
|
+
return (
|
|
75
|
+
<Tag ref={ref as React.Ref<HTMLElement & HTMLDivElement>} className={cx("oc-prose", size !== "md" && `oc-prose--${size}`, full && "oc-prose--full", className)} {...rest}>
|
|
76
|
+
{children ?? DEMO}
|
|
77
|
+
</Tag>
|
|
78
|
+
);
|
|
79
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
5
|
+
import { cx } from "../utils";
|
|
6
|
+
|
|
7
|
+
export interface RecoveryCodesProps extends Omit<HTMLAttributes<HTMLDivElement>, "title" | "onCopy"> {
|
|
8
|
+
codes: string[];
|
|
9
|
+
title?: ReactNode;
|
|
10
|
+
warning?: ReactNode;
|
|
11
|
+
copyLabel?: string;
|
|
12
|
+
downloadLabel?: string;
|
|
13
|
+
/** File name of the download (default `recovery-codes.txt`). */
|
|
14
|
+
fileName?: string;
|
|
15
|
+
onCopy?: (codes: string[]) => void;
|
|
16
|
+
onDownload?: (codes: string[]) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `.oc-recovery-codes`: warning panel with a code grid, "Copy all" and "Download .txt". */
|
|
20
|
+
export const RecoveryCodes = forwardRef<HTMLDivElement, RecoveryCodesProps>(function RecoveryCodes(
|
|
21
|
+
{
|
|
22
|
+
codes,
|
|
23
|
+
title = "Recovery codes",
|
|
24
|
+
warning = "Store these codes somewhere safe. Each code works once.",
|
|
25
|
+
copyLabel = "Copy all",
|
|
26
|
+
downloadLabel = "Download .txt",
|
|
27
|
+
fileName = "recovery-codes.txt",
|
|
28
|
+
onCopy,
|
|
29
|
+
onDownload,
|
|
30
|
+
className,
|
|
31
|
+
...rest
|
|
32
|
+
},
|
|
33
|
+
ref,
|
|
34
|
+
) {
|
|
35
|
+
const copy = async () => {
|
|
36
|
+
try {
|
|
37
|
+
await navigator.clipboard.writeText(codes.join("\n"));
|
|
38
|
+
onCopy?.(codes);
|
|
39
|
+
} catch {
|
|
40
|
+
/* clipboard unavailable */
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const download = () => {
|
|
44
|
+
const url = URL.createObjectURL(new Blob([codes.join("\n") + "\n"], { type: "text/plain" }));
|
|
45
|
+
const a = document.createElement("a");
|
|
46
|
+
a.href = url;
|
|
47
|
+
a.download = fileName;
|
|
48
|
+
a.click();
|
|
49
|
+
URL.revokeObjectURL(url);
|
|
50
|
+
onDownload?.(codes);
|
|
51
|
+
};
|
|
52
|
+
return (
|
|
53
|
+
<div ref={ref} className={cx("oc-recovery-codes", className)} {...rest}>
|
|
54
|
+
<h3 className="oc-recovery-codes-title">{title}</h3>
|
|
55
|
+
<p className="oc-recovery-codes-warning">{warning}</p>
|
|
56
|
+
<ul className="oc-recovery-codes-list">
|
|
57
|
+
{codes.map((code) => <li key={code}>{code}</li>)}
|
|
58
|
+
</ul>
|
|
59
|
+
<div className="oc-recovery-codes-actions">
|
|
60
|
+
<button className="oc-btn oc-btn--outline oc-btn--sm" type="button" onClick={copy}>{copyLabel}</button>
|
|
61
|
+
<button className="oc-btn oc-btn--outline oc-btn--sm" type="button" onClick={download}>{downloadLabel}</button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
});
|