@papernote/ui 1.6.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/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/Select.d.ts +2 -0
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/index.d.ts +195 -2
- package/dist/index.esm.js +2338 -346
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2343 -344
- package/dist/index.js.map +1 -1
- package/dist/styles.css +51 -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/DataGrid.stories.tsx +356 -0
- package/src/components/DataGrid.tsx +1025 -0
- package/src/components/FormulaAutocomplete.tsx +417 -0
- package/src/components/Select.tsx +121 -7
- package/src/components/index.ts +30 -0
- package/src/utils/formulaDefinitions.ts +1228 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import {
|
|
4
|
+
FORMULA_DEFINITIONS,
|
|
5
|
+
searchFormulas,
|
|
6
|
+
getFormula,
|
|
7
|
+
FORMULA_CATEGORIES,
|
|
8
|
+
FormulaDefinition,
|
|
9
|
+
FormulaCategory,
|
|
10
|
+
} from '../utils/formulaDefinitions';
|
|
11
|
+
|
|
12
|
+
export interface FormulaAutocompleteProps {
|
|
13
|
+
/** Current input value */
|
|
14
|
+
value: string;
|
|
15
|
+
/** Callback when value changes */
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
/** Callback when editing is complete */
|
|
18
|
+
onComplete: () => void;
|
|
19
|
+
/** Callback to cancel editing */
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
/** Position for the dropdown */
|
|
22
|
+
anchorRect: DOMRect | null;
|
|
23
|
+
/** Auto focus the input */
|
|
24
|
+
autoFocus?: boolean;
|
|
25
|
+
/** Custom class name */
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface FormulaHint {
|
|
30
|
+
formula: FormulaDefinition;
|
|
31
|
+
currentParamIndex: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* FormulaAutocomplete - Input with formula intellisense
|
|
36
|
+
*
|
|
37
|
+
* Features:
|
|
38
|
+
* - Autocomplete dropdown when typing after '='
|
|
39
|
+
* - Function signature hints while typing parameters
|
|
40
|
+
* - Category-based browsing
|
|
41
|
+
* - Keyboard navigation
|
|
42
|
+
*/
|
|
43
|
+
const FormulaAutocomplete: React.FC<FormulaAutocompleteProps> = ({
|
|
44
|
+
value,
|
|
45
|
+
onChange,
|
|
46
|
+
onComplete,
|
|
47
|
+
onCancel,
|
|
48
|
+
anchorRect,
|
|
49
|
+
autoFocus = true,
|
|
50
|
+
className = '',
|
|
51
|
+
}) => {
|
|
52
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
53
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
55
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
56
|
+
const [showHint, setShowHint] = useState(false);
|
|
57
|
+
const [hint, setHint] = useState<FormulaHint | null>(null);
|
|
58
|
+
const [activeCategory, setActiveCategory] = useState<FormulaCategory | null>(null);
|
|
59
|
+
|
|
60
|
+
// Parse the current formula context
|
|
61
|
+
const formulaContext = useMemo(() => {
|
|
62
|
+
if (!value.startsWith('=')) {
|
|
63
|
+
return { isFormula: false, query: '', inFunction: false, functionName: '', paramIndex: 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const formulaText = value.substring(1);
|
|
67
|
+
|
|
68
|
+
// Check if we're typing a function name (before opening paren)
|
|
69
|
+
const functionMatch = formulaText.match(/^([A-Z]+)$/i);
|
|
70
|
+
if (functionMatch) {
|
|
71
|
+
return {
|
|
72
|
+
isFormula: true,
|
|
73
|
+
query: functionMatch[1].toUpperCase(),
|
|
74
|
+
inFunction: false,
|
|
75
|
+
functionName: '',
|
|
76
|
+
paramIndex: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check if we're inside a function (after opening paren)
|
|
81
|
+
const insideFunctionMatch = formulaText.match(/^([A-Z]+)\((.*)$/i);
|
|
82
|
+
if (insideFunctionMatch) {
|
|
83
|
+
const functionName = insideFunctionMatch[1].toUpperCase();
|
|
84
|
+
const params = insideFunctionMatch[2];
|
|
85
|
+
|
|
86
|
+
// Count commas to determine parameter index (accounting for nested parens)
|
|
87
|
+
let paramIndex = 0;
|
|
88
|
+
let parenDepth = 0;
|
|
89
|
+
for (const char of params) {
|
|
90
|
+
if (char === '(') parenDepth++;
|
|
91
|
+
else if (char === ')') parenDepth--;
|
|
92
|
+
else if (char === ',' && parenDepth === 0) paramIndex++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
isFormula: true,
|
|
97
|
+
query: '',
|
|
98
|
+
inFunction: true,
|
|
99
|
+
functionName,
|
|
100
|
+
paramIndex,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Just '=' or other expression
|
|
105
|
+
return {
|
|
106
|
+
isFormula: true,
|
|
107
|
+
query: formulaText.toUpperCase(),
|
|
108
|
+
inFunction: false,
|
|
109
|
+
functionName: '',
|
|
110
|
+
paramIndex: 0,
|
|
111
|
+
};
|
|
112
|
+
}, [value]);
|
|
113
|
+
|
|
114
|
+
// Get matching formulas for dropdown
|
|
115
|
+
const matchingFormulas = useMemo(() => {
|
|
116
|
+
if (!formulaContext.isFormula || formulaContext.inFunction) return [];
|
|
117
|
+
|
|
118
|
+
if (activeCategory) {
|
|
119
|
+
const categoryFormulas = FORMULA_DEFINITIONS.filter(f => f.category === activeCategory);
|
|
120
|
+
if (formulaContext.query) {
|
|
121
|
+
return categoryFormulas.filter(f => f.name.startsWith(formulaContext.query));
|
|
122
|
+
}
|
|
123
|
+
return categoryFormulas;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (formulaContext.query) {
|
|
127
|
+
return searchFormulas(formulaContext.query);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Show all formulas grouped by first letter when just '='
|
|
131
|
+
return FORMULA_DEFINITIONS.slice(0, 20); // Show first 20 as default
|
|
132
|
+
}, [formulaContext, activeCategory]);
|
|
133
|
+
|
|
134
|
+
// Update hint when inside a function
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (formulaContext.inFunction && formulaContext.functionName) {
|
|
137
|
+
const formula = getFormula(formulaContext.functionName);
|
|
138
|
+
if (formula) {
|
|
139
|
+
setHint({
|
|
140
|
+
formula,
|
|
141
|
+
currentParamIndex: formulaContext.paramIndex,
|
|
142
|
+
});
|
|
143
|
+
setShowHint(true);
|
|
144
|
+
setShowDropdown(false);
|
|
145
|
+
} else {
|
|
146
|
+
setShowHint(false);
|
|
147
|
+
setHint(null);
|
|
148
|
+
}
|
|
149
|
+
} else if (formulaContext.isFormula && !formulaContext.inFunction) {
|
|
150
|
+
setShowDropdown(true);
|
|
151
|
+
setShowHint(false);
|
|
152
|
+
setHint(null);
|
|
153
|
+
} else {
|
|
154
|
+
setShowDropdown(false);
|
|
155
|
+
setShowHint(false);
|
|
156
|
+
setHint(null);
|
|
157
|
+
}
|
|
158
|
+
}, [formulaContext]);
|
|
159
|
+
|
|
160
|
+
// Reset selected index when matches change
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
setSelectedIndex(0);
|
|
163
|
+
}, [matchingFormulas.length]);
|
|
164
|
+
|
|
165
|
+
// Auto-focus
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (autoFocus && inputRef.current) {
|
|
168
|
+
inputRef.current.focus();
|
|
169
|
+
inputRef.current.select();
|
|
170
|
+
}
|
|
171
|
+
}, [autoFocus]);
|
|
172
|
+
|
|
173
|
+
// Scroll selected item into view
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (dropdownRef.current && showDropdown) {
|
|
176
|
+
const selectedItem = dropdownRef.current.querySelector(`[data-index="${selectedIndex}"]`);
|
|
177
|
+
if (selectedItem) {
|
|
178
|
+
selectedItem.scrollIntoView({ block: 'nearest' });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}, [selectedIndex, showDropdown]);
|
|
182
|
+
|
|
183
|
+
// Handle keyboard navigation
|
|
184
|
+
const handleKeyDown = useCallback(
|
|
185
|
+
(e: React.KeyboardEvent) => {
|
|
186
|
+
if (showDropdown && matchingFormulas.length > 0) {
|
|
187
|
+
switch (e.key) {
|
|
188
|
+
case 'ArrowDown':
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
setSelectedIndex((prev) => Math.min(prev + 1, matchingFormulas.length - 1));
|
|
191
|
+
break;
|
|
192
|
+
case 'ArrowUp':
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
195
|
+
break;
|
|
196
|
+
case 'Tab':
|
|
197
|
+
case 'Enter':
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
insertFormula(matchingFormulas[selectedIndex]);
|
|
200
|
+
break;
|
|
201
|
+
case 'Escape':
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
if (showDropdown) {
|
|
204
|
+
setShowDropdown(false);
|
|
205
|
+
} else {
|
|
206
|
+
onCancel();
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
if (e.key === 'Enter') {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
onComplete();
|
|
214
|
+
} else if (e.key === 'Escape') {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
onCancel();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[showDropdown, matchingFormulas, selectedIndex, onComplete, onCancel]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Insert selected formula
|
|
224
|
+
const insertFormula = useCallback(
|
|
225
|
+
(formula: FormulaDefinition) => {
|
|
226
|
+
// Replace the current query with the formula name and open paren
|
|
227
|
+
const newValue = `=${formula.name}(`;
|
|
228
|
+
onChange(newValue);
|
|
229
|
+
setShowDropdown(false);
|
|
230
|
+
inputRef.current?.focus();
|
|
231
|
+
},
|
|
232
|
+
[onChange]
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Calculate dropdown position
|
|
236
|
+
const dropdownPosition = useMemo(() => {
|
|
237
|
+
if (!anchorRect) return { top: 0, left: 0 };
|
|
238
|
+
return {
|
|
239
|
+
top: anchorRect.bottom + 2,
|
|
240
|
+
left: anchorRect.left,
|
|
241
|
+
};
|
|
242
|
+
}, [anchorRect]);
|
|
243
|
+
|
|
244
|
+
// Prevent blur when clicking inside dropdown
|
|
245
|
+
const handleDropdownMouseDown = useCallback((e: React.MouseEvent) => {
|
|
246
|
+
e.preventDefault(); // Prevents input from losing focus
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
// Render parameter hint
|
|
250
|
+
const renderHint = () => {
|
|
251
|
+
if (!showHint || !hint) return null;
|
|
252
|
+
|
|
253
|
+
return createPortal(
|
|
254
|
+
<div
|
|
255
|
+
className="fixed z-[9999] bg-white border border-stone-200 rounded-lg shadow-lg p-3 max-w-md"
|
|
256
|
+
style={{
|
|
257
|
+
top: dropdownPosition.top,
|
|
258
|
+
left: dropdownPosition.left,
|
|
259
|
+
}}
|
|
260
|
+
onMouseDown={handleDropdownMouseDown}
|
|
261
|
+
>
|
|
262
|
+
{/* Function name and syntax */}
|
|
263
|
+
<div className="font-mono text-sm mb-2">
|
|
264
|
+
<span className="text-primary-600 font-semibold">{hint.formula.name}</span>
|
|
265
|
+
<span className="text-ink-500">(</span>
|
|
266
|
+
{hint.formula.parameters.map((param, idx) => (
|
|
267
|
+
<span key={param.name}>
|
|
268
|
+
{idx > 0 && <span className="text-ink-500">, </span>}
|
|
269
|
+
<span
|
|
270
|
+
className={`${
|
|
271
|
+
idx === hint.currentParamIndex
|
|
272
|
+
? 'bg-primary-100 text-primary-700 px-1 rounded font-semibold'
|
|
273
|
+
: param.optional
|
|
274
|
+
? 'text-ink-400'
|
|
275
|
+
: 'text-ink-600'
|
|
276
|
+
}`}
|
|
277
|
+
>
|
|
278
|
+
{param.optional ? `[${param.name}]` : param.name}
|
|
279
|
+
</span>
|
|
280
|
+
</span>
|
|
281
|
+
))}
|
|
282
|
+
<span className="text-ink-500">)</span>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* Description */}
|
|
286
|
+
<div className="text-xs text-ink-600 mb-2">{hint.formula.description}</div>
|
|
287
|
+
|
|
288
|
+
{/* Current parameter description */}
|
|
289
|
+
{hint.formula.parameters[hint.currentParamIndex] && (
|
|
290
|
+
<div className="text-xs bg-paper-50 p-2 rounded border border-stone-100">
|
|
291
|
+
<span className="font-semibold text-primary-600">
|
|
292
|
+
{hint.formula.parameters[hint.currentParamIndex].name}:
|
|
293
|
+
</span>{' '}
|
|
294
|
+
<span className="text-ink-600">
|
|
295
|
+
{hint.formula.parameters[hint.currentParamIndex].description}
|
|
296
|
+
</span>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>,
|
|
300
|
+
document.body
|
|
301
|
+
);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Render autocomplete dropdown
|
|
305
|
+
const renderDropdown = () => {
|
|
306
|
+
if (!showDropdown || matchingFormulas.length === 0) return null;
|
|
307
|
+
|
|
308
|
+
return createPortal(
|
|
309
|
+
<div
|
|
310
|
+
ref={dropdownRef}
|
|
311
|
+
className="fixed z-[9999] bg-white border border-stone-200 rounded-lg shadow-lg overflow-hidden"
|
|
312
|
+
style={{
|
|
313
|
+
top: dropdownPosition.top,
|
|
314
|
+
left: dropdownPosition.left,
|
|
315
|
+
minWidth: 320,
|
|
316
|
+
maxWidth: 450,
|
|
317
|
+
maxHeight: 300,
|
|
318
|
+
}}
|
|
319
|
+
onMouseDown={handleDropdownMouseDown}
|
|
320
|
+
>
|
|
321
|
+
{/* Category tabs */}
|
|
322
|
+
<div className="flex flex-wrap gap-1 p-2 border-b border-stone-100 bg-paper-50">
|
|
323
|
+
<button
|
|
324
|
+
className={`px-2 py-1 text-xs rounded ${
|
|
325
|
+
activeCategory === null
|
|
326
|
+
? 'bg-primary-500 text-white'
|
|
327
|
+
: 'bg-white text-ink-600 hover:bg-stone-100'
|
|
328
|
+
}`}
|
|
329
|
+
onClick={() => {
|
|
330
|
+
setActiveCategory(null);
|
|
331
|
+
inputRef.current?.focus();
|
|
332
|
+
}}
|
|
333
|
+
>
|
|
334
|
+
All
|
|
335
|
+
</button>
|
|
336
|
+
{FORMULA_CATEGORIES.map((cat) => (
|
|
337
|
+
<button
|
|
338
|
+
key={cat}
|
|
339
|
+
className={`px-2 py-1 text-xs rounded ${
|
|
340
|
+
activeCategory === cat
|
|
341
|
+
? 'bg-primary-500 text-white'
|
|
342
|
+
: 'bg-white text-ink-600 hover:bg-stone-100'
|
|
343
|
+
}`}
|
|
344
|
+
onClick={() => {
|
|
345
|
+
setActiveCategory(cat);
|
|
346
|
+
inputRef.current?.focus();
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
{cat}
|
|
350
|
+
</button>
|
|
351
|
+
))}
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{/* Formula list */}
|
|
355
|
+
<div className="overflow-y-auto" style={{ maxHeight: 220 }}>
|
|
356
|
+
{matchingFormulas.map((formula, index) => (
|
|
357
|
+
<div
|
|
358
|
+
key={formula.name}
|
|
359
|
+
data-index={index}
|
|
360
|
+
className={`px-3 py-2 cursor-pointer border-b border-stone-50 ${
|
|
361
|
+
index === selectedIndex ? 'bg-primary-50' : 'hover:bg-paper-50'
|
|
362
|
+
}`}
|
|
363
|
+
onClick={() => insertFormula(formula)}
|
|
364
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
365
|
+
>
|
|
366
|
+
<div className="flex items-center gap-2">
|
|
367
|
+
<span className="font-mono font-semibold text-primary-600 text-sm">
|
|
368
|
+
{formula.name}
|
|
369
|
+
</span>
|
|
370
|
+
<span className="text-xs text-ink-400 bg-stone-100 px-1.5 py-0.5 rounded">
|
|
371
|
+
{formula.category}
|
|
372
|
+
</span>
|
|
373
|
+
</div>
|
|
374
|
+
<div className="text-xs text-ink-500 mt-0.5 truncate">{formula.description}</div>
|
|
375
|
+
<div className="font-mono text-xs text-ink-400 mt-0.5">{formula.syntax}</div>
|
|
376
|
+
</div>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
{/* Footer hint */}
|
|
381
|
+
<div className="px-3 py-1.5 bg-paper-50 border-t border-stone-100 text-xs text-ink-400">
|
|
382
|
+
<span className="font-medium">↑↓</span> navigate
|
|
383
|
+
<span className="mx-2">·</span>
|
|
384
|
+
<span className="font-medium">Tab/Enter</span> insert
|
|
385
|
+
<span className="mx-2">·</span>
|
|
386
|
+
<span className="font-medium">Esc</span> close
|
|
387
|
+
</div>
|
|
388
|
+
</div>,
|
|
389
|
+
document.body
|
|
390
|
+
);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<>
|
|
395
|
+
<input
|
|
396
|
+
ref={inputRef}
|
|
397
|
+
type="text"
|
|
398
|
+
value={value}
|
|
399
|
+
onChange={(e) => onChange(e.target.value)}
|
|
400
|
+
onKeyDown={handleKeyDown}
|
|
401
|
+
onBlur={() => {
|
|
402
|
+
// Delay to allow click on dropdown
|
|
403
|
+
setTimeout(() => {
|
|
404
|
+
setShowDropdown(false);
|
|
405
|
+
setShowHint(false);
|
|
406
|
+
}, 150);
|
|
407
|
+
}}
|
|
408
|
+
className={`w-full h-full border-none outline-none bg-transparent font-mono text-sm ${className}`}
|
|
409
|
+
style={{ margin: '-4px', padding: '4px' }}
|
|
410
|
+
/>
|
|
411
|
+
{renderDropdown()}
|
|
412
|
+
{renderHint()}
|
|
413
|
+
</>
|
|
414
|
+
);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
export default FormulaAutocomplete;
|
|
@@ -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(
|
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,
|
|
@@ -448,6 +463,21 @@ export type { ColumnResize, ColumnOrder } from '../utils/tableEnhancements';
|
|
|
448
463
|
export { exportToExcel, exportDataTableToExcel, createMultiSheetExcel } from '../utils/excelExport';
|
|
449
464
|
export type { ExcelColumn, ExportToExcelOptions, DataTableExportOptions, MultiSheetExcelOptions } from '../utils/excelExport';
|
|
450
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
|
+
|
|
451
481
|
// Hooks
|
|
452
482
|
export { useColumnResize, useColumnReorder } from '../hooks/useTableEnhancements';
|
|
453
483
|
export type { UseColumnResizeOptions, UseColumnReorderOptions } from '../hooks/useTableEnhancements';
|