@brightweblabs/ui 0.3.1 → 1.0.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 (39) hide show
  1. package/package.json +45 -7
  2. package/src/components/action.tsx +12 -0
  3. package/src/components/activity-message.tsx +24 -0
  4. package/src/components/alert-dialog.tsx +4 -4
  5. package/src/components/avatar.tsx +80 -0
  6. package/src/components/badge.tsx +20 -30
  7. package/src/components/button-variants.ts +47 -0
  8. package/src/components/button.tsx +102 -53
  9. package/src/components/calendar.tsx +5 -4
  10. package/src/components/chart.tsx +4 -4
  11. package/src/components/dropdown-menu.tsx +5 -5
  12. package/src/components/empty-state.tsx +26 -0
  13. package/src/components/field.tsx +2 -2
  14. package/src/components/initials-avatar.tsx +30 -0
  15. package/src/components/input.tsx +1 -1
  16. package/src/components/kpi-breakdown-bar.tsx +71 -0
  17. package/src/components/label.tsx +1 -1
  18. package/src/components/pagination.tsx +2 -3
  19. package/src/components/password-input.tsx +10 -43
  20. package/src/components/password-strength.tsx +9 -10
  21. package/src/components/phone-input.tsx +114 -0
  22. package/src/components/popover.tsx +1 -1
  23. package/src/components/role-badge.tsx +34 -0
  24. package/src/components/search-field.tsx +63 -0
  25. package/src/components/section-heading.tsx +28 -0
  26. package/src/components/sheet.tsx +34 -5
  27. package/src/components/skeleton-table.tsx +27 -0
  28. package/src/components/skeleton.tsx +45 -0
  29. package/src/components/sonner.tsx +2 -1
  30. package/src/components/stat-tile.tsx +30 -0
  31. package/src/components/status-pill.tsx +37 -0
  32. package/src/components/surface-card.tsx +11 -0
  33. package/src/components/table-pagination.tsx +107 -0
  34. package/src/components/table.tsx +7 -4
  35. package/src/components/theme-provider.tsx +130 -0
  36. package/src/components/tooltip.tsx +2 -2
  37. package/src/index.ts +31 -1
  38. package/src/lib/activity-format.ts +96 -0
  39. package/src/lib/patterns.ts +52 -0
@@ -0,0 +1,45 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ export function Skeleton({ className = "", rounded = "var(--radius)", style }: {
4
+ className?: string;
5
+ rounded?: string;
6
+ style?: CSSProperties;
7
+ }) {
8
+ return <span aria-hidden className={`skeleton-ghost block ${className}`} style={{ borderRadius: rounded, ...style }} />;
9
+ }
10
+
11
+ export function SkeletonLine({ w = "100%", className = "" }: { w?: string; className?: string }) {
12
+ return <Skeleton rounded="999px" className={`h-[0.6rem] ${className}`} style={{ width: w }} />;
13
+ }
14
+
15
+ export function SkeletonText({ lines = 3, className = "" }: { lines?: number; className?: string }) {
16
+ const widths = ["100%", "92%", "84%", "96%", "70%"];
17
+ return (
18
+ <div className={`flex flex-col gap-2 ${className}`}>
19
+ {Array.from({ length: lines }, (_, index) => (
20
+ <SkeletonLine key={index} w={index === lines - 1 ? "55%" : widths[index % widths.length]} />
21
+ ))}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ export function SkeletonCard({ lines = 3, meta = true, className = "", bodyClassName = "" }: {
27
+ lines?: number;
28
+ meta?: boolean;
29
+ className?: string;
30
+ bodyClassName?: string;
31
+ }) {
32
+ return (
33
+ <div className={`flex flex-col gap-4 rounded-[var(--radius-card)] border border-border bg-card p-5 shadow-[0_1px_2px_var(--hairline)] ${className}`}>
34
+ <div className="flex flex-col gap-2">
35
+ <SkeletonLine w="45%" className="h-[0.7rem]" />
36
+ {meta ? <SkeletonLine w="28%" /> : null}
37
+ </div>
38
+ <SkeletonText lines={lines} className={bodyClassName} />
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export function SkeletonCircle({ size = "2.25rem", className = "" }: { size?: string; className?: string }) {
44
+ return <Skeleton rounded="50%" className={`shrink-0 ${className}`} style={{ width: size, height: size }} />;
45
+ }
@@ -1,9 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
4
- import { useTheme } from "next-themes";
5
4
  import { Toaster as Sonner, type ToasterProps } from "sonner";
6
5
 
6
+ import { useTheme } from "./theme-provider";
7
+
7
8
  const Toaster = ({ ...props }: ToasterProps) => {
8
9
  const { theme = "system" } = useTheme();
9
10
 
@@ -0,0 +1,30 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ export type StatTileProps = HTMLAttributes<HTMLDivElement> & {
6
+ label: ReactNode;
7
+ };
8
+
9
+ export function StatTile({ label, children, className, ...props }: StatTileProps) {
10
+ return (
11
+ <div className={cn("stat-cell", className)} {...props}>
12
+ <p className="text-ui-label">{label}</p>
13
+ <div className="mt-3">{children}</div>
14
+ </div>
15
+ );
16
+ }
17
+
18
+ export type StatValueProps = HTMLAttributes<HTMLParagraphElement> & {
19
+ size?: "normal" | "large" | "display";
20
+ };
21
+
22
+ const statValueSizeClasses: Record<NonNullable<StatValueProps["size"]>, string> = {
23
+ normal: "text-ui-metric",
24
+ large: "text-ui-metric-xl",
25
+ display: "text-ui-metric-display",
26
+ };
27
+
28
+ export function StatValue({ size = "normal", className, ...props }: StatValueProps) {
29
+ return <p className={cn(statValueSizeClasses[size], className)} {...props} />;
30
+ }
@@ -0,0 +1,37 @@
1
+ import { Slot } from "@radix-ui/react-slot";
2
+ import { tintPill } from "@brightweblabs/theme/tint";
3
+ import type { ComponentProps, CSSProperties, ReactNode } from "react";
4
+
5
+ import { cn } from "../lib/utils";
6
+
7
+ export type StatusPillSize = "small" | "normal";
8
+
9
+ export const STATUS_PILL_BASE_CLASS =
10
+ "inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border font-semibold leading-none";
11
+
12
+ export const STATUS_PILL_SIZE_CLASSES: Record<StatusPillSize, string> = {
13
+ small: "h-5 px-2 text-ui-micro",
14
+ normal: "h-7 px-3 text-ui-meta",
15
+ };
16
+
17
+ export type StatusPillProps = Omit<ComponentProps<"span">, "children"> & {
18
+ children: ReactNode;
19
+ token: string;
20
+ size?: StatusPillSize;
21
+ asChild?: boolean;
22
+ };
23
+
24
+ export function StatusPill({ children, token, size = "small", asChild = false, className, style, ...props }: StatusPillProps) {
25
+ const Comp = asChild ? Slot : "span";
26
+ const tint = tintPill(token);
27
+
28
+ return (
29
+ <Comp
30
+ className={cn(STATUS_PILL_BASE_CLASS, STATUS_PILL_SIZE_CLASSES[size], tint.className, className)}
31
+ style={{ ...tint.style, ...style } as CSSProperties}
32
+ {...props}
33
+ >
34
+ {children}
35
+ </Comp>
36
+ );
37
+ }
@@ -0,0 +1,11 @@
1
+ import type { HTMLAttributes } from "react";
2
+
3
+ import { cn } from "../lib/utils";
4
+
5
+ export type SurfaceCardProps = HTMLAttributes<HTMLElement> & {
6
+ isLight?: boolean;
7
+ };
8
+
9
+ export function SurfaceCard({ isLight = false, className, ...props }: SurfaceCardProps) {
10
+ return <article className={cn("surface-card", isLight && "is-light", className)} {...props} />;
11
+ }
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ import { ChevronLeft, ChevronRight } from "lucide-react";
4
+
5
+ import { getPaginationWindow } from "../lib/patterns";
6
+ import { cn } from "../lib/utils";
7
+ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink } from "./pagination";
8
+
9
+ export type TablePaginationProps = {
10
+ page: number;
11
+ totalPages: number;
12
+ onPageChange: (page: number) => void;
13
+ summary?: string;
14
+ className?: string;
15
+ previousLabel?: string;
16
+ nextLabel?: string;
17
+ pageLabel?: (page: number, totalPages: number) => string;
18
+ };
19
+
20
+ export function TablePagination({
21
+ page,
22
+ totalPages,
23
+ onPageChange,
24
+ summary,
25
+ className,
26
+ previousLabel = "Go to the previous page",
27
+ nextLabel = "Go to the next page",
28
+ pageLabel = (currentPage, pageCount) => `Page ${currentPage} of ${pageCount}`,
29
+ }: TablePaginationProps) {
30
+ const safeTotalPages = Math.max(1, Math.trunc(totalPages));
31
+ const safePage = Math.min(Math.max(Math.trunc(page), 1), safeTotalPages);
32
+ const items = getPaginationWindow(safePage, safeTotalPages);
33
+
34
+ const changePage = (nextPage: number) => {
35
+ const clampedPage = Math.min(Math.max(nextPage, 1), safeTotalPages);
36
+ if (clampedPage !== safePage) onPageChange(clampedPage);
37
+ };
38
+
39
+ return (
40
+ <div className={cn("flex min-w-0 flex-col gap-2 border-t border-hairline px-4 py-2 md:flex-row md:items-center md:justify-between", className)}>
41
+ {summary ? (
42
+ <p className="min-w-0 truncate text-ui-meta">
43
+ <span className="font-semibold text-foreground">{pageLabel(safePage, safeTotalPages)}</span>
44
+ <span className="px-1.5 text-border">·</span>
45
+ {summary}
46
+ </p>
47
+ ) : null}
48
+ <Pagination className="mx-0 w-auto min-w-0 shrink-0 justify-start md:ml-auto md:justify-end">
49
+ <PaginationContent className="gap-1.5">
50
+ <PaginationItem>
51
+ <PaginationLink
52
+ size="sm"
53
+ href="#"
54
+ aria-label={previousLabel}
55
+ aria-disabled={safePage === 1}
56
+ onClick={(event) => {
57
+ event.preventDefault();
58
+ changePage(safePage - 1);
59
+ }}
60
+ className={cn("size-8 rounded-full border border-transparent text-muted-foreground hover:border-hairline-strong hover:bg-surface-hover hover:text-foreground", safePage === 1 && "pointer-events-none opacity-45")}
61
+ >
62
+ <ChevronLeft className="size-4" />
63
+ </PaginationLink>
64
+ </PaginationItem>
65
+ {items.map((item) => typeof item === "number" ? (
66
+ <PaginationItem key={item}>
67
+ <PaginationLink
68
+ size="sm"
69
+ href="#"
70
+ isActive={item === safePage}
71
+ onClick={(event) => {
72
+ event.preventDefault();
73
+ changePage(item);
74
+ }}
75
+ className={cn(
76
+ "size-8 rounded-full text-ui-label normal-case tracking-normal",
77
+ item === safePage ? "border-hairline-strong bg-elevate-2 text-foreground" : "text-muted-foreground hover:bg-surface-hover hover:text-foreground",
78
+ )}
79
+ >
80
+ {item}
81
+ </PaginationLink>
82
+ </PaginationItem>
83
+ ) : (
84
+ <PaginationItem key={item}>
85
+ <PaginationEllipsis />
86
+ </PaginationItem>
87
+ ))}
88
+ <PaginationItem>
89
+ <PaginationLink
90
+ size="sm"
91
+ href="#"
92
+ aria-label={nextLabel}
93
+ aria-disabled={safePage === safeTotalPages}
94
+ onClick={(event) => {
95
+ event.preventDefault();
96
+ changePage(safePage + 1);
97
+ }}
98
+ className={cn("size-8 rounded-full border border-transparent text-muted-foreground hover:border-hairline-strong hover:bg-surface-hover hover:text-foreground", safePage === safeTotalPages && "pointer-events-none opacity-45")}
99
+ >
100
+ <ChevronRight className="size-4" />
101
+ </PaginationLink>
102
+ </PaginationItem>
103
+ </PaginationContent>
104
+ </Pagination>
105
+ </div>
106
+ );
107
+ }
@@ -2,9 +2,12 @@ import * as React from "react";
2
2
 
3
3
  import { cn } from "../lib/utils";
4
4
 
5
- const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
6
- ({ className, ...props }, ref) => (
7
- <div className="w-full overflow-auto">
5
+ const Table = React.forwardRef<
6
+ HTMLTableElement,
7
+ React.HTMLAttributes<HTMLTableElement> & { containerClassName?: string }
8
+ >(
9
+ ({ className, containerClassName, ...props }, ref) => (
10
+ <div className={cn("w-full flex-1 overflow-auto", containerClassName)}>
8
11
  <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
9
12
  </div>
10
13
  ),
@@ -25,7 +28,7 @@ TableBody.displayName = "TableBody";
25
28
 
26
29
  const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
27
30
  ({ className, ...props }, ref) => (
28
- <tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
31
+ <tfoot ref={ref} className={cn("border-t bg-muted/50 font-semibold [&>tr]:last:border-b-0", className)} {...props} />
29
32
  ),
30
33
  );
31
34
  TableFooter.displayName = "TableFooter";
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState } from "react";
4
+ import type { ReactNode } from "react";
5
+
6
+ type Theme = "light" | "dark" | "system";
7
+
8
+ type ThemeProviderProps = {
9
+ children: ReactNode;
10
+ defaultTheme?: Theme;
11
+ enableSystem?: boolean;
12
+ disableTransitionOnChange?: boolean;
13
+ };
14
+
15
+ type ThemeContextValue = {
16
+ theme: Theme;
17
+ setTheme: (theme: string) => void;
18
+ resolvedTheme: "light" | "dark";
19
+ systemTheme: "light" | "dark";
20
+ themes: Theme[];
21
+ };
22
+
23
+ const STORAGE_KEY = "theme";
24
+ const THEME_VALUES: Theme[] = ["light", "dark", "system"];
25
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
26
+
27
+ function getSystemTheme(): "light" | "dark" {
28
+ if (typeof window === "undefined") return "light";
29
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
30
+ }
31
+
32
+ function readStoredTheme(defaultTheme: Theme): Theme {
33
+ if (typeof window === "undefined") return defaultTheme;
34
+ try {
35
+ const stored = window.localStorage.getItem(STORAGE_KEY);
36
+ return stored === "light" || stored === "dark" || stored === "system" ? stored : defaultTheme;
37
+ } catch {
38
+ return defaultTheme;
39
+ }
40
+ }
41
+
42
+ function applyResolvedTheme(resolvedTheme: "light" | "dark", disableTransitionOnChange: boolean) {
43
+ const root = document.documentElement;
44
+ let cleanup: (() => void) | null = null;
45
+
46
+ if (disableTransitionOnChange) {
47
+ const style = document.createElement("style");
48
+ style.appendChild(document.createTextNode("*,*::before,*::after{transition:none!important;animation-duration:0s!important}"));
49
+ document.head.appendChild(style);
50
+ cleanup = () => {
51
+ window.getComputedStyle(document.body);
52
+ window.setTimeout(() => style.remove(), 1);
53
+ };
54
+ }
55
+
56
+ root.classList.remove("light", "dark");
57
+ root.classList.add(resolvedTheme);
58
+ root.dataset.theme = resolvedTheme;
59
+ root.style.colorScheme = resolvedTheme;
60
+ cleanup?.();
61
+ }
62
+
63
+ export function ThemeProvider({
64
+ children,
65
+ defaultTheme = "light",
66
+ enableSystem = true,
67
+ disableTransitionOnChange = false,
68
+ }: ThemeProviderProps) {
69
+ const [theme, setThemeState] = useState<Theme>(defaultTheme);
70
+ const [systemTheme, setSystemTheme] = useState<"light" | "dark">("light");
71
+ const resolvedTheme = theme === "system" && enableSystem ? systemTheme : theme === "dark" ? "dark" : "light";
72
+
73
+ useLayoutEffect(() => {
74
+ applyResolvedTheme(resolvedTheme, disableTransitionOnChange);
75
+ }, [disableTransitionOnChange, resolvedTheme]);
76
+
77
+ useEffect(() => {
78
+ setThemeState(readStoredTheme(defaultTheme));
79
+ setSystemTheme(getSystemTheme());
80
+ }, [defaultTheme]);
81
+
82
+ useEffect(() => {
83
+ if (!enableSystem) return;
84
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
85
+ const handleChange = () => setSystemTheme(media.matches ? "dark" : "light");
86
+ handleChange();
87
+ media.addEventListener("change", handleChange);
88
+ return () => media.removeEventListener("change", handleChange);
89
+ }, [enableSystem]);
90
+
91
+ useEffect(() => {
92
+ const handleStorage = (event: StorageEvent) => {
93
+ if (event.key === STORAGE_KEY) setThemeState(readStoredTheme(defaultTheme));
94
+ };
95
+ window.addEventListener("storage", handleStorage);
96
+ return () => window.removeEventListener("storage", handleStorage);
97
+ }, [defaultTheme]);
98
+
99
+ const setTheme = useCallback((nextTheme: string) => {
100
+ const normalizedTheme: Theme = nextTheme === "dark" || nextTheme === "light" || nextTheme === "system" ? nextTheme : defaultTheme;
101
+ setThemeState(normalizedTheme);
102
+ try {
103
+ window.localStorage.setItem(STORAGE_KEY, normalizedTheme);
104
+ } catch {
105
+ // In-memory theme state remains usable when storage is unavailable.
106
+ }
107
+ }, [defaultTheme]);
108
+
109
+ const value = useMemo<ThemeContextValue>(() => ({
110
+ theme,
111
+ setTheme,
112
+ resolvedTheme,
113
+ systemTheme,
114
+ themes: enableSystem ? THEME_VALUES : ["light", "dark"],
115
+ }), [enableSystem, resolvedTheme, setTheme, systemTheme, theme]);
116
+
117
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
118
+ }
119
+
120
+ export function useTheme(): ThemeContextValue {
121
+ return useContext(ThemeContext) ?? {
122
+ theme: "light",
123
+ setTheme: () => undefined,
124
+ resolvedTheme: "light",
125
+ systemTheme: "light",
126
+ themes: THEME_VALUES,
127
+ };
128
+ }
129
+
130
+ export type { Theme, ThemeContextValue, ThemeProviderProps };
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { Tooltip as TooltipPrimitive } from "radix-ui"
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5
5
 
6
6
  import { cn } from "../lib/utils"
7
7
 
@@ -41,7 +41,7 @@ function TooltipContent({
41
41
  data-slot="tooltip-content"
42
42
  sideOffset={sideOffset}
43
43
  className={cn(
44
- "z-[1300] overflow-hidden rounded-xl border border-black/12 bg-white/95 px-2.5 py-1.5 text-xs font-medium text-foreground/90 shadow-[0_14px_34px_rgba(8,24,16,0.12)] backdrop-blur-xl data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 dark:border-white/12 dark:bg-black/70 dark:text-foreground",
44
+ "z-[1300] overflow-hidden rounded-[var(--radius-card)] border border-[color:var(--hairline-strong)] bg-[color:color-mix(in_srgb,var(--popover)_95%,transparent)] px-2.5 py-1.5 text-ui-meta !font-semibold text-popover-foreground shadow-[0_14px_34px_var(--elevate-3)] backdrop-blur-xl data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
45
45
  className
46
46
  )}
47
47
  {...props}
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { Button, buttonVariants } from "./components/button";
2
- export { Badge } from "./components/badge";
2
+ export type { ButtonProps, ButtonVariantProps } from "./components/button";
3
+ export { Badge, badgeVariants } from "./components/badge";
4
+ export * from "./components/avatar";
3
5
  export * from "./components/dropdown-menu";
4
6
  export {
5
7
  Field,
@@ -13,7 +15,14 @@ export {
13
15
  } from "./components/field";
14
16
  export { Input } from "./components/input";
15
17
  export { PasswordInput } from "./components/password-input";
18
+ export type { PasswordInputProps } from "./components/password-input";
16
19
  export { PasswordStrength } from "./components/password-strength";
20
+ export { PhoneInput } from "./components/phone-input";
21
+ export type { PhoneInputProps } from "./components/phone-input";
22
+ export { SearchField } from "./components/search-field";
23
+ export type { SearchFieldProps } from "./components/search-field";
24
+ export * from "./components/skeleton";
25
+ export { TableRowsSkeleton } from "./components/skeleton-table";
17
26
  export { Separator } from "./components/separator";
18
27
  export * from "./components/alert-dialog";
19
28
  export * from "./components/card";
@@ -26,4 +35,25 @@ export * from "./components/popover";
26
35
  export * from "./components/sheet";
27
36
  export * from "./components/table";
28
37
  export { Toaster } from "./components/sonner";
38
+ export { ThemeProvider, useTheme } from "./components/theme-provider";
39
+ export type { Theme, ThemeContextValue, ThemeProviderProps } from "./components/theme-provider";
29
40
  export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
41
+ export { ActivityMessage } from "./components/activity-message";
42
+ export * from "./components/action";
43
+ export * from "./components/empty-state";
44
+ export * from "./components/initials-avatar";
45
+ export * from "./components/kpi-breakdown-bar";
46
+ export * from "./components/role-badge";
47
+ export * from "./components/section-heading";
48
+ export * from "./components/stat-tile";
49
+ export * from "./components/status-pill";
50
+ export * from "./components/surface-card";
51
+ export * from "./components/table-pagination";
52
+ export {
53
+ formatActivityValue,
54
+ toActivityChanges,
55
+ type ActivityChange,
56
+ type ActivityChangesOptions,
57
+ type ActivityValueOptions,
58
+ type MsgSeg,
59
+ } from "./lib/activity-format";
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Framework-free formatting primitives shared by every activity / timeline
3
+ * surface (notifications bell, project "recent activity" rail, CRM timeline).
4
+ *
5
+ * These are language-neutral: all human-readable strings (field labels, the
6
+ * system fallback, boolean words, the date locale) are injected by the caller
7
+ * so each app/module supplies its own dictionary. Imports nothing from React —
8
+ * safe to use in server and client code alike.
9
+ */
10
+
11
+ /** A rendered message is plain text + emphasised names ({ b }) + emphasised values ({ v }). */
12
+ export type MsgSeg = string | { b: string } | { v: string };
13
+
14
+ /** One field-level change, ready to render as "Label: from → to". */
15
+ export type ActivityChange = { key: string; label: string; from: string | null; to: string | null };
16
+
17
+ export type ActivityValueOptions = {
18
+ /** Intl locale used to format ISO dates. Defaults to "en". */
19
+ locale?: string;
20
+ /** Label for a boolean `true` value. Defaults to "Yes". */
21
+ trueLabel?: string;
22
+ /** Label for a boolean `false` value. Defaults to "No". */
23
+ falseLabel?: string;
24
+ };
25
+
26
+ export type ActivityChangesOptions = ActivityValueOptions & {
27
+ /** Map of payload field name → human label. Unmapped fields fall back to the raw key. */
28
+ fieldLabels?: Record<string, string>;
29
+ /** Field names whose values are people — an empty one reads as the system, not a blank. */
30
+ personFields?: Iterable<string>;
31
+ /** Label used when a person field is explicitly empty. Defaults to "System". */
32
+ systemLabel?: string;
33
+ };
34
+
35
+ function looksLikeId(value: string) {
36
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(value);
37
+ }
38
+
39
+ function isEmpty(value: unknown) {
40
+ return value === null || value === undefined || value === "";
41
+ }
42
+
43
+ /** Render a stored change value into something readable, or null when it can't be shown plainly. */
44
+ export function formatActivityValue(value: unknown, options: ActivityValueOptions = {}): string | null {
45
+ const { locale = "en", trueLabel = "Yes", falseLabel = "No" } = options;
46
+ if (isEmpty(value)) return null;
47
+ if (typeof value === "boolean") return value ? trueLabel : falseLabel;
48
+ if (typeof value === "number") return String(value);
49
+ if (typeof value !== "string") return null;
50
+
51
+ const trimmed = value.trim();
52
+ if (!trimmed) return null;
53
+ // Profile / entity references come through as raw UUIDs — not worth showing.
54
+ if (looksLikeId(trimmed)) return null;
55
+
56
+ // ISO date / datetime → short localised form.
57
+ if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
58
+ const date = new Date(trimmed);
59
+ if (!Number.isNaN(date.getTime())) {
60
+ return new Intl.DateTimeFormat(locale, { day: "2-digit", month: "2-digit", year: "numeric" }).format(date);
61
+ }
62
+ }
63
+
64
+ return trimmed.length > 32 ? `${trimmed.slice(0, 31)}…` : trimmed;
65
+ }
66
+
67
+ /** Pull the `payload.changes` map ({ field: { from, to } }) into a flat, display-ready list. */
68
+ export function toActivityChanges(
69
+ payload: Record<string, unknown> | undefined,
70
+ options: ActivityChangesOptions = {},
71
+ ): ActivityChange[] {
72
+ const { fieldLabels = {}, systemLabel = "System" } = options;
73
+ const personFields = new Set(options.personFields ?? []);
74
+ const changesRaw = payload?.changes;
75
+ if (!changesRaw || typeof changesRaw !== "object" || Array.isArray(changesRaw)) return [];
76
+
77
+ return Object.entries(changesRaw as Record<string, unknown>).flatMap(([field, raw]) => {
78
+ if (!raw || typeof raw !== "object") return [];
79
+ const { from, to } = raw as { from?: unknown; to?: unknown };
80
+ const isPerson = personFields.has(field);
81
+ // People always read as someone: an explicitly empty assignee/owner is the
82
+ // system. An unresolved id (formats to null) is left blank rather than
83
+ // wrongly attributed to the system.
84
+ const resolve = (value: unknown) => {
85
+ const formatted = formatActivityValue(value, options);
86
+ if (formatted) return formatted;
87
+ return isPerson && isEmpty(value) ? systemLabel : null;
88
+ };
89
+ return [{
90
+ key: field,
91
+ label: fieldLabels[field] ?? field,
92
+ from: resolve(from),
93
+ to: resolve(to),
94
+ }];
95
+ });
96
+ }
@@ -0,0 +1,52 @@
1
+ export type PaginationWindowItem = number | "start-ellipsis" | "end-ellipsis";
2
+
3
+ export function getPaginationWindow(page: number, totalPages: number): PaginationWindowItem[] {
4
+ const safeTotal = Math.max(1, Math.trunc(totalPages));
5
+ const safePage = Math.min(Math.max(Math.trunc(page), 1), safeTotal);
6
+
7
+ if (safeTotal <= 5) {
8
+ return Array.from({ length: safeTotal }, (_, index) => index + 1);
9
+ }
10
+
11
+ const pages = new Set([1, safeTotal, safePage - 1, safePage, safePage + 1]);
12
+ const visible = Array.from(pages).filter((value) => value >= 1 && value <= safeTotal).sort((a, b) => a - b);
13
+ const result: PaginationWindowItem[] = [];
14
+
15
+ visible.forEach((value, index) => {
16
+ const previous = visible[index - 1];
17
+ if (previous && value - previous > 1) {
18
+ result.push(previous === 1 ? "start-ellipsis" : "end-ellipsis");
19
+ }
20
+ result.push(value);
21
+ });
22
+
23
+ return result;
24
+ }
25
+
26
+ export function getInitials(label?: string | null, fallback?: string | null): string {
27
+ const source = (label || fallback || "?").trim();
28
+ if (!source) return "?";
29
+
30
+ return source
31
+ .split(/\s+/)
32
+ .map((token) => token.charAt(0))
33
+ .filter(Boolean)
34
+ .slice(0, 2)
35
+ .join("")
36
+ .toUpperCase();
37
+ }
38
+
39
+ export function getRoleLabel(role: string): string {
40
+ return role
41
+ .trim()
42
+ .replace(/[_-]+/g, " ")
43
+ .replace(/\b\w/g, (character) => character.toUpperCase());
44
+ }
45
+
46
+ export function resolveRoleToken(
47
+ role: string,
48
+ tokenMap: Readonly<Record<string, string>>,
49
+ fallbackToken = "--semantic-neutral",
50
+ ): string {
51
+ return tokenMap[role] ?? fallbackToken;
52
+ }