@widergy/mobile-ui 1.46.1 → 1.47.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.47.0](https://github.com/widergy/mobile-ui/compare/v1.46.1...v1.47.0) (2025-05-28)
2
+
3
+
4
+ ### Features
5
+
6
+ * tabs scroll ([#435](https://github.com/widergy/mobile-ui/issues/435)) ([2e1a123](https://github.com/widergy/mobile-ui/commit/2e1a12397e82f069bf0d6329220d438224cef1fc))
7
+
1
8
  ## [1.46.1](https://github.com/widergy/mobile-ui/compare/v1.46.0...v1.46.1) (2025-05-23)
2
9
 
3
10
 
@@ -1,18 +1,21 @@
1
1
  # UTTabs
2
2
 
3
- Tabs component.
3
+ Tabs component with two display modes:
4
+ 1. Fixed width (default): Tabs width are adjusted evenly to fill the container width
5
+ 2. Scrollable: Each tab has width based on its content and if all tabs don't fit in the container width, the component becomes horizontally scrollable
4
6
 
5
7
  ## Props
6
- Here's a new table for the additional props:
8
+ Here's a table for the component props:
7
9
 
8
- | Name | Type | Default | Description |
9
- |------------|---------|------------|-----------------------------------------------------------|
10
- | colorTheme | string | `accent` | Defines the color theme for the component. |
11
- | hierarchy | string | `primary` | Determines the hierarchy or importance of the tabs. |
12
- | onChange | func | | Callback function triggered when the selected tab changes.|
13
- | style | object | | Custom styles for component. |
14
- | tabs | array | | Array of tab items to be displayed within the component. |
15
- | withTabSliding | bool | `true` | Defines if tabs can be changed with gesture. |
10
+ | Name | Type | Default | Description |
11
+ |---------------|----------|-----------|------------------------------------------------------------|
12
+ | colorTheme | string | `accent` | Defines the color theme for the component. |
13
+ | hierarchy | string | `primary` | Determines the hierarchy or importance of the tabs. |
14
+ | onChange | func | | Callback function triggered when the selected tab changes. |
15
+ | style | object | | Custom styles for component. |
16
+ | tabs | array | | Array of tab items to be displayed within the component. |
17
+ | withTabSliding| bool | `true` | Defines if tabs can be changed with gesture. |
18
+ | scrollableTabs| bool | `false` | Enables scrollable mode with content-based tab widths. |
16
19
 
17
20
 
18
21
  ### colorTheme
@@ -38,4 +41,17 @@ The value of `hierarchy` must be one of the following:
38
41
  - `icon`: name of `UTIcon` to render
39
42
  - `badge`: not affected by `colorTheme`
40
43
 
41
- Besides those, any other prop can be added to a tab to be accessed with the `onChange` callback
44
+ Besides those, any other prop can be added to a tab to be accessed with the `onChange` callback
45
+
46
+ ## Behavior
47
+
48
+ ### Fixed Width Mode (scrollableTabs=false, default)
49
+ - Each tab has equal width to fill the container space
50
+ - If there are 2 tabs, each takes up 50% of the width
51
+ - The indicator takes the same width as the tab
52
+
53
+ ### Scrollable Mode (scrollableTabs=true)
54
+ - Each tab's width is determined by its content (label, icon, badge)
55
+ - When the combined width of all tabs exceeds the container width, the component becomes horizontally scrollable
56
+ - Selected tab is automatically scrolled into view when changed
57
+ - The indicator follows the width of the selected tab
@@ -1,4 +1,5 @@
1
- import { Animated, View, Pressable, PanResponder } from 'react-native';
1
+ /* eslint-disable react-hooks/exhaustive-deps */
2
+ import { Animated, View, Pressable, PanResponder, ScrollView } from 'react-native';
2
3
  import { array, bool, func, object, string } from 'prop-types';
3
4
  import React, { useEffect, useRef, useState } from 'react';
4
5
 
@@ -18,24 +19,67 @@ const UTTabs = ({
18
19
  style,
19
20
  tabs,
20
21
  theme,
21
- withTabSliding = true
22
+ withTabSliding = true,
23
+ scrollableTabs = false
22
24
  }) => {
23
25
  const styles = styleSheet(theme);
24
26
  const [selectedTab, setSelectedTab] = useState(0);
27
+ const [scrollOffset, setScrollOffset] = useState(0);
25
28
  const position = useRef(new Animated.Value(0)).current;
29
+ const indicatorPos = useRef(new Animated.Value(0)).current;
30
+ const indicatorWidth = useRef(new Animated.Value(0)).current;
31
+ const scrollViewRef = useRef(null);
32
+ const tabRefs = useRef([]);
26
33
 
27
34
  useEffect(() => {
28
35
  onChange?.(tabs[selectedTab]);
29
36
  }, []);
30
37
 
38
+ useEffect(() => {
39
+ if (scrollableTabs) {
40
+ tabRefs.current = Array(tabs.length)
41
+ .fill()
42
+ .map((_, i) => tabRefs.current[i] || React.createRef());
43
+ }
44
+ }, [tabs, scrollableTabs]);
45
+
31
46
  useEffect(() => {
32
47
  Animated.timing(position, {
33
48
  toValue: selectedTab,
34
- duration: 300
49
+ duration: 300,
50
+ useNativeDriver: false
35
51
  }).start();
52
+
53
+ if (scrollableTabs && scrollViewRef.current && tabRefs.current[selectedTab]) {
54
+ tabRefs.current[selectedTab].measureLayout(
55
+ scrollViewRef.current,
56
+ (x, y, width) => {
57
+ scrollViewRef.current?.scrollTo({ x: Math.max(0, x - 50), animated: true });
58
+
59
+ Animated.parallel([
60
+ Animated.timing(indicatorPos, {
61
+ toValue: x,
62
+ duration: 300,
63
+ useNativeDriver: false
64
+ }),
65
+ Animated.timing(indicatorWidth, {
66
+ toValue: width,
67
+ duration: 300,
68
+ useNativeDriver: false
69
+ })
70
+ ]).start();
71
+ },
72
+ () => {}
73
+ );
74
+ }
75
+
36
76
  onChange?.(tabs[selectedTab]);
37
77
  }, [selectedTab]);
38
78
 
79
+ const handleScroll = event => {
80
+ setScrollOffset(event.nativeEvent.contentOffset.x);
81
+ };
82
+
39
83
  const panResponder = useRef(
40
84
  PanResponder.create({
41
85
  onMoveShouldSetPanResponder: (_, gestureState) => Math.abs(gestureState.dx),
@@ -48,27 +92,97 @@ const UTTabs = ({
48
92
  })
49
93
  ).current;
50
94
 
51
- return (
52
- <View style={[styles.container, style]} {...(withTabSliding ? panResponder.panHandlers : {})}>
53
- {tabs.map(({ badge, label, icon }, index) => {
54
- const selected = selectedTab === index;
55
- const colorThemeToUse = selected ? colorTheme : 'gray';
56
- return (
57
- <Pressable
58
- disabled={selected}
59
- key={`tab-${label || icon}`}
60
- onPress={() => setSelectedTab(index)}
61
- style={styles.tab(tabs.length)}
62
- >
63
- <UTIcon colorTheme={colorThemeToUse} name={icon} />
64
- <UTLabel colorTheme={colorThemeToUse} shade="04" weight="medium" {...labelProps}>
65
- {label}
66
- </UTLabel>
67
- {badge && <UTBadge colorTheme="accent" />}
68
- </Pressable>
95
+ if (scrollableTabs && tabRefs.current.length !== tabs.length) {
96
+ tabRefs.current = Array(tabs.length)
97
+ .fill()
98
+ .map((_, i) => tabRefs.current[i] || React.createRef());
99
+ }
100
+
101
+ const calculatedIndicatorLeft = Animated.subtract(indicatorPos, scrollOffset);
102
+
103
+ const indicatorStyle = scrollableTabs
104
+ ? [
105
+ styles.indicator(position, tabs.length, hierarchy, colorTheme, true),
106
+ { width: indicatorWidth, left: calculatedIndicatorLeft }
107
+ ]
108
+ : styles.indicator(position, tabs.length, hierarchy, colorTheme, false);
109
+
110
+ useEffect(() => {
111
+ if (scrollableTabs && tabRefs.current[selectedTab]) {
112
+ setTimeout(() => {
113
+ tabRefs.current[selectedTab]?.measureLayout(
114
+ scrollViewRef.current,
115
+ (x, y, width) => {
116
+ indicatorPos.setValue(x);
117
+ indicatorWidth.setValue(width);
118
+ },
119
+ () => {}
69
120
  );
70
- })}
71
- <Animated.View style={styles.indicator(position, tabs.length, hierarchy, colorTheme)} />
121
+ }, 100);
122
+ }
123
+ }, [scrollableTabs]);
124
+
125
+ const fixedLabelProps = scrollableTabs
126
+ ? labelProps
127
+ : { ...labelProps, numberOfLines: 1, ellipsizeMode: 'tail' };
128
+
129
+ return (
130
+ <View style={[styles.containerWrapper, style]} {...(withTabSliding ? panResponder.panHandlers : {})}>
131
+ <Animated.View style={indicatorStyle} />
132
+
133
+ {scrollableTabs ? (
134
+ <ScrollView
135
+ ref={scrollViewRef}
136
+ horizontal
137
+ showsHorizontalScrollIndicator={false}
138
+ style={styles.scrollView}
139
+ contentContainerStyle={styles.scrollViewContent}
140
+ onScroll={handleScroll}
141
+ scrollEventThrottle={16}
142
+ >
143
+ {tabs.map(({ badge, label, icon }, index) => {
144
+ const selected = selectedTab === index;
145
+ const colorThemeToUse = selected ? colorTheme : 'gray';
146
+ return (
147
+ <Pressable
148
+ ref={ref => (tabRefs.current[index] = ref)}
149
+ disabled={selected}
150
+ key={`tab-${label || icon}`}
151
+ onPress={() => setSelectedTab(index)}
152
+ style={styles.scrollableTab}
153
+ >
154
+ {icon && <UTIcon colorTheme={colorThemeToUse} name={icon} />}
155
+ <UTLabel colorTheme={colorThemeToUse} shade="04" weight="medium" {...labelProps}>
156
+ {label}
157
+ </UTLabel>
158
+ {badge && <UTBadge colorTheme="accent" />}
159
+ </Pressable>
160
+ );
161
+ })}
162
+ </ScrollView>
163
+ ) : (
164
+ <View style={styles.container}>
165
+ {tabs.map(({ badge, label, icon }, index) => {
166
+ const selected = selectedTab === index;
167
+ const colorThemeToUse = selected ? colorTheme : 'gray';
168
+ return (
169
+ <Pressable
170
+ disabled={selected}
171
+ key={`tab-${label || icon}`}
172
+ onPress={() => setSelectedTab(index)}
173
+ style={styles.tab(tabs.length)}
174
+ >
175
+ {icon && <UTIcon colorTheme={colorThemeToUse} name={icon} />}
176
+ <UTLabel colorTheme={colorThemeToUse} shade="04" weight="medium" {...fixedLabelProps}>
177
+ {label}
178
+ </UTLabel>
179
+ {badge && <UTBadge colorTheme="accent" />}
180
+ </Pressable>
181
+ );
182
+ })}
183
+ </View>
184
+ )}
185
+
72
186
  <View style={styles.border} />
73
187
  </View>
74
188
  );
@@ -82,7 +196,8 @@ UTTabs.propTypes = {
82
196
  style: object,
83
197
  tabs: array,
84
198
  theme: object,
85
- withTabSliding: bool
199
+ withTabSliding: bool,
200
+ scrollableTabs: bool
86
201
  };
87
202
 
88
203
  export default withTheme(UTTabs);
@@ -9,74 +9,120 @@ export default StyleSheet.create(({ Palette: { accent, neutral, negative, light
9
9
  width: '100%',
10
10
  height: 1,
11
11
  backgroundColor: light['04'],
12
- zIndex: 1
12
+ zIndex: 0
13
13
  },
14
- container: { display: 'flex', flexDirection: 'row', width: '100%' },
15
- indicator: (position, length, hierarchy, colorTheme) =>
16
- ({
17
- [HIERARCHIES.PRIMARY]: {
18
- position: 'absolute',
19
- bottom: 0,
20
- width: `${100 / length}%`,
21
- height: '100%',
22
- borderRightWidth: position.interpolate({
23
- inputRange: [0, length - 1],
24
- outputRange: [1, 0]
25
- }),
26
- borderTopRightRadius: position.interpolate({
27
- inputRange: [0, length - 1],
28
- outputRange: [4, 0]
29
- }),
30
- borderLeftWidth: position.interpolate({
31
- inputRange: [0, 1],
32
- outputRange: [0, 1],
33
- extrapolate: 'clamp'
34
- }),
35
- borderTopLeftRadius: position.interpolate({
36
- inputRange: [0, 1],
37
- outputRange: [0, 4],
38
- extrapolate: 'clamp'
39
- }),
40
- backgroundColor: light['01'],
41
- borderTopWidth: 1,
42
- borderTopColor: light['04'],
43
- borderLeftColor: light['04'],
44
- borderRightColor: light['04'],
45
- borderBottomWidth: 1,
46
- borderBottomColor: light['01'],
47
- left: position.interpolate({
48
- inputRange: [0, length],
49
- outputRange: ['0%', '100%']
50
- }),
51
- zIndex: 2
52
- },
53
- [HIERARCHIES.SECONDARY]: {
54
- position: 'absolute',
55
- bottom: 0,
56
- width: `${100 / length}%`,
57
- height: 4,
58
- backgroundColor: {
59
- [COLOR_THEMES.ACCENT]: accent['04'],
60
- [COLOR_THEMES.NEUTRAL]: neutral['04'],
61
- [COLOR_THEMES.NEGATIVE]: negative['04']
62
- }[colorTheme],
63
- left: position.interpolate({
64
- inputRange: [0, length],
65
- outputRange: ['0%', '100%']
66
- })
67
- }
68
- })[hierarchy],
14
+ containerWrapper: {
15
+ position: 'relative',
16
+ width: '100%'
17
+ },
18
+ container: {
19
+ display: 'flex',
20
+ flexDirection: 'row',
21
+ width: '100%',
22
+ zIndex: 3
23
+ },
24
+ scrollView: {
25
+ flexDirection: 'row',
26
+ width: '100%',
27
+ zIndex: 3
28
+ },
29
+ scrollViewContent: {
30
+ flexDirection: 'row',
31
+ flexGrow: 1
32
+ },
33
+ indicator: (position, length, hierarchy, colorTheme, isScrollable = false) => {
34
+ const baseStyle = {
35
+ position: 'absolute',
36
+ bottom: 0,
37
+ zIndex: 1
38
+ };
39
+
40
+ const heightStyle = hierarchy === HIERARCHIES.PRIMARY ? { height: '100%' } : { height: 4 };
41
+
42
+ const backgroundColorStyle = {
43
+ backgroundColor:
44
+ hierarchy === HIERARCHIES.PRIMARY
45
+ ? light['01']
46
+ : {
47
+ [COLOR_THEMES.ACCENT]: accent['04'],
48
+ [COLOR_THEMES.NEUTRAL]: neutral['04'],
49
+ [COLOR_THEMES.NEGATIVE]: negative['04']
50
+ }[colorTheme]
51
+ };
52
+
53
+ const primaryBorderStyles =
54
+ hierarchy === HIERARCHIES.PRIMARY
55
+ ? {
56
+ borderTopWidth: 1,
57
+ borderTopColor: light['04'],
58
+ borderLeftColor: light['04'],
59
+ borderRightColor: light['04'],
60
+ borderBottomWidth: 1,
61
+ borderBottomColor: light['01'],
62
+ borderRightWidth: position.interpolate({
63
+ inputRange: [0, length - 1],
64
+ outputRange: [1, 0]
65
+ }),
66
+ borderTopRightRadius: position.interpolate({
67
+ inputRange: [0, length - 1],
68
+ outputRange: [4, 0]
69
+ }),
70
+ borderLeftWidth: position.interpolate({
71
+ inputRange: [0, 1],
72
+ outputRange: [0, 1],
73
+ extrapolate: 'clamp'
74
+ }),
75
+ borderTopLeftRadius: position.interpolate({
76
+ inputRange: [0, 1],
77
+ outputRange: [0, 4],
78
+ extrapolate: 'clamp'
79
+ })
80
+ }
81
+ : {};
82
+
83
+ const positioningStyles = isScrollable
84
+ ? {}
85
+ : {
86
+ width: `${100 / length}%`,
87
+ left: position.interpolate({
88
+ inputRange: [0, length],
89
+ outputRange: ['0%', '100%']
90
+ })
91
+ };
92
+
93
+ return {
94
+ ...baseStyle,
95
+ ...heightStyle,
96
+ ...backgroundColorStyle,
97
+ ...primaryBorderStyles,
98
+ ...positioningStyles
99
+ };
100
+ },
101
+ // Original fixed-width tab style (for scrollableTabs=false)
69
102
  tab:
70
103
  tabs =>
71
104
  ({ pressed }) => ({
72
105
  alignItems: 'center',
73
106
  display: 'flex',
74
- flexBasis: `${100 / tabs}%`,
75
107
  flexDirection: 'row',
108
+ flexBasis: tabs ? `${100 / tabs}%` : 'auto',
109
+ paddingHorizontal: 16,
76
110
  gap: 8,
77
111
  height: 48,
78
112
  justifyContent: 'center',
79
113
  zIndex: 3,
80
114
  backgroundColor: pressed ? accent['01'] : null
81
- })
115
+ }),
116
+ // Scrollable tab style (for scrollableTabs=true)
117
+ scrollableTab: ({ pressed }) => ({
118
+ alignItems: 'center',
119
+ display: 'flex',
120
+ flexDirection: 'row',
121
+ paddingHorizontal: 16,
122
+ gap: 8,
123
+ height: 48,
124
+ justifyContent: 'center',
125
+ zIndex: 3,
126
+ backgroundColor: pressed ? accent['01'] : null
127
+ })
82
128
  }));
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@widergy/mobile-ui",
3
3
  "description": "Widergy Mobile Components",
4
4
  "author": "widergy",
5
- "version": "1.46.1",
5
+ "version": "1.47.0",
6
6
  "repository": "https://github.com/widergy/mobile-ui.git",
7
7
  "main": "lib/index.js",
8
8
  "files": [