@ecohouse/ui 0.1.29 → 0.1.30

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.
@@ -0,0 +1,305 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ComponentRef,
8
+ type ReactNode,
9
+ } from "react";
10
+ import {
11
+ Animated,
12
+ Easing,
13
+ Pressable,
14
+ StyleSheet,
15
+ View,
16
+ type StyleProp,
17
+ type ViewProps,
18
+ type ViewStyle,
19
+ } from "react-native";
20
+ import type { IconComponent } from "../../icons";
21
+ import { SidebarIcon } from "../../icons";
22
+ import { BrandLogo, MenuItem } from "../../primitives";
23
+ import { colors, fonts } from "../../theme";
24
+ import { mergeRefs } from "../../utils/mergeRefs";
25
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
26
+ import { AvatarSimple } from "../AvatarSimple";
27
+
28
+ export interface SidebarItem {
29
+ /** Stable identity for React keys. */
30
+ key: string;
31
+ label: string;
32
+ /** Icon component — colored by the sidebar based on active state. */
33
+ icon: IconComponent;
34
+ /** Marks the current route / selection. */
35
+ active?: boolean;
36
+ onPress?: () => void;
37
+ }
38
+
39
+ export interface SidebarUser {
40
+ name: string;
41
+ email: string;
42
+ imageUrl?: string | null;
43
+ }
44
+
45
+ export interface SidebarProps extends Omit<ViewProps, "style" | "children"> {
46
+ items: readonly SidebarItem[];
47
+ user: SidebarUser;
48
+ onLogOut?: () => void;
49
+ /** Accessible name for the brand mark. Defaults to `"Cortel Booking"`. */
50
+ logoAccessibilityLabel?: string;
51
+ /** Accessible name for the nav landmark. Defaults to `"Account navigation"`. */
52
+ navigationAccessibilityLabel?: string;
53
+ /** Accessible label when the sidebar is expanded. Defaults to `"Collapse sidebar"`. */
54
+ collapseAccessibilityLabel?: string;
55
+ /** Accessible label when the sidebar is collapsed. Defaults to `"Expand sidebar"`. */
56
+ expandAccessibilityLabel?: string;
57
+ /** Accessible label for the logout control. Defaults to `"Log out"`. */
58
+ logOutAccessibilityLabel?: string;
59
+ collapsed?: boolean;
60
+ defaultCollapsed?: boolean;
61
+ onCollapsedChange?: (collapsed: boolean) => void;
62
+ style?: StyleProp<ViewStyle>;
63
+ className?: string;
64
+ }
65
+
66
+ const SIDEBAR_WIDTH = 300;
67
+ const SIDEBAR_COLLAPSED_WIDTH = 72;
68
+ const HEADER_HEIGHT = 64;
69
+ const LOGO_WIDTH = 31;
70
+ const LOGO_HEIGHT = 44;
71
+ const TOGGLE_SIZE = 20;
72
+ const MENU_ICON_SIZE = 16;
73
+ const PROFILE_SECTION_HEIGHT = 80;
74
+ const SIDEBAR_TRANSITION_MS = 300;
75
+
76
+ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(function Sidebar(
77
+ {
78
+ items,
79
+ user,
80
+ onLogOut,
81
+ logoAccessibilityLabel = "Cortel Booking",
82
+ navigationAccessibilityLabel = "Account navigation",
83
+ collapseAccessibilityLabel = "Collapse sidebar",
84
+ expandAccessibilityLabel = "Expand sidebar",
85
+ logOutAccessibilityLabel = "Log out",
86
+ collapsed: collapsedProp,
87
+ defaultCollapsed = false,
88
+ onCollapsedChange,
89
+ style,
90
+ className,
91
+ ...rest
92
+ },
93
+ forwardedRef,
94
+ ) {
95
+ const containerRef = useRef<ComponentRef<typeof View>>(null);
96
+ const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
97
+ const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(defaultCollapsed);
98
+ const collapsed = collapsedProp ?? uncontrolledCollapsed;
99
+ const widthAnim = useRef(
100
+ new Animated.Value(collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH),
101
+ ).current;
102
+ const labelOpacity = useRef(new Animated.Value(collapsed ? 0 : 1)).current;
103
+
104
+ useApplyWebClassName(containerRef, className);
105
+
106
+ useEffect(() => {
107
+ const nextWidth = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
108
+ const nextOpacity = collapsed ? 0 : 1;
109
+ const timing = {
110
+ duration: SIDEBAR_TRANSITION_MS,
111
+ easing: Easing.inOut(Easing.ease),
112
+ useNativeDriver: false,
113
+ };
114
+
115
+ const widthAnimation = Animated.timing(widthAnim, { ...timing, toValue: nextWidth });
116
+ const opacityAnimation = Animated.timing(labelOpacity, { ...timing, toValue: nextOpacity });
117
+
118
+ Animated.parallel([widthAnimation, opacityAnimation]).start();
119
+ return () => {
120
+ widthAnimation.stop();
121
+ opacityAnimation.stop();
122
+ };
123
+ }, [collapsed, labelOpacity, widthAnim]);
124
+
125
+ const setCollapsed = (next: boolean) => {
126
+ onCollapsedChange?.(next);
127
+ if (collapsedProp === undefined) {
128
+ setUncontrolledCollapsed(next);
129
+ }
130
+ };
131
+
132
+ return (
133
+ <Animated.View
134
+ {...rest}
135
+ ref={setContainerRef}
136
+ style={[styles.root, { width: widthAnim }, style]}
137
+ accessibilityRole="menu"
138
+ >
139
+ <View style={[styles.header, collapsed ? styles.headerCollapsed : styles.headerExpanded]}>
140
+ <Animated.View
141
+ pointerEvents={collapsed ? "none" : "auto"}
142
+ style={[
143
+ styles.logoSlot,
144
+ {
145
+ opacity: labelOpacity,
146
+ maxWidth: collapsed ? 0 : LOGO_WIDTH,
147
+ },
148
+ ]}
149
+ accessibilityElementsHidden={collapsed}
150
+ importantForAccessibility={collapsed ? "no-hide-descendants" : "auto"}
151
+ >
152
+ <BrandLogo
153
+ accessibilityLabel={logoAccessibilityLabel}
154
+ height={LOGO_HEIGHT}
155
+ variant="brand"
156
+ width={LOGO_WIDTH}
157
+ />
158
+ </Animated.View>
159
+
160
+ <Pressable
161
+ accessibilityRole="button"
162
+ accessibilityLabel={collapsed ? expandAccessibilityLabel : collapseAccessibilityLabel}
163
+ accessibilityState={{ expanded: !collapsed }}
164
+ hitSlop={8}
165
+ onPress={() => setCollapsed(!collapsed)}
166
+ style={styles.toggleButton}
167
+ >
168
+ <SidebarIcon color={colors.grey100} size={TOGGLE_SIZE} strokeWidth={2} />
169
+ </Pressable>
170
+ </View>
171
+
172
+ <View
173
+ accessibilityLabel={navigationAccessibilityLabel}
174
+ accessibilityRole="menu"
175
+ style={styles.nav}
176
+ >
177
+ {items.map((item) => {
178
+ const isActive = Boolean(item.active);
179
+ const iconColor = isActive ? colors.primary : colors.grey300;
180
+ const Icon = item.icon;
181
+ const iconNode: ReactNode = (
182
+ <Icon color={iconColor} size={MENU_ICON_SIZE} strokeWidth={1.5} />
183
+ );
184
+
185
+ return (
186
+ <MenuItem
187
+ key={item.key}
188
+ icon={iconNode}
189
+ label={item.label}
190
+ accessibilityLabel={item.label}
191
+ backgroundColor={isActive ? colors.grey700 : "transparent"}
192
+ hoverColor={colors.grey700}
193
+ onPress={item.onPress}
194
+ labelStyle={[
195
+ styles.menuLabel,
196
+ { color: isActive ? colors.white : colors.grey200 },
197
+ collapsed ? styles.menuLabelCollapsed : styles.menuLabelExpanded,
198
+ ]}
199
+ style={[styles.menuItem, collapsed ? styles.menuItemCollapsed : null]}
200
+ />
201
+ );
202
+ })}
203
+ </View>
204
+
205
+ <View style={[styles.footer, collapsed ? styles.footerCollapsed : styles.footerExpanded]}>
206
+ <AvatarSimple
207
+ name={user.name}
208
+ email={user.email}
209
+ imageUrl={user.imageUrl}
210
+ onLogOut={onLogOut}
211
+ logOutAccessibilityLabel={logOutAccessibilityLabel}
212
+ compact={collapsed}
213
+ />
214
+ </View>
215
+ </Animated.View>
216
+ );
217
+ });
218
+
219
+ const styles = StyleSheet.create({
220
+ root: {
221
+ height: "100%",
222
+ flexShrink: 0,
223
+ backgroundColor: colors.dark,
224
+ borderRightWidth: 1,
225
+ borderRightColor: colors.grey700,
226
+ overflow: "hidden",
227
+ },
228
+ header: {
229
+ zIndex: 1,
230
+ height: HEADER_HEIGHT,
231
+ width: "100%",
232
+ flexDirection: "row",
233
+ alignItems: "center",
234
+ borderBottomWidth: 1,
235
+ borderBottomColor: colors.grey700,
236
+ backgroundColor: colors.dark,
237
+ },
238
+ headerExpanded: {
239
+ justifyContent: "space-between",
240
+ paddingHorizontal: 24,
241
+ },
242
+ headerCollapsed: {
243
+ justifyContent: "center",
244
+ paddingHorizontal: 0,
245
+ },
246
+ logoSlot: {
247
+ minWidth: 0,
248
+ height: LOGO_HEIGHT,
249
+ flexShrink: 0,
250
+ overflow: "hidden",
251
+ },
252
+ toggleButton: {
253
+ width: TOGGLE_SIZE,
254
+ height: TOGGLE_SIZE,
255
+ alignItems: "center",
256
+ justifyContent: "center",
257
+ flexShrink: 0,
258
+ },
259
+ nav: {
260
+ flex: 1,
261
+ width: "100%",
262
+ padding: 16,
263
+ gap: 10,
264
+ },
265
+ menuItem: {
266
+ gap: 8,
267
+ },
268
+ menuItemCollapsed: {
269
+ gap: 0,
270
+ },
271
+ menuLabel: {
272
+ fontFamily: fonts.sans,
273
+ fontSize: 12,
274
+ fontWeight: "600",
275
+ lineHeight: 16,
276
+ letterSpacing: 0,
277
+ },
278
+ menuLabelExpanded: {
279
+ flexGrow: 1,
280
+ flexShrink: 1,
281
+ opacity: 1,
282
+ },
283
+ menuLabelCollapsed: {
284
+ flexGrow: 0,
285
+ flexShrink: 0,
286
+ width: 0,
287
+ maxWidth: 0,
288
+ opacity: 0,
289
+ overflow: "hidden",
290
+ },
291
+ footer: {
292
+ height: PROFILE_SECTION_HEIGHT,
293
+ width: "100%",
294
+ flexShrink: 0,
295
+ alignItems: "center",
296
+ justifyContent: "center",
297
+ paddingVertical: 12,
298
+ },
299
+ footerExpanded: {
300
+ paddingHorizontal: 16,
301
+ },
302
+ footerCollapsed: {
303
+ paddingHorizontal: 0,
304
+ },
305
+ });
@@ -0,0 +1,2 @@
1
+ export { Sidebar } from "./Sidebar";
2
+ export type { SidebarProps, SidebarItem, SidebarUser } from "./Sidebar";
@@ -1,6 +1,9 @@
1
1
  export { Avatar } from "./Avatar";
2
2
  export type { AvatarProps, AvatarMenuItem } from "./Avatar";
3
3
 
4
+ export { AvatarSimple } from "./AvatarSimple";
5
+ export type { AvatarSimpleProps } from "./AvatarSimple";
6
+
4
7
  export { Badge } from "./Badge";
5
8
  export type { BadgeProps, BadgeVariant } from "./Badge";
6
9
 
@@ -10,6 +13,9 @@ export type { CounterProps } from "./Counter";
10
13
  export { SegmentedToggle } from "./SegmentedToggle";
11
14
  export type { SegmentedToggleOption, SegmentedToggleProps } from "./SegmentedToggle";
12
15
 
16
+ export { Sidebar } from "./Sidebar";
17
+ export type { SidebarProps, SidebarItem, SidebarUser } from "./Sidebar";
18
+
13
19
  export { LanguageSwitcher, defaultLanguageOptions } from "./LanguageSwitcher";
14
20
  export type { LanguageOption, LanguageSwitcherProps } from "./LanguageSwitcher";
15
21
 
@@ -1,3 +1,3 @@
1
1
  /** LogOut icon path — 16×16 viewBox. */
2
2
  export const LOG_OUT_PATH =
3
- "M10.6667 4.66667L14 8L10.6667 11.3333M14 8H6M8 11.3333C8 11.5304 8 11.6289 7.99268 11.7143C7.91655 12.6013 7.26324 13.3312 6.39002 13.5049C6.30602 13.5216 6.20801 13.5324 6.01223 13.5542L5.3313 13.6299C4.30832 13.7435 3.7968 13.8004 3.39044 13.6703C2.84862 13.4969 2.40628 13.101 2.17412 12.5817C2 12.1921 2 11.6775 2 10.6482V5.3518C2 4.32251 2 3.80787 2.17412 3.41835C2.40628 2.89899 2.84862 2.50307 3.39044 2.32969C3.79681 2.19965 4.3083 2.25648 5.33129 2.37015L6.01223 2.44581C6.20808 2.46757 6.306 2.47845 6.39002 2.49515C7.26324 2.66877 7.91655 3.39869 7.99268 4.28574C8 4.37109 8 4.46962 8 4.66667";
3
+ "M10.6667 4.66667L14 8L10.6667 11.3333M14 8H6M6 2H5.2C4.0799 2 3.51984 2 3.09202 2.21799C2.7157 2.40973 2.40973 2.71569 2.21799 3.09202C2 3.51984 2 4.07989 2 5.2V10.8C2 11.9201 2 12.4802 2.21799 12.908C2.40973 13.2843 2.71569 13.5903 3.09202 13.782C3.51984 14 4.0799 14 5.2 14H6";
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export {
2
2
  Avatar,
3
+ AvatarSimple,
3
4
  Badge,
4
5
  Counter,
5
6
  SegmentedToggle,
7
+ Sidebar,
6
8
  LanguageSwitcher,
7
9
  defaultLanguageOptions,
8
10
  StatCard,
@@ -21,11 +23,15 @@ export {
21
23
  export type {
22
24
  AvatarProps,
23
25
  AvatarMenuItem,
26
+ AvatarSimpleProps,
24
27
  BadgeProps,
25
28
  BadgeVariant,
26
29
  CounterProps,
27
30
  SegmentedToggleOption,
28
31
  SegmentedToggleProps,
32
+ SidebarProps,
33
+ SidebarItem,
34
+ SidebarUser,
29
35
  LanguageOption,
30
36
  LanguageSwitcherProps,
31
37
  StatCardProps,