@papernote/ui 1.1.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 -455
- 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/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +204 -4
- package/dist/index.esm.js +415 -88
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +413 -82
- package/dist/index.js.map +1 -1
- package/dist/styles.css +2877 -2675
- 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 +1 -1
- 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.tsx +78 -14
- 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 -216
- package/src/components/Spreadsheet.stories.tsx +362 -362
- package/src/components/Spreadsheet.tsx +351 -351
- package/src/components/SpreadsheetSimple.stories.tsx +27 -27
- package/src/components/Tabs.tsx +152 -152
- package/src/components/index.ts +5 -0
- package/src/styles/index.css +41 -4
- package/src/utils/excelExport.stories.tsx +535 -0
- package/src/utils/excelExport.ts +225 -0
- package/src/utils/index.ts +3 -0
- 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/Spreadsheet.css +0 -216
- 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;
|
|
@@ -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,
|
|
@@ -505,6 +509,17 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
505
509
|
// Row hover state (for coordinating primary + secondary row highlighting)
|
|
506
510
|
const [hoveredRowKey, setHoveredRowKey] = useState<string | null>(null);
|
|
507
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
|
+
|
|
508
523
|
// Filter columns based on hiddenColumns
|
|
509
524
|
const baseVisibleColumns = columns.filter(
|
|
510
525
|
col => !hiddenColumns.includes(String(col.key))
|
|
@@ -757,7 +772,31 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
757
772
|
}
|
|
758
773
|
|
|
759
774
|
const allActions = [...builtInActions, ...actions];
|
|
760
|
-
|
|
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
|
+
|
|
761
800
|
// Selection state management
|
|
762
801
|
const [internalSelectedRows, setInternalSelectedRows] = useState<Set<string>>(new Set());
|
|
763
802
|
|
|
@@ -997,6 +1036,21 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
997
1036
|
onMouseEnter={() => !disableHover && setHoveredRowKey(rowKey)}
|
|
998
1037
|
onMouseLeave={() => !disableHover && setHoveredRowKey(null)}
|
|
999
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
|
+
}}
|
|
1000
1054
|
onDoubleClick={() => {
|
|
1001
1055
|
// Priority 1: If there's an onEdit handler (legacy), trigger it
|
|
1002
1056
|
if (onEdit) {
|
|
@@ -1382,18 +1436,28 @@ export default function DataTable<T extends BaseDataItem = BaseDataItem>({
|
|
|
1382
1436
|
);
|
|
1383
1437
|
|
|
1384
1438
|
// Wrap in scrollable container if virtualized
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
);
|
|
1396
|
-
}
|
|
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;
|
|
1397
1449
|
|
|
1398
|
-
|
|
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
|
+
);
|
|
1399
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
|
+
};
|
package/src/components/Modal.tsx
CHANGED
|
@@ -30,6 +30,7 @@ export default function Modal({
|
|
|
30
30
|
animation = 'scale',
|
|
31
31
|
}: ModalProps) {
|
|
32
32
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const mouseDownOnBackdrop = useRef(false);
|
|
33
34
|
const titleId = useId();
|
|
34
35
|
|
|
35
36
|
// Handle escape key
|
|
@@ -51,11 +52,22 @@ export default function Modal({
|
|
|
51
52
|
};
|
|
52
53
|
}, [isOpen, onClose]);
|
|
53
54
|
|
|
54
|
-
//
|
|
55
|
-
const
|
|
55
|
+
// Track if mousedown originated on the backdrop
|
|
56
|
+
const handleBackdropMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
56
57
|
if (e.target === e.currentTarget) {
|
|
58
|
+
mouseDownOnBackdrop.current = true;
|
|
59
|
+
} else {
|
|
60
|
+
mouseDownOnBackdrop.current = false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Handle click outside - only close if both mousedown and click happened on backdrop
|
|
65
|
+
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
66
|
+
if (e.target === e.currentTarget && mouseDownOnBackdrop.current) {
|
|
57
67
|
onClose();
|
|
58
68
|
}
|
|
69
|
+
// Reset the flag after handling click
|
|
70
|
+
mouseDownOnBackdrop.current = false;
|
|
59
71
|
};
|
|
60
72
|
|
|
61
73
|
const getAnimationClass = () => {
|
|
@@ -80,6 +92,7 @@ export default function Modal({
|
|
|
80
92
|
return (
|
|
81
93
|
<div
|
|
82
94
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink-900 bg-opacity-50 backdrop-blur-sm animate-fade-in"
|
|
95
|
+
onMouseDown={handleBackdropMouseDown}
|
|
83
96
|
onClick={handleBackdropClick}
|
|
84
97
|
>
|
|
85
98
|
<div
|
|
@@ -278,3 +278,79 @@ export const SettingsPage: Story = {
|
|
|
278
278
|
</Page>
|
|
279
279
|
),
|
|
280
280
|
};
|
|
281
|
+
|
|
282
|
+
export const ResponsiveLayout: Story = {
|
|
283
|
+
render: () => (
|
|
284
|
+
<Page>
|
|
285
|
+
<Card style={{ marginBottom: '1.5rem', backgroundColor: '#dbeafe', border: '1px solid #3b82f6' }}>
|
|
286
|
+
<CardContent>
|
|
287
|
+
<h3 style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.875rem', color: '#1e40af' }}>
|
|
288
|
+
📐 Responsive Layout (Default)
|
|
289
|
+
</h3>
|
|
290
|
+
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
|
291
|
+
Try resizing your browser window! The page keeps <strong>left and top margins/padding fixed</strong>,
|
|
292
|
+
but <strong>right and bottom resize responsively</strong>:
|
|
293
|
+
</p>
|
|
294
|
+
<ul style={{ marginLeft: '1.5rem', fontSize: '0.875rem' }}>
|
|
295
|
+
<li>• Small screens: Minimal right/bottom padding for more content space</li>
|
|
296
|
+
<li>• Medium screens: Increased right/bottom padding</li>
|
|
297
|
+
<li>• Large screens (1024px+): Maximum spacing with centered layout</li>
|
|
298
|
+
</ul>
|
|
299
|
+
</CardContent>
|
|
300
|
+
</Card>
|
|
301
|
+
<Card>
|
|
302
|
+
<CardHeader>
|
|
303
|
+
<CardTitle>Content Adapts to Screen Size</CardTitle>
|
|
304
|
+
</CardHeader>
|
|
305
|
+
<CardContent>
|
|
306
|
+
<p style={{ marginBottom: '1rem' }}>
|
|
307
|
+
This is the default responsive behavior. The notebook aesthetic is maintained
|
|
308
|
+
across all screen sizes while optimizing for available space.
|
|
309
|
+
</p>
|
|
310
|
+
<p>
|
|
311
|
+
The left binding edge and top margin stay consistent (maintaining the notebook look),
|
|
312
|
+
while right and bottom spacing adjusts for comfort on different devices.
|
|
313
|
+
</p>
|
|
314
|
+
</CardContent>
|
|
315
|
+
</Card>
|
|
316
|
+
</Page>
|
|
317
|
+
),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export const FixedLayout: Story = {
|
|
321
|
+
render: () => (
|
|
322
|
+
<Page fixed={true}>
|
|
323
|
+
<Card style={{ marginBottom: '1.5rem', backgroundColor: '#fef3c7', border: '1px solid #f59e0b' }}>
|
|
324
|
+
<CardContent>
|
|
325
|
+
<h3 style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.875rem', color: '#92400e' }}>
|
|
326
|
+
📌 Fixed Layout (fixed=true)
|
|
327
|
+
</h3>
|
|
328
|
+
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
|
329
|
+
This page uses <code style={{ backgroundColor: '#fff', padding: '0.125rem 0.375rem', borderRadius: '0.25rem' }}>fixed=true</code>.
|
|
330
|
+
All margins and padding stay <strong>constant regardless of screen size</strong>:
|
|
331
|
+
</p>
|
|
332
|
+
<ul style={{ marginLeft: '1.5rem', fontSize: '0.875rem' }}>
|
|
333
|
+
<li>• Left, right, top, and bottom spacing never changes</li>
|
|
334
|
+
<li>• Consistent appearance on all devices</li>
|
|
335
|
+
<li>• May use more horizontal space on large screens</li>
|
|
336
|
+
</ul>
|
|
337
|
+
</CardContent>
|
|
338
|
+
</Card>
|
|
339
|
+
<Card>
|
|
340
|
+
<CardHeader>
|
|
341
|
+
<CardTitle>Consistent Spacing Everywhere</CardTitle>
|
|
342
|
+
</CardHeader>
|
|
343
|
+
<CardContent>
|
|
344
|
+
<p style={{ marginBottom: '1rem' }}>
|
|
345
|
+
Use the <code style={{ backgroundColor: '#f5f5f5', padding: '0.25rem 0.5rem', borderRadius: '0.25rem' }}>fixed</code> prop
|
|
346
|
+
when you need absolute consistency across all screen sizes, or when the responsive
|
|
347
|
+
behavior doesn't match your design requirements.
|
|
348
|
+
</p>
|
|
349
|
+
<p>
|
|
350
|
+
Try resizing your browser - the spacing around this page content remains identical.
|
|
351
|
+
</p>
|
|
352
|
+
</CardContent>
|
|
353
|
+
</Card>
|
|
354
|
+
</Page>
|
|
355
|
+
),
|
|
356
|
+
};
|
package/src/components/Page.tsx
CHANGED
|
@@ -12,6 +12,8 @@ export interface PageProps {
|
|
|
12
12
|
className?: string;
|
|
13
13
|
/** Padding size around the content (default: 'normal') */
|
|
14
14
|
padding?: 'none' | 'sm' | 'normal' | 'lg';
|
|
15
|
+
/** Fix all margins/padding instead of responsive (default: false) */
|
|
16
|
+
fixed?: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -47,14 +49,44 @@ export interface PageProps {
|
|
|
47
49
|
*/
|
|
48
50
|
export const Page: React.FC<PageProps> = ({
|
|
49
51
|
children,
|
|
50
|
-
maxWidth
|
|
52
|
+
maxWidth = '7xl',
|
|
51
53
|
className = '',
|
|
52
|
-
padding
|
|
54
|
+
padding = 'normal',
|
|
55
|
+
fixed = false
|
|
53
56
|
}) => {
|
|
57
|
+
// Max width classes
|
|
58
|
+
const maxWidthClasses = {
|
|
59
|
+
'4xl': 'max-w-4xl',
|
|
60
|
+
'5xl': 'max-w-5xl',
|
|
61
|
+
'6xl': 'max-w-6xl',
|
|
62
|
+
'7xl': 'max-w-7xl',
|
|
63
|
+
'full': 'max-w-full',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Padding classes - responsive (fixed left/top, responsive right/bottom) vs all fixed
|
|
67
|
+
const paddingClasses = {
|
|
68
|
+
none: fixed ? 'p-0' : 'pt-0 pl-0 pr-0 pb-0',
|
|
69
|
+
sm: fixed ? 'p-4' : 'pt-4 pl-4 pr-4 pb-4 sm:pr-6 md:pr-8 sm:pb-6 md:pb-8',
|
|
70
|
+
normal: fixed ? 'pt-12 pl-20 pr-16 pb-12' : 'pt-12 pl-20 pr-4 pb-4 sm:pr-8 md:pr-12 lg:pr-16 sm:pb-8 md:pb-12 lg:pb-16',
|
|
71
|
+
lg: fixed ? 'pt-16 pl-24 pr-20 pb-16' : 'pt-16 pl-24 pr-6 pb-6 sm:pr-12 md:pr-16 lg:pr-20 sm:pb-12 md:pb-16 lg:pb-20',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Margin classes - responsive (fixed left/top, responsive right/bottom) vs all fixed
|
|
75
|
+
const marginClasses = fixed
|
|
76
|
+
? 'mt-4 ml-4 mr-4 mb-4'
|
|
77
|
+
: 'mt-4 ml-4 mr-4 mb-4 sm:mr-6 md:mr-8 lg:mr-auto sm:mb-6 md:mb-8';
|
|
54
78
|
|
|
55
79
|
return (
|
|
56
80
|
<div className="min-h-screen bg-paper-100">
|
|
57
|
-
<div className={`
|
|
81
|
+
<div className={`
|
|
82
|
+
bg-white bg-subtle-grain rounded-sm shadow-lg border-l-4 border-paper-300
|
|
83
|
+
min-h-[calc(100vh-2rem)] relative
|
|
84
|
+
notebook-margin notebook-ruled
|
|
85
|
+
${maxWidthClasses[maxWidth]}
|
|
86
|
+
${paddingClasses[padding]}
|
|
87
|
+
${marginClasses}
|
|
88
|
+
${className}
|
|
89
|
+
`.trim().replace(/\s+/g, ' ')}>
|
|
58
90
|
{children}
|
|
59
91
|
</div>
|
|
60
92
|
</div>
|
|
@@ -586,3 +586,78 @@ export const WithSidebarAndGutter: Story = {
|
|
|
586
586
|
);
|
|
587
587
|
},
|
|
588
588
|
};
|
|
589
|
+
|
|
590
|
+
export const ResponsiveLayout: Story = {
|
|
591
|
+
render: () => (
|
|
592
|
+
<PageLayout
|
|
593
|
+
title="Responsive PageLayout"
|
|
594
|
+
description="Default responsive behavior - resize your browser to see the right and bottom padding adapt"
|
|
595
|
+
>
|
|
596
|
+
<Card style={{ marginBottom: '1.5rem', backgroundColor: '#dbeafe', border: '1px solid #3b82f6' }}>
|
|
597
|
+
<CardContent>
|
|
598
|
+
<h3 style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.875rem', color: '#1e40af' }}>
|
|
599
|
+
📐 Responsive Layout (Default)
|
|
600
|
+
</h3>
|
|
601
|
+
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
|
602
|
+
Try resizing your browser window! PageLayout keeps <strong>left and top padding fixed</strong>,
|
|
603
|
+
but <strong>right and bottom resize responsively</strong>:
|
|
604
|
+
</p>
|
|
605
|
+
<ul style={{ marginLeft: '1.5rem', fontSize: '0.875rem' }}>
|
|
606
|
+
<li>• Small screens: Minimal right/bottom padding (pr-2 pb-8)</li>
|
|
607
|
+
<li>• Medium screens (640px+): Increased padding (pr-4 pb-12)</li>
|
|
608
|
+
<li>• Large screens (768px+): More padding (pr-6 pb-16)</li>
|
|
609
|
+
<li>• XL screens (1024px+): Maximum padding (pb-20)</li>
|
|
610
|
+
</ul>
|
|
611
|
+
</CardContent>
|
|
612
|
+
</Card>
|
|
613
|
+
<Card>
|
|
614
|
+
<CardHeader>
|
|
615
|
+
<CardTitle>Content Adapts to Screen Size</CardTitle>
|
|
616
|
+
</CardHeader>
|
|
617
|
+
<CardContent>
|
|
618
|
+
<p>
|
|
619
|
+
This is perfect for applications where you want the notebook aesthetic to be
|
|
620
|
+
maintained while optimizing for different device sizes.
|
|
621
|
+
</p>
|
|
622
|
+
</CardContent>
|
|
623
|
+
</Card>
|
|
624
|
+
</PageLayout>
|
|
625
|
+
),
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
export const FixedLayout: Story = {
|
|
629
|
+
render: () => (
|
|
630
|
+
<PageLayout
|
|
631
|
+
title="Fixed PageLayout"
|
|
632
|
+
description="All padding remains constant regardless of screen size"
|
|
633
|
+
fixed={true}
|
|
634
|
+
>
|
|
635
|
+
<Card style={{ marginBottom: '1.5rem', backgroundColor: '#fef3c7', border: '1px solid #f59e0b' }}>
|
|
636
|
+
<CardContent>
|
|
637
|
+
<h3 style={{ fontWeight: 600, marginBottom: '0.75rem', fontSize: '0.875rem', color: '#92400e' }}>
|
|
638
|
+
📌 Fixed Layout (fixed=true)
|
|
639
|
+
</h3>
|
|
640
|
+
<p style={{ fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
|
641
|
+
This page uses <code style={{ backgroundColor: '#fff', padding: '0.125rem 0.375rem', borderRadius: '0.25rem' }}>fixed=true</code>.
|
|
642
|
+
All padding stays <strong>constant regardless of screen size</strong>:
|
|
643
|
+
</p>
|
|
644
|
+
<ul style={{ marginLeft: '1.5rem', fontSize: '0.875rem' }}>
|
|
645
|
+
<li>• Same padding on mobile and desktop</li>
|
|
646
|
+
<li>• Predictable layout at all viewport sizes</li>
|
|
647
|
+
<li>• Use when you need absolute consistency</li>
|
|
648
|
+
</ul>
|
|
649
|
+
</CardContent>
|
|
650
|
+
</Card>
|
|
651
|
+
<Card>
|
|
652
|
+
<CardHeader>
|
|
653
|
+
<CardTitle>Consistent Everywhere</CardTitle>
|
|
654
|
+
</CardHeader>
|
|
655
|
+
<CardContent>
|
|
656
|
+
<p>
|
|
657
|
+
Try resizing your browser - the spacing remains identical at all screen sizes.
|
|
658
|
+
</p>
|
|
659
|
+
</CardContent>
|
|
660
|
+
</Card>
|
|
661
|
+
</PageLayout>
|
|
662
|
+
),
|
|
663
|
+
};
|