@ecohouse/ui 0.1.33 → 0.1.35
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 +30078 -656
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -7
- package/dist/index.d.ts +114 -7
- package/dist/index.js +30007 -593
- 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 +223 -0
- package/src/components/AccordionItem/index.ts +2 -0
- package/src/components/AccountHeader/AccountHeader.tsx +50 -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 +51 -15
- package/src/components/Input/Input.tsx +54 -21
- package/src/components/LanguageSwitcher/LanguageSwitcher.stories.tsx +11 -0
- package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +103 -19
- package/src/components/LanguageSwitcher/index.ts +5 -1
- 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 +90 -5
- package/src/components/Sidebar/Sidebar.tsx +19 -3
- package/src/components/Textarea/Textarea.tsx +29 -4
- 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 +17 -1
- 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/Globe/GlobeIcon.tsx +2 -2
- package/src/icons/Globe/GlobeIcon.web.tsx +2 -2
- package/src/icons/Globe/globePath.ts +2 -2
- 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/Key/keyPath.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/index.ts +4 -0
- package/src/icons/registry.ts +11 -0
- package/src/index.ts +13 -0
- package/src/theme/typography.ts +3 -3
|
@@ -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
|
+
});
|
|
@@ -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,
|
|
@@ -64,6 +65,8 @@ type SelectBaseProps = Omit<ViewProps, "style" | "children"> & {
|
|
|
64
65
|
/** Override color for the selected value text in the trigger. */
|
|
65
66
|
valueColor?: string;
|
|
66
67
|
style?: StyleProp<ViewStyle>;
|
|
68
|
+
/** Styles applied to the dropdown menu only (not the trigger). */
|
|
69
|
+
menuStyle?: StyleProp<ViewStyle>;
|
|
67
70
|
className?: string;
|
|
68
71
|
};
|
|
69
72
|
|
|
@@ -268,6 +271,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
268
271
|
emptyText = "No results",
|
|
269
272
|
valueColor,
|
|
270
273
|
style,
|
|
274
|
+
menuStyle,
|
|
271
275
|
className,
|
|
272
276
|
accessibilityLabel,
|
|
273
277
|
// Pulled out only to keep them off `rest` (which is spread onto the root View);
|
|
@@ -297,6 +301,10 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
297
301
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
298
302
|
const [searchQuery, setSearchQuery] = useState("");
|
|
299
303
|
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
|
|
304
|
+
/** Web: flip menu to the trigger's right edge when it would overflow the viewport. */
|
|
305
|
+
const [menuAlignEnd, setMenuAlignEnd] = useState(false);
|
|
306
|
+
/** Web: open above the trigger when there isn't enough space below. */
|
|
307
|
+
const [menuPlaceAbove, setMenuPlaceAbove] = useState(false);
|
|
300
308
|
|
|
301
309
|
const selectedValues = useMemo(() => {
|
|
302
310
|
const current = isValueControlled
|
|
@@ -330,6 +338,25 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
330
338
|
|
|
331
339
|
useApplyWebClassName(rootRef, className);
|
|
332
340
|
|
|
341
|
+
const resolvedMenuWidth = useMemo(() => {
|
|
342
|
+
const flat = StyleSheet.flatten(menuStyle);
|
|
343
|
+
const width = flat?.width;
|
|
344
|
+
if (typeof width === "number") return width;
|
|
345
|
+
const minWidth = flat?.minWidth;
|
|
346
|
+
if (typeof minWidth === "number") return minWidth;
|
|
347
|
+
return null;
|
|
348
|
+
}, [menuStyle]);
|
|
349
|
+
|
|
350
|
+
const estimatedMenuHeight = useMemo(() => {
|
|
351
|
+
const searchBlock = showSearch ? 40 + 8 : 0;
|
|
352
|
+
const optionCount = Math.max(filteredOptions.length, 1);
|
|
353
|
+
const listHeight = Math.min(
|
|
354
|
+
optionCount * 43 + Math.max(0, optionCount - 1) * 8,
|
|
355
|
+
MENU_MAX_LIST_HEIGHT,
|
|
356
|
+
);
|
|
357
|
+
return 24 + searchBlock + listHeight;
|
|
358
|
+
}, [filteredOptions.length, showSearch]);
|
|
359
|
+
|
|
333
360
|
const setOpen = useCallback(
|
|
334
361
|
(next: boolean) => {
|
|
335
362
|
if (!isOpenControlled) setUncontrolledOpen(next);
|
|
@@ -341,6 +368,8 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
341
368
|
} else {
|
|
342
369
|
setSearchQuery("");
|
|
343
370
|
setHoveredValue(null);
|
|
371
|
+
setMenuAlignEnd(false);
|
|
372
|
+
setMenuPlaceAbove(false);
|
|
344
373
|
// Let the parent reset its externally fetched options.
|
|
345
374
|
onSearchChange?.("");
|
|
346
375
|
}
|
|
@@ -348,6 +377,38 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
348
377
|
[isOpenControlled, onOpenChange, onSearchChange, selectedValues],
|
|
349
378
|
);
|
|
350
379
|
|
|
380
|
+
// Prefer start (left) / below; flip when the menu would overflow the viewport (web).
|
|
381
|
+
useLayoutEffect(() => {
|
|
382
|
+
if (Platform.OS !== "web" || !isOpen) return;
|
|
383
|
+
|
|
384
|
+
const updateMenuPlacement = () => {
|
|
385
|
+
const trigger = triggerRef.current as unknown as {
|
|
386
|
+
getBoundingClientRect?: () => DOMRect;
|
|
387
|
+
} | null;
|
|
388
|
+
const rect = trigger?.getBoundingClientRect?.();
|
|
389
|
+
if (!rect) return;
|
|
390
|
+
|
|
391
|
+
const viewportPadding = 8;
|
|
392
|
+
const gap = 8;
|
|
393
|
+
const menuWidth = Math.max(rect.width, resolvedMenuWidth ?? 0);
|
|
394
|
+
const overflowsRight = rect.left + menuWidth > window.innerWidth - viewportPadding;
|
|
395
|
+
setMenuAlignEnd(overflowsRight);
|
|
396
|
+
|
|
397
|
+
const spaceBelow = window.innerHeight - viewportPadding - rect.bottom;
|
|
398
|
+
const spaceAbove = rect.top - viewportPadding;
|
|
399
|
+
const needsAbove = estimatedMenuHeight + gap > spaceBelow && spaceAbove > spaceBelow;
|
|
400
|
+
setMenuPlaceAbove(needsAbove);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
updateMenuPlacement();
|
|
404
|
+
window.addEventListener("resize", updateMenuPlacement);
|
|
405
|
+
window.addEventListener("scroll", updateMenuPlacement, true);
|
|
406
|
+
return () => {
|
|
407
|
+
window.removeEventListener("resize", updateMenuPlacement);
|
|
408
|
+
window.removeEventListener("scroll", updateMenuPlacement, true);
|
|
409
|
+
};
|
|
410
|
+
}, [estimatedMenuHeight, isOpen, resolvedMenuWidth]);
|
|
411
|
+
|
|
351
412
|
const handleSearchChange = (text: string) => {
|
|
352
413
|
setSearchQuery(text);
|
|
353
414
|
onSearchChange?.(text);
|
|
@@ -583,7 +644,14 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
583
644
|
</Pressable>
|
|
584
645
|
|
|
585
646
|
{isOpen ? (
|
|
586
|
-
<View
|
|
647
|
+
<View
|
|
648
|
+
style={[
|
|
649
|
+
styles.menu,
|
|
650
|
+
menuAlignEnd ? styles.menuAlignEnd : styles.menuAlignStart,
|
|
651
|
+
menuPlaceAbove ? styles.menuAbove : styles.menuBelow,
|
|
652
|
+
menuStyle,
|
|
653
|
+
]}
|
|
654
|
+
>
|
|
587
655
|
{showSearch ? (
|
|
588
656
|
<View style={styles.searchField}>
|
|
589
657
|
<View style={styles.iconSlot}>
|
|
@@ -777,10 +845,7 @@ const styles = StyleSheet.create({
|
|
|
777
845
|
},
|
|
778
846
|
menu: {
|
|
779
847
|
position: "absolute",
|
|
780
|
-
|
|
781
|
-
left: 0,
|
|
782
|
-
right: 0,
|
|
783
|
-
marginTop: 8,
|
|
848
|
+
minWidth: "100%",
|
|
784
849
|
zIndex: 10,
|
|
785
850
|
borderRadius: MENU_RADIUS,
|
|
786
851
|
padding: 12,
|
|
@@ -795,6 +860,26 @@ const styles = StyleSheet.create({
|
|
|
795
860
|
},
|
|
796
861
|
}),
|
|
797
862
|
},
|
|
863
|
+
menuAlignStart: {
|
|
864
|
+
left: 0,
|
|
865
|
+
right: "auto",
|
|
866
|
+
},
|
|
867
|
+
menuAlignEnd: {
|
|
868
|
+
left: "auto",
|
|
869
|
+
right: 0,
|
|
870
|
+
},
|
|
871
|
+
menuBelow: {
|
|
872
|
+
top: "100%",
|
|
873
|
+
bottom: "auto",
|
|
874
|
+
marginTop: 8,
|
|
875
|
+
marginBottom: 0,
|
|
876
|
+
},
|
|
877
|
+
menuAbove: {
|
|
878
|
+
top: "auto",
|
|
879
|
+
bottom: "100%",
|
|
880
|
+
marginTop: 0,
|
|
881
|
+
marginBottom: 8,
|
|
882
|
+
},
|
|
798
883
|
optionList: {
|
|
799
884
|
maxHeight: MENU_MAX_LIST_HEIGHT,
|
|
800
885
|
},
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type ViewStyle,
|
|
19
19
|
} from "react-native";
|
|
20
20
|
import type { IconComponent } from "../../icons";
|
|
21
|
-
import { SidebarIcon } from "../../icons";
|
|
21
|
+
import { CloseIcon, SidebarIcon } from "../../icons";
|
|
22
22
|
import { BrandLogo, MenuItem } from "../../primitives";
|
|
23
23
|
import { colors, fonts } from "../../theme";
|
|
24
24
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
@@ -61,6 +61,10 @@ export interface SidebarProps extends Omit<ViewProps, "style" | "children"> {
|
|
|
61
61
|
onCollapsedChange?: (collapsed: boolean) => void;
|
|
62
62
|
/** When false, hides the expand/collapse control. Defaults to `true`. */
|
|
63
63
|
showCollapseToggle?: boolean;
|
|
64
|
+
/** When set, shows a close control in the header (e.g. mobile drawer). */
|
|
65
|
+
onClose?: () => void;
|
|
66
|
+
/** Accessible label for the close control. Defaults to `"Close"`. */
|
|
67
|
+
closeAccessibilityLabel?: string;
|
|
64
68
|
style?: StyleProp<ViewStyle>;
|
|
65
69
|
className?: string;
|
|
66
70
|
}
|
|
@@ -89,6 +93,8 @@ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(funct
|
|
|
89
93
|
defaultCollapsed = false,
|
|
90
94
|
onCollapsedChange,
|
|
91
95
|
showCollapseToggle = true,
|
|
96
|
+
onClose,
|
|
97
|
+
closeAccessibilityLabel = "Close",
|
|
92
98
|
style,
|
|
93
99
|
className,
|
|
94
100
|
...rest
|
|
@@ -143,7 +149,7 @@ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(funct
|
|
|
143
149
|
style={[
|
|
144
150
|
styles.header,
|
|
145
151
|
collapsed ? styles.headerCollapsed : styles.headerExpanded,
|
|
146
|
-
!showCollapseToggle ? styles.headerNoToggle : null,
|
|
152
|
+
!showCollapseToggle && !onClose ? styles.headerNoToggle : null,
|
|
147
153
|
]}
|
|
148
154
|
>
|
|
149
155
|
<Animated.View
|
|
@@ -166,7 +172,17 @@ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(funct
|
|
|
166
172
|
/>
|
|
167
173
|
</Animated.View>
|
|
168
174
|
|
|
169
|
-
{
|
|
175
|
+
{onClose ? (
|
|
176
|
+
<Pressable
|
|
177
|
+
accessibilityRole="button"
|
|
178
|
+
accessibilityLabel={closeAccessibilityLabel}
|
|
179
|
+
hitSlop={8}
|
|
180
|
+
onPress={onClose}
|
|
181
|
+
style={styles.toggleButton}
|
|
182
|
+
>
|
|
183
|
+
<CloseIcon color={colors.grey100} size={TOGGLE_SIZE} strokeWidth={2} />
|
|
184
|
+
</Pressable>
|
|
185
|
+
) : showCollapseToggle ? (
|
|
170
186
|
<Pressable
|
|
171
187
|
accessibilityRole="button"
|
|
172
188
|
accessibilityLabel={collapsed ? expandAccessibilityLabel : collapseAccessibilityLabel}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { forwardRef, useMemo, useRef, useState, type ComponentRef } from "react";
|
|
1
|
+
import { forwardRef, useId, useMemo, useRef, useState, type ComponentRef } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Platform,
|
|
4
|
+
Pressable,
|
|
4
5
|
StyleSheet,
|
|
5
6
|
Text,
|
|
6
7
|
TextInput,
|
|
@@ -132,6 +133,8 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
132
133
|
const setInputRef = useMemo(() => mergeRefs(inputRef, forwardedRef), [forwardedRef]);
|
|
133
134
|
const [focused, setFocused] = useState(false);
|
|
134
135
|
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
|
|
136
|
+
const reactId = useId();
|
|
137
|
+
const inputDomId = `eh-textarea-${reactId.replace(/:/g, "")}`;
|
|
135
138
|
|
|
136
139
|
const isControlled = typeof value === "string";
|
|
137
140
|
const currentValue = isControlled ? value : uncontrolledValue;
|
|
@@ -141,28 +144,40 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
141
144
|
const chrome = chromeFor(visualState);
|
|
142
145
|
const resolvedAccessibilityLabel = accessibilityLabel ?? (showLabel ? undefined : label);
|
|
143
146
|
|
|
147
|
+
const focusInput = () => {
|
|
148
|
+
if (disabled) return;
|
|
149
|
+
inputRef.current?.focus();
|
|
150
|
+
};
|
|
151
|
+
|
|
144
152
|
useApplyWebClassName(rootRef, className);
|
|
145
153
|
useApplyWebClassName(inputRef, inputClassName);
|
|
146
154
|
|
|
147
155
|
return (
|
|
148
156
|
<View ref={rootRef} style={[styles.root, disabled && styles.disabled, style]}>
|
|
149
157
|
{showLabel && label ? (
|
|
150
|
-
<
|
|
158
|
+
<Pressable disabled={disabled} onPress={focusInput} style={styles.labelPressable}>
|
|
159
|
+
<Text style={[styles.label, { color: chrome.labelColor }]}>{label}</Text>
|
|
160
|
+
</Pressable>
|
|
151
161
|
) : null}
|
|
152
162
|
|
|
153
|
-
<
|
|
163
|
+
<Pressable
|
|
164
|
+
accessibilityRole="none"
|
|
165
|
+
disabled={disabled}
|
|
166
|
+
onPress={focusInput}
|
|
154
167
|
style={[
|
|
155
168
|
styles.field,
|
|
156
169
|
{
|
|
157
170
|
borderColor: chrome.borderColor,
|
|
158
171
|
backgroundColor: chrome.backgroundColor,
|
|
159
172
|
},
|
|
173
|
+
Platform.OS === "web" ? styles.fieldWeb : null,
|
|
160
174
|
fieldStyle,
|
|
161
175
|
]}
|
|
162
176
|
>
|
|
163
177
|
<TextInput
|
|
164
178
|
ref={setInputRef}
|
|
165
179
|
{...textInputProps}
|
|
180
|
+
nativeID={inputDomId}
|
|
166
181
|
multiline
|
|
167
182
|
textAlignVertical="top"
|
|
168
183
|
value={isControlled ? value : undefined}
|
|
@@ -193,7 +208,7 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
|
|
|
193
208
|
accessibilityLabel={resolvedAccessibilityLabel}
|
|
194
209
|
accessibilityState={{ disabled }}
|
|
195
210
|
/>
|
|
196
|
-
</
|
|
211
|
+
</Pressable>
|
|
197
212
|
|
|
198
213
|
{showHint && hint ? (
|
|
199
214
|
<Text style={[styles.hint, { color: chrome.hintColor }]}>{hint}</Text>
|
|
@@ -211,6 +226,9 @@ const styles = StyleSheet.create({
|
|
|
211
226
|
disabled: {
|
|
212
227
|
opacity: 0.6,
|
|
213
228
|
},
|
|
229
|
+
labelPressable: {
|
|
230
|
+
alignSelf: "flex-start",
|
|
231
|
+
},
|
|
214
232
|
label: {
|
|
215
233
|
...textareaTypography.label,
|
|
216
234
|
},
|
|
@@ -223,14 +241,21 @@ const styles = StyleSheet.create({
|
|
|
223
241
|
paddingLeft: FIELD_PADDING_HORIZONTAL,
|
|
224
242
|
paddingRight: FIELD_PADDING_HORIZONTAL,
|
|
225
243
|
},
|
|
244
|
+
fieldWeb: {
|
|
245
|
+
cursor: "text" as unknown as ViewStyle["cursor"],
|
|
246
|
+
},
|
|
226
247
|
input: {
|
|
227
248
|
flex: 1,
|
|
249
|
+
alignSelf: "stretch",
|
|
228
250
|
paddingVertical: 0,
|
|
229
251
|
margin: 0,
|
|
230
252
|
},
|
|
231
253
|
inputWeb: {
|
|
232
254
|
outlineStyle: "none",
|
|
233
255
|
resize: "none",
|
|
256
|
+
height: "100%",
|
|
257
|
+
minWidth: 0,
|
|
258
|
+
cursor: "text" as unknown as TextStyle["cursor"],
|
|
234
259
|
} as TextStyle,
|
|
235
260
|
inputAndroid: {
|
|
236
261
|
includeFontPadding: false,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { fn } from "storybook/test";
|
|
3
|
+
import { View, StyleSheet } from "react-native";
|
|
4
|
+
import { ToggleRow } from "./ToggleRow";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/ToggleRow",
|
|
8
|
+
component: ToggleRow,
|
|
9
|
+
args: {
|
|
10
|
+
label: "Bookings and digital key",
|
|
11
|
+
defaultValue: false,
|
|
12
|
+
onValueChange: fn(),
|
|
13
|
+
},
|
|
14
|
+
parameters: {
|
|
15
|
+
backgrounds: { default: "black" },
|
|
16
|
+
},
|
|
17
|
+
} satisfies Meta<typeof ToggleRow>;
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
20
|
+
type Story = StoryObj<typeof meta>;
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {};
|
|
23
|
+
|
|
24
|
+
export const Active: Story = {
|
|
25
|
+
args: { defaultValue: true },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Stack: Story = {
|
|
29
|
+
render: () => (
|
|
30
|
+
<View style={styles.stack}>
|
|
31
|
+
<ToggleRow label="Bookings and digital key" defaultValue={false} onValueChange={fn()} />
|
|
32
|
+
<ToggleRow
|
|
33
|
+
label="Email notifications"
|
|
34
|
+
defaultValue={false}
|
|
35
|
+
onValueChange={fn()}
|
|
36
|
+
showBorder={false}
|
|
37
|
+
/>
|
|
38
|
+
</View>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const styles = StyleSheet.create({
|
|
43
|
+
stack: {
|
|
44
|
+
width: 462,
|
|
45
|
+
},
|
|
46
|
+
});
|