@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 +7 -0
- package/lib/components/UTTabs/README.md +27 -11
- package/lib/components/UTTabs/index.js +139 -24
- package/lib/components/UTTabs/styles.js +104 -58
- package/package.json +1 -1
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
|
|
8
|
+
Here's a table for the component props:
|
|
7
9
|
|
|
8
|
-
| Name
|
|
9
|
-
|
|
10
|
-
| colorTheme
|
|
11
|
-
| hierarchy
|
|
12
|
-
| onChange
|
|
13
|
-
| style
|
|
14
|
-
| tabs
|
|
15
|
-
| withTabSliding
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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:
|
|
12
|
+
zIndex: 0
|
|
13
13
|
},
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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