@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.
- package/README.md +3 -3
- package/dist/components/ActionBar.d.ts +112 -0
- package/dist/components/ActionBar.d.ts.map +1 -0
- package/dist/components/DataGrid.d.ts +182 -0
- package/dist/components/DataGrid.d.ts.map +1 -0
- package/dist/components/FormulaAutocomplete.d.ts +29 -0
- package/dist/components/FormulaAutocomplete.d.ts.map +1 -0
- package/dist/components/Modal.d.ts +29 -1
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/PageHeader.d.ts +86 -0
- package/dist/components/PageHeader.d.ts.map +1 -0
- package/dist/components/Select.d.ts +2 -0
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +419 -3
- package/dist/index.esm.js +2533 -350
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2543 -348
- package/dist/index.js.map +1 -1
- package/dist/styles.css +81 -0
- package/dist/utils/formulaDefinitions.d.ts +25 -0
- package/dist/utils/formulaDefinitions.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/ActionBar.stories.tsx +246 -0
- package/src/components/ActionBar.tsx +242 -0
- package/src/components/DataGrid.stories.tsx +356 -0
- package/src/components/DataGrid.tsx +1025 -0
- package/src/components/FormulaAutocomplete.tsx +417 -0
- package/src/components/Modal.stories.tsx +205 -0
- package/src/components/Modal.tsx +38 -1
- package/src/components/PageHeader.stories.tsx +198 -0
- package/src/components/PageHeader.tsx +217 -0
- package/src/components/Select.tsx +121 -7
- package/src/components/Sidebar.tsx +2 -2
- package/src/components/index.ts +36 -0
- 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
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/components/index.ts
CHANGED
|
@@ -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';
|