@ecohouse/ui 0.1.6 → 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/README.md +13 -12
- package/dist/index.cjs +699 -102
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -3
- package/dist/index.d.ts +137 -3
- package/dist/index.js +690 -103
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/CommentCard/CommentCard.stories.tsx +31 -0
- package/src/components/CommentCard/CommentCard.tsx +209 -0
- package/src/components/CommentCard/index.ts +2 -0
- package/src/components/LanguageSwitcher/LanguageSwitcher.stories.tsx +52 -0
- package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +287 -0
- package/src/components/LanguageSwitcher/index.ts +2 -0
- package/src/components/RatingInput/RatingInput.stories.tsx +41 -0
- package/src/components/RatingInput/RatingInput.tsx +168 -0
- package/src/components/RatingInput/index.ts +2 -0
- package/src/components/index.ts +9 -0
- package/src/icons/Globe/GlobeIcon.tsx +20 -0
- package/src/icons/Globe/GlobeIcon.web.tsx +26 -0
- package/src/icons/Globe/globePath.ts +3 -0
- package/src/icons/Globe/index.ts +2 -0
- package/src/icons/Star/StarIcon.tsx +35 -0
- package/src/icons/Star/StarIcon.web.tsx +41 -0
- package/src/icons/Star/index.ts +2 -0
- package/src/icons/Star/starPath.ts +3 -0
- package/src/icons/StarFilled/StarFilledIcon.tsx +28 -0
- package/src/icons/StarFilled/StarFilledIcon.web.tsx +34 -0
- package/src/icons/StarFilled/index.ts +1 -0
- package/src/icons/index.ts +4 -0
- package/src/icons/registry.ts +9 -1
- package/src/index.ts +22 -1
- package/src/theme/index.ts +3 -0
- package/src/theme/typography.ts +50 -0
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { CommentCard } from "./CommentCard";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Components/Comment Card",
|
|
6
|
+
component: CommentCard,
|
|
7
|
+
args: {
|
|
8
|
+
userName: "Աննա Սարգսյան",
|
|
9
|
+
date: "Հուլիս 2026",
|
|
10
|
+
commentText:
|
|
11
|
+
"Աննկարագրելի գեղեցիկ և հանգիստ վայր։ QR-ով մուտքն անչափ հարմար էր, իսկ քոթեջի ներսում ամեն ինչ մտածված էր մինչև ամենափոքր մանրուքը։",
|
|
12
|
+
rating: 4,
|
|
13
|
+
imageUrl: "https://i.pravatar.cc/150?img=47",
|
|
14
|
+
},
|
|
15
|
+
parameters: {
|
|
16
|
+
backgrounds: { default: "black" },
|
|
17
|
+
},
|
|
18
|
+
} satisfies Meta<typeof CommentCard>;
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof meta>;
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {};
|
|
24
|
+
|
|
25
|
+
export const WithoutImage: Story = {
|
|
26
|
+
args: { imageUrl: undefined },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const ShortComment: Story = {
|
|
30
|
+
args: { commentText: "Հիանալի վայր և սպասարկում։" },
|
|
31
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { forwardRef, useEffect, useMemo, useRef, useState, type ComponentRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Image,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
type TextStyle,
|
|
10
|
+
type ViewProps,
|
|
11
|
+
type ViewStyle,
|
|
12
|
+
} from "react-native";
|
|
13
|
+
import { UserIcon } from "../../icons";
|
|
14
|
+
import { colors, commentCardTypography } from "../../theme";
|
|
15
|
+
import { mergeRefs } from "../../utils/mergeRefs";
|
|
16
|
+
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
17
|
+
import { RatingInput } from "../RatingInput";
|
|
18
|
+
|
|
19
|
+
export interface CommentCardProps extends Omit<ViewProps, "style" | "children"> {
|
|
20
|
+
userName: string;
|
|
21
|
+
date: string;
|
|
22
|
+
commentText: string;
|
|
23
|
+
rating: number;
|
|
24
|
+
imageUrl?: string | null;
|
|
25
|
+
maxCommentLines?: number;
|
|
26
|
+
maxCommentLength?: number;
|
|
27
|
+
readMoreLabel?: string;
|
|
28
|
+
readLessLabel?: string;
|
|
29
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
commentStyle?: StyleProp<TextStyle>;
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CARD_WIDTH = 370;
|
|
36
|
+
const CARD_MIN_HEIGHT = 173;
|
|
37
|
+
const AVATAR_SIZE = 44;
|
|
38
|
+
|
|
39
|
+
export const CommentCard = forwardRef<ComponentRef<typeof View>, CommentCardProps>(
|
|
40
|
+
function CommentCard(
|
|
41
|
+
{
|
|
42
|
+
userName,
|
|
43
|
+
date,
|
|
44
|
+
commentText,
|
|
45
|
+
rating,
|
|
46
|
+
imageUrl,
|
|
47
|
+
maxCommentLines = 3,
|
|
48
|
+
maxCommentLength = 111,
|
|
49
|
+
readMoreLabel = "Դիտել ավելին",
|
|
50
|
+
readLessLabel = "Փակել",
|
|
51
|
+
onExpandedChange,
|
|
52
|
+
style,
|
|
53
|
+
commentStyle,
|
|
54
|
+
className,
|
|
55
|
+
...rest
|
|
56
|
+
},
|
|
57
|
+
forwardedRef,
|
|
58
|
+
) {
|
|
59
|
+
const containerRef = useRef<ComponentRef<typeof View>>(null);
|
|
60
|
+
const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
|
|
61
|
+
const [imageFailed, setImageFailed] = useState(false);
|
|
62
|
+
const [expanded, setExpanded] = useState(false);
|
|
63
|
+
const [actionHovered, setActionHovered] = useState(false);
|
|
64
|
+
const hasImage = Boolean(imageUrl) && !imageFailed;
|
|
65
|
+
const shouldShowAction = Array.from(commentText).length >= maxCommentLength;
|
|
66
|
+
|
|
67
|
+
useApplyWebClassName(containerRef, className);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
setImageFailed(false);
|
|
71
|
+
}, [imageUrl]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
setExpanded(false);
|
|
75
|
+
setActionHovered(false);
|
|
76
|
+
}, [commentText, maxCommentLength, maxCommentLines]);
|
|
77
|
+
|
|
78
|
+
const toggleExpanded = () => {
|
|
79
|
+
const next = !expanded;
|
|
80
|
+
setExpanded(next);
|
|
81
|
+
onExpandedChange?.(next);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<View {...rest} ref={setContainerRef} style={[styles.root, style]}>
|
|
86
|
+
<View style={styles.header}>
|
|
87
|
+
<View style={styles.avatar}>
|
|
88
|
+
{hasImage ? (
|
|
89
|
+
<Image
|
|
90
|
+
source={{ uri: imageUrl ?? undefined }}
|
|
91
|
+
style={styles.avatarImage}
|
|
92
|
+
onError={() => setImageFailed(true)}
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<UserIcon size={20} color={colors.primary} />
|
|
96
|
+
)}
|
|
97
|
+
</View>
|
|
98
|
+
|
|
99
|
+
<View style={styles.identity}>
|
|
100
|
+
<Text numberOfLines={1} style={styles.name}>
|
|
101
|
+
{userName}
|
|
102
|
+
</Text>
|
|
103
|
+
<Text numberOfLines={1} style={styles.date}>
|
|
104
|
+
{date}
|
|
105
|
+
</Text>
|
|
106
|
+
</View>
|
|
107
|
+
|
|
108
|
+
<RatingInput value={rating} readOnly showValue={false} size={16} />
|
|
109
|
+
</View>
|
|
110
|
+
|
|
111
|
+
<Text
|
|
112
|
+
numberOfLines={expanded ? undefined : maxCommentLines}
|
|
113
|
+
style={[styles.comment, commentStyle]}
|
|
114
|
+
>
|
|
115
|
+
{commentText}
|
|
116
|
+
</Text>
|
|
117
|
+
|
|
118
|
+
{shouldShowAction ? (
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={toggleExpanded}
|
|
121
|
+
accessibilityRole="button"
|
|
122
|
+
accessibilityState={{ expanded }}
|
|
123
|
+
hitSlop={6}
|
|
124
|
+
onHoverIn={() => setActionHovered(true)}
|
|
125
|
+
onHoverOut={() => setActionHovered(false)}
|
|
126
|
+
style={styles.action}
|
|
127
|
+
>
|
|
128
|
+
<Text
|
|
129
|
+
style={[
|
|
130
|
+
styles.actionText,
|
|
131
|
+
expanded && styles.closeActionText,
|
|
132
|
+
actionHovered &&
|
|
133
|
+
(expanded ? styles.closeActionTextHovered : styles.openActionTextHovered),
|
|
134
|
+
]}
|
|
135
|
+
>
|
|
136
|
+
{expanded ? readLessLabel : readMoreLabel}
|
|
137
|
+
</Text>
|
|
138
|
+
</Pressable>
|
|
139
|
+
) : null}
|
|
140
|
+
</View>
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const styles = StyleSheet.create({
|
|
146
|
+
root: {
|
|
147
|
+
width: CARD_WIDTH,
|
|
148
|
+
minHeight: CARD_MIN_HEIGHT,
|
|
149
|
+
padding: 16,
|
|
150
|
+
gap: 12,
|
|
151
|
+
borderRadius: 12,
|
|
152
|
+
backgroundColor: colors.grey750,
|
|
153
|
+
},
|
|
154
|
+
header: {
|
|
155
|
+
flexDirection: "row",
|
|
156
|
+
alignItems: "center",
|
|
157
|
+
gap: 8,
|
|
158
|
+
},
|
|
159
|
+
avatar: {
|
|
160
|
+
width: AVATAR_SIZE,
|
|
161
|
+
height: AVATAR_SIZE,
|
|
162
|
+
flexShrink: 0,
|
|
163
|
+
alignItems: "center",
|
|
164
|
+
justifyContent: "center",
|
|
165
|
+
overflow: "hidden",
|
|
166
|
+
borderRadius: AVATAR_SIZE / 2,
|
|
167
|
+
backgroundColor: colors.grey600,
|
|
168
|
+
},
|
|
169
|
+
avatarImage: {
|
|
170
|
+
width: AVATAR_SIZE,
|
|
171
|
+
height: AVATAR_SIZE,
|
|
172
|
+
},
|
|
173
|
+
identity: {
|
|
174
|
+
minWidth: 0,
|
|
175
|
+
flex: 1,
|
|
176
|
+
gap: 4,
|
|
177
|
+
},
|
|
178
|
+
name: {
|
|
179
|
+
...commentCardTypography.name,
|
|
180
|
+
color: colors.white,
|
|
181
|
+
},
|
|
182
|
+
date: {
|
|
183
|
+
...commentCardTypography.date,
|
|
184
|
+
color: colors.grey0,
|
|
185
|
+
},
|
|
186
|
+
comment: {
|
|
187
|
+
...commentCardTypography.comment,
|
|
188
|
+
color: colors.white,
|
|
189
|
+
},
|
|
190
|
+
action: {
|
|
191
|
+
alignSelf: "flex-start",
|
|
192
|
+
marginTop: "auto",
|
|
193
|
+
minHeight: 16,
|
|
194
|
+
justifyContent: "center",
|
|
195
|
+
},
|
|
196
|
+
actionText: {
|
|
197
|
+
...commentCardTypography.action,
|
|
198
|
+
color: colors.white,
|
|
199
|
+
},
|
|
200
|
+
closeActionText: {
|
|
201
|
+
color: colors.primary,
|
|
202
|
+
},
|
|
203
|
+
openActionTextHovered: {
|
|
204
|
+
color: colors.primary,
|
|
205
|
+
},
|
|
206
|
+
closeActionTextHovered: {
|
|
207
|
+
color: colors.white,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
@@ -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,41 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { RatingInput } from "./RatingInput";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Components/Rating Input",
|
|
7
|
+
component: RatingInput,
|
|
8
|
+
args: {
|
|
9
|
+
defaultValue: 5,
|
|
10
|
+
showValue: true,
|
|
11
|
+
disabled: false,
|
|
12
|
+
},
|
|
13
|
+
argTypes: {
|
|
14
|
+
onValueChange: { action: "valueChange" },
|
|
15
|
+
},
|
|
16
|
+
parameters: {
|
|
17
|
+
backgrounds: { default: "black" },
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof RatingInput>;
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
export const WithValue: Story = {};
|
|
25
|
+
|
|
26
|
+
export const WithoutValue: Story = {
|
|
27
|
+
args: { showValue: false },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const ReadOnly: Story = {
|
|
31
|
+
args: { defaultValue: 4, readOnly: true },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function ControlledRatingInput() {
|
|
35
|
+
const [value, setValue] = useState(3);
|
|
36
|
+
return <RatingInput value={value} onValueChange={setValue} />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const Controlled: Story = {
|
|
40
|
+
render: () => <ControlledRatingInput />,
|
|
41
|
+
};
|