@papernote/ui 1.7.5 → 1.7.7
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/dist/components/Autocomplete.d.ts +4 -0
- package/dist/components/Autocomplete.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.esm.js +77 -10
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +77 -10
- package/dist/index.js.map +1 -1
- package/dist/styles.css +13 -0
- package/package.json +1 -1
- package/src/components/Autocomplete.tsx +104 -28
package/dist/styles.css
CHANGED
|
@@ -4131,6 +4131,10 @@ input:checked + .slider:before{
|
|
|
4131
4131
|
letter-spacing: -0.025em;
|
|
4132
4132
|
}
|
|
4133
4133
|
|
|
4134
|
+
.tracking-wide{
|
|
4135
|
+
letter-spacing: 0.025em;
|
|
4136
|
+
}
|
|
4137
|
+
|
|
4134
4138
|
.tracking-wider{
|
|
4135
4139
|
letter-spacing: 0.05em;
|
|
4136
4140
|
}
|
|
@@ -4928,6 +4932,15 @@ input:checked + .slider:before{
|
|
|
4928
4932
|
animation: slideInBottom 0.3s ease-out;
|
|
4929
4933
|
}
|
|
4930
4934
|
|
|
4935
|
+
.first\:rounded-t-lg:first-child{
|
|
4936
|
+
border-top-left-radius: 0.5rem;
|
|
4937
|
+
border-top-right-radius: 0.5rem;
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
.first\:border-t-0:first-child{
|
|
4941
|
+
border-top-width: 0px;
|
|
4942
|
+
}
|
|
4943
|
+
|
|
4931
4944
|
.last\:border-b-0:last-child{
|
|
4932
4945
|
border-bottom-width: 0px;
|
|
4933
4946
|
}
|
package/package.json
CHANGED
|
@@ -12,6 +12,8 @@ export interface AutocompleteOption {
|
|
|
12
12
|
label: string;
|
|
13
13
|
description?: string;
|
|
14
14
|
metadata?: Record<string, unknown>;
|
|
15
|
+
/** If true, renders as a non-selectable section header */
|
|
16
|
+
isHeader?: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface AutocompleteProps {
|
|
@@ -30,6 +32,8 @@ export interface AutocompleteProps {
|
|
|
30
32
|
maxResults?: number;
|
|
31
33
|
clearable?: boolean;
|
|
32
34
|
className?: string;
|
|
35
|
+
/** Show static options dropdown on focus when input is empty. Default: true */
|
|
36
|
+
showOptionsOnFocus?: boolean;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
@@ -48,6 +52,7 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
48
52
|
maxResults = 10,
|
|
49
53
|
clearable = true,
|
|
50
54
|
className = '',
|
|
55
|
+
showOptionsOnFocus = true,
|
|
51
56
|
}, ref) => {
|
|
52
57
|
const [isOpen, setIsOpen] = useState(false);
|
|
53
58
|
const [filteredOptions, setFilteredOptions] = useState<AutocompleteOption[]>([]);
|
|
@@ -62,6 +67,30 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
62
67
|
const listboxId = useId();
|
|
63
68
|
const errorId = useId();
|
|
64
69
|
|
|
70
|
+
// Helper to find next selectable (non-header) index
|
|
71
|
+
const findNextSelectableIndex = (currentIndex: number, optionsList: AutocompleteOption[]): number => {
|
|
72
|
+
for (let i = currentIndex + 1; i < optionsList.length; i++) {
|
|
73
|
+
if (!optionsList[i].isHeader) return i;
|
|
74
|
+
}
|
|
75
|
+
return currentIndex; // Stay at current if no next selectable
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Helper to find previous selectable (non-header) index
|
|
79
|
+
const findPrevSelectableIndex = (currentIndex: number, optionsList: AutocompleteOption[]): number => {
|
|
80
|
+
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
81
|
+
if (!optionsList[i].isHeader) return i;
|
|
82
|
+
}
|
|
83
|
+
return -1; // Go to -1 if no previous selectable
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Helper to find first selectable (non-header) index
|
|
87
|
+
const findFirstSelectableIndex = (optionsList: AutocompleteOption[]): number => {
|
|
88
|
+
for (let i = 0; i < optionsList.length; i++) {
|
|
89
|
+
if (!optionsList[i].isHeader) return i;
|
|
90
|
+
}
|
|
91
|
+
return -1;
|
|
92
|
+
};
|
|
93
|
+
|
|
65
94
|
// Expose methods via ref
|
|
66
95
|
useImperativeHandle(ref, () => ({
|
|
67
96
|
focus: () => inputRef.current?.focus(),
|
|
@@ -97,10 +126,13 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
97
126
|
const results = await onSearch(query);
|
|
98
127
|
setFilteredOptions(results.slice(0, maxResults));
|
|
99
128
|
setIsOpen(results.length > 0);
|
|
129
|
+
// Auto-highlight first selectable (non-header) result
|
|
130
|
+
setHighlightedIndex(findFirstSelectableIndex(results.slice(0, maxResults)));
|
|
100
131
|
} catch (err) {
|
|
101
132
|
console.error('Autocomplete search error:', err);
|
|
102
133
|
setFilteredOptions([]);
|
|
103
134
|
setIsOpen(false);
|
|
135
|
+
setHighlightedIndex(-1);
|
|
104
136
|
} finally {
|
|
105
137
|
setLoading(false);
|
|
106
138
|
}
|
|
@@ -109,6 +141,17 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
109
141
|
const filtered = filterOptions(query);
|
|
110
142
|
setFilteredOptions(filtered);
|
|
111
143
|
setIsOpen(filtered.length > 0);
|
|
144
|
+
// Auto-highlight first selectable (non-header) result
|
|
145
|
+
setHighlightedIndex(findFirstSelectableIndex(filtered));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Show static options (for focus/arrow down when input is empty)
|
|
150
|
+
const showStaticOptions = () => {
|
|
151
|
+
if (options.length > 0) {
|
|
152
|
+
setFilteredOptions(options.slice(0, maxResults));
|
|
153
|
+
setIsOpen(true);
|
|
154
|
+
setHighlightedIndex(findFirstSelectableIndex(options.slice(0, maxResults)));
|
|
112
155
|
}
|
|
113
156
|
};
|
|
114
157
|
|
|
@@ -151,7 +194,18 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
151
194
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
152
195
|
if (!isOpen) {
|
|
153
196
|
if (e.key === 'ArrowDown') {
|
|
154
|
-
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
// If we have cached results from a previous search, show them
|
|
199
|
+
if (filteredOptions.length > 0) {
|
|
200
|
+
setIsOpen(true);
|
|
201
|
+
setHighlightedIndex(findFirstSelectableIndex(filteredOptions));
|
|
202
|
+
} else if (value.length < minChars && options.length > 0) {
|
|
203
|
+
// Show static options when input is empty/below minChars
|
|
204
|
+
showStaticOptions();
|
|
205
|
+
} else if (value.length >= minChars) {
|
|
206
|
+
// Otherwise trigger a new search
|
|
207
|
+
handleSearch(value);
|
|
208
|
+
}
|
|
155
209
|
}
|
|
156
210
|
return;
|
|
157
211
|
}
|
|
@@ -159,18 +213,20 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
159
213
|
switch (e.key) {
|
|
160
214
|
case 'ArrowDown':
|
|
161
215
|
e.preventDefault();
|
|
162
|
-
setHighlightedIndex((prev) =>
|
|
163
|
-
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
|
164
|
-
);
|
|
216
|
+
setHighlightedIndex((prev) => findNextSelectableIndex(prev, filteredOptions));
|
|
165
217
|
break;
|
|
166
218
|
case 'ArrowUp':
|
|
167
219
|
e.preventDefault();
|
|
168
|
-
setHighlightedIndex((prev) => (prev
|
|
220
|
+
setHighlightedIndex((prev) => findPrevSelectableIndex(prev, filteredOptions));
|
|
169
221
|
break;
|
|
170
222
|
case 'Enter':
|
|
171
223
|
e.preventDefault();
|
|
172
224
|
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
|
173
|
-
|
|
225
|
+
const option = filteredOptions[highlightedIndex];
|
|
226
|
+
// Don't select headers
|
|
227
|
+
if (!option.isHeader) {
|
|
228
|
+
handleSelect(option);
|
|
229
|
+
}
|
|
174
230
|
}
|
|
175
231
|
break;
|
|
176
232
|
case 'Escape':
|
|
@@ -232,7 +288,15 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
232
288
|
value={value}
|
|
233
289
|
onChange={handleInputChange}
|
|
234
290
|
onKeyDown={handleKeyDown}
|
|
235
|
-
onFocus={() =>
|
|
291
|
+
onFocus={() => {
|
|
292
|
+
if (showOptionsOnFocus && value.length < minChars && options.length > 0) {
|
|
293
|
+
// Show static options when input is empty/below minChars
|
|
294
|
+
showStaticOptions();
|
|
295
|
+
} else if (value.length >= minChars) {
|
|
296
|
+
// Trigger search if we have enough chars
|
|
297
|
+
handleSearch(value);
|
|
298
|
+
}
|
|
299
|
+
}}
|
|
236
300
|
placeholder={placeholder}
|
|
237
301
|
disabled={disabled}
|
|
238
302
|
className={`
|
|
@@ -282,27 +346,39 @@ const Autocomplete = forwardRef<AutocompleteHandle, AutocompleteProps>(({
|
|
|
282
346
|
aria-label="Search results"
|
|
283
347
|
>
|
|
284
348
|
{filteredOptions.map((option, index) => (
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
349
|
+
option.isHeader ? (
|
|
350
|
+
// Render section header (non-selectable)
|
|
351
|
+
<div
|
|
352
|
+
key={`header-${option.value}`}
|
|
353
|
+
className="px-3 py-2 text-xs font-semibold text-ink-500 uppercase tracking-wide bg-paper-50 border-t border-paper-200 first:border-t-0 first:rounded-t-lg cursor-default"
|
|
354
|
+
role="presentation"
|
|
355
|
+
>
|
|
356
|
+
{option.label}
|
|
357
|
+
</div>
|
|
358
|
+
) : (
|
|
359
|
+
// Render selectable option
|
|
360
|
+
<button
|
|
361
|
+
key={option.value}
|
|
362
|
+
id={`autocomplete-option-${index}`}
|
|
363
|
+
type="button"
|
|
364
|
+
onClick={() => handleSelect(option)}
|
|
365
|
+
onMouseEnter={() => setHighlightedIndex(index)}
|
|
366
|
+
role="option"
|
|
367
|
+
aria-selected={highlightedIndex === index}
|
|
368
|
+
className={`
|
|
369
|
+
w-full text-left px-3 py-2 transition-colors
|
|
370
|
+
${highlightedIndex === index
|
|
371
|
+
? 'bg-accent-50'
|
|
372
|
+
: 'hover:bg-paper-50'
|
|
373
|
+
}
|
|
374
|
+
`}
|
|
375
|
+
>
|
|
376
|
+
<div className="text-sm font-medium text-ink-900">{option.label}</div>
|
|
377
|
+
{option.description && (
|
|
378
|
+
<div className="text-xs text-ink-600 mt-0.5">{option.description}</div>
|
|
379
|
+
)}
|
|
380
|
+
</button>
|
|
381
|
+
)
|
|
306
382
|
))}
|
|
307
383
|
</div>
|
|
308
384
|
)}
|