@ecohouse/ui 0.1.32 → 0.1.34

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.
Files changed (68) hide show
  1. package/dist/index.cjs +30063 -607
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +173 -10
  4. package/dist/index.d.ts +173 -10
  5. package/dist/index.js +29989 -547
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/AccordionItem/AccordionItem.stories.tsx +54 -0
  9. package/src/components/AccordionItem/AccordionItem.tsx +220 -0
  10. package/src/components/AccordionItem/index.ts +2 -0
  11. package/src/components/AccountHeader/AccountHeader.tsx +34 -29
  12. package/src/components/Button/Button.stories.tsx +10 -1
  13. package/src/components/Button/Button.tsx +32 -3
  14. package/src/components/ContactInfoCard/ContactInfoCard.stories.tsx +30 -0
  15. package/src/components/ContactInfoCard/ContactInfoCard.tsx +141 -0
  16. package/src/components/ContactInfoCard/index.ts +2 -0
  17. package/src/components/DatePicker/DatePicker.tsx +119 -46
  18. package/src/components/Modal/Modal.stories.tsx +100 -0
  19. package/src/components/Modal/Modal.tsx +165 -0
  20. package/src/components/Modal/index.ts +2 -0
  21. package/src/components/Select/Select.tsx +5 -2
  22. package/src/components/Sidebar/Sidebar.tsx +42 -12
  23. package/src/components/StepPager/StepPager.tsx +150 -0
  24. package/src/components/StepPager/index.ts +2 -0
  25. package/src/components/ToggleRow/ToggleRow.stories.tsx +46 -0
  26. package/src/components/ToggleRow/ToggleRow.tsx +121 -0
  27. package/src/components/ToggleRow/index.ts +2 -0
  28. package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +80 -60
  29. package/src/components/index.ts +15 -0
  30. package/src/icons/Ban/BanIcon.tsx +15 -0
  31. package/src/icons/Ban/banPath.ts +2 -0
  32. package/src/icons/Ban/index.ts +1 -0
  33. package/src/icons/CameraFilled/CameraFilledIcon.tsx +15 -0
  34. package/src/icons/CameraFilled/CameraFilledIcon.web.tsx +21 -0
  35. package/src/icons/CameraFilled/cameraFilledPath.ts +2 -0
  36. package/src/icons/CameraFilled/index.ts +1 -0
  37. package/src/icons/Fullscreen/FullscreenIcon.tsx +21 -0
  38. package/src/icons/Fullscreen/fullscreenPath.ts +2 -0
  39. package/src/icons/Fullscreen/index.ts +1 -0
  40. package/src/icons/Headset/HeadsetIcon.tsx +26 -0
  41. package/src/icons/Headset/HeadsetIcon.web.tsx +25 -0
  42. package/src/icons/Headset/headsetPath.ts +6 -0
  43. package/src/icons/Headset/index.ts +1 -0
  44. package/src/icons/Hide/HideIcon.tsx +21 -0
  45. package/src/icons/Hide/HideIcon.web.tsx +27 -0
  46. package/src/icons/Hide/hidePath.ts +3 -0
  47. package/src/icons/Hide/index.ts +1 -0
  48. package/src/icons/Home/homePath.ts +1 -1
  49. package/src/icons/MenuLines/MenuLinesIcon.tsx +20 -0
  50. package/src/icons/MenuLines/MenuLinesIcon.web.tsx +26 -0
  51. package/src/icons/MenuLines/index.ts +1 -0
  52. package/src/icons/MenuLines/menuLinesPath.ts +3 -0
  53. package/src/icons/Sort/SortIcon.tsx +3 -3
  54. package/src/icons/Sort/SortIcon.web.tsx +2 -2
  55. package/src/icons/Sort/sortPath.ts +3 -2
  56. package/src/icons/VideoOff/VideoOffIcon.tsx +21 -0
  57. package/src/icons/VideoOff/VideoOffIcon.web.tsx +27 -0
  58. package/src/icons/VideoOff/index.ts +1 -0
  59. package/src/icons/VideoOff/videoOffPath.ts +2 -0
  60. package/src/icons/ZoomOut/ZoomOutIcon.tsx +21 -0
  61. package/src/icons/ZoomOut/index.ts +1 -0
  62. package/src/icons/ZoomOut/zoomOutPath.ts +2 -0
  63. package/src/icons/index.ts +8 -0
  64. package/src/icons/registry.ts +23 -0
  65. package/src/index.ts +20 -0
  66. package/src/theme/colors.test.ts +1 -1
  67. package/src/theme/typography.ts +4 -4
  68. package/src/utils/popoverAlign.ts +85 -0
@@ -0,0 +1,141 @@
1
+ import { forwardRef, useMemo, useRef, type ComponentRef } from "react";
2
+ import {
3
+ StyleSheet,
4
+ Text,
5
+ useWindowDimensions,
6
+ View,
7
+ type StyleProp,
8
+ type ViewProps,
9
+ type ViewStyle,
10
+ } from "react-native";
11
+ import type { IconComponent, IconProps } from "../../icons";
12
+ import { colors, fonts } from "../../theme";
13
+ import { mergeRefs } from "../../utils/mergeRefs";
14
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
15
+
16
+ export interface ContactInfoCardProps extends Omit<ViewProps, "style" | "children"> {
17
+ title: string;
18
+ value: string;
19
+ icon: IconComponent;
20
+ iconProps?: Omit<IconProps, "size">;
21
+ iconSize?: number;
22
+ /** Icon slot fill. Defaults to `#E38E5E0F`. */
23
+ iconSlotBackgroundColor?: string;
24
+ style?: StyleProp<ViewStyle>;
25
+ className?: string;
26
+ }
27
+
28
+ const DESKTOP_MIN_WIDTH = 1024;
29
+ const CARD_HEIGHT_MOBILE = 78;
30
+ const CARD_HEIGHT_DESKTOP = 96;
31
+ const CARD_PADDING_MOBILE = 16;
32
+ const CARD_PADDING_DESKTOP = 24;
33
+ const CARD_GAP_MOBILE = 16;
34
+ const CARD_GAP_DESKTOP = 24;
35
+ const ICON_SLOT = 48;
36
+ const ICON_SIZE = 24;
37
+ const ICON_SLOT_BG = "#E38E5E0F";
38
+ const ICON_COLOR = "#B76533";
39
+
40
+ /**
41
+ * Support contact strip — icon slot + title/value.
42
+ * Mobile: height 78, padding 16, gap 16, radius 20.
43
+ * Desktop: height 96, padding 24, gap 24, radius 20.
44
+ */
45
+ export const ContactInfoCard = forwardRef<ComponentRef<typeof View>, ContactInfoCardProps>(
46
+ function ContactInfoCard(
47
+ {
48
+ title,
49
+ value,
50
+ icon: Icon,
51
+ iconProps,
52
+ iconSize = ICON_SIZE,
53
+ iconSlotBackgroundColor = ICON_SLOT_BG,
54
+ style,
55
+ className,
56
+ accessibilityLabel,
57
+ ...rest
58
+ },
59
+ forwardedRef,
60
+ ) {
61
+ const containerRef = useRef<ComponentRef<typeof View>>(null);
62
+ const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
63
+ const { width: viewportWidth } = useWindowDimensions();
64
+ const isDesktop = viewportWidth >= DESKTOP_MIN_WIDTH;
65
+
66
+ useApplyWebClassName(containerRef, className);
67
+
68
+ return (
69
+ <View
70
+ {...rest}
71
+ ref={setContainerRef}
72
+ accessibilityRole="text"
73
+ accessibilityLabel={accessibilityLabel ?? `${title}. ${value}`}
74
+ style={[
75
+ styles.root,
76
+ {
77
+ height: isDesktop ? CARD_HEIGHT_DESKTOP : CARD_HEIGHT_MOBILE,
78
+ gap: isDesktop ? CARD_GAP_DESKTOP : CARD_GAP_MOBILE,
79
+ padding: isDesktop ? CARD_PADDING_DESKTOP : CARD_PADDING_MOBILE,
80
+ },
81
+ style,
82
+ ]}
83
+ >
84
+ <View style={[styles.iconSlot, { backgroundColor: iconSlotBackgroundColor }]}>
85
+ <Icon
86
+ {...iconProps}
87
+ size={iconSize}
88
+ color={iconProps?.color ?? ICON_COLOR}
89
+ strokeWidth={iconProps?.strokeWidth ?? 2}
90
+ />
91
+ </View>
92
+ <View style={styles.textBlock}>
93
+ <Text style={styles.title}>{title}</Text>
94
+ <Text style={styles.value}>{value}</Text>
95
+ </View>
96
+ </View>
97
+ );
98
+ },
99
+ );
100
+
101
+ const styles = StyleSheet.create({
102
+ root: {
103
+ width: "100%",
104
+ flexDirection: "row",
105
+ alignItems: "center",
106
+ borderRadius: 20,
107
+ backgroundColor: colors.grey750,
108
+ opacity: 1,
109
+ },
110
+ iconSlot: {
111
+ width: ICON_SLOT,
112
+ height: ICON_SLOT,
113
+ padding: 12,
114
+ borderRadius: 12,
115
+ alignItems: "center",
116
+ justifyContent: "center",
117
+ flexShrink: 0,
118
+ },
119
+ textBlock: {
120
+ flexShrink: 1,
121
+ minWidth: 0,
122
+ flexDirection: "column",
123
+ gap: 8,
124
+ },
125
+ title: {
126
+ fontFamily: fonts.sans,
127
+ fontSize: 14,
128
+ fontWeight: "600",
129
+ lineHeight: 14,
130
+ letterSpacing: 0.42,
131
+ color: colors.white,
132
+ },
133
+ value: {
134
+ fontFamily: fonts.sans,
135
+ fontSize: 14,
136
+ fontWeight: "400",
137
+ lineHeight: 14,
138
+ letterSpacing: 0.42,
139
+ color: colors.lightGrey,
140
+ },
141
+ });
@@ -0,0 +1,2 @@
1
+ export { ContactInfoCard } from "./ContactInfoCard";
2
+ export type { ContactInfoCardProps } from "./ContactInfoCard";
@@ -2,6 +2,7 @@ import {
2
2
  forwardRef,
3
3
  useCallback,
4
4
  useEffect,
5
+ useLayoutEffect,
5
6
  useMemo,
6
7
  useRef,
7
8
  useState,
@@ -93,6 +94,11 @@ type DatePickerBaseProps = Omit<ViewProps, "style" | "children"> & {
93
94
  weekStartsOn?: 0 | 1;
94
95
  /** Styles the calendar popup independently from the trigger width. */
95
96
  calendarStyle?: StyleProp<ViewStyle>;
97
+ /**
98
+ * When true, only the calendar panel is rendered (no field/trigger).
99
+ * Use with controlled `open` for icon-button / popover hosts.
100
+ */
101
+ hideTrigger?: boolean;
96
102
  style?: StyleProp<ViewStyle>;
97
103
  className?: string;
98
104
  };
@@ -113,7 +119,8 @@ export type DatePickerRangeProps = DatePickerBaseProps & {
113
119
 
114
120
  export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
115
121
 
116
- const DEFAULT_WIDTH = 380;
122
+ const DEFAULT_WIDTH = 320;
123
+ const DEFAULT_MENU_HEIGHT = 388;
117
124
  const RANGE_TRIGGER_WIDTH = 382;
118
125
  const TRIGGER_HEIGHT = 44;
119
126
  const TRIGGER_RADIUS = 60;
@@ -174,6 +181,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
174
181
  formatValue: formatValueProp,
175
182
  weekStartsOn = 1,
176
183
  calendarStyle,
184
+ hideTrigger = false,
177
185
  style,
178
186
  className,
179
187
  accessibilityLabel,
@@ -207,6 +215,8 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
207
215
  );
208
216
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
209
217
  const [hoveredDayKey, setHoveredDayKey] = useState<number | null>(null);
218
+ /** Web: flip calendar to the trigger's right edge when it would overflow the viewport. */
219
+ const [menuAlignEnd, setMenuAlignEnd] = useState(false);
210
220
 
211
221
  const rangeValue: DateRange = isRange
212
222
  ? isValueControlled
@@ -221,6 +231,13 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
221
231
 
222
232
  const isOpen = isOpenControlled ? openProp : uncontrolledOpen;
223
233
 
234
+ const defaultMenuWidth = usesRangeTrigger ? RANGE_TRIGGER_WIDTH : DEFAULT_WIDTH;
235
+ const resolvedMenuWidth = useMemo(() => {
236
+ const flat = StyleSheet.flatten(calendarStyle);
237
+ const width = flat?.width;
238
+ return typeof width === "number" ? width : defaultMenuWidth;
239
+ }, [calendarStyle, defaultMenuWidth]);
240
+
224
241
  const [visibleMonth, setVisibleMonth] = useState(() => {
225
242
  const anchor =
226
243
  defaultVisibleMonth ?? (isRange ? rangeValue.start : singleValue) ?? new Date();
@@ -238,11 +255,33 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
238
255
  setVisibleMonth(new Date(anchor.getFullYear(), anchor.getMonth(), 1));
239
256
  } else {
240
257
  setHoveredDayKey(null);
258
+ setMenuAlignEnd(false);
241
259
  }
242
260
  },
243
261
  [isOpenControlled, isRange, onOpenChange, rangeValue.start, singleValue],
244
262
  );
245
263
 
264
+ // Prefer left alignment; flip to right when the menu would overflow the viewport (web).
265
+ useLayoutEffect(() => {
266
+ if (Platform.OS !== "web" || !isOpen || hideTrigger) return;
267
+
268
+ const updateMenuAlign = () => {
269
+ const trigger = triggerRef.current as unknown as {
270
+ getBoundingClientRect?: () => DOMRect;
271
+ } | null;
272
+ const rect = trigger?.getBoundingClientRect?.();
273
+ if (!rect) return;
274
+
275
+ const viewportPadding = 8;
276
+ const overflowsRight = rect.left + resolvedMenuWidth > window.innerWidth - viewportPadding;
277
+ setMenuAlignEnd(overflowsRight);
278
+ };
279
+
280
+ updateMenuAlign();
281
+ window.addEventListener("resize", updateMenuAlign);
282
+ return () => window.removeEventListener("resize", updateMenuAlign);
283
+ }, [hideTrigger, isOpen, resolvedMenuWidth]);
284
+
246
285
  // Close on outside click (web only; native has no document-level events).
247
286
  useEffect(() => {
248
287
  if (Platform.OS !== "web" || !isOpen) return;
@@ -371,6 +410,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
371
410
  style={[
372
411
  styles.root,
373
412
  usesRangeTrigger && styles.rangeRoot,
413
+ hideTrigger && styles.rootHideTrigger,
374
414
  isOpen && styles.rootOpen,
375
415
  disabled && styles.disabled,
376
416
  style,
@@ -378,52 +418,65 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
378
418
  accessibilityState={{ disabled, expanded: isOpen }}
379
419
  >
380
420
  <View style={[styles.triggerWrap, isOpen && styles.triggerWrapOpen]}>
381
- <Pressable
382
- ref={triggerRef}
383
- onPress={handleTriggerPress}
384
- disabled={disabled}
385
- accessibilityRole="button"
386
- accessibilityLabel={resolvedAccessibilityLabel}
387
- accessibilityState={{ disabled, expanded: isOpen }}
388
- style={[
389
- styles.trigger,
390
- usesRangeTrigger && styles.rangeTrigger,
391
- usesRangeTrigger &&
392
- (usesVerticalRangeTrigger
393
- ? styles.rangeTriggerVertical
394
- : styles.rangeTriggerHorizontal),
395
- {
396
- borderColor: triggerBorderColor,
397
- backgroundColor: usesRangeTrigger ? "transparent" : colors.grey700,
398
- },
399
- ]}
400
- >
401
- {usesRangeTrigger ? (
402
- <>
403
- <RangeTriggerField label={startLabel} time={startTime} value={startValue} />
404
- <View
405
- style={
406
- usesVerticalRangeTrigger
407
- ? styles.rangeDividerVertical
408
- : styles.rangeDividerHorizontal
409
- }
410
- />
411
- <RangeTriggerField label={endLabel} time={endTime} value={endValue} />
412
- </>
413
- ) : (
414
- <>
415
- <Text style={[styles.triggerText, { color: triggerTextColor }]} numberOfLines={1}>
416
- {triggerLabel}
417
- </Text>
418
- <View style={styles.iconSlot}>
419
- <CalendarOutlineIcon size={ICON_SIZE} color={colors.grey100} />
420
- </View>
421
- </>
422
- )}
423
- </Pressable>
421
+ {hideTrigger ? null : (
422
+ <Pressable
423
+ ref={triggerRef}
424
+ onPress={handleTriggerPress}
425
+ disabled={disabled}
426
+ accessibilityRole="button"
427
+ accessibilityLabel={resolvedAccessibilityLabel}
428
+ accessibilityState={{ disabled, expanded: isOpen }}
429
+ style={[
430
+ styles.trigger,
431
+ usesRangeTrigger && styles.rangeTrigger,
432
+ usesRangeTrigger &&
433
+ (usesVerticalRangeTrigger
434
+ ? styles.rangeTriggerVertical
435
+ : styles.rangeTriggerHorizontal),
436
+ {
437
+ borderColor: triggerBorderColor,
438
+ backgroundColor: usesRangeTrigger ? "transparent" : colors.grey700,
439
+ },
440
+ ]}
441
+ >
442
+ {usesRangeTrigger ? (
443
+ <>
444
+ <RangeTriggerField label={startLabel} time={startTime} value={startValue} />
445
+ <View
446
+ style={
447
+ usesVerticalRangeTrigger
448
+ ? styles.rangeDividerVertical
449
+ : styles.rangeDividerHorizontal
450
+ }
451
+ />
452
+ <RangeTriggerField label={endLabel} time={endTime} value={endValue} />
453
+ </>
454
+ ) : (
455
+ <>
456
+ <Text style={[styles.triggerText, { color: triggerTextColor }]} numberOfLines={1}>
457
+ {triggerLabel}
458
+ </Text>
459
+ <View style={styles.iconSlot}>
460
+ <CalendarOutlineIcon size={ICON_SIZE} color={colors.grey100} />
461
+ </View>
462
+ </>
463
+ )}
464
+ </Pressable>
465
+ )}
424
466
 
425
467
  {isOpen ? (
426
- <View style={[styles.menu, usesRangeTrigger && styles.rangeMenu, calendarStyle]}>
468
+ <View
469
+ style={[
470
+ styles.menu,
471
+ usesRangeTrigger && styles.rangeMenu,
472
+ hideTrigger
473
+ ? styles.menuInline
474
+ : menuAlignEnd
475
+ ? styles.menuAlignEnd
476
+ : styles.menuAlignStart,
477
+ calendarStyle,
478
+ ]}
479
+ >
427
480
  <View style={styles.calendarHeader}>
428
481
  <Pressable
429
482
  onPress={handlePrevMonth}
@@ -590,6 +643,10 @@ const styles = StyleSheet.create({
590
643
  gap: 8,
591
644
  alignSelf: "flex-start",
592
645
  },
646
+ rootHideTrigger: {
647
+ width: "auto",
648
+ gap: 0,
649
+ },
593
650
  rootOpen: {
594
651
  zIndex: 30,
595
652
  },
@@ -694,13 +751,14 @@ const styles = StyleSheet.create({
694
751
  menu: {
695
752
  position: "absolute",
696
753
  top: "100%",
697
- left: 0,
698
754
  width: DEFAULT_WIDTH,
755
+ height: DEFAULT_MENU_HEIGHT,
699
756
  marginTop: 8,
700
757
  zIndex: 10,
701
758
  borderRadius: MENU_RADIUS,
702
759
  padding: 12,
703
760
  gap: 0,
761
+ opacity: 1,
704
762
  backgroundColor: colors.grey700,
705
763
  ...Platform.select({
706
764
  web: {
@@ -711,6 +769,21 @@ const styles = StyleSheet.create({
711
769
  },
712
770
  }),
713
771
  },
772
+ menuAlignStart: {
773
+ left: 0,
774
+ right: "auto",
775
+ },
776
+ menuAlignEnd: {
777
+ left: "auto",
778
+ right: 0,
779
+ },
780
+ menuInline: {
781
+ position: "relative",
782
+ top: "auto",
783
+ left: "auto",
784
+ right: "auto",
785
+ marginTop: 0,
786
+ },
714
787
  rangeMenu: {
715
788
  width: RANGE_TRIGGER_WIDTH,
716
789
  },
@@ -0,0 +1,100 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { Pressable, StyleSheet, Text, View } from "react-native";
4
+ import { colors, fonts } from "../../theme";
5
+ import { Modal } from "./Modal";
6
+
7
+ const meta = {
8
+ title: "Components/Modal",
9
+ component: Modal,
10
+ parameters: {
11
+ backgrounds: { default: "black" },
12
+ },
13
+ } satisfies Meta<typeof Modal>;
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ function ModalDemo() {
19
+ const [open, setOpen] = useState(true);
20
+
21
+ return (
22
+ <View style={styles.page}>
23
+ <Pressable onPress={() => setOpen(true)} style={styles.openButton}>
24
+ <Text style={styles.openLabel}>Open modal</Text>
25
+ </Pressable>
26
+
27
+ <Modal open={open} onClose={() => setOpen(false)}>
28
+ <View style={styles.panel}>
29
+ <Text style={styles.title}>Modal</Text>
30
+ <Text style={styles.body}>Backdrop uses #09090933 + blur(20px).</Text>
31
+ <Pressable onPress={() => setOpen(false)} style={styles.closeButton}>
32
+ <Text style={styles.closeLabel}>Close</Text>
33
+ </Pressable>
34
+ </View>
35
+ </Modal>
36
+ </View>
37
+ );
38
+ }
39
+
40
+ export const Default: Story = {
41
+ args: {
42
+ open: true,
43
+ onClose: () => undefined,
44
+ children: null,
45
+ },
46
+ render: () => <ModalDemo />,
47
+ };
48
+
49
+ const styles = StyleSheet.create({
50
+ page: {
51
+ minHeight: 360,
52
+ alignItems: "center",
53
+ justifyContent: "center",
54
+ padding: 24,
55
+ },
56
+ openButton: {
57
+ paddingHorizontal: 16,
58
+ paddingVertical: 10,
59
+ borderRadius: 40,
60
+ backgroundColor: colors.primary,
61
+ },
62
+ openLabel: {
63
+ fontFamily: fonts.sans,
64
+ fontSize: 14,
65
+ fontWeight: "600",
66
+ color: colors.white,
67
+ },
68
+ panel: {
69
+ width: 320,
70
+ gap: 16,
71
+ padding: 24,
72
+ borderRadius: 24,
73
+ backgroundColor: colors.dark,
74
+ },
75
+ title: {
76
+ fontFamily: fonts.sans,
77
+ fontSize: 18,
78
+ fontWeight: "700",
79
+ color: colors.white,
80
+ },
81
+ body: {
82
+ fontFamily: fonts.sans,
83
+ fontSize: 14,
84
+ lineHeight: 17.5,
85
+ color: colors.grey100,
86
+ },
87
+ closeButton: {
88
+ alignSelf: "flex-start",
89
+ paddingHorizontal: 16,
90
+ paddingVertical: 10,
91
+ borderRadius: 40,
92
+ backgroundColor: colors.grey700,
93
+ },
94
+ closeLabel: {
95
+ fontFamily: fonts.sans,
96
+ fontSize: 14,
97
+ fontWeight: "600",
98
+ color: colors.white,
99
+ },
100
+ });
@@ -0,0 +1,165 @@
1
+ import { forwardRef, useEffect, useMemo, useRef, type ComponentRef, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import {
4
+ Modal as RNModal,
5
+ Platform,
6
+ Pressable,
7
+ StyleSheet,
8
+ View,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from "react-native";
12
+ import { mergeRefs } from "../../utils/mergeRefs";
13
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
14
+
15
+ export interface ModalProps {
16
+ /** Controls visibility. */
17
+ open: boolean;
18
+ /** Called on backdrop press, Escape / Android back, or explicit close. */
19
+ onClose: () => void;
20
+ children: ReactNode;
21
+ /** Close when the dimmed backdrop is pressed. Defaults to `true`. */
22
+ closeOnBackdropPress?: boolean;
23
+ /** Accessible name for the backdrop control. Defaults to `"Close"`. */
24
+ backdropAccessibilityLabel?: string;
25
+ /** Style for the centered content wrapper around `children`. */
26
+ contentStyle?: StyleProp<ViewStyle>;
27
+ /** Web CSS class applied to the content wrapper. */
28
+ className?: string;
29
+ }
30
+
31
+ const BACKDROP_COLOR = "#09090933";
32
+ const WEB_Z_INDEX = 500;
33
+
34
+ /** Plain DOM backdrop — RN StyleSheet / Modal host often drops `backdrop-filter`. */
35
+ const webBackdropDomStyle = {
36
+ position: "absolute",
37
+ top: 0,
38
+ right: 0,
39
+ bottom: 0,
40
+ left: 0,
41
+ backgroundColor: BACKDROP_COLOR,
42
+ backdropFilter: "blur(20px)",
43
+ WebkitBackdropFilter: "blur(20px)",
44
+ border: "none",
45
+ padding: 0,
46
+ margin: 0,
47
+ cursor: "pointer",
48
+ } as const;
49
+
50
+ const webRootDomStyle = {
51
+ position: "fixed",
52
+ top: 0,
53
+ right: 0,
54
+ bottom: 0,
55
+ left: 0,
56
+ zIndex: WEB_Z_INDEX,
57
+ display: "flex",
58
+ alignItems: "center",
59
+ justifyContent: "center",
60
+ } as const;
61
+
62
+ export const Modal = forwardRef<ComponentRef<typeof View>, ModalProps>(function Modal(
63
+ {
64
+ open,
65
+ onClose,
66
+ children,
67
+ closeOnBackdropPress = true,
68
+ backdropAccessibilityLabel = "Close",
69
+ contentStyle,
70
+ className,
71
+ },
72
+ forwardedRef,
73
+ ) {
74
+ const contentRef = useRef<ComponentRef<typeof View>>(null);
75
+ const setContentRef = useMemo(() => mergeRefs(contentRef, forwardedRef), [forwardedRef]);
76
+ const resolvedClassName = className?.trim() || undefined;
77
+
78
+ useApplyWebClassName(contentRef, resolvedClassName, open && Platform.OS === "web");
79
+
80
+ useEffect(() => {
81
+ if (!open || Platform.OS !== "web") return undefined;
82
+
83
+ const previousOverflow = document.body.style.overflow;
84
+ document.body.style.overflow = "hidden";
85
+
86
+ const onKeyDown = (event: KeyboardEvent) => {
87
+ if (event.key === "Escape") onClose();
88
+ };
89
+ document.addEventListener("keydown", onKeyDown);
90
+
91
+ return () => {
92
+ document.body.style.overflow = previousOverflow;
93
+ document.removeEventListener("keydown", onKeyDown);
94
+ };
95
+ }, [open, onClose]);
96
+
97
+ const content = (
98
+ <View
99
+ ref={setContentRef}
100
+ style={[styles.content, contentStyle]}
101
+ onStartShouldSetResponder={() => true}
102
+ >
103
+ {children}
104
+ </View>
105
+ );
106
+
107
+ // Web: portal + real DOM backdrop so `backdrop-filter` blurs the page behind.
108
+ if (Platform.OS === "web") {
109
+ if (!open || typeof document === "undefined") return null;
110
+
111
+ return createPortal(
112
+ <div style={webRootDomStyle} role="presentation">
113
+ <button
114
+ type="button"
115
+ aria-label={backdropAccessibilityLabel}
116
+ disabled={!closeOnBackdropPress}
117
+ onClick={closeOnBackdropPress ? onClose : undefined}
118
+ style={webBackdropDomStyle}
119
+ />
120
+ <div style={{ position: "relative", zIndex: 1, maxWidth: "100%", maxHeight: "100%" }}>
121
+ {content}
122
+ </div>
123
+ </div>,
124
+ document.body,
125
+ );
126
+ }
127
+
128
+ return (
129
+ <RNModal
130
+ visible={open}
131
+ transparent
132
+ animationType="fade"
133
+ onRequestClose={onClose}
134
+ statusBarTranslucent
135
+ >
136
+ <View style={styles.root} accessibilityViewIsModal>
137
+ <Pressable
138
+ accessibilityRole="button"
139
+ accessibilityLabel={backdropAccessibilityLabel}
140
+ disabled={!closeOnBackdropPress}
141
+ onPress={closeOnBackdropPress ? onClose : undefined}
142
+ style={styles.backdrop}
143
+ />
144
+ {content}
145
+ </View>
146
+ </RNModal>
147
+ );
148
+ });
149
+
150
+ const styles = StyleSheet.create({
151
+ root: {
152
+ flex: 1,
153
+ alignItems: "center",
154
+ justifyContent: "center",
155
+ },
156
+ backdrop: {
157
+ ...StyleSheet.absoluteFillObject,
158
+ backgroundColor: BACKDROP_COLOR,
159
+ },
160
+ content: {
161
+ zIndex: 1,
162
+ maxWidth: "100%",
163
+ maxHeight: "100%",
164
+ },
165
+ });
@@ -0,0 +1,2 @@
1
+ export { Modal } from "./Modal";
2
+ export type { ModalProps } from "./Modal";