@widergy/mobile-ui 1.51.0 → 2.0.1

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/lib/components/Carousel/components/CarouselContainer/index.js +5 -3
  3. package/lib/components/FilePicker/constants.js +1 -4
  4. package/lib/components/FilePicker/index.js +33 -17
  5. package/lib/components/ImagePicker/ModalSelectionOption/index.js +14 -2
  6. package/lib/components/ImagePicker/index.js +5 -1
  7. package/lib/components/ImagePicker/layout.js +65 -5
  8. package/lib/components/MultipleFilePicker/index.js +275 -35
  9. package/lib/components/MultipleFilePicker/propTypes.js +16 -1
  10. package/lib/components/MultipleFilePicker/utils.js +44 -13
  11. package/lib/components/Overlay/index.js +8 -3
  12. package/lib/components/PhotoAlbum/index.js +28 -4
  13. package/lib/components/RateChart/components/RateStagesGraph/components/Bars/index.js +5 -4
  14. package/lib/components/UTActionCard/components/Header/index.js +1 -0
  15. package/lib/components/UTActionCard/components/Header/utils.js +4 -1
  16. package/lib/components/UTCheckBox/index.js +3 -15
  17. package/lib/components/UTCheckBox/theme.js +4 -18
  18. package/lib/components/UTCheckList/index.js +11 -7
  19. package/lib/components/UTDetailDrawer/index.js +4 -4
  20. package/lib/components/UTIcon/index.js +0 -1
  21. package/lib/components/UTIcon/utils.js +3 -3
  22. package/lib/components/UTModal/index.js +2 -0
  23. package/lib/components/UTRating/index.js +4 -3
  24. package/lib/components/UTRating/styles.js +3 -1
  25. package/lib/components/UTTabs/index.js +14 -6
  26. package/lib/components/UTTabs/styles.js +51 -19
  27. package/lib/components/UTTopbar/index.js +3 -5
  28. package/lib/components/UTTracker/components/Step/styles.js +8 -4
  29. package/lib/components/UTWorkflowContainer/versions/V1/index.js +19 -4
  30. package/lib/reactotronConfig.js +36 -3
  31. package/lib/utils/fileUtils.js/index.js +15 -10
  32. package/package.json +12 -10
@@ -1,4 +1,4 @@
1
- import { string, bool, func, number, array, object } from 'prop-types';
1
+ import { string, bool, func, number, array, object, shape } from 'prop-types';
2
2
 
3
3
  export default {
4
4
  allowedPDFUploadSizes: array,
@@ -14,6 +14,21 @@ export default {
14
14
  onChange: func,
15
15
  onError: func,
16
16
  onMaxSizeError: func,
17
+ permissionPrompt: shape({
18
+ androidRationale: shape({
19
+ title: string,
20
+ message: string,
21
+ buttonPositive: string,
22
+ buttonNegative: string,
23
+ buttonNeutral: string
24
+ }),
25
+ expoPrePrompt: shape({
26
+ title: string,
27
+ message: string,
28
+ confirmText: string,
29
+ cancelText: string
30
+ })
31
+ }),
17
32
  pickerText: string,
18
33
  style: object,
19
34
  title: string,
@@ -1,7 +1,5 @@
1
1
  import { isEmpty } from 'lodash';
2
2
  import { PDFDocument } from 'pdf-lib';
3
- // eslint-disable-next-line import/no-unresolved
4
- import DocumentPicker from 'react-native-document-picker';
5
3
 
6
4
  const mimeTypes = {
7
5
  allFiles: '*/*',
@@ -57,11 +55,11 @@ const pageMatches = (pageSize, allowedPDFUploadSizes) =>
57
55
  const blobToBase64 = blob =>
58
56
  new Promise((resolve, reject) => {
59
57
  const reader = new FileReader();
60
- reader.onload = function () {
58
+ reader.onload = function onload() {
61
59
  const result = reader.result.replace(/^data:.+;base64,/, '');
62
60
  resolve(result);
63
61
  };
64
- reader.onerror = function () {
62
+ reader.onerror = function onerror() {
65
63
  reject(new Error('No es posible leer el archivo.'));
66
64
  };
67
65
  reader.readAsDataURL(blob);
@@ -78,19 +76,52 @@ const pdfAspectRatioValidation = async (file, allowedPDFUploadSizes) => {
78
76
  });
79
77
  };
80
78
 
81
- export const getFileType = extension =>
82
- Object.entries(extensions).find(([, value]) => value.includes(extension))[0];
83
-
84
- export const getMimeType = extension => {
85
- const fileType = getFileType(extension);
86
- const mimeType = mimeTypes[fileType];
87
- return mimeType.replace('*', extension.replace(/\./g, ''));
79
+ export const getFileType = extensionOrCategory => {
80
+ const input = extensionOrCategory;
81
+ if (!input) return undefined;
82
+ if (extensions[input]) return input;
83
+ if (input === 'image') return 'images';
84
+ if (input === 'video') return 'video';
85
+ if (input === 'audio') return 'audio';
86
+ if (typeof input === 'string' && input.startsWith('.')) {
87
+ const entry = Object.entries(extensions).find(([, value]) => value.includes(input));
88
+ return entry ? entry[0] : undefined;
89
+ }
90
+ return undefined;
88
91
  };
89
92
 
90
- export const getDocumentPickerType = extension => DocumentPicker.types[getFileType(extension)];
93
+ export const getMimeType = value => {
94
+ const input = value;
95
+ if (!input) return mimeTypes.allFiles;
96
+ if (typeof input === 'string' && input.includes('/')) return input;
97
+ const key = getFileType(input);
98
+ if (key && mimeTypes[key]) return mimeTypes[key];
99
+ if (input === 'image') return 'image/*';
100
+ if (input === 'video') return 'video/*';
101
+ if (input === 'audio') return 'audio/*';
102
+ return mimeTypes.allFiles;
103
+ };
91
104
 
92
105
  export const isFileTypeInvalid = (document, allowedTypes, fileTypeError, onError) => {
93
- const isInvalid = !allowedTypes.map(getMimeType).includes(document.type);
106
+ const normalizedAllowed = (
107
+ Array.isArray(allowedTypes) && allowedTypes.length > 0 ? allowedTypes : ['allFiles']
108
+ )
109
+ .map(getMimeType)
110
+ .filter(Boolean);
111
+
112
+ const docTypeRaw = document?.type || '';
113
+ const docType = docTypeRaw.includes('/') ? docTypeRaw : `${docTypeRaw}/*`;
114
+
115
+ const matches = (allowed, actual) => {
116
+ if (allowed === mimeTypes.allFiles) return true;
117
+ if (allowed.endsWith('/*')) {
118
+ const prefix = `${allowed.split('/')[0]}/`;
119
+ return actual.startsWith(prefix) || actual === allowed;
120
+ }
121
+ return allowed === actual;
122
+ };
123
+
124
+ const isInvalid = !normalizedAllowed.some(a => matches(a, docType));
94
125
  if (isInvalid) onError(fileTypeError || 'Tipo de archivo inválido.');
95
126
  return isInvalid;
96
127
  };
@@ -19,7 +19,8 @@ class Overlay extends Component {
19
19
 
20
20
  this.state = {
21
21
  opacity: new Value(0),
22
- backgroundOpacity: new Value(props.transparentBackground ? 0 : 1)
22
+ backgroundOpacity: new Value(props.transparentBackground ? 0 : 1),
23
+ backSubscription: null
23
24
  };
24
25
  }
25
26
 
@@ -58,7 +59,10 @@ class Overlay extends Component {
58
59
  }
59
60
 
60
61
  setBack() {
61
- BackHandler.addEventListener('hardwareBackPress', this.dismiss);
62
+ this.setState(prevState => ({
63
+ ...prevState,
64
+ backSubscription: BackHandler.addEventListener('hardwareBackPress', this.dismiss)
65
+ }));
62
66
  }
63
67
 
64
68
  dismiss = () => {
@@ -94,7 +98,8 @@ class Overlay extends Component {
94
98
  };
95
99
 
96
100
  removeBack() {
97
- BackHandler.removeEventListener('hardwareBackPress', this.dismiss);
101
+ const { backSubscription } = this.state;
102
+ backSubscription?.remove?.();
98
103
  }
99
104
 
100
105
  render() {
@@ -3,6 +3,7 @@ import React, { Fragment, useEffect, useState } from 'react';
3
3
  import { View, TouchableOpacity, PermissionsAndroid } from 'react-native';
4
4
  // eslint-disable-next-line import/no-unresolved
5
5
  import { launchCamera } from 'react-native-image-picker';
6
+ import * as ExpoImagePicker from 'expo-image-picker';
6
7
 
7
8
  import UTLabel from '../UTLabel';
8
9
  import Portal from '../Portal';
@@ -59,20 +60,43 @@ const PhotoAlbum = ({
59
60
  };
60
61
 
61
62
  const openCamera = async () => {
63
+ if (!ExpoImagePicker || typeof ExpoImagePicker.launchCameraAsync !== 'function') {
64
+ // eslint-disable-next-line no-console
65
+ console.error('No se encontró un ExpoImagePicker válido. instala "expo-image-picker".');
66
+ return;
67
+ }
62
68
  // eslint-disable-next-line no-useless-catch
63
69
  try {
64
- if (IS_ANDROID) await requestPermission();
70
+ if (ExpoImagePicker?.launchCameraAsync) {
71
+ const perm = await ExpoImagePicker.requestCameraPermissionsAsync?.();
72
+ if (!perm || perm.status !== 'granted') {
73
+ throw messageErrorPermissionCamera;
74
+ }
75
+ const mediaTypes = ExpoImagePicker.MediaType ? [ExpoImagePicker.MediaType.image] : undefined;
76
+ const result = await ExpoImagePicker.launchCameraAsync({
77
+ mediaTypes,
78
+ allowsMultipleSelection: false,
79
+ quality: 1
80
+ });
81
+ if (result?.canceled) return;
82
+ const asset = Array.isArray(result?.assets) && result.assets[0];
83
+ if (asset?.uri) {
84
+ onPressAddAnotherPhoto({ newImage: { uri: asset.uri }, numberOfPhotosInTheAlbumHasExceeded });
85
+ }
86
+ return;
87
+ }
65
88
 
89
+ if (IS_ANDROID) await requestPermission();
66
90
  const response = await launchCamera({});
67
91
  const { didCancel, errorCode, errorMessage, assets } = response;
68
92
  if (didCancel || errorCode) {
69
93
  if (errorCode) {
70
94
  onUnknownCameraError(`[${errorCode}] : ${errorMessage}`);
71
95
  }
72
- } else {
73
- const { uri } = assets[0];
74
- onPressAddAnotherPhoto({ newImage: { uri }, numberOfPhotosInTheAlbumHasExceeded });
96
+ return;
75
97
  }
98
+ const { uri } = assets[0];
99
+ onPressAddAnotherPhoto({ newImage: { uri }, numberOfPhotosInTheAlbumHasExceeded });
76
100
  } catch (error) {
77
101
  throw error;
78
102
  }
@@ -10,14 +10,15 @@ import SubStage from './components/Stage/components/SubStage';
10
10
  import styles from './styles';
11
11
 
12
12
  const Bars = ({ rateStages, withoutStages }) => {
13
- const totalSubStages = rateStages.reduce(
13
+ const rateStagesToUse = [...(rateStages || [])];
14
+ const totalSubStages = rateStagesToUse.reduce(
14
15
  (previous, current) => previous + current.sub_rate_stages.length,
15
16
  0
16
17
  );
17
18
 
18
- const totalRange = getTotalRange(rateStages);
19
+ const totalRange = getTotalRange(rateStagesToUse);
19
20
  // eslint-disable-next-line camelcase
20
- const subStages = rateStages.flatMap(rate => rate?.sub_rate_stages);
21
+ const subStages = rateStagesToUse.flatMap(rate => rate?.sub_rate_stages);
21
22
 
22
23
  return (
23
24
  <View style={styles.container}>
@@ -35,7 +36,7 @@ const Bars = ({ rateStages, withoutStages }) => {
35
36
  isLast={index === subStages.length - 1}
36
37
  />
37
38
  ))
38
- : rateStages.map((stage, index) => (
39
+ : rateStagesToUse.map((stage, index) => (
39
40
  <Stage
40
41
  key={stage.group}
41
42
  stage={stage}
@@ -60,6 +60,7 @@ const Header = ({
60
60
  )}
61
61
  </View>
62
62
  </View>
63
+ {renderAdornment(adornment, 'right')}
63
64
  {status ? (
64
65
  <Status
65
66
  {...statusPropsMapper(status, theme)}
@@ -65,7 +65,10 @@ export const statusPropsMapper = (status, theme) => {
65
65
  export const renderAdornment = (adornment, position) => {
66
66
  if (isEmpty(adornment) || adornment.position !== position) return null;
67
67
  const AdornmentComponent = ADORNMENT_COMPONENT_MAPPER[adornment.type];
68
- const defaultPlaceSelf = adornment.position === 'left' ? PLACE_SELF_TYPES.CENTER : PLACE_SELF_TYPES.START;
68
+ const defaultPlaceSelf =
69
+ adornment.position === 'left' || adornment.position === 'right'
70
+ ? PLACE_SELF_TYPES.CENTER
71
+ : PLACE_SELF_TYPES.START;
69
72
 
70
73
  return (
71
74
  <AdornmentComponent
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo, useCallback } from 'react';
1
+ import React, { useMemo, useCallback } from 'react';
2
2
  import { View, Pressable } from 'react-native';
3
3
 
4
4
  import { useTheme } from '../../theming';
@@ -33,7 +33,6 @@ const UTCheckBox = ({
33
33
  dataTestId
34
34
  }) => {
35
35
  const theme = useTheme();
36
- const [pressed, setPressed] = useState(false);
37
36
 
38
37
  const { containerStyles, iconContainerStyles, boxStyles, pressableStyles, titleStyles } = useMemo(
39
38
  () =>
@@ -42,14 +41,13 @@ const UTCheckBox = ({
42
41
  disabled,
43
42
  error,
44
43
  indeterminate,
45
- pressed,
46
44
  reversed,
47
45
  spacing,
48
46
  style,
49
47
  theme,
50
48
  variant
51
49
  }),
52
- [value, disabled, error, indeterminate, reversed, spacing, style, theme, variant, pressed]
50
+ [value, disabled, error, indeterminate, reversed, spacing, style, theme, variant]
53
51
  );
54
52
 
55
53
  const iconName = useMemo(
@@ -57,9 +55,6 @@ const UTCheckBox = ({
57
55
  [indeterminate, value]
58
56
  );
59
57
 
60
- const handlePressIn = useCallback(() => setPressed(true), []);
61
- const handlePressOut = useCallback(() => setPressed(false), []);
62
-
63
58
  const handlePress = useCallback(() => {
64
59
  if (!disabled && onChange) {
65
60
  onChange(!value);
@@ -71,14 +66,7 @@ const UTCheckBox = ({
71
66
  const validationData = useMemo(() => error && formatErrorToValidation(error), [error]);
72
67
 
73
68
  return (
74
- <Pressable
75
- style={pressableStyles}
76
- disabled={disabled}
77
- onPress={handlePress}
78
- onPressIn={handlePressIn}
79
- onPressOut={handlePressOut}
80
- testID={dataTestId}
81
- >
69
+ <Pressable style={pressableStyles} disabled={disabled} onPress={handlePress} testID={dataTestId}>
82
70
  <View style={styles.container}>
83
71
  <View style={containerStyles}>
84
72
  {isSimple ? (
@@ -33,7 +33,6 @@ const conditionalStyles = ({
33
33
  reversed,
34
34
  spacing,
35
35
  variant,
36
- pressed,
37
36
  theme
38
37
  }) => {
39
38
  const spacingValue = spacing === SPACING.SMALL ? SMALL_SPACING : MEDIUM_SPACING;
@@ -63,24 +62,13 @@ const conditionalStyles = ({
63
62
  marginLeft: spacingValue,
64
63
  marginRight: 0
65
64
  }
66
- : { marginRight: spacingValue }),
67
- ...(pressed &&
68
- variant !== BUTTON_VARIANT && {
69
- backgroundColor: theme.Palette.accent['02'],
70
- borderColor: theme.Palette.accent['03'],
71
- borderWidth: 1,
72
- overflow: 'hidden',
73
- padding: 5
74
- })
65
+ : { marginRight: spacingValue })
75
66
  },
76
67
  pressable: {
77
68
  ...(variant === BUTTON_VARIANT && {
78
69
  borderRadius: 8,
79
70
  paddingHorizontal: 16,
80
71
  paddingVertical: 12,
81
- ...(pressed && {
82
- backgroundColor: theme.Palette.light['04']
83
- }),
84
72
  ...(error && {
85
73
  backgroundColor: theme.Palette.error['02']
86
74
  })
@@ -99,8 +87,7 @@ export const retrieveStyle = ({
99
87
  spacing,
100
88
  style,
101
89
  theme,
102
- variant,
103
- pressed
90
+ variant
104
91
  }) => {
105
92
  const baseThemeStyles = baseCheckBoxTheme(theme);
106
93
 
@@ -112,7 +99,6 @@ export const retrieveStyle = ({
112
99
  reversed,
113
100
  spacing,
114
101
  variant,
115
- pressed,
116
102
  theme
117
103
  });
118
104
 
@@ -124,7 +110,7 @@ export const retrieveStyle = ({
124
110
  containerStyles: {
125
111
  ...baseThemeStyles.container,
126
112
  ...container,
127
- ...style.root
113
+ ...style?.root
128
114
  },
129
115
  iconContainerStyles: {
130
116
  ...baseThemeStyles.iconContainer,
@@ -132,7 +118,7 @@ export const retrieveStyle = ({
132
118
  },
133
119
  titleStyles: {
134
120
  ...baseThemeStyles.title,
135
- ...style.title
121
+ ...style?.title
136
122
  },
137
123
  pressableStyles: pressable
138
124
  };
@@ -51,7 +51,7 @@ const UTCheckList = ({
51
51
  }
52
52
  }, [value, onChange]);
53
53
 
54
- const enabledOptions = useMemo(() => options.filter(option => !option.disabled), [options]);
54
+ const enabledOptions = useMemo(() => [...options].filter(option => !option.disabled), [options]);
55
55
 
56
56
  const areAllSelected = useMemo(
57
57
  () => enabledOptions.every(option => value?.includes(option.value)),
@@ -83,9 +83,13 @@ const UTCheckList = ({
83
83
 
84
84
  return (
85
85
  <View
86
- style={[styles.container, verticalSpacing === SPACING.SMALL && styles.smallVerticalSpacing, style.root]}
86
+ style={[
87
+ styles.container,
88
+ verticalSpacing === SPACING.SMALL && styles.smallVerticalSpacing,
89
+ style?.root
90
+ ]}
87
91
  >
88
- <View style={[styles.headerContainer, style.header]}>
92
+ <View style={[styles.headerContainer, style?.header]}>
89
93
  {title && (
90
94
  <UTFieldLabel
91
95
  required={required}
@@ -106,7 +110,7 @@ const UTCheckList = ({
106
110
  styles.checkboxesContainer,
107
111
  verticalSpacingBasedOnVariant === SPACING.SMALL && styles.smallVerticalSpacing,
108
112
  variant === BUTTON_VARIANT && styles.buttonVariant,
109
- style.checkboxesContainer
113
+ style?.checkboxesContainer
110
114
  ]}
111
115
  >
112
116
  {showSelectAll && !isSimple && (
@@ -117,12 +121,12 @@ const UTCheckList = ({
117
121
  onChange={handleCheckAll}
118
122
  reversed={reversedBasedOnVariant}
119
123
  spacing={horizontalSpacing}
120
- style={style.selectAll}
124
+ style={style?.selectAll}
121
125
  variant={variant}
122
126
  dataTestId={dataTestId ? `${dataTestId}.${selectAllTestId}` : undefined}
123
127
  />
124
128
  )}
125
- {options?.map((item, index) => (
129
+ {[...options]?.map((item, index) => (
126
130
  <UTCheckBox
127
131
  isSimple={isSimple}
128
132
  value={isChecked(item, value)}
@@ -132,7 +136,7 @@ const UTCheckList = ({
132
136
  onChange={() => handleChange(item.value)}
133
137
  reversed={reversedBasedOnVariant}
134
138
  spacing={horizontalSpacing}
135
- style={style.item}
139
+ style={style?.item}
136
140
  variant={variant}
137
141
  dataTestId={dataTestId ? `${dataTestId}.${optionTestId}.${index}` : undefined}
138
142
  />
@@ -76,16 +76,16 @@ const UTDetailDrawer = ({
76
76
  dataTestId={dataTestId ? `${dataTestId}.${close}` : undefined}
77
77
  onPress={onClose}
78
78
  Icon="IconX"
79
- variant={CloseButtonProps.variant || 'text'}
80
- colorTheme={CloseButtonProps.colorTheme || null}
79
+ variant={CloseButtonProps?.variant || 'text'}
80
+ colorTheme={CloseButtonProps?.colorTheme || null}
81
81
  style={{
82
82
  root: {
83
83
  ...mergedStyles.closeButton,
84
- ...(CloseButtonProps.root ? CloseButtonProps.root : null)
84
+ ...(CloseButtonProps?.root ? CloseButtonProps.root : null)
85
85
  },
86
86
  icon: {
87
87
  ...mergedStyles.icon,
88
- ...(CloseButtonProps.icon ? CloseButtonProps.icon : null)
88
+ ...(CloseButtonProps?.icon ? CloseButtonProps.icon : null)
89
89
  }
90
90
  }}
91
91
  />
@@ -24,7 +24,6 @@ const UTIcon = ({
24
24
  variant = 'default'
25
25
  }) => {
26
26
  const theme = useTheme();
27
-
28
27
  const IconComponent = ENERGY_ICONS[name] || TablerIcons[name];
29
28
  if (!IconComponent) return null;
30
29
 
@@ -29,9 +29,9 @@ export const getIconProps = ({
29
29
  };
30
30
 
31
31
  return {
32
- style: [filled && { color: themeColor }, iconStyles],
33
- ...(internalFill && { fill: internalFill }),
34
- ...(isEnergyIcon || isBrandIcon ? customIconProps : defaultIconProps)
32
+ ...(isEnergyIcon || isBrandIcon ? customIconProps : defaultIconProps),
33
+ ...(filled ? { color: themeColor, ...iconStyles } : {}),
34
+ ...(internalFill && { fill: internalFill })
35
35
  };
36
36
  };
37
37
 
@@ -69,6 +69,8 @@ const UTModal = ({
69
69
  Keyboard.dismiss();
70
70
  };
71
71
 
72
+ if (!visible) return null;
73
+
72
74
  return (
73
75
  <Modal
74
76
  animationType="fade"
@@ -32,8 +32,9 @@ const UTRating = ({
32
32
  () => validations || (error && formatErrorToValidation(error)),
33
33
  [error, validations]
34
34
  );
35
+ const optionsToUse = [...options];
35
36
 
36
- const valueIndex = options.map(({ value: optionValue }) => optionValue).indexOf(value);
37
+ const valueIndex = optionsToUse.map(({ value: optionValue }) => optionValue).indexOf(value);
37
38
  const isSelected = index =>
38
39
  valueIndex !== -1 && variant === RATING_VARIANTS.STAR ? index <= valueIndex : index === valueIndex;
39
40
 
@@ -52,7 +53,7 @@ const UTRating = ({
52
53
  </UTFieldLabel>
53
54
  )}
54
55
  <View style={styles.optionsContainer} onLayout={onLayout}>
55
- {options.map((option, index) => (
56
+ {optionsToUse.map((option, index) => (
56
57
  <Option
57
58
  {...option}
58
59
  disabled={disabled}
@@ -61,7 +62,7 @@ const UTRating = ({
61
62
  key={option.value}
62
63
  onChange={onChange}
63
64
  variant={variant}
64
- wrapperStyle={styles.option(containerWidth, error, options, theme)}
65
+ wrapperStyle={styles.option(containerWidth, error, optionsToUse, theme)}
65
66
  />
66
67
  ))}
67
68
  </View>
@@ -23,6 +23,7 @@ export default StyleSheet.create({
23
23
  option: (containerWidth, error, options, theme) => {
24
24
  const topItems = Math.ceil(options.length / 2);
25
25
  const breakOptions = options.length > 5;
26
+
26
27
  return {
27
28
  alignContent: 'center',
28
29
  display: 'flex',
@@ -30,7 +31,8 @@ export default StyleSheet.create({
30
31
  flexDirection: 'row',
31
32
  flexGrow: breakOptions ? 0 : 1,
32
33
  flexShrink: 0,
33
- height: 48,
34
+ minHeight: 48,
35
+ minWidth: 48,
34
36
  justifyContent: 'center',
35
37
  ...(error ? { borderColor: theme.Palette.error['05'], borderWidth: 2 } : {})
36
38
  };
@@ -170,13 +170,21 @@ const UTTabs = ({
170
170
  disabled={selected}
171
171
  key={`tab-${label || icon}`}
172
172
  onPress={() => setSelectedTab(index)}
173
- style={styles.tab(tabs.length)}
173
+ style={styles.tabContainer(tabs.length, index)}
174
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" />}
175
+ <View style={styles.tabContent(selected)}>
176
+ {icon && <UTIcon colorTheme={colorThemeToUse} name={icon} />}
177
+ <UTLabel
178
+ colorTheme={colorThemeToUse}
179
+ shade="04"
180
+ weight="medium"
181
+ style={styles.tabLabel}
182
+ {...fixedLabelProps}
183
+ >
184
+ {label}
185
+ </UTLabel>
186
+ {badge && <UTBadge colorTheme="accent" />}
187
+ </View>
180
188
  </Pressable>
181
189
  );
182
190
  })}
@@ -2,6 +2,16 @@ import { StyleSheet } from 'react-native';
2
2
 
3
3
  import { COLOR_THEMES, HIERARCHIES } from './constants';
4
4
 
5
+ const getTabDistribution = length => {
6
+ const equalWidth = `${100 / length}%`;
7
+ return {
8
+ widths: Array(length).fill(equalWidth),
9
+ positions: Array(length)
10
+ .fill(0)
11
+ .map((_, i) => `${(100 / length) * i}%`)
12
+ };
13
+ };
14
+
5
15
  export default StyleSheet.create(({ Palette: { accent, neutral, negative, light } }) => ({
6
16
  border: {
7
17
  position: 'absolute',
@@ -80,13 +90,24 @@ export default StyleSheet.create(({ Palette: { accent, neutral, negative, light
80
90
  }
81
91
  : {};
82
92
 
93
+ const distribution = getTabDistribution(length);
94
+ const inputRange =
95
+ length === 3
96
+ ? [0, 1, 2]
97
+ : Array(length)
98
+ .fill(0)
99
+ .map((_, i) => i);
100
+
83
101
  const positioningStyles = isScrollable
84
102
  ? {}
85
103
  : {
86
- width: `${100 / length}%`,
104
+ width: position.interpolate({
105
+ inputRange,
106
+ outputRange: distribution.widths
107
+ }),
87
108
  left: position.interpolate({
88
- inputRange: [0, length],
89
- outputRange: ['0%', '100%']
109
+ inputRange,
110
+ outputRange: distribution.positions
90
111
  })
91
112
  };
92
113
 
@@ -98,22 +119,33 @@ export default StyleSheet.create(({ Palette: { accent, neutral, negative, light
98
119
  ...positioningStyles
99
120
  };
100
121
  },
101
- // Original fixed-width tab style (for scrollableTabs=false)
102
- tab:
103
- tabs =>
104
- ({ pressed }) => ({
105
- alignItems: 'center',
106
- display: 'flex',
107
- flexDirection: 'row',
108
- flexBasis: tabs ? `${100 / tabs}%` : 'auto',
109
- paddingHorizontal: 16,
110
- gap: 8,
111
- height: 48,
112
- justifyContent: 'center',
113
- zIndex: 3,
114
- backgroundColor: pressed ? accent['01'] : null
115
- }),
116
- // Scrollable tab style (for scrollableTabs=true)
122
+ tabContainer: (tabs, index) => () => {
123
+ const distribution = getTabDistribution(tabs);
124
+ const width = distribution.widths[index] || 'auto';
125
+ return {
126
+ width,
127
+ zIndex: 3
128
+ };
129
+ },
130
+ tabContent: selected => ({
131
+ alignItems: 'center',
132
+ display: 'flex',
133
+ flexDirection: 'row',
134
+ paddingHorizontal: 8,
135
+ gap: 8,
136
+ height: 48,
137
+ justifyContent: 'center',
138
+ backgroundColor: selected ? 'white' : 'transparent',
139
+ borderRadius: 4,
140
+ width: '100%',
141
+ minWidth: '100%',
142
+ overflow: 'hidden',
143
+ position: 'relative'
144
+ }),
145
+ tabLabel: {
146
+ flex: 1,
147
+ textAlign: 'center'
148
+ },
117
149
  scrollableTab: ({ pressed }) => ({
118
150
  alignItems: 'center',
119
151
  display: 'flex',