@ecohouse/ui 0.1.28 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecohouse/ui",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Cross-platform presentation-only UI components for EcoHouse (React Native + React Native Web)",
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { fn } from "storybook/test";
3
+ import { StyleSheet, View } from "react-native";
4
+ import { AvatarSimple } from "./AvatarSimple";
5
+
6
+ const meta = {
7
+ title: "Components/AvatarSimple",
8
+ component: AvatarSimple,
9
+ args: {
10
+ name: "Աննա Հակոբյան",
11
+ email: "anna.hakobyan@example.com",
12
+ imageUrl: "https://i.pravatar.cc/150?img=47",
13
+ onLogOut: fn(),
14
+ logOutAccessibilityLabel: "Log out",
15
+ compact: false,
16
+ },
17
+ argTypes: {
18
+ compact: { control: "boolean" },
19
+ onLogOut: { action: "log out" },
20
+ },
21
+ parameters: {
22
+ backgrounds: { default: "black" },
23
+ },
24
+ } satisfies Meta<typeof AvatarSimple>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ export const Default: Story = {};
30
+
31
+ export const WithoutImage: Story = {
32
+ args: { imageUrl: undefined },
33
+ };
34
+
35
+ export const Compact: Story = {
36
+ args: { compact: true },
37
+ };
38
+
39
+ export const LongEmail: Story = {
40
+ args: {
41
+ email: "anna.hakobyan.very.long.email@example.com",
42
+ },
43
+ };
44
+
45
+ export const InSidebarFooter: Story = {
46
+ render: (args) => (
47
+ <View style={storyStyles.footer}>
48
+ <AvatarSimple {...args} />
49
+ </View>
50
+ ),
51
+ };
52
+
53
+ const storyStyles = StyleSheet.create({
54
+ footer: {
55
+ width: 300,
56
+ height: 80,
57
+ paddingVertical: 12,
58
+ paddingHorizontal: 16,
59
+ backgroundColor: "#090909",
60
+ justifyContent: "center",
61
+ },
62
+ });
@@ -0,0 +1,178 @@
1
+ import { forwardRef, useMemo, useRef, useState, type ComponentRef } from "react";
2
+ import {
3
+ Image,
4
+ Pressable,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type StyleProp,
9
+ type ViewProps,
10
+ type ViewStyle,
11
+ } from "react-native";
12
+ import { LogOutIcon } from "../../icons";
13
+ import { colors, fonts } from "../../theme";
14
+ import { mergeRefs } from "../../utils/mergeRefs";
15
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
16
+
17
+ export interface AvatarSimpleProps extends Omit<ViewProps, "style" | "children"> {
18
+ /** Logged-in user's display name. */
19
+ name: string;
20
+ /** Shown under the name, e.g. the user's email. */
21
+ email: string;
22
+ /** Photo URL; falls back to a solid placeholder when missing or when it fails to load. */
23
+ imageUrl?: string | null;
24
+ /** Called when the trailing logout control is pressed. */
25
+ onLogOut?: () => void;
26
+ /** Accessible label for the logout control. Defaults to `"Log out"`. */
27
+ logOutAccessibilityLabel?: string;
28
+ /**
29
+ * Compact mode — avatar image only (no card chrome, name, email, or logout).
30
+ * Used by collapsed account sidebars.
31
+ */
32
+ compact?: boolean;
33
+ style?: StyleProp<ViewStyle>;
34
+ className?: string;
35
+ }
36
+
37
+ const IMAGE_SIZE = 32;
38
+ const CARD_WIDTH = 268;
39
+ const CARD_HEIGHT = 56;
40
+ const LOG_OUT_SIZE = 16;
41
+
42
+ export const AvatarSimple = forwardRef<ComponentRef<typeof View>, AvatarSimpleProps>(
43
+ function AvatarSimple(
44
+ {
45
+ name,
46
+ email,
47
+ imageUrl,
48
+ onLogOut,
49
+ logOutAccessibilityLabel = "Log out",
50
+ compact = false,
51
+ style,
52
+ className,
53
+ ...rest
54
+ },
55
+ forwardedRef,
56
+ ) {
57
+ const containerRef = useRef<ComponentRef<typeof View>>(null);
58
+ const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
59
+ const [imageFailed, setImageFailed] = useState(false);
60
+ const showImage = Boolean(imageUrl) && !imageFailed;
61
+
62
+ useApplyWebClassName(containerRef, className);
63
+
64
+ return (
65
+ <View
66
+ {...rest}
67
+ ref={setContainerRef}
68
+ style={[styles.root, compact ? styles.rootCompact : styles.rootCard, style]}
69
+ accessibilityRole="summary"
70
+ accessibilityLabel={`${name}, ${email}`}
71
+ >
72
+ <View style={styles.avatar}>
73
+ {showImage ? (
74
+ <Image
75
+ source={{ uri: imageUrl ?? undefined }}
76
+ style={styles.avatarImage}
77
+ onError={() => setImageFailed(true)}
78
+ accessibilityIgnoresInvertColors
79
+ />
80
+ ) : null}
81
+ </View>
82
+
83
+ {!compact ? (
84
+ <View style={styles.details}>
85
+ <View style={styles.textBlock}>
86
+ <Text style={styles.name} numberOfLines={1}>
87
+ {name}
88
+ </Text>
89
+ <Text style={styles.email} numberOfLines={1}>
90
+ {email}
91
+ </Text>
92
+ </View>
93
+
94
+ <Pressable
95
+ onPress={onLogOut}
96
+ accessibilityRole="button"
97
+ accessibilityLabel={logOutAccessibilityLabel}
98
+ hitSlop={8}
99
+ style={styles.logOutButton}
100
+ >
101
+ <LogOutIcon color={colors.primary} size={LOG_OUT_SIZE} strokeWidth={2} />
102
+ </Pressable>
103
+ </View>
104
+ ) : null}
105
+ </View>
106
+ );
107
+ },
108
+ );
109
+
110
+ const styles = StyleSheet.create({
111
+ root: {
112
+ flexDirection: "row",
113
+ alignItems: "center",
114
+ overflow: "hidden",
115
+ },
116
+ rootCard: {
117
+ width: CARD_WIDTH,
118
+ height: CARD_HEIGHT,
119
+ gap: 12,
120
+ borderRadius: 12,
121
+ paddingVertical: 4,
122
+ paddingHorizontal: 12,
123
+ backgroundColor: colors.grey700,
124
+ },
125
+ rootCompact: {
126
+ width: IMAGE_SIZE,
127
+ height: IMAGE_SIZE,
128
+ borderRadius: IMAGE_SIZE,
129
+ backgroundColor: "transparent",
130
+ },
131
+ avatar: {
132
+ width: IMAGE_SIZE,
133
+ height: IMAGE_SIZE,
134
+ borderRadius: 56,
135
+ backgroundColor: colors.grey500,
136
+ overflow: "hidden",
137
+ flexShrink: 0,
138
+ },
139
+ avatarImage: {
140
+ width: IMAGE_SIZE,
141
+ height: IMAGE_SIZE,
142
+ },
143
+ details: {
144
+ flex: 1,
145
+ minWidth: 0,
146
+ flexDirection: "row",
147
+ alignItems: "center",
148
+ gap: 12,
149
+ },
150
+ textBlock: {
151
+ flex: 1,
152
+ minWidth: 0,
153
+ },
154
+ name: {
155
+ fontFamily: fonts.sans,
156
+ fontSize: 12,
157
+ fontWeight: "600",
158
+ lineHeight: 16,
159
+ letterSpacing: 0,
160
+ color: colors.white,
161
+ },
162
+ email: {
163
+ marginTop: 2,
164
+ fontFamily: fonts.sans,
165
+ fontSize: 12,
166
+ fontWeight: "400",
167
+ lineHeight: 16,
168
+ letterSpacing: 0,
169
+ color: colors.grey0,
170
+ },
171
+ logOutButton: {
172
+ width: LOG_OUT_SIZE,
173
+ height: LOG_OUT_SIZE,
174
+ alignItems: "center",
175
+ justifyContent: "center",
176
+ flexShrink: 0,
177
+ },
178
+ });
@@ -0,0 +1,2 @@
1
+ export { AvatarSimple } from "./AvatarSimple";
2
+ export type { AvatarSimpleProps } from "./AvatarSimple";
@@ -14,6 +14,12 @@ const optionsWithoutIcons = [
14
14
  { value: "second", label: "Label" },
15
15
  ] as const;
16
16
 
17
+ const threeOptions = [
18
+ { value: "active", label: "Ակտիվ" },
19
+ { value: "completed", label: "Ավարտված" },
20
+ { value: "cancelled", label: "Չեղարկված" },
21
+ ] as const;
22
+
17
23
  const multilingualOptions = [
18
24
  { value: "daily", label: "Օրավարձ" },
19
25
  { value: "overnight", label: "Գիշերակաց երկար տարբերակ" },
@@ -50,6 +56,22 @@ export const WithoutIcons: Story = {
50
56
  args: { options: optionsWithoutIcons, defaultValue: "first" },
51
57
  };
52
58
 
59
+ export const ThreeOptions: Story = {
60
+ args: {
61
+ options: threeOptions,
62
+ defaultValue: "completed",
63
+ fullWidth: false,
64
+ },
65
+ };
66
+
67
+ export const ThreeOptionsFullWidth: Story = {
68
+ render: () => (
69
+ <View style={storyStyles.container}>
70
+ <SegmentedToggle options={threeOptions} defaultValue="active" />
71
+ </View>
72
+ ),
73
+ };
74
+
53
75
  export const ContentWidth: Story = {
54
76
  args: { fullWidth: false },
55
77
  };
@@ -23,7 +23,13 @@ export interface SegmentedToggleOption {
23
23
  }
24
24
 
25
25
  export interface SegmentedToggleProps extends Omit<ViewProps, "style" | "children"> {
26
- options: readonly [SegmentedToggleOption, SegmentedToggleOption];
26
+ /**
27
+ * Segment options. Supports 2 or 3 options (e.g. Active / Completed / Cancelled).
28
+ * Passing fewer than 2 options is unsupported.
29
+ */
30
+ options:
31
+ | readonly [SegmentedToggleOption, SegmentedToggleOption]
32
+ | readonly [SegmentedToggleOption, SegmentedToggleOption, SegmentedToggleOption];
27
33
  value?: string;
28
34
  defaultValue?: string;
29
35
  onValueChange?: (value: string) => void;
@@ -40,6 +46,18 @@ const OPTION_HORIZONTAL_PADDING = 16;
40
46
  const OPTION_CONTENT_GAP = 12;
41
47
  const OPTION_MIN_WIDTH = 97;
42
48
  const OPTION_MEASUREMENT_TOLERANCE = 0.5;
49
+ const TRACK_PADDING = 4;
50
+ const OPTION_GAP = 4;
51
+
52
+ function buildIndicatorOutputRange(optionWidths: number[]): number[] {
53
+ const outputRange: number[] = [];
54
+ let offset = 0;
55
+ for (let index = 0; index < optionWidths.length; index += 1) {
56
+ outputRange.push(offset);
57
+ offset += optionWidths[index] + OPTION_GAP;
58
+ }
59
+ return outputRange;
60
+ }
43
61
 
44
62
  export const SegmentedToggle = forwardRef<ComponentRef<typeof View>, SegmentedToggleProps>(
45
63
  function SegmentedToggle(
@@ -58,6 +76,7 @@ export const SegmentedToggle = forwardRef<ComponentRef<typeof View>, SegmentedTo
58
76
  },
59
77
  forwardedRef,
60
78
  ) {
79
+ const optionCount = options.length;
61
80
  const containerRef = useRef<ComponentRef<typeof View>>(null);
62
81
  const isControlled = value !== undefined;
63
82
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
@@ -69,9 +88,14 @@ export const SegmentedToggle = forwardRef<ComponentRef<typeof View>, SegmentedTo
69
88
  const resolvedClassName = className?.trim() || undefined;
70
89
  const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
71
90
  const progress = useRef(new Animated.Value(selectedIndex)).current;
72
- const [contentWidths, setContentWidths] = useState<[number, number]>([0, 0]);
91
+ const [contentWidths, setContentWidths] = useState<number[]>(() =>
92
+ Array.from({ length: optionCount }, () => 0),
93
+ );
73
94
  const [containerWidth, setContainerWidth] = useState(0);
74
- const availableOptionWidth = Math.max(0, (containerWidth - 12) / 2);
95
+
96
+ // Track chrome: left+right padding + gaps between options.
97
+ const trackChrome = TRACK_PADDING * 2 + OPTION_GAP * (optionCount - 1);
98
+ const availableOptionWidth = Math.max(0, (containerWidth - trackChrome) / optionCount);
75
99
  const resolveContentOptionWidth = (contentWidth: number) =>
76
100
  contentWidth > 0
77
101
  ? Math.max(
@@ -79,17 +103,28 @@ export const SegmentedToggle = forwardRef<ComponentRef<typeof View>, SegmentedTo
79
103
  contentWidth + OPTION_HORIZONTAL_PADDING * 2 + OPTION_MEASUREMENT_TOLERANCE,
80
104
  )
81
105
  : 0;
82
- const optionWidths: [number, number] = fullWidth
83
- ? [availableOptionWidth, availableOptionWidth]
84
- : [resolveContentOptionWidth(contentWidths[0]), resolveContentOptionWidth(contentWidths[1])];
85
- const selectedOptionWidth = optionWidths[selectedIndex];
106
+
107
+ const optionWidths = fullWidth
108
+ ? Array.from({ length: optionCount }, () => availableOptionWidth)
109
+ : contentWidths.map((width) => resolveContentOptionWidth(width));
110
+
111
+ const selectedOptionWidth = optionWidths[selectedIndex] ?? 0;
112
+ const indicatorInputRange = options.map((_, index) => index);
113
+ const indicatorOutputRange = buildIndicatorOutputRange(optionWidths);
86
114
  const indicatorTranslateX = progress.interpolate({
87
- inputRange: [0, 1],
88
- outputRange: [0, optionWidths[0] + 4],
115
+ inputRange: indicatorInputRange,
116
+ outputRange: indicatorOutputRange,
89
117
  });
90
118
 
91
119
  useApplyWebClassName(containerRef, resolvedClassName);
92
120
 
121
+ useEffect(() => {
122
+ setContentWidths((current) => {
123
+ if (current.length === optionCount) return current;
124
+ return Array.from({ length: optionCount }, (_, index) => current[index] ?? 0);
125
+ });
126
+ }, [optionCount]);
127
+
93
128
  useEffect(() => {
94
129
  const animation = Animated.timing(progress, {
95
130
  toValue: selectedIndex,
@@ -139,7 +174,7 @@ export const SegmentedToggle = forwardRef<ComponentRef<typeof View>, SegmentedTo
139
174
  const nextWidth = event.nativeEvent.layout.width;
140
175
  setContentWidths((currentWidths) => {
141
176
  if (currentWidths[index] === nextWidth) return currentWidths;
142
- const nextWidths: [number, number] = [currentWidths[0], currentWidths[1]];
177
+ const nextWidths = [...currentWidths];
143
178
  nextWidths[index] = nextWidth;
144
179
  return nextWidths;
145
180
  });
@@ -164,6 +199,7 @@ export const SegmentedToggle = forwardRef<ComponentRef<typeof View>, SegmentedTo
164
199
  const selected = optionValue === selectedValue;
165
200
  const iconColor = selected ? colors.primary : colors.whiteAlpha20;
166
201
  const labelColor = selected ? colors.whiteAlpha86 : colors.whiteAlpha40;
202
+ const optionWidth = optionWidths[index] ?? 0;
167
203
 
168
204
  return (
169
205
  <Pressable
@@ -178,10 +214,7 @@ export const SegmentedToggle = forwardRef<ComponentRef<typeof View>, SegmentedTo
178
214
  styles.option,
179
215
  fullWidth
180
216
  ? styles.optionFullWidth
181
- : [
182
- styles.optionContentWidth,
183
- optionWidths[index] > 0 && { width: optionWidths[index] },
184
- ],
217
+ : [styles.optionContentWidth, optionWidth > 0 && { width: optionWidth }],
185
218
  ]}
186
219
  >
187
220
  <View style={styles.optionContent}>
@@ -209,8 +242,8 @@ const styles = StyleSheet.create({
209
242
  base: {
210
243
  height: 44,
211
244
  flexDirection: "row",
212
- padding: 4,
213
- gap: 4,
245
+ padding: TRACK_PADDING,
246
+ gap: OPTION_GAP,
214
247
  borderRadius: 60,
215
248
  backgroundColor: colors.grey700,
216
249
  overflow: "hidden",
@@ -255,8 +288,8 @@ const styles = StyleSheet.create({
255
288
  },
256
289
  activeIndicator: {
257
290
  position: "absolute",
258
- top: 4,
259
- left: 4,
291
+ top: TRACK_PADDING,
292
+ left: TRACK_PADDING,
260
293
  height: 36,
261
294
  backgroundColor: colors.grey600,
262
295
  borderRadius: 60,
@@ -0,0 +1,119 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { fn } from "storybook/test";
4
+ import { StyleSheet, View } from "react-native";
5
+ import {
6
+ CalendarIcon,
7
+ HeartIcon,
8
+ HelpCircleIcon,
9
+ LayoutGridIcon,
10
+ SettingsIcon,
11
+ VideoIcon,
12
+ } from "../../icons";
13
+ import { Sidebar } from "./Sidebar";
14
+
15
+ const defaultItems = [
16
+ { key: "bookings", label: "Ամրագրումներ", icon: CalendarIcon, active: true, onPress: fn() },
17
+ { key: "digitalKeys", label: "Թվային բանալիներ", icon: LayoutGridIcon, onPress: fn() },
18
+ { key: "cameras", label: "Տեսախցիկներ", icon: VideoIcon, onPress: fn() },
19
+ { key: "favorites", label: "Նախընտրածներ", icon: HeartIcon, onPress: fn() },
20
+ { key: "support", label: "Աջակցություն", icon: HelpCircleIcon, onPress: fn() },
21
+ { key: "settings", label: "Կարգավորումներ", icon: SettingsIcon, onPress: fn() },
22
+ ] as const;
23
+
24
+ const meta = {
25
+ title: "Components/Sidebar",
26
+ component: Sidebar,
27
+ args: {
28
+ items: [...defaultItems],
29
+ user: {
30
+ name: "Աննա Հակոբյան",
31
+ email: "anna.hakobyan@example.com",
32
+ imageUrl: "https://i.pravatar.cc/150?img=47",
33
+ },
34
+ onLogOut: fn(),
35
+ logoAccessibilityLabel: "Cortel Booking",
36
+ navigationAccessibilityLabel: "Հաշվի նավիգացիա",
37
+ collapseAccessibilityLabel: "Թաքցնել կողագոտին",
38
+ expandAccessibilityLabel: "Բացել կողագոտին",
39
+ logOutAccessibilityLabel: "Ելք",
40
+ defaultCollapsed: false,
41
+ },
42
+ argTypes: {
43
+ collapsed: { control: "boolean" },
44
+ defaultCollapsed: { control: "boolean" },
45
+ onLogOut: { action: "log out" },
46
+ onCollapsedChange: { action: "collapsed change" },
47
+ },
48
+ parameters: {
49
+ backgrounds: { default: "black" },
50
+ layout: "fullscreen",
51
+ },
52
+ decorators: [
53
+ (Story) => (
54
+ <View style={storyStyles.frame}>
55
+ <Story />
56
+ <View style={storyStyles.content} />
57
+ </View>
58
+ ),
59
+ ],
60
+ } satisfies Meta<typeof Sidebar>;
61
+
62
+ export default meta;
63
+ type Story = StoryObj<typeof meta>;
64
+
65
+ export const Default: Story = {};
66
+
67
+ export const Collapsed: Story = {
68
+ args: { defaultCollapsed: true },
69
+ };
70
+
71
+ export const WithoutImage: Story = {
72
+ args: {
73
+ user: {
74
+ name: "Աննա Հակոբյան",
75
+ email: "anna.hakobyan@example.com",
76
+ imageUrl: null,
77
+ },
78
+ },
79
+ };
80
+
81
+ function ControlledSidebar() {
82
+ const [collapsed, setCollapsed] = useState(false);
83
+ const [activeKey, setActiveKey] = useState("bookings");
84
+
85
+ return (
86
+ <Sidebar
87
+ collapsed={collapsed}
88
+ onCollapsedChange={setCollapsed}
89
+ onLogOut={fn()}
90
+ user={{
91
+ name: "Աննա Հակոբյան",
92
+ email: "anna.hakobyan@example.com",
93
+ imageUrl: "https://i.pravatar.cc/150?img=47",
94
+ }}
95
+ items={defaultItems.map((item) => ({
96
+ ...item,
97
+ active: item.key === activeKey,
98
+ onPress: () => setActiveKey(item.key),
99
+ }))}
100
+ />
101
+ );
102
+ }
103
+
104
+ export const Controlled: Story = {
105
+ render: () => <ControlledSidebar />,
106
+ };
107
+
108
+ const storyStyles = StyleSheet.create({
109
+ frame: {
110
+ flexDirection: "row",
111
+ height: 720,
112
+ width: "100%",
113
+ backgroundColor: "#000000",
114
+ },
115
+ content: {
116
+ flex: 1,
117
+ backgroundColor: "#090909",
118
+ },
119
+ });