@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.
Files changed (84) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +455 -445
  3. package/dist/components/CurrencyInput.d.ts +52 -0
  4. package/dist/components/CurrencyInput.d.ts.map +1 -0
  5. package/dist/components/DataTable.d.ts +3 -1
  6. package/dist/components/DataTable.d.ts.map +1 -1
  7. package/dist/components/Modal.d.ts.map +1 -1
  8. package/dist/components/Page.d.ts +2 -0
  9. package/dist/components/Page.d.ts.map +1 -1
  10. package/dist/components/PageLayout.d.ts +5 -1
  11. package/dist/components/PageLayout.d.ts.map +1 -1
  12. package/dist/components/Spreadsheet.d.ts +129 -0
  13. package/dist/components/Spreadsheet.d.ts.map +1 -0
  14. package/dist/components/Tabs.d.ts +5 -1
  15. package/dist/components/Tabs.d.ts.map +1 -1
  16. package/dist/components/index.d.ts +6 -0
  17. package/dist/components/index.d.ts.map +1 -1
  18. package/dist/index.d.ts +336 -5
  19. package/dist/index.esm.js +51152 -174
  20. package/dist/index.esm.js.map +1 -1
  21. package/dist/index.js +51145 -143
  22. package/dist/index.js.map +1 -1
  23. package/dist/styles.css +1187 -11
  24. package/dist/utils/excelExport.d.ts +143 -0
  25. package/dist/utils/excelExport.d.ts.map +1 -0
  26. package/dist/utils/index.d.ts +2 -0
  27. package/dist/utils/index.d.ts.map +1 -1
  28. package/package.json +13 -3
  29. package/src/components/AdminModal.css +49 -49
  30. package/src/components/CurrencyInput.stories.tsx +290 -0
  31. package/src/components/CurrencyInput.tsx +193 -0
  32. package/src/components/DataTable.stories.tsx +87 -0
  33. package/src/components/DataTable.tsx +149 -37
  34. package/src/components/Modal.stories.tsx +64 -0
  35. package/src/components/Modal.tsx +15 -2
  36. package/src/components/Page.stories.tsx +76 -0
  37. package/src/components/Page.tsx +35 -3
  38. package/src/components/PageLayout.stories.tsx +75 -0
  39. package/src/components/PageLayout.tsx +28 -9
  40. package/src/components/RoleManager.css +10 -10
  41. package/src/components/Spreadsheet.css +216 -0
  42. package/src/components/Spreadsheet.stories.tsx +362 -0
  43. package/src/components/Spreadsheet.tsx +351 -0
  44. package/src/components/SpreadsheetSimple.stories.tsx +27 -0
  45. package/src/components/Tabs.stories.tsx +31 -0
  46. package/src/components/Tabs.tsx +28 -4
  47. package/src/components/TimePicker.tsx +1 -1
  48. package/src/components/Toast.tsx +9 -9
  49. package/src/components/__tests__/Input.test.tsx +22 -26
  50. package/src/components/index.ts +11 -2
  51. package/src/styles/index.css +44 -6
  52. package/src/utils/excelExport.stories.tsx +535 -0
  53. package/src/utils/excelExport.ts +225 -0
  54. package/src/utils/index.ts +3 -0
  55. package/src/utils/sqlToNaturalLanguage.ts +1 -1
  56. package/tailwind.config.js +253 -253
  57. package/dist/components/Button.stories.d.ts +0 -51
  58. package/dist/components/Button.stories.d.ts.map +0 -1
  59. package/dist/components/ChartVisualizationUI.d.ts +0 -21
  60. package/dist/components/ChartVisualizationUI.d.ts.map +0 -1
  61. package/dist/components/ChatUI.d.ts +0 -23
  62. package/dist/components/ChatUI.d.ts.map +0 -1
  63. package/dist/components/CommissionDashboardUI.d.ts +0 -25
  64. package/dist/components/CommissionDashboardUI.d.ts.map +0 -1
  65. package/dist/components/DataTable.stories.d.ts +0 -23
  66. package/dist/components/DataTable.stories.d.ts.map +0 -1
  67. package/dist/components/FormField.d.ts +0 -35
  68. package/dist/components/FormField.d.ts.map +0 -1
  69. package/dist/components/Input.stories.d.ts +0 -366
  70. package/dist/components/Input.stories.d.ts.map +0 -1
  71. package/dist/components/InsightsPanelUI.d.ts +0 -21
  72. package/dist/components/InsightsPanelUI.d.ts.map +0 -1
  73. package/dist/components/PaymentHistoryTimeline.d.ts +0 -34
  74. package/dist/components/PaymentHistoryTimeline.d.ts.map +0 -1
  75. package/dist/components/RelationshipManagerUI.d.ts +0 -60
  76. package/dist/components/RelationshipManagerUI.d.ts.map +0 -1
  77. package/dist/components/RoleManager.d.ts +0 -19
  78. package/dist/components/RoleManager.d.ts.map +0 -1
  79. package/dist/components/SplitCommissionBadge.d.ts +0 -18
  80. package/dist/components/SplitCommissionBadge.d.ts.map +0 -1
  81. package/dist/components/__tests__/Button.test.d.ts +0 -2
  82. package/dist/components/__tests__/Button.test.d.ts.map +0 -1
  83. package/dist/components/__tests__/Input.test.d.ts +0 -2
  84. 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={`${hoverClass} table-row-stable ${onRowDoubleClick || onRowClick || (expandedRowConfig?.edit?.triggerOnDoubleClick !== false) || (expandedRowConfig?.details?.triggerOnDoubleClick === true) ? 'cursor-pointer' : ''} ${isSelected ? 'bg-accent-50 border-l-2 border-accent-500' : rowBgClass} ${borderClass}`}
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
- // Check for edit mode with triggerOnDoubleClick
993
- if (expandedRowConfig?.edit && expandedRowConfig.edit.triggerOnDoubleClick !== false) {
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
- // Check for details mode with triggerOnDoubleClick
997
- else if (expandedRowConfig?.details && expandedRowConfig.details.triggerOnDoubleClick === true) {
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
- // Legacy: use onRowDoubleClick handler
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
- expandedRowConfig?.edit && expandedRowConfig.edit.triggerOnDoubleClick !== false ? 'Double-click to edit inline' :
1007
- expandedRowConfig?.details && expandedRowConfig.details.triggerOnDoubleClick === true ? 'Double-click to view details' :
1008
- onRowDoubleClick ? 'Double-click to open details' :
1009
- onRowClick ? 'Click to select' :
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 className={`sticky left-0 bg-white ${currentDensity.cell} z-10 align-middle ${bordered ? `border ${borderColor}` : ''}`}>
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 className={`sticky left-0 bg-white px-2 ${currentDensity.cell} z-10 ${bordered ? `border ${borderColor}` : ''}`}>
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={visibleColumns.some(col => !!col.renderSecondary) ? 2 : 1}
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
- {visibleColumns.some(col => !!col.renderSecondary) && (
1092
- <tr className={`secondary-row ${hoverClass} border-b ${borderColor}`}>
1093
- {selectable && <td className={`sticky left-0 bg-white ${currentDensity.cell} py-0.5 z-10 ${bordered ? `border ${borderColor}` : ''}`}></td>}
1094
- {((expandable || expandedRowConfig) && showExpandChevron) && <td className={`sticky left-0 bg-white px-2 py-0.5 z-10 ${bordered ? `border ${borderColor}` : ''}`}></td>}
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
- if (virtualized) {
1338
- return (
1339
- <div
1340
- ref={tableContainerRef}
1341
- onScroll={handleScroll}
1342
- style={{ height: virtualHeight, overflow: 'auto' }}
1343
- className="rounded-lg"
1344
- >
1345
- {tableContent}
1346
- </div>
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
- return tableContent;
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
+ };