@ecohouse/ui 0.1.3 → 0.1.4
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 +497 -75
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +102 -4
- package/dist/index.d.ts +102 -4
- package/dist/index.js +491 -78
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Avatar/Avatar.stories.tsx +133 -0
- package/src/components/Avatar/Avatar.tsx +389 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Select/Select.tsx +1 -1
- package/src/components/index.ts +3 -0
- package/src/icons/Calendar/CalendarIcon.tsx +21 -0
- package/src/icons/Calendar/CalendarIcon.web.tsx +27 -0
- package/src/icons/Calendar/calendarPath.ts +3 -0
- package/src/icons/Calendar/index.ts +1 -0
- package/src/icons/Heart/HeartIcon.tsx +23 -0
- package/src/icons/Heart/HeartIcon.web.tsx +29 -0
- package/src/icons/Heart/heartPath.ts +3 -0
- package/src/icons/Heart/index.ts +1 -0
- package/src/icons/HelpCircle/HelpCircleIcon.tsx +25 -0
- package/src/icons/HelpCircle/HelpCircleIcon.web.tsx +31 -0
- package/src/icons/HelpCircle/helpCirclePath.ts +3 -0
- package/src/icons/HelpCircle/index.ts +1 -0
- package/src/icons/LayoutGrid/LayoutGridIcon.tsx +25 -0
- package/src/icons/LayoutGrid/LayoutGridIcon.web.tsx +31 -0
- package/src/icons/LayoutGrid/index.ts +1 -0
- package/src/icons/LayoutGrid/layoutGridPath.ts +3 -0
- package/src/icons/LogOut/LogOutIcon.tsx +21 -0
- package/src/icons/LogOut/LogOutIcon.web.tsx +27 -0
- package/src/icons/LogOut/index.ts +1 -0
- package/src/icons/LogOut/logOutPath.ts +3 -0
- package/src/icons/Settings/SettingsIcon.tsx +21 -0
- package/src/icons/Settings/SettingsIcon.web.tsx +27 -0
- package/src/icons/Settings/index.ts +1 -0
- package/src/icons/Settings/settingsPath.ts +3 -0
- package/src/icons/Video/VideoIcon.tsx +24 -0
- package/src/icons/Video/VideoIcon.web.tsx +30 -0
- package/src/icons/Video/index.ts +1 -0
- package/src/icons/Video/videoPath.ts +5 -0
- package/src/icons/index.ts +7 -0
- package/src/icons/registry.ts +17 -2
- package/src/index.ts +11 -1
- package/src/theme/colors.ts +1 -0
- package/src/theme/index.ts +1 -0
- package/src/theme/typography.ts +25 -0
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { fn } from "storybook/test";
|
|
3
|
+
import { View, StyleSheet } from "react-native";
|
|
4
|
+
import { Avatar, type AvatarMenuItem } from "./Avatar";
|
|
5
|
+
import {
|
|
6
|
+
CalendarIcon,
|
|
7
|
+
HeartIcon,
|
|
8
|
+
HelpCircleIcon,
|
|
9
|
+
LayoutGridIcon,
|
|
10
|
+
LogOutIcon,
|
|
11
|
+
SettingsIcon,
|
|
12
|
+
VideoIcon,
|
|
13
|
+
} from "../../icons";
|
|
14
|
+
import { colors } from "../../theme";
|
|
15
|
+
|
|
16
|
+
const accountMenuItems: AvatarMenuItem[] = [
|
|
17
|
+
{ label: "Bookings", icon: <CalendarIcon size={16} />, onPress: fn() },
|
|
18
|
+
{ label: "Digital keys", icon: <LayoutGridIcon size={16} />, onPress: fn() },
|
|
19
|
+
{ label: "Cameras", icon: <VideoIcon size={16} />, onPress: fn() },
|
|
20
|
+
{ label: "Favorites", icon: <HeartIcon size={16} />, onPress: fn() },
|
|
21
|
+
{ label: "Support", icon: <HelpCircleIcon size={16} />, onPress: fn() },
|
|
22
|
+
{ label: "Settings", icon: <SettingsIcon size={16} />, onPress: fn() },
|
|
23
|
+
{
|
|
24
|
+
label: "Log out",
|
|
25
|
+
icon: <LogOutIcon size={16} />,
|
|
26
|
+
onPress: fn(),
|
|
27
|
+
backgroundColor: colors.redMuted,
|
|
28
|
+
hoverColor: colors.redHover,
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
title: "Components/Avatar",
|
|
34
|
+
component: Avatar,
|
|
35
|
+
args: {
|
|
36
|
+
name: "Աննա Հակոբյան",
|
|
37
|
+
email: "anna.hakobyan@example.com",
|
|
38
|
+
imageUrl: "https://i.pravatar.cc/150?img=47",
|
|
39
|
+
showChevron: true,
|
|
40
|
+
disabled: false,
|
|
41
|
+
onPress: fn(),
|
|
42
|
+
},
|
|
43
|
+
argTypes: {
|
|
44
|
+
disabled: { control: "boolean" },
|
|
45
|
+
showChevron: { control: "boolean" },
|
|
46
|
+
onPress: { action: "pressed" },
|
|
47
|
+
},
|
|
48
|
+
parameters: {
|
|
49
|
+
backgrounds: { default: "black" },
|
|
50
|
+
},
|
|
51
|
+
} satisfies Meta<typeof Avatar>;
|
|
52
|
+
|
|
53
|
+
export default meta;
|
|
54
|
+
type Story = StoryObj<typeof meta>;
|
|
55
|
+
|
|
56
|
+
export const Default: Story = {};
|
|
57
|
+
|
|
58
|
+
/** No `imageUrl` — falls back to the placeholder person icon. */
|
|
59
|
+
export const WithoutImage: Story = {
|
|
60
|
+
args: { imageUrl: undefined },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** A broken/unreachable URL also falls back to the placeholder icon once it fails to load. */
|
|
64
|
+
export const BrokenImage: Story = {
|
|
65
|
+
args: { imageUrl: "https://example.invalid/does-not-exist.png" },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const WithoutEmail: Story = {
|
|
69
|
+
args: { email: undefined },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const WithoutChevron: Story = {
|
|
73
|
+
args: { showChevron: false },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const NotInteractive: Story = {
|
|
77
|
+
args: { onPress: undefined },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const Disabled: Story = {
|
|
81
|
+
args: { disabled: true },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const LongContent: Story = {
|
|
85
|
+
args: {
|
|
86
|
+
name: "Alexandra Konstantinopoulos-Harutyunyan",
|
|
87
|
+
email: "alexandra.konstantinopoulos.harutyunyan@example.com",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Options — icon, label, action, and per-item colors — come entirely from the consumer. */
|
|
92
|
+
export const WithMenu: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
menuItems: accountMenuItems,
|
|
95
|
+
defaultOpen: true,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const WithMenuClosed: Story = {
|
|
100
|
+
name: "With Menu / Closed",
|
|
101
|
+
args: {
|
|
102
|
+
menuItems: accountMenuItems,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const AllStates: Story = {
|
|
107
|
+
render: () => (
|
|
108
|
+
<View style={storyStyles.column}>
|
|
109
|
+
<Avatar
|
|
110
|
+
name="Աննա Հակոբյան"
|
|
111
|
+
email="anna.hakobyan@example.com"
|
|
112
|
+
imageUrl="https://i.pravatar.cc/150?img=47"
|
|
113
|
+
onPress={fn()}
|
|
114
|
+
/>
|
|
115
|
+
<Avatar name="Աննա Հակոբյան" email="anna.hakobyan@example.com" onPress={fn()} />
|
|
116
|
+
<Avatar
|
|
117
|
+
name="Աննա Հակոբյան"
|
|
118
|
+
email="anna.hakobyan@example.com"
|
|
119
|
+
imageUrl="https://i.pravatar.cc/150?img=47"
|
|
120
|
+
disabled
|
|
121
|
+
onPress={fn()}
|
|
122
|
+
/>
|
|
123
|
+
<Avatar name="Աննա Հակոբյան" showChevron={false} onPress={fn()} />
|
|
124
|
+
</View>
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const storyStyles = StyleSheet.create({
|
|
129
|
+
column: {
|
|
130
|
+
gap: 16,
|
|
131
|
+
alignItems: "flex-start",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ComponentRef,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
Image,
|
|
13
|
+
Platform,
|
|
14
|
+
Pressable,
|
|
15
|
+
StyleSheet,
|
|
16
|
+
Text,
|
|
17
|
+
View,
|
|
18
|
+
type GestureResponderEvent,
|
|
19
|
+
type PressableProps,
|
|
20
|
+
type StyleProp,
|
|
21
|
+
type ViewStyle,
|
|
22
|
+
} from "react-native";
|
|
23
|
+
import { ChevronDownIcon, UserIcon } from "../../icons";
|
|
24
|
+
import { avatarTypography, colors } from "../../theme";
|
|
25
|
+
import { mergeRefs } from "../../utils/mergeRefs";
|
|
26
|
+
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
27
|
+
|
|
28
|
+
export interface AvatarMenuItem {
|
|
29
|
+
/** Stable identity for React keys / hover tracking; falls back to `label`. */
|
|
30
|
+
key?: string;
|
|
31
|
+
/** Leading icon, e.g. `<CalendarIcon />` — pass it pre-colored. */
|
|
32
|
+
icon?: ReactNode;
|
|
33
|
+
label: string;
|
|
34
|
+
onPress: () => void;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Row background at rest. Defaults to transparent. */
|
|
37
|
+
backgroundColor?: string;
|
|
38
|
+
/** Row background on hover/press. Defaults to `colors.grey600`. */
|
|
39
|
+
hoverColor?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AvatarProps extends Omit<
|
|
43
|
+
PressableProps,
|
|
44
|
+
"onPress" | "disabled" | "style" | "children" | "hitSlop"
|
|
45
|
+
> {
|
|
46
|
+
/** Logged-in user's display name. */
|
|
47
|
+
name: string;
|
|
48
|
+
/** Shown under the name, e.g. the user's email. */
|
|
49
|
+
email?: string;
|
|
50
|
+
/** Photo URL; falls back to a placeholder icon when missing or when it fails to load. */
|
|
51
|
+
imageUrl?: string | null;
|
|
52
|
+
/** Called on every press, in addition to any menu toggling driven by `menuItems`. */
|
|
53
|
+
onPress?: (event: GestureResponderEvent) => void;
|
|
54
|
+
/** External — non-interactive. */
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
/** Trailing chevron, typically used to hint a dropdown/menu. */
|
|
57
|
+
showChevron?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Account-menu options, supplied entirely by the consumer (icon, label,
|
|
60
|
+
* action, and per-item colors). When set, pressing the chip opens a
|
|
61
|
+
* dropdown listing them.
|
|
62
|
+
*/
|
|
63
|
+
menuItems?: AvatarMenuItem[];
|
|
64
|
+
/** Controlled open state for the menu. */
|
|
65
|
+
open?: boolean;
|
|
66
|
+
defaultOpen?: boolean;
|
|
67
|
+
onOpenChange?: (open: boolean) => void;
|
|
68
|
+
/** Fixed menu width; defaults to matching the chip's width. */
|
|
69
|
+
menuWidth?: number;
|
|
70
|
+
style?: StyleProp<ViewStyle>;
|
|
71
|
+
className?: string;
|
|
72
|
+
hitSlop?: PressableProps["hitSlop"];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const IMAGE_SIZE = 48;
|
|
76
|
+
const CONTAINER_RADIUS = 71;
|
|
77
|
+
const CHEVRON_SIZE = 20;
|
|
78
|
+
const FALLBACK_ICON_SIZE = 20;
|
|
79
|
+
const MENU_RADIUS = 16;
|
|
80
|
+
const MENU_ITEM_RADIUS = 12;
|
|
81
|
+
const MENU_ITEM_ICON_SIZE = 16;
|
|
82
|
+
|
|
83
|
+
export const Avatar = forwardRef<ComponentRef<typeof View>, AvatarProps>(function Avatar(
|
|
84
|
+
{
|
|
85
|
+
name,
|
|
86
|
+
email,
|
|
87
|
+
imageUrl,
|
|
88
|
+
onPress,
|
|
89
|
+
disabled = false,
|
|
90
|
+
showChevron = true,
|
|
91
|
+
menuItems,
|
|
92
|
+
open: openProp,
|
|
93
|
+
defaultOpen = false,
|
|
94
|
+
onOpenChange,
|
|
95
|
+
menuWidth,
|
|
96
|
+
style,
|
|
97
|
+
className,
|
|
98
|
+
accessibilityLabel,
|
|
99
|
+
hitSlop = 8,
|
|
100
|
+
...rest
|
|
101
|
+
},
|
|
102
|
+
forwardedRef,
|
|
103
|
+
) {
|
|
104
|
+
const wrapperRef = useRef<ComponentRef<typeof View>>(null);
|
|
105
|
+
const containerRef = useRef<ComponentRef<typeof View>>(null);
|
|
106
|
+
const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
|
|
107
|
+
const [imageFailed, setImageFailed] = useState(false);
|
|
108
|
+
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
|
109
|
+
|
|
110
|
+
const hasMenu = Boolean(menuItems && menuItems.length > 0);
|
|
111
|
+
const isInteractive = onPress != null || hasMenu;
|
|
112
|
+
const isOpenControlled = typeof openProp === "boolean";
|
|
113
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
114
|
+
const isOpen = hasMenu && (isOpenControlled ? openProp : uncontrolledOpen);
|
|
115
|
+
|
|
116
|
+
useApplyWebClassName(containerRef, className);
|
|
117
|
+
|
|
118
|
+
const setOpen = useCallback(
|
|
119
|
+
(next: boolean) => {
|
|
120
|
+
if (!isOpenControlled) setUncontrolledOpen(next);
|
|
121
|
+
onOpenChange?.(next);
|
|
122
|
+
if (!next) setHoveredKey(null);
|
|
123
|
+
},
|
|
124
|
+
[isOpenControlled, onOpenChange],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const focusTrigger = useCallback(() => {
|
|
128
|
+
const node = containerRef.current as unknown as { focus?: () => void } | null;
|
|
129
|
+
node?.focus?.();
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const closeAndFocusTrigger = useCallback(() => {
|
|
133
|
+
setOpen(false);
|
|
134
|
+
focusTrigger();
|
|
135
|
+
}, [focusTrigger, setOpen]);
|
|
136
|
+
|
|
137
|
+
const handlePress = (event: GestureResponderEvent) => {
|
|
138
|
+
onPress?.(event);
|
|
139
|
+
if (hasMenu) setOpen(!isOpen);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleItemPress = useCallback(
|
|
143
|
+
(item: AvatarMenuItem) => {
|
|
144
|
+
if (item.disabled) return;
|
|
145
|
+
item.onPress();
|
|
146
|
+
closeAndFocusTrigger();
|
|
147
|
+
},
|
|
148
|
+
[closeAndFocusTrigger],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Close on outside click (web only; native has no document-level events).
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (Platform.OS !== "web" || !isOpen) return;
|
|
154
|
+
|
|
155
|
+
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
|
156
|
+
const wrapper = wrapperRef.current as unknown as {
|
|
157
|
+
contains?: (node: Node) => boolean;
|
|
158
|
+
} | null;
|
|
159
|
+
const target = event.target;
|
|
160
|
+
if (wrapper?.contains && target instanceof Node && !wrapper.contains(target)) {
|
|
161
|
+
setOpen(false);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
document.addEventListener("mousedown", handlePointerDown);
|
|
166
|
+
document.addEventListener("touchstart", handlePointerDown);
|
|
167
|
+
return () => {
|
|
168
|
+
document.removeEventListener("mousedown", handlePointerDown);
|
|
169
|
+
document.removeEventListener("touchstart", handlePointerDown);
|
|
170
|
+
};
|
|
171
|
+
}, [isOpen, setOpen]);
|
|
172
|
+
|
|
173
|
+
// Escape closes the menu and returns focus to the trigger (web only).
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (Platform.OS !== "web" || !isOpen) return;
|
|
176
|
+
|
|
177
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
178
|
+
if (event.key === "Escape") {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
closeAndFocusTrigger();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
185
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
186
|
+
}, [isOpen, closeAndFocusTrigger]);
|
|
187
|
+
|
|
188
|
+
const hasImage = Boolean(imageUrl) && !imageFailed;
|
|
189
|
+
const resolvedAccessibilityLabel = accessibilityLabel ?? [name, email].filter(Boolean).join(", ");
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<View ref={wrapperRef} style={[styles.wrapper, isOpen && styles.wrapperOpen]}>
|
|
193
|
+
<Pressable
|
|
194
|
+
{...rest}
|
|
195
|
+
ref={setContainerRef}
|
|
196
|
+
onPress={handlePress}
|
|
197
|
+
disabled={disabled || !isInteractive}
|
|
198
|
+
hitSlop={isInteractive ? hitSlop : undefined}
|
|
199
|
+
accessibilityRole={isInteractive ? "button" : undefined}
|
|
200
|
+
accessibilityLabel={resolvedAccessibilityLabel}
|
|
201
|
+
accessibilityState={
|
|
202
|
+
isInteractive ? { disabled, expanded: hasMenu ? isOpen : undefined } : undefined
|
|
203
|
+
}
|
|
204
|
+
style={[styles.root, disabled && styles.disabled, style]}
|
|
205
|
+
>
|
|
206
|
+
<View style={styles.imageWrap}>
|
|
207
|
+
{hasImage ? (
|
|
208
|
+
<Image
|
|
209
|
+
source={{ uri: imageUrl ?? undefined }}
|
|
210
|
+
style={styles.image}
|
|
211
|
+
onError={() => setImageFailed(true)}
|
|
212
|
+
/>
|
|
213
|
+
) : (
|
|
214
|
+
<View style={styles.imageFallback}>
|
|
215
|
+
<UserIcon size={FALLBACK_ICON_SIZE} color={colors.primary} />
|
|
216
|
+
</View>
|
|
217
|
+
)}
|
|
218
|
+
</View>
|
|
219
|
+
|
|
220
|
+
<View style={styles.textColumn}>
|
|
221
|
+
<Text style={styles.name} numberOfLines={1}>
|
|
222
|
+
{name}
|
|
223
|
+
</Text>
|
|
224
|
+
{email ? (
|
|
225
|
+
<Text style={styles.email} numberOfLines={1}>
|
|
226
|
+
{email}
|
|
227
|
+
</Text>
|
|
228
|
+
) : null}
|
|
229
|
+
</View>
|
|
230
|
+
|
|
231
|
+
{showChevron ? (
|
|
232
|
+
<View style={[styles.chevronSlot, isOpen && styles.chevronOpen]}>
|
|
233
|
+
<ChevronDownIcon size={CHEVRON_SIZE} color={isOpen ? colors.primary : colors.grey100} />
|
|
234
|
+
</View>
|
|
235
|
+
) : null}
|
|
236
|
+
</Pressable>
|
|
237
|
+
|
|
238
|
+
{hasMenu && isOpen ? (
|
|
239
|
+
<View style={[styles.menu, menuWidth != null && { right: undefined, width: menuWidth }]}>
|
|
240
|
+
{menuItems!.map((item) => {
|
|
241
|
+
const itemKey = item.key ?? item.label;
|
|
242
|
+
const isHovered = hoveredKey === itemKey;
|
|
243
|
+
const backgroundColor = isHovered
|
|
244
|
+
? (item.hoverColor ?? colors.grey600)
|
|
245
|
+
: (item.backgroundColor ?? "transparent");
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<Pressable
|
|
249
|
+
key={itemKey}
|
|
250
|
+
onPress={() => handleItemPress(item)}
|
|
251
|
+
disabled={item.disabled}
|
|
252
|
+
onHoverIn={() => setHoveredKey(itemKey)}
|
|
253
|
+
onHoverOut={() =>
|
|
254
|
+
setHoveredKey((current) => (current === itemKey ? null : current))
|
|
255
|
+
}
|
|
256
|
+
onPressIn={() => setHoveredKey(itemKey)}
|
|
257
|
+
onPressOut={() =>
|
|
258
|
+
setHoveredKey((current) => (current === itemKey ? null : current))
|
|
259
|
+
}
|
|
260
|
+
accessibilityRole="menuitem"
|
|
261
|
+
accessibilityState={{ disabled: item.disabled }}
|
|
262
|
+
style={[
|
|
263
|
+
styles.menuItem,
|
|
264
|
+
{ backgroundColor },
|
|
265
|
+
item.disabled && styles.menuItemDisabled,
|
|
266
|
+
]}
|
|
267
|
+
>
|
|
268
|
+
{item.icon ? <View style={styles.menuItemIcon}>{item.icon}</View> : null}
|
|
269
|
+
<Text style={styles.menuItemLabel} numberOfLines={1}>
|
|
270
|
+
{item.label}
|
|
271
|
+
</Text>
|
|
272
|
+
</Pressable>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
</View>
|
|
276
|
+
) : null}
|
|
277
|
+
</View>
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const styles = StyleSheet.create({
|
|
282
|
+
wrapper: {
|
|
283
|
+
position: "relative",
|
|
284
|
+
alignSelf: "flex-start",
|
|
285
|
+
},
|
|
286
|
+
wrapperOpen: {
|
|
287
|
+
zIndex: 20,
|
|
288
|
+
},
|
|
289
|
+
root: {
|
|
290
|
+
flexDirection: "row",
|
|
291
|
+
alignItems: "center",
|
|
292
|
+
alignSelf: "flex-start",
|
|
293
|
+
gap: 12,
|
|
294
|
+
paddingTop: 4,
|
|
295
|
+
paddingBottom: 4,
|
|
296
|
+
paddingLeft: 4,
|
|
297
|
+
paddingRight: 12,
|
|
298
|
+
borderRadius: CONTAINER_RADIUS,
|
|
299
|
+
backgroundColor: colors.grey700,
|
|
300
|
+
},
|
|
301
|
+
disabled: {
|
|
302
|
+
opacity: 0.6,
|
|
303
|
+
},
|
|
304
|
+
imageWrap: {
|
|
305
|
+
width: IMAGE_SIZE,
|
|
306
|
+
height: IMAGE_SIZE,
|
|
307
|
+
borderRadius: IMAGE_SIZE / 2,
|
|
308
|
+
overflow: "hidden",
|
|
309
|
+
flexShrink: 0,
|
|
310
|
+
},
|
|
311
|
+
image: {
|
|
312
|
+
width: IMAGE_SIZE,
|
|
313
|
+
height: IMAGE_SIZE,
|
|
314
|
+
},
|
|
315
|
+
imageFallback: {
|
|
316
|
+
width: IMAGE_SIZE,
|
|
317
|
+
height: IMAGE_SIZE,
|
|
318
|
+
alignItems: "center",
|
|
319
|
+
justifyContent: "center",
|
|
320
|
+
backgroundColor: colors.grey600,
|
|
321
|
+
},
|
|
322
|
+
textColumn: {
|
|
323
|
+
minWidth: 0,
|
|
324
|
+
flexShrink: 1,
|
|
325
|
+
gap: 3,
|
|
326
|
+
},
|
|
327
|
+
name: {
|
|
328
|
+
...avatarTypography.name,
|
|
329
|
+
color: colors.white,
|
|
330
|
+
},
|
|
331
|
+
email: {
|
|
332
|
+
...avatarTypography.email,
|
|
333
|
+
color: colors.grey0,
|
|
334
|
+
},
|
|
335
|
+
chevronSlot: {
|
|
336
|
+
width: CHEVRON_SIZE,
|
|
337
|
+
height: CHEVRON_SIZE,
|
|
338
|
+
alignItems: "center",
|
|
339
|
+
justifyContent: "center",
|
|
340
|
+
flexShrink: 0,
|
|
341
|
+
},
|
|
342
|
+
chevronOpen: {
|
|
343
|
+
transform: [{ rotate: "180deg" }],
|
|
344
|
+
},
|
|
345
|
+
menu: {
|
|
346
|
+
position: "absolute",
|
|
347
|
+
top: "100%",
|
|
348
|
+
left: 0,
|
|
349
|
+
right: 0,
|
|
350
|
+
marginTop: 8,
|
|
351
|
+
zIndex: 10,
|
|
352
|
+
borderRadius: MENU_RADIUS,
|
|
353
|
+
padding: 8,
|
|
354
|
+
gap: 8,
|
|
355
|
+
backgroundColor: colors.grey700,
|
|
356
|
+
...Platform.select({
|
|
357
|
+
web: {
|
|
358
|
+
boxShadow: `0 8px 24px ${colors.black}73`,
|
|
359
|
+
},
|
|
360
|
+
default: {
|
|
361
|
+
elevation: 8,
|
|
362
|
+
},
|
|
363
|
+
}),
|
|
364
|
+
},
|
|
365
|
+
menuItem: {
|
|
366
|
+
minHeight: 40,
|
|
367
|
+
borderRadius: MENU_ITEM_RADIUS,
|
|
368
|
+
paddingVertical: 12,
|
|
369
|
+
paddingHorizontal: 12,
|
|
370
|
+
flexDirection: "row",
|
|
371
|
+
alignItems: "center",
|
|
372
|
+
gap: 8,
|
|
373
|
+
},
|
|
374
|
+
menuItemDisabled: {
|
|
375
|
+
opacity: 0.5,
|
|
376
|
+
},
|
|
377
|
+
menuItemIcon: {
|
|
378
|
+
width: MENU_ITEM_ICON_SIZE,
|
|
379
|
+
height: MENU_ITEM_ICON_SIZE,
|
|
380
|
+
alignItems: "center",
|
|
381
|
+
justifyContent: "center",
|
|
382
|
+
flexShrink: 0,
|
|
383
|
+
},
|
|
384
|
+
menuItemLabel: {
|
|
385
|
+
...avatarTypography.menuItem,
|
|
386
|
+
color: colors.white,
|
|
387
|
+
flex: 1,
|
|
388
|
+
},
|
|
389
|
+
});
|
|
@@ -575,7 +575,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
|
|
|
575
575
|
</View>
|
|
576
576
|
|
|
577
577
|
<View style={[styles.iconSlot, isOpen && styles.chevronOpen]}>
|
|
578
|
-
<ChevronDownIcon size={ICON_SIZE} color={colors.grey100} />
|
|
578
|
+
<ChevronDownIcon size={ICON_SIZE} color={isOpen ? colors.primary : colors.grey100} />
|
|
579
579
|
</View>
|
|
580
580
|
</Pressable>
|
|
581
581
|
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import Svg, { Path } from "react-native-svg";
|
|
2
|
+
import { CALENDAR_PATH } from "./calendarPath";
|
|
3
|
+
import { colors } from "../../theme";
|
|
4
|
+
import type { IconProps } from "../types";
|
|
5
|
+
|
|
6
|
+
export { CALENDAR_PATH } from "./calendarPath";
|
|
7
|
+
|
|
8
|
+
/** Native — react-native-svg (peer dependency). */
|
|
9
|
+
export function CalendarIcon({ size = 20, color = colors.primary, strokeWidth = 1.5 }: IconProps) {
|
|
10
|
+
return (
|
|
11
|
+
<Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
|
12
|
+
<Path
|
|
13
|
+
d={CALENDAR_PATH}
|
|
14
|
+
stroke={color}
|
|
15
|
+
strokeWidth={strokeWidth}
|
|
16
|
+
strokeLinecap="round"
|
|
17
|
+
strokeLinejoin="round"
|
|
18
|
+
/>
|
|
19
|
+
</Svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CALENDAR_PATH } from "./calendarPath";
|
|
2
|
+
import { colors } from "../../theme";
|
|
3
|
+
import type { IconProps } from "../types";
|
|
4
|
+
|
|
5
|
+
/** Web / Storybook — plain SVG (no react-native-svg). */
|
|
6
|
+
export function CalendarIcon({ size = 20, color = colors.primary, strokeWidth = 1.5 }: IconProps) {
|
|
7
|
+
return (
|
|
8
|
+
<svg
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
viewBox="0 0 16 16"
|
|
12
|
+
fill="none"
|
|
13
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
14
|
+
aria-hidden
|
|
15
|
+
>
|
|
16
|
+
<path
|
|
17
|
+
d={CALENDAR_PATH}
|
|
18
|
+
stroke={color}
|
|
19
|
+
strokeWidth={strokeWidth}
|
|
20
|
+
strokeLinecap="round"
|
|
21
|
+
strokeLinejoin="round"
|
|
22
|
+
/>
|
|
23
|
+
</svg>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { CALENDAR_PATH } from "./calendarPath";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Calendar icon path — 16×16 viewBox. */
|
|
2
|
+
export const CALENDAR_PATH =
|
|
3
|
+
"M14 6.66668H2M14 8.33334V5.86668C14 4.74657 14 4.18652 13.782 3.7587C13.5903 3.38237 13.2843 3.07641 12.908 2.88466C12.4802 2.66668 11.9201 2.66668 10.8 2.66668H5.2C4.0799 2.66668 3.51984 2.66668 3.09202 2.88466C2.71569 3.07641 2.40973 3.38237 2.21799 3.7587C2 4.18652 2 4.74657 2 5.86668V11.4667C2 12.5868 2 13.1468 2.21799 13.5747C2.40973 13.951 2.71569 14.2569 3.09202 14.4487C3.51984 14.6667 4.0799 14.6667 5.2 14.6667H8M10.6667 1.33334V4.00001M5.33333 1.33334V4.00001M9.66667 12.6667L11 14L14 11";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CalendarIcon, CALENDAR_PATH } from "./CalendarIcon";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import Svg, { Path } from "react-native-svg";
|
|
2
|
+
import { HEART_PATH } from "./heartPath";
|
|
3
|
+
import { colors } from "../../theme";
|
|
4
|
+
import type { IconProps } from "../types";
|
|
5
|
+
|
|
6
|
+
export { HEART_PATH } from "./heartPath";
|
|
7
|
+
|
|
8
|
+
/** Native — react-native-svg (peer dependency). */
|
|
9
|
+
export function HeartIcon({ size = 20, color = colors.primary, strokeWidth = 1.5 }: IconProps) {
|
|
10
|
+
return (
|
|
11
|
+
<Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
|
12
|
+
<Path
|
|
13
|
+
d={HEART_PATH}
|
|
14
|
+
stroke={color}
|
|
15
|
+
strokeWidth={strokeWidth}
|
|
16
|
+
strokeLinecap="round"
|
|
17
|
+
strokeLinejoin="round"
|
|
18
|
+
fillRule="evenodd"
|
|
19
|
+
clipRule="evenodd"
|
|
20
|
+
/>
|
|
21
|
+
</Svg>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { HEART_PATH } from "./heartPath";
|
|
2
|
+
import { colors } from "../../theme";
|
|
3
|
+
import type { IconProps } from "../types";
|
|
4
|
+
|
|
5
|
+
/** Web / Storybook — plain SVG (no react-native-svg). */
|
|
6
|
+
export function HeartIcon({ size = 20, color = colors.primary, strokeWidth = 1.5 }: IconProps) {
|
|
7
|
+
return (
|
|
8
|
+
<svg
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
viewBox="0 0 16 16"
|
|
12
|
+
fill="none"
|
|
13
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
14
|
+
aria-hidden
|
|
15
|
+
>
|
|
16
|
+
<path
|
|
17
|
+
d={HEART_PATH}
|
|
18
|
+
stroke={color}
|
|
19
|
+
strokeWidth={strokeWidth}
|
|
20
|
+
strokeLinecap="round"
|
|
21
|
+
strokeLinejoin="round"
|
|
22
|
+
fillRule="evenodd"
|
|
23
|
+
clipRule="evenodd"
|
|
24
|
+
/>
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { HEART_PATH } from "./heartPath";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Heart icon path — 16×16 viewBox. */
|
|
2
|
+
export const HEART_PATH =
|
|
3
|
+
"M7.99547 3.42388C6.66257 1.8656 4.43987 1.44643 2.76984 2.87334C1.0998 4.30026 0.864686 6.68598 2.17617 8.3736C3.26659 9.77674 6.56656 12.7361 7.64811 13.6939C7.76911 13.801 7.82961 13.8546 7.90018 13.8757C7.96178 13.8941 8.02917 13.8941 8.09077 13.8757C8.16134 13.8546 8.22184 13.801 8.34284 13.6939C9.42439 12.7361 12.7244 9.77674 13.8148 8.3736C15.1263 6.68598 14.9199 4.28525 13.2211 2.87334C11.5224 1.46144 9.32838 1.8656 7.99547 3.42388Z";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HeartIcon, HEART_PATH } from "./HeartIcon";
|