@ecohouse/ui 0.1.52 → 0.1.54
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/index.cjs +196 -103
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +196 -103
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/DatePicker/DatePicker.tsx +104 -15
- package/src/components/Select/Select.tsx +163 -105
package/package.json
CHANGED
|
@@ -53,6 +53,21 @@ const DEFAULT_MONTH_NAMES = [
|
|
|
53
53
|
/** Monday-first — override via `weekdayLabels` for other locales. */
|
|
54
54
|
const DEFAULT_WEEKDAY_LABELS = ["ԵՐ", "ԵՔ", "ՉՐ", "ՀՆ", "ՈՒՐ", "ՇԲ", "ԿՐ"];
|
|
55
55
|
|
|
56
|
+
function rangeIncludesUnavailable(
|
|
57
|
+
start: Date,
|
|
58
|
+
end: Date,
|
|
59
|
+
isUnavailable: (day: Date) => boolean,
|
|
60
|
+
): boolean {
|
|
61
|
+
const cursor = startOfDay(start);
|
|
62
|
+
const last = startOfDay(end);
|
|
63
|
+
const step = isBefore(last, cursor) ? -1 : 1;
|
|
64
|
+
while (true) {
|
|
65
|
+
if (isUnavailable(cursor)) return true;
|
|
66
|
+
if (isSameDay(cursor, last)) return false;
|
|
67
|
+
cursor.setDate(cursor.getDate() + step);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
export type DateRange = { start: Date | null; end: Date | null };
|
|
57
72
|
export type DatePickerVariant = "default" | "range-horizontal" | "range-vertical";
|
|
58
73
|
|
|
@@ -82,6 +97,15 @@ type DatePickerBaseProps = Omit<ViewProps, "style" | "children"> & {
|
|
|
82
97
|
closeOnSelect?: boolean;
|
|
83
98
|
minDate?: Date;
|
|
84
99
|
maxDate?: Date;
|
|
100
|
+
/** Calendar days that cannot be selected, such as dates already booked. */
|
|
101
|
+
unavailableDates?: readonly Date[];
|
|
102
|
+
/**
|
|
103
|
+
* When true, unavailable days cannot be selected and a range cannot include them.
|
|
104
|
+
* Defaults to false so booked days can stay visual-only.
|
|
105
|
+
*/
|
|
106
|
+
blockUnavailableDates?: boolean;
|
|
107
|
+
/** Accessibility label for an unavailable day. */
|
|
108
|
+
unavailableDateLabel?: string;
|
|
85
109
|
/** First visible month when the calendar opens; defaults to the selected value's month, else today. */
|
|
86
110
|
defaultVisibleMonth?: Date;
|
|
87
111
|
/** Monday-first full month names. Defaults to Armenian. */
|
|
@@ -183,6 +207,9 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
183
207
|
closeOnSelect = true,
|
|
184
208
|
minDate,
|
|
185
209
|
maxDate,
|
|
210
|
+
unavailableDates,
|
|
211
|
+
unavailableDateLabel = "Booked",
|
|
212
|
+
blockUnavailableDates = false,
|
|
186
213
|
defaultVisibleMonth,
|
|
187
214
|
monthNames = DEFAULT_MONTH_NAMES,
|
|
188
215
|
weekdayLabels = DEFAULT_WEEKDAY_LABELS,
|
|
@@ -202,6 +229,19 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
202
229
|
...rest
|
|
203
230
|
} = props;
|
|
204
231
|
|
|
232
|
+
const unavailableDayKeys = useMemo(() => {
|
|
233
|
+
const keys = new Set<number>();
|
|
234
|
+
for (const date of unavailableDates ?? []) {
|
|
235
|
+
keys.add(startOfDay(date).getTime());
|
|
236
|
+
}
|
|
237
|
+
return keys;
|
|
238
|
+
}, [unavailableDates]);
|
|
239
|
+
|
|
240
|
+
const isUnavailable = useCallback(
|
|
241
|
+
(day: Date) => unavailableDayKeys.has(startOfDay(day).getTime()),
|
|
242
|
+
[unavailableDayKeys],
|
|
243
|
+
);
|
|
244
|
+
|
|
205
245
|
const isRange = props.range === true;
|
|
206
246
|
const usesRangeTrigger = isRange && variant !== "default";
|
|
207
247
|
const formatValue =
|
|
@@ -343,6 +383,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
343
383
|
const handleDayPress = useCallback(
|
|
344
384
|
(day: Date) => {
|
|
345
385
|
if (disabled || isDateDisabled(day, minDate, maxDate)) return;
|
|
386
|
+
if (blockUnavailableDates && isUnavailable(day)) return;
|
|
346
387
|
const picked = startOfDay(day);
|
|
347
388
|
|
|
348
389
|
if (!isRange) {
|
|
@@ -360,14 +401,25 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
360
401
|
? { start: picked, end: null }
|
|
361
402
|
: { start, end: picked };
|
|
362
403
|
|
|
404
|
+
if (
|
|
405
|
+
blockUnavailableDates &&
|
|
406
|
+
nextRange.start &&
|
|
407
|
+
nextRange.end &&
|
|
408
|
+
rangeIncludesUnavailable(nextRange.start, nextRange.end, isUnavailable)
|
|
409
|
+
) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
363
413
|
if (!isValueControlled) setUncontrolledRange(nextRange);
|
|
364
414
|
(props as DatePickerRangeProps).onValueChange?.(nextRange);
|
|
365
415
|
if (closeOnSelect && nextRange.start && nextRange.end) setOpen(false);
|
|
366
416
|
},
|
|
367
417
|
[
|
|
418
|
+
blockUnavailableDates,
|
|
368
419
|
closeOnSelect,
|
|
369
420
|
disabled,
|
|
370
421
|
isRange,
|
|
422
|
+
isUnavailable,
|
|
371
423
|
isValueControlled,
|
|
372
424
|
maxDate,
|
|
373
425
|
minDate,
|
|
@@ -483,6 +535,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
483
535
|
? styles.menuAlignEnd
|
|
484
536
|
: styles.menuAlignStart,
|
|
485
537
|
calendarStyle,
|
|
538
|
+
styles.menuFitContent,
|
|
486
539
|
]}
|
|
487
540
|
>
|
|
488
541
|
<View style={styles.calendarHeader}>
|
|
@@ -513,7 +566,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
513
566
|
</Pressable>
|
|
514
567
|
</View>
|
|
515
568
|
|
|
516
|
-
<View>
|
|
569
|
+
<View style={styles.calendarGrid}>
|
|
517
570
|
<View style={styles.weekdayRow}>
|
|
518
571
|
{weekdayLabels.map((weekdayLabel, index) => (
|
|
519
572
|
<View key={`${weekdayLabel}-${index}`} style={styles.weekdayCell}>
|
|
@@ -582,10 +635,15 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
582
635
|
const isRangeStart = isRange && isSameDay(day, rangeValue.start);
|
|
583
636
|
const isRangeEnd = isRange && isSameDay(day, rangeValue.end);
|
|
584
637
|
const isSelectedEndpoint = isSelectedSingle || isRangeStart || isRangeEnd;
|
|
638
|
+
const dayUnavailable = isUnavailable(day);
|
|
639
|
+
const dayBlocked = blockUnavailableDates && dayUnavailable;
|
|
585
640
|
const dayDisabled = disabled || isDateDisabled(day, minDate, maxDate);
|
|
586
641
|
const dayKey = startOfDay(day).getTime();
|
|
587
642
|
const isHovered =
|
|
588
|
-
!dayDisabled &&
|
|
643
|
+
!dayDisabled &&
|
|
644
|
+
!dayBlocked &&
|
|
645
|
+
!isSelectedEndpoint &&
|
|
646
|
+
hoveredDayKey === dayKey;
|
|
589
647
|
|
|
590
648
|
const dayTextColor = isSelectedEndpoint
|
|
591
649
|
? colors.white
|
|
@@ -601,7 +659,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
601
659
|
<Pressable
|
|
602
660
|
key={dayIndex}
|
|
603
661
|
onPress={() => handleDayPress(day)}
|
|
604
|
-
disabled={dayDisabled}
|
|
662
|
+
disabled={dayDisabled || dayBlocked}
|
|
605
663
|
onHoverIn={() => setHoveredDayKey(dayKey)}
|
|
606
664
|
onHoverOut={() =>
|
|
607
665
|
setHoveredDayKey((current) => (current === dayKey ? null : current))
|
|
@@ -612,9 +670,13 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
612
670
|
}
|
|
613
671
|
style={styles.dayCellWrap}
|
|
614
672
|
accessibilityRole="button"
|
|
615
|
-
accessibilityLabel={
|
|
673
|
+
accessibilityLabel={
|
|
674
|
+
dayUnavailable
|
|
675
|
+
? `${formatValue(day)}, ${unavailableDateLabel}`
|
|
676
|
+
: formatValue(day)
|
|
677
|
+
}
|
|
616
678
|
accessibilityState={{
|
|
617
|
-
disabled: dayDisabled,
|
|
679
|
+
disabled: dayDisabled || dayBlocked,
|
|
618
680
|
selected: isSelectedEndpoint,
|
|
619
681
|
}}
|
|
620
682
|
>
|
|
@@ -622,10 +684,17 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
|
|
|
622
684
|
style={[
|
|
623
685
|
styles.dayCircle,
|
|
624
686
|
isSelectedEndpoint && styles.dayCircleSelected,
|
|
687
|
+
dayUnavailable && styles.dayCircleBooked,
|
|
625
688
|
isHovered && styles.dayCircleHovered,
|
|
626
689
|
]}
|
|
627
690
|
>
|
|
628
|
-
<Text
|
|
691
|
+
<Text
|
|
692
|
+
style={[
|
|
693
|
+
styles.dayText,
|
|
694
|
+
{ color: dayTextColor },
|
|
695
|
+
dayUnavailable && styles.dayTextBooked,
|
|
696
|
+
]}
|
|
697
|
+
>
|
|
629
698
|
{day.getDate()}
|
|
630
699
|
</Text>
|
|
631
700
|
</View>
|
|
@@ -762,7 +831,7 @@ const styles = StyleSheet.create({
|
|
|
762
831
|
menu: {
|
|
763
832
|
position: "absolute",
|
|
764
833
|
top: "100%",
|
|
765
|
-
|
|
834
|
+
alignSelf: "flex-start",
|
|
766
835
|
height: DEFAULT_MENU_HEIGHT,
|
|
767
836
|
marginTop: 8,
|
|
768
837
|
zIndex: 10,
|
|
@@ -796,10 +865,26 @@ const styles = StyleSheet.create({
|
|
|
796
865
|
right: "auto",
|
|
797
866
|
marginTop: 0,
|
|
798
867
|
},
|
|
799
|
-
rangeMenu: {
|
|
800
|
-
|
|
868
|
+
rangeMenu: {},
|
|
869
|
+
menuFitContent: {
|
|
870
|
+
width: MENU_CONTENT_WIDTH + MENU_PADDING * 2,
|
|
871
|
+
maxWidth: MENU_CONTENT_WIDTH + MENU_PADDING * 2,
|
|
872
|
+
alignSelf: "flex-start",
|
|
873
|
+
alignItems: "flex-start",
|
|
874
|
+
...(Platform.select({
|
|
875
|
+
web: {
|
|
876
|
+
width: "fit-content",
|
|
877
|
+
maxWidth: "fit-content",
|
|
878
|
+
},
|
|
879
|
+
default: {},
|
|
880
|
+
}) as ViewStyle),
|
|
881
|
+
},
|
|
882
|
+
calendarGrid: {
|
|
883
|
+
width: MENU_CONTENT_WIDTH,
|
|
884
|
+
alignSelf: "flex-start",
|
|
801
885
|
},
|
|
802
886
|
calendarHeader: {
|
|
887
|
+
width: MENU_CONTENT_WIDTH,
|
|
803
888
|
height: HEADER_HEIGHT,
|
|
804
889
|
paddingTop: 8,
|
|
805
890
|
paddingBottom: 8,
|
|
@@ -833,9 +918,8 @@ const styles = StyleSheet.create({
|
|
|
833
918
|
weekdayCell: {
|
|
834
919
|
width: DAY_CELL_WIDTH,
|
|
835
920
|
height: WEEKDAY_ROW_HEIGHT,
|
|
836
|
-
flexGrow:
|
|
837
|
-
flexShrink:
|
|
838
|
-
flexBasis: 0,
|
|
921
|
+
flexGrow: 0,
|
|
922
|
+
flexShrink: 0,
|
|
839
923
|
alignItems: "center",
|
|
840
924
|
justifyContent: "center",
|
|
841
925
|
},
|
|
@@ -863,9 +947,8 @@ const styles = StyleSheet.create({
|
|
|
863
947
|
dayCellWrap: {
|
|
864
948
|
width: DAY_CELL_WIDTH,
|
|
865
949
|
height: DAY_CELL_HEIGHT,
|
|
866
|
-
flexGrow:
|
|
867
|
-
flexShrink:
|
|
868
|
-
flexBasis: 0,
|
|
950
|
+
flexGrow: 0,
|
|
951
|
+
flexShrink: 0,
|
|
869
952
|
alignItems: "center",
|
|
870
953
|
justifyContent: "center",
|
|
871
954
|
opacity: 1,
|
|
@@ -882,6 +965,12 @@ const styles = StyleSheet.create({
|
|
|
882
965
|
dayCircleSelected: {
|
|
883
966
|
backgroundColor: colors.primary,
|
|
884
967
|
},
|
|
968
|
+
dayCircleBooked: {
|
|
969
|
+
backgroundColor: colors.grey700,
|
|
970
|
+
},
|
|
971
|
+
dayTextBooked: {
|
|
972
|
+
textDecorationLine: "line-through",
|
|
973
|
+
},
|
|
885
974
|
dayCircleHovered: {
|
|
886
975
|
backgroundColor: colors.grey600,
|
|
887
976
|
},
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
9
|
type ComponentRef,
|
|
10
|
+
type ReactNode,
|
|
10
11
|
} from "react";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
11
13
|
import {
|
|
12
14
|
Platform,
|
|
13
15
|
Pressable,
|
|
@@ -95,6 +97,17 @@ const ITEM_RADIUS = 12;
|
|
|
95
97
|
const ICON_SIZE = 20;
|
|
96
98
|
const CHIP_GAP = 6;
|
|
97
99
|
const FALLBACK_ELLIPSIS_WIDTH = 20;
|
|
100
|
+
/** Above Modal (500) so portaled menus stay clickable inside dialogs. */
|
|
101
|
+
const WEB_MENU_Z_INDEX = 600;
|
|
102
|
+
const WEB_VIEWPORT_PADDING = 8;
|
|
103
|
+
const WEB_MENU_GAP = 8;
|
|
104
|
+
|
|
105
|
+
type WebMenuPosition = {
|
|
106
|
+
top: number;
|
|
107
|
+
left: number;
|
|
108
|
+
width: number;
|
|
109
|
+
maxHeight: number;
|
|
110
|
+
};
|
|
98
111
|
|
|
99
112
|
type ItemVisualState = "default" | "hover" | "selected";
|
|
100
113
|
|
|
@@ -287,6 +300,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
287
300
|
const valueProp = props.value;
|
|
288
301
|
const rootRef = useRef<ComponentRef<typeof View>>(null);
|
|
289
302
|
const triggerRef = useRef<ComponentRef<typeof Pressable>>(null);
|
|
303
|
+
const menuRef = useRef<ComponentRef<typeof View>>(null);
|
|
290
304
|
const searchInputRef = useRef<ComponentRef<typeof TextInput>>(null);
|
|
291
305
|
const setRootRef = useMemo(() => mergeRefs(rootRef, forwardedRef), [forwardedRef]);
|
|
292
306
|
const isOpenControlled = typeof openProp === "boolean";
|
|
@@ -303,8 +317,10 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
303
317
|
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
|
|
304
318
|
/** Web: flip menu to the trigger's right edge when it would overflow the viewport. */
|
|
305
319
|
const [menuAlignEnd, setMenuAlignEnd] = useState(false);
|
|
306
|
-
/**
|
|
320
|
+
/** Native: open above the trigger when there isn't enough space below. */
|
|
307
321
|
const [menuPlaceAbove, setMenuPlaceAbove] = useState(false);
|
|
322
|
+
/** Web: fixed coords for the portaled menu (avoids expanding overflow parents). */
|
|
323
|
+
const [webMenuPosition, setWebMenuPosition] = useState<WebMenuPosition | null>(null);
|
|
308
324
|
|
|
309
325
|
const selectedValues = useMemo(() => {
|
|
310
326
|
const current = isValueControlled
|
|
@@ -379,6 +395,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
379
395
|
setHoveredValue(null);
|
|
380
396
|
setMenuAlignEnd(false);
|
|
381
397
|
setMenuPlaceAbove(false);
|
|
398
|
+
setWebMenuPosition(null);
|
|
382
399
|
// Let the parent reset its externally fetched options.
|
|
383
400
|
onSearchChange?.("");
|
|
384
401
|
}
|
|
@@ -387,6 +404,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
387
404
|
);
|
|
388
405
|
|
|
389
406
|
// Prefer start (left) / below; flip when the menu would overflow the viewport (web).
|
|
407
|
+
// On web the menu is portaled with position:fixed so overflow parents don't grow/clip it.
|
|
390
408
|
useLayoutEffect(() => {
|
|
391
409
|
if (Platform.OS !== "web" || !isOpen) return;
|
|
392
410
|
|
|
@@ -397,16 +415,26 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
397
415
|
const rect = trigger?.getBoundingClientRect?.();
|
|
398
416
|
if (!rect) return;
|
|
399
417
|
|
|
400
|
-
const viewportPadding = 8;
|
|
401
|
-
const gap = 8;
|
|
402
418
|
const menuWidth = Math.max(rect.width, resolvedMenuWidth ?? 0);
|
|
403
|
-
const overflowsRight = rect.left + menuWidth > window.innerWidth -
|
|
419
|
+
const overflowsRight = rect.left + menuWidth > window.innerWidth - WEB_VIEWPORT_PADDING;
|
|
404
420
|
setMenuAlignEnd(overflowsRight);
|
|
405
421
|
|
|
406
|
-
const spaceBelow = window.innerHeight -
|
|
407
|
-
const spaceAbove = rect.top -
|
|
408
|
-
const needsAbove =
|
|
422
|
+
const spaceBelow = window.innerHeight - WEB_VIEWPORT_PADDING - rect.bottom;
|
|
423
|
+
const spaceAbove = rect.top - WEB_VIEWPORT_PADDING;
|
|
424
|
+
const needsAbove =
|
|
425
|
+
estimatedMenuHeight + WEB_MENU_GAP > spaceBelow && spaceAbove > spaceBelow;
|
|
409
426
|
setMenuPlaceAbove(needsAbove);
|
|
427
|
+
|
|
428
|
+
const available = Math.max(0, (needsAbove ? spaceAbove : spaceBelow) - WEB_MENU_GAP);
|
|
429
|
+
const maxHeight = Math.min(estimatedMenuHeight, Math.max(available, TRIGGER_HEIGHT));
|
|
430
|
+
const left = overflowsRight
|
|
431
|
+
? Math.max(WEB_VIEWPORT_PADDING, rect.right - menuWidth)
|
|
432
|
+
: Math.min(rect.left, window.innerWidth - WEB_VIEWPORT_PADDING - menuWidth);
|
|
433
|
+
const top = needsAbove
|
|
434
|
+
? Math.max(WEB_VIEWPORT_PADDING, rect.top - WEB_MENU_GAP - maxHeight)
|
|
435
|
+
: rect.bottom + WEB_MENU_GAP;
|
|
436
|
+
|
|
437
|
+
setWebMenuPosition({ top, left, width: menuWidth, maxHeight });
|
|
410
438
|
};
|
|
411
439
|
|
|
412
440
|
updateMenuPlacement();
|
|
@@ -428,11 +456,12 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
428
456
|
if (Platform.OS !== "web" || !isOpen) return;
|
|
429
457
|
|
|
430
458
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
|
431
|
-
const root = rootRef.current as unknown as { contains?: (node: Node) => boolean } | null;
|
|
432
459
|
const target = event.target;
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
}
|
|
460
|
+
if (!(target instanceof Node)) return;
|
|
461
|
+
const root = rootRef.current as unknown as { contains?: (node: Node) => boolean } | null;
|
|
462
|
+
const menu = menuRef.current as unknown as { contains?: (node: Node) => boolean } | null;
|
|
463
|
+
if (root?.contains?.(target) || menu?.contains?.(target)) return;
|
|
464
|
+
setOpen(false);
|
|
436
465
|
};
|
|
437
466
|
|
|
438
467
|
document.addEventListener("mousedown", handlePointerDown);
|
|
@@ -581,7 +610,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
581
610
|
|
|
582
611
|
const leftIconNode = renderStatefulIcon(leftIcon, UserIcon, {
|
|
583
612
|
size: ICON_SIZE,
|
|
584
|
-
color: error ? colors.red : colors.primary,
|
|
613
|
+
color: error ? colors.red : isOpen ? colors.primary : colors.grey100,
|
|
585
614
|
});
|
|
586
615
|
const hasLeftIcon = showLeftIcon || leftIcon != null;
|
|
587
616
|
|
|
@@ -604,6 +633,127 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
604
633
|
const resolvedAccessibilityLabel =
|
|
605
634
|
accessibilityLabel ?? (typeof label === "string" ? label : undefined);
|
|
606
635
|
|
|
636
|
+
const optionListMaxHeight =
|
|
637
|
+
Platform.OS === "web" && webMenuPosition != null
|
|
638
|
+
? Math.max(43, webMenuPosition.maxHeight - (24 + (showSearch ? 48 : 0)))
|
|
639
|
+
: resolvedOptionListMaxHeight;
|
|
640
|
+
|
|
641
|
+
const menuNode: ReactNode = isOpen ? (
|
|
642
|
+
<View
|
|
643
|
+
ref={menuRef}
|
|
644
|
+
style={[
|
|
645
|
+
styles.menu,
|
|
646
|
+
Platform.OS === "web" && webMenuPosition != null
|
|
647
|
+
? ({
|
|
648
|
+
position: "fixed",
|
|
649
|
+
top: webMenuPosition.top,
|
|
650
|
+
left: webMenuPosition.left,
|
|
651
|
+
width: webMenuPosition.width,
|
|
652
|
+
minWidth: webMenuPosition.width,
|
|
653
|
+
maxHeight: webMenuPosition.maxHeight,
|
|
654
|
+
zIndex: WEB_MENU_Z_INDEX,
|
|
655
|
+
marginTop: 0,
|
|
656
|
+
marginBottom: 0,
|
|
657
|
+
} as unknown as ViewStyle)
|
|
658
|
+
: Platform.OS === "web"
|
|
659
|
+
? ({ position: "fixed", opacity: 0, pointerEvents: "none" } as unknown as ViewStyle)
|
|
660
|
+
: [
|
|
661
|
+
menuAlignEnd ? styles.menuAlignEnd : styles.menuAlignStart,
|
|
662
|
+
menuPlaceAbove ? styles.menuAbove : styles.menuBelow,
|
|
663
|
+
],
|
|
664
|
+
menuStyle,
|
|
665
|
+
]}
|
|
666
|
+
>
|
|
667
|
+
{showSearch ? (
|
|
668
|
+
<View style={styles.searchField}>
|
|
669
|
+
<View style={styles.iconSlot}>
|
|
670
|
+
<SearchIcon size={ICON_SIZE} color={colors.grey100} />
|
|
671
|
+
</View>
|
|
672
|
+
<TextInput
|
|
673
|
+
ref={searchInputRef}
|
|
674
|
+
value={searchQuery}
|
|
675
|
+
onChangeText={handleSearchChange}
|
|
676
|
+
editable={!disabled}
|
|
677
|
+
placeholder={searchPlaceholder}
|
|
678
|
+
placeholderTextColor={colors.grey100}
|
|
679
|
+
style={[
|
|
680
|
+
styles.searchInput,
|
|
681
|
+
Platform.OS === "web" ? styles.searchInputWeb : null,
|
|
682
|
+
Platform.OS === "android" ? styles.searchInputAndroid : null,
|
|
683
|
+
]}
|
|
684
|
+
accessibilityLabel={searchPlaceholder}
|
|
685
|
+
/>
|
|
686
|
+
</View>
|
|
687
|
+
) : null}
|
|
688
|
+
|
|
689
|
+
<ScrollView
|
|
690
|
+
style={[styles.optionList, { maxHeight: optionListMaxHeight }]}
|
|
691
|
+
contentContainerStyle={styles.optionListContent}
|
|
692
|
+
keyboardShouldPersistTaps="handled"
|
|
693
|
+
showsVerticalScrollIndicator={false}
|
|
694
|
+
>
|
|
695
|
+
{loading ? (
|
|
696
|
+
<View style={styles.statusRow}>
|
|
697
|
+
<Text style={styles.statusText}>Loading...</Text>
|
|
698
|
+
</View>
|
|
699
|
+
) : filteredOptions.length === 0 ? (
|
|
700
|
+
<View style={styles.statusRow}>
|
|
701
|
+
<Text style={styles.statusText}>{emptyText}</Text>
|
|
702
|
+
</View>
|
|
703
|
+
) : null}
|
|
704
|
+
|
|
705
|
+
{!loading &&
|
|
706
|
+
filteredOptions.map((option) => {
|
|
707
|
+
const isSelected = selectedValues.includes(option.value);
|
|
708
|
+
const isHovered = hoveredValue === option.value;
|
|
709
|
+
const visualState: ItemVisualState = isSelected
|
|
710
|
+
? "selected"
|
|
711
|
+
: isHovered
|
|
712
|
+
? "hover"
|
|
713
|
+
: "default";
|
|
714
|
+
|
|
715
|
+
return (
|
|
716
|
+
<Pressable
|
|
717
|
+
key={option.value}
|
|
718
|
+
onPress={() => handleSelect(option.value)}
|
|
719
|
+
disabled={disabled}
|
|
720
|
+
onHoverIn={() => setHoveredValue(option.value)}
|
|
721
|
+
onHoverOut={() =>
|
|
722
|
+
setHoveredValue((current) => (current === option.value ? null : current))
|
|
723
|
+
}
|
|
724
|
+
onPressIn={() => setHoveredValue(option.value)}
|
|
725
|
+
onPressOut={() =>
|
|
726
|
+
setHoveredValue((current) => (current === option.value ? null : current))
|
|
727
|
+
}
|
|
728
|
+
accessibilityRole={multiple ? "checkbox" : "button"}
|
|
729
|
+
accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}
|
|
730
|
+
style={[
|
|
731
|
+
styles.item,
|
|
732
|
+
{
|
|
733
|
+
backgroundColor: itemBackground(visualState),
|
|
734
|
+
},
|
|
735
|
+
]}
|
|
736
|
+
>
|
|
737
|
+
{multiple ? (
|
|
738
|
+
<View style={[styles.itemCheckbox, { pointerEvents: "none" }]}>
|
|
739
|
+
<Checkbox value={isSelected} variant="light" />
|
|
740
|
+
</View>
|
|
741
|
+
) : null}
|
|
742
|
+
<Text style={styles.itemLabel} numberOfLines={1}>
|
|
743
|
+
{option.label}
|
|
744
|
+
</Text>
|
|
745
|
+
</Pressable>
|
|
746
|
+
);
|
|
747
|
+
})}
|
|
748
|
+
</ScrollView>
|
|
749
|
+
</View>
|
|
750
|
+
) : null;
|
|
751
|
+
|
|
752
|
+
const webMenuPortal =
|
|
753
|
+
Platform.OS === "web" && isOpen && typeof document !== "undefined"
|
|
754
|
+
? createPortal(menuNode, document.body)
|
|
755
|
+
: null;
|
|
756
|
+
|
|
607
757
|
return (
|
|
608
758
|
<View
|
|
609
759
|
{...rest}
|
|
@@ -652,99 +802,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
652
802
|
</View>
|
|
653
803
|
</Pressable>
|
|
654
804
|
|
|
655
|
-
{
|
|
656
|
-
<View
|
|
657
|
-
style={[
|
|
658
|
-
styles.menu,
|
|
659
|
-
menuAlignEnd ? styles.menuAlignEnd : styles.menuAlignStart,
|
|
660
|
-
menuPlaceAbove ? styles.menuAbove : styles.menuBelow,
|
|
661
|
-
menuStyle,
|
|
662
|
-
]}
|
|
663
|
-
>
|
|
664
|
-
{showSearch ? (
|
|
665
|
-
<View style={styles.searchField}>
|
|
666
|
-
<View style={styles.iconSlot}>
|
|
667
|
-
<SearchIcon size={ICON_SIZE} color={colors.grey100} />
|
|
668
|
-
</View>
|
|
669
|
-
<TextInput
|
|
670
|
-
ref={searchInputRef}
|
|
671
|
-
value={searchQuery}
|
|
672
|
-
onChangeText={handleSearchChange}
|
|
673
|
-
editable={!disabled}
|
|
674
|
-
placeholder={searchPlaceholder}
|
|
675
|
-
placeholderTextColor={colors.grey100}
|
|
676
|
-
style={[
|
|
677
|
-
styles.searchInput,
|
|
678
|
-
Platform.OS === "web" ? styles.searchInputWeb : null,
|
|
679
|
-
Platform.OS === "android" ? styles.searchInputAndroid : null,
|
|
680
|
-
]}
|
|
681
|
-
accessibilityLabel={searchPlaceholder}
|
|
682
|
-
/>
|
|
683
|
-
</View>
|
|
684
|
-
) : null}
|
|
685
|
-
|
|
686
|
-
<ScrollView
|
|
687
|
-
style={[styles.optionList, { maxHeight: resolvedOptionListMaxHeight }]}
|
|
688
|
-
contentContainerStyle={styles.optionListContent}
|
|
689
|
-
keyboardShouldPersistTaps="handled"
|
|
690
|
-
showsVerticalScrollIndicator={false}
|
|
691
|
-
>
|
|
692
|
-
{loading ? (
|
|
693
|
-
<View style={styles.statusRow}>
|
|
694
|
-
<Text style={styles.statusText}>Loading...</Text>
|
|
695
|
-
</View>
|
|
696
|
-
) : filteredOptions.length === 0 ? (
|
|
697
|
-
<View style={styles.statusRow}>
|
|
698
|
-
<Text style={styles.statusText}>{emptyText}</Text>
|
|
699
|
-
</View>
|
|
700
|
-
) : null}
|
|
701
|
-
|
|
702
|
-
{!loading &&
|
|
703
|
-
filteredOptions.map((option) => {
|
|
704
|
-
const isSelected = selectedValues.includes(option.value);
|
|
705
|
-
const isHovered = hoveredValue === option.value;
|
|
706
|
-
const visualState: ItemVisualState = isSelected
|
|
707
|
-
? "selected"
|
|
708
|
-
: isHovered
|
|
709
|
-
? "hover"
|
|
710
|
-
: "default";
|
|
711
|
-
|
|
712
|
-
return (
|
|
713
|
-
<Pressable
|
|
714
|
-
key={option.value}
|
|
715
|
-
onPress={() => handleSelect(option.value)}
|
|
716
|
-
disabled={disabled}
|
|
717
|
-
onHoverIn={() => setHoveredValue(option.value)}
|
|
718
|
-
onHoverOut={() =>
|
|
719
|
-
setHoveredValue((current) => (current === option.value ? null : current))
|
|
720
|
-
}
|
|
721
|
-
onPressIn={() => setHoveredValue(option.value)}
|
|
722
|
-
onPressOut={() =>
|
|
723
|
-
setHoveredValue((current) => (current === option.value ? null : current))
|
|
724
|
-
}
|
|
725
|
-
accessibilityRole={multiple ? "checkbox" : "button"}
|
|
726
|
-
accessibilityState={{ selected: isSelected, checked: isSelected, disabled }}
|
|
727
|
-
style={[
|
|
728
|
-
styles.item,
|
|
729
|
-
{
|
|
730
|
-
backgroundColor: itemBackground(visualState),
|
|
731
|
-
},
|
|
732
|
-
]}
|
|
733
|
-
>
|
|
734
|
-
{multiple ? (
|
|
735
|
-
<View style={[styles.itemCheckbox, { pointerEvents: "none" }]}>
|
|
736
|
-
<Checkbox value={isSelected} variant="light" />
|
|
737
|
-
</View>
|
|
738
|
-
) : null}
|
|
739
|
-
<Text style={styles.itemLabel} numberOfLines={1}>
|
|
740
|
-
{option.label}
|
|
741
|
-
</Text>
|
|
742
|
-
</Pressable>
|
|
743
|
-
);
|
|
744
|
-
})}
|
|
745
|
-
</ScrollView>
|
|
746
|
-
</View>
|
|
747
|
-
) : null}
|
|
805
|
+
{Platform.OS === "web" ? webMenuPortal : menuNode}
|
|
748
806
|
</View>
|
|
749
807
|
|
|
750
808
|
{showHint && hint ? <Text style={[styles.hint, { color: hintColor }]}>{hint}</Text> : null}
|