@papernote/ui 1.5.0 → 1.7.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 (37) hide show
  1. package/README.md +3 -3
  2. package/dist/components/ActionBar.d.ts +112 -0
  3. package/dist/components/ActionBar.d.ts.map +1 -0
  4. package/dist/components/DataGrid.d.ts +182 -0
  5. package/dist/components/DataGrid.d.ts.map +1 -0
  6. package/dist/components/FormulaAutocomplete.d.ts +29 -0
  7. package/dist/components/FormulaAutocomplete.d.ts.map +1 -0
  8. package/dist/components/Modal.d.ts +29 -1
  9. package/dist/components/Modal.d.ts.map +1 -1
  10. package/dist/components/PageHeader.d.ts +86 -0
  11. package/dist/components/PageHeader.d.ts.map +1 -0
  12. package/dist/components/Select.d.ts +2 -0
  13. package/dist/components/Select.d.ts.map +1 -1
  14. package/dist/components/index.d.ts +8 -0
  15. package/dist/components/index.d.ts.map +1 -1
  16. package/dist/index.d.ts +419 -3
  17. package/dist/index.esm.js +2533 -350
  18. package/dist/index.esm.js.map +1 -1
  19. package/dist/index.js +2543 -348
  20. package/dist/index.js.map +1 -1
  21. package/dist/styles.css +81 -0
  22. package/dist/utils/formulaDefinitions.d.ts +25 -0
  23. package/dist/utils/formulaDefinitions.d.ts.map +1 -0
  24. package/package.json +1 -1
  25. package/src/components/ActionBar.stories.tsx +246 -0
  26. package/src/components/ActionBar.tsx +242 -0
  27. package/src/components/DataGrid.stories.tsx +356 -0
  28. package/src/components/DataGrid.tsx +1025 -0
  29. package/src/components/FormulaAutocomplete.tsx +417 -0
  30. package/src/components/Modal.stories.tsx +205 -0
  31. package/src/components/Modal.tsx +38 -1
  32. package/src/components/PageHeader.stories.tsx +198 -0
  33. package/src/components/PageHeader.tsx +217 -0
  34. package/src/components/Select.tsx +121 -7
  35. package/src/components/Sidebar.tsx +2 -2
  36. package/src/components/index.ts +36 -0
  37. package/src/utils/formulaDefinitions.ts +1228 -0
@@ -0,0 +1,198 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { MemoryRouter } from 'react-router-dom';
3
+ import PageHeader from './PageHeader';
4
+ import { Plus, Download, Filter, Settings, Share2, Trash2, Edit } from 'lucide-react';
5
+
6
+ const meta: Meta<typeof PageHeader> = {
7
+ title: 'Layout/PageHeader',
8
+ component: PageHeader,
9
+ decorators: [
10
+ (Story) => (
11
+ <MemoryRouter>
12
+ <Story />
13
+ </MemoryRouter>
14
+ ),
15
+ ],
16
+ parameters: {
17
+ layout: 'fullscreen',
18
+ docs: {
19
+ description: {
20
+ component: 'A standard page header component with title, breadcrumbs, and action buttons. Use this at the top of pages to provide consistent navigation and actions.',
21
+ },
22
+ },
23
+ },
24
+ argTypes: {
25
+ // Disable controls for ReactNode props that can't be edited via text
26
+ rightContent: { control: false },
27
+ belowTitle: { control: false },
28
+ actions: { control: false },
29
+ breadcrumbs: { control: false },
30
+ backButton: { control: false },
31
+ },
32
+ tags: ['autodocs'],
33
+ };
34
+
35
+ export default meta;
36
+ type Story = StoryObj<typeof PageHeader>;
37
+
38
+ export const Default: Story = {
39
+ args: {
40
+ title: 'Products',
41
+ subtitle: 'Manage your product catalog',
42
+ },
43
+ };
44
+
45
+ export const WithBreadcrumbs: Story = {
46
+ args: {
47
+ title: 'Products',
48
+ subtitle: 'Manage your product catalog',
49
+ breadcrumbs: [
50
+ { label: 'Inventory' },
51
+ { label: 'Products' },
52
+ ],
53
+ },
54
+ };
55
+
56
+ export const WithActions: Story = {
57
+ args: {
58
+ title: 'Products',
59
+ subtitle: 'Manage your product catalog',
60
+ breadcrumbs: [
61
+ { label: 'Inventory' },
62
+ { label: 'Products' },
63
+ ],
64
+ actions: [
65
+ { id: 'filter', label: 'Filter', icon: <Filter className="h-4 w-4" />, onClick: () => alert('Filter clicked'), variant: 'ghost' },
66
+ { id: 'export', label: 'Export', icon: <Download className="h-4 w-4" />, onClick: () => alert('Export clicked'), variant: 'secondary' },
67
+ { id: 'add', label: 'Add Product', icon: <Plus className="h-4 w-4" />, onClick: () => alert('Add clicked'), variant: 'primary' },
68
+ ],
69
+ },
70
+ };
71
+
72
+ export const WithBackButton: Story = {
73
+ args: {
74
+ title: 'Edit Product',
75
+ subtitle: 'Update product details',
76
+ backButton: {
77
+ label: 'Back to Products',
78
+ onClick: () => alert('Back clicked'),
79
+ },
80
+ },
81
+ };
82
+
83
+ export const DetailPage: Story = {
84
+ args: {
85
+ title: 'Wireless Bluetooth Headphones',
86
+ subtitle: 'SKU: WBH-2024-001 • In Stock',
87
+ breadcrumbs: [
88
+ { label: 'Inventory' },
89
+ { label: 'Products', href: '/products' },
90
+ { label: 'Wireless Bluetooth Headphones' },
91
+ ],
92
+ actions: [
93
+ { id: 'share', label: 'Share', icon: <Share2 className="h-4 w-4" />, onClick: () => alert('Share'), variant: 'ghost' },
94
+ { id: 'edit', label: 'Edit', icon: <Edit className="h-4 w-4" />, onClick: () => alert('Edit'), variant: 'secondary' },
95
+ { id: 'delete', label: 'Delete', icon: <Trash2 className="h-4 w-4" />, onClick: () => alert('Delete'), variant: 'danger' },
96
+ ],
97
+ },
98
+ };
99
+
100
+ export const Sticky: Story = {
101
+ args: {
102
+ title: 'Products',
103
+ subtitle: 'Scroll down to see the sticky behavior',
104
+ breadcrumbs: [
105
+ { label: 'Inventory' },
106
+ { label: 'Products' },
107
+ ],
108
+ actions: [
109
+ { id: 'add', label: 'Add Product', icon: <Plus className="h-4 w-4" />, onClick: () => {}, variant: 'primary' },
110
+ ],
111
+ sticky: true,
112
+ },
113
+ decorators: [
114
+ (Story) => (
115
+ <div style={{ height: '400px', overflow: 'auto', border: '1px solid #e5e5e5', borderRadius: '8px' }}>
116
+ <Story />
117
+ <div className="p-6">
118
+ <p className="text-ink-600 font-medium">↓ Scroll down to see the sticky header behavior</p>
119
+ <div className="mt-4 space-y-4">
120
+ {Array.from({ length: 20 }).map((_, i) => (
121
+ <div key={i} className="p-4 bg-paper-100 rounded-lg">
122
+ Content block {i + 1}
123
+ </div>
124
+ ))}
125
+ </div>
126
+ </div>
127
+ </div>
128
+ ),
129
+ ],
130
+ };
131
+
132
+ export const WithLoadingAction: Story = {
133
+ args: {
134
+ title: 'Products',
135
+ actions: [
136
+ { id: 'export', label: 'Exporting...', icon: <Download className="h-4 w-4" />, onClick: () => {}, variant: 'secondary', loading: true },
137
+ { id: 'add', label: 'Add Product', icon: <Plus className="h-4 w-4" />, onClick: () => {}, variant: 'primary' },
138
+ ],
139
+ },
140
+ };
141
+
142
+ export const WithDisabledAction: Story = {
143
+ args: {
144
+ title: 'Products',
145
+ actions: [
146
+ { id: 'export', label: 'Export', icon: <Download className="h-4 w-4" />, onClick: () => {}, variant: 'secondary', disabled: true },
147
+ { id: 'add', label: 'Add Product', icon: <Plus className="h-4 w-4" />, onClick: () => {}, variant: 'primary' },
148
+ ],
149
+ },
150
+ };
151
+
152
+ export const SettingsPage: Story = {
153
+ args: {
154
+ title: 'Settings',
155
+ subtitle: 'Manage your application preferences',
156
+ breadcrumbs: [
157
+ { label: 'Settings' },
158
+ ],
159
+ actions: [
160
+ { id: 'reset', label: 'Reset to Defaults', onClick: () => alert('Reset'), variant: 'ghost' },
161
+ { id: 'save', label: 'Save Changes', onClick: () => alert('Save'), variant: 'primary' },
162
+ ],
163
+ },
164
+ };
165
+
166
+ export const DashboardPage: Story = {
167
+ args: {
168
+ title: 'Dashboard',
169
+ subtitle: 'Welcome back, John! Here\'s what\'s happening today.',
170
+ actions: [
171
+ { id: 'settings', label: 'Settings', icon: <Settings className="h-4 w-4" />, onClick: () => {}, variant: 'ghost' },
172
+ { id: 'export', label: 'Export Report', icon: <Download className="h-4 w-4" />, onClick: () => {}, variant: 'secondary' },
173
+ ],
174
+ },
175
+ };
176
+
177
+ export const TitleOnly: Story = {
178
+ args: {
179
+ title: 'Simple Page',
180
+ },
181
+ };
182
+
183
+ export const LongTitle: Story = {
184
+ args: {
185
+ title: 'This Is a Very Long Page Title That Might Need to Truncate on Smaller Screens',
186
+ subtitle: 'With an equally verbose subtitle that provides additional context about this page',
187
+ breadcrumbs: [
188
+ { label: 'Category' },
189
+ { label: 'Subcategory' },
190
+ { label: 'This Is a Very Long Page Title' },
191
+ ],
192
+ actions: [
193
+ { id: 'action1', label: 'Action 1', onClick: () => {}, variant: 'ghost' },
194
+ { id: 'action2', label: 'Action 2', onClick: () => {}, variant: 'secondary' },
195
+ { id: 'action3', label: 'Primary Action', onClick: () => {}, variant: 'primary' },
196
+ ],
197
+ },
198
+ };
@@ -0,0 +1,217 @@
1
+ import { ReactNode } from 'react';
2
+ import Breadcrumbs, { BreadcrumbItem } from './Breadcrumbs';
3
+
4
+ export interface PageHeaderAction {
5
+ /** Unique identifier for the action */
6
+ id: string;
7
+ /** Button label text */
8
+ label: string;
9
+ /** Icon to display (from lucide-react) */
10
+ icon?: ReactNode;
11
+ /** Click handler */
12
+ onClick: () => void;
13
+ /** Button variant */
14
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline';
15
+ /** Disabled state */
16
+ disabled?: boolean;
17
+ /** Loading state */
18
+ loading?: boolean;
19
+ /** Hide on mobile */
20
+ hideOnMobile?: boolean;
21
+ }
22
+
23
+ export interface PageHeaderProps {
24
+ /** Page title */
25
+ title: string;
26
+ /** Optional subtitle/description */
27
+ subtitle?: string;
28
+ /** Breadcrumb navigation items */
29
+ breadcrumbs?: BreadcrumbItem[];
30
+ /** Show home icon in breadcrumbs (default: true) */
31
+ showHomeBreadcrumb?: boolean;
32
+ /** Action buttons to display on the right */
33
+ actions?: PageHeaderAction[];
34
+ /** Custom content to render on the right (instead of actions) */
35
+ rightContent?: ReactNode;
36
+ /** Custom content to render below title */
37
+ belowTitle?: ReactNode;
38
+ /** Additional CSS classes */
39
+ className?: string;
40
+ /** Make header sticky at top */
41
+ sticky?: boolean;
42
+ /** Back button configuration */
43
+ backButton?: {
44
+ label?: string;
45
+ onClick: () => void;
46
+ };
47
+ }
48
+
49
+ /**
50
+ * PageHeader - Standard page header with title, breadcrumbs, and actions
51
+ *
52
+ * A consistent header component for pages that provides:
53
+ * - Page title and optional subtitle
54
+ * - Breadcrumb navigation
55
+ * - Action buttons (Create, Export, etc.)
56
+ * - Optional back button
57
+ * - Sticky positioning option
58
+ *
59
+ * @example Basic usage
60
+ * ```tsx
61
+ * <PageHeader
62
+ * title="Products"
63
+ * subtitle="Manage your product catalog"
64
+ * breadcrumbs={[{ label: 'Inventory' }, { label: 'Products' }]}
65
+ * actions={[
66
+ * { id: 'export', label: 'Export', icon: <Download />, onClick: handleExport, variant: 'ghost' },
67
+ * { id: 'add', label: 'Add Product', icon: <Plus />, onClick: handleAdd, variant: 'primary' },
68
+ * ]}
69
+ * />
70
+ * ```
71
+ *
72
+ * @example With back button
73
+ * ```tsx
74
+ * <PageHeader
75
+ * title="Edit Product"
76
+ * backButton={{ label: 'Back to Products', onClick: () => navigate('/products') }}
77
+ * />
78
+ * ```
79
+ *
80
+ * @example With custom right content
81
+ * ```tsx
82
+ * <PageHeader
83
+ * title="Dashboard"
84
+ * rightContent={<DateRangePicker value={range} onChange={setRange} />}
85
+ * />
86
+ * ```
87
+ */
88
+ export default function PageHeader({
89
+ title,
90
+ subtitle,
91
+ breadcrumbs,
92
+ showHomeBreadcrumb = true,
93
+ actions,
94
+ rightContent,
95
+ belowTitle,
96
+ className = '',
97
+ sticky = false,
98
+ backButton,
99
+ }: PageHeaderProps) {
100
+ const variantStyles: Record<string, string> = {
101
+ primary: 'bg-accent-500 text-white border-accent-500 hover:bg-accent-600 hover:shadow-sm',
102
+ secondary: 'bg-white text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-paper-400 shadow-xs hover:shadow-sm',
103
+ ghost: 'bg-transparent text-ink-600 border-transparent hover:text-ink-800 hover:bg-paper-100',
104
+ danger: 'bg-error-500 text-white border-error-500 hover:bg-error-600 hover:shadow-sm',
105
+ outline: 'bg-transparent text-ink-700 border-paper-300 hover:bg-paper-50 hover:border-ink-400',
106
+ };
107
+
108
+ return (
109
+ <div
110
+ className={`
111
+ bg-white border-b border-paper-200
112
+ ${sticky ? 'sticky top-0 z-40' : ''}
113
+ ${className}
114
+ `}
115
+ >
116
+ <div className="px-6 py-4">
117
+ {/* Breadcrumbs */}
118
+ {breadcrumbs && breadcrumbs.length > 0 && (
119
+ <div className="mb-3">
120
+ <Breadcrumbs items={breadcrumbs} showHome={showHomeBreadcrumb} />
121
+ </div>
122
+ )}
123
+
124
+ {/* Back button */}
125
+ {backButton && (
126
+ <div className="mb-3">
127
+ <button
128
+ onClick={backButton.onClick}
129
+ className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-700 transition-colors"
130
+ >
131
+ <svg
132
+ className="h-4 w-4"
133
+ fill="none"
134
+ stroke="currentColor"
135
+ viewBox="0 0 24 24"
136
+ >
137
+ <path
138
+ strokeLinecap="round"
139
+ strokeLinejoin="round"
140
+ strokeWidth={2}
141
+ d="M15 19l-7-7 7-7"
142
+ />
143
+ </svg>
144
+ <span>{backButton.label || 'Back'}</span>
145
+ </button>
146
+ </div>
147
+ )}
148
+
149
+ {/* Title row */}
150
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
151
+ {/* Title and subtitle */}
152
+ <div className="min-w-0 flex-1">
153
+ <h1 className="text-2xl font-bold text-ink-900 truncate">{title}</h1>
154
+ {subtitle && (
155
+ <p className="mt-1 text-sm text-ink-500">{subtitle}</p>
156
+ )}
157
+ </div>
158
+
159
+ {/* Actions or custom right content */}
160
+ {(actions || rightContent) && (
161
+ <div className="flex items-center gap-2 flex-shrink-0">
162
+ {rightContent}
163
+ {actions && actions.map((action) => (
164
+ <button
165
+ key={action.id}
166
+ onClick={action.onClick}
167
+ disabled={action.disabled || action.loading}
168
+ className={`
169
+ inline-flex items-center justify-center gap-2
170
+ px-4 py-2 text-sm font-medium rounded-lg border
171
+ transition-all duration-200
172
+ focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-400
173
+ disabled:opacity-40 disabled:cursor-not-allowed
174
+ ${variantStyles[action.variant || 'secondary']}
175
+ ${action.hideOnMobile ? 'hidden sm:inline-flex' : ''}
176
+ `}
177
+ >
178
+ {action.loading ? (
179
+ <svg
180
+ className="h-4 w-4 animate-spin"
181
+ fill="none"
182
+ viewBox="0 0 24 24"
183
+ >
184
+ <circle
185
+ className="opacity-25"
186
+ cx="12"
187
+ cy="12"
188
+ r="10"
189
+ stroke="currentColor"
190
+ strokeWidth="4"
191
+ />
192
+ <path
193
+ className="opacity-75"
194
+ fill="currentColor"
195
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
196
+ />
197
+ </svg>
198
+ ) : action.icon ? (
199
+ <span className="h-4 w-4">{action.icon}</span>
200
+ ) : null}
201
+ <span>{action.label}</span>
202
+ </button>
203
+ ))}
204
+ </div>
205
+ )}
206
+ </div>
207
+
208
+ {/* Below title content */}
209
+ {belowTitle && (
210
+ <div className="mt-4">
211
+ {belowTitle}
212
+ </div>
213
+ )}
214
+ </div>
215
+ </div>
216
+ );
217
+ }
@@ -83,6 +83,8 @@ export interface SelectProps {
83
83
  size?: 'sm' | 'md' | 'lg';
84
84
  /** Mobile display mode - 'auto' uses BottomSheet on mobile, 'dropdown' always uses dropdown, 'native' uses native select on mobile */
85
85
  mobileMode?: 'auto' | 'dropdown' | 'native';
86
+ /** Render dropdown via portal (default: true). Set to false when overflow clipping is not an issue */
87
+ usePortal?: boolean;
86
88
  }
87
89
 
88
90
  // Size classes for trigger button
@@ -191,13 +193,16 @@ const Select = forwardRef<SelectHandle, SelectProps>(
191
193
  virtualItemHeight = 42,
192
194
  size = 'md',
193
195
  mobileMode = 'auto',
196
+ usePortal = true,
194
197
  } = props;
195
198
  const [isOpen, setIsOpen] = useState(false);
196
199
  const [searchQuery, setSearchQuery] = useState('');
197
200
  const [scrollTop, setScrollTop] = useState(0);
198
201
  const [activeDescendant] = useState<string | undefined>(undefined);
202
+ const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number; width: number; placement: 'bottom' | 'top' } | null>(null);
199
203
  const selectRef = useRef<HTMLDivElement>(null);
200
204
  const buttonRef = useRef<HTMLButtonElement>(null);
205
+ const dropdownRef = useRef<HTMLDivElement>(null);
201
206
  const searchInputRef = useRef<HTMLInputElement>(null);
202
207
  const mobileSearchInputRef = useRef<HTMLInputElement>(null);
203
208
  const listRef = useRef<HTMLDivElement>(null);
@@ -313,9 +318,14 @@ const Select = forwardRef<SelectHandle, SelectProps>(
313
318
  // Handle click outside (desktop dropdown only)
314
319
  useEffect(() => {
315
320
  if (useMobileSheet) return; // Mobile sheet handles its own closing
316
-
321
+
317
322
  const handleClickOutside = (event: MouseEvent) => {
318
- if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
323
+ const target = event.target as Node;
324
+ // Check if click is outside both the select trigger and the dropdown portal
325
+ const isOutsideSelect = selectRef.current && !selectRef.current.contains(target);
326
+ const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target);
327
+
328
+ if (isOutsideSelect && isOutsideDropdown) {
319
329
  setIsOpen(false);
320
330
  setSearchQuery('');
321
331
  }
@@ -342,6 +352,55 @@ const Select = forwardRef<SelectHandle, SelectProps>(
342
352
  }
343
353
  }, [isOpen, searchable, useMobileSheet]);
344
354
 
355
+ // Calculate dropdown position with collision detection and scroll/resize handling
356
+ useEffect(() => {
357
+ if (!isOpen || useMobileSheet || !usePortal) {
358
+ setDropdownPosition(null);
359
+ return;
360
+ }
361
+
362
+ const updatePosition = () => {
363
+ if (!buttonRef.current) return;
364
+
365
+ const rect = buttonRef.current.getBoundingClientRect();
366
+ const dropdownHeight = 240; // max-h-60 = 15rem = 240px
367
+ const gap = 2; // Small gap to visually connect to trigger
368
+ const viewportHeight = window.innerHeight;
369
+
370
+ // Check if there's enough space below
371
+ const spaceBelow = viewportHeight - rect.bottom;
372
+ const spaceAbove = rect.top;
373
+ const hasSpaceBelow = spaceBelow >= dropdownHeight + gap;
374
+ const hasSpaceAbove = spaceAbove >= dropdownHeight + gap;
375
+
376
+ // Prefer bottom placement, flip to top if not enough space below but enough above
377
+ const placement: 'bottom' | 'top' = hasSpaceBelow || !hasSpaceAbove ? 'bottom' : 'top';
378
+
379
+ const top = placement === 'bottom'
380
+ ? rect.bottom + gap
381
+ : rect.top - dropdownHeight - gap;
382
+
383
+ setDropdownPosition({
384
+ top,
385
+ left: rect.left,
386
+ width: rect.width,
387
+ placement,
388
+ });
389
+ };
390
+
391
+ // Initial position calculation
392
+ updatePosition();
393
+
394
+ // Listen for scroll events on all scrollable ancestors
395
+ window.addEventListener('scroll', updatePosition, true);
396
+ window.addEventListener('resize', updatePosition);
397
+
398
+ return () => {
399
+ window.removeEventListener('scroll', updatePosition, true);
400
+ window.removeEventListener('resize', updatePosition);
401
+ };
402
+ }, [isOpen, useMobileSheet, usePortal]);
403
+
345
404
  // Lock body scroll when mobile sheet is open
346
405
  useEffect(() => {
347
406
  if (useMobileSheet && isOpen) {
@@ -617,9 +676,64 @@ const Select = forwardRef<SelectHandle, SelectProps>(
617
676
  </div>
618
677
  </button>
619
678
 
620
- {/* Desktop Dropdown */}
621
- {isOpen && !useMobileSheet && (
622
- <div className="absolute z-50 w-full mt-2 bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in">
679
+ </div>
680
+
681
+ {/* Desktop Dropdown - rendered via portal to avoid overflow clipping */}
682
+ {isOpen && !useMobileSheet && (usePortal ? dropdownPosition : true) && (
683
+ usePortal ? createPortal(
684
+ <div
685
+ ref={dropdownRef}
686
+ className={`fixed z-[9999] bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in ${
687
+ dropdownPosition?.placement === 'top' ? 'origin-bottom' : 'origin-top'
688
+ }`}
689
+ style={{
690
+ top: dropdownPosition!.top,
691
+ left: dropdownPosition!.left,
692
+ width: dropdownPosition!.width,
693
+ }}
694
+ >
695
+ {/* Search Input */}
696
+ {searchable && (
697
+ <div className="p-2 border-b border-paper-200">
698
+ <div className="relative">
699
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
700
+ <input
701
+ ref={searchInputRef}
702
+ type="text"
703
+ value={searchQuery}
704
+ onChange={(e) => setSearchQuery(e.target.value)}
705
+ placeholder="Search..."
706
+ className="w-full pl-9 pr-3 py-2 text-sm border border-paper-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400"
707
+ role="searchbox"
708
+ aria-label="Search options"
709
+ aria-autocomplete="list"
710
+ aria-controls={listboxId}
711
+ />
712
+ </div>
713
+ </div>
714
+ )}
715
+
716
+ {/* Options List */}
717
+ <div
718
+ ref={listRef}
719
+ id={listboxId}
720
+ className="overflow-y-auto"
721
+ style={{ maxHeight: useVirtualScrolling ? virtualHeight : '12rem' }}
722
+ onScroll={(e) => useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop)}
723
+ role="listbox"
724
+ aria-label="Available options"
725
+ aria-multiselectable="false"
726
+ >
727
+ {renderOptionsContent(false)}
728
+ </div>
729
+ </div>,
730
+ document.body
731
+ ) : (
732
+ // Non-portal dropdown (inline, relative positioning)
733
+ <div
734
+ ref={dropdownRef}
735
+ className="absolute z-50 mt-1 w-full bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in"
736
+ >
623
737
  {/* Search Input */}
624
738
  {searchable && (
625
739
  <div className="p-2 border-b border-paper-200">
@@ -655,8 +769,8 @@ const Select = forwardRef<SelectHandle, SelectProps>(
655
769
  {renderOptionsContent(false)}
656
770
  </div>
657
771
  </div>
658
- )}
659
- </div>
772
+ )
773
+ )}
660
774
 
661
775
  {/* Mobile Bottom Sheet */}
662
776
  {isOpen && useMobileSheet && createPortal(
@@ -57,9 +57,9 @@ function SidebarNavItem({
57
57
  // Auto-detect if this item or any child is active based on currentPath
58
58
  const isItemActive = currentPath && item.href ? currentPath === item.href : item.active;
59
59
  const isChildActive = hasChildren && currentPath
60
- ? item.children?.some(child => currentPath === child.href || currentPath?.startsWith(child.href || ''))
60
+ ? item.children?.some(child => child.href && (currentPath === child.href || currentPath.startsWith(child.href)))
61
61
  : false;
62
- const shouldExpandByDefault = isChildActive || (hasChildren && currentPath?.startsWith(item.href || ''));
62
+ const shouldExpandByDefault = isChildActive || (hasChildren && item.href && currentPath?.startsWith(item.href));
63
63
 
64
64
  const [isExpanded, setIsExpanded] = useState(shouldExpandByDefault);
65
65
 
@@ -315,6 +315,17 @@ export type {
315
315
  export { default as DataTableCardView } from './DataTableCardView';
316
316
  export type { CardViewConfig, DataTableCardViewProps } from './DataTableCardView';
317
317
 
318
+ // DataGrid (Excel-like grid with formulas)
319
+ export { default as DataGrid } from './DataGrid';
320
+ export type {
321
+ DataGridProps,
322
+ DataGridHandle,
323
+ DataGridColumn,
324
+ DataGridCell,
325
+ CellValue,
326
+ FrozenRowMode,
327
+ } from './DataGrid';
328
+
318
329
  export { default as SwipeActions } from './SwipeActions';
319
330
  export type { SwipeActionsProps, SwipeAction } from './SwipeActions';
320
331
 
@@ -322,6 +333,10 @@ export type { SwipeActionsProps, SwipeAction } from './SwipeActions';
322
333
  export { Spreadsheet, SpreadsheetReport } from './Spreadsheet';
323
334
  export type { SpreadsheetProps, SpreadsheetCell, Matrix, CellBase } from './Spreadsheet';
324
335
 
336
+ // ExcelTable has been moved to a separate package: @papernote/excel-table
337
+ // This is due to Handsontable's commercial licensing requirements
338
+ // See: https://github.com/kwhittenberger/papernote-ui/tree/main/packages/excel-table
339
+
325
340
  export { default as ExpandedRowEditForm } from './ExpandedRowEditForm';
326
341
  export type {
327
342
  ExpandedRowEditFormProps,
@@ -380,6 +395,12 @@ export type { AppLayoutProps } from './AppLayout';
380
395
 
381
396
  export { PageLayout } from './PageLayout';
382
397
 
398
+ export { default as PageHeader } from './PageHeader';
399
+ export type { PageHeaderProps, PageHeaderAction } from './PageHeader';
400
+
401
+ export { default as ActionBar, ActionBarLeft, ActionBarCenter, ActionBarRight } from './ActionBar';
402
+ export type { ActionBarProps, ActionBarAction } from './ActionBar';
403
+
383
404
  export { AdminModal } from './AdminModal';
384
405
  export type { AdminModalProps, AdminModalTab } from './AdminModal';
385
406
 
@@ -442,6 +463,21 @@ export type { ColumnResize, ColumnOrder } from '../utils/tableEnhancements';
442
463
  export { exportToExcel, exportDataTableToExcel, createMultiSheetExcel } from '../utils/excelExport';
443
464
  export type { ExcelColumn, ExportToExcelOptions, DataTableExportOptions, MultiSheetExcelOptions } from '../utils/excelExport';
444
465
 
466
+ // Formula Definitions (for DataGrid intellisense)
467
+ export {
468
+ FORMULA_DEFINITIONS,
469
+ FORMULA_NAMES,
470
+ FORMULA_CATEGORIES,
471
+ getFormulasByCategory,
472
+ searchFormulas,
473
+ getFormula,
474
+ } from '../utils/formulaDefinitions';
475
+ export type {
476
+ FormulaDefinition,
477
+ FormulaParameter,
478
+ FormulaCategory,
479
+ } from '../utils/formulaDefinitions';
480
+
445
481
  // Hooks
446
482
  export { useColumnResize, useColumnReorder } from '../hooks/useTableEnhancements';
447
483
  export type { UseColumnResizeOptions, UseColumnReorderOptions } from '../hooks/useTableEnhancements';