@papernote/ui 1.13.0 → 1.14.1
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/FilterBar.d.ts +1 -1
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterPills.d.ts +14 -0
- package/dist/components/FilterPills.d.ts.map +1 -0
- package/dist/components/LetterNav.d.ts +8 -0
- package/dist/components/LetterNav.d.ts.map +1 -0
- package/dist/components/Pagination.d.ts +11 -1
- package/dist/components/Pagination.d.ts.map +1 -1
- package/dist/components/Select.d.ts +3 -3
- 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 +38 -6
- package/dist/index.esm.js +446 -344
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +446 -342
- package/dist/index.js.map +1 -1
- package/dist/styles.css +4 -0
- package/package.json +1 -1
- package/src/components/FilterBar.tsx +116 -3
- package/src/components/FilterPills.tsx +58 -0
- package/src/components/LetterNav.tsx +67 -0
- package/src/components/Pagination.tsx +49 -1
- package/src/components/Select.tsx +367 -241
- package/src/components/index.ts +6 -0
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import React, {
|
|
2
|
+
useState,
|
|
3
|
+
useRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useId,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Check, ChevronDown, Search, Loader2, X } from "lucide-react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
11
|
+
import { useIsMobile } from "../hooks/useResponsive";
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* Single option in a select dropdown
|
|
@@ -80,9 +87,9 @@ export interface SelectProps {
|
|
|
80
87
|
/** Height of each option row in pixels (default: 42) */
|
|
81
88
|
virtualItemHeight?: number;
|
|
82
89
|
/** Size of the select trigger - 'lg' provides 44px touch-friendly target */
|
|
83
|
-
size?:
|
|
90
|
+
size?: "sm" | "md" | "lg";
|
|
84
91
|
/** Mobile display mode - 'auto' uses BottomSheet on mobile, 'dropdown' always uses dropdown, 'native' uses native select on mobile */
|
|
85
|
-
mobileMode?:
|
|
92
|
+
mobileMode?: "auto" | "dropdown" | "native";
|
|
86
93
|
/** Render dropdown via portal (default: true). Set to false when overflow clipping is not an issue */
|
|
87
94
|
usePortal?: boolean;
|
|
88
95
|
/** Whether this field is required */
|
|
@@ -91,16 +98,16 @@ export interface SelectProps {
|
|
|
91
98
|
|
|
92
99
|
// Size classes for trigger button
|
|
93
100
|
const sizeClasses = {
|
|
94
|
-
sm:
|
|
95
|
-
md:
|
|
96
|
-
lg:
|
|
101
|
+
sm: "h-8 text-sm py-1",
|
|
102
|
+
md: "h-10 text-base py-2",
|
|
103
|
+
lg: "h-12 text-base py-3 min-h-touch", // 44px touch target
|
|
97
104
|
};
|
|
98
105
|
|
|
99
106
|
// Size classes for options
|
|
100
107
|
const optionSizeClasses = {
|
|
101
|
-
sm:
|
|
102
|
-
md:
|
|
103
|
-
lg:
|
|
108
|
+
sm: "py-2 text-sm",
|
|
109
|
+
md: "py-2.5 text-sm",
|
|
110
|
+
lg: "py-3.5 text-base min-h-touch", // 44px touch target for mobile
|
|
104
111
|
};
|
|
105
112
|
|
|
106
113
|
/**
|
|
@@ -173,14 +180,13 @@ const optionSizeClasses = {
|
|
|
173
180
|
* />
|
|
174
181
|
* ```
|
|
175
182
|
*/
|
|
176
|
-
const Select = forwardRef<SelectHandle, SelectProps>(
|
|
177
|
-
(props, ref) => {
|
|
183
|
+
const Select = forwardRef<SelectHandle, SelectProps>((props, ref) => {
|
|
178
184
|
const {
|
|
179
185
|
options = [],
|
|
180
186
|
groups = [],
|
|
181
187
|
value,
|
|
182
188
|
onChange,
|
|
183
|
-
placeholder =
|
|
189
|
+
placeholder = "Select an option",
|
|
184
190
|
searchable = false,
|
|
185
191
|
disabled = false,
|
|
186
192
|
label,
|
|
@@ -191,18 +197,23 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
191
197
|
creatable = false,
|
|
192
198
|
onCreateOption,
|
|
193
199
|
virtualized = false,
|
|
194
|
-
virtualHeight =
|
|
200
|
+
virtualHeight = "300px",
|
|
195
201
|
virtualItemHeight = 42,
|
|
196
|
-
size =
|
|
197
|
-
mobileMode =
|
|
202
|
+
size = "md",
|
|
203
|
+
mobileMode = "auto",
|
|
198
204
|
usePortal = true,
|
|
199
205
|
required = false,
|
|
200
206
|
} = props;
|
|
201
207
|
const [isOpen, setIsOpen] = useState(false);
|
|
202
|
-
const [searchQuery, setSearchQuery] = useState(
|
|
208
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
203
209
|
const [scrollTop, setScrollTop] = useState(0);
|
|
204
210
|
const [activeDescendant] = useState<string | undefined>(undefined);
|
|
205
|
-
const [dropdownPosition, setDropdownPosition] = useState<{
|
|
211
|
+
const [dropdownPosition, setDropdownPosition] = useState<{
|
|
212
|
+
top: number;
|
|
213
|
+
left: number;
|
|
214
|
+
width: number;
|
|
215
|
+
placement: "bottom" | "top";
|
|
216
|
+
} | null>(null);
|
|
206
217
|
const selectRef = useRef<HTMLDivElement>(null);
|
|
207
218
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
208
219
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
@@ -213,12 +224,12 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
213
224
|
|
|
214
225
|
// Detect mobile viewport
|
|
215
226
|
const isMobile = useIsMobile();
|
|
216
|
-
const useMobileSheet = mobileMode ===
|
|
217
|
-
const useNativeSelect = mobileMode ===
|
|
218
|
-
|
|
227
|
+
const useMobileSheet = mobileMode === "auto" && isMobile;
|
|
228
|
+
const useNativeSelect = mobileMode === "native" && isMobile;
|
|
229
|
+
|
|
219
230
|
// Auto-size for mobile
|
|
220
|
-
const effectiveSize = isMobile && size ===
|
|
221
|
-
|
|
231
|
+
const effectiveSize = isMobile && size === "md" ? "lg" : size;
|
|
232
|
+
|
|
222
233
|
// Generate unique IDs for ARIA
|
|
223
234
|
const labelId = useId();
|
|
224
235
|
const listboxId = useId();
|
|
@@ -234,12 +245,9 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
234
245
|
}));
|
|
235
246
|
|
|
236
247
|
// Flatten all options (from both options and groups)
|
|
237
|
-
const allOptions = [
|
|
238
|
-
...options,
|
|
239
|
-
...groups.flatMap(group => group.options)
|
|
240
|
-
];
|
|
248
|
+
const allOptions = [...options, ...groups.flatMap((group) => group.options)];
|
|
241
249
|
|
|
242
|
-
const selectedOption = allOptions.find(opt => opt.value === value);
|
|
250
|
+
const selectedOption = allOptions.find((opt) => opt.value === value);
|
|
243
251
|
|
|
244
252
|
// Filter options/groups based on search
|
|
245
253
|
const getFilteredData = () => {
|
|
@@ -250,27 +258,29 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
250
258
|
const query = searchQuery.toLowerCase();
|
|
251
259
|
|
|
252
260
|
// Filter flat options
|
|
253
|
-
const filteredOptions = options.filter(opt =>
|
|
254
|
-
opt.label.toLowerCase().includes(query)
|
|
261
|
+
const filteredOptions = options.filter((opt) =>
|
|
262
|
+
opt.label.toLowerCase().includes(query),
|
|
255
263
|
);
|
|
256
264
|
|
|
257
265
|
// Filter grouped options
|
|
258
266
|
const filteredGroups = groups
|
|
259
|
-
.map(group => ({
|
|
267
|
+
.map((group) => ({
|
|
260
268
|
...group,
|
|
261
|
-
options: group.options.filter(opt =>
|
|
262
|
-
opt.label.toLowerCase().includes(query)
|
|
263
|
-
)
|
|
269
|
+
options: group.options.filter((opt) =>
|
|
270
|
+
opt.label.toLowerCase().includes(query),
|
|
271
|
+
),
|
|
264
272
|
}))
|
|
265
|
-
.filter(group => group.options.length > 0);
|
|
273
|
+
.filter((group) => group.options.length > 0);
|
|
266
274
|
|
|
267
275
|
return { options: filteredOptions, groups: filteredGroups };
|
|
268
276
|
};
|
|
269
277
|
|
|
270
|
-
const { options: filteredOptions, groups: filteredGroups } =
|
|
278
|
+
const { options: filteredOptions, groups: filteredGroups } =
|
|
279
|
+
getFilteredData();
|
|
271
280
|
|
|
272
281
|
// Virtual scrolling calculations
|
|
273
|
-
const totalItems =
|
|
282
|
+
const totalItems =
|
|
283
|
+
filteredOptions.length + filteredGroups.flatMap((g) => g.options).length;
|
|
274
284
|
const useVirtualScrolling = virtualized && totalItems > 50;
|
|
275
285
|
|
|
276
286
|
const visibleRangeStart = useVirtualScrolling
|
|
@@ -278,32 +288,54 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
278
288
|
: 0;
|
|
279
289
|
const visibleRangeEnd = useVirtualScrolling
|
|
280
290
|
? Math.min(
|
|
281
|
-
visibleRangeStart +
|
|
282
|
-
|
|
291
|
+
visibleRangeStart +
|
|
292
|
+
Math.ceil(parseInt(virtualHeight) / virtualItemHeight) +
|
|
293
|
+
5,
|
|
294
|
+
totalItems,
|
|
283
295
|
)
|
|
284
296
|
: totalItems;
|
|
285
297
|
|
|
286
298
|
// Flatten all filtered items for virtualization
|
|
287
299
|
const allFilteredItems = [
|
|
288
|
-
...filteredOptions.map((opt, idx) => ({
|
|
300
|
+
...filteredOptions.map((opt, idx) => ({
|
|
301
|
+
type: "option" as const,
|
|
302
|
+
option: opt,
|
|
303
|
+
groupIndex: -1,
|
|
304
|
+
optionIndex: idx,
|
|
305
|
+
})),
|
|
289
306
|
...filteredGroups.flatMap((group, groupIdx) =>
|
|
290
|
-
group.options.map((opt, optIdx) => ({
|
|
291
|
-
|
|
307
|
+
group.options.map((opt, optIdx) => ({
|
|
308
|
+
type: "grouped" as const,
|
|
309
|
+
option: opt,
|
|
310
|
+
groupIndex: groupIdx,
|
|
311
|
+
optionIndex: optIdx,
|
|
312
|
+
groupLabel: group.label,
|
|
313
|
+
})),
|
|
314
|
+
),
|
|
292
315
|
];
|
|
293
316
|
|
|
294
317
|
const visibleItems = useVirtualScrolling
|
|
295
318
|
? allFilteredItems.slice(visibleRangeStart, visibleRangeEnd)
|
|
296
319
|
: allFilteredItems;
|
|
297
320
|
|
|
298
|
-
const offsetY = useVirtualScrolling
|
|
299
|
-
|
|
321
|
+
const offsetY = useVirtualScrolling
|
|
322
|
+
? visibleRangeStart * virtualItemHeight
|
|
323
|
+
: 0;
|
|
324
|
+
const totalHeight = useVirtualScrolling
|
|
325
|
+
? totalItems * virtualItemHeight
|
|
326
|
+
: "auto";
|
|
300
327
|
|
|
301
328
|
// Check if we should show "Create" option
|
|
302
|
-
const showCreateOption =
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
!
|
|
306
|
-
|
|
329
|
+
const showCreateOption =
|
|
330
|
+
creatable &&
|
|
331
|
+
searchQuery.trim() !== "" &&
|
|
332
|
+
!filteredOptions.some(
|
|
333
|
+
(opt) => opt.label.toLowerCase() === searchQuery.toLowerCase(),
|
|
334
|
+
) &&
|
|
335
|
+
!filteredGroups.some((group) =>
|
|
336
|
+
group.options.some(
|
|
337
|
+
(opt) => opt.label.toLowerCase() === searchQuery.toLowerCase(),
|
|
338
|
+
),
|
|
307
339
|
);
|
|
308
340
|
|
|
309
341
|
// Handle creating new option
|
|
@@ -314,7 +346,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
314
346
|
// If no callback, just select the typed value
|
|
315
347
|
onChange?.(searchQuery.trim());
|
|
316
348
|
}
|
|
317
|
-
setSearchQuery(
|
|
349
|
+
setSearchQuery("");
|
|
318
350
|
setIsOpen(false);
|
|
319
351
|
};
|
|
320
352
|
|
|
@@ -325,21 +357,23 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
325
357
|
const handleClickOutside = (event: MouseEvent) => {
|
|
326
358
|
const target = event.target as Node;
|
|
327
359
|
// Check if click is outside both the select trigger and the dropdown portal
|
|
328
|
-
const isOutsideSelect =
|
|
329
|
-
|
|
360
|
+
const isOutsideSelect =
|
|
361
|
+
selectRef.current && !selectRef.current.contains(target);
|
|
362
|
+
const isOutsideDropdown =
|
|
363
|
+
dropdownRef.current && !dropdownRef.current.contains(target);
|
|
330
364
|
|
|
331
365
|
if (isOutsideSelect && isOutsideDropdown) {
|
|
332
366
|
setIsOpen(false);
|
|
333
|
-
setSearchQuery(
|
|
367
|
+
setSearchQuery("");
|
|
334
368
|
}
|
|
335
369
|
};
|
|
336
370
|
|
|
337
371
|
if (isOpen) {
|
|
338
|
-
document.addEventListener(
|
|
372
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
339
373
|
}
|
|
340
374
|
|
|
341
375
|
return () => {
|
|
342
|
-
document.removeEventListener(
|
|
376
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
343
377
|
};
|
|
344
378
|
}, [isOpen, useMobileSheet]);
|
|
345
379
|
|
|
@@ -377,11 +411,13 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
377
411
|
const hasSpaceAbove = spaceAbove >= dropdownHeight + gap;
|
|
378
412
|
|
|
379
413
|
// Prefer bottom placement, flip to top if not enough space below but enough above
|
|
380
|
-
const placement:
|
|
414
|
+
const placement: "bottom" | "top" =
|
|
415
|
+
hasSpaceBelow || !hasSpaceAbove ? "bottom" : "top";
|
|
381
416
|
|
|
382
|
-
const top =
|
|
383
|
-
|
|
384
|
-
|
|
417
|
+
const top =
|
|
418
|
+
placement === "bottom"
|
|
419
|
+
? rect.bottom + gap
|
|
420
|
+
: rect.top - dropdownHeight - gap;
|
|
385
421
|
|
|
386
422
|
setDropdownPosition({
|
|
387
423
|
top,
|
|
@@ -395,24 +431,24 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
395
431
|
updatePosition();
|
|
396
432
|
|
|
397
433
|
// Listen for scroll events on all scrollable ancestors
|
|
398
|
-
window.addEventListener(
|
|
399
|
-
window.addEventListener(
|
|
434
|
+
window.addEventListener("scroll", updatePosition, true);
|
|
435
|
+
window.addEventListener("resize", updatePosition);
|
|
400
436
|
|
|
401
437
|
return () => {
|
|
402
|
-
window.removeEventListener(
|
|
403
|
-
window.removeEventListener(
|
|
438
|
+
window.removeEventListener("scroll", updatePosition, true);
|
|
439
|
+
window.removeEventListener("resize", updatePosition);
|
|
404
440
|
};
|
|
405
441
|
}, [isOpen, useMobileSheet, usePortal]);
|
|
406
442
|
|
|
407
443
|
// Lock body scroll when mobile sheet is open
|
|
408
444
|
useEffect(() => {
|
|
409
445
|
if (useMobileSheet && isOpen) {
|
|
410
|
-
document.body.style.overflow =
|
|
446
|
+
document.body.style.overflow = "hidden";
|
|
411
447
|
} else {
|
|
412
|
-
document.body.style.overflow =
|
|
448
|
+
document.body.style.overflow = "";
|
|
413
449
|
}
|
|
414
450
|
return () => {
|
|
415
|
-
document.body.style.overflow =
|
|
451
|
+
document.body.style.overflow = "";
|
|
416
452
|
};
|
|
417
453
|
}, [isOpen, useMobileSheet]);
|
|
418
454
|
|
|
@@ -421,29 +457,33 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
421
457
|
if (!useMobileSheet || !isOpen) return;
|
|
422
458
|
|
|
423
459
|
const handleEscape = (e: KeyboardEvent) => {
|
|
424
|
-
if (e.key ===
|
|
460
|
+
if (e.key === "Escape") {
|
|
425
461
|
setIsOpen(false);
|
|
426
|
-
setSearchQuery(
|
|
462
|
+
setSearchQuery("");
|
|
427
463
|
}
|
|
428
464
|
};
|
|
429
465
|
|
|
430
|
-
document.addEventListener(
|
|
431
|
-
return () => document.removeEventListener(
|
|
466
|
+
document.addEventListener("keydown", handleEscape);
|
|
467
|
+
return () => document.removeEventListener("keydown", handleEscape);
|
|
432
468
|
}, [isOpen, useMobileSheet]);
|
|
433
469
|
|
|
434
470
|
const handleSelect = (optionValue: string) => {
|
|
435
471
|
onChange?.(optionValue);
|
|
436
472
|
setIsOpen(false);
|
|
437
|
-
setSearchQuery(
|
|
473
|
+
setSearchQuery("");
|
|
438
474
|
};
|
|
439
475
|
|
|
440
476
|
const handleClose = () => {
|
|
441
477
|
setIsOpen(false);
|
|
442
|
-
setSearchQuery(
|
|
478
|
+
setSearchQuery("");
|
|
443
479
|
};
|
|
444
480
|
|
|
445
481
|
// Render option button (shared between desktop and mobile)
|
|
446
|
-
const renderOption = (
|
|
482
|
+
const renderOption = (
|
|
483
|
+
option: SelectOption,
|
|
484
|
+
isSelected: boolean,
|
|
485
|
+
mobile = false,
|
|
486
|
+
) => (
|
|
447
487
|
<button
|
|
448
488
|
key={option.value}
|
|
449
489
|
type="button"
|
|
@@ -452,8 +492,8 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
452
492
|
className={`
|
|
453
493
|
w-full flex items-center justify-between px-4 transition-colors
|
|
454
494
|
${mobile ? optionSizeClasses.lg : optionSizeClasses[effectiveSize]}
|
|
455
|
-
${isSelected ?
|
|
456
|
-
${option.disabled ?
|
|
495
|
+
${isSelected ? "bg-accent-50 text-accent-900" : "text-ink-700"}
|
|
496
|
+
${option.disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-paper-50 active:bg-paper-100 cursor-pointer"}
|
|
457
497
|
`}
|
|
458
498
|
role="option"
|
|
459
499
|
aria-selected={isSelected}
|
|
@@ -462,7 +502,11 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
462
502
|
{option.icon && <span>{option.icon}</span>}
|
|
463
503
|
{option.label}
|
|
464
504
|
</span>
|
|
465
|
-
{isSelected &&
|
|
505
|
+
{isSelected && (
|
|
506
|
+
<Check
|
|
507
|
+
className={`${mobile ? "h-5 w-5" : "h-4 w-4"} text-accent-600`}
|
|
508
|
+
/>
|
|
509
|
+
)}
|
|
466
510
|
</button>
|
|
467
511
|
);
|
|
468
512
|
|
|
@@ -470,16 +514,28 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
470
514
|
const renderOptionsContent = (mobile = false) => {
|
|
471
515
|
if (loading) {
|
|
472
516
|
return (
|
|
473
|
-
<div
|
|
517
|
+
<div
|
|
518
|
+
className="px-4 py-8 flex items-center justify-center"
|
|
519
|
+
role="status"
|
|
520
|
+
aria-live="polite"
|
|
521
|
+
>
|
|
474
522
|
<Loader2 className="h-5 w-5 animate-spin text-ink-500" />
|
|
475
523
|
<span className="ml-2 text-sm text-ink-500">Loading...</span>
|
|
476
524
|
</div>
|
|
477
525
|
);
|
|
478
526
|
}
|
|
479
|
-
|
|
480
|
-
if (
|
|
527
|
+
|
|
528
|
+
if (
|
|
529
|
+
filteredOptions.length === 0 &&
|
|
530
|
+
filteredGroups.length === 0 &&
|
|
531
|
+
!showCreateOption
|
|
532
|
+
) {
|
|
481
533
|
return (
|
|
482
|
-
<div
|
|
534
|
+
<div
|
|
535
|
+
className="px-4 py-3 text-sm text-ink-500 text-center"
|
|
536
|
+
role="status"
|
|
537
|
+
aria-live="polite"
|
|
538
|
+
>
|
|
483
539
|
No options found
|
|
484
540
|
</div>
|
|
485
541
|
);
|
|
@@ -494,7 +550,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
494
550
|
onClick={handleCreateOption}
|
|
495
551
|
className={`
|
|
496
552
|
w-full flex items-center px-4 text-accent-700 hover:bg-accent-50 transition-colors border-b border-paper-200
|
|
497
|
-
${mobile ?
|
|
553
|
+
${mobile ? "py-3.5 text-base" : "py-2.5 text-sm"}
|
|
498
554
|
`}
|
|
499
555
|
>
|
|
500
556
|
<span className="font-medium">Create "{searchQuery}"</span>
|
|
@@ -503,7 +559,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
503
559
|
|
|
504
560
|
{/* Virtual scrolling container */}
|
|
505
561
|
{useVirtualScrolling ? (
|
|
506
|
-
<div style={{ height: totalHeight, position:
|
|
562
|
+
<div style={{ height: totalHeight, position: "relative" }}>
|
|
507
563
|
<div style={{ transform: `translateY(${offsetY}px)` }}>
|
|
508
564
|
{visibleItems.map((item) => {
|
|
509
565
|
const option = item.option;
|
|
@@ -514,14 +570,18 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
514
570
|
<button
|
|
515
571
|
key={key}
|
|
516
572
|
type="button"
|
|
517
|
-
onClick={() =>
|
|
573
|
+
onClick={() =>
|
|
574
|
+
!option.disabled && handleSelect(option.value)
|
|
575
|
+
}
|
|
518
576
|
disabled={option.disabled}
|
|
519
|
-
style={{
|
|
577
|
+
style={{
|
|
578
|
+
height: mobile ? "56px" : `${virtualItemHeight}px`,
|
|
579
|
+
}}
|
|
520
580
|
className={`
|
|
521
581
|
w-full flex items-center justify-between px-4 transition-colors
|
|
522
|
-
${mobile ?
|
|
523
|
-
${isSelected ?
|
|
524
|
-
${option.disabled ?
|
|
582
|
+
${mobile ? "text-base" : "text-sm"}
|
|
583
|
+
${isSelected ? "bg-accent-50 text-accent-900" : "text-ink-700"}
|
|
584
|
+
${option.disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-paper-50 cursor-pointer"}
|
|
525
585
|
`}
|
|
526
586
|
role="option"
|
|
527
587
|
aria-selected={isSelected}
|
|
@@ -530,7 +590,11 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
530
590
|
{option.icon && <span>{option.icon}</span>}
|
|
531
591
|
{option.label}
|
|
532
592
|
</span>
|
|
533
|
-
{isSelected &&
|
|
593
|
+
{isSelected && (
|
|
594
|
+
<Check
|
|
595
|
+
className={`${mobile ? "h-5 w-5" : "h-4 w-4"} text-accent-600`}
|
|
596
|
+
/>
|
|
597
|
+
)}
|
|
534
598
|
</button>
|
|
535
599
|
);
|
|
536
600
|
})}
|
|
@@ -539,21 +603,27 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
539
603
|
) : (
|
|
540
604
|
<>
|
|
541
605
|
{/* Render flat options */}
|
|
542
|
-
{filteredOptions.map((option) =>
|
|
606
|
+
{filteredOptions.map((option) =>
|
|
607
|
+
renderOption(option, option.value === value, mobile),
|
|
608
|
+
)}
|
|
543
609
|
|
|
544
610
|
{/* Render grouped options */}
|
|
545
611
|
{filteredGroups.map((group) => (
|
|
546
612
|
<div key={group.label}>
|
|
547
613
|
{/* Group Header */}
|
|
548
|
-
<div
|
|
614
|
+
<div
|
|
615
|
+
className={`
|
|
549
616
|
px-4 font-semibold text-ink-500 uppercase tracking-wider bg-paper-50 border-t border-b border-paper-200
|
|
550
|
-
${mobile ?
|
|
551
|
-
`}
|
|
617
|
+
${mobile ? "py-2.5 text-xs" : "py-2 text-xs"}
|
|
618
|
+
`}
|
|
619
|
+
>
|
|
552
620
|
{group.label}
|
|
553
621
|
</div>
|
|
554
622
|
|
|
555
623
|
{/* Group Options */}
|
|
556
|
-
{group.options.map((option) =>
|
|
624
|
+
{group.options.map((option) =>
|
|
625
|
+
renderOption(option, option.value === value, mobile),
|
|
626
|
+
)}
|
|
557
627
|
</div>
|
|
558
628
|
))}
|
|
559
629
|
</>
|
|
@@ -576,21 +646,25 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
576
646
|
<div className="relative">
|
|
577
647
|
<select
|
|
578
648
|
ref={nativeSelectRef}
|
|
579
|
-
value={value ||
|
|
649
|
+
value={value || ""}
|
|
580
650
|
onChange={(e) => onChange?.(e.target.value)}
|
|
581
651
|
disabled={disabled}
|
|
582
652
|
className={`
|
|
583
653
|
input w-full appearance-none pr-10
|
|
584
654
|
${sizeClasses[effectiveSize]}
|
|
585
|
-
${error ?
|
|
586
|
-
${disabled ?
|
|
655
|
+
${error ? "border-error-400 focus:border-error-400 focus:ring-error-400" : ""}
|
|
656
|
+
${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}
|
|
587
657
|
`}
|
|
588
658
|
aria-labelledby={label ? labelId : undefined}
|
|
589
|
-
aria-invalid={error ?
|
|
590
|
-
aria-describedby={
|
|
659
|
+
aria-invalid={error ? "true" : undefined}
|
|
660
|
+
aria-describedby={
|
|
661
|
+
error ? errorId : helperText ? helperTextId : undefined
|
|
662
|
+
}
|
|
591
663
|
aria-required={required}
|
|
592
664
|
>
|
|
593
|
-
<option value="" disabled>
|
|
665
|
+
<option value="" disabled>
|
|
666
|
+
{placeholder}
|
|
667
|
+
</option>
|
|
594
668
|
{options.map((opt) => (
|
|
595
669
|
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
|
596
670
|
{opt.label}
|
|
@@ -599,7 +673,11 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
599
673
|
{groups.map((group) => (
|
|
600
674
|
<optgroup key={group.label} label={group.label}>
|
|
601
675
|
{group.options.map((opt) => (
|
|
602
|
-
<option
|
|
676
|
+
<option
|
|
677
|
+
key={opt.value}
|
|
678
|
+
value={opt.value}
|
|
679
|
+
disabled={opt.disabled}
|
|
680
|
+
>
|
|
603
681
|
{opt.label}
|
|
604
682
|
</option>
|
|
605
683
|
))}
|
|
@@ -610,7 +688,12 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
610
688
|
</div>
|
|
611
689
|
|
|
612
690
|
{error && (
|
|
613
|
-
<p
|
|
691
|
+
<p
|
|
692
|
+
id={errorId}
|
|
693
|
+
className="mt-2 text-xs text-error-600"
|
|
694
|
+
role="alert"
|
|
695
|
+
aria-live="assertive"
|
|
696
|
+
>
|
|
614
697
|
{error}
|
|
615
698
|
</p>
|
|
616
699
|
)}
|
|
@@ -644,8 +727,8 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
644
727
|
className={`
|
|
645
728
|
input w-full flex items-center justify-between px-3
|
|
646
729
|
${sizeClasses[effectiveSize]}
|
|
647
|
-
${error ?
|
|
648
|
-
${disabled ?
|
|
730
|
+
${error ? "border-error-400 focus:border-error-400 focus:ring-error-400" : ""}
|
|
731
|
+
${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}
|
|
649
732
|
`}
|
|
650
733
|
role="combobox"
|
|
651
734
|
aria-haspopup="listbox"
|
|
@@ -654,87 +737,117 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
654
737
|
aria-labelledby={label ? labelId : undefined}
|
|
655
738
|
aria-label={!label ? placeholder : undefined}
|
|
656
739
|
aria-activedescendant={activeDescendant}
|
|
657
|
-
aria-invalid={error ?
|
|
658
|
-
aria-describedby={
|
|
740
|
+
aria-invalid={error ? "true" : undefined}
|
|
741
|
+
aria-describedby={
|
|
742
|
+
error ? errorId : helperText ? helperTextId : undefined
|
|
743
|
+
}
|
|
659
744
|
aria-disabled={disabled}
|
|
660
745
|
aria-required={required}
|
|
661
746
|
>
|
|
662
|
-
<span
|
|
663
|
-
{
|
|
664
|
-
|
|
747
|
+
<span
|
|
748
|
+
className={`flex items-center gap-2 ${selectedOption ? "text-ink-800" : "text-ink-400"}`}
|
|
749
|
+
>
|
|
750
|
+
{loading && (
|
|
751
|
+
<Loader2 className="h-4 w-4 animate-spin text-ink-500" />
|
|
752
|
+
)}
|
|
753
|
+
{!loading && selectedOption?.icon && (
|
|
754
|
+
<span>{selectedOption.icon}</span>
|
|
755
|
+
)}
|
|
665
756
|
{selectedOption ? selectedOption.label : placeholder}
|
|
666
757
|
</span>
|
|
667
758
|
<div className="flex items-center gap-1">
|
|
668
759
|
{clearable && value && (
|
|
669
|
-
<
|
|
670
|
-
|
|
760
|
+
<span
|
|
761
|
+
role="button"
|
|
762
|
+
tabIndex={-1}
|
|
671
763
|
onClick={(e) => {
|
|
672
764
|
e.stopPropagation();
|
|
673
|
-
onChange?.(
|
|
765
|
+
onChange?.("");
|
|
674
766
|
setIsOpen(false);
|
|
675
767
|
}}
|
|
676
|
-
|
|
768
|
+
onKeyDown={(e) => {
|
|
769
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
770
|
+
e.preventDefault();
|
|
771
|
+
e.stopPropagation();
|
|
772
|
+
onChange?.("");
|
|
773
|
+
setIsOpen(false);
|
|
774
|
+
}
|
|
775
|
+
}}
|
|
776
|
+
className="text-ink-400 hover:text-ink-600 transition-colors p-0.5 cursor-pointer inline-flex"
|
|
677
777
|
aria-label="Clear selection"
|
|
678
778
|
>
|
|
679
|
-
<X
|
|
680
|
-
|
|
779
|
+
<X
|
|
780
|
+
className={`${effectiveSize === "lg" ? "h-5 w-5" : "h-4 w-4"}`}
|
|
781
|
+
/>
|
|
782
|
+
</span>
|
|
681
783
|
)}
|
|
682
|
-
<ChevronDown
|
|
784
|
+
<ChevronDown
|
|
785
|
+
className={`${effectiveSize === "lg" ? "h-5 w-5" : "h-4 w-4"} text-ink-500 transition-transform ${isOpen ? "rotate-180" : ""}`}
|
|
786
|
+
/>
|
|
683
787
|
</div>
|
|
684
788
|
</button>
|
|
685
|
-
|
|
686
789
|
</div>
|
|
687
790
|
|
|
688
791
|
{/* Desktop Dropdown - rendered via portal to avoid overflow clipping */}
|
|
689
|
-
{isOpen &&
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
dropdownPosition?.placement === 'top' ? 'origin-bottom' : 'origin-top'
|
|
695
|
-
}`}
|
|
696
|
-
style={{
|
|
697
|
-
top: dropdownPosition!.top,
|
|
698
|
-
left: dropdownPosition!.left,
|
|
699
|
-
width: dropdownPosition!.width,
|
|
700
|
-
}}
|
|
701
|
-
>
|
|
702
|
-
{/* Search Input */}
|
|
703
|
-
{searchable && (
|
|
704
|
-
<div className="p-2 border-b border-paper-200">
|
|
705
|
-
<div className="relative">
|
|
706
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
|
707
|
-
<input
|
|
708
|
-
ref={searchInputRef}
|
|
709
|
-
type="text"
|
|
710
|
-
value={searchQuery}
|
|
711
|
-
onChange={(e) => setSearchQuery(e.target.value)}
|
|
712
|
-
placeholder="Search..."
|
|
713
|
-
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"
|
|
714
|
-
role="searchbox"
|
|
715
|
-
aria-label="Search options"
|
|
716
|
-
aria-autocomplete="list"
|
|
717
|
-
aria-controls={listboxId}
|
|
718
|
-
/>
|
|
719
|
-
</div>
|
|
720
|
-
</div>
|
|
721
|
-
)}
|
|
722
|
-
|
|
723
|
-
{/* Options List */}
|
|
792
|
+
{isOpen &&
|
|
793
|
+
!useMobileSheet &&
|
|
794
|
+
(usePortal ? dropdownPosition : true) &&
|
|
795
|
+
(usePortal ? (
|
|
796
|
+
createPortal(
|
|
724
797
|
<div
|
|
725
|
-
ref={
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
798
|
+
ref={dropdownRef}
|
|
799
|
+
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 ${
|
|
800
|
+
dropdownPosition?.placement === "top"
|
|
801
|
+
? "origin-bottom"
|
|
802
|
+
: "origin-top"
|
|
803
|
+
}`}
|
|
804
|
+
style={{
|
|
805
|
+
top: dropdownPosition!.top,
|
|
806
|
+
left: dropdownPosition!.left,
|
|
807
|
+
width: dropdownPosition!.width,
|
|
808
|
+
}}
|
|
733
809
|
>
|
|
734
|
-
{
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
810
|
+
{/* Search Input */}
|
|
811
|
+
{searchable && (
|
|
812
|
+
<div className="p-2 border-b border-paper-200">
|
|
813
|
+
<div className="relative">
|
|
814
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
|
815
|
+
<input
|
|
816
|
+
ref={searchInputRef}
|
|
817
|
+
type="text"
|
|
818
|
+
value={searchQuery}
|
|
819
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
820
|
+
placeholder="Search..."
|
|
821
|
+
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"
|
|
822
|
+
role="searchbox"
|
|
823
|
+
aria-label="Search options"
|
|
824
|
+
aria-autocomplete="list"
|
|
825
|
+
aria-controls={listboxId}
|
|
826
|
+
/>
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
829
|
+
)}
|
|
830
|
+
|
|
831
|
+
{/* Options List */}
|
|
832
|
+
<div
|
|
833
|
+
ref={listRef}
|
|
834
|
+
id={listboxId}
|
|
835
|
+
className="overflow-y-auto"
|
|
836
|
+
style={{
|
|
837
|
+
maxHeight: useVirtualScrolling ? virtualHeight : "12rem",
|
|
838
|
+
}}
|
|
839
|
+
onScroll={(e) =>
|
|
840
|
+
useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop)
|
|
841
|
+
}
|
|
842
|
+
role="listbox"
|
|
843
|
+
aria-label="Available options"
|
|
844
|
+
aria-multiselectable="false"
|
|
845
|
+
>
|
|
846
|
+
{renderOptionsContent(false)}
|
|
847
|
+
</div>
|
|
848
|
+
</div>,
|
|
849
|
+
document.body,
|
|
850
|
+
)
|
|
738
851
|
) : (
|
|
739
852
|
// Non-portal dropdown (inline, relative positioning)
|
|
740
853
|
<div
|
|
@@ -767,8 +880,12 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
767
880
|
ref={listRef}
|
|
768
881
|
id={listboxId}
|
|
769
882
|
className="overflow-y-auto"
|
|
770
|
-
style={{
|
|
771
|
-
|
|
883
|
+
style={{
|
|
884
|
+
maxHeight: useVirtualScrolling ? virtualHeight : "12rem",
|
|
885
|
+
}}
|
|
886
|
+
onScroll={(e) =>
|
|
887
|
+
useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop)
|
|
888
|
+
}
|
|
772
889
|
role="listbox"
|
|
773
890
|
aria-label="Available options"
|
|
774
891
|
aria-multiselectable="false"
|
|
@@ -776,91 +893,100 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
776
893
|
{renderOptionsContent(false)}
|
|
777
894
|
</div>
|
|
778
895
|
</div>
|
|
779
|
-
)
|
|
780
|
-
)}
|
|
896
|
+
))}
|
|
781
897
|
|
|
782
898
|
{/* Mobile Bottom Sheet */}
|
|
783
|
-
{isOpen &&
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
onClick={(e) => e.target === e.currentTarget && handleClose()}
|
|
787
|
-
role="dialog"
|
|
788
|
-
aria-modal="true"
|
|
789
|
-
aria-labelledby={label ? `mobile-${labelId}` : undefined}
|
|
790
|
-
>
|
|
791
|
-
{/* Backdrop */}
|
|
792
|
-
<div className="absolute inset-0 bg-black/50 animate-fade-in" />
|
|
793
|
-
|
|
794
|
-
{/* Sheet */}
|
|
899
|
+
{isOpen &&
|
|
900
|
+
useMobileSheet &&
|
|
901
|
+
createPortal(
|
|
795
902
|
<div
|
|
796
|
-
className="
|
|
797
|
-
|
|
903
|
+
className="fixed inset-0 z-50 flex items-end"
|
|
904
|
+
onClick={(e) => e.target === e.currentTarget && handleClose()}
|
|
905
|
+
role="dialog"
|
|
906
|
+
aria-modal="true"
|
|
907
|
+
aria-labelledby={label ? `mobile-${labelId}` : undefined}
|
|
798
908
|
>
|
|
799
|
-
{/*
|
|
800
|
-
<div className="
|
|
801
|
-
<div className="w-12 h-1.5 bg-ink-300 rounded-full mx-auto" />
|
|
802
|
-
</div>
|
|
909
|
+
{/* Backdrop */}
|
|
910
|
+
<div className="absolute inset-0 bg-black/50 animate-fade-in" />
|
|
803
911
|
|
|
804
|
-
{/*
|
|
805
|
-
<div
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
{placeholder}
|
|
814
|
-
</h2>
|
|
815
|
-
)}
|
|
816
|
-
<button
|
|
817
|
-
onClick={handleClose}
|
|
818
|
-
className="text-ink-400 hover:text-ink-600 transition-colors p-2 -mr-2"
|
|
819
|
-
aria-label="Close"
|
|
820
|
-
>
|
|
821
|
-
<X className="h-5 w-5" />
|
|
822
|
-
</button>
|
|
823
|
-
</div>
|
|
912
|
+
{/* Sheet */}
|
|
913
|
+
<div
|
|
914
|
+
className="relative w-full bg-white rounded-t-2xl shadow-2xl animate-slide-up max-h-[85vh] flex flex-col"
|
|
915
|
+
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
|
916
|
+
>
|
|
917
|
+
{/* Handle */}
|
|
918
|
+
<div className="py-3 cursor-grab">
|
|
919
|
+
<div className="w-12 h-1.5 bg-ink-300 rounded-full mx-auto" />
|
|
920
|
+
</div>
|
|
824
921
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
922
|
+
{/* Header */}
|
|
923
|
+
<div className="px-4 pb-3 border-b border-paper-200 flex items-center justify-between">
|
|
924
|
+
{label && (
|
|
925
|
+
<h2
|
|
926
|
+
id={`mobile-${labelId}`}
|
|
927
|
+
className="text-lg font-semibold text-ink-900"
|
|
928
|
+
>
|
|
929
|
+
{label}
|
|
930
|
+
</h2>
|
|
931
|
+
)}
|
|
932
|
+
{!label && (
|
|
933
|
+
<h2 className="text-lg font-semibold text-ink-900">
|
|
934
|
+
{placeholder}
|
|
935
|
+
</h2>
|
|
936
|
+
)}
|
|
937
|
+
<button
|
|
938
|
+
onClick={handleClose}
|
|
939
|
+
className="text-ink-400 hover:text-ink-600 transition-colors p-2 -mr-2"
|
|
940
|
+
aria-label="Close"
|
|
941
|
+
>
|
|
942
|
+
<X className="h-5 w-5" />
|
|
943
|
+
</button>
|
|
843
944
|
</div>
|
|
844
|
-
)}
|
|
845
945
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
946
|
+
{/* Search Input (Mobile) */}
|
|
947
|
+
{searchable && (
|
|
948
|
+
<div className="p-3 border-b border-paper-200">
|
|
949
|
+
<div className="relative">
|
|
950
|
+
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-400" />
|
|
951
|
+
<input
|
|
952
|
+
ref={mobileSearchInputRef}
|
|
953
|
+
type="text"
|
|
954
|
+
value={searchQuery}
|
|
955
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
956
|
+
placeholder="Search..."
|
|
957
|
+
inputMode="search"
|
|
958
|
+
enterKeyHint="search"
|
|
959
|
+
className="w-full pl-12 pr-4 py-3 text-base border border-paper-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400"
|
|
960
|
+
role="searchbox"
|
|
961
|
+
aria-label="Search options"
|
|
962
|
+
/>
|
|
963
|
+
</div>
|
|
964
|
+
</div>
|
|
965
|
+
)}
|
|
966
|
+
|
|
967
|
+
{/* Options List (Mobile) */}
|
|
968
|
+
<div
|
|
969
|
+
id={listboxId}
|
|
970
|
+
className="overflow-y-auto flex-1"
|
|
971
|
+
role="listbox"
|
|
972
|
+
aria-label="Available options"
|
|
973
|
+
aria-multiselectable="false"
|
|
974
|
+
>
|
|
975
|
+
{renderOptionsContent(true)}
|
|
976
|
+
</div>
|
|
855
977
|
</div>
|
|
856
|
-
</div
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
)}
|
|
978
|
+
</div>,
|
|
979
|
+
document.body,
|
|
980
|
+
)}
|
|
860
981
|
|
|
861
982
|
{/* Helper Text or Error */}
|
|
862
983
|
{error && (
|
|
863
|
-
<p
|
|
984
|
+
<p
|
|
985
|
+
id={errorId}
|
|
986
|
+
className="mt-2 text-xs text-error-600"
|
|
987
|
+
role="alert"
|
|
988
|
+
aria-live="assertive"
|
|
989
|
+
>
|
|
864
990
|
{error}
|
|
865
991
|
</p>
|
|
866
992
|
)}
|
|
@@ -873,5 +999,5 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
873
999
|
);
|
|
874
1000
|
});
|
|
875
1001
|
|
|
876
|
-
Select.displayName =
|
|
1002
|
+
Select.displayName = "Select";
|
|
877
1003
|
export default Select;
|