react-native-molecules 0.5.0-beta.10 → 0.5.0-beta.12

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.
@@ -1,9 +1,6 @@
1
1
  import {
2
- Children,
3
2
  cloneElement,
4
- type FC,
5
- isValidElement,
6
- memo,
3
+ type ComponentType,
7
4
  type ReactElement,
8
5
  useCallback,
9
6
  useEffect,
@@ -24,21 +21,21 @@ import {
24
21
  type ViewStyle,
25
22
  } from 'react-native';
26
23
 
27
- import { useControlledValue } from '../../hooks';
24
+ import { typedMemo } from '../../hocs';
25
+ import { useControlledValue, useSubcomponents } from '../../hooks';
28
26
  import { noop } from '../../utils/lodash';
29
- import { HorizontalDivider, type HorizontalDividerProps } from '../HorizontalDivider';
30
27
  import type { TabItemProps } from './TabItem';
31
28
  import { tabsStyles } from './utils';
32
29
 
33
- export type TabsProps = ViewProps & {
30
+ export type TabsProps<T extends string | number> = ViewProps & {
34
31
  /**
35
32
  * child tab name
36
33
  * */
37
- value?: string;
34
+ value?: T;
38
35
  /**
39
36
  * defaultValue to preselected for uncontrolled mode
40
37
  * */
41
- defaultValue?: string;
38
+ defaultValue?: T;
42
39
  /**
43
40
  * to enable scroll
44
41
  * */
@@ -46,7 +43,7 @@ export type TabsProps = ViewProps & {
46
43
  /**
47
44
  * on name change callback.
48
45
  * */
49
- onChange?: (value: string) => void;
46
+ onChange?: (value: T) => void;
50
47
  /**
51
48
  * Disable the active indicator below.
52
49
  * */
@@ -58,10 +55,6 @@ export type TabsProps = ViewProps & {
58
55
 
59
56
  indicatorProps?: Omit<ViewStyle, 'style'>;
60
57
 
61
- dividerStyle?: ViewStyle;
62
-
63
- dividerProps?: Omit<HorizontalDividerProps, 'style'>;
64
-
65
58
  /** Define the background Variant. */
66
59
  variant?: 'primary' | 'secondary';
67
60
  activeColor?: string;
@@ -69,7 +62,7 @@ export type TabsProps = ViewProps & {
69
62
 
70
63
  const emptyObj = {};
71
64
 
72
- export const TabBase = ({
65
+ export const TabBase = <T extends string | number>({
73
66
  children,
74
67
  value: valueProp,
75
68
  defaultValue,
@@ -80,84 +73,75 @@ export const TabBase = ({
80
73
  style,
81
74
  variant = 'primary',
82
75
  indicatorProps,
83
- dividerStyle: dividerStyleProp = emptyObj,
84
- dividerProps,
85
76
  activeColor: activeColorProp,
86
77
  testID,
87
78
  ...rest
88
- }: TabsProps) => {
79
+ }: TabsProps<T>) => {
89
80
  tabsStyles.useVariants({
90
81
  variant,
91
82
  });
92
83
 
93
- const validChildren = useMemo(
94
- () =>
95
- Children.toArray(children).filter(
96
- child => isValidElement(child) && (child?.type as FC).displayName === 'Tabs_Item',
97
- ),
98
- [children],
99
- );
100
-
101
- const nameToIndexMap = useMemo(
102
- () =>
103
- validChildren.reduce((acc, child, currentIndex) => {
104
- acc[(child as ReactElement<TabItemProps>).props?.name] = currentIndex;
84
+ const { Tabs_Item: tabItems } = useSubcomponents({
85
+ children,
86
+ allowedChildren: ['Tabs_Item'],
87
+ });
105
88
 
106
- return acc;
107
- }, {} as Record<string, number>),
108
- [validChildren],
89
+ // Get ordered list of tab names for current children
90
+ const tabNames = useMemo(
91
+ () => tabItems.map(child => (child as ReactElement<TabItemProps<T>>).props?.name),
92
+ [tabItems],
109
93
  );
110
94
 
111
95
  const [value, onChange] = useControlledValue({
112
96
  value: valueProp,
113
97
  onChange: onChangeProp,
114
- defaultValue: defaultValue || (validChildren[0] as ReactElement<TabItemProps>)?.props?.name,
98
+ defaultValue: defaultValue || tabNames[0],
115
99
  });
116
100
 
117
- const valueIndex = nameToIndexMap[value];
101
+ const valueIndex = useMemo(() => tabNames.indexOf(value), [value, tabNames]);
102
+ const previousTabCountRef = useRef(tabNames.length);
118
103
 
119
104
  const positionAnimationRef = useRef(new Animated.Value(0));
120
105
  const widthAnimationRef = useRef(new Animated.Value(0));
121
106
  const scrollViewRef = useRef<RNScrollView>(null);
122
107
  const scrollViewPosition = useRef(0);
123
108
 
124
- const tabItemPositions = useRef<Array<{ width: number; contentWidth: number }>>([]);
109
+ const tabItemPositions = useRef<Map<T, { width: number; contentWidth: number }>>(new Map());
125
110
  const [tabContainerWidth, setTabContainerWidth] = useState(0);
111
+ const [layoutVersion, setLayoutVersion] = useState(0);
126
112
 
127
113
  const itemPositionsMap = useMemo(() => {
128
- return tabItemPositions.current.reduce((acc, item, index) => {
129
- const previousItemsWidth = tabItemPositions.current
130
- .slice(0, index)
131
- .reduce((totalWidth, _item) => {
132
- totalWidth += _item.width || 0;
133
-
134
- return totalWidth;
135
- }, 0);
114
+ // Build positions based on current render order
115
+ let accumulatedWidth = 0;
116
+ return tabNames.reduce((acc, name, index) => {
117
+ const itemData = tabItemPositions.current.get(name);
118
+ if (!itemData) return acc;
136
119
 
137
120
  acc[index] =
138
121
  variant === 'primary'
139
- ? previousItemsWidth + (item.width - item.contentWidth) / 2
140
- : previousItemsWidth;
122
+ ? accumulatedWidth + (itemData.width - itemData.contentWidth) / 2
123
+ : accumulatedWidth;
141
124
 
125
+ accumulatedWidth += itemData.width || 0;
142
126
  return acc;
143
127
  }, {} as Record<number, number>);
144
- // to make useMemo in sync with the ref
145
128
  // eslint-disable-next-line react-hooks/exhaustive-deps
146
- }, [variant, tabItemPositions.current.length]);
129
+ }, [variant, tabNames, layoutVersion]);
147
130
 
148
131
  const itemWidthsMap = useMemo(() => {
149
- return tabItemPositions.current.reduce((acc, item, index) => {
150
- acc[index] = variant === 'primary' ? item.contentWidth : item.width;
132
+ return tabNames.reduce((acc, name, index) => {
133
+ const itemData = tabItemPositions.current.get(name);
134
+ if (!itemData) return acc;
151
135
 
136
+ acc[index] = variant === 'primary' ? itemData.contentWidth : itemData.width;
152
137
  return acc;
153
138
  }, {} as Record<number, number>);
154
- // to make useMemo in sync with the ref
155
139
  // eslint-disable-next-line react-hooks/exhaustive-deps
156
- }, [variant, tabItemPositions.current.length]);
140
+ }, [variant, tabNames, layoutVersion]);
157
141
 
158
142
  const scrollHandler = useCallback(
159
143
  (currValue: number) => {
160
- if (tabItemPositions.current.length > currValue) {
144
+ if (tabItemPositions.current.size > currValue) {
161
145
  const itemStartPosition = currValue === 0 ? 0 : itemPositionsMap[currValue - 1];
162
146
  const itemEndPosition = itemPositionsMap[currValue];
163
147
 
@@ -182,45 +166,76 @@ export const TabBase = ({
182
166
  [itemPositionsMap, tabContainerWidth],
183
167
  );
184
168
 
169
+ // Animate indicator position and width when value changes
170
+ useEffect(() => {
171
+ Animated.parallel([
172
+ Animated.timing(positionAnimationRef.current, {
173
+ toValue: valueIndex,
174
+ useNativeDriver: false,
175
+ duration: 170,
176
+ }),
177
+ Animated.timing(widthAnimationRef.current, {
178
+ toValue: valueIndex,
179
+ useNativeDriver: false,
180
+ duration: 170,
181
+ }),
182
+ ]).start();
183
+
184
+ if (scrollable) {
185
+ requestAnimationFrame(() => scrollHandler(valueIndex));
186
+ }
187
+ }, [scrollHandler, valueIndex, scrollable]);
188
+
189
+ // Handle tab count changes
185
190
  useEffect(() => {
186
- Animated.timing(positionAnimationRef.current, {
187
- toValue: valueIndex,
188
- useNativeDriver: false,
189
- duration: 170,
190
- }).start();
191
+ const currentTabCount = tabNames.length;
192
+
193
+ if (currentTabCount !== previousTabCountRef.current) {
194
+ // Clean up positions for removed tabs
195
+ const currentTabNamesSet = new Set(tabNames);
196
+ tabItemPositions.current.forEach((_, name) => {
197
+ if (!currentTabNamesSet.has(name)) {
198
+ tabItemPositions.current.delete(name);
199
+ }
200
+ });
191
201
 
192
- scrollable && requestAnimationFrame(() => scrollHandler(valueIndex));
193
- }, [positionAnimationRef, scrollHandler, valueIndex, scrollable]);
202
+ // Trigger re-calculation
203
+ setLayoutVersion(v => v + 1);
194
204
 
195
- useEffect(() => {
196
- Animated.timing(widthAnimationRef.current, {
197
- toValue: valueIndex,
198
- useNativeDriver: false,
199
- duration: 170,
200
- }).start();
201
- }, [positionAnimationRef, scrollHandler, valueIndex]);
205
+ // Clamp animated values when tabs are removed
206
+ if (currentTabCount < previousTabCountRef.current) {
207
+ const maxValidIndex = Math.max(0, currentTabCount - 1);
208
+ positionAnimationRef.current.setValue(Math.min(valueIndex, maxValidIndex));
209
+ widthAnimationRef.current.setValue(Math.min(valueIndex, maxValidIndex));
210
+ }
211
+ }
212
+
213
+ previousTabCountRef.current = currentTabCount;
214
+ }, [tabNames, valueIndex]);
202
215
 
203
216
  const onScrollHandler = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
204
217
  scrollViewPosition.current = event.nativeEvent.contentOffset.x;
205
218
  }, []);
206
219
 
207
- const transitionInterpolateWithMap = useCallback(
208
- (obj: Record<any, number>) => {
209
- const countItems = validChildren.length;
210
- if (countItems < 2 || !tabItemPositions.current.length) {
211
- // if there's only one item, use the value of that
212
- return Object.values(obj)[0] || 0;
213
- }
214
- const inputRange = Array.from(Array(countItems).keys());
215
- const outputRange = Object.values(obj);
220
+ const transitionInterpolateWithMap = useCallback((obj: Record<number, number>) => {
221
+ const entries = Object.entries(obj);
222
+ const countItems = entries.length;
216
223
 
217
- return positionAnimationRef.current.interpolate({
218
- inputRange,
219
- outputRange,
220
- });
221
- },
222
- [validChildren.length],
223
- );
224
+ if (countItems < 2 || !tabItemPositions.current.size) {
225
+ // If there's only one item or no layout data, use the first value
226
+ return Object.values(obj)[0] || 0;
227
+ }
228
+
229
+ // Use indices from the map entries to ensure inputRange matches outputRange
230
+ const inputRange = entries.map(([key]) => Number(key));
231
+ const outputRange = entries.map(([, val]) => val);
232
+
233
+ return positionAnimationRef.current.interpolate({
234
+ inputRange,
235
+ outputRange,
236
+ extrapolate: 'clamp',
237
+ });
238
+ }, []);
224
239
 
225
240
  const indicatorTransitionInterpolate = useMemo(() => {
226
241
  return transitionInterpolateWithMap(itemPositionsMap);
@@ -230,14 +245,12 @@ export const TabBase = ({
230
245
  return transitionInterpolateWithMap(itemWidthsMap);
231
246
  }, [transitionInterpolateWithMap, itemWidthsMap]);
232
247
 
233
- const { containerStyle, itemsContainerStyle, dividerStyle, indicatorStyle } = useMemo(() => {
234
- const { indicator, itemsContainer, divider } = tabsStyles;
248
+ const { containerStyle, indicatorStyle } = useMemo(() => {
249
+ const { indicator, itemsContainer } = tabsStyles;
235
250
  const { activeColor, ...restStyle } = tabsStyles.root;
236
251
 
237
252
  return {
238
- containerStyle: [restStyle, style],
239
- itemsContainerStyle: itemsContainer,
240
- dividerStyle: [divider, dividerStyleProp],
253
+ containerStyle: [restStyle, itemsContainer, style],
241
254
  indicatorStyle: [
242
255
  indicator,
243
256
  {
@@ -254,7 +267,6 @@ export const TabBase = ({
254
267
  };
255
268
  }, [
256
269
  style,
257
- dividerStyleProp,
258
270
  activeColorProp,
259
271
  indicatorTransitionInterpolate,
260
272
  widthTransitionInterpolate,
@@ -268,7 +280,6 @@ export const TabBase = ({
268
280
  ref: scrollViewRef,
269
281
  onScroll: onScrollHandler,
270
282
  showsHorizontalScrollIndicator: false,
271
- style: itemsContainerStyle,
272
283
  }
273
284
  : {};
274
285
 
@@ -276,95 +287,88 @@ export const TabBase = ({
276
287
  setTabContainerWidth(layout.width);
277
288
  }, []);
278
289
 
279
- const onLayoutItem = useCallback((event: LayoutChangeEvent, index: number) => {
290
+ const onLayoutItem = useCallback((event: LayoutChangeEvent, name: T) => {
280
291
  const { width } = event.nativeEvent.layout;
281
292
 
282
- const currentItemPosition = tabItemPositions.current[index];
293
+ const currentItemPosition = tabItemPositions.current.get(name) || {
294
+ width: 0,
295
+ contentWidth: 0,
296
+ };
283
297
 
284
- tabItemPositions.current[index] = {
298
+ tabItemPositions.current.set(name, {
285
299
  ...currentItemPosition,
286
300
  width: width,
287
- };
301
+ });
302
+ setLayoutVersion(v => v + 1);
288
303
  }, []);
289
304
 
290
- const onLayoutText = useCallback((event: LayoutChangeEvent, index: number) => {
305
+ const onLayoutText = useCallback((event: LayoutChangeEvent, name: T) => {
291
306
  const { width } = event.nativeEvent.layout;
292
307
 
293
- const currentItemPosition = tabItemPositions.current[index];
308
+ const currentItemPosition = tabItemPositions.current.get(name) || {
309
+ width: 0,
310
+ contentWidth: 0,
311
+ };
294
312
 
295
- tabItemPositions.current[index] = {
313
+ tabItemPositions.current.set(name, {
296
314
  ...currentItemPosition,
297
315
  contentWidth: width,
298
- };
316
+ });
317
+ setLayoutVersion(v => v + 1);
299
318
  }, []);
300
319
 
301
320
  return (
302
- <View
321
+ <Container
303
322
  {...rest}
304
323
  testID={testID}
324
+ {...containerProps}
305
325
  style={containerStyle}
306
326
  accessibilityRole="tablist"
307
327
  onLayout={onLayout}>
308
- <>
309
- <Container
310
- testID={testID && `${testID}--inner-container`}
311
- {...containerProps}
312
- style={itemsContainerStyle}>
313
- {validChildren.map((child, index) => (
314
- <ChildItem
315
- key={(child as ReactElement<TabItemProps>).props?.name}
316
- testID={testID && `${testID}--tab-item`}
317
- index={index}
318
- value={value}
319
- child={child as ReactElement<TabItemProps>}
320
- onChange={onChange}
321
- onLayout={onLayoutItem}
322
- onLayoutContent={onLayoutText}
323
- variant={variant}
324
- />
325
- ))}
326
-
327
- {!disableIndicator && (
328
- <Animated.View
329
- testID={testID && `${testID}--active-indicator`}
330
- {...indicatorProps}
331
- style={indicatorStyle}
332
- />
333
- )}
334
- </Container>
335
-
336
- <HorizontalDivider
337
- testID={testID && `${testID}--divider`}
338
- {...dividerProps}
339
- style={dividerStyle}
328
+ {tabItems.map(child => (
329
+ <ChildItem
330
+ key={(child as ReactElement<TabItemProps<T>>).props?.name}
331
+ testID={testID && `${testID}--tab-item`}
332
+ value={value}
333
+ child={child as ReactElement<TabItemProps<T>>}
334
+ onChange={onChange}
335
+ onLayout={onLayoutItem}
336
+ onLayoutContent={onLayoutText}
337
+ variant={variant}
338
+ />
339
+ ))}
340
+
341
+ {!disableIndicator && (
342
+ <Animated.View
343
+ testID={testID && `${testID}--active-indicator`}
344
+ {...indicatorProps}
345
+ style={indicatorStyle}
340
346
  />
341
- </>
342
- </View>
347
+ )}
348
+ </Container>
343
349
  );
344
350
  };
345
351
 
346
- type ChildItemProps = {
347
- variant: TabsProps['variant'];
348
- child: ReactElement<TabItemProps>;
349
- onChange: TabsProps['onChange'];
350
- onLayout: (event: LayoutChangeEvent, index: number) => void;
351
- onLayoutContent: (event: LayoutChangeEvent, index: number) => void;
352
- value: string;
353
- index: number;
352
+ type ChildItemProps<T extends string | number> = {
353
+ variant: TabsProps<T>['variant'];
354
+ child: ReactElement<TabItemProps<T>>;
355
+ onChange: TabsProps<T>['onChange'];
356
+ onLayout: (event: LayoutChangeEvent, name: T) => void;
357
+ onLayoutContent: (event: LayoutChangeEvent, name: T) => void;
358
+ value: string | number;
354
359
  testID?: string;
355
360
  };
356
361
 
357
- const ChildItem = memo(
358
- ({
362
+ const ChildItem = typedMemo(
363
+ <T extends string | number>({
359
364
  value,
360
365
  child,
361
366
  onChange,
362
367
  onLayout: onLayoutProp,
363
368
  onLayoutContent: onLayoutContentProp,
364
369
  variant,
365
- index,
366
370
  testID,
367
- }: ChildItemProps) => {
371
+ }: ChildItemProps<T>) => {
368
372
  const name = child.props?.name;
369
373
 
370
374
  const onPress = useCallback(() => {
@@ -373,16 +377,16 @@ const ChildItem = memo(
373
377
 
374
378
  const onLayout = useCallback(
375
379
  (e: LayoutChangeEvent) => {
376
- onLayoutProp(e, index);
380
+ onLayoutProp(e, name);
377
381
  },
378
- [index, onLayoutProp],
382
+ [name, onLayoutProp],
379
383
  );
380
384
 
381
385
  const onLayoutContent = useCallback(
382
386
  (e: LayoutChangeEvent) => {
383
- onLayoutContentProp(e, index);
387
+ onLayoutContentProp(e, name);
384
388
  },
385
- [index, onLayoutContentProp],
389
+ [name, onLayoutContentProp],
386
390
  );
387
391
 
388
392
  return cloneElement(child, {
@@ -396,4 +400,5 @@ const ChildItem = memo(
396
400
  },
397
401
  );
398
402
 
403
+ (ChildItem as ComponentType).displayName = 'Tabs_ChildItem';
399
404
  TabBase.displayName = 'Tabs';
@@ -1,10 +1,25 @@
1
+ import { createContext } from 'react';
1
2
  import { StyleSheet } from 'react-native-unistyles';
2
3
 
3
4
  import { getRegisteredComponentStylesWithFallback } from '../../core';
4
5
 
6
+ export type TabItemContextType = {
7
+ active: boolean;
8
+ hovered: boolean;
9
+ variant: 'primary' | 'secondary';
10
+ };
11
+
12
+ export const TabItemContext = createContext<TabItemContextType>({
13
+ active: false,
14
+ hovered: false,
15
+ variant: 'primary',
16
+ });
17
+
5
18
  const tabsStylesDefault = StyleSheet.create(theme => ({
6
19
  root: {
7
20
  activeColor: theme.colors.primary,
21
+ borderBottomWidth: 1,
22
+ borderBottomColor: theme.colors.outlineVariant,
8
23
  } as any,
9
24
 
10
25
  itemsContainer: {
@@ -30,8 +45,6 @@ const tabsStylesDefault = StyleSheet.create(theme => ({
30
45
  },
31
46
  },
32
47
  },
33
-
34
- divider: {},
35
48
  }));
36
49
 
37
50
  const tabsItemStylesDefault = StyleSheet.create(theme => ({