@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.
Files changed (75) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +455 -455
  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/index.d.ts +4 -0
  13. package/dist/components/index.d.ts.map +1 -1
  14. package/dist/index.d.ts +204 -4
  15. package/dist/index.esm.js +415 -88
  16. package/dist/index.esm.js.map +1 -1
  17. package/dist/index.js +413 -82
  18. package/dist/index.js.map +1 -1
  19. package/dist/styles.css +2877 -2675
  20. package/dist/utils/excelExport.d.ts +143 -0
  21. package/dist/utils/excelExport.d.ts.map +1 -0
  22. package/dist/utils/index.d.ts +2 -0
  23. package/dist/utils/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/components/AdminModal.css +49 -49
  26. package/src/components/CurrencyInput.stories.tsx +290 -0
  27. package/src/components/CurrencyInput.tsx +193 -0
  28. package/src/components/DataTable.tsx +78 -14
  29. package/src/components/Modal.stories.tsx +64 -0
  30. package/src/components/Modal.tsx +15 -2
  31. package/src/components/Page.stories.tsx +76 -0
  32. package/src/components/Page.tsx +35 -3
  33. package/src/components/PageLayout.stories.tsx +75 -0
  34. package/src/components/PageLayout.tsx +28 -9
  35. package/src/components/RoleManager.css +10 -10
  36. package/src/components/Spreadsheet.css +216 -216
  37. package/src/components/Spreadsheet.stories.tsx +362 -362
  38. package/src/components/Spreadsheet.tsx +351 -351
  39. package/src/components/SpreadsheetSimple.stories.tsx +27 -27
  40. package/src/components/Tabs.tsx +152 -152
  41. package/src/components/index.ts +5 -0
  42. package/src/styles/index.css +41 -4
  43. package/src/utils/excelExport.stories.tsx +535 -0
  44. package/src/utils/excelExport.ts +225 -0
  45. package/src/utils/index.ts +3 -0
  46. package/tailwind.config.js +253 -253
  47. package/dist/components/Button.stories.d.ts +0 -51
  48. package/dist/components/Button.stories.d.ts.map +0 -1
  49. package/dist/components/ChartVisualizationUI.d.ts +0 -21
  50. package/dist/components/ChartVisualizationUI.d.ts.map +0 -1
  51. package/dist/components/ChatUI.d.ts +0 -23
  52. package/dist/components/ChatUI.d.ts.map +0 -1
  53. package/dist/components/CommissionDashboardUI.d.ts +0 -25
  54. package/dist/components/CommissionDashboardUI.d.ts.map +0 -1
  55. package/dist/components/DataTable.stories.d.ts +0 -23
  56. package/dist/components/DataTable.stories.d.ts.map +0 -1
  57. package/dist/components/FormField.d.ts +0 -35
  58. package/dist/components/FormField.d.ts.map +0 -1
  59. package/dist/components/Input.stories.d.ts +0 -366
  60. package/dist/components/Input.stories.d.ts.map +0 -1
  61. package/dist/components/InsightsPanelUI.d.ts +0 -21
  62. package/dist/components/InsightsPanelUI.d.ts.map +0 -1
  63. package/dist/components/PaymentHistoryTimeline.d.ts +0 -34
  64. package/dist/components/PaymentHistoryTimeline.d.ts.map +0 -1
  65. package/dist/components/RelationshipManagerUI.d.ts +0 -60
  66. package/dist/components/RelationshipManagerUI.d.ts.map +0 -1
  67. package/dist/components/RoleManager.d.ts +0 -19
  68. package/dist/components/RoleManager.d.ts.map +0 -1
  69. package/dist/components/SplitCommissionBadge.d.ts +0 -18
  70. package/dist/components/SplitCommissionBadge.d.ts.map +0 -1
  71. package/dist/components/Spreadsheet.css +0 -216
  72. package/dist/components/__tests__/Button.test.d.ts +0 -2
  73. package/dist/components/__tests__/Button.test.d.ts.map +0 -1
  74. package/dist/components/__tests__/Input.test.d.ts +0 -2
  75. 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
- if (virtualized) {
1386
- return (
1387
- <div
1388
- ref={tableContainerRef}
1389
- onScroll={handleScroll}
1390
- style={{ height: virtualHeight, overflow: 'auto' }}
1391
- className="rounded-lg"
1392
- >
1393
- {tableContent}
1394
- </div>
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
- 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
+ );
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
+ };
@@ -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
- // Handle click outside
55
- const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
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
+ };
@@ -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: _maxWidth = '7xl',
52
+ maxWidth = '7xl',
51
53
  className = '',
52
- padding: _padding = 'normal'
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={`notebook-page notebook-margin notebook-ruled ${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
+ };