@forwardreach/saas-ui 0.4.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 (80) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +7 -0
  3. package/dist/components/audit-log.d.ts +79 -0
  4. package/dist/components/audit-log.js +24 -0
  5. package/dist/components/auth-shell.d.ts +25 -0
  6. package/dist/components/auth-shell.js +30 -0
  7. package/dist/components/avatar.d.ts +31 -0
  8. package/dist/components/avatar.js +56 -0
  9. package/dist/components/badge.d.ts +9 -0
  10. package/dist/components/badge.js +23 -0
  11. package/dist/components/brand-icons.d.ts +14 -0
  12. package/dist/components/brand-icons.js +23 -0
  13. package/dist/components/button.d.ts +16 -0
  14. package/dist/components/button.js +37 -0
  15. package/dist/components/confirm-dialog.d.ts +18 -0
  16. package/dist/components/confirm-dialog.js +39 -0
  17. package/dist/components/copy-field.d.ts +22 -0
  18. package/dist/components/copy-field.js +53 -0
  19. package/dist/components/dialog.d.ts +14 -0
  20. package/dist/components/dialog.js +24 -0
  21. package/dist/components/dropdown-menu.d.ts +23 -0
  22. package/dist/components/dropdown-menu.js +30 -0
  23. package/dist/components/empty-state.d.ts +9 -0
  24. package/dist/components/empty-state.js +7 -0
  25. package/dist/components/filter-chip.d.ts +11 -0
  26. package/dist/components/filter-chip.js +13 -0
  27. package/dist/components/getting-started.d.ts +31 -0
  28. package/dist/components/getting-started.js +17 -0
  29. package/dist/components/index.d.ts +29 -0
  30. package/dist/components/index.js +29 -0
  31. package/dist/components/inline-notice.d.ts +27 -0
  32. package/dist/components/inline-notice.js +47 -0
  33. package/dist/components/inline-rename-input.d.ts +11 -0
  34. package/dist/components/inline-rename-input.js +57 -0
  35. package/dist/components/input.d.ts +4 -0
  36. package/dist/components/input.js +5 -0
  37. package/dist/components/list-shell.d.ts +17 -0
  38. package/dist/components/list-shell.js +13 -0
  39. package/dist/components/login.d.ts +39 -0
  40. package/dist/components/login.js +50 -0
  41. package/dist/components/page-header.d.ts +10 -0
  42. package/dist/components/page-header.js +6 -0
  43. package/dist/components/request-access.d.ts +25 -0
  44. package/dist/components/request-access.js +40 -0
  45. package/dist/components/scroll-area.d.ts +4 -0
  46. package/dist/components/scroll-area.js +8 -0
  47. package/dist/components/search-input.d.ts +7 -0
  48. package/dist/components/search-input.js +11 -0
  49. package/dist/components/separator.d.ts +3 -0
  50. package/dist/components/separator.js +6 -0
  51. package/dist/components/settings-section.d.ts +18 -0
  52. package/dist/components/settings-section.js +10 -0
  53. package/dist/components/skeleton.d.ts +4 -0
  54. package/dist/components/skeleton.js +6 -0
  55. package/dist/components/status-pill.d.ts +7 -0
  56. package/dist/components/status-pill.js +7 -0
  57. package/dist/components/tabs.d.ts +6 -0
  58. package/dist/components/tabs.js +11 -0
  59. package/dist/components/textarea.d.ts +4 -0
  60. package/dist/components/textarea.js +5 -0
  61. package/dist/components/toolbar.d.ts +7 -0
  62. package/dist/components/toolbar.js +9 -0
  63. package/dist/components/tooltip.d.ts +6 -0
  64. package/dist/components/tooltip.js +9 -0
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.js +2 -0
  67. package/dist/styles/index.css +71 -0
  68. package/dist/utils/cn.d.ts +2 -0
  69. package/dist/utils/cn.js +5 -0
  70. package/dist/utils/email.d.ts +2 -0
  71. package/dist/utils/email.js +5 -0
  72. package/dist/utils/flow-runner.d.ts +18 -0
  73. package/dist/utils/flow-runner.js +39 -0
  74. package/dist/utils/index.d.ts +3 -0
  75. package/dist/utils/index.js +3 -0
  76. package/dist/utils/initials.d.ts +10 -0
  77. package/dist/utils/initials.js +27 -0
  78. package/dist/utils/time.d.ts +5 -0
  79. package/dist/utils/time.js +46 -0
  80. package/package.json +84 -0
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { KeyRound, Mail } from "lucide-react";
3
+ import * as React from "react";
4
+ import { isPlausibleEmail } from "../utils/email.js";
5
+ import { useFlowRunner } from "../utils/flow-runner.js";
6
+ import { AuthFeedback, AuthHeader, AuthLinkLine, AuthShell, AuthSpinner } from "./auth-shell.js";
7
+ import { Button } from "./button.js";
8
+ import { Input } from "./input.js";
9
+ const REQUEST_FLOW = "request";
10
+ const ACCESS_CODE_FLOW = "access-code";
11
+ function Card({ children }) {
12
+ return (_jsx("div", { className: "space-y-3 rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] p-4", children: children }));
13
+ }
14
+ export function RequestAccess({ accessCode, description, logo, request, signIn, title = "Request access" }) {
15
+ const flows = useFlowRunner();
16
+ const emailInputId = React.useId();
17
+ const codeInputId = React.useId();
18
+ const [email, setEmail] = React.useState("");
19
+ const [code, setCode] = React.useState("");
20
+ const [requestSent, setRequestSent] = React.useState(false);
21
+ const trimmedEmail = email.trim();
22
+ const trimmedCode = code.trim();
23
+ async function handleRequestSubmit(event) {
24
+ event.preventDefault();
25
+ if (flows.busy || !isPlausibleEmail(trimmedEmail))
26
+ return;
27
+ const sent = await flows.run(REQUEST_FLOW, () => request.submit(trimmedEmail));
28
+ if (sent)
29
+ setRequestSent(true);
30
+ }
31
+ async function handleCodeSubmit(event) {
32
+ if (!accessCode)
33
+ return;
34
+ event.preventDefault();
35
+ if (flows.busy || trimmedCode.length === 0)
36
+ return;
37
+ await flows.run(ACCESS_CODE_FLOW, () => accessCode.submit(trimmedCode));
38
+ }
39
+ return (_jsxs(AuthShell, { children: [_jsx(AuthHeader, { description: description, logo: logo, title: title }), _jsxs("div", { className: "space-y-5", children: [_jsxs(Card, { children: [requestSent ? (_jsx(AuthFeedback, { tone: "status", children: request.successMessage ?? "Access request sent. We'll email you." })) : (_jsxs("form", { "aria-busy": flows.busyKey === REQUEST_FLOW || undefined, "aria-label": "Request access", className: "space-y-2", onSubmit: handleRequestSubmit, children: [_jsx("label", { className: "block", htmlFor: emailInputId, children: _jsxs("span", { className: "mb-1.5 flex items-center gap-1.5 text-xs font-medium text-[color:var(--ssui-text-muted)]", children: [_jsx(Mail, { "aria-hidden": "true", className: "size-3.5" }), "Email"] }) }), _jsx(Input, { autoComplete: "email", disabled: flows.busy, id: emailInputId, inputMode: "email", name: "email", onChange: (event) => setEmail(event.target.value), placeholder: "you@example.com", type: "email", value: email }), _jsxs(Button, { "aria-busy": flows.busyKey === REQUEST_FLOW || undefined, className: "w-full justify-center", disabled: flows.busy || !isPlausibleEmail(trimmedEmail), type: "submit", variant: "primary", children: [flows.busyKey === REQUEST_FLOW ? _jsx(AuthSpinner, {}) : null, "Request access"] })] })), _jsx(AuthFeedback, { tone: "error", children: flows.errorFor(REQUEST_FLOW) })] }), accessCode ? (_jsxs(Card, { children: [_jsxs("form", { "aria-busy": flows.busyKey === ACCESS_CODE_FLOW || undefined, "aria-label": "Access code", className: "space-y-2", onSubmit: handleCodeSubmit, children: [_jsx("label", { className: "block", htmlFor: codeInputId, children: _jsxs("span", { className: "mb-1.5 flex items-center gap-1.5 text-xs font-medium text-[color:var(--ssui-text-muted)]", children: [_jsx(KeyRound, { "aria-hidden": "true", className: "size-3.5" }), "Have an access code?"] }) }), _jsx(Input, { autoComplete: "one-time-code", disabled: flows.busy, id: codeInputId, name: "code", onChange: (event) => setCode(event.target.value), placeholder: "Access code", value: code }), _jsxs(Button, { "aria-busy": flows.busyKey === ACCESS_CODE_FLOW || undefined, className: "w-full justify-center", disabled: flows.busy || trimmedCode.length === 0, type: "submit", variant: "secondary", children: [flows.busyKey === ACCESS_CODE_FLOW ? _jsx(AuthSpinner, {}) : null, "Continue"] })] }), _jsx(AuthFeedback, { tone: "error", children: flows.errorFor(ACCESS_CODE_FLOW) })] })) : null] }), signIn ? (_jsx("div", { className: "mt-6", children: _jsx(AuthLinkLine, { link: signIn.link, prompt: signIn.prompt ?? "Existing user?" }) })) : null] }));
40
+ }
@@ -0,0 +1,4 @@
1
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
2
+ import * as React from "react";
3
+ export declare const ScrollArea: React.ForwardRefExoticComponent<Omit<ScrollAreaPrimitive.ScrollAreaProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
4
+ export declare const ScrollBar: React.ForwardRefExoticComponent<Omit<ScrollAreaPrimitive.ScrollAreaScrollbarProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ export const ScrollArea = React.forwardRef(({ children, className, ...props }, ref) => (_jsxs(ScrollAreaPrimitive.Root, { ref: ref, className: cn("relative overflow-hidden", className), ...props, children: [_jsx(ScrollAreaPrimitive.Viewport, { className: "size-full rounded-[inherit]", children: children }), _jsx(ScrollBar, {}), _jsx(ScrollAreaPrimitive.Corner, {})] })));
6
+ ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
7
+ export const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (_jsx(ScrollAreaPrimitive.ScrollAreaScrollbar, { ref: ref, className: cn("flex touch-none select-none rounded-full p-0.5 transition-colors", orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent", orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent", className), orientation: orientation, ...props, children: _jsx(ScrollAreaPrimitive.ScrollAreaThumb, { className: "relative flex-1 rounded-full bg-[color:var(--ssui-border-strong)]" }) })));
8
+ ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
@@ -0,0 +1,7 @@
1
+ import * as React from "react";
2
+ import { type InputProps } from "./input.js";
3
+ export interface SearchInputProps extends Omit<InputProps, "type"> {
4
+ clearLabel?: string;
5
+ onClear?: () => void;
6
+ }
7
+ export declare const SearchInput: React.ForwardRefExoticComponent<SearchInputProps & React.RefAttributes<HTMLInputElement>>;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Search, X } from "lucide-react";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ import { IconButton } from "./button.js";
6
+ import { Input } from "./input.js";
7
+ export const SearchInput = React.forwardRef(({ className, clearLabel = "Clear search", onClear, placeholder = "Search", value, ...props }, ref) => {
8
+ const hasValue = typeof value === "string" && value.length > 0;
9
+ return (_jsxs("div", { className: cn("relative w-full", className), children: [_jsx(Search, { className: "pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ssui-text-subtle)]", "aria-hidden": "true" }), _jsx(Input, { ref: ref, className: "pl-9 pr-9", placeholder: placeholder, type: "search", value: value, ...props }), hasValue && onClear ? (_jsx(IconButton, { className: "absolute right-1 top-1/2 -translate-y-1/2", label: clearLabel, onClick: onClear, size: "icon-sm", type: "button", variant: "ghost", children: _jsx(X, {}) })) : null] }));
10
+ });
11
+ SearchInput.displayName = "SearchInput";
@@ -0,0 +1,3 @@
1
+ import * as SeparatorPrimitive from "@radix-ui/react-separator";
2
+ import * as React from "react";
3
+ export declare const Separator: React.ForwardRefExoticComponent<Omit<SeparatorPrimitive.SeparatorProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as SeparatorPrimitive from "@radix-ui/react-separator";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ export const Separator = React.forwardRef(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (_jsx(SeparatorPrimitive.Root, { ref: ref, className: cn("shrink-0 bg-[color:var(--ssui-border)]", orientation === "horizontal" ? "h-px w-full" : "h-full w-px", className), decorative: decorative, orientation: orientation, ...props })));
6
+ Separator.displayName = SeparatorPrimitive.Root.displayName;
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ export interface SettingsSectionProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
3
+ /** Section heading. */
4
+ title: React.ReactNode;
5
+ /** Optional supporting copy shown under the title. */
6
+ description?: React.ReactNode;
7
+ /** Optional leading icon rendered before the title. */
8
+ icon?: React.ReactNode;
9
+ /** Optional header-aligned action (button, link, etc.). */
10
+ action?: React.ReactNode;
11
+ /** Optional footer content rendered under the body. */
12
+ footer?: React.ReactNode;
13
+ }
14
+ /**
15
+ * Titled card used as the repeating visual unit across settings surfaces:
16
+ * a heading with optional description and header action, plus a body slot.
17
+ */
18
+ export declare function SettingsSection({ title, description, icon, action, footer, className, children, ...props }: SettingsSectionProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ /**
5
+ * Titled card used as the repeating visual unit across settings surfaces:
6
+ * a heading with optional description and header action, plus a body slot.
7
+ */
8
+ export function SettingsSection({ title, description, icon, action, footer, className, children, ...props }) {
9
+ return (_jsxs("section", { className: cn("rounded-[var(--ssui-radius-lg)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] p-4", className), ...props, children: [_jsxs("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "min-w-0 space-y-1", children: [_jsxs("h2", { className: "flex items-center gap-2 text-sm font-semibold text-[color:var(--ssui-text)]", children: [icon ? (_jsx("span", { className: "shrink-0 [&_svg]:size-4", "aria-hidden": "true", children: icon })) : null, title] }), description ? (_jsx("div", { className: "text-sm text-[color:var(--ssui-text-muted)]", children: description })) : null] }), action ? (_jsx("div", { className: "flex shrink-0 flex-wrap gap-2", children: action })) : null] }), children ? _jsx("div", { className: "mt-4", children: children }) : null, footer ? _jsx("div", { className: "mt-4", children: footer }) : null] }));
10
+ }
@@ -0,0 +1,4 @@
1
+ import * as React from "react";
2
+ export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ }
4
+ export declare function Skeleton({ className, ...props }: SkeletonProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ export function Skeleton({ className, ...props }) {
5
+ return (_jsx("div", { className: cn("animate-pulse rounded-[var(--ssui-radius)] bg-[color:var(--ssui-surface-muted)]", className), ...props }));
6
+ }
@@ -0,0 +1,7 @@
1
+ import { type BadgeProps } from "./badge.js";
2
+ export interface StatusPillProps extends BadgeProps {
3
+ /** Render a small leading status dot in the active variant's color. */
4
+ dot?: boolean;
5
+ status?: BadgeProps["variant"];
6
+ }
7
+ export declare function StatusPill({ children, className, dot, status, variant, ...props }: StatusPillProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ import { Badge } from "./badge.js";
5
+ export function StatusPill({ children, className, dot = false, status = "neutral", variant, ...props }) {
6
+ return (_jsxs(Badge, { className: cn("capitalize", className), variant: variant ?? status, ...props, children: [dot ? (_jsx("span", { "aria-hidden": "true", className: "size-1.5 shrink-0 rounded-full bg-[currentColor]", "data-status-dot": "" })) : null, children] }));
7
+ }
@@ -0,0 +1,6 @@
1
+ import * as TabsPrimitive from "@radix-ui/react-tabs";
2
+ import * as React from "react";
3
+ export declare const Tabs: React.ForwardRefExoticComponent<TabsPrimitive.TabsProps & React.RefAttributes<HTMLDivElement>>;
4
+ export declare const TabsList: React.ForwardRefExoticComponent<Omit<TabsPrimitive.TabsListProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
5
+ export declare const TabsTrigger: React.ForwardRefExoticComponent<Omit<TabsPrimitive.TabsTriggerProps & React.RefAttributes<HTMLButtonElement>, "ref"> & React.RefAttributes<HTMLButtonElement>>;
6
+ export declare const TabsContent: React.ForwardRefExoticComponent<Omit<TabsPrimitive.TabsContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as TabsPrimitive from "@radix-ui/react-tabs";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ export const Tabs = TabsPrimitive.Root;
6
+ export const TabsList = React.forwardRef(({ className, ...props }, ref) => (_jsx(TabsPrimitive.List, { ref: ref, className: cn("inline-flex h-10 items-center justify-center rounded-[var(--ssui-radius)] bg-[color:var(--ssui-surface-muted)] p-1 text-[color:var(--ssui-text-muted)]", className), ...props })));
7
+ TabsList.displayName = TabsPrimitive.List.displayName;
8
+ export const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (_jsx(TabsPrimitive.Trigger, { ref: ref, className: cn("inline-flex items-center justify-center whitespace-nowrap rounded-[var(--ssui-radius-sm)] px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-[color:var(--ssui-surface)] data-[state=active]:text-[color:var(--ssui-text)] data-[state=active]:shadow-sm", className), ...props })));
9
+ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
10
+ export const TabsContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(TabsPrimitive.Content, { ref: ref, className: cn("mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)]", className), ...props })));
11
+ TabsContent.displayName = TabsPrimitive.Content.displayName;
@@ -0,0 +1,4 @@
1
+ import * as React from "react";
2
+ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
3
+ }
4
+ export declare const Textarea: React.ForwardRefExoticComponent<TextareaProps & React.RefAttributes<HTMLTextAreaElement>>;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ export const Textarea = React.forwardRef(({ className, rows = 4, ...props }, ref) => (_jsx("textarea", { ref: ref, rows: rows, className: cn("flex min-h-24 w-full resize-y rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...props })));
5
+ Textarea.displayName = "Textarea";
@@ -0,0 +1,7 @@
1
+ import * as React from "react";
2
+ export interface ToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ }
4
+ export declare function Toolbar({ className, ...props }: ToolbarProps): import("react/jsx-runtime").JSX.Element;
5
+ export interface ToolbarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ }
7
+ export declare function ToolbarGroup({ className, ...props }: ToolbarGroupProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { cn } from "../utils/cn.js";
4
+ export function Toolbar({ className, ...props }) {
5
+ return (_jsx("div", { className: cn("flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between", className), ...props }));
6
+ }
7
+ export function ToolbarGroup({ className, ...props }) {
8
+ return (_jsx("div", { className: cn("flex flex-wrap items-center gap-2", className), ...props }));
9
+ }
@@ -0,0 +1,6 @@
1
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
2
+ import * as React from "react";
3
+ export declare const TooltipProvider: React.FC<TooltipPrimitive.TooltipProviderProps>;
4
+ export declare const Tooltip: React.FC<TooltipPrimitive.TooltipProps>;
5
+ export declare const TooltipTrigger: React.ForwardRefExoticComponent<TooltipPrimitive.TooltipTriggerProps & React.RefAttributes<HTMLButtonElement>>;
6
+ export declare const TooltipContent: React.ForwardRefExoticComponent<Omit<TooltipPrimitive.TooltipContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ export const TooltipProvider = TooltipPrimitive.Provider;
6
+ export const Tooltip = TooltipPrimitive.Root;
7
+ export const TooltipTrigger = TooltipPrimitive.Trigger;
8
+ export const TooltipContent = React.forwardRef(({ className, sideOffset = 6, ...props }, ref) => (_jsx(TooltipPrimitive.Portal, { children: _jsx(TooltipPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn("z-50 max-w-72 overflow-hidden rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] px-3 py-1.5 text-xs text-[color:var(--ssui-text)] shadow-[var(--ssui-shadow-md)]", className), ...props }) })));
9
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
@@ -0,0 +1,2 @@
1
+ export * from "./components/index.js";
2
+ export * from "./utils/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./components/index.js";
2
+ export * from "./utils/index.js";
@@ -0,0 +1,71 @@
1
+ :root {
2
+ --ssui-bg: #ffffff;
3
+ --ssui-surface: #ffffff;
4
+ --ssui-surface-muted: #f4f6f8;
5
+ --ssui-surface-elevated: #ffffff;
6
+ --ssui-overlay: rgb(15 23 42 / 0.48);
7
+ --ssui-overlay-hover: rgb(17 24 39 / 0.08);
8
+ --ssui-text: #111827;
9
+ --ssui-text-muted: #667085;
10
+ --ssui-text-subtle: #98a2b3;
11
+ --ssui-text-inverse: #ffffff;
12
+ --ssui-border: #d0d5dd;
13
+ --ssui-border-strong: #98a2b3;
14
+ --ssui-primary: #2563eb;
15
+ --ssui-primary-hover: #1d4ed8;
16
+ --ssui-primary-foreground: #ffffff;
17
+ --ssui-accent-subtle: #eff6ff;
18
+ --ssui-accent-subtle-foreground: #1e3a8a;
19
+ --ssui-destructive: #dc2626;
20
+ --ssui-destructive-hover: #b91c1c;
21
+ --ssui-destructive-foreground: #ffffff;
22
+ --ssui-focus-ring: #2563eb;
23
+ --ssui-radius-sm: 0.25rem;
24
+ --ssui-radius: 0.375rem;
25
+ --ssui-radius-lg: 0.5rem;
26
+ --ssui-shadow-sm: 0 1px 2px rgb(16 24 40 / 0.08);
27
+ --ssui-shadow-md: 0 12px 24px rgb(16 24 40 / 0.12);
28
+ --ssui-notice-info-bg: #eff6ff;
29
+ --ssui-notice-info-text: #1e3a8a;
30
+ --ssui-notice-info-border: #bfdbfe;
31
+ --ssui-notice-success-bg: #ecfdf3;
32
+ --ssui-notice-success-text: #05603a;
33
+ --ssui-notice-success-border: #abefc6;
34
+ --ssui-notice-warning-bg: #fffaeb;
35
+ --ssui-notice-warning-text: #93370d;
36
+ --ssui-notice-warning-border: #fedf89;
37
+ --ssui-notice-danger-bg: #fef3f2;
38
+ --ssui-notice-danger-text: #912018;
39
+ --ssui-notice-danger-border: #fecdca;
40
+ --ssui-status-neutral-bg: #f4f6f8;
41
+ --ssui-status-neutral-text: #344054;
42
+ --ssui-status-info-bg: #eff6ff;
43
+ --ssui-status-info-text: #1e3a8a;
44
+ --ssui-status-success-bg: #ecfdf3;
45
+ --ssui-status-success-text: #05603a;
46
+ --ssui-status-warning-bg: #fffaeb;
47
+ --ssui-status-warning-text: #93370d;
48
+ --ssui-status-danger-bg: #fef3f2;
49
+ --ssui-status-danger-text: #912018;
50
+ --ssui-avatar-1-bg: #eef2ff;
51
+ --ssui-avatar-1-text: #3730a3;
52
+ --ssui-avatar-2-bg: #ecfdf5;
53
+ --ssui-avatar-2-text: #065f46;
54
+ --ssui-avatar-3-bg: #fff7ed;
55
+ --ssui-avatar-3-text: #9a3412;
56
+ --ssui-avatar-4-bg: #fdf2f8;
57
+ --ssui-avatar-4-text: #9d174d;
58
+ --ssui-avatar-5-bg: #f0f9ff;
59
+ --ssui-avatar-5-text: #075985;
60
+ }
61
+
62
+ @media (prefers-reduced-motion: reduce) {
63
+ *,
64
+ *::before,
65
+ *::after {
66
+ animation-duration: 0.01ms !important;
67
+ animation-iteration-count: 1 !important;
68
+ scroll-behavior: auto !important;
69
+ transition-duration: 0.01ms !important;
70
+ }
71
+ }
@@ -0,0 +1,2 @@
1
+ import { type ClassValue } from "clsx";
2
+ export declare function cn(...inputs: ClassValue[]): string;
@@ -0,0 +1,5 @@
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+ export function cn(...inputs) {
4
+ return twMerge(clsx(inputs));
5
+ }
@@ -0,0 +1,2 @@
1
+ /** Cheap plausibility check used to gate email submit buttons; real validation happens server-side. */
2
+ export declare function isPlausibleEmail(value: string): boolean;
@@ -0,0 +1,5 @@
1
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2
+ /** Cheap plausibility check used to gate email submit buttons; real validation happens server-side. */
3
+ export function isPlausibleEmail(value) {
4
+ return EMAIL_PATTERN.test(value.trim());
5
+ }
@@ -0,0 +1,18 @@
1
+ import * as React from "react";
2
+ export interface FlowRunner {
3
+ /** True while any flow is in flight; callers disable every interactive control. */
4
+ busy: boolean;
5
+ /** Key of the in-flight flow, for marking the active control busy. */
6
+ busyKey: string | null;
7
+ clearError: () => void;
8
+ /** The message for a rejected flow, or null if that flow has no error. */
9
+ errorFor: (key: string) => React.ReactNode;
10
+ /** Runs a flow's async handler; resolves true on success, false on a caught rejection. */
11
+ run: (key: string, action: () => Promise<void>) => Promise<boolean>;
12
+ }
13
+ /**
14
+ * Owns the async flow state (single in-flight flow, error scoped to the flow
15
+ * that rejected) shared by Login and RequestAccess. Rejections surface the
16
+ * thrown Error's message as user-facing copy per the package convention.
17
+ */
18
+ export declare function useFlowRunner(): FlowRunner;
@@ -0,0 +1,39 @@
1
+ import * as React from "react";
2
+ const GENERIC_ERROR_MESSAGE = "Something went wrong. Please try again.";
3
+ /**
4
+ * Owns the async flow state (single in-flight flow, error scoped to the flow
5
+ * that rejected) shared by Login and RequestAccess. Rejections surface the
6
+ * thrown Error's message as user-facing copy per the package convention.
7
+ */
8
+ export function useFlowRunner() {
9
+ const [busyKey, setBusyKey] = React.useState(null);
10
+ const [error, setError] = React.useState(null);
11
+ const run = React.useCallback(async (key, action) => {
12
+ setBusyKey(key);
13
+ setError(null);
14
+ try {
15
+ await action();
16
+ return true;
17
+ }
18
+ catch (cause) {
19
+ setError({
20
+ key,
21
+ message: cause instanceof Error && cause.message
22
+ ? cause.message
23
+ : GENERIC_ERROR_MESSAGE
24
+ });
25
+ return false;
26
+ }
27
+ finally {
28
+ setBusyKey(null);
29
+ }
30
+ }, []);
31
+ const clearError = React.useCallback(() => setError(null), []);
32
+ return {
33
+ busy: busyKey !== null,
34
+ busyKey,
35
+ clearError,
36
+ errorFor: (key) => (error?.key === key ? error.message : null),
37
+ run
38
+ };
39
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./cn.js";
2
+ export * from "./initials.js";
3
+ export * from "./time.js";
@@ -0,0 +1,3 @@
1
+ export * from "./cn.js";
2
+ export * from "./initials.js";
3
+ export * from "./time.js";
@@ -0,0 +1,10 @@
1
+ export interface InitialsInput {
2
+ name?: string | null;
3
+ email?: string | null;
4
+ fallback?: string | null;
5
+ }
6
+ export interface GetInitialsOptions {
7
+ maxLength?: number;
8
+ fallback?: string;
9
+ }
10
+ export declare function getInitials(input: InitialsInput | string | null | undefined, options?: GetInitialsOptions): string;
@@ -0,0 +1,27 @@
1
+ function cleanPart(part) {
2
+ return part.trim().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
3
+ }
4
+ export function getInitials(input, options = {}) {
5
+ const maxLength = Math.max(1, options.maxLength ?? 2);
6
+ const fallback = options.fallback ??
7
+ (typeof input === "object" ? input?.fallback : undefined) ??
8
+ "?";
9
+ const name = typeof input === "string" ? input : input?.name;
10
+ const email = typeof input === "object" ? input?.email : undefined;
11
+ const source = name?.trim() || email?.split("@")[0] || "";
12
+ const parts = source
13
+ .split(/[\s._-]+/u)
14
+ .map(cleanPart)
15
+ .filter(Boolean);
16
+ if (parts.length === 0) {
17
+ return fallback.slice(0, maxLength).toLocaleUpperCase();
18
+ }
19
+ if (parts.length === 1) {
20
+ return parts[0].slice(0, maxLength).toLocaleUpperCase();
21
+ }
22
+ return parts
23
+ .slice(0, maxLength)
24
+ .map((part) => part[0])
25
+ .join("")
26
+ .toLocaleUpperCase();
27
+ }
@@ -0,0 +1,5 @@
1
+ export interface FormatRelativeTimeOptions {
2
+ now?: Date | number | string;
3
+ numeric?: "auto" | "always";
4
+ }
5
+ export declare function formatRelativeTime(value: Date | number | string, options?: FormatRelativeTimeOptions): string;
@@ -0,0 +1,46 @@
1
+ const SECOND = 1000;
2
+ const MINUTE = 60 * SECOND;
3
+ const HOUR = 60 * MINUTE;
4
+ const DAY = 24 * HOUR;
5
+ const WEEK = 7 * DAY;
6
+ const MONTH = 30 * DAY;
7
+ const YEAR = 365 * DAY;
8
+ function toDate(value) {
9
+ return value instanceof Date ? value : new Date(value);
10
+ }
11
+ function format(amount, unit, future) {
12
+ const value = `${amount}${unit}`;
13
+ return future ? `in ${value}` : `${value} ago`;
14
+ }
15
+ export function formatRelativeTime(value, options = {}) {
16
+ const target = toDate(value);
17
+ const now = toDate(options.now ?? Date.now());
18
+ if (Number.isNaN(target.getTime()) || Number.isNaN(now.getTime())) {
19
+ return "Unknown";
20
+ }
21
+ const diff = target.getTime() - now.getTime();
22
+ const abs = Math.abs(diff);
23
+ const future = diff > 0;
24
+ if (abs < 45 * SECOND) {
25
+ return future && abs > 5 * SECOND ? "in a few seconds" : "just now";
26
+ }
27
+ if (abs < 45 * MINUTE) {
28
+ return format(Math.round(abs / MINUTE), "m", future);
29
+ }
30
+ if (abs < 22 * HOUR) {
31
+ return format(Math.round(abs / HOUR), "h", future);
32
+ }
33
+ if (abs < 36 * HOUR && options.numeric !== "always") {
34
+ return future ? "tomorrow" : "yesterday";
35
+ }
36
+ if (abs < 6 * DAY) {
37
+ return format(Math.round(abs / DAY), "d", future);
38
+ }
39
+ if (abs < 4 * WEEK) {
40
+ return format(Math.round(abs / WEEK), "w", future);
41
+ }
42
+ if (abs < YEAR) {
43
+ return format(Math.round(abs / MONTH), "mo", future);
44
+ }
45
+ return format(Math.round(abs / YEAR), "y", future);
46
+ }
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@forwardreach/saas-ui",
3
+ "version": "0.4.0",
4
+ "description": "Brand-neutral React UI primitives and SaaS app patterns for ForwardReach-owned business applications.",
5
+ "type": "module",
6
+ "private": false,
7
+ "license": "UNLICENSED",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/forwardreach/saas-shared.git",
11
+ "directory": "packages/saas-ui"
12
+ },
13
+ "homepage": "https://github.com/forwardreach/saas-shared/tree/main/packages/saas-ui#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/forwardreach/saas-shared/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "CHANGELOG.md"
25
+ ],
26
+ "sideEffects": [
27
+ "**/*.css"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ },
34
+ "./components": {
35
+ "types": "./dist/components/index.d.ts",
36
+ "import": "./dist/components/index.js"
37
+ },
38
+ "./utils": {
39
+ "types": "./dist/utils/index.d.ts",
40
+ "import": "./dist/utils/index.js"
41
+ },
42
+ "./styles.css": "./dist/styles/index.css",
43
+ "./styles": "./dist/styles/index.css",
44
+ "./package.json": "./package.json"
45
+ },
46
+ "peerDependencies": {
47
+ "react": "^19.0.0",
48
+ "react-dom": "^19.0.0"
49
+ },
50
+ "dependencies": {
51
+ "@radix-ui/react-avatar": "^1.1.10",
52
+ "@radix-ui/react-dialog": "^1.1.15",
53
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
54
+ "@radix-ui/react-scroll-area": "^1.2.10",
55
+ "@radix-ui/react-separator": "^1.1.7",
56
+ "@radix-ui/react-slot": "^1.2.4",
57
+ "@radix-ui/react-tabs": "^1.1.13",
58
+ "@radix-ui/react-tooltip": "^1.2.8",
59
+ "class-variance-authority": "^0.7.1",
60
+ "clsx": "^2.1.1",
61
+ "lucide-react": "^0.468.0",
62
+ "tailwind-merge": "^3.3.1"
63
+ },
64
+ "devDependencies": {
65
+ "@testing-library/jest-dom": "^6.6.3",
66
+ "@testing-library/react": "^16.3.0",
67
+ "@testing-library/user-event": "^14.6.1",
68
+ "@types/react": "^19.0.0",
69
+ "@types/react-dom": "^19.0.0",
70
+ "jsdom": "^26.1.0",
71
+ "rimraf": "^6.0.1",
72
+ "typescript": "^5.8.3",
73
+ "vitest": "^3.2.4"
74
+ },
75
+ "scripts": {
76
+ "build": "pnpm clean && tsc -p tsconfig.build.json && node scripts/copy-styles.mjs",
77
+ "dev": "node scripts/copy-styles.mjs && tsc -p tsconfig.build.json --watch",
78
+ "check:brand": "node scripts/check-brand-neutrality.mjs",
79
+ "clean": "rimraf dist coverage dist-pack *.tgz",
80
+ "pack:local": "pnpm build && mkdir -p dist-pack && pnpm pack --pack-destination dist-pack",
81
+ "test": "vitest run && pnpm check:brand",
82
+ "typecheck": "tsc --noEmit"
83
+ }
84
+ }