@papernote/ui 1.0.0 → 1.1.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.
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { Spreadsheet } from './Spreadsheet';
3
+
4
+ const meta: Meta<typeof Spreadsheet> = {
5
+ title: 'Components/Spreadsheet/Simple Test',
6
+ component: Spreadsheet,
7
+ parameters: {
8
+ docs: {
9
+ description: {
10
+ component: 'Simple test story for Spreadsheet component debugging.',
11
+ },
12
+ },
13
+ },
14
+ };
15
+
16
+ export default meta;
17
+ type Story = StoryObj<typeof Spreadsheet>;
18
+
19
+ /**
20
+ * Minimal test - just render with defaults
21
+ */
22
+ export const MinimalTest: Story = {
23
+ args: {
24
+ rows: 5,
25
+ columns: 3,
26
+ },
27
+ };
@@ -249,3 +249,34 @@ export const Complete: Story = {
249
249
  return <Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} variant="underline" />;
250
250
  },
251
251
  };
252
+
253
+ export const ControlledMode: Story = {
254
+ render: () => {
255
+ const [activeTab, setActiveTab] = useState('profile');
256
+ return (
257
+ <div>
258
+ <div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem' }}>
259
+ <button
260
+ onClick={() => setActiveTab('profile')}
261
+ style={{ padding: '0.5rem 1rem', background: activeTab === 'profile' ? '#334155' : '#f1f5f9', color: activeTab === 'profile' ? 'white' : '#334155', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
262
+ >
263
+ Go to Profile
264
+ </button>
265
+ <button
266
+ onClick={() => setActiveTab('settings')}
267
+ style={{ padding: '0.5rem 1rem', background: activeTab === 'settings' ? '#334155' : '#f1f5f9', color: activeTab === 'settings' ? 'white' : '#334155', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
268
+ >
269
+ Go to Settings
270
+ </button>
271
+ <button
272
+ onClick={() => setActiveTab('notifications')}
273
+ style={{ padding: '0.5rem 1rem', background: activeTab === 'notifications' ? '#334155' : '#f1f5f9', color: activeTab === 'notifications' ? 'white' : '#334155', border: 'none', borderRadius: '0.375rem', cursor: 'pointer' }}
274
+ >
275
+ Go to Notifications
276
+ </button>
277
+ </div>
278
+ <Tabs tabs={basicTabs} activeTab={activeTab} onChange={setActiveTab} />
279
+ </div>
280
+ );
281
+ },
282
+ };
@@ -1,128 +1,152 @@
1
- import React, { useState } from 'react';
2
-
3
- export interface Tab {
4
- id: string;
5
- label: string;
6
- icon?: React.ReactNode;
7
- content: React.ReactNode;
8
- disabled?: boolean;
9
- }
10
-
11
- export interface TabsProps {
12
- tabs: Tab[];
13
- defaultTab?: string;
14
- variant?: 'underline' | 'pill';
15
- /** Orientation of tabs (default: 'horizontal') */
16
- orientation?: 'horizontal' | 'vertical';
17
- /** Size of tabs (default: 'md') */
18
- size?: 'sm' | 'md' | 'lg';
19
- onChange?: (tabId: string) => void;
20
- }
21
-
22
- export default function Tabs({ tabs, defaultTab, variant = 'underline', orientation = 'horizontal', size = 'md', onChange }: TabsProps) {
23
- const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
24
-
25
- const handleTabChange = (tabId: string) => {
26
- setActiveTab(tabId);
27
- onChange?.(tabId);
28
- };
29
-
30
- // Size-specific classes
31
- const sizeClasses = {
32
- sm: {
33
- padding: 'px-3 py-1.5',
34
- text: 'text-xs',
35
- icon: 'h-3.5 w-3.5',
36
- gap: orientation === 'vertical' ? 'gap-1.5' : 'gap-4',
37
- minWidth: orientation === 'vertical' ? 'min-w-[150px]' : '',
38
- spacing: orientation === 'vertical' ? 'mt-4' : 'mt-4',
39
- },
40
- md: {
41
- padding: 'px-4 py-2.5',
42
- text: 'text-sm',
43
- icon: 'h-4 w-4',
44
- gap: orientation === 'vertical' ? 'gap-2' : 'gap-6',
45
- minWidth: orientation === 'vertical' ? 'min-w-[200px]' : '',
46
- spacing: orientation === 'vertical' ? 'mt-6' : 'mt-6',
47
- },
48
- lg: {
49
- padding: 'px-5 py-3',
50
- text: 'text-base',
51
- icon: 'h-5 w-5',
52
- gap: orientation === 'vertical' ? 'gap-3' : 'gap-8',
53
- minWidth: orientation === 'vertical' ? 'min-w-[250px]' : '',
54
- spacing: orientation === 'vertical' ? 'mt-8' : 'mt-8',
55
- },
56
- };
57
-
58
- return (
59
- <div className={`w-full ${orientation === 'vertical' ? `flex ${sizeClasses[size].gap}` : ''}`}>
60
- {/* Tab Headers */}
61
- <div
62
- className={`
63
- flex ${orientation === 'vertical' ? 'flex-col' : ''}
64
- ${variant === 'underline'
65
- ? orientation === 'vertical'
66
- ? `border-r border-paper-200 ${sizeClasses[size].gap} pr-6`
67
- : `border-b border-paper-200 ${sizeClasses[size].gap}`
68
- : `${sizeClasses[size].gap} p-1 bg-paper-50 rounded-lg`
69
- }
70
- ${sizeClasses[size].minWidth}
71
- `}
72
- role="tablist"
73
- >
74
- {tabs.map((tab) => {
75
- const isActive = activeTab === tab.id;
76
-
77
- return (
78
- <button
79
- key={tab.id}
80
- role="tab"
81
- aria-selected={isActive}
82
- aria-controls={`panel-${tab.id}`}
83
- disabled={tab.disabled}
84
- onClick={() => !tab.disabled && handleTabChange(tab.id)}
85
- className={`
86
- flex items-center gap-2 ${sizeClasses[size].padding} ${sizeClasses[size].text} font-medium transition-all duration-200
87
- ${orientation === 'vertical' ? 'w-full justify-start' : ''}
88
- ${
89
- variant === 'underline'
90
- ? isActive
91
- ? orientation === 'vertical'
92
- ? 'text-accent-900 border-r-2 border-accent-500 -mr-[1px]'
93
- : 'text-accent-900 border-b-2 border-accent-500 -mb-[1px]'
94
- : orientation === 'vertical'
95
- ? 'text-ink-600 hover:text-ink-900 border-r-2 border-transparent'
96
- : 'text-ink-600 hover:text-ink-900 border-b-2 border-transparent'
97
- : isActive
98
- ? 'bg-white text-accent-900 rounded-md shadow-xs'
99
- : 'text-ink-600 hover:text-ink-900 hover:bg-white/50 rounded-md'
100
- }
101
- ${tab.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
102
- `}
103
- >
104
- {tab.icon && <span className={`flex-shrink-0 ${sizeClasses[size].icon}`}>{tab.icon}</span>}
105
- <span>{tab.label}</span>
106
- </button>
107
- );
108
- })}
109
- </div>
110
-
111
- {/* Tab Content */}
112
- <div className={`${orientation === 'vertical' ? 'flex-1' : sizeClasses[size].spacing}`}>
113
- {tabs.map((tab) => (
114
- <div
115
- key={tab.id}
116
- id={`panel-${tab.id}`}
117
- role="tabpanel"
118
- aria-labelledby={tab.id}
119
- hidden={activeTab !== tab.id}
120
- className={activeTab === tab.id ? 'animate-fade-in' : ''}
121
- >
122
- {activeTab === tab.id && tab.content}
123
- </div>
124
- ))}
125
- </div>
126
- </div>
127
- );
128
- }
1
+ import React, { useState, useEffect } from 'react';
2
+
3
+ export interface Tab {
4
+ id: string;
5
+ label: string;
6
+ icon?: React.ReactNode;
7
+ content: React.ReactNode;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ export interface TabsProps {
12
+ tabs: Tab[];
13
+ /** Controlled mode: Currently active tab ID */
14
+ activeTab?: string;
15
+ /** Uncontrolled mode: Initial tab ID (ignored if activeTab is provided) */
16
+ defaultTab?: string;
17
+ variant?: 'underline' | 'pill';
18
+ /** Orientation of tabs (default: 'horizontal') */
19
+ orientation?: 'horizontal' | 'vertical';
20
+ /** Size of tabs (default: 'md') */
21
+ size?: 'sm' | 'md' | 'lg';
22
+ /** Called when tab changes (required for controlled mode) */
23
+ onChange?: (tabId: string) => void;
24
+ }
25
+
26
+ export default function Tabs({ tabs, activeTab: controlledActiveTab, defaultTab, variant = 'underline', orientation = 'horizontal', size = 'md', onChange }: TabsProps) {
27
+ const [internalActiveTab, setInternalActiveTab] = useState(defaultTab || tabs[0]?.id);
28
+
29
+ // Controlled mode: use activeTab prop, Uncontrolled mode: use internal state
30
+ const isControlled = controlledActiveTab !== undefined;
31
+ const activeTab = isControlled ? controlledActiveTab : internalActiveTab;
32
+
33
+ // Ensure the activeTab exists in the current tabs array
34
+ // This handles the case where tabs array reference changes at the same time as activeTab
35
+ useEffect(() => {
36
+ const tabExists = tabs.some(tab => tab.id === activeTab);
37
+ if (!tabExists && tabs.length > 0) {
38
+ // If the activeTab doesn't exist in the new tabs array, use the first tab
39
+ if (isControlled) {
40
+ onChange?.(tabs[0].id);
41
+ } else {
42
+ setInternalActiveTab(tabs[0].id);
43
+ }
44
+ }
45
+ }, [tabs, activeTab, isControlled, onChange]);
46
+
47
+ const handleTabChange = (tabId: string) => {
48
+ if (!isControlled) {
49
+ setInternalActiveTab(tabId);
50
+ }
51
+ onChange?.(tabId);
52
+ };
53
+
54
+ // Size-specific classes
55
+ const sizeClasses = {
56
+ sm: {
57
+ padding: 'px-3 py-1.5',
58
+ text: 'text-xs',
59
+ icon: 'h-3.5 w-3.5',
60
+ gap: orientation === 'vertical' ? 'gap-1.5' : 'gap-4',
61
+ minWidth: orientation === 'vertical' ? 'min-w-[150px]' : '',
62
+ spacing: orientation === 'vertical' ? 'mt-4' : 'mt-4',
63
+ },
64
+ md: {
65
+ padding: 'px-4 py-2.5',
66
+ text: 'text-sm',
67
+ icon: 'h-4 w-4',
68
+ gap: orientation === 'vertical' ? 'gap-2' : 'gap-6',
69
+ minWidth: orientation === 'vertical' ? 'min-w-[200px]' : '',
70
+ spacing: orientation === 'vertical' ? 'mt-6' : 'mt-6',
71
+ },
72
+ lg: {
73
+ padding: 'px-5 py-3',
74
+ text: 'text-base',
75
+ icon: 'h-5 w-5',
76
+ gap: orientation === 'vertical' ? 'gap-3' : 'gap-8',
77
+ minWidth: orientation === 'vertical' ? 'min-w-[250px]' : '',
78
+ spacing: orientation === 'vertical' ? 'mt-8' : 'mt-8',
79
+ },
80
+ };
81
+
82
+ return (
83
+ <div className={`w-full ${orientation === 'vertical' ? `flex ${sizeClasses[size].gap}` : ''}`}>
84
+ {/* Tab Headers */}
85
+ <div
86
+ className={`
87
+ flex ${orientation === 'vertical' ? 'flex-col' : ''}
88
+ ${variant === 'underline'
89
+ ? orientation === 'vertical'
90
+ ? `border-r border-paper-200 ${sizeClasses[size].gap} pr-6`
91
+ : `border-b border-paper-200 ${sizeClasses[size].gap}`
92
+ : `${sizeClasses[size].gap} p-1 bg-paper-50 rounded-lg`
93
+ }
94
+ ${sizeClasses[size].minWidth}
95
+ `}
96
+ role="tablist"
97
+ >
98
+ {tabs.map((tab) => {
99
+ const isActive = activeTab === tab.id;
100
+
101
+ return (
102
+ <button
103
+ key={tab.id}
104
+ role="tab"
105
+ aria-selected={isActive}
106
+ aria-controls={`panel-${tab.id}`}
107
+ disabled={tab.disabled}
108
+ onClick={() => !tab.disabled && handleTabChange(tab.id)}
109
+ className={`
110
+ flex items-center gap-2 ${sizeClasses[size].padding} ${sizeClasses[size].text} font-medium transition-all duration-200
111
+ ${orientation === 'vertical' ? 'w-full justify-start' : ''}
112
+ ${
113
+ variant === 'underline'
114
+ ? isActive
115
+ ? orientation === 'vertical'
116
+ ? 'text-accent-900 border-r-2 border-accent-500 -mr-[1px]'
117
+ : 'text-accent-900 border-b-2 border-accent-500 -mb-[1px]'
118
+ : orientation === 'vertical'
119
+ ? 'text-ink-600 hover:text-ink-900 border-r-2 border-transparent'
120
+ : 'text-ink-600 hover:text-ink-900 border-b-2 border-transparent'
121
+ : isActive
122
+ ? 'bg-white text-accent-900 rounded-md shadow-xs'
123
+ : 'text-ink-600 hover:text-ink-900 hover:bg-white/50 rounded-md'
124
+ }
125
+ ${tab.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
126
+ `}
127
+ >
128
+ {tab.icon && <span className={`flex-shrink-0 ${sizeClasses[size].icon}`}>{tab.icon}</span>}
129
+ <span>{tab.label}</span>
130
+ </button>
131
+ );
132
+ })}
133
+ </div>
134
+
135
+ {/* Tab Content */}
136
+ <div className={`${orientation === 'vertical' ? 'flex-1' : sizeClasses[size].spacing}`}>
137
+ {tabs.map((tab) => (
138
+ <div
139
+ key={tab.id}
140
+ id={`panel-${tab.id}`}
141
+ role="tabpanel"
142
+ aria-labelledby={tab.id}
143
+ hidden={activeTab !== tab.id}
144
+ className={activeTab === tab.id ? 'animate-fade-in' : ''}
145
+ >
146
+ {activeTab === tab.id && tab.content}
147
+ </div>
148
+ ))}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
@@ -142,7 +142,7 @@ const TimePicker = forwardRef<TimePickerHandle, TimePickerProps>(({
142
142
 
143
143
  // Format TimeValue to string
144
144
  function formatTimeValue(tv: TimeValue, is12Hour: boolean, includeSeconds: boolean): string {
145
- let hours = tv.hours;
145
+ const hours = tv.hours;
146
146
 
147
147
  if (is12Hour) {
148
148
  const formatted = `${hours.toString().padStart(2, '0')}:${tv.minutes.toString().padStart(2, '0')}${includeSeconds ? ':' + tv.seconds.toString().padStart(2, '0') : ''} ${tv.period}`;
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
2
  import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
3
3
 
4
4
  export type ToastType = 'success' | 'error' | 'warning' | 'info';
@@ -40,20 +40,20 @@ export default function Toast({ id, type, title, message, duration = 5000, onClo
40
40
  const [isExiting, setIsExiting] = useState(false);
41
41
  const styles = toastStyles[type];
42
42
 
43
+ const handleClose = useCallback(() => {
44
+ setIsExiting(true);
45
+ setTimeout(() => {
46
+ onClose(id);
47
+ }, 300); // Match animation duration
48
+ }, [id, onClose]);
49
+
43
50
  useEffect(() => {
44
51
  const timer = setTimeout(() => {
45
52
  handleClose();
46
53
  }, duration);
47
54
 
48
55
  return () => clearTimeout(timer);
49
- }, [duration]);
50
-
51
- const handleClose = () => {
52
- setIsExiting(true);
53
- setTimeout(() => {
54
- onClose(id);
55
- }, 300); // Match animation duration
56
- };
56
+ }, [duration, handleClose]);
57
57
 
58
58
  return (
59
59
  <div
@@ -1,4 +1,4 @@
1
- import { render, screen, fireEvent } from '@testing-library/react';
1
+ import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import Input from '../Input';
4
4
 
@@ -19,8 +19,8 @@ describe('Input', () => {
19
19
  expect(handleChange).toHaveBeenCalled();
20
20
  });
21
21
 
22
- it('shows error message', () => {
23
- render(<Input label="Email" error="Invalid email" />);
22
+ it('shows validation message', () => {
23
+ render(<Input label="Email" validationState="error" validationMessage="Invalid email" />);
24
24
  expect(screen.getByText('Invalid email')).toBeInTheDocument();
25
25
  });
26
26
 
@@ -40,7 +40,7 @@ describe('Input', () => {
40
40
  });
41
41
 
42
42
  it('is read-only when readOnly prop is true', () => {
43
- render(<Input label="Email" readOnly value="test@example.com" />);
43
+ render(<Input label="Email" readOnly value="test@example.com" onChange={() => {}} />);
44
44
  const input = screen.getByLabelText('Email') as HTMLInputElement;
45
45
  expect(input.readOnly).toBe(true);
46
46
  });
@@ -55,39 +55,25 @@ describe('Input', () => {
55
55
  expect(screen.getByText('.com')).toBeInTheDocument();
56
56
  });
57
57
 
58
- it('applies different sizes', () => {
59
- const { rerender } = render(<Input label="Email" size="sm" />);
60
- let input = screen.getByLabelText('Email');
61
- expect(input).toHaveClass('text-sm');
62
-
63
- rerender(<Input label="Email" size="lg" />);
64
- input = screen.getByLabelText('Email');
65
- expect(input).toHaveClass('text-lg');
66
- });
67
-
68
58
  it('handles clear button click', async () => {
69
59
  const user = userEvent.setup();
70
60
  const handleClear = jest.fn();
61
+ const handleChange = jest.fn();
71
62
 
72
- render(<Input label="Search" clearable onClear={handleClear} value="test" />);
63
+ render(<Input label="Search" clearable onClear={handleClear} value="test" onChange={handleChange} />);
73
64
 
74
- // Find and click clear button (would need to verify selector)
75
- const clearButton = screen.getByLabelText('Clear');
65
+ // Find clear button by aria-label
66
+ const clearButton = screen.getByLabelText('Clear input');
76
67
  await user.click(clearButton);
77
68
 
78
69
  expect(handleClear).toHaveBeenCalled();
79
70
  });
80
71
 
81
- it('shows loading state', () => {
82
- render(<Input label="Email" loading />);
83
- // Loading indicator should be rendered
84
- expect(screen.getByLabelText('Email')).toBeInTheDocument();
85
- });
86
-
87
- it('applies error styling when error is present', () => {
88
- render(<Input label="Email" error="Invalid" />);
72
+ it('applies error styling when validation state is error', () => {
73
+ render(<Input label="Email" validationState="error" validationMessage="Invalid" />);
89
74
  const input = screen.getByLabelText('Email');
90
- expect(input).toHaveClass('border-error-500');
75
+ // Check for error border class
76
+ expect(input).toHaveClass('border-error-400');
91
77
  });
92
78
 
93
79
  it('supports different input types', () => {
@@ -99,4 +85,14 @@ describe('Input', () => {
99
85
  input = screen.getByLabelText('Password') as HTMLInputElement;
100
86
  expect(input.type).toBe('password');
101
87
  });
88
+
89
+ it('shows character count when enabled', () => {
90
+ render(<Input label="Bio" showCount maxLength={100} value="Hello" onChange={() => {}} />);
91
+ expect(screen.getByText(/5/)).toBeInTheDocument();
92
+ });
93
+
94
+ it('shows password toggle button for password input', () => {
95
+ render(<Input label="Password" type="password" showPasswordToggle />);
96
+ expect(screen.getByLabelText('Show password')).toBeInTheDocument();
97
+ });
102
98
  });
@@ -283,13 +283,17 @@ export type { NotificationIndicatorProps } from './NotificationIndicator';
283
283
 
284
284
  // Data Table
285
285
  export { default as DataTable } from './DataTable';
286
- export type {
287
- DataTableColumn,
286
+ export type {
287
+ DataTableColumn,
288
288
  DataTableAction,
289
289
  ExpandedRowConfig,
290
290
  ExpansionMode
291
291
  } from './DataTable';
292
292
 
293
+ // Spreadsheet
294
+ export { Spreadsheet, SpreadsheetReport } from './Spreadsheet';
295
+ export type { SpreadsheetProps, SpreadsheetCell, Matrix, CellBase } from './Spreadsheet';
296
+
293
297
  export { default as ExpandedRowEditForm } from './ExpandedRowEditForm';
294
298
  export type {
295
299
  ExpandedRowEditFormProps,
@@ -1,5 +1,8 @@
1
1
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
2
2
 
3
+ /* Component styles */
4
+ @import '../components/Spreadsheet.css';
5
+
3
6
  @tailwind base;
4
7
  @tailwind components;
5
8
  @tailwind utilities;
@@ -427,8 +430,6 @@
427
430
 
428
431
  .table-stable tbody tr td {
429
432
  vertical-align: top;
430
- overflow: hidden;
431
- text-overflow: ellipsis;
432
433
  }
433
434
 
434
435
  /* Smooth loading overlay */
@@ -415,7 +415,7 @@ function parseCondition(condition: string, friendlyNames: FriendlyNameConfig): s
415
415
  function extractFieldName(condition: string, friendlyNames: FriendlyNameConfig): string {
416
416
  condition = condition.trim();
417
417
 
418
- let match = condition.match(/^(?:["']?\w+["']?\.)?["']?(\w+)["']?/);
418
+ const match = condition.match(/^(?:["']?\w+["']?\.)?["']?(\w+)["']?/);
419
419
 
420
420
  if (match && match[1]) {
421
421
  const fieldName = match[1];