rn-vs-lb 1.0.57 → 1.0.58

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,202 @@
1
+ import React from 'react';
2
+ import type { Meta, StoryFn } from '@storybook/react';
3
+ import { Button, FlatList, View } from 'react-native';
4
+ import GuestAiChatModalView, { type PureChatMessage } from './GuestAiChatModal';
5
+ import appTheme, { SIZES } from '../../theme/theme';
6
+ import { getTypography } from '../../theme/styles/styleSheet';
7
+
8
+ type GuestAiChatProps = React.ComponentProps<typeof GuestAiChatModalView>;
9
+
10
+ const theme = appTheme.lightTheme;
11
+ const typography = getTypography(theme);
12
+
13
+ const sampleMessages: PureChatMessage[] = [
14
+ { id: '1', role: 'assistant', content: 'Привет! Я помогу найти событие мечты.' },
15
+ { id: '2', role: 'user', content: 'Покажи вечеринки на выходных.' },
16
+ {
17
+ id: '3',
18
+ role: 'assistant',
19
+ content:
20
+ 'Вот несколько вариантов: концерт в субботу, гастрономический фестиваль и выставка.\nВведите уточнение, чтобы подобрать лучше.',
21
+ },
22
+ ];
23
+
24
+ const meta = {
25
+ title: 'Modals/GuestAiChatModal',
26
+ component: GuestAiChatModalView,
27
+ argTypes: {
28
+ onClose: { action: 'close modal' },
29
+ onSend: { action: 'send message' },
30
+ onChangeInput: { action: 'change input' },
31
+ listRef: {
32
+ table: {
33
+ disable: true,
34
+ },
35
+ },
36
+ theme: { table: { disable: true } },
37
+ typography: { table: { disable: true } },
38
+ sizes: { table: { disable: true } },
39
+ },
40
+ args: {
41
+ visible: true,
42
+ botName: 'AI гид',
43
+ messages: sampleMessages,
44
+ inputValue: '',
45
+ error: null,
46
+ isSending: false,
47
+ limit: 10,
48
+ remaining: 10,
49
+ theme,
50
+ typography,
51
+ sizes: SIZES,
52
+ },
53
+ decorators: [
54
+ (StoryComponent) => (
55
+ <View
56
+ style={{
57
+ flex: 1,
58
+ minHeight: 640,
59
+ justifyContent: 'flex-end',
60
+ backgroundColor: '#0b0b0b55',
61
+ paddingBottom: 24,
62
+ }}
63
+ >
64
+ <StoryComponent />
65
+ </View>
66
+ ),
67
+ ],
68
+ parameters: {
69
+ layout: 'fullscreen',
70
+ docs: {
71
+ description: {
72
+ component:
73
+ 'Guest chat modal that lets unauthorised users ask AI-powered questions before signing in.',
74
+ },
75
+ },
76
+ },
77
+ } satisfies Meta<typeof GuestAiChatModalView>;
78
+
79
+ export default meta;
80
+
81
+ const Template: StoryFn<GuestAiChatProps> = (args) => {
82
+ const { onClose, onSend, ...rest } = args;
83
+
84
+ const [isVisible, setIsVisible] = React.useState(rest.visible);
85
+ const [inputValue, setInputValue] = React.useState(rest.inputValue);
86
+ const [messages, setMessages] = React.useState<PureChatMessage[]>(rest.messages);
87
+ const [remaining, setRemaining] = React.useState(rest.remaining);
88
+ const [isSending, setIsSending] = React.useState(rest.isSending);
89
+ const listRef = React.useRef<FlatList<PureChatMessage>>(null);
90
+
91
+ React.useEffect(() => {
92
+ setIsVisible(rest.visible);
93
+ }, [rest.visible]);
94
+
95
+ React.useEffect(() => {
96
+ setInputValue(rest.inputValue);
97
+ }, [rest.inputValue]);
98
+
99
+ React.useEffect(() => {
100
+ setMessages(rest.messages);
101
+ }, [rest.messages]);
102
+
103
+ React.useEffect(() => {
104
+ setRemaining(rest.remaining);
105
+ }, [rest.remaining]);
106
+
107
+ React.useEffect(() => {
108
+ setIsSending(rest.isSending);
109
+ }, [rest.isSending]);
110
+
111
+ const handleClose = React.useCallback(() => {
112
+ setIsVisible(false);
113
+ onClose();
114
+ }, [onClose]);
115
+
116
+ const handleSend = React.useCallback(() => {
117
+ const trimmed = inputValue.trim();
118
+ if (!trimmed) return;
119
+
120
+ setIsSending(true);
121
+ onSend();
122
+
123
+ const userMessage: PureChatMessage = {
124
+ id: `user-${Date.now()}`,
125
+ role: 'user',
126
+ content: trimmed,
127
+ };
128
+
129
+ setMessages((prev) => [...prev, userMessage]);
130
+ setInputValue('');
131
+ setRemaining((prev) => (prev === undefined ? prev : Math.max(prev - 1, 0)));
132
+
133
+ setTimeout(() => {
134
+ setMessages((prev) => [
135
+ ...prev,
136
+ {
137
+ id: `assistant-${Date.now()}`,
138
+ role: 'assistant',
139
+ content: `Я сохранил ваш запрос: "${trimmed}". Скоро пришлю подборку!`,
140
+ },
141
+ ]);
142
+ listRef.current?.scrollToEnd({ animated: true });
143
+ setIsSending(false);
144
+ }, 600);
145
+ }, [inputValue, onSend]);
146
+
147
+ return (
148
+ <View style={{ flex: 1 }}>
149
+ <View style={{ marginBottom: 12, alignItems: 'center' }}>
150
+ <Button
151
+ title={isVisible ? 'Скрыть модалку' : 'Показать модалку'}
152
+ onPress={() => setIsVisible((prev) => !prev)}
153
+ />
154
+ </View>
155
+ <GuestAiChatModalView
156
+ {...rest}
157
+ visible={isVisible}
158
+ messages={messages}
159
+ inputValue={inputValue}
160
+ remaining={remaining}
161
+ isSending={isSending}
162
+ onClose={handleClose}
163
+ onSend={handleSend}
164
+ onChangeInput={setInputValue}
165
+ listRef={listRef}
166
+ />
167
+ </View>
168
+ );
169
+ };
170
+
171
+ export const Playground = Template.bind({});
172
+ Playground.args = {
173
+ visible: false,
174
+ remaining: 5,
175
+ };
176
+
177
+ export const DefaultOpen: StoryFn<GuestAiChatProps> = (args) => Template({ ...args, visible: true });
178
+ DefaultOpen.args = {
179
+ visible: true,
180
+ };
181
+
182
+ export const WithErrorState: StoryFn<GuestAiChatProps> = (args) => (
183
+ <GuestAiChatModalView
184
+ {...args}
185
+ visible
186
+ error="Не удалось подключиться к боту, попробуйте снова."
187
+ messages={args.messages}
188
+ inputValue={args.inputValue}
189
+ onChangeInput={args.onChangeInput ?? (() => undefined)}
190
+ onSend={args.onSend ?? (() => undefined)}
191
+ listRef={React.createRef()}
192
+ />
193
+ );
194
+ WithErrorState.args = {
195
+ inputValue: 'Сообщение',
196
+ isSending: false,
197
+ };
198
+ WithErrorState.parameters = {
199
+ controls: {
200
+ exclude: ['listRef', 'onChangeInput', 'onSend'],
201
+ },
202
+ };
@@ -0,0 +1,249 @@
1
+ import React, { FC, memo, RefObject, useMemo } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ FlatList,
5
+ KeyboardAvoidingView,
6
+ Modal,
7
+ Platform,
8
+ StyleSheet,
9
+ Text,
10
+ TextInput,
11
+ TouchableOpacity,
12
+ View,
13
+ } from 'react-native';
14
+ import { Ionicons } from '@expo/vector-icons';
15
+ import { ThemeType, SizesType, TypographytType } from '../../theme';
16
+
17
+ export type PureChatMessage = {
18
+ id: string;
19
+ role: 'user' | 'assistant';
20
+ content: string;
21
+ };
22
+
23
+ export type GuestAiChatViewProps = {
24
+ visible: boolean;
25
+ onClose: () => void;
26
+ botName?: string;
27
+
28
+ // данные
29
+ messages: PureChatMessage[];
30
+ inputValue: string;
31
+ error: string | null;
32
+ isSending: boolean;
33
+ limit?: number;
34
+ remaining?: number;
35
+
36
+ // экшены
37
+ onChangeInput: (v: string) => void;
38
+ onSend: () => void;
39
+
40
+ // ссылки
41
+ listRef: RefObject<FlatList<PureChatMessage>>;
42
+
43
+ // тема
44
+ theme: ThemeType;
45
+ typography: TypographytType;
46
+ sizes: SizesType;
47
+ };
48
+
49
+ export const GuestAiChatModalView: FC<GuestAiChatViewProps> = memo(
50
+ ({
51
+ visible,
52
+ onClose,
53
+ botName,
54
+ messages,
55
+ inputValue,
56
+ error,
57
+ isSending,
58
+ limit,
59
+ remaining,
60
+ onChangeInput,
61
+ onSend,
62
+ listRef,
63
+ theme,
64
+ typography,
65
+ sizes,
66
+ }) => {
67
+ const isAndroid = Platform.OS === 'android';
68
+ const styles = useMemo(() => getStyles(theme, isAndroid), [theme, isAndroid]);
69
+
70
+ const disabled =
71
+ isSending || !inputValue.trim() || (remaining !== undefined && remaining <= 0);
72
+
73
+ const renderItem = ({ item }: { item: PureChatMessage }) => {
74
+ const isUser = item.role === 'user';
75
+ return (
76
+ <View style={[styles.messageRow, isUser ? styles.rowEnd : styles.rowStart]}>
77
+ <View style={[styles.messageBubble, isUser ? styles.userBubble : styles.botBubble]}>
78
+ <Text style={[typography.body, isUser ? styles.userText : styles.botText]}>
79
+ {item.content}
80
+ </Text>
81
+ </View>
82
+ </View>
83
+ );
84
+ };
85
+
86
+ return (
87
+ <Modal visible={visible} animationType="slide" onRequestClose={onClose} transparent>
88
+ <KeyboardAvoidingView
89
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
90
+ style={styles.centered}
91
+ >
92
+ <View style={styles.modalContainer}>
93
+ <View style={styles.header}>
94
+ <View>
95
+ <Text style={typography.titleH6}>{botName || 'AI-бот'}</Text>
96
+ {limit !== undefined && remaining !== undefined && (
97
+ <Text style={styles.limitText}>
98
+ Осталось сообщений: {remaining} / {limit}
99
+ </Text>
100
+ )}
101
+ </View>
102
+ <TouchableOpacity onPress={onClose} style={styles.closeButton}>
103
+ <Ionicons name="close" size={sizes.md} color={theme.text} />
104
+ </TouchableOpacity>
105
+ </View>
106
+
107
+ <FlatList
108
+ ref={listRef}
109
+ data={messages}
110
+ keyExtractor={(item) => item.id}
111
+ renderItem={renderItem}
112
+ contentContainerStyle={styles.messagesContainer}
113
+ showsVerticalScrollIndicator={false}
114
+ />
115
+
116
+ {error ? <Text style={styles.errorText}>{error}</Text> : null}
117
+
118
+ <View style={styles.inputRow}>
119
+ <TextInput
120
+ style={styles.input}
121
+ placeholder="Спросите что-нибудь..."
122
+ placeholderTextColor={theme.greyText}
123
+ value={inputValue}
124
+ onChangeText={onChangeInput}
125
+ editable={!isSending && (remaining === undefined || remaining > 0)}
126
+ multiline
127
+ />
128
+ <TouchableOpacity
129
+ onPress={onSend}
130
+ style={[styles.sendButton, disabled && styles.sendButtonDisabled]}
131
+ disabled={disabled}
132
+ >
133
+ {isSending ? (
134
+ <ActivityIndicator color={theme.white} />
135
+ ) : (
136
+ <Ionicons name="send" size={18} color={theme.white} />
137
+ )}
138
+ </TouchableOpacity>
139
+ </View>
140
+ </View>
141
+ </KeyboardAvoidingView>
142
+ </Modal>
143
+ );
144
+ }
145
+ );
146
+
147
+ const getStyles = (theme: ThemeType, isAndroid: boolean) =>
148
+ StyleSheet.create({
149
+ centered: {
150
+ flex: 1,
151
+ backgroundColor: theme.backgroundSemiTransparent,
152
+ justifyContent: 'flex-end',
153
+ },
154
+ modalContainer: {
155
+ backgroundColor: theme.white,
156
+ borderTopLeftRadius: 16,
157
+ borderTopRightRadius: 16,
158
+ paddingHorizontal: 16,
159
+ paddingTop: 16,
160
+ paddingBottom: 24 + (isAndroid ? 25 : 0),
161
+ maxHeight: '85%',
162
+ },
163
+ header: {
164
+ flexDirection: 'row',
165
+ alignItems: 'center',
166
+ justifyContent: 'space-between',
167
+ marginBottom: 12,
168
+ },
169
+ closeButton: {
170
+ width: 32,
171
+ height: 32,
172
+ borderRadius: 16,
173
+ alignItems: 'center',
174
+ justifyContent: 'center',
175
+ backgroundColor: theme.background,
176
+ },
177
+ limitText: {
178
+ marginTop: 4,
179
+ fontSize: 12,
180
+ color: theme.greyText,
181
+ },
182
+ messagesContainer: {
183
+ paddingBottom: 12,
184
+ },
185
+ messageRow: {
186
+ flexDirection: 'row',
187
+ marginBottom: 8,
188
+ },
189
+ rowEnd: {
190
+ justifyContent: 'flex-end',
191
+ },
192
+ rowStart: {
193
+ justifyContent: 'flex-start',
194
+ },
195
+ messageBubble: {
196
+ maxWidth: '85%',
197
+ paddingHorizontal: 12,
198
+ paddingVertical: 8,
199
+ borderRadius: 12,
200
+ },
201
+ userBubble: {
202
+ backgroundColor: theme.primary,
203
+ borderBottomRightRadius: 2,
204
+ },
205
+ botBubble: {
206
+ backgroundColor: theme.background,
207
+ borderBottomLeftRadius: 2,
208
+ },
209
+ userText: {
210
+ color: theme.white,
211
+ },
212
+ botText: {
213
+ color: theme.text,
214
+ },
215
+ inputRow: {
216
+ flexDirection: 'row',
217
+ alignItems: 'flex-end',
218
+ marginTop: 8,
219
+ },
220
+ input: {
221
+ flex: 1,
222
+ minHeight: 40,
223
+ maxHeight: 120,
224
+ paddingHorizontal: 12,
225
+ paddingVertical: 10,
226
+ borderRadius: 12,
227
+ backgroundColor: theme.background,
228
+ color: theme.text,
229
+ },
230
+ sendButton: {
231
+ width: 42,
232
+ height: 42,
233
+ borderRadius: 21,
234
+ alignItems: 'center',
235
+ justifyContent: 'center',
236
+ backgroundColor: theme.primary,
237
+ marginLeft: 8,
238
+ },
239
+ sendButtonDisabled: {
240
+ opacity: 0.5,
241
+ },
242
+ errorText: {
243
+ color: theme.danger || '#E53935',
244
+ marginBottom: 4,
245
+ fontSize: 12,
246
+ },
247
+ });
248
+
249
+ export default GuestAiChatModalView;
@@ -155,10 +155,10 @@ export default function PureProfileCard(props: PureProfileCardProps) {
155
155
  )}
156
156
 
157
157
  {/* learn more */}
158
- <TouchableOpacity onPress={onLearnMorePress} style={globalStyleSheet.flexRowCenter}>
158
+ {onLearnMorePress && <TouchableOpacity onPress={onLearnMorePress} style={globalStyleSheet.flexRowCenter}>
159
159
  <MaterialIcons color={theme.placeholder} name="info-outline" size={18} />
160
160
  <Text style={[typography.body, styles.learnMoreText]}>Learn more</Text>
161
- </TouchableOpacity>
161
+ </TouchableOpacity>}
162
162
 
163
163
  {/* CTA */}
164
164
  {isAuth && !isMe && (
@@ -177,7 +177,7 @@ export default function PureProfileCard(props: PureProfileCardProps) {
177
177
  </View>
178
178
  )}
179
179
 
180
- {isMe && (
180
+ {(isMe && onOpenCreatePoll || isMe && onOpenCreateEvent) &&(
181
181
  <View style={styles.buttonsRow}>
182
182
  {onOpenCreatePoll && (
183
183
  <TouchableOpacity style={styles.btnOutline} onPress={onOpenCreatePoll}>
@@ -0,0 +1,106 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { View } from 'react-native';
4
+ import TabBarAi, { type TabItem } from './TabBarAi';
5
+
6
+ type TabBarAiProps = React.ComponentProps<typeof TabBarAi>;
7
+
8
+ const DEFAULT_TABS: TabItem[] = [
9
+ { key: 'discover', label: 'Discover' },
10
+ { key: 'specialists', label: 'Specialists' },
11
+ { key: 'places', label: 'Places' },
12
+ { key: 'events', label: 'Events' },
13
+ { key: 'polls', label: 'Polls', disabled: true },
14
+ ];
15
+
16
+ const meta = {
17
+ title: 'UI/TabBar/TabBarAi',
18
+ component: TabBarAi,
19
+ decorators: [
20
+ (Story) => (
21
+ <View style={{ paddingVertical: 8, backgroundColor: '#1b1b1b' }}>
22
+ <Story />
23
+ </View>
24
+ ),
25
+ ],
26
+ parameters: {
27
+ docs: {
28
+ description: {
29
+ component:
30
+ 'Horizontal scrollable tab bar with animated indicator tailored for the AI discovery screens.',
31
+ },
32
+ },
33
+ },
34
+ argTypes: {
35
+ onChange: { action: 'onChange' },
36
+ activeIndex: {
37
+ control: { type: 'number' },
38
+ description: 'Index of the active tab. In the stories it is managed internally.',
39
+ },
40
+ },
41
+ args: {
42
+ tabs: DEFAULT_TABS,
43
+ activeIndex: 0,
44
+ activeColor: '#ffffff',
45
+ inactiveColor: 'rgba(255,255,255,0.55)',
46
+ indicatorColor: '#ffffff',
47
+ indicatorHeight: 4,
48
+ fontSize: 18,
49
+ },
50
+ } satisfies Meta<typeof TabBarAi>;
51
+
52
+ export default meta;
53
+
54
+ type Story = StoryObj<typeof TabBarAi>;
55
+
56
+ const Controlled: React.FC<TabBarAiProps> = ({ activeIndex = 0, onChange, ...rest }) => {
57
+ const [active, setActive] = useState(activeIndex);
58
+
59
+ useEffect(() => {
60
+ setActive(activeIndex);
61
+ }, [activeIndex]);
62
+
63
+ return (
64
+ <TabBarAi
65
+ {...rest}
66
+ activeIndex={active}
67
+ onChange={(index) => {
68
+ setActive(index);
69
+ onChange?.(index);
70
+ }}
71
+ />
72
+ );
73
+ };
74
+
75
+ export const Default: Story = {
76
+ render: (args) => <Controlled {...args} />,
77
+ };
78
+
79
+ export const DarkBackground: Story = {
80
+ render: (args) => <Controlled {...args} />,
81
+ args: {
82
+ activeColor: '#ffe066',
83
+ indicatorColor: '#ffe066',
84
+ },
85
+ };
86
+
87
+ export const WithManyTabs: Story = {
88
+ render: (args) => <Controlled {...args} />,
89
+ args: {
90
+ tabs: Array.from({ length: 10 }).map((_, index) => ({
91
+ key: `tab-${index}`,
92
+ label: `Section ${index + 1}`,
93
+ })),
94
+ },
95
+ };
96
+
97
+ export const CustomSpacing: Story = {
98
+ render: (args) => <Controlled {...args} />,
99
+ args: {
100
+ gap: 12,
101
+ tabHorizontalPadding: 12,
102
+ fontSize: 16,
103
+ fontWeightActive: '600',
104
+ fontWeightInactive: '400',
105
+ },
106
+ };
@@ -0,0 +1,233 @@
1
+ // TabBar.tsx
2
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ Animated,
5
+ LayoutChangeEvent,
6
+ Pressable,
7
+ StyleProp,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ ViewStyle,
12
+ } from 'react-native';
13
+ import type { ScrollView } from 'react-native';
14
+
15
+ export type TabItem = {
16
+ key: string;
17
+ label: string;
18
+ disabled?: boolean;
19
+ };
20
+
21
+ type MeasuredTab = {
22
+ x: number; // абсолютный x внутри контента ScrollView (с учётом padding)
23
+ width: number;
24
+ };
25
+
26
+ type TabBarProps = {
27
+ tabs: TabItem[];
28
+ activeIndex: number;
29
+ onChange: (index: number) => void;
30
+
31
+ style?: StyleProp<ViewStyle>;
32
+ contentContainerStyle?: StyleProp<ViewStyle>;
33
+
34
+ // стилизация
35
+ activeColor?: string;
36
+ inactiveColor?: string;
37
+ indicatorColor?: string;
38
+ indicatorHeight?: number;
39
+ fontSize?: number;
40
+ fontWeightActive?: '700' | '600' | '500' | '400';
41
+ fontWeightInactive?: '600' | '500' | '400' | '300';
42
+ tabHorizontalPadding?: number;
43
+ gap?: number;
44
+ };
45
+
46
+ const PADDING_X = 12; // должен совпадать со styles.row.paddingHorizontal
47
+
48
+ export const TabBar: React.FC<TabBarProps> = memo((props) => {
49
+ const {
50
+ tabs,
51
+ activeIndex,
52
+ onChange,
53
+ style,
54
+ contentContainerStyle,
55
+ activeColor = '#000000',
56
+ inactiveColor = 'rgba(255,255,255,0.55)',
57
+ indicatorColor = '#000000',
58
+ indicatorHeight = 4,
59
+ fontSize = 24,
60
+ fontWeightActive = '700',
61
+ fontWeightInactive = '600',
62
+ tabHorizontalPadding = 4,
63
+ gap = 24,
64
+ } = props;
65
+
66
+ const scrollRef = useRef<ScrollView>(null);
67
+ const [rootWidth, setRootWidth] = useState(0);
68
+ const scrollX = useRef(new Animated.Value(0)).current;
69
+
70
+ // измерения вкладок
71
+ const [measures, setMeasures] = useState<Record<string, MeasuredTab>>({});
72
+ const onTabLayout = useCallback(
73
+ (key: string) => (e: LayoutChangeEvent) => {
74
+ const { x, width } = e.nativeEvent.layout;
75
+ setMeasures((prev) =>
76
+ prev[key]?.x === x && prev[key]?.width === width ? prev : { ...prev, [key]: { x, width } },
77
+ );
78
+ },
79
+ [],
80
+ );
81
+
82
+ // индикатор: позиционируем по центру вкладки
83
+ const centerX = useRef(new Animated.Value(0)).current; // координата центра ОТНОСИТЕЛЬНО left=PADDING_X
84
+ const scaleX = useRef(new Animated.Value(1)).current; // ширина индикатора (масштаб по X)
85
+
86
+ const animateTo = useCallback(
87
+ (centerRel: number, width: number) => {
88
+ Animated.parallel([
89
+ Animated.spring(centerX, {
90
+ toValue: centerRel,
91
+ useNativeDriver: true,
92
+ bounciness: 6,
93
+ speed: 15,
94
+ }),
95
+ Animated.spring(scaleX, {
96
+ toValue: Math.max(1, width), // базовая ширина 1 -> растягиваем до нужной
97
+ useNativeDriver: true,
98
+ bounciness: 6,
99
+ speed: 15,
100
+ }),
101
+ ]).start();
102
+ },
103
+ [centerX, scaleX],
104
+ );
105
+
106
+ // пересчёт при смене активной вкладки или измерений
107
+ useEffect(() => {
108
+ const tab = tabs[activeIndex];
109
+ if (!tab) return;
110
+ const m = measures[tab.key];
111
+ if (!m) return;
112
+
113
+ const targetWidth = Math.max(16, m.width * 0.6);
114
+ const centerAbs = m.x + m.width / 2; // абсолютный центр относительно контента
115
+ const centerRel = centerAbs - PADDING_X; // смещаем, т.к. индикатор имеет left=PADDING_X
116
+
117
+ animateTo(centerRel, targetWidth);
118
+
119
+ // автоцентрирование активной вкладки в видимой области
120
+ const desiredScrollX = Math.max(0, centerAbs - rootWidth / 2);
121
+ scrollRef.current?.scrollTo({ x: desiredScrollX, y: 0, animated: true });
122
+ }, [activeIndex, measures, tabs, rootWidth, animateTo]);
123
+
124
+ const handlePress = useCallback(
125
+ (index: number, disabled?: boolean) => {
126
+ if (!disabled) onChange(index);
127
+ },
128
+ [onChange],
129
+ );
130
+
131
+ const contentGapStyle = useMemo(() => ({ columnGap: gap }), [gap]);
132
+
133
+ const translateX = useMemo(() => Animated.subtract(centerX, scrollX), [centerX, scrollX]);
134
+
135
+ return (
136
+ <View
137
+ style={[styles.root, style]}
138
+ onLayout={(e) => setRootWidth(e.nativeEvent.layout.width)}
139
+ >
140
+ <Animated.ScrollView
141
+ ref={scrollRef}
142
+ horizontal
143
+ showsHorizontalScrollIndicator={false}
144
+ contentContainerStyle={[styles.row, contentGapStyle, contentContainerStyle]}
145
+ keyboardShouldPersistTaps="handled"
146
+ // important: чтобы Pressable не обрезался тенями
147
+ overScrollMode="never"
148
+ onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: scrollX } } }], {
149
+ useNativeDriver: true,
150
+ })}
151
+ scrollEventThrottle={16}
152
+ >
153
+ {tabs.map((t, i) => {
154
+ const isActive = i === activeIndex;
155
+ return (
156
+ <Pressable
157
+ key={t.key}
158
+ onPress={() => handlePress(i, t.disabled)}
159
+ onLayout={onTabLayout(t.key)}
160
+ android_ripple={{ color: 'rgba(255,255,255,0.08)', borderless: true }}
161
+ style={({ pressed }) => [
162
+ styles.tab,
163
+ { paddingHorizontal: tabHorizontalPadding, opacity: t.disabled ? 0.5 : pressed ? 0.8 : 1 },
164
+ ]}
165
+ accessibilityRole="tab"
166
+ accessibilityState={{ selected: isActive, disabled: !!t.disabled }}
167
+ >
168
+ <Text
169
+ numberOfLines={1}
170
+ style={[
171
+ styles.label,
172
+ {
173
+ color: isActive ? activeColor : inactiveColor,
174
+ fontSize,
175
+ fontWeight: isActive ? fontWeightActive : fontWeightInactive,
176
+ },
177
+ ]}
178
+ >
179
+ {t.label}
180
+ </Text>
181
+ </Pressable>
182
+ );
183
+ })}
184
+ </Animated.ScrollView>
185
+
186
+ {/* Индикатор: width анимируем через scaleX (native-driver) */}
187
+ <Animated.View
188
+ pointerEvents="none"
189
+ style={[
190
+ styles.indicator,
191
+ {
192
+ height: indicatorHeight,
193
+ backgroundColor: indicatorColor,
194
+ // базовая «единичка», далее растягиваем scaleX до нужной ширины
195
+ width: 1,
196
+ transform: [
197
+ { translateX }, // сдвиг центра
198
+ { scaleX }, // ширина
199
+ ],
200
+ },
201
+ ]}
202
+ />
203
+ </View>
204
+ );
205
+ });
206
+
207
+ const styles = StyleSheet.create({
208
+ root: {
209
+ position: 'relative',
210
+ backgroundColor: 'transparent',
211
+ },
212
+ row: {
213
+ flexDirection: 'row',
214
+ alignItems: 'flex-end',
215
+ paddingHorizontal: PADDING_X,
216
+ paddingTop: 4,
217
+ paddingBottom: 4,
218
+ },
219
+ tab: {
220
+ paddingVertical: 6,
221
+ },
222
+ label: {
223
+ letterSpacing: 0.2,
224
+ },
225
+ indicator: {
226
+ position: 'absolute',
227
+ left: PADDING_X, // точка отсчёта для centerX
228
+ bottom: 0,
229
+ borderRadius: 999,
230
+ },
231
+ });
232
+
233
+ export default TabBar;
@@ -6,13 +6,15 @@ import {
6
6
  StyleSheet,
7
7
  Modal,
8
8
  Pressable,
9
- LayoutRectangle
9
+ LayoutRectangle,
10
+ ViewStyle,
11
+ StyleProp
10
12
  } from 'react-native';
11
13
  import { Ionicons } from '@expo/vector-icons';
12
14
  import { ThemeType, useTheme } from '../../theme';
13
15
  import { IoniconsProps } from '../../types/Icon';
14
16
 
15
- type MenuItem = {
17
+ export type MenuItem = {
16
18
  label: string;
17
19
  icon?: IoniconsProps;
18
20
  colorIcon?: any;
@@ -21,9 +23,13 @@ type MenuItem = {
21
23
 
22
24
  type Props = {
23
25
  items: MenuItem[];
26
+ style?: StyleProp<ViewStyle>;
27
+ iconColor?: string;
28
+ positionLeft?: number;
29
+ positionTop?: number;
24
30
  };
25
31
 
26
- export const ThreeDotsMenu: React.FC<Props> = ({ items }) => {
32
+ export const ThreeDotsMenu: React.FC<Props> = ({ items, style, iconColor, positionLeft, positionTop }) => {
27
33
  const { theme, typography, globalStyleSheet } = useTheme();
28
34
 
29
35
  const [menuVisible, setMenuVisible] = useState(false);
@@ -45,8 +51,8 @@ export const ThreeDotsMenu: React.FC<Props> = ({ items }) => {
45
51
 
46
52
  return (
47
53
  <View>
48
- <TouchableOpacity ref={buttonRef as any} onPress={openMenu}>
49
- <Ionicons name="ellipsis-vertical" size={22} color={theme.primary} />
54
+ <TouchableOpacity style={style} ref={buttonRef as any} onPress={openMenu}>
55
+ <Ionicons name="ellipsis-vertical" size={22} color={iconColor ?? theme.primary} />
50
56
  </TouchableOpacity>
51
57
 
52
58
  <Modal transparent visible={menuVisible} animationType="fade" onRequestClose={closeMenu}>
@@ -56,8 +62,8 @@ export const ThreeDotsMenu: React.FC<Props> = ({ items }) => {
56
62
  style={[
57
63
  styles.dropdown,
58
64
  {
59
- top: position.y + position.height,
60
- left: position.x - 160 + position.width, // подправь под себя
65
+ top: position.y + position.height + (positionTop ?? 0),
66
+ left: position.x - (positionLeft ?? 160) + position.width, // подправь под себя
61
67
  },
62
68
  ]}
63
69
  >
@@ -0,0 +1,71 @@
1
+ import React, { useRef, useEffect } from 'react';
2
+ import { Animated } from 'react-native';
3
+ import { Meta, StoryObj } from '@storybook/react';
4
+ import { UpdateRequiredView } from './UpdateRequiredView';
5
+ import { ThemeProvider } from '../../theme';
6
+
7
+ // 👇 Декоратор, чтобы обернуть компонент в тему
8
+ const withTheme = (StoryFn: any) => (
9
+ <ThemeProvider>
10
+ <StoryFn />
11
+ </ThemeProvider>
12
+ );
13
+
14
+ const meta: Meta<typeof UpdateRequiredView> = {
15
+ title: 'UI/UpdateRequiredView',
16
+ component: UpdateRequiredView,
17
+ decorators: [withTheme],
18
+ argTypes: {
19
+ onPressUpdate: { action: 'pressed update' },
20
+ onRefresh: { action: 'refreshed' },
21
+ },
22
+ };
23
+
24
+ export default meta;
25
+
26
+ type Story = StoryObj<typeof UpdateRequiredView>;
27
+
28
+ export const Default: Story = {
29
+ render: (args) => {
30
+ const anim = useRef(new Animated.Value(0)).current;
31
+
32
+ useEffect(() => {
33
+ Animated.loop(
34
+ Animated.sequence([
35
+ Animated.timing(anim, {
36
+ toValue: -10,
37
+ duration: 600,
38
+ useNativeDriver: true,
39
+ }),
40
+ Animated.timing(anim, {
41
+ toValue: 0,
42
+ duration: 600,
43
+ useNativeDriver: true,
44
+ }),
45
+ ])
46
+ ).start();
47
+ }, [anim]);
48
+
49
+ return (
50
+ <UpdateRequiredView
51
+ {...args}
52
+ anim={anim}
53
+ />
54
+ );
55
+ },
56
+ args: {
57
+ refreshing: false,
58
+ title: 'Необходимо обновление',
59
+ description:
60
+ 'Для корректной работы приложения установите последнюю версию. Нажмите кнопку ниже, чтобы перейти в магазин.',
61
+ },
62
+ };
63
+
64
+ export const Refreshing: Story = {
65
+ render: Default.render,
66
+ args: {
67
+ refreshing: true,
68
+ title: 'Проверка обновлений...',
69
+ description: 'Подождите, идет проверка доступных обновлений.',
70
+ },
71
+ };
@@ -0,0 +1,65 @@
1
+ import React, { memo } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ ScrollView,
7
+ RefreshControl,
8
+ Animated,
9
+ } from 'react-native';
10
+ import { useTheme } from '../../theme';
11
+ import { MaterialCommunityIcons } from '@expo/vector-icons';
12
+ import Spacer from './Spacer';
13
+ import { Button } from '../Button';
14
+
15
+ type Props = {
16
+ anim: Animated.Value; // внешний Animated.Value с уже запущенным loop
17
+ refreshing: boolean;
18
+ onRefresh: () => void;
19
+ onPressUpdate: () => void;
20
+ title: string;
21
+ description: string;
22
+ };
23
+
24
+ export const UpdateRequiredView = memo(({ anim, refreshing, onRefresh, onPressUpdate, title, description }: Props) => {
25
+ const { theme, commonStyles, typography } = useTheme() as any;
26
+
27
+ const ui = {
28
+ bg: theme?.background ?? '#fff',
29
+ primary: theme?.primary ?? '#6f2da8',
30
+ container: commonStyles?.container ?? { flex: 1, padding: 16 },
31
+ titleH2: typography?.titleH2 ?? { fontSize: 24, fontWeight: '700' },
32
+ body: typography?.body ?? { fontSize: 16 },
33
+ };
34
+
35
+ return (
36
+ <ScrollView
37
+ contentContainerStyle={[ui.container, { backgroundColor: ui.bg, flexGrow: 1, justifyContent: 'center' }]}
38
+ refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
39
+ >
40
+ <Animated.View style={[styles.iconContainer, { transform: [{ translateY: anim }] }]}>
41
+ <MaterialCommunityIcons name="update" size={64} color={ui.primary} />
42
+ </Animated.View>
43
+
44
+ <Spacer size="xl" />
45
+ <Text style={[ui.titleH2, styles.title]}>{title}</Text>
46
+
47
+ <Spacer size="sm" />
48
+ <Text style={[ui.body, styles.description]}>
49
+ {description}
50
+ </Text>
51
+
52
+ <Spacer size="xl" />
53
+ <View style={styles.button}>
54
+ <Button title="Обновить" onPress={onPressUpdate} />
55
+ </View>
56
+ </ScrollView>
57
+ );
58
+ });
59
+
60
+ const styles = StyleSheet.create({
61
+ button: { width: '70%' },
62
+ iconContainer: { alignItems: 'center' },
63
+ title: { textAlign: 'center' },
64
+ description: { textAlign: 'center', paddingHorizontal: 20 },
65
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-vs-lb",
3
- "version": "1.0.57",
3
+ "version": "1.0.58",
4
4
  "description": "Expo Router + Storybook template ready for npm distribution.",
5
5
  "keywords": [
6
6
  "expo",