@utilitywarehouse/hearth-react-native 0.36.2 → 0.36.3
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/build/components/DatePicker/DatePicker.context.d.ts +1 -0
- package/build/components/DatePicker/DatePicker.js +27 -0
- package/build/components/DatePicker/DatePickerFooter.js +6 -2
- package/build/components/DatePickerInput/DatePickerInput.d.ts +1 -1
- package/build/components/DatePickerInput/DatePickerInput.js +11 -5
- package/build/components/DatePickerInput/DatePickerInput.props.d.ts +7 -0
- package/build/components/TimePicker/TimePicker.js +9 -1
- package/build/components/TimePicker/TimePickerView.js +5 -19
- package/build/components/TimePicker/TimePickerWheel.web.js +11 -2
- package/build/components/TimePicker/utils.d.ts +9 -0
- package/build/components/TimePicker/utils.js +30 -0
- package/build/components/TimePicker/utils.test.d.ts +1 -0
- package/build/components/TimePicker/utils.test.js +31 -0
- package/build/components/TimePickerInput/TimePickerInput.d.ts +1 -1
- package/build/components/TimePickerInput/TimePickerInput.js +11 -5
- package/build/components/TimePickerInput/TimePickerInput.props.d.ts +7 -0
- package/package.json +2 -2
- package/public/llms/components/date-picker.md +7 -1
- package/public/llms/components/time-picker.md +1 -0
- package/public/llms/docs/introduction.md +1 -1
|
@@ -13,6 +13,7 @@ export interface DatePickerContextType extends DatePickerBaseProps {
|
|
|
13
13
|
onSelectYear: (year: number) => void;
|
|
14
14
|
onChangeMonth: (value: number) => void;
|
|
15
15
|
onChangeYear: (value: number) => void;
|
|
16
|
+
onConfirm: () => void;
|
|
16
17
|
closeDatePicker: () => void;
|
|
17
18
|
}
|
|
18
19
|
export declare const DatePickerContext: import("react").Context<DatePickerContextType>;
|
|
@@ -155,6 +155,9 @@ const DateTimePicker = (props) => {
|
|
|
155
155
|
}, initialState);
|
|
156
156
|
const stateRef = useRef(state);
|
|
157
157
|
stateRef.current = state;
|
|
158
|
+
// Tracks whether a selection was made since the sheet last opened, so Ok doesn't
|
|
159
|
+
// re-emit onChange for a value it already just committed via onSelectDate.
|
|
160
|
+
const hasChangedRef = useRef(false);
|
|
158
161
|
useEffect(() => {
|
|
159
162
|
const newState = {
|
|
160
163
|
...initialState,
|
|
@@ -238,6 +241,7 @@ const DateTimePicker = (props) => {
|
|
|
238
241
|
dispatch({ type: CalendarActionKind.SET_CALENDAR_VIEW, payload: view });
|
|
239
242
|
}, []);
|
|
240
243
|
const onSelectDate = useCallback((selectedDate) => {
|
|
244
|
+
hasChangedRef.current = true;
|
|
241
245
|
if (onChange) {
|
|
242
246
|
if (mode === 'single') {
|
|
243
247
|
const newDate = timePicker
|
|
@@ -336,6 +340,26 @@ const DateTimePicker = (props) => {
|
|
|
336
340
|
}
|
|
337
341
|
}
|
|
338
342
|
}, [mode, timePicker, min, max, timeZone]);
|
|
343
|
+
const onConfirm = useCallback(() => {
|
|
344
|
+
if (!onChange || hasChangedRef.current)
|
|
345
|
+
return;
|
|
346
|
+
const current = stateRef.current;
|
|
347
|
+
if (mode === 'single' && current.date) {
|
|
348
|
+
onChange({ date: dayjs(current.date).toDate() });
|
|
349
|
+
}
|
|
350
|
+
else if (mode === 'range' && current.startDate) {
|
|
351
|
+
onChange({
|
|
352
|
+
startDate: dayjs(current.startDate).toDate(),
|
|
353
|
+
endDate: current.endDate ? dayjs(current.endDate).toDate() : current.endDate,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
else if (mode === 'multiple' && current.dates?.length) {
|
|
357
|
+
onChange({
|
|
358
|
+
dates: current.dates.map(item => dayjs(item).toDate()),
|
|
359
|
+
change: 'updated',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}, [mode, onChange]);
|
|
339
363
|
// set the active displayed month
|
|
340
364
|
const onSelectMonth = useCallback((value) => {
|
|
341
365
|
const currentMonth = dayjs(stateRef.current.currentDate).month();
|
|
@@ -449,6 +473,7 @@ const DateTimePicker = (props) => {
|
|
|
449
473
|
onSelectYear,
|
|
450
474
|
onChangeMonth,
|
|
451
475
|
onChangeYear,
|
|
476
|
+
onConfirm,
|
|
452
477
|
onCancel,
|
|
453
478
|
}), [
|
|
454
479
|
setCalendarView,
|
|
@@ -457,6 +482,7 @@ const DateTimePicker = (props) => {
|
|
|
457
482
|
onSelectYear,
|
|
458
483
|
onChangeMonth,
|
|
459
484
|
onChangeYear,
|
|
485
|
+
onConfirm,
|
|
460
486
|
onCancel,
|
|
461
487
|
]);
|
|
462
488
|
const memoizedValue = useMemo(() => ({
|
|
@@ -466,6 +492,7 @@ const DateTimePicker = (props) => {
|
|
|
466
492
|
}), [state, baseContextValue, handlerContextValue]);
|
|
467
493
|
const handleChange = useCallback((index) => {
|
|
468
494
|
if (index > -1) {
|
|
495
|
+
hasChangedRef.current = false;
|
|
469
496
|
// Add a small delay to ensure the bottom sheet is fully rendered
|
|
470
497
|
setTimeout(() => {
|
|
471
498
|
// Announce to screen readers
|
|
@@ -4,14 +4,18 @@ import { StyleSheet } from 'react-native-unistyles';
|
|
|
4
4
|
import { Button } from '../Button';
|
|
5
5
|
import { useDatePickerContext } from './DatePicker.context';
|
|
6
6
|
const Footer = () => {
|
|
7
|
-
const { closeDatePicker, onCancel } = useDatePickerContext();
|
|
7
|
+
const { closeDatePicker, onConfirm, onCancel } = useDatePickerContext();
|
|
8
8
|
const handleCancel = () => {
|
|
9
9
|
if (onCancel) {
|
|
10
10
|
onCancel();
|
|
11
11
|
}
|
|
12
12
|
closeDatePicker();
|
|
13
13
|
};
|
|
14
|
-
|
|
14
|
+
const handleConfirm = () => {
|
|
15
|
+
onConfirm();
|
|
16
|
+
closeDatePicker();
|
|
17
|
+
};
|
|
18
|
+
return (_jsxs(View, { style: styles.container, testID: "footer", children: [_jsx(Button, { variant: "ghost", colorScheme: "functional", onPress: handleCancel, children: "Cancel" }), _jsx(Button, { variant: "ghost", colorScheme: "functional", onPress: handleConfirm, children: "Ok" })] }));
|
|
15
19
|
};
|
|
16
20
|
const styles = StyleSheet.create(theme => ({
|
|
17
21
|
container: {
|
|
@@ -4,7 +4,7 @@ import type DatePickerInputProps from './DatePickerInput.props';
|
|
|
4
4
|
* attached calendar, formatting and parsing the value with Day.js.
|
|
5
5
|
*/
|
|
6
6
|
declare const DatePickerInput: {
|
|
7
|
-
({ validationStatus, disabled, focused, readonly, placeholder, inBottomSheet, format, openButtonLabel, autoCloseOnSelect, label, labelVariant, helperText, helperIcon, validText, invalidText, required, onChange, onChangeText, onBlur, onFocus, value, datePickerProps, onClear, ...rest }: DatePickerInputProps): import("react").JSX.Element;
|
|
7
|
+
({ validationStatus, disabled, focused, readonly, placeholder, inBottomSheet, format, openButtonLabel, autoCloseOnSelect, disableManualEntry, label, labelVariant, helperText, helperIcon, validText, invalidText, required, onChange, onChangeText, onBlur, onFocus, value, datePickerProps, onClear, ...rest }: DatePickerInputProps): import("react").JSX.Element;
|
|
8
8
|
displayName: string;
|
|
9
9
|
};
|
|
10
10
|
export default DatePickerInput;
|
|
@@ -3,7 +3,7 @@ import { CalendarSmallIcon, CloseSmallIcon } from '@utilitywarehouse/hearth-reac
|
|
|
3
3
|
import dayjs from 'dayjs';
|
|
4
4
|
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
-
import { Keyboard, Platform } from 'react-native';
|
|
6
|
+
import { Keyboard, Platform, Pressable } from 'react-native';
|
|
7
7
|
import { StyleSheet } from 'react-native-unistyles';
|
|
8
8
|
import { DatePicker } from '../DatePicker';
|
|
9
9
|
import { useFormFieldContext } from '../FormField';
|
|
@@ -16,7 +16,7 @@ dayjs.extend(customParseFormat);
|
|
|
16
16
|
* Use DatePickerInput for a text field that accepts a typed date or one picked from an
|
|
17
17
|
* attached calendar, formatting and parsing the value with Day.js.
|
|
18
18
|
*/
|
|
19
|
-
const DatePickerInput = ({ validationStatus = 'initial', disabled, focused, readonly, placeholder, inBottomSheet = false, format = DEFAULT_FORMAT, openButtonLabel = 'Open date picker', autoCloseOnSelect = true, label, labelVariant, helperText, helperIcon, validText, invalidText, required = true, onChange, onChangeText, onBlur, onFocus, value, datePickerProps, onClear, ...rest }) => {
|
|
19
|
+
const DatePickerInput = ({ validationStatus = 'initial', disabled, focused, readonly, placeholder, inBottomSheet = false, format = DEFAULT_FORMAT, openButtonLabel = 'Open date picker', autoCloseOnSelect = true, disableManualEntry = false, label, labelVariant, helperText, helperIcon, validText, invalidText, required = true, onChange, onChangeText, onBlur, onFocus, value, datePickerProps, onClear, ...rest }) => {
|
|
20
20
|
const formFieldContext = useFormFieldContext();
|
|
21
21
|
const isDisabled = formFieldContext?.disabled ?? disabled;
|
|
22
22
|
const isReadonly = formFieldContext?.readonly ?? readonly;
|
|
@@ -105,18 +105,24 @@ const DatePickerInput = ({ validationStatus = 'initial', disabled, focused, read
|
|
|
105
105
|
pickerRef.current?.close?.();
|
|
106
106
|
}, [pickerOnCancel]);
|
|
107
107
|
const placeholderValue = placeholder ?? format;
|
|
108
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Input, { validationStatus: validationStatus, disabled: disabled, readonly: readonly, focused: focused, label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, style: styles.wrap, accessible: false, children: [_jsx(
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Input, { validationStatus: validationStatus, disabled: disabled, readonly: readonly, focused: focused, label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, style: styles.wrap, accessible: false, children: [_jsx(Pressable, { style: styles.fieldPressable, disabled: !disableManualEntry || isDisabled || isReadonly, onPress: openPicker, children: _jsx(InputField, { value: inputValue, placeholder: placeholderValue, onChangeText: handleTextChange, onBlur: handleBlur, onFocus: handleFocus, inBottomSheet: inBottomSheet, keyboardType: resolvedKeyboardType, inputMode: resolvedInputMode, accessibilityHint: resolvedAccessibilityHint, "aria-label": "Date input", accessibilityLabel: resolvedAccessibilityLabel, accessible: resolvedAccessible, accessibilityState: {
|
|
109
|
+
disabled: isDisabled || isReadonly,
|
|
110
|
+
}, importantForAccessibility: resolvedImportantForAccessibility, inputAccessoryViewID: Platform.OS === 'ios' ? accessoryViewID : undefined, ...textInputProps, editable: !isReadonly && !isDisabled && !disableManualEntry, style: [styles.input, inputStyle, disableManualEntry && styles.noPointerEvents] }) }), !!inputValue && onClear && !isReadonly && !isDisabled && (_jsx(InputSlot, { accessibilityElementsHidden: false, children: _jsx(UnstyledIconButton, { accessibilityLabel: "Clear date", accessibilityHint: "Removes the current date", icon: CloseSmallIcon, onPress: handleClear }) })), _jsx(InputSlot, { accessibilityElementsHidden: false, children: _jsx(UnstyledIconButton, { accessibilityLabel: openButtonLabel, accessibilityHint: "Opens the date picker calendar", icon: CalendarSmallIcon, onPress: openPicker, disabled: isDisabled || isReadonly }) })] }), _jsx(DatePicker, { ref: pickerRef, mode: "single", date: selectedDate, onChange: handlePickerChange, onCancel: handleCancel, ...restDatePickerProps }), Platform.OS === 'ios' && accessoryViewID && (_jsx(DatePickerInputDoneButton, { accessoryViewID: accessoryViewID, closeKeyboard: closeKeyboard }))] }));
|
|
111
111
|
};
|
|
112
112
|
DatePickerInput.displayName = 'DatePickerInput';
|
|
113
113
|
const styles = StyleSheet.create(theme => ({
|
|
114
114
|
wrap: {
|
|
115
115
|
gap: theme.components.input.date.gap,
|
|
116
116
|
},
|
|
117
|
+
fieldPressable: {
|
|
118
|
+
flex: 1,
|
|
119
|
+
},
|
|
117
120
|
input: {
|
|
118
121
|
paddingLeft: 0,
|
|
119
122
|
paddingRight: 0,
|
|
120
123
|
},
|
|
124
|
+
noPointerEvents: {
|
|
125
|
+
pointerEvents: 'none',
|
|
126
|
+
},
|
|
121
127
|
}));
|
|
122
128
|
export default DatePickerInput;
|
|
@@ -50,6 +50,13 @@ export interface DatePickerInputBaseProps {
|
|
|
50
50
|
* When true (default), the calendar sheet is dismissed as soon as a date is picked.
|
|
51
51
|
*/
|
|
52
52
|
autoCloseOnSelect?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Blocks manual keyboard entry and makes tapping anywhere on the field open the
|
|
55
|
+
* calendar picker, instead of only the trailing calendar button. Independent of
|
|
56
|
+
* `disabled`/`readonly`, which still disable the whole field regardless of this prop.
|
|
57
|
+
* @default false
|
|
58
|
+
*/
|
|
59
|
+
disableManualEntry?: boolean;
|
|
53
60
|
/**
|
|
54
61
|
* Additional props forwarded to the underlying DatePicker instance.
|
|
55
62
|
*/
|
|
@@ -27,6 +27,9 @@ const TimePicker = ({ timeZone, date, onChange, use12Hours, minuteInterval, hide
|
|
|
27
27
|
const [currentDate, setCurrentDate] = useState(() => {
|
|
28
28
|
return date ? dayjs.tz(date, timeZone) : dayjs().tz(timeZone);
|
|
29
29
|
});
|
|
30
|
+
// Tracks whether a wheel change was made since the sheet last opened, so Ok
|
|
31
|
+
// doesn't re-emit onChange for a value it already just committed via the wheel.
|
|
32
|
+
const hasChangedRef = useRef(false);
|
|
30
33
|
useEffect(() => {
|
|
31
34
|
const nextDate = date ? dayjs.tz(date, timeZone) : dayjs().tz(timeZone);
|
|
32
35
|
const isSameMinute = dayjs(currentDate).isSame(nextDate, 'minute');
|
|
@@ -39,6 +42,7 @@ const TimePicker = ({ timeZone, date, onChange, use12Hours, minuteInterval, hide
|
|
|
39
42
|
}, []);
|
|
40
43
|
const handleSelectDate = useCallback((selectedDate) => {
|
|
41
44
|
const newDate = dayjs.tz(selectedDate ?? currentDate, timeZone);
|
|
45
|
+
hasChangedRef.current = true;
|
|
42
46
|
if (!dayjs(currentDate).isSame(newDate, 'minute')) {
|
|
43
47
|
setCurrentDate(newDate);
|
|
44
48
|
}
|
|
@@ -49,10 +53,14 @@ const TimePicker = ({ timeZone, date, onChange, use12Hours, minuteInterval, hide
|
|
|
49
53
|
closeTimePicker();
|
|
50
54
|
}, [closeTimePicker, onCancel]);
|
|
51
55
|
const handleConfirm = useCallback(() => {
|
|
56
|
+
if (!hasChangedRef.current) {
|
|
57
|
+
onChange?.({ date: currentDate ? dayjs(currentDate).toDate() : currentDate });
|
|
58
|
+
}
|
|
52
59
|
closeTimePicker();
|
|
53
|
-
}, [closeTimePicker]);
|
|
60
|
+
}, [currentDate, onChange, closeTimePicker]);
|
|
54
61
|
const handleChange = useCallback((index) => {
|
|
55
62
|
if (index > -1) {
|
|
63
|
+
hasChangedRef.current = false;
|
|
56
64
|
setTimeout(() => {
|
|
57
65
|
AccessibilityInfo.announceForAccessibility('Time picker opened.');
|
|
58
66
|
const targetRef = pickerViewRef.current;
|
|
@@ -6,6 +6,7 @@ import { StyleSheet } from 'react-native-unistyles';
|
|
|
6
6
|
import { BodyText } from '../BodyText';
|
|
7
7
|
import { formatNumber, getParsedDate } from '../DatePicker/utils';
|
|
8
8
|
import TimePickerWheel from './TimePickerWheel';
|
|
9
|
+
import { getClosestOption } from './utils';
|
|
9
10
|
const createNumberList = (num, numerals, startFrom = 0) => {
|
|
10
11
|
return Array.from({ length: num }, (_, i) => ({
|
|
11
12
|
value: i + startFrom,
|
|
@@ -24,23 +25,6 @@ const createMinuteList = (interval, numerals) => {
|
|
|
24
25
|
: `${formatNumber(value, numerals)}`,
|
|
25
26
|
}));
|
|
26
27
|
};
|
|
27
|
-
const getClosestMinute = (value, options) => {
|
|
28
|
-
if (!options.length)
|
|
29
|
-
return value;
|
|
30
|
-
const values = options.map(option => option.value);
|
|
31
|
-
if (values.includes(value))
|
|
32
|
-
return value;
|
|
33
|
-
let closest = values[0] ?? value;
|
|
34
|
-
let closestDiff = Math.abs(value - closest);
|
|
35
|
-
values.forEach(optionValue => {
|
|
36
|
-
const diff = Math.abs(value - optionValue);
|
|
37
|
-
if (diff < closestDiff) {
|
|
38
|
-
closestDiff = diff;
|
|
39
|
-
closest = optionValue;
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
return closest;
|
|
43
|
-
};
|
|
44
28
|
const TimePickerView = ({ currentDate, onSelectDate, timeZone, use12Hours, minuteInterval = 1, }) => {
|
|
45
29
|
const hours = useMemo(() => createNumberList(use12Hours ? 12 : 24, 'latn', use12Hours ? 1 : 0), [use12Hours]);
|
|
46
30
|
const minutes = useMemo(() => createMinuteList(minuteInterval, 'latn'), [minuteInterval]);
|
|
@@ -50,7 +34,9 @@ const TimePickerView = ({ currentDate, onSelectDate, timeZone, use12Hours, minut
|
|
|
50
34
|
], []);
|
|
51
35
|
const baseDate = currentDate;
|
|
52
36
|
const { hour, hour12, minute, period } = getParsedDate(baseDate);
|
|
53
|
-
const
|
|
37
|
+
const rawHour = use12Hours ? hour12 : hour;
|
|
38
|
+
const hourValue = useMemo(() => getClosestOption(rawHour, hours), [rawHour, hours]);
|
|
39
|
+
const minuteValue = useMemo(() => getClosestOption(minute, minutes), [minute, minutes]);
|
|
54
40
|
const handleChangeHour = useCallback((value) => {
|
|
55
41
|
let hour24 = value;
|
|
56
42
|
if (use12Hours) {
|
|
@@ -85,7 +71,7 @@ const TimePickerView = ({ currentDate, onSelectDate, timeZone, use12Hours, minut
|
|
|
85
71
|
const newDate = dayjs.tz(baseDate, timeZone).hour(newHour);
|
|
86
72
|
onSelectDate(newDate);
|
|
87
73
|
}, [baseDate, onSelectDate, timeZone, hour, hour12]);
|
|
88
|
-
return (_jsxs(View, { style: styles.container, testID: "time-selector", children: [_jsxs(View, { style: styles.timePickerContainer, children: [_jsx(View, { style: styles.wheelContainer, children: _jsx(TimePickerWheel, { value:
|
|
74
|
+
return (_jsxs(View, { style: styles.container, testID: "time-selector", children: [_jsxs(View, { style: styles.timePickerContainer, children: [_jsx(View, { style: styles.wheelContainer, children: _jsx(TimePickerWheel, { value: hourValue, items: hours, setValue: handleChangeHour }) }), _jsx(BodyText, { style: styles.timeSeparator, size: "lg", children: ":" }), _jsx(View, { style: styles.wheelContainer, children: _jsx(TimePickerWheel, { value: minuteValue, items: minutes, setValue: handleChangeMinute }) })] }), use12Hours && period ? (_jsx(View, { style: styles.periodContainer, children: _jsx(TimePickerWheel, { value: period, items: periodOptions, setValue: handlePeriodChange }) })) : null] }));
|
|
89
75
|
};
|
|
90
76
|
const styles = StyleSheet.create({
|
|
91
77
|
container: {
|
|
@@ -34,7 +34,14 @@ const TimePickerWheel = ({ value, setValue = () => { }, items }) => {
|
|
|
34
34
|
const height = 140;
|
|
35
35
|
const radius = height / 2;
|
|
36
36
|
const valueIndex = useMemo(() => {
|
|
37
|
-
|
|
37
|
+
const rawIndex = items.findIndex(item => item.value === value);
|
|
38
|
+
if (rawIndex === -1) {
|
|
39
|
+
if (__DEV__) {
|
|
40
|
+
console.warn(`TimePickerWheel: value "${value}" not found in items [${items.map(item => item.value).join(', ')}]; falling back to index 0.`);
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
return rawIndex;
|
|
38
45
|
}, [items, value]);
|
|
39
46
|
const handlePanEnd = useCallback((deltaY) => {
|
|
40
47
|
let newValueIndex = valueIndex - Math.round(deltaY / ((radius * 2) / displayCount));
|
|
@@ -81,7 +88,9 @@ const TimePickerWheel = ({ value, setValue = () => { }, items }) => {
|
|
|
81
88
|
return items[targetIndex] || items[0];
|
|
82
89
|
});
|
|
83
90
|
}, [renderCount, valueIndex, items, circular]);
|
|
84
|
-
|
|
91
|
+
// displayValues is centered on valueIndex by construction, so the current item always
|
|
92
|
+
// sits at the middle index rather than needing a second findIndex/fallback lookup.
|
|
93
|
+
const currentIndex = Math.floor(renderCount / 2);
|
|
85
94
|
return (_jsx(GestureDetector, { gesture: panGesture, children: _jsxs(View, { style: styles.container, children: [_jsx(View, { style: [
|
|
86
95
|
styles.selectedIndicator,
|
|
87
96
|
{
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PickerOption } from './TimePicker.props';
|
|
2
|
+
/**
|
|
3
|
+
* Snaps a value to the closest matching option's value. Used to guarantee the value
|
|
4
|
+
* handed to a wheel picker always exists in its own options list — an exact match is
|
|
5
|
+
* returned as-is, a numeric mismatch snaps to the nearest option by distance, and a
|
|
6
|
+
* non-numeric or unmatched value falls back to the first option, since a mismatched
|
|
7
|
+
* value has nothing for the wheel to scroll to.
|
|
8
|
+
*/
|
|
9
|
+
export declare const getClosestOption: <T extends number | string>(value: T, options: PickerOption[]) => T;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snaps a value to the closest matching option's value. Used to guarantee the value
|
|
3
|
+
* handed to a wheel picker always exists in its own options list — an exact match is
|
|
4
|
+
* returned as-is, a numeric mismatch snaps to the nearest option by distance, and a
|
|
5
|
+
* non-numeric or unmatched value falls back to the first option, since a mismatched
|
|
6
|
+
* value has nothing for the wheel to scroll to.
|
|
7
|
+
*/
|
|
8
|
+
export const getClosestOption = (value, options) => {
|
|
9
|
+
if (!options.length)
|
|
10
|
+
return value;
|
|
11
|
+
const values = options.map(option => option.value);
|
|
12
|
+
if (values.includes(value))
|
|
13
|
+
return value;
|
|
14
|
+
const numericValues = values.filter((optionValue) => {
|
|
15
|
+
return typeof optionValue === 'number';
|
|
16
|
+
});
|
|
17
|
+
if (typeof value !== 'number' || Number.isNaN(value) || !numericValues.length) {
|
|
18
|
+
return (options[0]?.value ?? value);
|
|
19
|
+
}
|
|
20
|
+
let closest = numericValues[0];
|
|
21
|
+
let closestDiff = Math.abs(value - closest);
|
|
22
|
+
numericValues.forEach(optionValue => {
|
|
23
|
+
const diff = Math.abs(value - optionValue);
|
|
24
|
+
if (diff < closestDiff) {
|
|
25
|
+
closestDiff = diff;
|
|
26
|
+
closest = optionValue;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return closest;
|
|
30
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getClosestOption } from './utils';
|
|
3
|
+
const minuteOptions = [0, 15, 30, 45].map(value => ({
|
|
4
|
+
value,
|
|
5
|
+
text: String(value),
|
|
6
|
+
}));
|
|
7
|
+
const periodOptions = [
|
|
8
|
+
{ value: 'AM', text: 'AM' },
|
|
9
|
+
{ value: 'PM', text: 'PM' },
|
|
10
|
+
];
|
|
11
|
+
describe('getClosestOption', () => {
|
|
12
|
+
it('returns the value unchanged when it matches an option exactly', () => {
|
|
13
|
+
expect(getClosestOption(30, minuteOptions)).toBe(30);
|
|
14
|
+
});
|
|
15
|
+
it('snaps a mismatched numeric value to the nearest option', () => {
|
|
16
|
+
expect(getClosestOption(20, minuteOptions)).toBe(15);
|
|
17
|
+
expect(getClosestOption(23, minuteOptions)).toBe(30);
|
|
18
|
+
});
|
|
19
|
+
it('returns the value unchanged when options is empty', () => {
|
|
20
|
+
expect(getClosestOption(37, [])).toBe(37);
|
|
21
|
+
});
|
|
22
|
+
it('falls back to the first option for a NaN value', () => {
|
|
23
|
+
expect(getClosestOption(NaN, minuteOptions)).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
it('matches a non-numeric value against string options', () => {
|
|
26
|
+
expect(getClosestOption('PM', periodOptions)).toBe('PM');
|
|
27
|
+
});
|
|
28
|
+
it('falls back to the first option for an unmatched non-numeric value', () => {
|
|
29
|
+
expect(getClosestOption('unknown', periodOptions)).toBe('AM');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -5,7 +5,7 @@ import type TimePickerInputProps from './TimePickerInput.props';
|
|
|
5
5
|
* @summary A text input with a time picker trigger.
|
|
6
6
|
*/
|
|
7
7
|
declare const TimePickerInput: {
|
|
8
|
-
({ validationStatus, disabled, focused, readonly, placeholder, inBottomSheet, format, openButtonLabel, autoCloseOnSelect, label, labelVariant, helperText, helperIcon, validText, invalidText, required, onChange, onChangeText, onBlur, onFocus, value, timePickerProps, onClear, ...rest }: TimePickerInputProps): import("react").JSX.Element;
|
|
8
|
+
({ validationStatus, disabled, focused, readonly, placeholder, inBottomSheet, format, openButtonLabel, autoCloseOnSelect, disableManualEntry, label, labelVariant, helperText, helperIcon, validText, invalidText, required, onChange, onChangeText, onBlur, onFocus, value, timePickerProps, onClear, ...rest }: TimePickerInputProps): import("react").JSX.Element;
|
|
9
9
|
displayName: string;
|
|
10
10
|
};
|
|
11
11
|
export default TimePickerInput;
|
|
@@ -3,7 +3,7 @@ import { CloseSmallIcon, TimeSmallIcon } from '@utilitywarehouse/hearth-react-na
|
|
|
3
3
|
import dayjs from 'dayjs';
|
|
4
4
|
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
-
import { Keyboard, Platform } from 'react-native';
|
|
6
|
+
import { Keyboard, Platform, Pressable } from 'react-native';
|
|
7
7
|
import { StyleSheet } from 'react-native-unistyles';
|
|
8
8
|
import { useFormFieldContext } from '../FormField';
|
|
9
9
|
import { Input, InputField, InputSlot } from '../Input';
|
|
@@ -17,7 +17,7 @@ dayjs.extend(customParseFormat);
|
|
|
17
17
|
* TimePicker for selecting the same value. Formats and parses input using Day.js.
|
|
18
18
|
* @summary A text input with a time picker trigger.
|
|
19
19
|
*/
|
|
20
|
-
const TimePickerInput = ({ validationStatus = 'initial', disabled, focused, readonly, placeholder = '--:--', inBottomSheet = false, format, openButtonLabel = 'Open time picker', autoCloseOnSelect = true, label, labelVariant, helperText, helperIcon, validText, invalidText, required = true, onChange, onChangeText, onBlur, onFocus, value, timePickerProps, onClear, ...rest }) => {
|
|
20
|
+
const TimePickerInput = ({ validationStatus = 'initial', disabled, focused, readonly, placeholder = '--:--', inBottomSheet = false, format, openButtonLabel = 'Open time picker', autoCloseOnSelect = true, disableManualEntry = false, label, labelVariant, helperText, helperIcon, validText, invalidText, required = true, onChange, onChangeText, onBlur, onFocus, value, timePickerProps, onClear, ...rest }) => {
|
|
21
21
|
const formFieldContext = useFormFieldContext();
|
|
22
22
|
const isDisabled = formFieldContext?.disabled ?? disabled;
|
|
23
23
|
const isReadonly = formFieldContext?.readonly ?? readonly;
|
|
@@ -108,18 +108,24 @@ const TimePickerInput = ({ validationStatus = 'initial', disabled, focused, read
|
|
|
108
108
|
pickerRef.current?.close?.();
|
|
109
109
|
}, [pickerOnCancel]);
|
|
110
110
|
const placeholderValue = placeholder;
|
|
111
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Input, { validationStatus: validationStatus, disabled: disabled, readonly: readonly, focused: focused, label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, style: styles.wrap, accessible: false, children: [_jsx(
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Input, { validationStatus: validationStatus, disabled: disabled, readonly: readonly, focused: focused, label: label, labelVariant: labelVariant, helperText: helperText, helperIcon: helperIcon, validText: validText, invalidText: invalidText, required: required, style: styles.wrap, accessible: false, children: [_jsx(Pressable, { style: styles.fieldPressable, disabled: !disableManualEntry || isDisabled || isReadonly, onPress: openPicker, children: _jsx(InputField, { value: inputValue, placeholder: placeholderValue, onChangeText: handleTextChange, onBlur: handleBlur, onFocus: handleFocus, inBottomSheet: inBottomSheet, keyboardType: resolvedKeyboardType, inputMode: resolvedInputMode, accessibilityHint: resolvedAccessibilityHint, "aria-label": "Time input", accessibilityLabel: resolvedAccessibilityLabel, accessible: resolvedAccessible, accessibilityState: {
|
|
112
|
+
disabled: isDisabled || isReadonly,
|
|
113
|
+
}, importantForAccessibility: resolvedImportantForAccessibility, inputAccessoryViewID: Platform.OS === 'ios' ? accessoryViewID : undefined, ...textInputProps, editable: !isReadonly && !isDisabled && !disableManualEntry, style: [styles.input, inputStyle, disableManualEntry && styles.noPointerEvents] }) }), !!inputValue && onClear && !isReadonly && !isDisabled && (_jsx(InputSlot, { accessibilityElementsHidden: false, children: _jsx(UnstyledIconButton, { accessibilityLabel: "Clear time", accessibilityHint: "Removes the current time", icon: CloseSmallIcon, onPress: handleClear }) })), _jsx(InputSlot, { accessibilityElementsHidden: false, children: _jsx(UnstyledIconButton, { accessibilityLabel: openButtonLabel, accessibilityHint: "Opens the time picker", icon: TimeSmallIcon, onPress: openPicker, disabled: isDisabled || isReadonly }) })] }), _jsx(TimePicker, { ref: pickerRef, date: selectedDate, onChange: handlePickerChange, onCancel: handleCancel, ...restTimePickerProps }), Platform.OS === 'ios' && accessoryViewID && (_jsx(TimePickerInputDoneButton, { accessoryViewID: accessoryViewID, closeKeyboard: closeKeyboard }))] }));
|
|
114
114
|
};
|
|
115
115
|
TimePickerInput.displayName = 'TimePickerInput';
|
|
116
116
|
const styles = StyleSheet.create(theme => ({
|
|
117
117
|
wrap: {
|
|
118
118
|
gap: theme.components.input.date.gap,
|
|
119
119
|
},
|
|
120
|
+
fieldPressable: {
|
|
121
|
+
flex: 1,
|
|
122
|
+
},
|
|
120
123
|
input: {
|
|
121
124
|
paddingLeft: 0,
|
|
122
125
|
paddingRight: 0,
|
|
123
126
|
},
|
|
127
|
+
noPointerEvents: {
|
|
128
|
+
pointerEvents: 'none',
|
|
129
|
+
},
|
|
124
130
|
}));
|
|
125
131
|
export default TimePickerInput;
|
|
@@ -56,6 +56,13 @@ export interface TimePickerInputBaseProps {
|
|
|
56
56
|
* When true (default), the picker sheet is dismissed as soon as a time is picked.
|
|
57
57
|
*/
|
|
58
58
|
autoCloseOnSelect?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Blocks manual keyboard entry and makes tapping anywhere on the field open the
|
|
61
|
+
* time picker, instead of only the trailing trigger button. Independent of
|
|
62
|
+
* `disabled`/`readonly`, which still disable the whole field regardless of this prop.
|
|
63
|
+
* @default false
|
|
64
|
+
*/
|
|
65
|
+
disableManualEntry?: boolean;
|
|
59
66
|
/**
|
|
60
67
|
* Additional props forwarded to the underlying TimePicker instance.
|
|
61
68
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@utilitywarehouse/hearth-react-native",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.3",
|
|
4
4
|
"description": "Utility Warehouse React Native UI library",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -82,9 +82,9 @@
|
|
|
82
82
|
"vitest": "^4.1.7",
|
|
83
83
|
"@utilitywarehouse/hearth-fonts": "^0.1.1",
|
|
84
84
|
"@utilitywarehouse/hearth-react-icons": "^0.9.2",
|
|
85
|
+
"@utilitywarehouse/hearth-react-native-icons": "^0.9.2",
|
|
85
86
|
"@utilitywarehouse/hearth-storybook-utils": "0.0.0",
|
|
86
87
|
"@utilitywarehouse/hearth-svg-assets": "^0.6.6",
|
|
87
|
-
"@utilitywarehouse/hearth-react-native-icons": "^0.9.2",
|
|
88
88
|
"@utilitywarehouse/hearth-tokens": "^0.4.7"
|
|
89
89
|
},
|
|
90
90
|
"peerDependencies": {
|
|
@@ -24,11 +24,17 @@ The `DatePicker` component presents a calendar experience in a bottom sheet so p
|
|
|
24
24
|
<View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
|
|
25
25
|
<ViewWrap>
|
|
26
26
|
<Button onPress={() => modalRef.current?.present()}>Show Date Picker</Button>
|
|
27
|
+
<BodyText>
|
|
28
|
+
{confirmedDate ? `Confirmed: ${dayjs(confirmedDate).format('YYYY-MM-DD')}` : 'Not confirmed'}
|
|
29
|
+
</BodyText>
|
|
27
30
|
<DatePicker
|
|
28
31
|
ref={modalRef}
|
|
29
32
|
mode="single"
|
|
30
33
|
date={selected}
|
|
31
|
-
onChange={({ date }) =>
|
|
34
|
+
onChange={({ date }) => {
|
|
35
|
+
setSelected(date);
|
|
36
|
+
setConfirmedDate(date);
|
|
37
|
+
}}
|
|
32
38
|
onCancel={() => setSelected(undefined)}
|
|
33
39
|
/>
|
|
34
40
|
</ViewWrap>
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<View style={Platform.OS === 'web' ? { width: 400, height: 400 } : {}}>
|
|
15
15
|
<ViewWrap>
|
|
16
16
|
<Button onPress={() => modalRef.current?.present()}>Show Time Picker</Button>
|
|
17
|
+
<BodyText>{selected ? 'Confirmed' : 'Not confirmed'}</BodyText>
|
|
17
18
|
<TimePicker
|
|
18
19
|
ref={modalRef}
|
|
19
20
|
date={selected}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Hearth React Native
|
|
2
2
|
|
|
3
3
|
React Native component library for building UIs at Utility Warehouse.<br/>
|
|
4
|
-
Current version: v0.36.
|
|
4
|
+
Current version: v0.36.3
|
|
5
5
|
|
|
6
6
|
Hearth React Native is a comprehensive design system library for React Native, providing reusable components, consistent styling, and tools to streamline UI development. It is built to enhance productivity and maintain design consistency across projects.
|
|
7
7
|
|