@brightweblabs/ui 0.3.0 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brightweblabs/ui",
3
3
  "private": false,
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "files": [
@@ -17,6 +17,8 @@
17
17
  },
18
18
  "exports": {
19
19
  ".": "./src/index.ts",
20
+ "./activity-format": "./src/lib/activity-format.ts",
21
+ "./activity-message": "./src/components/activity-message.tsx",
20
22
  "./alert-dialog": "./src/components/alert-dialog.tsx",
21
23
  "./badge": "./src/components/badge.tsx",
22
24
  "./button": "./src/components/button.tsx",
@@ -35,13 +37,13 @@
35
37
  "class-variance-authority": "^0.7.1",
36
38
  "clsx": "^2.1.1",
37
39
  "date-fns": "^4.1.0",
38
- "lucide-react": "^0.562.0",
40
+ "lucide-react": "^0.577.0",
39
41
  "next": "16.1.6",
40
42
  "next-themes": "^0.4.6",
41
43
  "radix-ui": "^1.4.3",
42
- "react": "19.2.3",
44
+ "react": "19.2.4",
43
45
  "react-day-picker": "^9.13.2",
44
- "react-dom": "19.2.3",
46
+ "react-dom": "19.2.4",
45
47
  "recharts": "^2.15.4",
46
48
  "sonner": "^2.0.7",
47
49
  "tailwind-merge": "^3.4.0"
@@ -0,0 +1,24 @@
1
+ import type { MsgSeg } from "../lib/activity-format";
2
+
3
+ /** Render a composed activity message — actor/entity names semibold, values medium. */
4
+ export function ActivityMessage({ segs }: { segs: MsgSeg[] }) {
5
+ return (
6
+ <>
7
+ {segs.map((seg, index) => {
8
+ if (typeof seg === "string") return <span key={index}>{seg}</span>;
9
+ if ("b" in seg) {
10
+ return (
11
+ <span key={index} className="font-semibold text-[color:var(--foreground)]">
12
+ {seg.b}
13
+ </span>
14
+ );
15
+ }
16
+ return (
17
+ <span key={index} className="font-medium text-[color:var(--foreground)]">
18
+ {seg.v}
19
+ </span>
20
+ );
21
+ })}
22
+ </>
23
+ );
24
+ }
@@ -9,22 +9,24 @@ interface PasswordInputProps extends Omit<React.ComponentProps<"input">, "type">
9
9
  }
10
10
 
11
11
  const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
12
- ({ className, showToggle = true, ...props }, ref) => {
12
+ ({ className, disabled, showToggle = true, ...props }, ref) => {
13
13
  const [showPassword, setShowPassword] = React.useState(false);
14
14
 
15
15
  return (
16
- <div className="relative">
16
+ <div className="relative w-full">
17
17
  <Input
18
18
  type={showPassword ? "text" : "password"}
19
- className={cn("pr-10", className)}
19
+ className={cn(showToggle && "pr-12", className)}
20
+ disabled={disabled}
20
21
  ref={ref}
21
22
  {...props}
22
23
  />
23
24
  {showToggle && (
24
25
  <button
25
26
  type="button"
26
- onClick={() => setShowPassword(!showPassword)}
27
- className="absolute right-3 top-1/2 -translate-y-1/2 text-foreground/40 hover:text-foreground/70 transition-colors"
27
+ onClick={() => setShowPassword((current) => !current)}
28
+ className="absolute right-2 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center text-foreground/40 transition-colors hover:text-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:pointer-events-none disabled:opacity-50"
29
+ disabled={disabled}
28
30
  aria-label={showPassword ? "Ocultar palavra-passe" : "Mostrar palavra-passe"}
29
31
  tabIndex={-1}
30
32
  >
package/src/index.ts CHANGED
@@ -27,3 +27,12 @@ export * from "./components/sheet";
27
27
  export * from "./components/table";
28
28
  export { Toaster } from "./components/sonner";
29
29
  export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
30
+ export { ActivityMessage } from "./components/activity-message";
31
+ export {
32
+ formatActivityValue,
33
+ toActivityChanges,
34
+ type ActivityChange,
35
+ type ActivityChangesOptions,
36
+ type ActivityValueOptions,
37
+ type MsgSeg,
38
+ } 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
+ }