@widergy/mobile-ui 2.17.0 → 2.17.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.
package/CHANGELOG.md CHANGED
@@ -1,9 +1,15 @@
1
- # [2.17.0](https://github.com/widergy/mobile-ui/compare/v2.16.0...v2.17.0) (2026-06-03)
1
+ ## [2.17.1](https://github.com/widergy/mobile-ui/compare/v2.17.0...v2.17.1) (2026-06-05)
2
2
 
3
3
 
4
- ### Features
4
+ ### Bug Fixes
5
+
6
+ * [DIS-1103] ut date picker ([#507](https://github.com/widergy/mobile-ui/issues/507)) ([ffd6db3](https://github.com/widergy/mobile-ui/commit/ffd6db3c847dca170a5df955c1149a880c7f42cb))
7
+
8
+ # [2.17.0](https://github.com/widergy/mobile-ui/compare/v2.16.0...v2.17.0) (2026-06-03)
9
+
10
+ ### Novedades y Mejoras
5
11
 
6
- * [DIS-1102] utbanner upgrade ([#505](https://github.com/widergy/mobile-ui/issues/505)) ([6f4a16f](https://github.com/widergy/mobile-ui/commit/6f4a16f927728b1f790d5793e34d8abe9a083a2a))
12
+ * Se amplió el componente de banner con nuevas opciones de presentación: categoría encima del título, texto de ayuda debajo de la descripción, disposición del ícono y los botones en fila o columna, un segundo botón opcional y la posibilidad de cerrarlo desde una esquina. [#505](https://github.com/widergy/Energy-UI-Mobile/pull/505) [DIS-1102](https://widergy.atlassian.net/browse/DIS-1102)
7
13
 
8
14
  # [2.16.0](https://github.com/widergy/mobile-ui/compare/v2.15.0...v2.16.0) (2026-05-15)
9
15
 
@@ -96,6 +96,7 @@ const UTBottomSheet = ({
96
96
  }, [height]);
97
97
 
98
98
  const ChildrenContainer = scrolleable ? ScrollView : View;
99
+ const hasHeader = !!(title || buttonText || description);
99
100
 
100
101
  const actionAlignment =
101
102
  actionAlignment_ || [primaryAction, secondaryAction, tertiaryAction].filter(Boolean).length === 2
@@ -118,44 +119,46 @@ const UTBottomSheet = ({
118
119
  >
119
120
  <Animated.View style={[styles.animatedContainer(theme), { height: modalHeight }, pan.getLayout()]}>
120
121
  <SafeAreaView onLayout={onLayout} style={styles.content(adjustableHeight)}>
121
- <View style={styles.dragHandle}>
122
+ <View style={styles.dragHandle(hasHeader)}>
122
123
  <View style={styles.handleIndicator(theme)} />
123
124
  </View>
124
125
  <View style={styles.content(adjustableHeight)}>
125
- <View style={styles.header}>
126
- <View style={styles.headerTopRow}>
127
- <UTFieldLabel
128
- required={required}
129
- style={styles.title}
130
- variant="subtitle1"
131
- weight="medium"
132
- dataTestId={dataTestId ? `${dataTestId}.${titleTestId}` : undefined}
133
- {...titleProps}
134
- >
135
- {title}
136
- </UTFieldLabel>
137
- {buttonText && (
138
- <UTButton
139
- colorTheme="primary"
140
- onPress={onButtonPress ?? onClose}
141
- variant="semitransparent"
142
- dataTestId={dataTestId ? `${dataTestId}.${closeButton}` : undefined}
143
- {...onCloseProps}
126
+ {hasHeader && (
127
+ <View style={styles.header}>
128
+ <View style={styles.headerTopRow}>
129
+ <UTFieldLabel
130
+ required={required}
131
+ style={styles.title}
132
+ variant="subtitle1"
133
+ weight="medium"
134
+ dataTestId={dataTestId ? `${dataTestId}.${titleTestId}` : undefined}
135
+ {...titleProps}
144
136
  >
145
- {buttonText}
146
- </UTButton>
137
+ {title}
138
+ </UTFieldLabel>
139
+ {buttonText && (
140
+ <UTButton
141
+ colorTheme="primary"
142
+ onPress={onButtonPress ?? onClose}
143
+ variant="semitransparent"
144
+ dataTestId={dataTestId ? `${dataTestId}.${closeButton}` : undefined}
145
+ {...onCloseProps}
146
+ >
147
+ {buttonText}
148
+ </UTButton>
149
+ )}
150
+ </View>
151
+ {description && (
152
+ <UTLabel
153
+ colorTheme="gray"
154
+ variant="small"
155
+ dataTestId={dataTestId ? `${dataTestId}.${descriptionTestId}` : undefined}
156
+ >
157
+ {description}
158
+ </UTLabel>
147
159
  )}
148
160
  </View>
149
- {description && (
150
- <UTLabel
151
- colorTheme="gray"
152
- variant="small"
153
- dataTestId={dataTestId ? `${dataTestId}.${descriptionTestId}` : undefined}
154
- >
155
- {description}
156
- </UTLabel>
157
- )}
158
- </View>
161
+ )}
159
162
  <ChildrenContainer
160
163
  keyboardShouldPersistTaps="handled"
161
164
  style={styles.childrenContainer(adjustableHeight, withBodyPadding)}
@@ -42,11 +42,12 @@ const styles = StyleSheet.create({
42
42
  content: adjustableHeight => ({
43
43
  flexGrow: adjustableHeight ? 0 : 1
44
44
  }),
45
- dragHandle: {
45
+ dragHandle: hasHeader => ({
46
46
  alignItems: 'center',
47
47
  backgroundColor: 'transparent',
48
- padding: 16
49
- },
48
+ padding: 16,
49
+ paddingBottom: hasHeader ? 16 : 0
50
+ }),
50
51
  handleIndicator: theme => ({
51
52
  backgroundColor: theme.Palette.light['04'],
52
53
  borderRadius: 2.5,
@@ -0,0 +1,73 @@
1
+ # UTDatePicker
2
+
3
+ Selector de fechas con calendario modal y soporte de entrada manual. Adaptación mobile del componente `UTDatePicker` de Energy-UI web (V1).
4
+
5
+ ## Props
6
+
7
+ | Nombre | Tipo | Default | Descripción |
8
+ |-------------|---------------------------|---------------|-----------------------------------------------------------------------|
9
+ | clearable | `bool` | `true` | Muestra botón para limpiar la fecha seleccionada |
10
+ | CustomIcon | `elementType` | — | Ícono personalizado para el botón del calendario |
11
+ | disabled | `bool` | — | Deshabilita toda interacción |
12
+ | error | `string` | `''` | Mensaje de error externo |
13
+ | helpText | `string` | — | Texto de ayuda debajo del campo |
14
+ | modalProps | `object` | — | Props adicionales para el Modal de React Native (equivalente mobile de `popoverProps` en web) |
15
+ | onChange | `func` | `() => {}` | Callback al seleccionar una fecha. Recibe string en formato `DD/MM/YYYY` |
16
+ | placeholder | `string` | `'DD/MM/AAAA'`| Texto placeholder del campo |
17
+ | range | `{ maxDate, minDate }` | — | Restricción de rango. Fechas en formato `DD/MM/YYYY` |
18
+ | readOnly | `bool` | `false` | Modo solo lectura |
19
+ | required | `bool` | — | Marca el título como requerido |
20
+ | size | `'sm' \| 'md'` | `'sm'` | Tamaño del campo de entrada |
21
+ | style | `{ root: ViewStyle }` | — | Estilos personalizados para el contenedor raíz |
22
+ | title | `string` | — | Etiqueta superior del campo |
23
+ | value | `string` | `''` | Fecha seleccionada en formato `DD/MM/YYYY` |
24
+ | variant | `'select' \| 'picker'` | `'select'` | Variante visual del campo. `'picker'` usa fondo transparente sin borde |
25
+
26
+ ## Variantes
27
+
28
+ ### `variant='select'` (default)
29
+ Campo con borde y fondo blanco. Muestra el mensaje de error debajo del campo.
30
+
31
+ ### `variant='picker'`
32
+ Fondo transparente sin borde. El error no se muestra debajo (se maneja externamente).
33
+
34
+ ## Adaptaciones mobile vs web
35
+
36
+ - El calendario se abre mediante el ícono de calendario (no al hacer foco en el input, como en web).
37
+ - El `Popover` de MUI fue reemplazado por un `Modal` de React Native. Usar `modalProps` en lugar de `popoverProps`.
38
+ - Los estilos usan `StyleSheet` y el sistema de theming de la librería (no CSS Modules).
39
+ - La entrada de texto usa teclado numérico (`type='number'`).
40
+
41
+ ## Ejemplos
42
+
43
+ ```jsx
44
+ // Básico
45
+ <UTDatePicker
46
+ onChange={date => console.log(date)}
47
+ title="Fecha de nacimiento"
48
+ value={selectedDate}
49
+ />
50
+
51
+ // Con rango y error
52
+ <UTDatePicker
53
+ error="La fecha no es válida"
54
+ onChange={setDate}
55
+ range={{ minDate: '01/01/2020', maxDate: '31/12/2030' }}
56
+ title="Seleccioná una fecha"
57
+ value={date}
58
+ />
59
+
60
+ // Variante picker (transparente)
61
+ <UTDatePicker
62
+ onChange={setDate}
63
+ value={date}
64
+ variant="picker"
65
+ />
66
+
67
+ // Solo lectura
68
+ <UTDatePicker
69
+ readOnly
70
+ title="Fecha registrada"
71
+ value="15/06/2024"
72
+ />
73
+ ```
@@ -0,0 +1,3 @@
1
+ export const CALENDAR_LOCALE = 'es';
2
+ export const PICKER_ITEM_HEIGHT = 40;
3
+ export const WEEK_KEY_FORMAT = 'YYYY-MM-DD';
@@ -0,0 +1,197 @@
1
+ import React, { useState, useEffect, useMemo, useRef } from 'react';
2
+ import dayjs from 'dayjs';
3
+ import 'dayjs/locale/es';
4
+ import { View } from 'react-native';
5
+ import { func, object, string } from 'prop-types';
6
+
7
+ import UTButton from '../../../UTButton';
8
+ import UTLabel from '../../../UTLabel';
9
+ import { TEST_IDS } from '../../../../constants/testIds';
10
+ import {
11
+ CALENDAR_VIEWS,
12
+ DAY_UNIT,
13
+ MONTH_LABELS,
14
+ MONTH_UNIT,
15
+ MONTH_YEAR_FORMAT,
16
+ OUTPUT_LABEL_MASK,
17
+ WEEKDAYS_LABELS
18
+ } from '../../constants';
19
+ import styles from '../../styles';
20
+ import { getWeeks, getYearRange } from '../../utils';
21
+ import Day from '../Day';
22
+ import PickerColumn from '../PickerColumn';
23
+
24
+ import { CALENDAR_LOCALE, PICKER_ITEM_HEIGHT, WEEK_KEY_FORMAT } from './constants';
25
+
26
+ const { UTDatePicker: datePickerTestIds } = TEST_IDS;
27
+
28
+ const Calendar = ({ dataTestId, maxDate, minDate, onDaySelect, pickedDate }) => {
29
+ const monthScrollRef = useRef(null);
30
+ const yearScrollRef = useRef(null);
31
+ const [currentMonth, setCurrentMonth] = useState(pickedDate?.isValid() ? pickedDate : dayjs());
32
+ const [viewMode, setViewMode] = useState(CALENDAR_VIEWS.calendar);
33
+
34
+ useEffect(() => {
35
+ if (pickedDate?.isValid()) setCurrentMonth(pickedDate);
36
+ }, [pickedDate]);
37
+
38
+ const weeks = getWeeks(currentMonth);
39
+ const years = useMemo(() => getYearRange(minDate, maxDate), [minDate, maxDate]);
40
+ const selectedYear = pickedDate?.isValid() ? pickedDate.year() : null;
41
+ const selectedMonth = pickedDate?.isValid() ? pickedDate.month() : null;
42
+
43
+ const visibleMonths = useMemo(() => {
44
+ const viewedYear = currentMonth.year();
45
+ const parsedMin = minDate ? dayjs(minDate, OUTPUT_LABEL_MASK) : null;
46
+ const parsedMax = maxDate ? dayjs(maxDate, OUTPUT_LABEL_MASK) : null;
47
+ const minMonthBound = parsedMin?.year() === viewedYear ? parsedMin.month() : 0;
48
+ const maxMonthBound = parsedMax?.year() === viewedYear ? parsedMax.month() : 11;
49
+ return MONTH_LABELS.map((label, index) => ({ index, label })).filter(
50
+ ({ index }) => index >= minMonthBound && index <= maxMonthBound
51
+ );
52
+ }, [currentMonth, minDate, maxDate]);
53
+
54
+ const handlePrevMonth = () => setCurrentMonth(prev => prev.subtract(1, MONTH_UNIT));
55
+ const handleNextMonth = () => setCurrentMonth(prev => prev.add(1, MONTH_UNIT));
56
+
57
+ const handleMonthSelect = month => {
58
+ setCurrentMonth(prev => prev.month(month));
59
+ };
60
+
61
+ const handleYearSelect = year => {
62
+ setCurrentMonth(prev => prev.year(year));
63
+ };
64
+
65
+ const isInYearView = viewMode === CALENDAR_VIEWS.year;
66
+ const rawMonthLabel = currentMonth.locale(CALENDAR_LOCALE).format(MONTH_YEAR_FORMAT);
67
+ const monthYearLabel = rawMonthLabel.charAt(0).toUpperCase() + rawMonthLabel.slice(1);
68
+
69
+ const toggleViewMode = () =>
70
+ setViewMode(prev => (prev === CALENDAR_VIEWS.calendar ? CALENDAR_VIEWS.year : CALENDAR_VIEWS.calendar));
71
+
72
+ useEffect(() => {
73
+ if (!isInYearView) return;
74
+ const targetMonth = selectedMonth ?? currentMonth.month();
75
+ const monthScrollPos = visibleMonths.findIndex(m => m.index === targetMonth);
76
+ const yearIdx = years.findIndex(y => y === (selectedYear ?? currentMonth.year()));
77
+ const id = setTimeout(() => {
78
+ if (monthScrollPos >= 0) {
79
+ monthScrollRef.current?.scrollTo({ animated: false, y: monthScrollPos * PICKER_ITEM_HEIGHT });
80
+ }
81
+ if (yearIdx >= 0) yearScrollRef.current?.scrollTo({ animated: false, y: yearIdx * PICKER_ITEM_HEIGHT });
82
+ }, 50);
83
+ return () => clearTimeout(id);
84
+ }, [isInYearView, visibleMonths, years, selectedMonth, selectedYear, currentMonth]);
85
+
86
+ const isDisabled = date => {
87
+ if (minDate && date.isBefore(dayjs(minDate, OUTPUT_LABEL_MASK), DAY_UNIT)) return true;
88
+ if (maxDate && date.isAfter(dayjs(maxDate, OUTPUT_LABEL_MASK), DAY_UNIT)) return true;
89
+ return false;
90
+ };
91
+
92
+ const monthItems = visibleMonths.map(({ index, label }) => ({
93
+ key: label,
94
+ label,
95
+ onPress: () => handleMonthSelect(index),
96
+ selected: index === currentMonth.month(),
97
+ testID: `${datePickerTestIds.monthItem}.${label}`
98
+ }));
99
+
100
+ const yearItems = years.map(year => ({
101
+ key: year,
102
+ label: String(year),
103
+ onPress: () => handleYearSelect(year),
104
+ selected: year === currentMonth.year(),
105
+ testID: `${datePickerTestIds.yearItem}.${year}`
106
+ }));
107
+
108
+ return (
109
+ <View style={styles.calendarRoot} testID={dataTestId || datePickerTestIds.calendar}>
110
+ <View style={styles.nav}>
111
+ {isInYearView ? (
112
+ <View style={styles.navSpacer} />
113
+ ) : (
114
+ <UTButton
115
+ colorTheme="secondary"
116
+ dataTestId={datePickerTestIds.calendarPrevBtn}
117
+ Icon="IconChevronLeft"
118
+ onPress={handlePrevMonth}
119
+ size="small"
120
+ variant="text"
121
+ />
122
+ )}
123
+
124
+ <UTButton
125
+ colorTheme="secondary"
126
+ dataTestId={datePickerTestIds.calendarMonthYear}
127
+ Icon={isInYearView ? 'IconChevronUp' : 'IconChevronDown'}
128
+ iconPlacement="right"
129
+ onPress={toggleViewMode}
130
+ size="small"
131
+ style={{ root: styles.navLabel }}
132
+ variant="text"
133
+ >
134
+ {monthYearLabel}
135
+ </UTButton>
136
+
137
+ {isInYearView ? (
138
+ <View style={styles.navSpacer} />
139
+ ) : (
140
+ <UTButton
141
+ colorTheme="secondary"
142
+ dataTestId={datePickerTestIds.calendarNextBtn}
143
+ Icon="IconChevronRight"
144
+ onPress={handleNextMonth}
145
+ size="small"
146
+ variant="text"
147
+ />
148
+ )}
149
+ </View>
150
+
151
+ {isInYearView ? (
152
+ <View style={styles.pickerContainer}>
153
+ <PickerColumn items={monthItems} scrollRef={monthScrollRef} testID={datePickerTestIds.monthView} />
154
+ <PickerColumn items={yearItems} scrollRef={yearScrollRef} testID={datePickerTestIds.yearView} />
155
+ </View>
156
+ ) : (
157
+ <View style={styles.month}>
158
+ <View style={styles.week}>
159
+ {WEEKDAYS_LABELS.map(label => (
160
+ <UTLabel colorTheme="gray" key={label} style={styles.dayCell} variant="small" weight="medium">
161
+ {label}
162
+ </UTLabel>
163
+ ))}
164
+ </View>
165
+
166
+ {weeks.map(week => (
167
+ <View key={week[0].format(WEEK_KEY_FORMAT)} style={styles.week}>
168
+ {week.map(date => {
169
+ const dayInCurrentMonth = date.month() === currentMonth.month();
170
+ return (
171
+ <Day
172
+ date={date}
173
+ dayInCurrentMonth={dayInCurrentMonth}
174
+ isDisabled={isDisabled(date)}
175
+ key={date.format(WEEK_KEY_FORMAT)}
176
+ onClick={onDaySelect}
177
+ pickedDate={pickedDate}
178
+ />
179
+ );
180
+ })}
181
+ </View>
182
+ ))}
183
+ </View>
184
+ )}
185
+ </View>
186
+ );
187
+ };
188
+
189
+ Calendar.propTypes = {
190
+ dataTestId: string,
191
+ maxDate: string,
192
+ minDate: string,
193
+ onDaySelect: func,
194
+ pickedDate: object
195
+ };
196
+
197
+ export default Calendar;
@@ -0,0 +1,44 @@
1
+ import React, { memo } from 'react';
2
+ import dayjs from 'dayjs';
3
+ import { View } from 'react-native';
4
+ import { bool, func, object } from 'prop-types';
5
+
6
+ import { useTheme } from '../../../../theming';
7
+ import UTButton from '../../../UTButton';
8
+ import { DAY_NUMBER_FORMAT } from '../../constants';
9
+ import { getDayCellButtonStyle } from '../../theme';
10
+ import { isSelected } from '../../utils';
11
+
12
+ import styles from './styles';
13
+
14
+ const Day = ({ date, dayInCurrentMonth, isDisabled, onClick, pickedDate }) => {
15
+ const theme = useTheme();
16
+ const selected = isSelected(date, pickedDate);
17
+
18
+ if (!dayInCurrentMonth) return <View style={styles.otherMonthCell} />;
19
+
20
+ const buttonStyle = getDayCellButtonStyle(selected, theme);
21
+
22
+ return (
23
+ <UTButton
24
+ colorTheme={selected ? 'primary' : 'secondary'}
25
+ disabled={isDisabled}
26
+ onPress={() => onClick(date)}
27
+ size="small"
28
+ style={buttonStyle}
29
+ variant={selected ? 'filled' : 'text'}
30
+ >
31
+ {dayjs(date).format(DAY_NUMBER_FORMAT)}
32
+ </UTButton>
33
+ );
34
+ };
35
+
36
+ Day.propTypes = {
37
+ date: object,
38
+ dayInCurrentMonth: bool,
39
+ isDisabled: bool,
40
+ onClick: func,
41
+ pickedDate: object
42
+ };
43
+
44
+ export default memo(Day);
@@ -0,0 +1,8 @@
1
+ import { StyleSheet } from 'react-native';
2
+
3
+ export default StyleSheet.create({
4
+ otherMonthCell: {
5
+ flex: 1,
6
+ height: 36
7
+ }
8
+ });
@@ -0,0 +1,57 @@
1
+ import React from 'react';
2
+ import { ScrollView, View } from 'react-native';
3
+ import { arrayOf, bool, func, number, object, oneOfType, shape, string } from 'prop-types';
4
+
5
+ import { useTheme } from '../../../../theming';
6
+ import UTButton from '../../../UTButton';
7
+ import { getYearItemStyle } from '../../theme';
8
+ import styles from '../../styles';
9
+
10
+ const PickerColumn = ({ items, scrollRef, testID }) => {
11
+ const theme = useTheme();
12
+
13
+ return (
14
+ <View style={styles.pickerColumn}>
15
+ <ScrollView
16
+ contentContainerStyle={styles.pickerScrollContent}
17
+ ref={scrollRef}
18
+ showsVerticalScrollIndicator={false}
19
+ style={styles.pickerScroll}
20
+ testID={testID}
21
+ >
22
+ {items.map(({ key, label, onPress, selected, testID: itemTestID }) => {
23
+ const itemTheme = getYearItemStyle(selected, theme);
24
+ return (
25
+ <UTButton
26
+ colorTheme={selected ? 'primary' : 'secondary'}
27
+ dataTestId={itemTestID}
28
+ key={key}
29
+ onPress={onPress}
30
+ size="small"
31
+ style={{ root: { ...styles.yearItem, ...itemTheme } }}
32
+ variant={selected ? 'filled' : 'text'}
33
+ >
34
+ {label}
35
+ </UTButton>
36
+ );
37
+ })}
38
+ </ScrollView>
39
+ </View>
40
+ );
41
+ };
42
+
43
+ PickerColumn.propTypes = {
44
+ items: arrayOf(
45
+ shape({
46
+ key: oneOfType([number, string]),
47
+ label: string,
48
+ onPress: func,
49
+ selected: bool,
50
+ testID: string
51
+ })
52
+ ),
53
+ scrollRef: object,
54
+ testID: string
55
+ };
56
+
57
+ export default PickerColumn;
@@ -0,0 +1,48 @@
1
+ export const OUTPUT_LABEL_MASK = 'DD/MM/YYYY';
2
+
3
+ export const WEEKDAYS_LABELS = ['Dom', 'Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab'];
4
+
5
+ export const MONTH_YEAR_FORMAT = 'MMMM YYYY';
6
+
7
+ export const DAY_NUMBER_FORMAT = 'DD';
8
+
9
+ export const DEFAULT_PLACEHOLDER = 'DD/MM/AAAA';
10
+
11
+ export const TYPING_ERROR_MESSAGE = 'Fecha inválida';
12
+
13
+ export const VARIANTS = {
14
+ picker: 'picker',
15
+ select: 'select'
16
+ };
17
+
18
+ export const SIZES = {
19
+ md: 'md',
20
+ sm: 'sm'
21
+ };
22
+
23
+ export const DAY_UNIT = 'day';
24
+ export const MONTH_UNIT = 'month';
25
+
26
+ export const CALENDAR_VIEWS = {
27
+ calendar: 'calendar',
28
+ year: 'year'
29
+ };
30
+
31
+ export const MONTH_LABELS = [
32
+ 'Ene',
33
+ 'Feb',
34
+ 'Mar',
35
+ 'Abr',
36
+ 'May',
37
+ 'Jun',
38
+ 'Jul',
39
+ 'Ago',
40
+ 'Sep',
41
+ 'Oct',
42
+ 'Nov',
43
+ 'Dic'
44
+ ];
45
+
46
+ export const YEAR_RANGE_BEFORE = 50;
47
+ export const YEAR_RANGE_AFTER = 10;
48
+ export const YEARS_PER_ROW = 3;
@@ -0,0 +1,135 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Keyboard } from 'react-native';
3
+ import dayjs from 'dayjs';
4
+ import customParseFormat from 'dayjs/plugin/customParseFormat';
5
+
6
+ import { COMPONENT_KEYS } from '../UTBaseInputField/constants';
7
+
8
+ import { DEFAULT_PLACEHOLDER, OUTPUT_LABEL_MASK, VARIANTS } from './constants';
9
+ import UTDatePickerLayout from './layout';
10
+ import { propTypes } from './proptypes';
11
+ import { dateMatchesFormat, getFinalDate } from './utils';
12
+
13
+ dayjs.extend(customParseFormat);
14
+
15
+ const UTDatePicker = ({
16
+ modalProps,
17
+ clearable = true,
18
+ CustomIcon,
19
+ disabled,
20
+ error = '',
21
+ helpText,
22
+ onChange = () => {},
23
+ placeholder,
24
+ range,
25
+ readOnly = false,
26
+ required,
27
+ style,
28
+ title,
29
+ value = '',
30
+ variant = VARIANTS.select
31
+ }) => {
32
+ const { maxDate, minDate } = range || {};
33
+
34
+ const [pickedDate, setPickedDate] = useState(null);
35
+ const [isOpen, setIsOpen] = useState(false);
36
+
37
+ useEffect(() => {
38
+ if (!value && pickedDate?.isValid()) setPickedDate(null);
39
+ else if (dateMatchesFormat(value, OUTPUT_LABEL_MASK)) setPickedDate(dayjs(value, OUTPUT_LABEL_MASK));
40
+ else setPickedDate(null);
41
+ }, [value]);
42
+
43
+ const openCalendar = () => {
44
+ if (disabled || readOnly) return;
45
+ Keyboard.dismiss();
46
+ setIsOpen(true);
47
+ };
48
+
49
+ const handleClose = () => {
50
+ setIsOpen(false);
51
+ };
52
+
53
+ const handleDaySelect = date => {
54
+ const finalDate = getFinalDate({ date, maxDate, minDate });
55
+ setPickedDate(finalDate);
56
+ if (finalDate.isValid()) onChange(finalDate.format(OUTPUT_LABEL_MASK));
57
+ else onChange('');
58
+ setIsOpen(false);
59
+ };
60
+
61
+ const handleClear = () => {
62
+ setPickedDate(null);
63
+ onChange('');
64
+ };
65
+
66
+ const displayValue = pickedDate?.isValid() ? pickedDate.format(OUTPUT_LABEL_MASK) : '';
67
+
68
+ const hasError = !!error;
69
+ const hasValue = !!pickedDate?.isValid();
70
+ const displayPlaceholder = placeholder || DEFAULT_PLACEHOLDER;
71
+ const errorMessage = error;
72
+ const isTransparent = variant === VARIANTS.picker;
73
+
74
+ const showClearButton = clearable && hasValue && !disabled && !readOnly;
75
+ const showCalendarIcon = !readOnly && !disabled;
76
+
77
+ const calendarAction = {
78
+ colorTheme: hasError ? 'error' : isOpen ? 'primary' : 'gray',
79
+ Icon: CustomIcon || 'IconCalendarEvent',
80
+ onPress: openCalendar,
81
+ size: 'small',
82
+ variant: 'text'
83
+ };
84
+
85
+ const clearAction = {
86
+ colorTheme: 'secondary',
87
+ Icon: 'IconX',
88
+ onPress: handleClear,
89
+ size: 'small',
90
+ variant: 'text'
91
+ };
92
+
93
+ const rightAdornments = showCalendarIcon
94
+ ? [
95
+ {
96
+ name: COMPONENT_KEYS.ACTION,
97
+ props: { action: showClearButton ? clearAction : calendarAction }
98
+ }
99
+ ]
100
+ : [];
101
+
102
+ const inputStyle = isTransparent
103
+ ? { container: { backgroundColor: 'transparent', borderColor: 'transparent' } }
104
+ : { container: { alignItems: 'stretch', paddingHorizontal: 12 } };
105
+
106
+ return (
107
+ <UTDatePickerLayout
108
+ disabled={disabled}
109
+ displayPlaceholder={displayPlaceholder}
110
+ displayValue={displayValue}
111
+ errorMessage={errorMessage}
112
+ handleClose={handleClose}
113
+ handleDaySelect={handleDaySelect}
114
+ hasError={hasError}
115
+ helpText={helpText}
116
+ inputStyle={inputStyle}
117
+ isOpen={isOpen}
118
+ maxDate={maxDate}
119
+ minDate={minDate}
120
+ modalProps={modalProps}
121
+ onPress={openCalendar}
122
+ pickedDate={pickedDate}
123
+ readOnly={readOnly}
124
+ required={required}
125
+ rightAdornments={rightAdornments}
126
+ style={style}
127
+ title={title}
128
+ variant={variant}
129
+ />
130
+ );
131
+ };
132
+
133
+ UTDatePicker.propTypes = propTypes;
134
+
135
+ export default UTDatePicker;
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { array, bool, func, object, string } from 'prop-types';
4
+
5
+ import UTBaseInputField from '../UTBaseInputField';
6
+ import UTBottomSheet from '../UTBottomSheet';
7
+ import UTFieldLabel from '../UTFieldLabel';
8
+ import UTLabel from '../UTLabel';
9
+ import UTValidation from '../UTValidation';
10
+ import { TEST_IDS } from '../../constants/testIds';
11
+
12
+ import Calendar from './components/Calendar';
13
+ import { VARIANTS } from './constants';
14
+ import styles from './styles';
15
+
16
+ const { UTDatePicker: datePickerTestIds } = TEST_IDS;
17
+
18
+ const UTDatePickerLayout = ({
19
+ disabled,
20
+ displayPlaceholder,
21
+ displayValue,
22
+ errorMessage,
23
+ handleClose,
24
+ handleDaySelect,
25
+ hasError,
26
+ helpText,
27
+ inputStyle,
28
+ isOpen,
29
+ maxDate,
30
+ minDate,
31
+ modalProps,
32
+ onPress,
33
+ pickedDate,
34
+ readOnly,
35
+ required,
36
+ rightAdornments,
37
+ style,
38
+ title,
39
+ variant
40
+ }) => (
41
+ <View style={[styles.root, style?.root]} testID={datePickerTestIds.root}>
42
+ {!!title && <UTFieldLabel required={required}>{title}</UTFieldLabel>}
43
+
44
+ <Pressable onPress={onPress} style={styles.pressable}>
45
+ <UTBaseInputField
46
+ alwaysShowPlaceholder
47
+ dataTestId={datePickerTestIds.input}
48
+ disabled={disabled}
49
+ editable={false}
50
+ error={hasError}
51
+ placeholder={displayPlaceholder}
52
+ readOnly={readOnly}
53
+ rightAdornments={rightAdornments}
54
+ style={inputStyle}
55
+ value={displayValue}
56
+ />
57
+ </Pressable>
58
+
59
+ {!!helpText && (
60
+ <UTLabel colorTheme="gray" variant="small">
61
+ {helpText}
62
+ </UTLabel>
63
+ )}
64
+
65
+ {variant === VARIANTS.select && !!errorMessage && (
66
+ <UTValidation
67
+ dataTestId={datePickerTestIds.errorMessage}
68
+ validationData={[{ items: [{ status: 'error', text: errorMessage }] }]}
69
+ />
70
+ )}
71
+
72
+ <UTBottomSheet
73
+ adjustableHeight
74
+ onClose={handleClose}
75
+ visible={isOpen}
76
+ withBodyPadding={false}
77
+ {...modalProps}
78
+ >
79
+ <Calendar maxDate={maxDate} minDate={minDate} onDaySelect={handleDaySelect} pickedDate={pickedDate} />
80
+ </UTBottomSheet>
81
+ </View>
82
+ );
83
+
84
+ UTDatePickerLayout.propTypes = {
85
+ disabled: bool,
86
+ displayPlaceholder: string,
87
+ displayValue: string,
88
+ errorMessage: string,
89
+ handleClose: func,
90
+ handleDaySelect: func,
91
+ hasError: bool,
92
+ helpText: string,
93
+ inputStyle: object,
94
+ isOpen: bool,
95
+ maxDate: string,
96
+ minDate: string,
97
+ modalProps: object,
98
+ onPress: func,
99
+ pickedDate: object,
100
+ readOnly: bool,
101
+ required: bool,
102
+ rightAdornments: array,
103
+ style: object,
104
+ title: string,
105
+ variant: string
106
+ };
107
+
108
+ export default UTDatePickerLayout;
@@ -0,0 +1,20 @@
1
+ import { bool, elementType, func, object, oneOf, shape, string } from 'prop-types';
2
+
3
+ export const propTypes = {
4
+ clearable: bool,
5
+ CustomIcon: elementType,
6
+ disabled: bool,
7
+ error: string,
8
+ helpText: string,
9
+ modalProps: object,
10
+ onChange: func,
11
+ placeholder: string,
12
+ range: shape({ maxDate: string, minDate: string }),
13
+ readOnly: bool,
14
+ required: bool,
15
+ size: oneOf(['sm', 'md']),
16
+ style: shape({ root: object }),
17
+ title: string,
18
+ value: string,
19
+ variant: oneOf(['select', 'picker'])
20
+ };
@@ -0,0 +1,63 @@
1
+ import { StyleSheet } from 'react-native';
2
+
3
+ const styles = StyleSheet.create({
4
+ calendarRoot: {
5
+ paddingHorizontal: 24,
6
+ paddingTop: 16
7
+ },
8
+ dayCell: {
9
+ flex: 1,
10
+ paddingVertical: 2,
11
+ textAlign: 'center'
12
+ },
13
+ month: {
14
+ gap: 2,
15
+ minHeight: 260
16
+ },
17
+ nav: {
18
+ alignItems: 'center',
19
+ flexDirection: 'row',
20
+ justifyContent: 'space-between',
21
+ marginBottom: 24
22
+ },
23
+ navLabel: {
24
+ flex: 1,
25
+ textAlign: 'center'
26
+ },
27
+ navSpacer: {
28
+ width: 36
29
+ },
30
+ pickerColumn: {
31
+ flex: 1,
32
+ overflow: 'hidden'
33
+ },
34
+ pickerContainer: {
35
+ flexDirection: 'row',
36
+ gap: 8,
37
+ height: 260
38
+ },
39
+ pickerScroll: {
40
+ height: 260
41
+ },
42
+ pickerScrollContent: {
43
+ paddingBottom: 220
44
+ },
45
+ pressable: {
46
+ width: '100%'
47
+ },
48
+ root: {
49
+ gap: 8
50
+ },
51
+ week: {
52
+ flexDirection: 'row'
53
+ },
54
+ yearItem: {
55
+ alignItems: 'center',
56
+ borderRadius: 8,
57
+ height: 40,
58
+ justifyContent: 'center',
59
+ width: '100%'
60
+ }
61
+ });
62
+
63
+ export default styles;
@@ -0,0 +1,18 @@
1
+ export const getDayCellButtonStyle = (selected, theme) => ({
2
+ root: {
3
+ alignItems: 'center',
4
+ flex: 1,
5
+ height: 36,
6
+ justifyContent: 'center',
7
+ paddingHorizontal: 0,
8
+ paddingVertical: 0,
9
+ ...(selected && {
10
+ backgroundColor: theme.Palette.accent['04'],
11
+ borderRadius: 18
12
+ })
13
+ }
14
+ });
15
+
16
+ export const getYearItemStyle = (selected, theme) => ({
17
+ backgroundColor: selected ? theme.Palette.accent['04'] : undefined
18
+ });
@@ -0,0 +1,52 @@
1
+ import dayjs from 'dayjs';
2
+ import customParseFormat from 'dayjs/plugin/customParseFormat';
3
+
4
+ import { DAY_UNIT, MONTH_UNIT, OUTPUT_LABEL_MASK, YEAR_RANGE_AFTER, YEAR_RANGE_BEFORE } from './constants';
5
+
6
+ dayjs.extend(customParseFormat);
7
+
8
+ export const isSelected = (date, selectedDate) => selectedDate && date.isSame(selectedDate, DAY_UNIT);
9
+
10
+ export const dateMatchesFormat = (date, targetFormat) => dayjs(date, targetFormat, true).isValid();
11
+
12
+ export const getFinalDate = ({ date, maxDate, minDate }) => {
13
+ const formattedDate = dayjs(date);
14
+ if (maxDate && formattedDate?.isAfter(dayjs(maxDate, OUTPUT_LABEL_MASK)))
15
+ return dayjs(maxDate, OUTPUT_LABEL_MASK);
16
+ if (minDate && formattedDate?.isBefore(dayjs(minDate, OUTPUT_LABEL_MASK)))
17
+ return dayjs(minDate, OUTPUT_LABEL_MASK);
18
+ return formattedDate;
19
+ };
20
+
21
+ export const getWeeks = month => {
22
+ const firstDay = month.startOf(MONTH_UNIT);
23
+ const lastDay = month.endOf(MONTH_UNIT);
24
+ const startDate = firstDay.subtract(firstDay.day(), DAY_UNIT);
25
+
26
+ const weeks = [];
27
+ let current = startDate;
28
+
29
+ do {
30
+ const weekStart = current;
31
+ weeks.push(Array.from({ length: 7 }, (_, i) => weekStart.add(i, DAY_UNIT)));
32
+ current = current.add(7, DAY_UNIT);
33
+ } while (current.isBefore(lastDay) || current.isSame(lastDay, DAY_UNIT));
34
+
35
+ return weeks;
36
+ };
37
+
38
+ export const getYearRange = (minDateStr, maxDateStr) => {
39
+ const currentYear = dayjs().year();
40
+ const minYear = minDateStr ? dayjs(minDateStr, OUTPUT_LABEL_MASK).year() : currentYear - YEAR_RANGE_BEFORE;
41
+ const maxYear = maxDateStr ? dayjs(maxDateStr, OUTPUT_LABEL_MASK).year() : currentYear + YEAR_RANGE_AFTER;
42
+ const years = [];
43
+ for (let y = maxYear; y >= minYear; y--) years.push(y);
44
+ return years;
45
+ };
46
+
47
+ export const autoFormatDate = val => {
48
+ const digits = val.replace(/\D/g, '').slice(0, 8);
49
+ if (digits.length <= 2) return digits;
50
+ if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`;
51
+ return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
52
+ };
@@ -44,6 +44,19 @@ export const TEST_ID_CONSTANTS = {
44
44
 
45
45
  export const TEST_IDS = {
46
46
  modal: 'modal',
47
+ UTDatePicker: {
48
+ calendar: 'UTDatePicker.calendar',
49
+ calendarMonthYear: 'UTDatePicker.calendar.monthYear',
50
+ calendarNextBtn: 'UTDatePicker.calendar.nextBtn',
51
+ calendarPrevBtn: 'UTDatePicker.calendar.prevBtn',
52
+ errorMessage: 'UTDatePicker.errorMessage',
53
+ input: 'UTDatePicker.input',
54
+ monthItem: 'UTDatePicker.monthItem',
55
+ monthView: 'UTDatePicker.monthView',
56
+ root: 'UTDatePicker.root',
57
+ yearItem: 'UTDatePicker.yearItem',
58
+ yearView: 'UTDatePicker.yearView'
59
+ },
47
60
  roundView: 'roundView',
48
61
  skeletonLoader: 'skeletonLoader',
49
62
  topbar: {
package/lib/index.js CHANGED
@@ -49,6 +49,7 @@ export { default as UTCuit } from './components/UTCuit';
49
49
  export { default as UTCheckList } from './components/UTCheckList';
50
50
  export { default as UTDataCategory } from './components/UTDataCategory';
51
51
  export { default as UTDataElement } from './components/UTDataElement';
52
+ export { default as UTDatePicker } from './components/UTDatePicker';
52
53
  export { default as UTDetailDrawer } from './components/UTDetailDrawer';
53
54
  export { default as UTIcon } from './components/UTIcon';
54
55
  export { default as UTImage } from './components/UTImage';
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.17.0",
5
+ "version": "2.17.1",
6
6
  "repository": "https://github.com/widergy/mobile-ui.git",
7
7
  "main": "lib/index.js",
8
8
  "files": [
@@ -43,6 +43,7 @@
43
43
  "@tabler/icons-react-native": "^3.34.1",
44
44
  "@widergy/web-utils": "^2.0.0",
45
45
  "core-js": "3",
46
+ "dayjs": "^1.11.10",
46
47
  "deprecated-react-native-prop-types": "^5.0.0",
47
48
  "expo-document-picker": "^13.1.6",
48
49
  "expo-image-manipulator": "^13.1.7",
@@ -73,12 +74,12 @@
73
74
  "@commitlint/config-conventional": "^17.7.0",
74
75
  "@eslint/compat": "^1.4.0",
75
76
  "@eslint/eslintrc": "^3.3.0",
76
- "@widergy/semantic-release-package-config": "^1.0.0",
77
- "@widergy/eslint-config": "^1.0.0",
78
77
  "@react-native/babel-preset": "0.73.0",
78
+ "@widergy/eslint-config": "^1.0.0",
79
+ "@widergy/semantic-release-package-config": "^1.0.0",
80
+ "babel-jest": "^29.6.2",
79
81
  "babel-plugin-import-glob": "^2.0.0",
80
82
  "babel-plugin-module-resolver": "^5.0.0",
81
- "babel-jest": "^29.6.2",
82
83
  "babel-preset-minify": "^0.5.2",
83
84
  "eslint": "^9.38.0",
84
85
  "eslint-config-airbnb": "^19.0.4",