@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.
- package/dist/index.cjs +30063 -607
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +173 -10
- package/dist/index.d.ts +173 -10
- package/dist/index.js +29989 -547
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AccordionItem/AccordionItem.stories.tsx +54 -0
- package/src/components/AccordionItem/AccordionItem.tsx +220 -0
- package/src/components/AccordionItem/index.ts +2 -0
- package/src/components/AccountHeader/AccountHeader.tsx +34 -29
- package/src/components/Button/Button.stories.tsx +10 -1
- package/src/components/Button/Button.tsx +32 -3
- package/src/components/ContactInfoCard/ContactInfoCard.stories.tsx +30 -0
- package/src/components/ContactInfoCard/ContactInfoCard.tsx +141 -0
- package/src/components/ContactInfoCard/index.ts +2 -0
- package/src/components/DatePicker/DatePicker.tsx +119 -46
- package/src/components/Modal/Modal.stories.tsx +100 -0
- package/src/components/Modal/Modal.tsx +165 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Select/Select.tsx +5 -2
- package/src/components/Sidebar/Sidebar.tsx +42 -12
- package/src/components/StepPager/StepPager.tsx +150 -0
- package/src/components/StepPager/index.ts +2 -0
- package/src/components/ToggleRow/ToggleRow.stories.tsx +46 -0
- package/src/components/ToggleRow/ToggleRow.tsx +121 -0
- package/src/components/ToggleRow/index.ts +2 -0
- package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +80 -60
- package/src/components/index.ts +15 -0
- package/src/icons/Ban/BanIcon.tsx +15 -0
- package/src/icons/Ban/banPath.ts +2 -0
- package/src/icons/Ban/index.ts +1 -0
- package/src/icons/CameraFilled/CameraFilledIcon.tsx +15 -0
- package/src/icons/CameraFilled/CameraFilledIcon.web.tsx +21 -0
- package/src/icons/CameraFilled/cameraFilledPath.ts +2 -0
- package/src/icons/CameraFilled/index.ts +1 -0
- package/src/icons/Fullscreen/FullscreenIcon.tsx +21 -0
- package/src/icons/Fullscreen/fullscreenPath.ts +2 -0
- package/src/icons/Fullscreen/index.ts +1 -0
- package/src/icons/Headset/HeadsetIcon.tsx +26 -0
- package/src/icons/Headset/HeadsetIcon.web.tsx +25 -0
- package/src/icons/Headset/headsetPath.ts +6 -0
- package/src/icons/Headset/index.ts +1 -0
- package/src/icons/Hide/HideIcon.tsx +21 -0
- package/src/icons/Hide/HideIcon.web.tsx +27 -0
- package/src/icons/Hide/hidePath.ts +3 -0
- package/src/icons/Hide/index.ts +1 -0
- package/src/icons/Home/homePath.ts +1 -1
- package/src/icons/MenuLines/MenuLinesIcon.tsx +20 -0
- package/src/icons/MenuLines/MenuLinesIcon.web.tsx +26 -0
- package/src/icons/MenuLines/index.ts +1 -0
- package/src/icons/MenuLines/menuLinesPath.ts +3 -0
- package/src/icons/Sort/SortIcon.tsx +3 -3
- package/src/icons/Sort/SortIcon.web.tsx +2 -2
- package/src/icons/Sort/sortPath.ts +3 -2
- package/src/icons/VideoOff/VideoOffIcon.tsx +21 -0
- package/src/icons/VideoOff/VideoOffIcon.web.tsx +27 -0
- package/src/icons/VideoOff/index.ts +1 -0
- package/src/icons/VideoOff/videoOffPath.ts +2 -0
- package/src/icons/ZoomOut/ZoomOutIcon.tsx +21 -0
- package/src/icons/ZoomOut/index.ts +1 -0
- package/src/icons/ZoomOut/zoomOutPath.ts +2 -0
- package/src/icons/index.ts +8 -0
- package/src/icons/registry.ts +23 -0
- package/src/index.ts +20 -0
- package/src/theme/colors.test.ts +1 -1
- package/src/theme/typography.ts +4 -4
- 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
|
+
});
|
|
@@ -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 =
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
{
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
<
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
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
|
+
});
|