@papernote/ui 1.0.0 → 1.2.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/LICENSE +21 -21
- package/README.md +455 -445
- package/dist/components/CurrencyInput.d.ts +52 -0
- package/dist/components/CurrencyInput.d.ts.map +1 -0
- package/dist/components/DataTable.d.ts +3 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/Page.d.ts +2 -0
- package/dist/components/Page.d.ts.map +1 -1
- package/dist/components/PageLayout.d.ts +5 -1
- package/dist/components/PageLayout.d.ts.map +1 -1
- package/dist/components/Spreadsheet.d.ts +129 -0
- package/dist/components/Spreadsheet.d.ts.map +1 -0
- package/dist/components/Tabs.d.ts +5 -1
- package/dist/components/Tabs.d.ts.map +1 -1
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +336 -5
- package/dist/index.esm.js +51152 -174
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +51145 -143
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1187 -11
- package/dist/utils/excelExport.d.ts +143 -0
- package/dist/utils/excelExport.d.ts.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/package.json +13 -3
- package/src/components/AdminModal.css +49 -49
- package/src/components/CurrencyInput.stories.tsx +290 -0
- package/src/components/CurrencyInput.tsx +193 -0
- package/src/components/DataTable.stories.tsx +87 -0
- package/src/components/DataTable.tsx +149 -37
- package/src/components/Modal.stories.tsx +64 -0
- package/src/components/Modal.tsx +15 -2
- package/src/components/Page.stories.tsx +76 -0
- package/src/components/Page.tsx +35 -3
- package/src/components/PageLayout.stories.tsx +75 -0
- package/src/components/PageLayout.tsx +28 -9
- package/src/components/RoleManager.css +10 -10
- package/src/components/Spreadsheet.css +216 -0
- package/src/components/Spreadsheet.stories.tsx +362 -0
- package/src/components/Spreadsheet.tsx +351 -0
- package/src/components/SpreadsheetSimple.stories.tsx +27 -0
- package/src/components/Tabs.stories.tsx +31 -0
- package/src/components/Tabs.tsx +28 -4
- package/src/components/TimePicker.tsx +1 -1
- package/src/components/Toast.tsx +9 -9
- package/src/components/__tests__/Input.test.tsx +22 -26
- package/src/components/index.ts +11 -2
- package/src/styles/index.css +44 -6
- package/src/utils/excelExport.stories.tsx +535 -0
- package/src/utils/excelExport.ts +225 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/sqlToNaturalLanguage.ts +1 -1
- package/tailwind.config.js +253 -253
- package/dist/components/Button.stories.d.ts +0 -51
- package/dist/components/Button.stories.d.ts.map +0 -1
- package/dist/components/ChartVisualizationUI.d.ts +0 -21
- package/dist/components/ChartVisualizationUI.d.ts.map +0 -1
- package/dist/components/ChatUI.d.ts +0 -23
- package/dist/components/ChatUI.d.ts.map +0 -1
- package/dist/components/CommissionDashboardUI.d.ts +0 -25
- package/dist/components/CommissionDashboardUI.d.ts.map +0 -1
- package/dist/components/DataTable.stories.d.ts +0 -23
- package/dist/components/DataTable.stories.d.ts.map +0 -1
- package/dist/components/FormField.d.ts +0 -35
- package/dist/components/FormField.d.ts.map +0 -1
- package/dist/components/Input.stories.d.ts +0 -366
- package/dist/components/Input.stories.d.ts.map +0 -1
- package/dist/components/InsightsPanelUI.d.ts +0 -21
- package/dist/components/InsightsPanelUI.d.ts.map +0 -1
- package/dist/components/PaymentHistoryTimeline.d.ts +0 -34
- package/dist/components/PaymentHistoryTimeline.d.ts.map +0 -1
- package/dist/components/RelationshipManagerUI.d.ts +0 -60
- package/dist/components/RelationshipManagerUI.d.ts.map +0 -1
- package/dist/components/RoleManager.d.ts +0 -19
- package/dist/components/RoleManager.d.ts.map +0 -1
- package/dist/components/SplitCommissionBadge.d.ts +0 -18
- package/dist/components/SplitCommissionBadge.d.ts.map +0 -1
- package/dist/components/__tests__/Button.test.d.ts +0 -2
- package/dist/components/__tests__/Button.test.d.ts.map +0 -1
- package/dist/components/__tests__/Input.test.d.ts +0 -2
- package/dist/components/__tests__/Input.test.d.ts.map +0 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import React, { forwardRef, useState, useEffect } from 'react';
|
|
2
|
+
import Input, { InputProps } from './Input';
|
|
3
|
+
|
|
4
|
+
export interface CurrencyInputProps extends Omit<InputProps, 'type' | 'value' | 'onChange' | 'prefix'> {
|
|
5
|
+
/** Numeric value (not formatted) */
|
|
6
|
+
value?: number | string;
|
|
7
|
+
/** Callback when value changes (receives numeric value) */
|
|
8
|
+
onChange?: (value: number | null) => void;
|
|
9
|
+
/** Currency code (default: 'USD') */
|
|
10
|
+
currency?: string;
|
|
11
|
+
/** Locale for formatting (default: 'en-US') */
|
|
12
|
+
locale?: string;
|
|
13
|
+
/** Number of decimal places (default: 2) */
|
|
14
|
+
precision?: number;
|
|
15
|
+
/** Allow negative values (default: false) */
|
|
16
|
+
allowNegative?: boolean;
|
|
17
|
+
/** Minimum allowed value */
|
|
18
|
+
min?: number;
|
|
19
|
+
/** Maximum allowed value */
|
|
20
|
+
max?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CurrencyInput - Specialized input for monetary values
|
|
25
|
+
*
|
|
26
|
+
* Automatically formats currency values with proper symbols and thousands separators.
|
|
27
|
+
* Handles parsing and validation of numeric currency input.
|
|
28
|
+
*
|
|
29
|
+
* @example Basic usage
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <CurrencyInput
|
|
32
|
+
* label="Price"
|
|
33
|
+
* value={price}
|
|
34
|
+
* onChange={setPrice}
|
|
35
|
+
* currency="USD"
|
|
36
|
+
* />
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @example With validation
|
|
40
|
+
* ```tsx
|
|
41
|
+
* <CurrencyInput
|
|
42
|
+
* label="Budget"
|
|
43
|
+
* value={budget}
|
|
44
|
+
* onChange={setBudget}
|
|
45
|
+
* min={0}
|
|
46
|
+
* max={10000}
|
|
47
|
+
* validationState={budget > 10000 ? 'error' : null}
|
|
48
|
+
* validationMessage={budget > 10000 ? 'Exceeds maximum budget' : ''}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
const CurrencyInput = forwardRef<HTMLInputElement, CurrencyInputProps>(
|
|
53
|
+
(
|
|
54
|
+
{
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
currency = 'USD',
|
|
58
|
+
locale = 'en-US',
|
|
59
|
+
precision = 2,
|
|
60
|
+
allowNegative = false,
|
|
61
|
+
min,
|
|
62
|
+
max,
|
|
63
|
+
onBlur,
|
|
64
|
+
onFocus,
|
|
65
|
+
...props
|
|
66
|
+
},
|
|
67
|
+
ref
|
|
68
|
+
) => {
|
|
69
|
+
const [displayValue, setDisplayValue] = useState('');
|
|
70
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
71
|
+
|
|
72
|
+
// Get currency symbol
|
|
73
|
+
const getCurrencySymbol = () => {
|
|
74
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
75
|
+
style: 'currency',
|
|
76
|
+
currency,
|
|
77
|
+
});
|
|
78
|
+
const parts = formatter.formatToParts(0);
|
|
79
|
+
const symbolPart = parts.find(part => part.type === 'currency');
|
|
80
|
+
return symbolPart?.value || '$';
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const currencySymbol = getCurrencySymbol();
|
|
84
|
+
|
|
85
|
+
// Format number as currency
|
|
86
|
+
const formatCurrency = (num: number): string => {
|
|
87
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
88
|
+
minimumFractionDigits: isFocused ? 0 : precision,
|
|
89
|
+
maximumFractionDigits: precision,
|
|
90
|
+
});
|
|
91
|
+
return formatter.format(num);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Parse display value to number
|
|
95
|
+
const parseValue = (str: string): number | null => {
|
|
96
|
+
if (!str || str === '') return null;
|
|
97
|
+
|
|
98
|
+
// Remove all non-numeric characters except decimal point and minus sign
|
|
99
|
+
let cleaned = str.replace(/[^\d.-]/g, '');
|
|
100
|
+
|
|
101
|
+
// Handle multiple decimal points
|
|
102
|
+
const parts = cleaned.split('.');
|
|
103
|
+
if (parts.length > 2) {
|
|
104
|
+
cleaned = parts[0] + '.' + parts.slice(1).join('');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle multiple minus signs (keep only first)
|
|
108
|
+
const minusCount = (cleaned.match(/-/g) || []).length;
|
|
109
|
+
if (minusCount > 1) {
|
|
110
|
+
const hasLeadingMinus = cleaned.startsWith('-');
|
|
111
|
+
cleaned = cleaned.replace(/-/g, '');
|
|
112
|
+
if (hasLeadingMinus) cleaned = '-' + cleaned;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse to float
|
|
116
|
+
const num = parseFloat(cleaned);
|
|
117
|
+
|
|
118
|
+
if (isNaN(num)) return null;
|
|
119
|
+
|
|
120
|
+
// Apply precision
|
|
121
|
+
return Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Update display value when external value changes
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (value === undefined || value === null || value === '') {
|
|
127
|
+
setDisplayValue('');
|
|
128
|
+
} else {
|
|
129
|
+
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
|
130
|
+
if (!isNaN(numValue)) {
|
|
131
|
+
setDisplayValue(formatCurrency(numValue));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}, [value, isFocused]);
|
|
135
|
+
|
|
136
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
137
|
+
const inputValue = e.target.value;
|
|
138
|
+
setDisplayValue(inputValue);
|
|
139
|
+
|
|
140
|
+
const numValue = parseValue(inputValue);
|
|
141
|
+
|
|
142
|
+
// Validate constraints
|
|
143
|
+
if (numValue !== null) {
|
|
144
|
+
if (!allowNegative && numValue < 0) return;
|
|
145
|
+
if (min !== undefined && numValue < min) return;
|
|
146
|
+
if (max !== undefined && numValue > max) return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onChange?.(numValue);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
153
|
+
setIsFocused(true);
|
|
154
|
+
// Remove formatting when focused for easier editing
|
|
155
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
156
|
+
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
|
157
|
+
if (!isNaN(numValue)) {
|
|
158
|
+
setDisplayValue(numValue.toString());
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
onFocus?.(e);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
165
|
+
setIsFocused(false);
|
|
166
|
+
// Reformat on blur
|
|
167
|
+
const numValue = parseValue(displayValue);
|
|
168
|
+
if (numValue !== null) {
|
|
169
|
+
setDisplayValue(formatCurrency(numValue));
|
|
170
|
+
} else if (displayValue === '') {
|
|
171
|
+
setDisplayValue('');
|
|
172
|
+
}
|
|
173
|
+
onBlur?.(e);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<Input
|
|
178
|
+
ref={ref}
|
|
179
|
+
type="text"
|
|
180
|
+
value={displayValue}
|
|
181
|
+
onChange={handleChange}
|
|
182
|
+
onFocus={handleFocus}
|
|
183
|
+
onBlur={handleBlur}
|
|
184
|
+
prefix={currencySymbol}
|
|
185
|
+
{...props}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
CurrencyInput.displayName = 'CurrencyInput';
|
|
192
|
+
|
|
193
|
+
export default CurrencyInput;
|
|
@@ -334,3 +334,90 @@ export const FullFeatured: Story = {
|
|
|
334
334
|
),
|
|
335
335
|
},
|
|
336
336
|
};
|
|
337
|
+
|
|
338
|
+
export const WithSecondaryRows: Story = {
|
|
339
|
+
args: {
|
|
340
|
+
data: sampleUsers,
|
|
341
|
+
columns: [
|
|
342
|
+
{
|
|
343
|
+
key: 'name',
|
|
344
|
+
header: 'Name',
|
|
345
|
+
sortable: true,
|
|
346
|
+
renderSecondary: (user: User) => (
|
|
347
|
+
<span className="text-xs text-ink-500">Member since {user.joinedAt}</span>
|
|
348
|
+
),
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
key: 'email',
|
|
352
|
+
header: 'Email',
|
|
353
|
+
sortable: true,
|
|
354
|
+
renderSecondary: (user: User) => (
|
|
355
|
+
<span className="text-xs text-ink-500">{user.role}</span>
|
|
356
|
+
),
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
key: 'status',
|
|
360
|
+
header: 'Status',
|
|
361
|
+
render: (user: User) => (
|
|
362
|
+
<Badge variant={user.status === 'active' ? 'success' : user.status === 'inactive' ? 'error' : 'warning'}>
|
|
363
|
+
{user.status}
|
|
364
|
+
</Badge>
|
|
365
|
+
),
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
selectable: true,
|
|
369
|
+
actions: [
|
|
370
|
+
{
|
|
371
|
+
label: 'Edit',
|
|
372
|
+
icon: <Edit className="h-4 w-4" />,
|
|
373
|
+
onClick: (user: User) => alert(`Edit ${user.name}`),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
label: 'View',
|
|
377
|
+
icon: <Eye className="h-4 w-4" />,
|
|
378
|
+
onClick: (user: User) => alert(`View ${user.name}`),
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export const FullWidthWrappingText: Story = {
|
|
385
|
+
args: {
|
|
386
|
+
data: [
|
|
387
|
+
{ id: '1', name: 'ADOBE Adobe Systems SAN JOSE CA - Expense - Monthly subscription for creative cloud services' },
|
|
388
|
+
{ id: '2', name: 'AMAZON PRIME Amazon.com SEATTLE WA - Entertainment - Annual membership fee' },
|
|
389
|
+
{ id: '3', name: 'SPOTIFY Spotify USA NEW YORK NY - Music streaming service monthly payment' },
|
|
390
|
+
{ id: '4', name: 'NETFLIX Netflix.com LOS GATOS CA - Video streaming monthly subscription' },
|
|
391
|
+
{ id: '5', name: 'MICROSOFT Microsoft Corporation REDMOND WA - Office 365 business subscription annual fee' },
|
|
392
|
+
],
|
|
393
|
+
columns: [
|
|
394
|
+
{
|
|
395
|
+
key: 'name',
|
|
396
|
+
header: 'Transaction Name',
|
|
397
|
+
flex: 1,
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export const FullWidthWithSecondaryRow: Story = {
|
|
404
|
+
args: {
|
|
405
|
+
data: [
|
|
406
|
+
{ id: '1', name: 'ADOBE Adobe Systems SAN JOSE CA - Expense', frequency: 'Monthly', amount: '$16.52' },
|
|
407
|
+
{ id: '2', name: 'AMAZON PRIME Amazon.com SEATTLE WA - Entertainment', frequency: 'Monthly', amount: '$25.00' },
|
|
408
|
+
{ id: '3', name: 'SPOTIFY Spotify USA NEW YORK NY - Music streaming service', frequency: 'Monthly', amount: '$9.99' },
|
|
409
|
+
{ id: '4', name: 'NETFLIX Netflix.com LOS GATOS CA - Video streaming', frequency: 'Monthly', amount: '$15.49' },
|
|
410
|
+
{ id: '5', name: 'MICROSOFT Microsoft Corporation REDMOND WA - Office 365 business subscription', frequency: 'Annual', amount: '$132.14' },
|
|
411
|
+
],
|
|
412
|
+
columns: [
|
|
413
|
+
{
|
|
414
|
+
key: 'name',
|
|
415
|
+
header: 'Name',
|
|
416
|
+
flex: 1,
|
|
417
|
+
renderSecondary: (item: any) => (
|
|
418
|
+
<span className="text-xs text-ink-500">{item.frequency} • {item.amount}</span>
|
|
419
|
+
),
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import React, { useState, useRef, useEffect } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
4
|
import { ChevronDown, ChevronRight, MoreVertical, Edit, Trash } from 'lucide-react';
|
|
5
|
+
import Menu, { MenuItem } from './Menu';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Base data item interface - all data items must have an id
|
|
@@ -164,6 +165,8 @@ interface DataTableProps<T extends BaseDataItem = BaseDataItem> {
|
|
|
164
165
|
onDelete?: (item: T) => void | Promise<void>;
|
|
165
166
|
/** Optional custom row actions (in addition to edit/delete) */
|
|
166
167
|
actions?: DataTableAction<T>[];
|
|
168
|
+
/** Enable context menu (right-click) for row actions (default: true when actions exist) */
|
|
169
|
+
enableContextMenu?: boolean;
|
|
167
170
|
/** Optional click handler for rows */
|
|
168
171
|
onRowClick?: (item: T) => void;
|
|
169
172
|
/** Optional double-click handler for rows */
|
|
@@ -455,6 +458,7 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
455
458
|
onEdit,
|
|
456
459
|
onDelete,
|
|
457
460
|
actions = [],
|
|
461
|
+
enableContextMenu = true,
|
|
458
462
|
onRowClick,
|
|
459
463
|
onRowDoubleClick,
|
|
460
464
|
selectable = false,
|
|
@@ -502,6 +506,20 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
502
506
|
const [scrollTop, setScrollTop] = useState(0);
|
|
503
507
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
504
508
|
|
|
509
|
+
// Row hover state (for coordinating primary + secondary row highlighting)
|
|
510
|
+
const [hoveredRowKey, setHoveredRowKey] = useState<string | null>(null);
|
|
511
|
+
|
|
512
|
+
// Context menu state
|
|
513
|
+
const [contextMenuState, setContextMenuState] = useState<{
|
|
514
|
+
isOpen: boolean;
|
|
515
|
+
position: { x: number; y: number };
|
|
516
|
+
item: T | null;
|
|
517
|
+
}>({
|
|
518
|
+
isOpen: false,
|
|
519
|
+
position: { x: 0, y: 0 },
|
|
520
|
+
item: null,
|
|
521
|
+
});
|
|
522
|
+
|
|
505
523
|
// Filter columns based on hiddenColumns
|
|
506
524
|
const baseVisibleColumns = columns.filter(
|
|
507
525
|
col => !hiddenColumns.includes(String(col.key))
|
|
@@ -754,7 +772,31 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
754
772
|
}
|
|
755
773
|
|
|
756
774
|
const allActions = [...builtInActions, ...actions];
|
|
757
|
-
|
|
775
|
+
|
|
776
|
+
// Convert actions to menu items for context menu
|
|
777
|
+
const convertActionsToMenuItems = (item: T): MenuItem[] => {
|
|
778
|
+
const visibleActions = allActions.filter(action => !action.show || action.show(item));
|
|
779
|
+
|
|
780
|
+
return visibleActions.map((action, idx) => {
|
|
781
|
+
let iconElement: React.ReactNode = null;
|
|
782
|
+
if (action.icon) {
|
|
783
|
+
if (React.isValidElement(action.icon)) {
|
|
784
|
+
iconElement = action.icon;
|
|
785
|
+
} else {
|
|
786
|
+
iconElement = React.createElement(action.icon as any, { className: 'h-4 w-4' });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
id: `action-${idx}`,
|
|
792
|
+
label: action.label,
|
|
793
|
+
icon: iconElement,
|
|
794
|
+
onClick: () => action.onClick(item),
|
|
795
|
+
danger: action.variant === 'danger',
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
};
|
|
799
|
+
|
|
758
800
|
// Selection state management
|
|
759
801
|
const [internalSelectedRows, setInternalSelectedRows] = useState<Set<string>>(new Set());
|
|
760
802
|
|
|
@@ -980,50 +1022,98 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
980
1022
|
const isSelected = selectedRowsSet.has(rowKey);
|
|
981
1023
|
const isExpanded = expandedRowsSet.has(rowKey);
|
|
982
1024
|
const rowBgClass = getRowBackgroundClass(item, index);
|
|
983
|
-
const hoverClass = disableHover ? '' : 'hover:bg-paper-100';
|
|
984
1025
|
const borderClass = bordered ? `border-b ${borderColor}` : (!visibleColumns.some(col => !!col.renderSecondary) ? `border-b ${borderColor}` : '');
|
|
1026
|
+
const hasSecondaryRow = visibleColumns.some(col => !!col.renderSecondary);
|
|
1027
|
+
|
|
1028
|
+
// Hover state for row pair (primary + secondary)
|
|
1029
|
+
const isHovered = hoveredRowKey === rowKey;
|
|
1030
|
+
const hoverClass = disableHover ? '' : (isHovered ? 'bg-paper-100' : '');
|
|
985
1031
|
|
|
986
1032
|
return (
|
|
987
1033
|
<React.Fragment key={rowKey}>
|
|
988
1034
|
<tr
|
|
989
|
-
className={
|
|
1035
|
+
className={`table-row-stable ${onRowDoubleClick || onRowClick || onEdit || expandedRowConfig?.edit || expandedRowConfig?.details || expandedRowConfig?.addRelated?.length || expandedRowConfig?.manageRelated?.length ? 'cursor-pointer' : ''} ${isSelected ? 'bg-accent-50 border-l-2 border-accent-500' : hoverClass || rowBgClass} ${borderClass}`}
|
|
1036
|
+
onMouseEnter={() => !disableHover && setHoveredRowKey(rowKey)}
|
|
1037
|
+
onMouseLeave={() => !disableHover && setHoveredRowKey(null)}
|
|
990
1038
|
onClick={() => onRowClick?.(item)}
|
|
1039
|
+
onContextMenu={(e) => {
|
|
1040
|
+
if (enableContextMenu && allActions.length > 0) {
|
|
1041
|
+
e.preventDefault();
|
|
1042
|
+
e.stopPropagation();
|
|
1043
|
+
|
|
1044
|
+
const x = e.clientX;
|
|
1045
|
+
const y = e.clientY;
|
|
1046
|
+
|
|
1047
|
+
setContextMenuState({
|
|
1048
|
+
isOpen: true,
|
|
1049
|
+
position: { x, y },
|
|
1050
|
+
item,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
}}
|
|
991
1054
|
onDoubleClick={() => {
|
|
992
|
-
//
|
|
993
|
-
if (
|
|
1055
|
+
// Priority 1: If there's an onEdit handler (legacy), trigger it
|
|
1056
|
+
if (onEdit) {
|
|
1057
|
+
onEdit(item);
|
|
1058
|
+
}
|
|
1059
|
+
// Priority 2: If there's an expandable edit mode, trigger it
|
|
1060
|
+
else if (expandedRowConfig?.edit) {
|
|
994
1061
|
handleExpansionWithMode(rowKey, 'edit');
|
|
995
1062
|
}
|
|
996
|
-
//
|
|
997
|
-
else if (expandedRowConfig?.details
|
|
1063
|
+
// Priority 3: If there's an expandable details mode, trigger it
|
|
1064
|
+
else if (expandedRowConfig?.details) {
|
|
998
1065
|
handleExpansionWithMode(rowKey, 'details');
|
|
999
1066
|
}
|
|
1000
|
-
//
|
|
1067
|
+
// Priority 4: If there's any addRelated mode, trigger the first one
|
|
1068
|
+
else if (expandedRowConfig?.addRelated && expandedRowConfig.addRelated.length > 0) {
|
|
1069
|
+
handleExpansionWithMode(rowKey, `addRelated-${expandedRowConfig.addRelated[0].key}`);
|
|
1070
|
+
}
|
|
1071
|
+
// Priority 5: If there's any manageRelated mode, trigger the first one
|
|
1072
|
+
else if (expandedRowConfig?.manageRelated && expandedRowConfig.manageRelated.length > 0) {
|
|
1073
|
+
handleExpansionWithMode(rowKey, `manageRelated-${expandedRowConfig.manageRelated[0].key}`);
|
|
1074
|
+
}
|
|
1075
|
+
// Priority 6: Legacy onRowDoubleClick handler
|
|
1001
1076
|
else {
|
|
1002
1077
|
onRowDoubleClick?.(item);
|
|
1003
1078
|
}
|
|
1004
1079
|
}}
|
|
1005
1080
|
title={
|
|
1006
|
-
|
|
1007
|
-
expandedRowConfig?.
|
|
1008
|
-
|
|
1009
|
-
|
|
1081
|
+
onEdit ? 'Double-click to edit' :
|
|
1082
|
+
expandedRowConfig?.edit ? 'Double-click to edit inline' :
|
|
1083
|
+
expandedRowConfig?.details ? 'Double-click to view details' :
|
|
1084
|
+
expandedRowConfig?.addRelated && expandedRowConfig.addRelated.length > 0 ? `Double-click to ${expandedRowConfig.addRelated[0].label}` :
|
|
1085
|
+
expandedRowConfig?.manageRelated && expandedRowConfig.manageRelated.length > 0 ? `Double-click to ${expandedRowConfig.manageRelated[0].label}` :
|
|
1086
|
+
onRowDoubleClick ? 'Double-click for details' :
|
|
1087
|
+
onRowClick ? 'Click to select' :
|
|
1010
1088
|
undefined
|
|
1011
1089
|
}
|
|
1012
1090
|
>
|
|
1013
1091
|
{selectable && (
|
|
1014
|
-
<td
|
|
1092
|
+
<td
|
|
1093
|
+
className={`sticky left-0 z-10 ${bordered ? `border ${borderColor}` : ''}`}
|
|
1094
|
+
style={{
|
|
1095
|
+
backgroundColor: 'inherit',
|
|
1096
|
+
verticalAlign: 'middle',
|
|
1097
|
+
padding: '0.375rem 0.75rem',
|
|
1098
|
+
textAlign: 'center'
|
|
1099
|
+
}}
|
|
1100
|
+
rowSpan={hasSecondaryRow ? 2 : 1}
|
|
1101
|
+
>
|
|
1015
1102
|
<input
|
|
1016
1103
|
type="checkbox"
|
|
1017
1104
|
checked={isSelected}
|
|
1018
1105
|
onChange={() => handleRowSelect(rowKey)}
|
|
1019
1106
|
className="w-4 h-4 text-accent-600 border-paper-300 rounded focus:ring-accent-400"
|
|
1020
|
-
style={{ verticalAlign: 'middle' }}
|
|
1021
1107
|
aria-label={`Select row ${rowKey}`}
|
|
1022
1108
|
/>
|
|
1023
1109
|
</td>
|
|
1024
1110
|
)}
|
|
1025
1111
|
{((expandable || expandedRowConfig) && showExpandChevron) && (
|
|
1026
|
-
<td
|
|
1112
|
+
<td
|
|
1113
|
+
className={`sticky left-0 px-2 ${currentDensity.cell} z-10 ${bordered ? `border ${borderColor}` : ''}`}
|
|
1114
|
+
style={{ backgroundColor: 'inherit', verticalAlign: 'middle' }}
|
|
1115
|
+
rowSpan={hasSecondaryRow ? 2 : 1}
|
|
1116
|
+
>
|
|
1027
1117
|
<button
|
|
1028
1118
|
onClick={() => {
|
|
1029
1119
|
// NEW: Enhanced logic for expandedRowConfig
|
|
@@ -1059,14 +1149,14 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
1059
1149
|
verticalAlign: 'middle'
|
|
1060
1150
|
}}
|
|
1061
1151
|
onClick={(e) => e.stopPropagation()}
|
|
1062
|
-
rowSpan={
|
|
1152
|
+
rowSpan={hasSecondaryRow ? 2 : 1}
|
|
1063
1153
|
>
|
|
1064
1154
|
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '28px' }}>
|
|
1065
1155
|
<ActionMenu actions={allActions} item={item} />
|
|
1066
1156
|
</div>
|
|
1067
1157
|
</td>
|
|
1068
1158
|
)}
|
|
1069
|
-
{visibleColumns.map((column) => {
|
|
1159
|
+
{visibleColumns.map((column, colIdx) => {
|
|
1070
1160
|
const columnKey = String(column.key);
|
|
1071
1161
|
const dynamicWidth = columnWidths[columnKey];
|
|
1072
1162
|
const value = typeof column.key === 'string'
|
|
@@ -1075,10 +1165,14 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
1075
1165
|
|
|
1076
1166
|
const primaryContent = column.render ? column.render(item, value) : String(value || '');
|
|
1077
1167
|
|
|
1168
|
+
// Reduce left padding on first column when there are action buttons
|
|
1169
|
+
const isFirstColumn = colIdx === 0;
|
|
1170
|
+
const paddingClass = isFirstColumn && allActions.length > 0 ? 'pl-3' : '';
|
|
1171
|
+
|
|
1078
1172
|
return (
|
|
1079
1173
|
<td
|
|
1080
1174
|
key={`${item.id}-${columnKey}`}
|
|
1081
|
-
className={`${currentDensity.cell} ${column.className || ''} ${bordered ? `border ${borderColor}` : ''}`}
|
|
1175
|
+
className={`${currentDensity.cell} ${paddingClass} ${column.className || ''} ${bordered ? `border ${borderColor}` : ''}`}
|
|
1082
1176
|
style={getColumnStyle(column, dynamicWidth)}
|
|
1083
1177
|
>
|
|
1084
1178
|
<div className={`${currentDensity.text} leading-tight`}>{primaryContent}</div>
|
|
@@ -1088,12 +1182,16 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
1088
1182
|
</tr>
|
|
1089
1183
|
|
|
1090
1184
|
{/* Secondary row - only render if any column has renderSecondary */}
|
|
1091
|
-
{
|
|
1092
|
-
<tr
|
|
1093
|
-
|
|
1094
|
-
{(
|
|
1185
|
+
{hasSecondaryRow && (
|
|
1186
|
+
<tr
|
|
1187
|
+
className={`secondary-row ${isSelected ? 'bg-accent-50 border-l-2 border-accent-500' : hoverClass || rowBgClass} border-b ${borderColor}`}
|
|
1188
|
+
onMouseEnter={() => !disableHover && setHoveredRowKey(rowKey)}
|
|
1189
|
+
onMouseLeave={() => !disableHover && setHoveredRowKey(null)}
|
|
1190
|
+
>
|
|
1191
|
+
{/* Selectable checkbox uses rowspan from primary row, no cell needed here */}
|
|
1192
|
+
{/* Expand chevron uses rowspan from primary row, no cell needed here */}
|
|
1095
1193
|
{/* Actions column uses rowspan from primary row, no cell needed here */}
|
|
1096
|
-
{visibleColumns.map((column) => {
|
|
1194
|
+
{visibleColumns.map((column, colIdx) => {
|
|
1097
1195
|
const columnKey = String(column.key);
|
|
1098
1196
|
const dynamicWidth = columnWidths[columnKey];
|
|
1099
1197
|
const value = typeof column.key === 'string'
|
|
@@ -1101,10 +1199,14 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
1101
1199
|
: item[column.key];
|
|
1102
1200
|
const secondaryContent = column.renderSecondary ? column.renderSecondary(item, value) : null;
|
|
1103
1201
|
|
|
1202
|
+
// Reduce left padding on first column when there are action buttons
|
|
1203
|
+
const isFirstColumn = colIdx === 0;
|
|
1204
|
+
const paddingClass = isFirstColumn && allActions.length > 0 ? 'pl-3' : '';
|
|
1205
|
+
|
|
1104
1206
|
return (
|
|
1105
1207
|
<td
|
|
1106
1208
|
key={`${item.id}-${columnKey}-secondary`}
|
|
1107
|
-
className={`${currentDensity.cell} py-0.5 ${column.className || ''} ${bordered ? `border ${borderColor}` : ''}`}
|
|
1209
|
+
className={`${currentDensity.cell} py-0.5 ${paddingClass} ${column.className || ''} ${bordered ? `border ${borderColor}` : ''}`}
|
|
1108
1210
|
style={getColumnStyle(column, dynamicWidth)}
|
|
1109
1211
|
>
|
|
1110
1212
|
<div className="text-xs text-ink-500 leading-tight">
|
|
@@ -1334,18 +1436,28 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
1334
1436
|
);
|
|
1335
1437
|
|
|
1336
1438
|
// Wrap in scrollable container if virtualized
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
);
|
|
1348
|
-
}
|
|
1439
|
+
const finalContent = virtualized ? (
|
|
1440
|
+
<div
|
|
1441
|
+
ref={tableContainerRef}
|
|
1442
|
+
onScroll={handleScroll}
|
|
1443
|
+
style={{ height: virtualHeight, overflow: 'auto' }}
|
|
1444
|
+
className="rounded-lg"
|
|
1445
|
+
>
|
|
1446
|
+
{tableContent}
|
|
1447
|
+
</div>
|
|
1448
|
+
) : tableContent;
|
|
1349
1449
|
|
|
1350
|
-
|
|
1450
|
+
// Render with context menu
|
|
1451
|
+
return (
|
|
1452
|
+
<>
|
|
1453
|
+
{finalContent}
|
|
1454
|
+
{contextMenuState.isOpen && contextMenuState.item && (
|
|
1455
|
+
<Menu
|
|
1456
|
+
items={convertActionsToMenuItems(contextMenuState.item)}
|
|
1457
|
+
position={contextMenuState.position}
|
|
1458
|
+
onClose={() => setContextMenuState({ isOpen: false, position: { x: 0, y: 0 }, item: null })}
|
|
1459
|
+
/>
|
|
1460
|
+
)}
|
|
1461
|
+
</>
|
|
1462
|
+
);
|
|
1351
1463
|
}
|
|
@@ -284,3 +284,67 @@ export const NoCloseButton: Story = {
|
|
|
284
284
|
);
|
|
285
285
|
},
|
|
286
286
|
};
|
|
287
|
+
|
|
288
|
+
export const TextSelectionTest: Story = {
|
|
289
|
+
render: () => {
|
|
290
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
291
|
+
return (
|
|
292
|
+
<>
|
|
293
|
+
<Button onClick={() => setIsOpen(true)}>Test Text Selection</Button>
|
|
294
|
+
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Text Selection Test">
|
|
295
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
296
|
+
<div style={{
|
|
297
|
+
padding: '1rem',
|
|
298
|
+
backgroundColor: '#dbeafe',
|
|
299
|
+
border: '1px solid #3b82f6',
|
|
300
|
+
borderRadius: '0.375rem'
|
|
301
|
+
}}>
|
|
302
|
+
<h4 style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem', color: '#1e40af' }}>
|
|
303
|
+
✅ Bug Fix Test: Text Selection
|
|
304
|
+
</h4>
|
|
305
|
+
<p style={{ fontSize: '0.875rem' }}>
|
|
306
|
+
Try selecting text in the input fields below by clicking and dragging <strong>outside</strong> the modal boundaries.
|
|
307
|
+
The modal should <strong>NOT</strong> close when you release the mouse button outside.
|
|
308
|
+
</p>
|
|
309
|
+
</div>
|
|
310
|
+
<Input
|
|
311
|
+
label="Full Name"
|
|
312
|
+
placeholder="Try selecting this text and dragging outside the modal"
|
|
313
|
+
defaultValue="John Smith - Drag to select and move mouse outside modal bounds"
|
|
314
|
+
/>
|
|
315
|
+
<Input
|
|
316
|
+
label="Email Address"
|
|
317
|
+
type="email"
|
|
318
|
+
placeholder="user@example.com"
|
|
319
|
+
defaultValue="test.user@example.com - Select text here too"
|
|
320
|
+
/>
|
|
321
|
+
<Input
|
|
322
|
+
label="Company"
|
|
323
|
+
placeholder="Company name"
|
|
324
|
+
defaultValue="This is a long company name that you can select by dragging"
|
|
325
|
+
/>
|
|
326
|
+
<div style={{
|
|
327
|
+
padding: '0.75rem',
|
|
328
|
+
backgroundColor: '#fef3c7',
|
|
329
|
+
border: '1px solid #f59e0b',
|
|
330
|
+
borderRadius: '0.375rem',
|
|
331
|
+
fontSize: '0.875rem'
|
|
332
|
+
}}>
|
|
333
|
+
<strong>Test Instructions:</strong>
|
|
334
|
+
<ol style={{ marginLeft: '1.5rem', marginTop: '0.5rem' }}>
|
|
335
|
+
<li>Click and hold inside any input field</li>
|
|
336
|
+
<li>While holding, drag your mouse outside the modal</li>
|
|
337
|
+
<li>Release the mouse button while outside</li>
|
|
338
|
+
<li>Modal should remain open! ✅</li>
|
|
339
|
+
</ol>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
<ModalFooter>
|
|
343
|
+
<Button variant="ghost" onClick={() => setIsOpen(false)}>Cancel</Button>
|
|
344
|
+
<Button variant="primary" onClick={() => setIsOpen(false)}>Save</Button>
|
|
345
|
+
</ModalFooter>
|
|
346
|
+
</Modal>
|
|
347
|
+
</>
|
|
348
|
+
);
|
|
349
|
+
},
|
|
350
|
+
};
|