@ecohouse/ui 0.1.7 → 0.1.8

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.7",
3
+ "version": "0.1.8",
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,52 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { View } from "react-native";
4
+ import { LanguageSwitcher } from "./LanguageSwitcher";
5
+
6
+ const meta = {
7
+ title: "Components/LanguageSwitcher",
8
+ component: LanguageSwitcher,
9
+ args: {
10
+ defaultValue: "hy",
11
+ },
12
+ parameters: {
13
+ backgrounds: { default: "black" },
14
+ },
15
+ } satisfies Meta<typeof LanguageSwitcher>;
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ export const Default: Story = {};
21
+
22
+ export const Open: Story = {
23
+ args: { defaultOpen: true },
24
+ };
25
+
26
+ export const Disabled: Story = {
27
+ args: { disabled: true },
28
+ };
29
+
30
+ function ControlledExample() {
31
+ const [value, setValue] = useState("hy");
32
+ return <LanguageSwitcher value={value} onValueChange={setValue} />;
33
+ }
34
+
35
+ export const Controlled: Story = {
36
+ render: () => <ControlledExample />,
37
+ };
38
+
39
+ export const CustomLanguages: Story = {
40
+ render: () => (
41
+ <View style={{ alignItems: "flex-start" }}>
42
+ <LanguageSwitcher
43
+ defaultValue="fr"
44
+ options={[
45
+ { value: "fr", label: "Fra" },
46
+ { value: "de", label: "Deu" },
47
+ { value: "it", label: "Ita" },
48
+ ]}
49
+ />
50
+ </View>
51
+ ),
52
+ };
@@ -0,0 +1,287 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ } from "react";
10
+ import {
11
+ Platform,
12
+ Pressable,
13
+ StyleSheet,
14
+ Text,
15
+ View,
16
+ type PressableProps,
17
+ type StyleProp,
18
+ type ViewProps,
19
+ type ViewStyle,
20
+ } from "react-native";
21
+ import { ChevronDownIcon, GlobeIcon } from "../../icons";
22
+ import { colors, fonts, languageSwitcherTypography } from "../../theme";
23
+ import { mergeRefs } from "../../utils/mergeRefs";
24
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
25
+
26
+ export interface LanguageOption {
27
+ /** Stable locale identifier, such as `hy`, `en`, or `ru`. */
28
+ value: string;
29
+ /** Short label shown in the trigger and menu. */
30
+ label: string;
31
+ }
32
+
33
+ export interface LanguageSwitcherProps extends Omit<ViewProps, "style" | "children"> {
34
+ options?: readonly LanguageOption[];
35
+ value?: string;
36
+ defaultValue?: string;
37
+ onValueChange?: (value: string) => void;
38
+ open?: boolean;
39
+ defaultOpen?: boolean;
40
+ onOpenChange?: (open: boolean) => void;
41
+ disabled?: boolean;
42
+ accessibilityLabel?: string;
43
+ style?: StyleProp<ViewStyle>;
44
+ className?: string;
45
+ hitSlop?: PressableProps["hitSlop"];
46
+ }
47
+
48
+ export const defaultLanguageOptions = [
49
+ { value: "hy", label: "Հայ" },
50
+ { value: "en", label: "Eng" },
51
+ { value: "ru", label: "Рус" },
52
+ ] as const satisfies readonly LanguageOption[];
53
+
54
+ const CONTROL_WIDTH = 90;
55
+ const CONTROL_HEIGHT = 35;
56
+ const ICON_SIZE = 16;
57
+
58
+ export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSwitcherProps>(
59
+ function LanguageSwitcher(
60
+ {
61
+ options = defaultLanguageOptions,
62
+ value,
63
+ defaultValue,
64
+ onValueChange,
65
+ open: openProp,
66
+ defaultOpen = false,
67
+ onOpenChange,
68
+ disabled = false,
69
+ accessibilityLabel = "Language",
70
+ style,
71
+ className,
72
+ hitSlop = 8,
73
+ ...rest
74
+ },
75
+ forwardedRef,
76
+ ) {
77
+ const wrapperRef = useRef<ComponentRef<typeof View>>(null);
78
+ const triggerRef = useRef<ComponentRef<typeof Pressable>>(null);
79
+ const setWrapperRef = useMemo(() => mergeRefs(wrapperRef, forwardedRef), [forwardedRef]);
80
+ const firstValue = options[0]?.value ?? "";
81
+ const isValueControlled = value !== undefined;
82
+ const isOpenControlled = openProp !== undefined;
83
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? firstValue);
84
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
85
+ const [triggerHovered, setTriggerHovered] = useState(false);
86
+ const [hoveredValue, setHoveredValue] = useState<string | null>(null);
87
+ const selectedValue = isValueControlled ? value : uncontrolledValue;
88
+ const isOpen = isOpenControlled ? openProp : uncontrolledOpen;
89
+ const selectedOption =
90
+ options.find((option) => option.value === selectedValue) ?? options[0] ?? null;
91
+
92
+ useApplyWebClassName(wrapperRef, className?.trim() || undefined);
93
+
94
+ const setOpen = useCallback(
95
+ (next: boolean) => {
96
+ if (disabled || next === isOpen) return;
97
+ if (!isOpenControlled) setUncontrolledOpen(next);
98
+ onOpenChange?.(next);
99
+ },
100
+ [disabled, isOpen, isOpenControlled, onOpenChange],
101
+ );
102
+
103
+ const closeAndFocusTrigger = useCallback(() => {
104
+ setOpen(false);
105
+ const node = triggerRef.current as unknown as { focus?: () => void } | null;
106
+ node?.focus?.();
107
+ }, [setOpen]);
108
+
109
+ useEffect(() => {
110
+ if (Platform.OS !== "web" || !isOpen) return;
111
+
112
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
113
+ const wrapper = wrapperRef.current as unknown as {
114
+ contains?: (node: Node) => boolean;
115
+ } | null;
116
+ const target = event.target;
117
+ if (wrapper?.contains && target instanceof Node && !wrapper.contains(target)) {
118
+ setOpen(false);
119
+ }
120
+ };
121
+ const handleKeyDown = (event: KeyboardEvent) => {
122
+ if (event.key === "Escape") {
123
+ event.preventDefault();
124
+ closeAndFocusTrigger();
125
+ }
126
+ };
127
+
128
+ document.addEventListener("mousedown", handlePointerDown);
129
+ document.addEventListener("touchstart", handlePointerDown);
130
+ document.addEventListener("keydown", handleKeyDown);
131
+ return () => {
132
+ document.removeEventListener("mousedown", handlePointerDown);
133
+ document.removeEventListener("touchstart", handlePointerDown);
134
+ document.removeEventListener("keydown", handleKeyDown);
135
+ };
136
+ }, [closeAndFocusTrigger, isOpen, setOpen]);
137
+
138
+ const selectLanguage = (nextValue: string) => {
139
+ if (nextValue !== selectedValue) {
140
+ if (!isValueControlled) setUncontrolledValue(nextValue);
141
+ onValueChange?.(nextValue);
142
+ }
143
+ closeAndFocusTrigger();
144
+ };
145
+
146
+ return (
147
+ <View
148
+ {...rest}
149
+ ref={setWrapperRef}
150
+ style={[styles.wrapper, isOpen && styles.wrapperOpen, disabled && styles.disabled, style]}
151
+ >
152
+ <Pressable
153
+ ref={triggerRef}
154
+ onPress={() => setOpen(!isOpen)}
155
+ disabled={disabled || options.length === 0}
156
+ hitSlop={hitSlop}
157
+ accessibilityRole="button"
158
+ accessibilityLabel={accessibilityLabel}
159
+ accessibilityState={{ disabled, expanded: isOpen }}
160
+ onHoverIn={() => setTriggerHovered(true)}
161
+ onHoverOut={() => setTriggerHovered(false)}
162
+ style={({ pressed }) => [
163
+ styles.trigger,
164
+ (triggerHovered || pressed || isOpen) && styles.triggerActive,
165
+ ]}
166
+ >
167
+ <GlobeIcon size={ICON_SIZE} color={colors.white} strokeWidth={2} />
168
+ <Text style={styles.triggerLabel} numberOfLines={1}>
169
+ {selectedOption?.label ?? ""}
170
+ </Text>
171
+ <View style={[styles.chevron, isOpen && styles.chevronOpen]}>
172
+ <ChevronDownIcon size={ICON_SIZE} color={colors.white} strokeWidth={2} />
173
+ </View>
174
+ </Pressable>
175
+
176
+ {isOpen && options.length > 0 ? (
177
+ <View style={styles.menu} accessibilityRole="menu">
178
+ {options.map((option) => {
179
+ const selected = option.value === selectedValue;
180
+ return (
181
+ <Pressable
182
+ key={option.value}
183
+ onPress={() => selectLanguage(option.value)}
184
+ accessibilityRole="menuitem"
185
+ accessibilityLabel={option.label}
186
+ accessibilityState={{ selected }}
187
+ onHoverIn={() => setHoveredValue(option.value)}
188
+ onHoverOut={() => setHoveredValue(null)}
189
+ style={({ pressed }) => [
190
+ styles.item,
191
+ selected
192
+ ? styles.itemSelected
193
+ : (hoveredValue === option.value || pressed) && styles.itemHovered,
194
+ ]}
195
+ >
196
+ <Text style={[styles.itemLabel, selected && styles.itemLabelSelected]}>
197
+ {option.label}
198
+ </Text>
199
+ </Pressable>
200
+ );
201
+ })}
202
+ </View>
203
+ ) : null}
204
+ </View>
205
+ );
206
+ },
207
+ );
208
+
209
+ const styles = StyleSheet.create({
210
+ wrapper: {
211
+ position: "relative",
212
+ width: CONTROL_WIDTH,
213
+ alignSelf: "flex-start",
214
+ },
215
+ wrapperOpen: {
216
+ zIndex: 20,
217
+ },
218
+ disabled: {
219
+ opacity: 0.6,
220
+ },
221
+ trigger: {
222
+ width: CONTROL_WIDTH,
223
+ height: CONTROL_HEIGHT,
224
+ flexDirection: "row",
225
+ alignItems: "center",
226
+ gap: 8,
227
+ padding: 8,
228
+ borderRadius: 12,
229
+ backgroundColor: "transparent",
230
+ },
231
+ triggerActive: {
232
+ backgroundColor: colors.grey700,
233
+ },
234
+ triggerLabel: {
235
+ ...languageSwitcherTypography,
236
+ ...Platform.select({ web: { fontFamily: `${fonts.sans}, sans-serif` } }),
237
+ minWidth: 0,
238
+ flex: 1,
239
+ color: colors.white,
240
+ },
241
+ chevron: {
242
+ width: ICON_SIZE,
243
+ height: ICON_SIZE,
244
+ flexShrink: 0,
245
+ alignItems: "center",
246
+ justifyContent: "center",
247
+ },
248
+ chevronOpen: {
249
+ transform: [{ rotate: "180deg" }],
250
+ },
251
+ menu: {
252
+ position: "absolute",
253
+ top: CONTROL_HEIGHT + 8,
254
+ left: 0,
255
+ width: CONTROL_WIDTH,
256
+ padding: 8,
257
+ gap: 8,
258
+ borderRadius: 12,
259
+ backgroundColor: colors.grey700,
260
+ ...Platform.select({
261
+ web: { boxShadow: `0 8px 24px ${colors.black}73` },
262
+ default: { elevation: 8 },
263
+ }),
264
+ },
265
+ item: {
266
+ height: CONTROL_HEIGHT,
267
+ alignItems: "center",
268
+ justifyContent: "center",
269
+ paddingVertical: 8,
270
+ paddingHorizontal: 12,
271
+ borderRadius: 8,
272
+ },
273
+ itemHovered: {
274
+ backgroundColor: colors.whiteAlpha5,
275
+ },
276
+ itemSelected: {
277
+ backgroundColor: colors.primaryMuted,
278
+ },
279
+ itemLabel: {
280
+ ...languageSwitcherTypography,
281
+ ...Platform.select({ web: { fontFamily: `${fonts.sans}, sans-serif` } }),
282
+ color: colors.white,
283
+ },
284
+ itemLabelSelected: {
285
+ color: colors.primary,
286
+ },
287
+ });
@@ -0,0 +1,2 @@
1
+ export { LanguageSwitcher, defaultLanguageOptions } from "./LanguageSwitcher";
2
+ export type { LanguageOption, LanguageSwitcherProps } from "./LanguageSwitcher";
@@ -10,6 +10,9 @@ export type { CounterProps } from "./Counter";
10
10
  export { SegmentedToggle } from "./SegmentedToggle";
11
11
  export type { SegmentedToggleOption, SegmentedToggleProps } from "./SegmentedToggle";
12
12
 
13
+ export { LanguageSwitcher, defaultLanguageOptions } from "./LanguageSwitcher";
14
+ export type { LanguageOption, LanguageSwitcherProps } from "./LanguageSwitcher";
15
+
13
16
  export { StatCard } from "./StatCard";
14
17
  export type { StatCardProps } from "./StatCard";
15
18
 
@@ -0,0 +1,20 @@
1
+ import Svg, { Path } from "react-native-svg";
2
+ import { colors } from "../../theme";
3
+ import type { IconProps } from "../types";
4
+ import { GLOBE_PATH } from "./globePath";
5
+
6
+ export function GlobeIcon({ size = 20, color = colors.primary, strokeWidth = 2 }: IconProps) {
7
+ return (
8
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill="none">
9
+ <Path
10
+ d={GLOBE_PATH}
11
+ stroke={color}
12
+ strokeWidth={strokeWidth}
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ />
16
+ </Svg>
17
+ );
18
+ }
19
+
20
+ export { GLOBE_PATH } from "./globePath";
@@ -0,0 +1,26 @@
1
+ import { colors } from "../../theme";
2
+ import type { IconProps } from "../types";
3
+ import { GLOBE_PATH } from "./globePath";
4
+
5
+ export function GlobeIcon({ size = 20, color = colors.primary, strokeWidth = 2 }: IconProps) {
6
+ return (
7
+ <svg
8
+ width={size}
9
+ height={size}
10
+ viewBox="0 0 20 20"
11
+ fill="none"
12
+ xmlns="http://www.w3.org/2000/svg"
13
+ aria-hidden
14
+ >
15
+ <path
16
+ d={GLOBE_PATH}
17
+ stroke={color}
18
+ strokeWidth={strokeWidth}
19
+ strokeLinecap="round"
20
+ strokeLinejoin="round"
21
+ />
22
+ </svg>
23
+ );
24
+ }
25
+
26
+ export { GLOBE_PATH } from "./globePath";
@@ -0,0 +1,3 @@
1
+ /** Globe paths — 20×20 viewBox. */
2
+ export const GLOBE_PATH =
3
+ "M18.3333 10C18.3333 14.6024 14.6024 18.3333 10 18.3333M18.3333 10C18.3333 5.39763 14.6024 1.66667 10 1.66667M18.3333 10H1.66667M10 18.3333C5.39763 18.3333 1.66667 14.6024 1.66667 10M10 18.3333C12.0833 16.0511 13.2675 13.0836 13.3333 10C13.2675 6.91643 12.0833 3.94892 10 1.66667M10 18.3333C7.91667 16.0511 6.73248 13.0836 6.66667 10C6.73248 6.91643 7.91667 3.94892 10 1.66667M1.66667 10C1.66667 5.39763 5.39763 1.66667 10 1.66667";
@@ -0,0 +1,2 @@
1
+ export { GlobeIcon } from "./GlobeIcon";
2
+ export { GLOBE_PATH } from "./globePath";
@@ -19,6 +19,7 @@ export { ChevronLeftIcon } from "./ChevronLeft";
19
19
  export { ClockIcon } from "./Clock";
20
20
  export { FacebookIcon } from "./Facebook";
21
21
  export { HeartIcon } from "./Heart";
22
+ export { GlobeIcon } from "./Globe";
22
23
  export { HelpCircleIcon } from "./HelpCircle";
23
24
  export { HomeIcon } from "./Home";
24
25
  export { InstagramIcon } from "./Instagram";
@@ -12,6 +12,7 @@ import { ChevronLeftIcon } from "./ChevronLeft";
12
12
  import { ClockIcon } from "./Clock";
13
13
  import { FacebookIcon } from "./Facebook";
14
14
  import { HeartIcon } from "./Heart";
15
+ import { GlobeIcon } from "./Globe";
15
16
  import { HelpCircleIcon } from "./HelpCircle";
16
17
  import { HomeIcon } from "./Home";
17
18
  import { InstagramIcon } from "./Instagram";
@@ -50,6 +51,7 @@ export const icons = {
50
51
  clock: ClockIcon,
51
52
  facebook: FacebookIcon,
52
53
  heart: HeartIcon,
54
+ globe: GlobeIcon,
53
55
  helpCircle: HelpCircleIcon,
54
56
  home: HomeIcon,
55
57
  instagram: InstagramIcon,
@@ -98,7 +100,7 @@ export const iconGroups = {
98
100
  objects: ["bag", "building", "clock", "user", "usersAlt", "video"],
99
101
  communication: ["mail", "phone"],
100
102
  social: ["facebook", "instagram", "linkedin"],
101
- interface: ["layoutGrid", "sidebar", "settings", "helpCircle", "logOut"],
103
+ interface: ["globe", "layoutGrid", "sidebar", "settings", "helpCircle", "logOut"],
102
104
  } as const satisfies Record<string, readonly IconName[]>;
103
105
 
104
106
  export type IconGroup = keyof typeof iconGroups;
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ export {
3
3
  Badge,
4
4
  Counter,
5
5
  SegmentedToggle,
6
+ LanguageSwitcher,
7
+ defaultLanguageOptions,
6
8
  StatCard,
7
9
  Button,
8
10
  Checkbox,
@@ -22,6 +24,8 @@ export type {
22
24
  CounterProps,
23
25
  SegmentedToggleOption,
24
26
  SegmentedToggleProps,
27
+ LanguageOption,
28
+ LanguageSwitcherProps,
25
29
  StatCardProps,
26
30
  ButtonProps,
27
31
  ButtonSize,
@@ -68,6 +72,7 @@ export {
68
72
  ClockIcon,
69
73
  FacebookIcon,
70
74
  HeartIcon,
75
+ GlobeIcon,
71
76
  HelpCircleIcon,
72
77
  HomeIcon,
73
78
  InstagramIcon,
@@ -106,6 +111,7 @@ export {
106
111
  badgeTypography,
107
112
  statCardTypography,
108
113
  segmentedToggleTypography,
114
+ languageSwitcherTypography,
109
115
  counterTypography,
110
116
  ratingTypography,
111
117
  commentCardTypography,
@@ -7,6 +7,7 @@ export {
7
7
  badgeTypography,
8
8
  statCardTypography,
9
9
  segmentedToggleTypography,
10
+ languageSwitcherTypography,
10
11
  counterTypography,
11
12
  ratingTypography,
12
13
  commentCardTypography,
@@ -47,6 +47,15 @@ export const segmentedToggleTypography = {
47
47
  letterSpacing: 0,
48
48
  } as const;
49
49
 
50
+ /** Language switcher trigger and option labels — SemiBold 14. */
51
+ export const languageSwitcherTypography = {
52
+ fontFamily: fonts.sans,
53
+ fontSize: 14,
54
+ fontWeight: "600" as const,
55
+ lineHeight: 19,
56
+ letterSpacing: 0,
57
+ } as const;
58
+
50
59
  /** Counter value — Bold 14, line-height 100%, letter-spacing 0. */
51
60
  export const counterTypography = {
52
61
  fontFamily: fonts.sans,