@widergy/mobile-ui 2.1.4 → 2.3.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,17 @@
1
+ # [2.3.0](https://github.com/widergy/mobile-ui/compare/v2.2.0...v2.3.0) (2025-12-01)
2
+
3
+
4
+ ### Features
5
+
6
+ * [UGGC-23] carousel autoplay ([#464](https://github.com/widergy/mobile-ui/issues/464)) ([5a81315](https://github.com/widergy/mobile-ui/commit/5a81315a79571e3b833e99b6a96f40ecf4684571))
7
+
8
+ # [2.2.0](https://github.com/widergy/mobile-ui/compare/v2.1.4...v2.2.0) (2025-11-28)
9
+
10
+
11
+ ### Features
12
+
13
+ * [EVEP-52] onboarding component ([#467](https://github.com/widergy/mobile-ui/issues/467)) ([277ff19](https://github.com/widergy/mobile-ui/commit/277ff194b397ca08001d151a9dd52ec17a4e63df))
14
+
1
15
  ## [2.1.4](https://github.com/widergy/mobile-ui/compare/v2.1.3...v2.1.4) (2025-11-19)
2
16
 
3
17
 
@@ -11,6 +11,8 @@ For example, if `contentHeight` and `contentWidth` are not set, the carousel wil
11
11
 
12
12
  | NAME | TYPE | REQUIRED | DESCRIPTION | DEFAULT |
13
13
  | --- | --- | --- | --- | --- |
14
+ | autoPlay | bool | No | Whether the carousel should automatically scroll to the next item. | * `false` |
15
+ | autoPlayDelay | number | No | The delay between each auto scroll. | * `3000` |
14
16
  | ...[ScrollView props]('https://facebook.github.io/react-native/docs/scrollview#props') | ScrollViewProps | No | Excluded `alwaysBounceHorizontal`, `alwaysBounceVertical`, `automaticallyAdjustContentInsets`, `pagingEnabled`, `scrollEventThrottle` & `scrollsToTop`. | `ScrollView.defaultProps` |
15
17
  | horizontal | bool | No* | The orientation of the carousel. | * `true` |
16
18
  | contentHeight | number | No* | The `height` of each carousel item. Also the height of the carousel itself, if the prop `horizontal` is `true`. | * `0` |
@@ -6,23 +6,30 @@ import styles from '../../styles';
6
6
  import propTypes, { carouselForcedProps } from '../../propTypes';
7
7
  import { checkSizeProp, CAROUSEL_MOUNT_DELAY, EXTRA_CAROUSEL_ITEMS } from '../../constants';
8
8
 
9
+ import { validateIndex } from './utils';
10
+
9
11
  class CarouselComponent extends Component {
10
12
  constructor(props) {
11
13
  super(props);
12
14
  this.ref = createRef();
13
- const { initialIndex: currentIndex, spacing, visibleEdge } = props;
15
+ const { initialIndex, spacing, visibleEdge } = props;
14
16
  checkSizeProp(spacing, 'spacing');
15
17
  checkSizeProp(visibleEdge, 'visibleEdge');
16
18
  this.state = {
17
- currentIndex,
19
+ autoPlayTimeout: null,
20
+ autoScrollRestartTimeout: null,
21
+ currentIndex: validateIndex(initialIndex, 0),
22
+ isAutoScrolling: false,
18
23
  opacity: 0,
19
24
  opacityTimeout: null,
25
+ resumeAutoPlayTimeout: null,
20
26
  timeout: null
21
27
  };
22
28
  }
23
29
 
24
30
  componentDidMount() {
25
- const { initialIndex } = this.props;
31
+ const { initialIndex, autoPlay } = this.props;
32
+ const validInitialIndex = validateIndex(initialIndex, 0);
26
33
  /**
27
34
  * These timeouts are required to scroll to the initial index on mount.
28
35
  * They are assigned to state variables because state is asynchronous.
@@ -30,23 +37,29 @@ class CarouselComponent extends Component {
30
37
  * We can then cancel the timeouts in case the component unmounts before they were called.
31
38
  */
32
39
  const timeout = setTimeout(() => {
33
- this.scrollToIndex(initialIndex);
40
+ this.scrollToIndex(validInitialIndex);
41
+ if (autoPlay) {
42
+ this.startAutoPlay();
43
+ }
34
44
  }, CAROUSEL_MOUNT_DELAY);
45
+
35
46
  const opacityTimeout = setTimeout(() => this.setState({ opacity: 1 }), CAROUSEL_MOUNT_DELAY + 10);
36
- this.setState({
37
- opacityTimeout,
38
- timeout
39
- });
47
+
48
+ this.setState({ opacityTimeout, timeout });
40
49
  }
41
50
 
42
51
  componentWillUnmount() {
43
- const { opacityTimeout, timeout } = this.state;
52
+ const { opacityTimeout, timeout, autoPlayTimeout, resumeAutoPlayTimeout, autoScrollRestartTimeout } =
53
+ this.state;
44
54
  /**
45
55
  * Here we cancel the timeouts in case they were not executed before the component was unmounted.
46
56
  * This avoids memory leaks (because if we don't do it, it will set state to an unmounted component).
47
57
  */
48
58
  clearTimeout(opacityTimeout);
49
59
  clearTimeout(timeout);
60
+ clearTimeout(autoPlayTimeout);
61
+ clearTimeout(resumeAutoPlayTimeout);
62
+ clearTimeout(autoScrollRestartTimeout);
50
63
  }
51
64
 
52
65
  changeCurrentIndex = index => {
@@ -58,36 +71,145 @@ class CarouselComponent extends Component {
58
71
 
59
72
  changeInternalIndex = index => {
60
73
  const { currentIndex } = this.state;
61
- if (index !== currentIndex) {
62
- this.setState({ currentIndex: index }, () => this.changeCurrentIndex(index));
74
+ const { items, loop } = this.props;
75
+ const validIndex = validateIndex(index, currentIndex);
76
+
77
+ if (validIndex !== currentIndex) {
78
+ this.setState({ currentIndex: validIndex }, () => {
79
+ let indexToReport = validIndex;
80
+ if (loop) {
81
+ indexToReport = ((validIndex % items.length) + items.length) % items.length;
82
+ }
83
+ this.changeCurrentIndex(indexToReport);
84
+ });
63
85
  }
64
86
  };
65
87
 
88
+ startAutoPlay = () => {
89
+ const { autoPlay, autoPlayDelay, items } = this.props;
90
+ if (!autoPlay || items.length <= 1) return;
91
+
92
+ this.clearAutoPlay();
93
+ const autoPlayTimeout = setTimeout(this.autoPlayNext, autoPlayDelay);
94
+ this.setState({ autoPlayTimeout });
95
+ };
96
+
97
+ clearAutoPlay = () => {
98
+ const { autoPlayTimeout } = this.state;
99
+ if (autoPlayTimeout) {
100
+ clearTimeout(autoPlayTimeout);
101
+ this.setState({ autoPlayTimeout: null });
102
+ }
103
+ };
104
+
105
+ autoPlayNext = () => {
106
+ const { currentIndex } = this.state;
107
+ const { items, loop } = this.props;
108
+
109
+ if (!items || items.length <= 1) return;
110
+
111
+ const nextIndex = loop
112
+ ? (currentIndex + 1) % items.length
113
+ : currentIndex + 1 >= items.length
114
+ ? 0
115
+ : currentIndex + 1;
116
+
117
+ this.setState({ isAutoScrolling: true });
118
+ this.scrollToIndex(nextIndex, true, false, true);
119
+ };
120
+
121
+ pauseAutoPlay = () => {
122
+ const { autoPlay } = this.props;
123
+ if (autoPlay) {
124
+ this.clearAutoPlay();
125
+ const { resumeAutoPlayTimeout } = this.state;
126
+ if (resumeAutoPlayTimeout) {
127
+ clearTimeout(resumeAutoPlayTimeout);
128
+ this.setState({ resumeAutoPlayTimeout: null });
129
+ }
130
+ }
131
+ };
132
+
133
+ resumeAutoPlay = (delay = 1000) => {
134
+ const { autoPlay } = this.props;
135
+ if (autoPlay) {
136
+ const { resumeAutoPlayTimeout } = this.state;
137
+ if (resumeAutoPlayTimeout) {
138
+ clearTimeout(resumeAutoPlayTimeout);
139
+ }
140
+
141
+ const newResumeTimeout = setTimeout(() => {
142
+ this.startAutoPlay();
143
+ this.setState({ resumeAutoPlayTimeout: null });
144
+ }, delay);
145
+
146
+ this.setState({ resumeAutoPlayTimeout: newResumeTimeout });
147
+ }
148
+ };
149
+
150
+ handleTouchStart = () => this.pauseAutoPlay();
151
+
152
+ handleTouchEnd = () => this.resumeAutoPlay();
153
+
66
154
  handleScroll = ({ nativeEvent: { contentOffset } }) => {
67
155
  const { horizontal, contentHeight, contentWidth, items, loop, spacing, visibleEdge } = this.props;
156
+ const { isAutoScrolling } = this.state;
68
157
  const offset = horizontal ? contentOffset.x : contentOffset.y;
69
158
  const size = (horizontal ? contentWidth : contentHeight) - visibleEdge * 2 - spacing;
70
159
  const index = Math.round(offset / size);
71
160
  const last = items.length - 1;
72
- const boundary = spacing + visibleEdge;
73
- const isLast = index <= 1 && offset <= size + boundary;
74
- const isFirst = index >= last + 3 && offset >= (last + 3) * size - boundary;
161
+ const isLast = index <= 1;
162
+ const isFirst = index >= last + 3;
163
+
164
+ if (!isAutoScrolling) {
165
+ this.pauseAutoPlay();
166
+ this.resumeAutoPlay(1500);
167
+ }
168
+
75
169
  if (loop) {
76
- if (isLast) this.scrollToIndex(last);
77
- else if (isFirst) this.scrollToIndex(0);
78
- else this.changeInternalIndex(index - EXTRA_CAROUSEL_ITEMS);
79
- } else this.changeInternalIndex(index);
170
+ if (isLast) {
171
+ this.scrollToIndex(last, false, false, true);
172
+ this.setState({ currentIndex: last }, () => {
173
+ this.changeCurrentIndex(last);
174
+ });
175
+ } else if (isFirst) {
176
+ this.scrollToIndex(0, false, false, true);
177
+ this.setState({ currentIndex: 0 }, () => {
178
+ this.changeCurrentIndex(0);
179
+ });
180
+ } else {
181
+ const actualIndex = index - EXTRA_CAROUSEL_ITEMS;
182
+ this.changeInternalIndex(actualIndex);
183
+ }
184
+ } else {
185
+ this.changeInternalIndex(index);
186
+ }
187
+
188
+ if (isAutoScrolling) {
189
+ this.setState({ isAutoScrolling: false });
190
+
191
+ const { autoScrollRestartTimeout } = this.state;
192
+ if (autoScrollRestartTimeout) {
193
+ clearTimeout(autoScrollRestartTimeout);
194
+ }
195
+
196
+ const newAutoScrollRestartTimeout = setTimeout(() => {
197
+ this.startAutoPlay();
198
+ this.setState({ autoScrollRestartTimeout: null });
199
+ }, 100);
200
+ this.setState({ autoScrollRestartTimeout: newAutoScrollRestartTimeout });
201
+ }
80
202
  };
81
203
 
82
204
  renderCarouselItem = (item, index, array) => {
83
205
  const {
84
206
  contentHeight,
85
207
  contentWidth,
208
+ horizontal,
209
+ itemStyle,
86
210
  keyExtractor,
87
211
  keyPrefix,
88
- horizontal,
89
212
  loop,
90
- itemStyle,
91
213
  renderItem,
92
214
  spacing,
93
215
  visibleEdge
@@ -102,11 +224,19 @@ class CarouselComponent extends Component {
102
224
  visibleEdge
103
225
  };
104
226
 
105
- const key = loop
106
- ? index < EXTRA_CAROUSEL_ITEMS || index > array.length - 1
107
- ? `${keyPrefix}${keyExtractor(item, index, array)}`
108
- : `${keyPrefix}${index}`
109
- : `${keyPrefix}${keyExtractor(item, index, array)}`;
227
+ let key;
228
+ if (loop) {
229
+ if (index < EXTRA_CAROUSEL_ITEMS) {
230
+ key = `${keyPrefix}loop-start-${index}`;
231
+ } else if (index >= array.length - EXTRA_CAROUSEL_ITEMS) {
232
+ key = `${keyPrefix}loop-end-${index}`;
233
+ } else {
234
+ const originalIndex = index - EXTRA_CAROUSEL_ITEMS;
235
+ key = `${keyPrefix}${keyExtractor(item, originalIndex, array)}`;
236
+ }
237
+ } else {
238
+ key = `${keyPrefix}${keyExtractor(item, index, array)}`;
239
+ }
110
240
 
111
241
  return (
112
242
  <CarouselItem {...itemProps} key={key}>
@@ -124,19 +254,23 @@ class CarouselComponent extends Component {
124
254
  return children.map(this.renderCarouselItem);
125
255
  };
126
256
 
127
- scrollToIndex = (index, animated = false, disableThreshold = false) => {
257
+ scrollToIndex = (index, animated = false, disableThreshold = false, skipOnChange = false) => {
128
258
  if (this.ref && this.ref.current) {
129
- this.changeCurrentIndex(index);
130
259
  const { contentHeight, contentWidth, horizontal, items, loop, spacing, visibleEdge } = this.props;
131
260
  if (index >= 0 - disableThreshold && index < items.length + disableThreshold) {
261
+ if (!skipOnChange) {
262
+ this.changeCurrentIndex(index);
263
+ }
132
264
  const indexToScrollTo = loop ? index + EXTRA_CAROUSEL_ITEMS : index;
133
265
  const scrollConfig = horizontal
134
266
  ? { x: indexToScrollTo * (contentWidth - visibleEdge * 2 - spacing) }
135
267
  : { y: indexToScrollTo * (contentHeight - visibleEdge * 2 - spacing) };
136
268
  this.ref.current.scrollTo({ ...scrollConfig, animated });
269
+ } else {
270
+ // eslint-disable-next-line no-console
271
+ console.warn('The index specified is not in the item list!', index, items.length);
137
272
  }
138
- // eslint-disable-next-line no-console
139
- } else console.warn('The index specified is not in the item list!');
273
+ }
140
274
  };
141
275
 
142
276
  // eslint-disable-next-line react/no-unused-class-component-methods
@@ -179,6 +313,8 @@ class CarouselComponent extends Component {
179
313
  style={[styles.contentStyle, forcedStyles, { opacity }]}
180
314
  horizontal={horizontal}
181
315
  onScroll={this.handleScroll}
316
+ onTouchStart={this.handleTouchStart}
317
+ onTouchEnd={this.handleTouchEnd}
182
318
  overScrollMode={loop ? 'never' : 'auto'}
183
319
  pagingEnabled
184
320
  ref={this.ref}
@@ -0,0 +1,2 @@
1
+ export const validateIndex = (index, fallback = 0) =>
2
+ typeof index === 'number' && !Number.isNaN(index) ? index : fallback;
@@ -13,6 +13,8 @@ export const carouselForcedProps = {
13
13
 
14
14
  export const carouselDefaultProps = {
15
15
  ...ScrollView.defaultProps,
16
+ autoPlay: false,
17
+ autoPlayDelay: 3000,
16
18
  contentHeight: 0,
17
19
  contentWidth: 0,
18
20
  horizontal: true, // ScrollView prop
@@ -31,6 +33,8 @@ export const carouselDefaultProps = {
31
33
 
32
34
  const propTypes = {
33
35
  ...ScrollView.propTypes,
36
+ autoPlay: bool,
37
+ autoPlayDelay: number,
34
38
  contentHeight: number,
35
39
  contentWidth: number,
36
40
  initialIndex: number.isRequired,
@@ -140,7 +140,7 @@ const UTTabs = ({
140
140
  onScroll={handleScroll}
141
141
  scrollEventThrottle={16}
142
142
  >
143
- {tabs.map(({ badge, label, icon }, index) => {
143
+ {tabs.map(({ badge, label, icon, walkthrough }, index) => {
144
144
  const selected = selectedTab === index;
145
145
  const colorThemeToUse = selected ? colorTheme : 'gray';
146
146
  return (
@@ -150,6 +150,7 @@ const UTTabs = ({
150
150
  key={`tab-${label || icon}`}
151
151
  onPress={() => setSelectedTab(index)}
152
152
  style={styles.scrollableTab}
153
+ {...walkthrough}
153
154
  >
154
155
  {icon && <UTIcon colorTheme={colorThemeToUse} name={icon} />}
155
156
  <UTLabel colorTheme={colorThemeToUse} shade="04" weight="medium" {...labelProps}>
@@ -162,7 +163,7 @@ const UTTabs = ({
162
163
  </ScrollView>
163
164
  ) : (
164
165
  <View style={styles.container}>
165
- {tabs.map(({ badge, label, icon }, index) => {
166
+ {tabs.map(({ badge, label, icon, walkthrough, walkthroughStyle }, index) => {
166
167
  const selected = selectedTab === index;
167
168
  const colorThemeToUse = selected ? colorTheme : 'gray';
168
169
  return (
@@ -170,7 +171,8 @@ const UTTabs = ({
170
171
  disabled={selected}
171
172
  key={`tab-${label || icon}`}
172
173
  onPress={() => setSelectedTab(index)}
173
- style={styles.tabContainer(tabs.length, index)}
174
+ style={() => [styles.tabContainer(tabs.length, index)(), ...(walkthroughStyle || [])]}
175
+ {...walkthrough}
174
176
  >
175
177
  <View style={styles.tabContent(selected)}>
176
178
  {icon && <UTIcon colorTheme={colorThemeToUse} name={icon} />}
@@ -123,6 +123,7 @@ export default StyleSheet.create(({ Palette: { accent, neutral, negative, light
123
123
  const distribution = getTabDistribution(tabs);
124
124
  const width = distribution.widths[index] || 'auto';
125
125
  return {
126
+ paddingBottom: 4,
126
127
  width,
127
128
  zIndex: 3
128
129
  };
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { func, object, array } from 'prop-types';
4
+
5
+ import UTLabel from '../UTLabel';
6
+ import UTButton from '../UTButton';
7
+
8
+ import ownStyles from './styles';
9
+
10
+ const UTWalkthroughStep = ({
11
+ buttonTexts,
12
+ containerStyle,
13
+ colors,
14
+ handleFinishWalkthrough,
15
+ next,
16
+ previous,
17
+ step,
18
+ stepsConfig,
19
+ walkthroughStepTestIds
20
+ }) => {
21
+ const styles = ownStyles(colors);
22
+ const stepNumber = stepsConfig.findIndex(s => s.identifier === step.identifier) + 1;
23
+
24
+ const currentStep = stepsConfig.find(stepConfig => stepConfig.identifier === step.identifier);
25
+ const isFirstStep = currentStep.identifier === stepsConfig[0].identifier;
26
+ const isLastStep = currentStep.identifier === stepsConfig[stepsConfig.length - 1].identifier;
27
+
28
+ return (
29
+ <View style={[styles.container, containerStyle]}>
30
+ <View style={styles.titleContainer}>
31
+ <UTLabel
32
+ colorTheme="light"
33
+ dataTestId={walkthroughStepTestIds.title}
34
+ style={styles.grow}
35
+ weight="medium"
36
+ withMarkdown
37
+ >
38
+ {currentStep.title}
39
+ </UTLabel>
40
+ <View style={styles.buttons}>
41
+ <UTButton
42
+ colorTheme="negative"
43
+ dataTestId={walkthroughStepTestIds.closeButton}
44
+ Icon="IconX"
45
+ onPress={handleFinishWalkthrough}
46
+ size="small"
47
+ variant="text"
48
+ />
49
+ </View>
50
+ </View>
51
+ <View style={styles.content}>
52
+ <UTLabel
53
+ colorTheme="light"
54
+ dataTestId={walkthroughStepTestIds.description}
55
+ variant="small"
56
+ withMarkdown
57
+ >
58
+ {currentStep.description}
59
+ </UTLabel>
60
+ </View>
61
+ <View style={styles.footer}>
62
+ <View style={styles.page}>
63
+ <UTLabel colorTheme="light" variant="small" withMarkdown>
64
+ {`${stepNumber} de ${stepsConfig.length}`}
65
+ </UTLabel>
66
+ </View>
67
+ <View style={styles.buttons}>
68
+ {!isFirstStep && (
69
+ <UTButton
70
+ colorTheme="negative"
71
+ dataTestId={walkthroughStepTestIds.previousButton}
72
+ onPress={previous}
73
+ size="small"
74
+ variant="text"
75
+ weight="medium"
76
+ >
77
+ {buttonTexts.previous}
78
+ </UTButton>
79
+ )}
80
+ <UTButton
81
+ colorTheme="negative"
82
+ dataTestId={walkthroughStepTestIds.nextButton}
83
+ onPress={isLastStep ? handleFinishWalkthrough : next}
84
+ size="small"
85
+ variant="filled"
86
+ weight="medium"
87
+ >
88
+ {isLastStep ? buttonTexts.finish : isFirstStep ? buttonTexts.start : buttonTexts.next}
89
+ </UTButton>
90
+ </View>
91
+ </View>
92
+ </View>
93
+ );
94
+ };
95
+
96
+ UTWalkthroughStep.propTypes = {
97
+ buttonTexts: object,
98
+ colors: object,
99
+ containerStyle: object,
100
+ handleFinishWalkthrough: func,
101
+ next: func,
102
+ previous: func,
103
+ step: object,
104
+ stepsConfig: array,
105
+ walkthroughStepTestIds: object
106
+ };
107
+
108
+ export default UTWalkthroughStep;
@@ -0,0 +1,41 @@
1
+ import { StyleSheet } from 'react-native';
2
+
3
+ export default StyleSheet.create(colors => {
4
+ return {
5
+ container: {
6
+ borderRadius: 4,
7
+ backgroundColor: colors.dark04,
8
+ display: 'flex',
9
+ width: 296,
10
+ padding: 16,
11
+ position: 'absolute'
12
+ },
13
+ titleContainer: {
14
+ flexDirection: 'row',
15
+ justifyContent: 'space-between',
16
+ alignItems: 'flex-start'
17
+ },
18
+ grow: {
19
+ flexShrink: 1,
20
+ flexGrow: 1,
21
+ marginRight: 8
22
+ },
23
+ buttons: {
24
+ flexDirection: 'row',
25
+ gap: 8
26
+ },
27
+
28
+ content: {
29
+ marginBottom: 8
30
+ },
31
+ footer: {
32
+ flexDirection: 'row',
33
+ justifyContent: 'space-between',
34
+ marginTop: 8,
35
+ alignItems: 'flex-end'
36
+ },
37
+ page: {
38
+ alignItems: 'center'
39
+ }
40
+ };
41
+ });
package/lib/index.js CHANGED
@@ -77,6 +77,7 @@ export { default as UTTooltip } from './components/UTTooltip';
77
77
  export { default as UTTracker } from './components/UTTracker';
78
78
  export { default as UTValidation } from './components/UTValidation';
79
79
  export { default as UTWorkflowContainer } from './components/UTWorkflowContainer';
80
+ export { default as UTWalkthroughStep } from './components/UTWalkthroughStep';
80
81
 
81
82
  // Theming
82
83
  export * from './theming';
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": "2.1.4",
5
+ "version": "2.3.0",
6
6
  "repository": "https://github.com/widergy/mobile-ui.git",
7
7
  "main": "lib/index.js",
8
8
  "files": [