@wealthx/shadcn 1.0.2 → 1.1.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 (36) hide show
  1. package/.turbo/turbo-build.log +154 -138
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +82 -0
  4. package/dist/chunk-3EQP72AW.mjs +58 -0
  5. package/dist/chunk-5JGQAAQV.mjs +212 -0
  6. package/dist/chunk-GLW2UO6O.mjs +212 -0
  7. package/dist/chunk-RN67642N.mjs +171 -0
  8. package/dist/chunk-UEL4RD5P.mjs +272 -0
  9. package/dist/chunk-YBXCIF5Q.mjs +198 -0
  10. package/dist/components/ui/cashflow-bar-chart.js +596 -0
  11. package/dist/components/ui/cashflow-bar-chart.mjs +16 -0
  12. package/dist/components/ui/combobox.js +261 -0
  13. package/dist/components/ui/combobox.mjs +28 -0
  14. package/dist/components/ui/data-table.mjs +3 -3
  15. package/dist/components/ui/expense-bar-chart.js +543 -0
  16. package/dist/components/ui/expense-bar-chart.mjs +16 -0
  17. package/dist/components/ui/field.mjs +2 -2
  18. package/dist/components/ui/income-bar-chart.js +543 -0
  19. package/dist/components/ui/income-bar-chart.mjs +16 -0
  20. package/dist/components/ui/transactions-income-expense-bar-chart.js +478 -0
  21. package/dist/components/ui/transactions-income-expense-bar-chart.mjs +16 -0
  22. package/dist/index.js +1685 -725
  23. package/dist/index.mjs +152 -111
  24. package/dist/styles.css +1 -1
  25. package/package.json +30 -2
  26. package/src/components/index.tsx +56 -0
  27. package/src/components/ui/cashflow-bar-chart.tsx +336 -0
  28. package/src/components/ui/chart-shared.tsx +100 -0
  29. package/src/components/ui/combobox.tsx +217 -0
  30. package/src/components/ui/expense-bar-chart.tsx +278 -0
  31. package/src/components/ui/income-bar-chart.tsx +278 -0
  32. package/src/components/ui/transactions-income-expense-bar-chart.tsx +198 -0
  33. package/src/styles/styles-css.ts +1 -1
  34. package/tsup.config.ts +5 -0
  35. package/dist/{chunk-K76E2TQU.mjs → chunk-CJ46PDXE.mjs} +5 -5
  36. package/dist/{chunk-HUVTPUV2.mjs → chunk-NLLKTU4B.mjs} +3 -3
@@ -0,0 +1,278 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ Chart as ChartJS,
4
+ CategoryScale,
5
+ LinearScale,
6
+ BarElement,
7
+ Tooltip,
8
+ Legend,
9
+ type ChartOptions,
10
+ type ChartData,
11
+ } from "chart.js";
12
+ import { Chart } from "react-chartjs-2";
13
+ import { useThemeVars } from "@/lib/theme-provider";
14
+ import { Card, CardContent, CardHeader, CardTitle, CardAction } from "./card";
15
+ import { Empty, EmptyDescription } from "./empty";
16
+ import { Skeleton } from "./skeleton";
17
+ import { cn } from "@/lib/utils";
18
+ import {
19
+ hexToRgba,
20
+ DATASET_ALPHAS,
21
+ FALLBACK_TICK,
22
+ CHART_SLICE_COUNT,
23
+ CHART_PERIODS,
24
+ formatTooltipDate,
25
+ ChartLegendItem,
26
+ ChartPeriodButton,
27
+ type ChartPeriod,
28
+ type ChartGranularity,
29
+ } from "./chart-shared";
30
+
31
+ ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Types
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export interface IncomeDataset {
38
+ /** Income source label, e.g. "Employment", "Rental", "Other" */
39
+ label: string;
40
+ /** One value per data point, aligned to the months array */
41
+ data: number[];
42
+ }
43
+
44
+ export interface IncomeBarChartData {
45
+ /** Display labels, e.g. ["Jul", "Aug"] for monthly or ["Mar 8", "Mar 9"] for daily */
46
+ months: string[];
47
+ /** Optional ISO date strings per point — used for the tooltip title */
48
+ dates?: string[];
49
+ datasets: IncomeDataset[];
50
+ }
51
+
52
+ export type IncomePeriod = ChartPeriod;
53
+ export type IncomeGranularity = ChartGranularity;
54
+
55
+ export interface IncomeBarChartProps {
56
+ /** Full dataset — sliced to the selected period */
57
+ incomeData: IncomeBarChartData | null;
58
+ title?: string;
59
+ /** Show or hide the chart legend */
60
+ showLegend?: boolean;
61
+ /** Show or hide X axis labels */
62
+ showXAxis?: boolean;
63
+ /** Show or hide Y axis labels */
64
+ showYAxis?: boolean;
65
+ /** Legend placement relative to chart */
66
+ legendPosition?: "top" | "bottom";
67
+ /** Default period selector value */
68
+ defaultPeriod?: IncomePeriod;
69
+ /**
70
+ * Data granularity — controls available period buttons and slice counts.
71
+ * "monthly" (default): shows 3M / 6M / 12M, slices by month count.
72
+ * "daily": shows 1M / 3M / 6M / 12M, slices by day count (1M=30, 3M=90, etc.).
73
+ */
74
+ granularity?: IncomeGranularity;
75
+ /** Chart canvas height in pixels */
76
+ height?: number;
77
+ /** Width of the card in pixels */
78
+ width?: number | string;
79
+ className?: string;
80
+ /** Show skeleton loading state instead of the chart */
81
+ isLoading?: boolean;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Constants
86
+ // ---------------------------------------------------------------------------
87
+
88
+ const FALLBACK_PRIMARY = "#33FF99";
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Component
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export function IncomeBarChart({
95
+ incomeData,
96
+ title = "Income",
97
+ showLegend = true,
98
+ showXAxis = true,
99
+ showYAxis = true,
100
+ legendPosition = "top",
101
+ defaultPeriod = 6,
102
+ granularity = "monthly",
103
+ height = 280,
104
+ width = "100%",
105
+ className,
106
+ isLoading = false,
107
+ }: IncomeBarChartProps) {
108
+ const periods = CHART_PERIODS[granularity];
109
+ const [period, setPeriod] = useState<IncomePeriod>(defaultPeriod);
110
+
111
+ // Reset period when granularity changes, but not on initial mount
112
+ // (defaultPeriod handles the initial value).
113
+ const isFirstRender = useRef(true);
114
+ useEffect(() => {
115
+ if (isFirstRender.current) { isFirstRender.current = false; return; }
116
+ setPeriod(CHART_PERIODS[granularity][0]);
117
+ }, [granularity]);
118
+
119
+ const themeVars = useThemeVars();
120
+ const brandPrimary: string =
121
+ (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY;
122
+
123
+ const sliced = useMemo<IncomeBarChartData | null>(() => {
124
+ if (!incomeData?.months?.length || !incomeData.datasets.length) return null;
125
+ const count = Math.min(CHART_SLICE_COUNT[granularity][period], incomeData.months.length);
126
+ const start = incomeData.months.length - count;
127
+ return {
128
+ months: incomeData.months.slice(start),
129
+ dates: incomeData.dates?.slice(start),
130
+ datasets: incomeData.datasets.map((ds) => ({
131
+ ...ds,
132
+ data: ds.data.slice(start),
133
+ })),
134
+ };
135
+ }, [incomeData, period, granularity]);
136
+
137
+ const datasetColors = useMemo(
138
+ () => sliced?.datasets.map((_, i) =>
139
+ hexToRgba(brandPrimary, DATASET_ALPHAS[i % DATASET_ALPHAS.length])
140
+ ) ?? [],
141
+ [sliced, brandPrimary]
142
+ );
143
+
144
+ const chartData = useMemo<ChartData<"bar">>(() => {
145
+ if (!sliced) return { labels: [], datasets: [] };
146
+ return {
147
+ labels: sliced.months,
148
+ datasets: sliced.datasets.map((ds, i) => ({
149
+ label: ds.label,
150
+ data: ds.data,
151
+ backgroundColor: datasetColors[i],
152
+ hoverBackgroundColor: hexToRgba(
153
+ brandPrimary,
154
+ Math.min(DATASET_ALPHAS[i % DATASET_ALPHAS.length] + 0.15, 1)
155
+ ),
156
+ borderWidth: 0,
157
+ borderRadius: 0,
158
+ borderSkipped: false,
159
+ barPercentage: 0.75,
160
+ categoryPercentage: 0.7,
161
+ stack: "income",
162
+ })),
163
+ };
164
+ }, [sliced, datasetColors, brandPrimary]);
165
+
166
+ const options = useMemo<ChartOptions<"bar">>(() => ({
167
+ responsive: true,
168
+ maintainAspectRatio: false,
169
+ animation: { duration: 800, easing: "easeOutQuart" },
170
+ layout: { padding: 0 },
171
+ plugins: {
172
+ legend: { display: false },
173
+ tooltip: {
174
+ mode: "index",
175
+ intersect: false,
176
+ padding: 12,
177
+ cornerRadius: 0,
178
+ titleFont: { size: 11, weight: "600" },
179
+ bodyFont: { size: 12, weight: "500" },
180
+ callbacks: {
181
+ title: (tooltipItems) => {
182
+ const idx = tooltipItems[0]?.dataIndex;
183
+ if (idx != null && sliced?.dates?.[idx]) {
184
+ return formatTooltipDate(sliced.dates[idx], granularity);
185
+ }
186
+ return tooltipItems[0]?.label ?? "";
187
+ },
188
+ label: (ctx) => {
189
+ const val = ctx.raw as number;
190
+ if (val === 0) return null;
191
+ return ` ${ctx.dataset.label}: $${val.toLocaleString()}`;
192
+ },
193
+ },
194
+ },
195
+ },
196
+ scales: {
197
+ x: {
198
+ display: showXAxis,
199
+ stacked: true,
200
+ grid: { display: false },
201
+ border: { display: false },
202
+ ticks: { font: { size: 10 }, color: FALLBACK_TICK },
203
+ },
204
+ y: {
205
+ display: showYAxis,
206
+ stacked: true,
207
+ grid: { display: false },
208
+ border: { display: false },
209
+ ticks: {
210
+ font: { size: 10 },
211
+ color: FALLBACK_TICK,
212
+ maxTicksLimit: 5,
213
+ padding: 8,
214
+ callback: (v) => `$${Number(v).toLocaleString()}`,
215
+ },
216
+ },
217
+ },
218
+ }), [showXAxis, showYAxis, sliced, granularity]);
219
+
220
+ return (
221
+ <Card
222
+ className={cn("w-full py-4 sm:py-6 gap-2", className)}
223
+ style={{ maxWidth: width }}
224
+ >
225
+ <CardHeader className="px-3 sm:px-6">
226
+ <CardTitle className="text-sm sm:text-base">{title}</CardTitle>
227
+ <CardAction>
228
+ <div className="flex gap-0.5 sm:gap-1">
229
+ {periods.map((p) => (
230
+ <ChartPeriodButton
231
+ key={p}
232
+ period={p}
233
+ active={period === p}
234
+ onClick={() => setPeriod(p)}
235
+ />
236
+ ))}
237
+ </div>
238
+ </CardAction>
239
+ </CardHeader>
240
+
241
+ <CardContent className="px-3 sm:px-6">
242
+ {isLoading ? (
243
+ <Skeleton style={{ height, width: "100%" }} />
244
+ ) : !sliced ? (
245
+ <Empty className="flex-none p-4" style={{ height }}>
246
+ <EmptyDescription>No data available</EmptyDescription>
247
+ </Empty>
248
+ ) : (
249
+ <div className="flex flex-col gap-2">
250
+ {showLegend && legendPosition === "top" && (
251
+ <div className="flex flex-wrap gap-x-3 gap-y-1.5 pb-2">
252
+ {sliced.datasets.map((ds, i) => (
253
+ <ChartLegendItem key={ds.label} label={ds.label} color={datasetColors[i]} />
254
+ ))}
255
+ </div>
256
+ )}
257
+ <div style={{ height, width: "100%", position: "relative" }}>
258
+ <Chart
259
+ key={brandPrimary}
260
+ type="bar"
261
+ data={chartData}
262
+ options={options}
263
+ aria-label={title}
264
+ />
265
+ </div>
266
+ {showLegend && legendPosition === "bottom" && (
267
+ <div className="flex flex-wrap gap-x-3 gap-y-1.5 pt-2">
268
+ {sliced.datasets.map((ds, i) => (
269
+ <ChartLegendItem key={ds.label} label={ds.label} color={datasetColors[i]} />
270
+ ))}
271
+ </div>
272
+ )}
273
+ </div>
274
+ )}
275
+ </CardContent>
276
+ </Card>
277
+ );
278
+ }
@@ -0,0 +1,198 @@
1
+ import React, { useMemo } from "react";
2
+ import {
3
+ Chart as ChartJS,
4
+ CategoryScale,
5
+ LinearScale,
6
+ BarElement,
7
+ Tooltip,
8
+ type ChartOptions,
9
+ type ChartData,
10
+ } from "chart.js";
11
+ import ChartDataLabels from "chartjs-plugin-datalabels";
12
+ import { Chart } from "react-chartjs-2";
13
+ import { useThemeVars } from "@/lib/theme-provider";
14
+ import { Card, CardContent, CardHeader, CardTitle } from "./card";
15
+ import { Empty, EmptyDescription } from "./empty";
16
+ import { Spinner } from "./spinner";
17
+ import { cn } from "@/lib/utils";
18
+ import { FALLBACK_TICK } from "./chart-shared";
19
+
20
+ ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip);
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const FALLBACK_PRIMARY = "#33FF99";
27
+ const FALLBACK_SECONDARY = "#162029";
28
+ /** Dark neutral used for dollar value labels — not a brand color. */
29
+ const VALUE_LABEL_COLOR = "#162029";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function formatDollar(value: number): string {
36
+ return `$${value.toLocaleString(undefined, {
37
+ minimumFractionDigits: 2,
38
+ maximumFractionDigits: 2,
39
+ })}`;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Types
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export interface TransactionsIncomeExpenseBarChartProps {
47
+ /** Total income amount (positive number) */
48
+ totalIncome: number | null;
49
+ /** Total expense amount (negative or positive — absolute value is used) */
50
+ totalExpense: number | null;
51
+ title?: string;
52
+ /** Chart canvas height in pixels */
53
+ height?: number;
54
+ /** Width of the card */
55
+ width?: number | string;
56
+ className?: string;
57
+ /** Show skeleton loading state instead of the chart */
58
+ isLoading?: boolean;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Component
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export function TransactionsIncomeExpenseBarChart({
66
+ totalIncome,
67
+ totalExpense,
68
+ title = "Transactions — Income vs Expense",
69
+ height = 120,
70
+ width = "100%",
71
+ className,
72
+ isLoading = false,
73
+ }: TransactionsIncomeExpenseBarChartProps) {
74
+ const themeVars = useThemeVars();
75
+ const brandPrimary =
76
+ (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY;
77
+ const brandSecondary =
78
+ (themeVars["--theme-secondary"] as string | undefined) || FALLBACK_SECONDARY;
79
+
80
+ const hasData = totalIncome != null && totalExpense != null;
81
+ const incomeVal = totalIncome ?? 0;
82
+ const expenseVal = Math.abs(totalExpense ?? 0);
83
+ const maxVal = Math.max(incomeVal, expenseVal);
84
+
85
+ const chartData = useMemo<ChartData<"bar">>(() => {
86
+ if (!hasData) return { labels: [], datasets: [] };
87
+ return {
88
+ labels: ["Incoming", "Outgoing"],
89
+ datasets: [
90
+ {
91
+ barThickness: 40,
92
+ backgroundColor: [brandPrimary, brandSecondary],
93
+ hoverBackgroundColor: [brandPrimary, brandSecondary],
94
+ borderWidth: 0,
95
+ borderRadius: 0,
96
+ borderSkipped: false,
97
+ data: [incomeVal, expenseVal],
98
+ // chartjs-plugin-datalabels config — typed via plugin module augmentation
99
+ datalabels: {
100
+ labels: {
101
+ value: {
102
+ anchor: "end",
103
+ align: "end",
104
+ offset: 10,
105
+ clamp: false,
106
+ font: { weight: "bold", size: 14 },
107
+ color: VALUE_LABEL_COLOR,
108
+ textAlign: "left",
109
+ // Returns array for multi-line: dollar value on line 1, blank on line 2
110
+ formatter: (v: number) => [formatDollar(v), ""],
111
+ },
112
+ name: {
113
+ anchor: "end",
114
+ align: "end",
115
+ offset: 10,
116
+ clamp: false,
117
+ font: { size: 12 },
118
+ color: FALLBACK_TICK,
119
+ textAlign: "left",
120
+ // Returns array for multi-line: blank on line 1, bar label on line 2
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ formatter: (_: number, ctx: any) =>
123
+ ["", String(ctx.chart.data.labels?.[ctx.dataIndex] ?? "")],
124
+ },
125
+ },
126
+ } as unknown as never,
127
+ },
128
+ ],
129
+ };
130
+ }, [hasData, incomeVal, expenseVal, brandPrimary, brandSecondary]);
131
+
132
+ const options = useMemo<ChartOptions<"bar">>(() => ({
133
+ indexAxis: "y",
134
+ responsive: true,
135
+ maintainAspectRatio: false,
136
+ animation: { duration: 800, easing: "easeOutQuart" },
137
+ layout: {
138
+ // Right padding reserves space for the datalabels rendered outside the bar area
139
+ padding: { right: 180, left: 0, top: 10, bottom: 10 },
140
+ },
141
+ plugins: {
142
+ legend: { display: false },
143
+ tooltip: { enabled: false },
144
+ },
145
+ scales: {
146
+ y: {
147
+ display: true,
148
+ grid: { display: false },
149
+ border: { display: false },
150
+ ticks: { display: false },
151
+ },
152
+ x: {
153
+ display: true,
154
+ suggestedMax: maxVal * 1.3,
155
+ grid: { display: false },
156
+ border: { display: false },
157
+ ticks: { display: false },
158
+ },
159
+ },
160
+ }), [maxVal]);
161
+
162
+ return (
163
+ <Card
164
+ className={cn("w-full py-4 sm:py-6 gap-2", className)}
165
+ style={{ maxWidth: width }}
166
+ >
167
+ <CardHeader className="px-3 sm:px-6">
168
+ <CardTitle className="text-sm sm:text-base">{title}</CardTitle>
169
+ </CardHeader>
170
+
171
+ <CardContent className="px-3 sm:px-6">
172
+ {isLoading ? (
173
+ <div
174
+ className="flex items-center justify-center text-muted-foreground"
175
+ style={{ height, width: "100%" }}
176
+ >
177
+ <Spinner size="lg" />
178
+ </div>
179
+ ) : !hasData ? (
180
+ <Empty className="flex-none p-4" style={{ height }}>
181
+ <EmptyDescription>No data available</EmptyDescription>
182
+ </Empty>
183
+ ) : (
184
+ <div style={{ height, width: "100%", position: "relative" }}>
185
+ <Chart
186
+ key={`${brandPrimary}__${brandSecondary}`}
187
+ type="bar"
188
+ data={chartData}
189
+ options={options}
190
+ plugins={[ChartDataLabels]}
191
+ aria-label={title}
192
+ />
193
+ </div>
194
+ )}
195
+ </CardContent>
196
+ </Card>
197
+ );
198
+ }