@brightweblabs/ui 0.4.0 → 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 (38) hide show
  1. package/package.json +43 -7
  2. package/src/components/action.tsx +12 -0
  3. package/src/components/activity-message.tsx +1 -1
  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 +22 -1
  38. 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,8 +35,20 @@ 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";
30
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";
31
52
  export {
32
53
  formatActivityValue,
33
54
  toActivityChanges,
@@ -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
+ }