@hexclave/dashboard-ui-components 1.0.3 → 1.0.5

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 (52) hide show
  1. package/dist/components/button.d.ts +2 -2
  2. package/dist/components/pill-toggle.js +2 -2
  3. package/dist/components/pill-toggle.js.map +1 -1
  4. package/dist/dashboard-ui-components.global.js +7447 -9242
  5. package/dist/dashboard-ui-components.global.js.map +4 -4
  6. package/dist/esm/components/button.d.ts +2 -2
  7. package/dist/esm/components/pill-toggle.js +3 -3
  8. package/dist/esm/components/pill-toggle.js.map +1 -1
  9. package/package.json +4 -3
  10. package/src/components/alert.tsx +120 -0
  11. package/src/components/analytics-chart/analytics-chart-pie.tsx +369 -0
  12. package/src/components/analytics-chart/analytics-chart.tsx +1585 -0
  13. package/src/components/analytics-chart/default-analytics-chart-tooltip.tsx +265 -0
  14. package/src/components/analytics-chart/format.ts +101 -0
  15. package/src/components/analytics-chart/index.ts +75 -0
  16. package/src/components/analytics-chart/palette.ts +68 -0
  17. package/src/components/analytics-chart/render-data-series.tsx +169 -0
  18. package/src/components/analytics-chart/state.ts +165 -0
  19. package/src/components/analytics-chart/strings.ts +72 -0
  20. package/src/components/analytics-chart/types.ts +220 -0
  21. package/src/components/badge.tsx +108 -0
  22. package/src/components/button.tsx +104 -0
  23. package/src/components/card.tsx +263 -0
  24. package/src/components/chart-card.tsx +101 -0
  25. package/src/components/chart-container.tsx +155 -0
  26. package/src/components/chart-legend.tsx +65 -0
  27. package/src/components/chart-theme.tsx +52 -0
  28. package/src/components/chart-tooltip.tsx +165 -0
  29. package/src/components/cursor-blast-effect.tsx +334 -0
  30. package/src/components/data-grid/data-grid-sizing.ts +51 -0
  31. package/src/components/data-grid/data-grid-toolbar.tsx +373 -0
  32. package/src/components/data-grid/data-grid.test.tsx +366 -0
  33. package/src/components/data-grid/data-grid.tsx +1220 -0
  34. package/src/components/data-grid/index.ts +64 -0
  35. package/src/components/data-grid/state.ts +235 -0
  36. package/src/components/data-grid/strings.ts +45 -0
  37. package/src/components/data-grid/types.ts +401 -0
  38. package/src/components/data-grid/use-data-source.ts +413 -0
  39. package/src/components/data-grid/use-url-state.test.tsx +88 -0
  40. package/src/components/data-grid/use-url-state.ts +298 -0
  41. package/src/components/dialog.tsx +207 -0
  42. package/src/components/edit-mode.tsx +17 -0
  43. package/src/components/empty-state.tsx +64 -0
  44. package/src/components/input.tsx +85 -0
  45. package/src/components/metric-card.tsx +142 -0
  46. package/src/components/pill-toggle.tsx +159 -0
  47. package/src/components/progress-bar.tsx +82 -0
  48. package/src/components/separator.tsx +36 -0
  49. package/src/components/skeleton.tsx +30 -0
  50. package/src/components/table.tsx +113 -0
  51. package/src/components/tabs.tsx +214 -0
  52. package/src/index.ts +69 -0
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as RechartsPrimitive from "recharts";
5
+ import { cn } from "@hexclave/ui";
6
+ import { useDesignChart, getPayloadConfigFromPayload } from "./chart-container";
7
+
8
+ export const DesignChartTooltip = RechartsPrimitive.Tooltip;
9
+
10
+ export const DesignChartTooltipContent = React.forwardRef<
11
+ HTMLDivElement,
12
+ React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
13
+ React.ComponentProps<"div"> & {
14
+ hideLabel?: boolean,
15
+ hideIndicator?: boolean,
16
+ indicator?: "line" | "dot" | "dashed",
17
+ nameKey?: string,
18
+ labelKey?: string,
19
+ }
20
+ >(
21
+ (
22
+ {
23
+ active,
24
+ payload,
25
+ className,
26
+ indicator = "dot",
27
+ hideLabel = false,
28
+ hideIndicator = false,
29
+ label,
30
+ labelFormatter,
31
+ labelClassName,
32
+ formatter,
33
+ color,
34
+ nameKey,
35
+ labelKey,
36
+ },
37
+ ref
38
+ ) => {
39
+ const { config } = useDesignChart();
40
+
41
+ const tooltipLabel = React.useMemo(() => {
42
+ if (hideLabel || !payload?.length) {
43
+ return null;
44
+ }
45
+
46
+ const [item] = payload;
47
+ const key = `${labelKey || item.dataKey || item.name || "value"}`;
48
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
49
+ const configEntry = typeof label === "string" ? config[label as keyof typeof config] : undefined;
50
+ const value =
51
+ !labelKey && typeof label === "string"
52
+ ? configEntry?.label ?? label
53
+ : itemConfig?.label;
54
+
55
+ if (labelFormatter) {
56
+ return (
57
+ <div className={cn("font-medium text-muted-foreground tracking-wide", labelClassName)}>
58
+ {labelFormatter(value, payload)}
59
+ </div>
60
+ );
61
+ }
62
+
63
+ if (!value) {
64
+ return null;
65
+ }
66
+
67
+ return <div className={cn("font-medium text-muted-foreground tracking-wide", labelClassName)}>{value}</div>;
68
+ }, [
69
+ label,
70
+ labelFormatter,
71
+ payload,
72
+ hideLabel,
73
+ labelClassName,
74
+ config,
75
+ labelKey,
76
+ ]);
77
+
78
+ if (!active || !payload?.length) {
79
+ return null;
80
+ }
81
+
82
+ const nestLabel = payload.length === 1 && indicator !== "dot";
83
+
84
+ return (
85
+ <div
86
+ ref={ref}
87
+ className={cn(
88
+ "grid min-w-[8rem] items-start gap-1.5 rounded-xl bg-background/95 px-3.5 py-2.5 text-xs shadow-lg backdrop-blur-xl ring-1 ring-foreground/[0.08]",
89
+ className
90
+ )}
91
+ style={{ zIndex: 9999 }}
92
+ >
93
+ {!nestLabel ? tooltipLabel : null}
94
+ <div className="grid gap-1.5">
95
+ {payload.map((item, index) => {
96
+ const key = `${nameKey || item.name || item.dataKey || "value"}`;
97
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
98
+ const indicatorColor = color || item.payload.fill || item.color;
99
+
100
+ return (
101
+ <div
102
+ key={item.dataKey}
103
+ className={cn(
104
+ "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
105
+ indicator === "dot" && "items-center"
106
+ )}
107
+ >
108
+ {formatter && item.value !== undefined && item.name ? (
109
+ formatter(item.value, item.name, item, index, item.payload)
110
+ ) : (
111
+ <>
112
+ {itemConfig?.icon ? (
113
+ <itemConfig.icon />
114
+ ) : (
115
+ !hideIndicator && (
116
+ <div
117
+ className={cn(
118
+ "shrink-0 rounded-full",
119
+ {
120
+ "h-2 w-2 ring-2 ring-white/20": indicator === "dot",
121
+ "w-1 rounded-[2px]": indicator === "line",
122
+ "w-0 border-[1.5px] border-dashed bg-transparent rounded-[2px]":
123
+ indicator === "dashed",
124
+ "my-0.5": nestLabel && indicator === "dashed",
125
+ }
126
+ )}
127
+ style={
128
+ {
129
+ "--color-bg": indicatorColor,
130
+ "--color-border": indicatorColor,
131
+ backgroundColor: indicator === "dot" ? indicatorColor : undefined,
132
+ } as React.CSSProperties
133
+ }
134
+ />
135
+ )
136
+ )}
137
+ <div
138
+ className={cn(
139
+ "flex flex-1 justify-between leading-none",
140
+ nestLabel ? "items-end" : "items-center"
141
+ )}
142
+ >
143
+ <div className="grid gap-1.5">
144
+ {nestLabel ? tooltipLabel : null}
145
+ <span className="text-[11px] text-muted-foreground">
146
+ {itemConfig?.label || item.name}
147
+ </span>
148
+ </div>
149
+ {item.value != null && (
150
+ <span className="ml-auto font-mono text-xs font-semibold tabular-nums text-foreground">
151
+ {typeof item.value === "number" ? item.value.toLocaleString() : item.value}
152
+ </span>
153
+ )}
154
+ </div>
155
+ </>
156
+ )}
157
+ </div>
158
+ );
159
+ })}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+ );
165
+ DesignChartTooltipContent.displayName = "DesignChartTooltipContent";
@@ -0,0 +1,334 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+
6
+ type Blast = {
7
+ id: number,
8
+ x: number,
9
+ y: number,
10
+ size: number,
11
+ hue: number,
12
+ };
13
+
14
+ const DEFAULT_BLAST_LIFETIME_MS = 720;
15
+ const DEFAULT_MAX_ACTIVE_BLASTS = 18;
16
+
17
+ /** Minimum rapid clicks in the time window to count as a rage click */
18
+ const DEFAULT_RAGE_CLICK_THRESHOLD = 3;
19
+ /** Time window (ms) in which clicks must occur to be considered rage clicking */
20
+ const DEFAULT_RAGE_CLICK_WINDOW_MS = 600;
21
+ /** Max distance (px) between clicks to still count as same-spot rage clicking */
22
+ const DEFAULT_RAGE_CLICK_RADIUS_PX = 60;
23
+
24
+ type RecentClick = {
25
+ time: number,
26
+ x: number,
27
+ y: number,
28
+ };
29
+
30
+ export type CursorBlastEffectProps = {
31
+ /** Lifetime of each blast animation in ms. Default: 720 */
32
+ blastLifetimeMs?: number,
33
+ /** Maximum number of concurrent active blasts. Default: 18 */
34
+ maxActiveBlasts?: number,
35
+ /** Minimum rapid clicks in the time window to trigger a blast. Default: 3 */
36
+ rageClickThreshold?: number,
37
+ /** Time window (ms) for counting rage clicks. Default: 600 */
38
+ rageClickWindowMs?: number,
39
+ /** Max distance (px) between clicks to count as same-spot rage clicking. Default: 60 */
40
+ rageClickRadiusPx?: number,
41
+ /**
42
+ * When provided, the blast effect is scoped to this container element.
43
+ * Clicks are only detected within the container and blasts are positioned
44
+ * relative to the container rather than the viewport.
45
+ */
46
+ containerRef?: React.RefObject<HTMLElement | null>,
47
+ };
48
+
49
+ export function CursorBlastEffect({
50
+ blastLifetimeMs = DEFAULT_BLAST_LIFETIME_MS,
51
+ maxActiveBlasts = DEFAULT_MAX_ACTIVE_BLASTS,
52
+ rageClickThreshold = DEFAULT_RAGE_CLICK_THRESHOLD,
53
+ rageClickWindowMs = DEFAULT_RAGE_CLICK_WINDOW_MS,
54
+ rageClickRadiusPx = DEFAULT_RAGE_CLICK_RADIUS_PX,
55
+ containerRef,
56
+ }: CursorBlastEffectProps = {}) {
57
+ const [blasts, setBlasts] = useState<Blast[]>([]);
58
+ const [mounted, setMounted] = useState(false);
59
+ const idCounterRef = useRef(0);
60
+ const timeoutIdsRef = useRef<Map<number, number>>(new Map());
61
+ const recentClicksRef = useRef<RecentClick[]>([]);
62
+
63
+ // Store latest config in refs so the effect callback always reads current values
64
+ const configRef = useRef({
65
+ blastLifetimeMs,
66
+ maxActiveBlasts,
67
+ rageClickThreshold,
68
+ rageClickWindowMs,
69
+ rageClickRadiusPx,
70
+ });
71
+ configRef.current = {
72
+ blastLifetimeMs,
73
+ maxActiveBlasts,
74
+ rageClickThreshold,
75
+ rageClickWindowMs,
76
+ rageClickRadiusPx,
77
+ };
78
+
79
+ useEffect(() => {
80
+ setMounted(true);
81
+ }, []);
82
+
83
+ useEffect(() => {
84
+ const removeBlast = (id: number) => {
85
+ setBlasts((prev) => prev.filter((blast) => blast.id !== id));
86
+ const timeoutId = timeoutIdsRef.current.get(id);
87
+ if (timeoutId !== undefined) {
88
+ window.clearTimeout(timeoutId);
89
+ timeoutIdsRef.current.delete(id);
90
+ }
91
+ };
92
+
93
+ const spawnBlast = (x: number, y: number) => {
94
+ const cfg = configRef.current;
95
+ const nextId = idCounterRef.current;
96
+ idCounterRef.current += 1;
97
+
98
+ const nextBlast: Blast = {
99
+ id: nextId,
100
+ x,
101
+ y,
102
+ size: 44 + Math.random() * 20,
103
+ hue: 185 + Math.random() * 35,
104
+ };
105
+
106
+ setBlasts((prev) => {
107
+ const next = [...prev, nextBlast];
108
+ if (next.length <= cfg.maxActiveBlasts) {
109
+ return next;
110
+ }
111
+ return next.slice(next.length - cfg.maxActiveBlasts);
112
+ });
113
+
114
+ const timeoutId = window.setTimeout(() => removeBlast(nextId), cfg.blastLifetimeMs);
115
+ timeoutIdsRef.current.set(nextId, timeoutId);
116
+ };
117
+
118
+ const onClick = (event: MouseEvent) => {
119
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
120
+ return;
121
+ }
122
+
123
+ const cfg = configRef.current;
124
+ const now = performance.now();
125
+
126
+ let x: number;
127
+ let y: number;
128
+
129
+ if (containerRef?.current) {
130
+ const rect = containerRef.current.getBoundingClientRect();
131
+ x = event.clientX - rect.left;
132
+ y = event.clientY - rect.top;
133
+ } else {
134
+ x = event.clientX;
135
+ y = event.clientY;
136
+ }
137
+
138
+ // Prune clicks outside the time window
139
+ recentClicksRef.current = recentClicksRef.current.filter(
140
+ (click) => now - click.time < cfg.rageClickWindowMs,
141
+ );
142
+
143
+ recentClicksRef.current.push({ time: now, x, y });
144
+
145
+ // Count how many recent clicks are within the radius of the current click
146
+ const nearbyCount = recentClicksRef.current.filter((click) => {
147
+ const dx = click.x - x;
148
+ const dy = click.y - y;
149
+ return Math.sqrt(dx * dx + dy * dy) <= cfg.rageClickRadiusPx;
150
+ }).length;
151
+
152
+ if (nearbyCount >= cfg.rageClickThreshold) {
153
+ spawnBlast(x, y);
154
+ }
155
+ };
156
+
157
+ const target = containerRef?.current ?? window;
158
+ const timeoutIds = timeoutIdsRef.current;
159
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- EventTarget union requires cast
160
+ (target as EventTarget).addEventListener("click", onClick as EventListener);
161
+ return () => {
162
+ (target as EventTarget).removeEventListener("click", onClick as EventListener);
163
+ for (const timeoutId of timeoutIds.values()) {
164
+ window.clearTimeout(timeoutId);
165
+ }
166
+ timeoutIds.clear();
167
+ };
168
+ }, [containerRef]);
169
+
170
+ if (!mounted) {
171
+ return null;
172
+ }
173
+
174
+ const blastElements = (
175
+ <>
176
+ {blasts.map((blast) => (
177
+ <div
178
+ key={blast.id}
179
+ style={{
180
+ position: "absolute",
181
+ left: blast.x,
182
+ top: blast.y,
183
+ width: blast.size,
184
+ height: blast.size,
185
+ transform: "translate(-50%, -50%)",
186
+ willChange: "transform, opacity",
187
+ filter: `hue-rotate(${blast.hue}deg)`,
188
+ }}
189
+ >
190
+ <span className="cursor-blast-ring" />
191
+ <span className="cursor-blast-core" />
192
+ {Array.from({ length: 10 }).map((_, index) => {
193
+ const angle = (360 / 10) * index;
194
+ return (
195
+ <span
196
+ key={`${blast.id}-${index}`}
197
+ className="cursor-blast-shard-wrap"
198
+ style={{
199
+ transform: `translate(-50%, -50%) rotate(${angle}deg)`,
200
+ animationDelay: `${index * 16}ms`,
201
+ }}
202
+ >
203
+ <span className="cursor-blast-shard" />
204
+ </span>
205
+ );
206
+ })}
207
+ </div>
208
+ ))}
209
+ <style dangerouslySetInnerHTML={{ __html: `
210
+ .cursor-blast-ring {
211
+ position: absolute;
212
+ inset: 0;
213
+ border-radius: 999px;
214
+ border: 2px solid hsl(197 98% 67% / 0.9);
215
+ box-shadow:
216
+ 0 0 22px hsl(191 100% 72% / 0.6),
217
+ inset 0 0 12px hsl(204 100% 77% / 0.65);
218
+ animation: blast-ring 560ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
219
+ }
220
+
221
+ .cursor-blast-core {
222
+ position: absolute;
223
+ left: 50%;
224
+ top: 50%;
225
+ width: 10px;
226
+ height: 10px;
227
+ border-radius: 999px;
228
+ transform: translate(-50%, -50%);
229
+ background: hsl(196 100% 85%);
230
+ box-shadow:
231
+ 0 0 26px hsl(193 100% 72% / 0.9),
232
+ 0 0 10px hsl(201 100% 85% / 0.9);
233
+ animation: blast-core 420ms ease-out forwards;
234
+ }
235
+
236
+ .cursor-blast-shard-wrap {
237
+ position: absolute;
238
+ left: 50%;
239
+ top: 50%;
240
+ width: 0;
241
+ height: 0;
242
+ }
243
+
244
+ .cursor-blast-shard {
245
+ position: absolute;
246
+ left: 0;
247
+ top: -1.5px;
248
+ width: 12px;
249
+ height: 3px;
250
+ border-radius: 999px;
251
+ background: linear-gradient(90deg, hsl(190 100% 84%), hsl(197 98% 67%));
252
+ box-shadow: 0 0 12px hsl(195 100% 70% / 0.8);
253
+ animation: blast-shard 680ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
254
+ }
255
+
256
+ @keyframes blast-ring {
257
+ 0% {
258
+ transform: scale(0.2);
259
+ opacity: 0.95;
260
+ }
261
+ 100% {
262
+ transform: scale(1.6);
263
+ opacity: 0;
264
+ }
265
+ }
266
+
267
+ @keyframes blast-core {
268
+ 0% {
269
+ transform: translate(-50%, -50%) scale(0.5);
270
+ opacity: 1;
271
+ }
272
+ 100% {
273
+ transform: translate(-50%, -50%) scale(2.2);
274
+ opacity: 0;
275
+ }
276
+ }
277
+
278
+ @keyframes blast-shard {
279
+ 0% {
280
+ transform: translateX(0) scaleX(0.7);
281
+ opacity: 1;
282
+ }
283
+ 100% {
284
+ transform: translateX(46px) scaleX(1.1);
285
+ opacity: 0;
286
+ }
287
+ }
288
+
289
+ @media (prefers-reduced-motion: reduce) {
290
+ .cursor-blast-ring,
291
+ .cursor-blast-core,
292
+ .cursor-blast-shard {
293
+ animation-duration: 1ms;
294
+ }
295
+ }
296
+ ` }} />
297
+ </>
298
+ );
299
+
300
+ // When scoped to a container, render inline (the container must have position: relative)
301
+ if (containerRef) {
302
+ return (
303
+ <div
304
+ aria-hidden
305
+ style={{
306
+ position: "absolute",
307
+ inset: 0,
308
+ zIndex: 2147483647,
309
+ pointerEvents: "none",
310
+ overflow: "hidden",
311
+ borderRadius: "inherit",
312
+ }}
313
+ >
314
+ {blastElements}
315
+ </div>
316
+ );
317
+ }
318
+
319
+ // Default: portal to body with fixed positioning (original behaviour)
320
+ return createPortal(
321
+ <div
322
+ aria-hidden
323
+ style={{
324
+ position: "fixed",
325
+ inset: 0,
326
+ zIndex: 2147483647,
327
+ pointerEvents: "none",
328
+ }}
329
+ >
330
+ {blastElements}
331
+ </div>,
332
+ document.body,
333
+ );
334
+ }
@@ -0,0 +1,51 @@
1
+ import type { DataGridColumnDef } from "./types";
2
+
3
+ const MIN_COL_WIDTH = 20;
4
+ const MIN_CUSTOM_HEADER_WIDTH = 50;
5
+ export const DEFAULT_MAX_COL_WIDTH = 800;
6
+ export const DEFAULT_COL_WIDTH = 150;
7
+
8
+ // px-3 both sides + gap-1.5 + sort icon (h-3 w-3) + 2px rounding buffer
9
+ const HEADER_CHROME_PX = 12 + 12 + 6 + 12 + 2;
10
+
11
+ let measureContext: CanvasRenderingContext2D | null = null;
12
+ const headerWidthCache = new Map<string, number>();
13
+
14
+ function measureHeaderLabelWidth(label: string): number {
15
+ const cached = headerWidthCache.get(label);
16
+ if (cached != null) return cached;
17
+ if (typeof document === "undefined") return 0;
18
+ if (measureContext == null) {
19
+ measureContext = document.createElement("canvas").getContext("2d");
20
+ }
21
+ if (measureContext == null) return 0;
22
+
23
+ // Match header cell: text-xs (12px) font-semibold uppercase tracking-wider (0.05em)
24
+ measureContext.font = "600 12px system-ui, -apple-system, sans-serif";
25
+ const text = label.toUpperCase();
26
+ const letterSpacingPx = 0.05 * 12;
27
+ const width = Math.ceil(
28
+ measureContext.measureText(text).width + letterSpacingPx * text.length,
29
+ );
30
+ headerWidthCache.set(label, width);
31
+ return width;
32
+ }
33
+
34
+ /** Effective minimum column width. When `col.minWidth` is unset, derive
35
+ * one from the header label so it never gets clipped during resize. */
36
+ export function getEffectiveMinWidth<TRow>(col: DataGridColumnDef<TRow>): number {
37
+ if (col.minWidth != null) return col.minWidth;
38
+ const label = typeof col.header === "string" ? col.header : null;
39
+ if (label == null) {
40
+ return typeof col.header === "function" ? MIN_CUSTOM_HEADER_WIDTH : MIN_COL_WIDTH;
41
+ }
42
+ return Math.max(MIN_COL_WIDTH, measureHeaderLabelWidth(label) + HEADER_CHROME_PX);
43
+ }
44
+
45
+ export function getEffectiveMaxWidth<TRow>(col: DataGridColumnDef<TRow>): number {
46
+ return col.maxWidth ?? DEFAULT_MAX_COL_WIDTH;
47
+ }
48
+
49
+ export function clampColumnWidth<TRow>(col: DataGridColumnDef<TRow>, width: number): number {
50
+ return Math.max(getEffectiveMinWidth(col), Math.min(getEffectiveMaxWidth(col), width));
51
+ }