@papernote/ui 1.10.19 → 1.10.21

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.
@@ -0,0 +1,422 @@
1
+ import { useMemo } from 'react';
2
+
3
+ /**
4
+ * PivotTable - Transforms flat data into a cross-tabulation table
5
+ *
6
+ * Takes data like:
7
+ * [{ principal: 'A', month: 1, amount: 100 }, { principal: 'A', month: 2, amount: 200 }, ...]
8
+ *
9
+ * And displays it as:
10
+ * | Principal | January | February | ... | Total | Average |
11
+ * |-----------|---------|----------|-----|-------|---------|
12
+ * | A | $100 | $200 | ... | $300 | $150 |
13
+ */
14
+
15
+ export interface PivotTableProps<T = Record<string, unknown>> {
16
+ /** Array of data rows to pivot */
17
+ data: T[];
18
+ /** Field name to use for row labels */
19
+ rowField: string;
20
+ /** Field name to use for column headers */
21
+ columnField: string;
22
+ /** Field name containing the values to display */
23
+ valueField: string;
24
+ /** Optional map of column field values to display labels */
25
+ columnLabels?: Record<string | number, string>;
26
+ /** Optional row label header text */
27
+ rowLabel?: string;
28
+ /** Format function for values */
29
+ formatValue?: (value: number | null) => string;
30
+ /** Show row totals column */
31
+ showRowTotals?: boolean;
32
+ /** Show row averages column */
33
+ showRowAverages?: boolean;
34
+ /** Show column totals row */
35
+ showColumnTotals?: boolean;
36
+ /** Custom className */
37
+ className?: string;
38
+ /** Sort columns by label or value */
39
+ sortColumns?: 'label' | 'value' | 'none';
40
+ /** Sort rows alphabetically */
41
+ sortRows?: boolean;
42
+ /** Empty cell placeholder */
43
+ emptyValue?: string;
44
+ }
45
+
46
+ interface PivotedData {
47
+ rowLabels: string[];
48
+ columnLabels: string[];
49
+ columnKeys: (string | number)[];
50
+ matrix: (number | null)[][];
51
+ rowTotals: number[];
52
+ rowAverages: number[];
53
+ columnTotals: number[];
54
+ grandTotal: number;
55
+ }
56
+
57
+ /**
58
+ * Default currency formatter
59
+ */
60
+ const defaultFormatValue = (value: number | null): string => {
61
+ if (value === null || value === undefined) return '-';
62
+ return new Intl.NumberFormat('en-US', {
63
+ style: 'currency',
64
+ currency: 'USD',
65
+ minimumFractionDigits: 2,
66
+ maximumFractionDigits: 2,
67
+ }).format(value);
68
+ };
69
+
70
+ /**
71
+ * Transform flat data into pivoted structure
72
+ */
73
+ function pivotData<T>(
74
+ data: T[],
75
+ rowField: string,
76
+ columnField: string,
77
+ valueField: string,
78
+ columnLabels?: Record<string | number, string>,
79
+ sortColumns?: 'label' | 'value' | 'none',
80
+ sortRows?: boolean
81
+ ): PivotedData {
82
+ // Extract unique row and column values
83
+ const rowSet = new Set<string>();
84
+ const columnSet = new Set<string | number>();
85
+ const valueMap = new Map<string, number>();
86
+
87
+ for (const row of data) {
88
+ const rowValue = String((row as Record<string, unknown>)[rowField] ?? '');
89
+ const colValue = (row as Record<string, unknown>)[columnField];
90
+ const val = (row as Record<string, unknown>)[valueField];
91
+
92
+ if (rowValue) rowSet.add(rowValue);
93
+ if (colValue !== undefined && colValue !== null) columnSet.add(colValue as string | number);
94
+
95
+ // Create composite key for the cell
96
+ const key = `${rowValue}|||${colValue}`;
97
+ const numVal = typeof val === 'number' ? val : parseFloat(String(val)) || 0;
98
+ valueMap.set(key, (valueMap.get(key) || 0) + numVal);
99
+ }
100
+
101
+ // Sort row labels
102
+ let rowLabels = Array.from(rowSet);
103
+ if (sortRows !== false) {
104
+ rowLabels.sort((a, b) => a.localeCompare(b));
105
+ }
106
+
107
+ // Sort column keys
108
+ let columnKeys = Array.from(columnSet);
109
+ if (sortColumns === 'label') {
110
+ columnKeys.sort((a, b) => {
111
+ const labelA = columnLabels?.[a] ?? String(a);
112
+ const labelB = columnLabels?.[b] ?? String(b);
113
+ return labelA.localeCompare(labelB);
114
+ });
115
+ } else if (sortColumns !== 'none') {
116
+ // Default: sort by value (numeric if possible)
117
+ columnKeys.sort((a, b) => {
118
+ const numA = typeof a === 'number' ? a : parseFloat(String(a));
119
+ const numB = typeof b === 'number' ? b : parseFloat(String(b));
120
+ if (!isNaN(numA) && !isNaN(numB)) return numA - numB;
121
+ return String(a).localeCompare(String(b));
122
+ });
123
+ }
124
+
125
+ // Build column labels array
126
+ const colLabels = columnKeys.map((k) => columnLabels?.[k] ?? String(k));
127
+
128
+ // Build the matrix
129
+ const matrix: (number | null)[][] = [];
130
+ const rowTotals: number[] = [];
131
+ const rowAverages: number[] = [];
132
+ const columnTotals: number[] = new Array(columnKeys.length).fill(0);
133
+ let grandTotal = 0;
134
+
135
+ for (const rowLabel of rowLabels) {
136
+ const rowData: (number | null)[] = [];
137
+ let rowSum = 0;
138
+ let rowCount = 0;
139
+
140
+ for (let colIdx = 0; colIdx < columnKeys.length; colIdx++) {
141
+ const colKey = columnKeys[colIdx];
142
+ const key = `${rowLabel}|||${colKey}`;
143
+ const value = valueMap.get(key) ?? null;
144
+ rowData.push(value);
145
+
146
+ if (value !== null) {
147
+ rowSum += value;
148
+ rowCount++;
149
+ columnTotals[colIdx] += value;
150
+ grandTotal += value;
151
+ }
152
+ }
153
+
154
+ matrix.push(rowData);
155
+ rowTotals.push(rowSum);
156
+ rowAverages.push(rowCount > 0 ? rowSum / rowCount : 0);
157
+ }
158
+
159
+ return {
160
+ rowLabels,
161
+ columnLabels: colLabels,
162
+ columnKeys,
163
+ matrix,
164
+ rowTotals,
165
+ rowAverages,
166
+ columnTotals,
167
+ grandTotal,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * PivotTable Component
173
+ */
174
+ export function PivotTable<T = Record<string, unknown>>({
175
+ data,
176
+ rowField,
177
+ columnField,
178
+ valueField,
179
+ columnLabels,
180
+ rowLabel = '',
181
+ formatValue = defaultFormatValue,
182
+ showRowTotals = true,
183
+ showRowAverages = false,
184
+ showColumnTotals = true,
185
+ className = '',
186
+ sortColumns = 'value',
187
+ sortRows = true,
188
+ emptyValue = '-',
189
+ }: PivotTableProps<T>) {
190
+ const pivoted = useMemo(
191
+ () => pivotData(data, rowField, columnField, valueField, columnLabels, sortColumns, sortRows),
192
+ [data, rowField, columnField, valueField, columnLabels, sortColumns, sortRows]
193
+ );
194
+
195
+ const formatCell = (value: number | null): string => {
196
+ if (value === null || value === undefined) return emptyValue;
197
+ return formatValue(value);
198
+ };
199
+
200
+ // Calculate column totals row average
201
+ const columnTotalsAverage =
202
+ pivoted.columnTotals.length > 0
203
+ ? pivoted.columnTotals.reduce((a, b) => a + b, 0) / pivoted.columnTotals.length
204
+ : 0;
205
+
206
+ return (
207
+ <div className={`pivot-table-container ${className}`} style={{ overflowX: 'auto' }}>
208
+ <table
209
+ style={{
210
+ width: '100%',
211
+ borderCollapse: 'collapse',
212
+ fontSize: '14px',
213
+ fontFamily: 'inherit',
214
+ }}
215
+ >
216
+ <thead>
217
+ <tr style={{ backgroundColor: '#f8fafc', borderBottom: '2px solid #e2e8f0' }}>
218
+ {/* Row label header */}
219
+ <th
220
+ style={{
221
+ padding: '12px 16px',
222
+ textAlign: 'left',
223
+ fontWeight: 600,
224
+ color: '#334155',
225
+ position: 'sticky',
226
+ left: 0,
227
+ backgroundColor: '#f8fafc',
228
+ zIndex: 1,
229
+ minWidth: '150px',
230
+ }}
231
+ >
232
+ {rowLabel}
233
+ </th>
234
+ {/* Column headers */}
235
+ {pivoted.columnLabels.map((label, idx) => (
236
+ <th
237
+ key={idx}
238
+ style={{
239
+ padding: '12px 16px',
240
+ textAlign: 'right',
241
+ fontWeight: 600,
242
+ color: '#334155',
243
+ minWidth: '100px',
244
+ whiteSpace: 'nowrap',
245
+ }}
246
+ >
247
+ {label}
248
+ </th>
249
+ ))}
250
+ {/* Total header */}
251
+ {showRowTotals && (
252
+ <th
253
+ style={{
254
+ padding: '12px 16px',
255
+ textAlign: 'right',
256
+ fontWeight: 700,
257
+ color: '#1e40af',
258
+ backgroundColor: '#eff6ff',
259
+ minWidth: '100px',
260
+ }}
261
+ >
262
+ Total
263
+ </th>
264
+ )}
265
+ {/* Average header */}
266
+ {showRowAverages && (
267
+ <th
268
+ style={{
269
+ padding: '12px 16px',
270
+ textAlign: 'right',
271
+ fontWeight: 700,
272
+ color: '#166534',
273
+ backgroundColor: '#f0fdf4',
274
+ minWidth: '100px',
275
+ }}
276
+ >
277
+ Average
278
+ </th>
279
+ )}
280
+ </tr>
281
+ </thead>
282
+ <tbody>
283
+ {pivoted.rowLabels.map((rowLabel, rowIdx) => (
284
+ <tr
285
+ key={rowIdx}
286
+ style={{
287
+ borderBottom: '1px solid #e2e8f0',
288
+ backgroundColor: rowIdx % 2 === 0 ? '#ffffff' : '#f8fafc',
289
+ }}
290
+ >
291
+ {/* Row label */}
292
+ <td
293
+ style={{
294
+ padding: '10px 16px',
295
+ fontWeight: 500,
296
+ color: '#1e293b',
297
+ position: 'sticky',
298
+ left: 0,
299
+ backgroundColor: rowIdx % 2 === 0 ? '#ffffff' : '#f8fafc',
300
+ zIndex: 1,
301
+ }}
302
+ >
303
+ {rowLabel}
304
+ </td>
305
+ {/* Data cells */}
306
+ {pivoted.matrix[rowIdx].map((value, colIdx) => (
307
+ <td
308
+ key={colIdx}
309
+ style={{
310
+ padding: '10px 16px',
311
+ textAlign: 'right',
312
+ color: value === null ? '#94a3b8' : '#334155',
313
+ fontVariantNumeric: 'tabular-nums',
314
+ }}
315
+ >
316
+ {formatCell(value)}
317
+ </td>
318
+ ))}
319
+ {/* Row total */}
320
+ {showRowTotals && (
321
+ <td
322
+ style={{
323
+ padding: '10px 16px',
324
+ textAlign: 'right',
325
+ fontWeight: 600,
326
+ color: '#1e40af',
327
+ backgroundColor: '#eff6ff',
328
+ fontVariantNumeric: 'tabular-nums',
329
+ }}
330
+ >
331
+ {formatCell(pivoted.rowTotals[rowIdx])}
332
+ </td>
333
+ )}
334
+ {/* Row average */}
335
+ {showRowAverages && (
336
+ <td
337
+ style={{
338
+ padding: '10px 16px',
339
+ textAlign: 'right',
340
+ fontWeight: 600,
341
+ color: '#166534',
342
+ backgroundColor: '#f0fdf4',
343
+ fontVariantNumeric: 'tabular-nums',
344
+ }}
345
+ >
346
+ {formatCell(pivoted.rowAverages[rowIdx])}
347
+ </td>
348
+ )}
349
+ </tr>
350
+ ))}
351
+ {/* Column totals row */}
352
+ {showColumnTotals && (
353
+ <tr
354
+ style={{
355
+ borderTop: '2px solid #e2e8f0',
356
+ backgroundColor: '#f1f5f9',
357
+ fontWeight: 600,
358
+ }}
359
+ >
360
+ <td
361
+ style={{
362
+ padding: '12px 16px',
363
+ fontWeight: 700,
364
+ color: '#1e293b',
365
+ position: 'sticky',
366
+ left: 0,
367
+ backgroundColor: '#f1f5f9',
368
+ zIndex: 1,
369
+ }}
370
+ >
371
+ Total
372
+ </td>
373
+ {pivoted.columnTotals.map((total, idx) => (
374
+ <td
375
+ key={idx}
376
+ style={{
377
+ padding: '12px 16px',
378
+ textAlign: 'right',
379
+ color: '#1e293b',
380
+ fontVariantNumeric: 'tabular-nums',
381
+ }}
382
+ >
383
+ {formatCell(total)}
384
+ </td>
385
+ ))}
386
+ {showRowTotals && (
387
+ <td
388
+ style={{
389
+ padding: '12px 16px',
390
+ textAlign: 'right',
391
+ fontWeight: 700,
392
+ color: '#1e40af',
393
+ backgroundColor: '#dbeafe',
394
+ fontVariantNumeric: 'tabular-nums',
395
+ }}
396
+ >
397
+ {formatCell(pivoted.grandTotal)}
398
+ </td>
399
+ )}
400
+ {showRowAverages && (
401
+ <td
402
+ style={{
403
+ padding: '12px 16px',
404
+ textAlign: 'right',
405
+ fontWeight: 700,
406
+ color: '#166534',
407
+ backgroundColor: '#dcfce7',
408
+ fontVariantNumeric: 'tabular-nums',
409
+ }}
410
+ >
411
+ {formatCell(columnTotalsAverage)}
412
+ </td>
413
+ )}
414
+ </tr>
415
+ )}
416
+ </tbody>
417
+ </table>
418
+ </div>
419
+ );
420
+ }
421
+
422
+ export default PivotTable;
@@ -1,8 +1,8 @@
1
1
 
2
- import { CheckCircle, Clock, AlertCircle, XCircle } from 'lucide-react';
2
+ import { CheckCircle, Clock, AlertCircle, XCircle, Info } from 'lucide-react';
3
3
 
4
4
  export interface StatusBadgeProps {
5
- status: 'paid' | 'pending' | 'overdue' | 'cancelled' | 'success' | 'warning' | 'error' | 'info';
5
+ status: 'paid' | 'pending' | 'overdue' | 'cancelled' | 'success' | 'warning' | 'caution' | 'error' | 'info';
6
6
  label?: string;
7
7
  size?: 'sm' | 'md' | 'lg';
8
8
  showIcon?: boolean;
@@ -29,6 +29,11 @@ const statusConfig = {
29
29
  defaultLabel: 'Warning',
30
30
  className: 'bg-warning-100 text-warning-800',
31
31
  },
32
+ caution: {
33
+ icon: Info,
34
+ defaultLabel: 'Caution',
35
+ className: 'bg-warning-100 text-warning-700',
36
+ },
32
37
  overdue: {
33
38
  icon: AlertCircle,
34
39
  defaultLabel: 'Overdue',
@@ -16,7 +16,7 @@ Text component for consistent typography across the application.
16
16
  - **Semantic elements**: Render as p, span, div, h1-h6, or label
17
17
  - **Size scale**: xs, sm, base, lg, xl, 2xl
18
18
  - **Weight options**: normal, medium, semibold, bold
19
- - **Color variants**: primary, secondary, muted, accent, error, success, warning
19
+ - **Color variants**: primary, secondary, muted, accent, error, success, warning, caution
20
20
  - **Text alignment**: left, center, right
21
21
  - **Truncation**: Single line truncate or multi-line clamp (1-6 lines)
22
22
  - **Transform**: uppercase, lowercase, capitalize
@@ -61,7 +61,7 @@ import { Text } from 'notebook-ui';
61
61
  },
62
62
  color: {
63
63
  control: 'select',
64
- options: ['primary', 'secondary', 'muted', 'accent', 'error', 'success', 'warning'],
64
+ options: ['primary', 'secondary', 'muted', 'accent', 'error', 'success', 'warning', 'caution'],
65
65
  description: 'Text color',
66
66
  },
67
67
  align: {
@@ -120,7 +120,9 @@ export const Weights: Story = {
120
120
  };
121
121
 
122
122
  /**
123
- * All available color variants including the new `warning` color.
123
+ * All available color variants including `warning` and `caution` colors.
124
+ * - **warning**: Urgent attention needed (brighter amber)
125
+ * - **caution**: Informational, exploratory states like sandbox/demo mode (darker, subdued amber)
124
126
  */
125
127
  export const Colors: Story = {
126
128
  render: () => (
@@ -130,15 +132,16 @@ export const Colors: Story = {
130
132
  <Text color="muted">Muted - Subdued text</Text>
131
133
  <Text color="accent">Accent - Branded color</Text>
132
134
  <Text color="success">Success - Positive feedback</Text>
133
- <Text color="warning">Warning - Caution messages</Text>
135
+ <Text color="warning">Warning - Urgent attention</Text>
136
+ <Text color="caution">Caution - Informational, exploratory</Text>
134
137
  <Text color="error">Error - Error messages</Text>
135
138
  </Stack>
136
139
  ),
137
140
  };
138
141
 
139
142
  /**
140
- * The warning color is useful for displaying caution messages,
141
- * threshold alerts, or status indicators that need attention.
143
+ * The warning color is useful for urgent alerts, threshold warnings,
144
+ * or status indicators that need immediate attention.
142
145
  */
143
146
  export const WarningColor: Story = {
144
147
  render: () => (
@@ -156,6 +159,27 @@ export const WarningColor: Story = {
156
159
  ),
157
160
  };
158
161
 
162
+ /**
163
+ * The caution color is for informational states that need context but aren't alarming.
164
+ * Use for demo/sandbox modes, wash sale notices, or exploratory features.
165
+ * It's calmer than warning - inviting users to explore safely.
166
+ */
167
+ export const CautionColor: Story = {
168
+ render: () => (
169
+ <Stack spacing="md">
170
+ <Text color="caution" size="lg" weight="semibold">
171
+ Demo Mode: Changes will not be saved
172
+ </Text>
173
+ <Text color="caution">
174
+ This is a sandbox environment for testing
175
+ </Text>
176
+ <Text color="caution" size="sm">
177
+ Wash sale: Cost basis adjusted for tax purposes
178
+ </Text>
179
+ </Stack>
180
+ ),
181
+ };
182
+
159
183
  export const Alignment: Story = {
160
184
  render: () => (
161
185
  <Stack spacing="sm" style={{ width: '300px' }}>
@@ -23,7 +23,7 @@ export interface TextProps extends Omit<React.HTMLAttributes<HTMLElement>, 'colo
23
23
  /** Weight variant */
24
24
  weight?: 'normal' | 'medium' | 'semibold' | 'bold';
25
25
  /** Color variant */
26
- color?: 'primary' | 'secondary' | 'muted' | 'accent' | 'error' | 'success' | 'warning';
26
+ color?: 'primary' | 'secondary' | 'muted' | 'accent' | 'error' | 'success' | 'warning' | 'caution';
27
27
  /** Text alignment */
28
28
  align?: 'left' | 'center' | 'right';
29
29
  /** Truncate text with ellipsis (single line) */
@@ -54,9 +54,10 @@ export interface TextProps extends Omit<React.HTMLAttributes<HTMLElement>, 'colo
54
54
  * <Text size="lg" weight="semibold" color="primary">
55
55
  * Hello World
56
56
  * </Text>
57
- *
57
+ *
58
58
  * <Text color="warning">Warning message</Text>
59
- *
59
+ * <Text color="caution">Caution message (informational, not alarming)</Text>
60
+ *
60
61
  * // With ref
61
62
  * const textRef = useRef<HTMLParagraphElement>(null);
62
63
  * <Text ref={textRef}>Measurable text</Text>
@@ -130,6 +131,7 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
130
131
  error: 'text-error-600',
131
132
  success: 'text-success-600',
132
133
  warning: 'text-warning-600',
134
+ caution: 'text-warning-700',
133
135
  };
134
136
 
135
137
  const alignClasses = {
@@ -404,6 +404,10 @@ export type {
404
404
  FrozenRowMode,
405
405
  } from './DataGrid';
406
406
 
407
+ // PivotTable (cross-tabulation display)
408
+ export { default as PivotTable } from './PivotTable';
409
+ export type { PivotTableProps } from './PivotTable';
410
+
407
411
  export { default as SwipeActions } from './SwipeActions';
408
412
  export type { SwipeActionsProps, SwipeAction } from './SwipeActions';
409
413