@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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -2
  3. package/dist/index.d.ts +2640 -0
  4. package/dist/index.js +4813 -0
  5. package/manifest.json +838 -0
  6. package/package.json +69 -4
  7. package/src/blocks/BlockAboutTimeline.tsx +46 -0
  8. package/src/blocks/BlockAppHeader.tsx +52 -0
  9. package/src/blocks/BlockAppShell.tsx +56 -0
  10. package/src/blocks/BlockAuth.tsx +53 -0
  11. package/src/blocks/BlockAuthSplit.tsx +84 -0
  12. package/src/blocks/BlockContactGrid.tsx +71 -0
  13. package/src/blocks/BlockCta.tsx +23 -0
  14. package/src/blocks/BlockDataTable.tsx +57 -0
  15. package/src/blocks/BlockFaq.tsx +55 -0
  16. package/src/blocks/BlockFeatures.tsx +38 -0
  17. package/src/blocks/BlockFooter.tsx +50 -0
  18. package/src/blocks/BlockFormPage.tsx +26 -0
  19. package/src/blocks/BlockHero.tsx +27 -0
  20. package/src/blocks/BlockHeroCode.tsx +63 -0
  21. package/src/blocks/BlockHeroOrbit.tsx +73 -0
  22. package/src/blocks/BlockHeroSplit.tsx +28 -0
  23. package/src/blocks/BlockModuleHeader.tsx +34 -0
  24. package/src/blocks/BlockPageHero.tsx +25 -0
  25. package/src/blocks/BlockPortfolioGrid.tsx +50 -0
  26. package/src/blocks/BlockPricing.tsx +62 -0
  27. package/src/blocks/BlockProcess.tsx +50 -0
  28. package/src/blocks/BlockProductGrid.tsx +54 -0
  29. package/src/blocks/BlockProfileHeader.tsx +57 -0
  30. package/src/blocks/BlockShowcase.tsx +58 -0
  31. package/src/blocks/BlockSiteFooter.tsx +60 -0
  32. package/src/blocks/BlockSiteHeader.tsx +42 -0
  33. package/src/blocks/BlockSpotlight.tsx +58 -0
  34. package/src/blocks/BlockStats.tsx +26 -0
  35. package/src/blocks/BlockStatsCounter.tsx +31 -0
  36. package/src/blocks/BlockTeamList.tsx +48 -0
  37. package/src/blocks/BlockTwoFactor.tsx +57 -0
  38. package/src/blocks/BlockWizardDialog.tsx +101 -0
  39. package/src/components/Accordion.tsx +31 -0
  40. package/src/components/AccordionAccent.tsx +39 -0
  41. package/src/components/AffixInput.tsx +47 -0
  42. package/src/components/Alert.tsx +24 -0
  43. package/src/components/AlertDialog.tsx +60 -0
  44. package/src/components/AlphaTabs.tsx +63 -0
  45. package/src/components/Attachment.tsx +140 -0
  46. package/src/components/Avatar.tsx +32 -0
  47. package/src/components/BackLink.tsx +11 -0
  48. package/src/components/Badge.tsx +14 -0
  49. package/src/components/BarcodeInput.tsx +64 -0
  50. package/src/components/Breadcrumb.tsx +32 -0
  51. package/src/components/BrowserFrame.tsx +48 -0
  52. package/src/components/BusyOverlay.tsx +52 -0
  53. package/src/components/Button.tsx +49 -0
  54. package/src/components/Card.tsx +27 -0
  55. package/src/components/ChartLine.tsx +119 -0
  56. package/src/components/ChatComposer.tsx +73 -0
  57. package/src/components/Checkbox.tsx +14 -0
  58. package/src/components/Checklist.tsx +45 -0
  59. package/src/components/ChipFilter.tsx +60 -0
  60. package/src/components/Collapsible.tsx +43 -0
  61. package/src/components/ColorField.tsx +48 -0
  62. package/src/components/ColorSelect.tsx +49 -0
  63. package/src/components/ColumnMenu.tsx +66 -0
  64. package/src/components/Command.tsx +113 -0
  65. package/src/components/ConfirmDialog.tsx +95 -0
  66. package/src/components/CookieConsent.tsx +137 -0
  67. package/src/components/CopyField.tsx +44 -0
  68. package/src/components/Counter.tsx +21 -0
  69. package/src/components/DataTable.tsx +201 -0
  70. package/src/components/DecimalInput.tsx +87 -0
  71. package/src/components/DefinitionList.tsx +38 -0
  72. package/src/components/DocViewer.tsx +110 -0
  73. package/src/components/Dropdown.tsx +52 -0
  74. package/src/components/Dropzone.tsx +46 -0
  75. package/src/components/EmojiPicker.tsx +72 -0
  76. package/src/components/EmptyState.tsx +27 -0
  77. package/src/components/EntityCard.tsx +56 -0
  78. package/src/components/FactList.tsx +43 -0
  79. package/src/components/FilterCounts.tsx +50 -0
  80. package/src/components/FlipCard.tsx +55 -0
  81. package/src/components/FormField.tsx +54 -0
  82. package/src/components/Greeting.tsx +63 -0
  83. package/src/components/Heading.tsx +27 -0
  84. package/src/components/Icon.tsx +25 -0
  85. package/src/components/ImageZoom.tsx +51 -0
  86. package/src/components/Input.tsx +21 -0
  87. package/src/components/InputGroup.tsx +24 -0
  88. package/src/components/LanguageSelect.tsx +64 -0
  89. package/src/components/LauncherCard.tsx +50 -0
  90. package/src/components/Lightbox.tsx +83 -0
  91. package/src/components/LinkArrow.tsx +27 -0
  92. package/src/components/ListToolbar.tsx +67 -0
  93. package/src/components/Marquee.tsx +38 -0
  94. package/src/components/MediaCard.tsx +47 -0
  95. package/src/components/MediaRow.tsx +53 -0
  96. package/src/components/MediaThumb.tsx +49 -0
  97. package/src/components/Message.tsx +171 -0
  98. package/src/components/MessageThread.tsx +177 -0
  99. package/src/components/Modal.tsx +81 -0
  100. package/src/components/Navmenu.tsx +62 -0
  101. package/src/components/NextTeaser.tsx +29 -0
  102. package/src/components/NumberInput.tsx +64 -0
  103. package/src/components/OtpInput.tsx +48 -0
  104. package/src/components/PageHeader.tsx +43 -0
  105. package/src/components/Pagination.tsx +42 -0
  106. package/src/components/PasswordInput.tsx +36 -0
  107. package/src/components/PasswordRequirements.tsx +46 -0
  108. package/src/components/PhoneInput.tsx +93 -0
  109. package/src/components/Popover.tsx +34 -0
  110. package/src/components/PriceCard.tsx +83 -0
  111. package/src/components/ProductMark.tsx +35 -0
  112. package/src/components/Progress.tsx +32 -0
  113. package/src/components/ProjectCard.tsx +46 -0
  114. package/src/components/Prose.tsx +79 -0
  115. package/src/components/RecoveryCodes.tsx +65 -0
  116. package/src/components/Segmented.tsx +47 -0
  117. package/src/components/Select.tsx +27 -0
  118. package/src/components/SeverityCard.tsx +53 -0
  119. package/src/components/Sheet.tsx +62 -0
  120. package/src/components/Sidebar.tsx +145 -0
  121. package/src/components/Skeleton.tsx +12 -0
  122. package/src/components/SortableRow.tsx +46 -0
  123. package/src/components/StackCard.tsx +40 -0
  124. package/src/components/StackList.tsx +42 -0
  125. package/src/components/StatCard.tsx +49 -0
  126. package/src/components/Stepper.tsx +54 -0
  127. package/src/components/Switch.tsx +18 -0
  128. package/src/components/Table.tsx +75 -0
  129. package/src/components/TableActions.tsx +54 -0
  130. package/src/components/TableCompact.tsx +61 -0
  131. package/src/components/Tabs.tsx +45 -0
  132. package/src/components/TagInput.tsx +73 -0
  133. package/src/components/TerminalLog.tsx +63 -0
  134. package/src/components/Textarea.tsx +19 -0
  135. package/src/components/ThemeSwitch.tsx +45 -0
  136. package/src/components/TimeInput.tsx +76 -0
  137. package/src/components/Timeline.tsx +45 -0
  138. package/src/components/Toast.tsx +138 -0
  139. package/src/components/ToggleField.tsx +39 -0
  140. package/src/components/Tooltip.tsx +14 -0
  141. package/src/components/TopProgress.tsx +54 -0
  142. package/src/components/Tree.tsx +97 -0
  143. package/src/components/UpgradeBanner.tsx +62 -0
  144. package/src/components/User.tsx +31 -0
  145. package/src/components/VersionBanner.tsx +41 -0
  146. package/src/hooks.tsx +141 -0
  147. package/src/index.ts +142 -0
  148. package/src/utils.ts +25 -0
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import { createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
+ import type { HTMLAttributes, ReactNode } from "react";
5
+ import { cx } from "../utils";
6
+
7
+ export type ToastVariant = "success" | "error" | "warning" | "info";
8
+
9
+ const GLYPHS: Record<ToastVariant, string> = { success: "✓", error: "!", warning: "!", info: "i" };
10
+
11
+ export interface ToastProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
12
+ variant?: ToastVariant;
13
+ title: ReactNode;
14
+ description?: ReactNode;
15
+ /** Custom status glyph (defaults per variant). */
16
+ icon?: ReactNode;
17
+ /** Shows the dismiss button. */
18
+ onDismiss?: () => void;
19
+ dismissLabel?: string;
20
+ }
21
+
22
+ /** One `.oc-toast` notification (`role="alert"` for errors, `status` otherwise). */
23
+ export const Toast = forwardRef<HTMLDivElement, ToastProps>(function Toast(
24
+ { variant = "info", title, description, icon, onDismiss, dismissLabel = "Dismiss", className, ...rest },
25
+ ref,
26
+ ) {
27
+ return (
28
+ <div ref={ref} className={cx("oc-toast", `oc-toast--${variant}`, className)} role={variant === "error" ? "alert" : "status"} {...rest}>
29
+ <span className="oc-toast-icon" aria-hidden="true">{icon ?? GLYPHS[variant]}</span>
30
+ <div className="oc-toast-body">
31
+ <p className="oc-toast-title">{title}</p>
32
+ {description != null && <p className="oc-toast-description">{description}</p>}
33
+ </div>
34
+ {onDismiss && (
35
+ <button className="oc-toast-close" type="button" aria-label={dismissLabel} onClick={onDismiss}>&times;</button>
36
+ )}
37
+ </div>
38
+ );
39
+ });
40
+
41
+ /** `.oc-toast-container` (fixed bottom right; `top` or `static` for in-flow). */
42
+ export const ToastContainer = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement> & { top?: boolean; static?: boolean }>(function ToastContainer(
43
+ { top, static: isStatic, className, ...rest },
44
+ ref,
45
+ ) {
46
+ return (
47
+ <div
48
+ ref={ref}
49
+ className={cx("oc-toast-container", top && "oc-toast-container--top", isStatic && "oc-toast-container--static", className)}
50
+ {...rest}
51
+ />
52
+ );
53
+ });
54
+
55
+ export interface ToastOptions {
56
+ variant?: ToastVariant;
57
+ title: ReactNode;
58
+ description?: ReactNode;
59
+ /** Auto-dismiss after ms (default 5000). Use 0 to keep the toast until dismissed. */
60
+ duration?: number;
61
+ }
62
+
63
+ interface ToastEntry extends ToastOptions {
64
+ id: number;
65
+ }
66
+
67
+ interface ToastApi {
68
+ /** Queue a toast; returns its id. */
69
+ toast: (options: ToastOptions) => number;
70
+ dismiss: (id?: number) => void;
71
+ }
72
+
73
+ const ToastContext = createContext<ToastApi | null>(null);
74
+
75
+ export interface ToastProviderProps {
76
+ children?: ReactNode;
77
+ /** Stack at the top instead of the bottom. */
78
+ top?: boolean;
79
+ /** Default auto-dismiss in ms (default 5000). */
80
+ duration?: number;
81
+ /** Maximum toasts shown at once (oldest are dropped; default 5). */
82
+ limit?: number;
83
+ }
84
+
85
+ /** Holds the toast queue and renders the fixed `.oc-toast-container`. Use `useToast()` below it. */
86
+ export function ToastProvider({ children, top, duration = 5000, limit = 5 }: ToastProviderProps) {
87
+ const [items, setItems] = useState<ToastEntry[]>([]);
88
+ const nextId = useRef(1);
89
+ const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
90
+
91
+ const dismiss = useCallback((id?: number) => {
92
+ if (id === undefined) {
93
+ timers.current.forEach(clearTimeout);
94
+ timers.current.clear();
95
+ setItems([]);
96
+ return;
97
+ }
98
+ clearTimeout(timers.current.get(id));
99
+ timers.current.delete(id);
100
+ setItems((list) => list.filter((t) => t.id !== id));
101
+ }, []);
102
+
103
+ const toast = useCallback(
104
+ (options: ToastOptions) => {
105
+ const id = nextId.current++;
106
+ setItems((list) => [...list, { ...options, id }].slice(-limit));
107
+ const ms = options.duration ?? duration;
108
+ if (ms > 0) timers.current.set(id, setTimeout(() => dismiss(id), ms));
109
+ return id;
110
+ },
111
+ [dismiss, duration, limit],
112
+ );
113
+
114
+ useEffect(() => {
115
+ const map = timers.current;
116
+ return () => map.forEach(clearTimeout);
117
+ }, []);
118
+
119
+ const api = useMemo(() => ({ toast, dismiss }), [toast, dismiss]);
120
+
121
+ return (
122
+ <ToastContext.Provider value={api}>
123
+ {children}
124
+ <ToastContainer top={top} aria-live="polite">
125
+ {items.map((t) => (
126
+ <Toast key={t.id} variant={t.variant} title={t.title} description={t.description} onDismiss={() => dismiss(t.id)} />
127
+ ))}
128
+ </ToastContainer>
129
+ </ToastContext.Provider>
130
+ );
131
+ }
132
+
133
+ /** `const { toast, dismiss } = useToast()`: needs a surrounding `<ToastProvider>`. */
134
+ export function useToast(): ToastApi {
135
+ const ctx = useContext(ToastContext);
136
+ if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
137
+ return ctx;
138
+ }
@@ -0,0 +1,39 @@
1
+ // File: packages/react/src/components/ToggleField.tsx
2
+ // Map: props -> component (label row: text block + span.oc-switch with checkbox)
3
+ // Same markup as packages/ui/snippets/toggle-field.html. Works controlled (checked) or uncontrolled.
4
+ "use client";
5
+
6
+ import { forwardRef, useId } from "react";
7
+ import type { InputHTMLAttributes, ReactNode } from "react";
8
+ import { cx } from "../utils";
9
+
10
+ export interface ToggleFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "role"> {
11
+ label: ReactNode;
12
+ description?: ReactNode;
13
+ /** Bordered box. */
14
+ card?: boolean;
15
+ /** Switch first, text after it. */
16
+ reverse?: boolean;
17
+ /** Class for the row (`className` goes to the input). */
18
+ fieldClassName?: string;
19
+ }
20
+
21
+ /** `.oc-toggle-field`: the whole row is the label, so clicking the text flips the switch. */
22
+ export const ToggleField = forwardRef<HTMLInputElement, ToggleFieldProps>(function ToggleField(
23
+ { label, description, card, reverse, fieldClassName, className, ...rest },
24
+ ref,
25
+ ) {
26
+ const descId = `oc-toggle-field-${useId().replace(/:/g, "")}`;
27
+ return (
28
+ <label className={cx("oc-toggle-field", card && "oc-toggle-field--card", reverse && "oc-toggle-field--reverse", fieldClassName)}>
29
+ <span className="oc-toggle-field-text">
30
+ <span className="oc-toggle-field-label">{label}</span>
31
+ {description != null && <span className="oc-toggle-field-description" id={descId}>{description}</span>}
32
+ </span>
33
+ <span className="oc-switch">
34
+ <input ref={ref} type="checkbox" role="switch" className={className} aria-describedby={description != null ? descId : undefined} {...rest} />
35
+ <span className="oc-switch-track" />
36
+ </span>
37
+ </label>
38
+ );
39
+ });
@@ -0,0 +1,14 @@
1
+ import { forwardRef } from "react";
2
+ import type { HTMLAttributes, ReactNode } from "react";
3
+ import { cx } from "../utils";
4
+
5
+ export interface TooltipProps extends Omit<HTMLAttributes<HTMLSpanElement>, "content"> {
6
+ /** Tooltip text (`data-tooltip`). */
7
+ content: string;
8
+ children: ReactNode;
9
+ }
10
+
11
+ /** `<span class="oc-tooltip" data-tooltip>`: CSS-only tooltip around its children. */
12
+ export const Tooltip = forwardRef<HTMLSpanElement, TooltipProps>(function Tooltip({ content, className, ...rest }, ref) {
13
+ return <span ref={ref} className={cx("oc-tooltip", className)} data-tooltip={content} {...rest} />;
14
+ });
@@ -0,0 +1,54 @@
1
+ // File: packages/react/src/components/TopProgress.tsx
2
+ // Map: TopProgress = progress.oc-top-progress -> phase state (hidden / active / done) -> fade-out timer
3
+ // `active` true shows the bar. When it turns false the bar gets `.is-done` (fades out),
4
+ // and after the fade it is hidden again. Without `value` the bar is indeterminate.
5
+ "use client";
6
+
7
+ import { forwardRef, useEffect, useState } from "react";
8
+ import type { ProgressHTMLAttributes } from "react";
9
+ import { cx } from "../utils";
10
+
11
+ export type TopProgressTone = "default" | "success" | "warning" | "danger" | "brand";
12
+
13
+ export interface TopProgressProps extends ProgressHTMLAttributes<HTMLProgressElement> {
14
+ /** Show the bar (a request or page load is running). Default true. */
15
+ active?: boolean;
16
+ tone?: TopProgressTone;
17
+ /** 4px instead of 2px. */
18
+ large?: boolean;
19
+ /** In the normal flow (e.g. inside a card) instead of fixed at the top. */
20
+ static?: boolean;
21
+ /** Fade-out time in ms before the bar is hidden. */
22
+ fadeMs?: number;
23
+ }
24
+
25
+ type Phase = "hidden" | "active" | "done";
26
+
27
+ /** `progress.oc-top-progress`: thin loading bar at the top of the page. */
28
+ export const TopProgress = forwardRef<HTMLProgressElement, TopProgressProps>(function TopProgress(
29
+ { active = true, tone = "default", large, static: inFlow, fadeMs = 400, className, ...rest },
30
+ ref,
31
+ ) {
32
+ const [phase, setPhase] = useState<Phase>(active ? "active" : "hidden");
33
+
34
+ useEffect(() => {
35
+ if (active) {
36
+ setPhase("active");
37
+ return;
38
+ }
39
+ // Only fade out when the bar was visible; keep it hidden otherwise.
40
+ setPhase((p) => (p === "hidden" ? "hidden" : "done"));
41
+ const timer = setTimeout(() => setPhase("hidden"), fadeMs);
42
+ return () => clearTimeout(timer);
43
+ }, [active, fadeMs]);
44
+
45
+ const cls = cx(
46
+ "oc-top-progress",
47
+ tone !== "default" && `oc-top-progress--${tone}`,
48
+ large && "oc-top-progress--lg",
49
+ inFlow && "oc-top-progress--static",
50
+ phase === "done" && "is-done",
51
+ className,
52
+ );
53
+ return <progress ref={ref} className={cls} hidden={phase === "hidden"} {...rest} />;
54
+ });
@@ -0,0 +1,97 @@
1
+ // File: packages/react/src/components/Tree.tsx
2
+ // Map: Tree = box -> optional head row -> list -> TreeNode (folder = details/summary, leaf = div row) -> nested list
3
+ // Folders open and close natively (<details>), so there is no state here.
4
+ import { forwardRef } from "react";
5
+ import type { HTMLAttributes, ReactNode } from "react";
6
+ import { cx } from "../utils";
7
+
8
+ export interface TreeItem {
9
+ label: ReactNode;
10
+ /** Leaf link target (renders the label as `a.oc-tree-label`). */
11
+ href?: string;
12
+ icon?: ReactNode;
13
+ count?: ReactNode;
14
+ actions?: ReactNode;
15
+ /** Child items: makes this item a folder. */
16
+ children?: TreeItem[];
17
+ /** Folder starts open. */
18
+ open?: boolean;
19
+ /** Current / selected item. */
20
+ current?: boolean;
21
+ muted?: boolean;
22
+ }
23
+
24
+ export interface TreeProps extends HTMLAttributes<HTMLDivElement> {
25
+ items: TreeItem[];
26
+ /** Sticky root row content (label), with optional `headActions`. */
27
+ head?: ReactNode;
28
+ headActions?: ReactNode;
29
+ /** Actions only show on hover / focus. */
30
+ quiet?: boolean;
31
+ /** No card frame. */
32
+ flush?: boolean;
33
+ }
34
+
35
+ /** The row content: icon, label (link or text), count, actions. */
36
+ function RowContent({ item }: { item: TreeItem }) {
37
+ const isLink = item.href !== undefined;
38
+ return (
39
+ <>
40
+ {item.icon != null && <span className="oc-tree-icon" aria-hidden="true">{item.icon}</span>}
41
+ {isLink ? (
42
+ <a className="oc-tree-label" href={item.href} aria-current={item.current ? "true" : undefined}>{item.label}</a>
43
+ ) : (
44
+ <span className="oc-tree-label">{item.label}</span>
45
+ )}
46
+ {item.count != null && <span className="oc-tree-count">{item.count}</span>}
47
+ {item.actions != null && <span className="oc-tree-actions">{item.actions}</span>}
48
+ </>
49
+ );
50
+ }
51
+
52
+ function TreeNode({ item }: { item: TreeItem }) {
53
+ // aria-current on the row only when the label is not a link (a link carries it itself).
54
+ const rowCurrent = item.current && item.href === undefined ? "true" : undefined;
55
+ const rowCls = cx("oc-tree-row", item.muted && "is-muted");
56
+ if (!item.children?.length) {
57
+ return (
58
+ <li>
59
+ <div className={rowCls} aria-current={rowCurrent}><RowContent item={item} /></div>
60
+ </li>
61
+ );
62
+ }
63
+ return (
64
+ <li>
65
+ <details className="oc-tree-node" open={item.open}>
66
+ <summary className={rowCls} aria-current={rowCurrent}><RowContent item={item} /></summary>
67
+ <TreeList items={item.children} />
68
+ </details>
69
+ </li>
70
+ );
71
+ }
72
+
73
+ function TreeList({ items }: { items: TreeItem[] }) {
74
+ return (
75
+ <ul className="oc-tree-list">
76
+ {items.map((item, i) => <TreeNode item={item} key={i} />)}
77
+ </ul>
78
+ );
79
+ }
80
+
81
+ /** `.oc-tree`: nested tree list with a sticky action column. */
82
+ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
83
+ { items, head, headActions, quiet, flush, className, ...rest },
84
+ ref,
85
+ ) {
86
+ return (
87
+ <div ref={ref} className={cx("oc-tree", quiet && "oc-tree--quiet", flush && "oc-tree--flush", className)} {...rest}>
88
+ {head != null && (
89
+ <div className="oc-tree-row oc-tree-head">
90
+ <span className="oc-tree-label">{head}</span>
91
+ {headActions != null && <span className="oc-tree-actions">{headActions}</span>}
92
+ </div>
93
+ )}
94
+ <TreeList items={items} />
95
+ </div>
96
+ );
97
+ });
@@ -0,0 +1,62 @@
1
+ // File: packages/react/src/components/UpgradeBanner.tsx
2
+ // Map: UpgradeBanner = aside -> icon -> body (title + plan pill, text, usage meter) -> actions -> dismiss button
3
+ // The dismiss button only shows when `onDismiss` is given; your app then hides or removes the banner.
4
+ import { forwardRef } from "react";
5
+ import type { HTMLAttributes, ReactNode } from "react";
6
+ import { cx } from "../utils";
7
+
8
+ export interface UpgradeBannerUsage {
9
+ label: ReactNode;
10
+ value: number;
11
+ max: number;
12
+ }
13
+
14
+ export interface UpgradeBannerProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
15
+ title: ReactNode;
16
+ /** Plan pill next to the title, e.g. "Free". */
17
+ plan?: ReactNode;
18
+ /** Explanation; also accepted as children. */
19
+ text?: ReactNode;
20
+ usage?: UpgradeBannerUsage;
21
+ icon?: ReactNode;
22
+ actions?: ReactNode;
23
+ premium?: boolean;
24
+ compact?: boolean;
25
+ onDismiss?: () => void;
26
+ dismissLabel?: string;
27
+ }
28
+
29
+ /** `.oc-upgrade-banner`: free-plan limits callout with an upgrade action. */
30
+ export const UpgradeBanner = forwardRef<HTMLElement, UpgradeBannerProps>(function UpgradeBanner(
31
+ { title, plan, text, usage, icon, actions, premium, compact, onDismiss, dismissLabel = "Dismiss", className, children, ...rest },
32
+ ref,
33
+ ) {
34
+ const cls = cx("oc-upgrade-banner", premium && "oc-upgrade-banner--premium", compact && "oc-upgrade-banner--compact", className);
35
+ const body = text ?? children;
36
+ return (
37
+ <aside ref={ref} className={cls} {...rest}>
38
+ {icon != null && <span className="oc-upgrade-banner-icon" aria-hidden="true">{icon}</span>}
39
+ <div className="oc-upgrade-banner-body">
40
+ <p className="oc-upgrade-banner-title">
41
+ {title}
42
+ {plan != null && <> <span className="oc-upgrade-banner-plan">{plan}</span></>}
43
+ </p>
44
+ {body != null && <p className="oc-upgrade-banner-text">{body}</p>}
45
+ {usage && (
46
+ <div className="oc-upgrade-banner-usage">
47
+ <span>{usage.label}</span>
48
+ {/* low / high make the meter turn "less good" near the limit. */}
49
+ <meter className="oc-upgrade-banner-meter" min={0} max={usage.max} low={usage.max * 0.6} high={usage.max * 0.8} optimum={0} value={usage.value}>
50
+ {usage.value} of {usage.max}
51
+ </meter>
52
+ <span>{usage.value} / {usage.max}</span>
53
+ </div>
54
+ )}
55
+ </div>
56
+ {actions != null && <div className="oc-upgrade-banner-actions">{actions}</div>}
57
+ {onDismiss && (
58
+ <button className="oc-upgrade-banner-dismiss" type="button" aria-label={dismissLabel} onClick={onDismiss}>×</button>
59
+ )}
60
+ </aside>
61
+ );
62
+ });
@@ -0,0 +1,31 @@
1
+ import { forwardRef } from "react";
2
+ import type { HTMLAttributes, ReactNode } from "react";
3
+ import { cx } from "../utils";
4
+ import { Avatar } from "./Avatar";
5
+ import type { AvatarSize } from "./Avatar";
6
+
7
+ export interface UserProps extends HTMLAttributes<HTMLDivElement> {
8
+ name: ReactNode;
9
+ email?: ReactNode;
10
+ /** Initials shown in the avatar. */
11
+ initials?: string;
12
+ /** Avatar image URL (wins over initials). */
13
+ avatarSrc?: string;
14
+ avatarSize?: AvatarSize;
15
+ }
16
+
17
+ /** `.oc-user`: avatar with name and e-mail. */
18
+ export const User = forwardRef<HTMLDivElement, UserProps>(function User(
19
+ { name, email, initials, avatarSrc, avatarSize = "md", className, ...rest },
20
+ ref,
21
+ ) {
22
+ return (
23
+ <div ref={ref} className={cx("oc-user", className)} {...rest}>
24
+ <Avatar size={avatarSize} src={avatarSrc}>{initials}</Avatar>
25
+ <span className="oc-user-text">
26
+ <span className="oc-user-name">{name}</span>
27
+ {email != null && <span className="oc-user-email">{email}</span>}
28
+ </span>
29
+ </div>
30
+ );
31
+ });
@@ -0,0 +1,41 @@
1
+ // File: packages/react/src/components/VersionBanner.tsx
2
+ // Map: VersionBanner = chip (role=status) -> dot -> long text -> short text -> reload action
3
+ // Renders nothing while `version` is empty. Your app sets `version` when it sees a newer build.
4
+ import { forwardRef } from "react";
5
+ import type { HTMLAttributes, ReactNode } from "react";
6
+ import { cx } from "../utils";
7
+
8
+ export interface VersionBannerProps extends HTMLAttributes<HTMLDivElement> {
9
+ /** The new version, e.g. "v0.6.0". Empty = no banner. */
10
+ version?: string | null;
11
+ /** Long text; default "New version (vX) available - reload the page". */
12
+ text?: ReactNode;
13
+ /** Short text below 40rem; default "New version". */
14
+ short?: ReactNode;
15
+ actionLabel?: ReactNode;
16
+ /** Custom reload; without it the action is a plain link to the current page. */
17
+ onReload?: () => void;
18
+ tone?: "warning" | "info" | "success";
19
+ floating?: boolean;
20
+ }
21
+
22
+ /** `.oc-version-banner`: "new version available - reload" chip. */
23
+ export const VersionBanner = forwardRef<HTMLDivElement, VersionBannerProps>(function VersionBanner(
24
+ { version, text, short = "New version", actionLabel = "Reload", onReload, tone = "warning", floating, className, ...rest },
25
+ ref,
26
+ ) {
27
+ if (!version) return null;
28
+ const cls = cx("oc-version-banner", tone !== "warning" && `oc-version-banner--${tone}`, floating && "oc-version-banner--floating", className);
29
+ return (
30
+ <div ref={ref} className={cls} role="status" {...rest}>
31
+ <span className="oc-version-banner-dot" aria-hidden="true" />
32
+ <span className="oc-version-banner-text">{text ?? `New version (${version}) available - reload the page`}</span>
33
+ <span className="oc-version-banner-short">{short}</span>
34
+ {onReload ? (
35
+ <button className="oc-version-banner-action" type="button" onClick={onReload}>{actionLabel}</button>
36
+ ) : (
37
+ <a className="oc-version-banner-action" href="">{actionLabel}</a>
38
+ )}
39
+ </div>
40
+ );
41
+ });
package/src/hooks.tsx ADDED
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
4
+ import type { RefObject, SyntheticEvent } from "react";
5
+
6
+ /** Merge several refs into one callback ref. */
7
+ export function useMergedRef<T>(...refs: Array<React.Ref<T> | undefined>) {
8
+ return useCallback((node: T | null) => {
9
+ for (const ref of refs) {
10
+ if (typeof ref === "function") ref(node);
11
+ else if (ref) (ref as React.MutableRefObject<T | null>).current = node;
12
+ }
13
+ // eslint-disable-next-line react-hooks/exhaustive-deps
14
+ }, refs);
15
+ }
16
+
17
+ /** Controlled + uncontrolled value in one hook. */
18
+ export function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
19
+ const [inner, setInner] = useState<T>(defaultValue);
20
+ const controlled = value !== undefined;
21
+ const current = controlled ? (value as T) : inner;
22
+ const set = useCallback(
23
+ (next: T) => {
24
+ if (!controlled) setInner(next);
25
+ onChange?.(next);
26
+ },
27
+ [controlled, onChange],
28
+ );
29
+ return [current, set] as const;
30
+ }
31
+
32
+ export interface DialogState {
33
+ open?: boolean;
34
+ defaultOpen?: boolean;
35
+ onOpenChange?: (open: boolean) => void;
36
+ }
37
+
38
+ export const DialogContext = createContext<{ close: () => void } | null>(null);
39
+ export const useDialogContext = () => useContext(DialogContext);
40
+
41
+ /**
42
+ * Keeps a native <dialog> in sync with an `open` prop: showModal() / close() in an effect,
43
+ * and reports native closes (Escape, backdrop, form method=dialog) through onOpenChange.
44
+ */
45
+ export function useDialog(ref: RefObject<HTMLDialogElement | null>, { open, defaultOpen, onOpenChange }: DialogState) {
46
+ const [isOpen, setOpen] = useControllable<boolean>(open, defaultOpen ?? false, onOpenChange);
47
+ const openRef = useRef(isOpen);
48
+ openRef.current = isOpen;
49
+
50
+ useEffect(() => {
51
+ const dialog = ref.current;
52
+ if (!dialog) return;
53
+ if (isOpen && !dialog.open) dialog.showModal?.();
54
+ else if (!isOpen && dialog.open) dialog.close?.();
55
+ }, [isOpen, ref]);
56
+
57
+ const onClose = useCallback(
58
+ (e: SyntheticEvent<HTMLDialogElement>) => {
59
+ e.stopPropagation();
60
+ if (openRef.current) setOpen(false);
61
+ },
62
+ [setOpen],
63
+ );
64
+ const close = useCallback(() => setOpen(false), [setOpen]);
65
+ return { open: isOpen, setOpen, close, onClose };
66
+ }
67
+
68
+ /* ---------------------------------------------------------------------------------------------
69
+ * Menu keyboard helpers (shared by DropdownMenu, Menubar and ContextMenu)
70
+ * ------------------------------------------------------------------------------------------- */
71
+
72
+ /** Focusable items that belong directly to a `[role=menu]` (sub-menu triggers included, sub-menu items excluded). */
73
+ export function menuItems(menu: Element): HTMLElement[] {
74
+ const out: HTMLElement[] = [];
75
+ for (const child of Array.from(menu.children)) {
76
+ if (child.matches('[role^="menuitem"]')) out.push(child as HTMLElement);
77
+ else if (child.classList.contains("oc-dropdown-sub") && child.firstElementChild) out.push(child.firstElementChild as HTMLElement);
78
+ }
79
+ return out.filter((el) => el.getAttribute("aria-disabled") !== "true" && !(el as HTMLButtonElement).disabled);
80
+ }
81
+
82
+ /**
83
+ * Roving focus inside an `.oc-dropdown-menu[role=menu]`: ArrowDown / ArrowUp (wrapping), Home, End,
84
+ * ArrowRight on a sub-menu trigger enters the sub-menu, ArrowLeft inside a sub-menu returns to its trigger.
85
+ * Returns `true` (and calls preventDefault) when the key was handled.
86
+ */
87
+ export function handleMenuKeyDown(e: React.KeyboardEvent<HTMLElement>): boolean {
88
+ const item = (e.target as HTMLElement).closest<HTMLElement>('[role^="menuitem"]');
89
+ const menu = item?.closest<HTMLElement>('[role="menu"]');
90
+ if (!item || !menu) return false;
91
+ const items = menuItems(menu);
92
+ const at = items.indexOf(item);
93
+ let next: HTMLElement | undefined;
94
+ switch (e.key) {
95
+ case "ArrowDown":
96
+ next = items[(at + 1) % items.length];
97
+ break;
98
+ case "ArrowUp":
99
+ next = items[(at - 1 + items.length) % items.length];
100
+ break;
101
+ case "Home":
102
+ next = items[0];
103
+ break;
104
+ case "End":
105
+ next = items[items.length - 1];
106
+ break;
107
+ case "ArrowRight": {
108
+ const sub = item.parentElement?.classList.contains("oc-dropdown-sub") ? item.parentElement.querySelector<HTMLElement>(".oc-dropdown-submenu") : null;
109
+ next = sub ? menuItems(sub)[0] : undefined;
110
+ break;
111
+ }
112
+ case "ArrowLeft":
113
+ if (menu.classList.contains("oc-dropdown-submenu")) next = menu.parentElement?.firstElementChild as HTMLElement | undefined;
114
+ break;
115
+ default:
116
+ return false;
117
+ }
118
+ if (!next) return false;
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ next.focus();
122
+ return true;
123
+ }
124
+
125
+ /** Closes the popover a menu item lives in. */
126
+ export function closeMenuOf(el: Element | null) {
127
+ (el?.closest("[popover]") as (HTMLElement & { hidePopover?: () => void }) | null)?.hidePopover?.();
128
+ }
129
+
130
+ /** Focuses the first menu item whenever the popover opens (the native `toggle` event). */
131
+ export function useMenuAutofocus(ref: RefObject<HTMLElement | null>) {
132
+ useEffect(() => {
133
+ const el = ref.current;
134
+ if (!el) return;
135
+ const onToggle = (e: Event) => {
136
+ if ((e as Event & { newState?: string }).newState === "open") menuItems(el)[0]?.focus();
137
+ };
138
+ el.addEventListener("toggle", onToggle);
139
+ return () => el.removeEventListener("toggle", onToggle);
140
+ }, [ref]);
141
+ }