@lotics/ui 1.12.0 → 1.13.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/package.json +7 -4
- package/src/accordion.tsx +97 -0
- package/src/button.tsx +1 -1
- package/src/cell_date_format.test.ts +32 -0
- package/src/cell_date_format.ts +28 -3
- package/src/checkbox_input.tsx +8 -3
- package/src/date_calendar.tsx +679 -0
- package/src/date_field.tsx +172 -0
- package/src/date_picker.tsx +403 -28
- package/src/date_picker_value.test.ts +167 -0
- package/src/date_picker_value.ts +128 -0
- package/src/date_segments.test.ts +206 -0
- package/src/date_segments.ts +347 -0
- package/src/date_segments_field.tsx +418 -0
- package/src/icon.tsx +2 -0
- package/src/menu_button.tsx +1 -1
- package/src/menu_list_item.tsx +1 -1
- package/src/radio_picker.tsx +1 -1
- package/src/stepper.tsx +83 -0
- package/src/switch.tsx +1 -1
- package/src/switch_button.tsx +1 -1
- package/src/tabs.tsx +1 -1
- package/src/time_field.tsx +300 -0
- package/src/datetime_picker.tsx +0 -44
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
+
import { colors } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { DateSegments } from "./date_segments_field";
|
|
6
|
+
import { SegmentLabels, dateSegmentsConfig } from "./date_segments";
|
|
7
|
+
|
|
8
|
+
export interface DateFieldProps {
|
|
9
|
+
/** Canonical ISO value parts: one (single) or two (range, "start"/"end"). */
|
|
10
|
+
parts: string[];
|
|
11
|
+
onPartChange: (index: number, value: string) => void;
|
|
12
|
+
hasTime: boolean;
|
|
13
|
+
segmentLabels: SegmentLabels;
|
|
14
|
+
/** BCP-47 locale driving segment order + 12/24h. Defaults to "en-US". */
|
|
15
|
+
locale?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** Shown when every part is empty and the field is unfocused. */
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
/** Accessible name per part (e.g. ["Start date", "End date"]). */
|
|
20
|
+
partLabels?: string[];
|
|
21
|
+
testID?: string;
|
|
22
|
+
/** Pointer click in a segment area — the "open the calendar to select" path. */
|
|
23
|
+
onActivate?: () => void;
|
|
24
|
+
/** Fires once focus leaves the whole field. */
|
|
25
|
+
onBlur?: () => void;
|
|
26
|
+
/** Rendered inside the border, after the segments (the calendar button). */
|
|
27
|
+
rightSlot?: React.ReactNode;
|
|
28
|
+
/** Ref to the frame, used to anchor the popover. */
|
|
29
|
+
triggerRef?: React.RefObject<View | null>;
|
|
30
|
+
style?: StyleProp<ViewStyle>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The bordered date field: a single framed control wrapping one segment group
|
|
35
|
+
* (single date/datetime) or two joined by an en-dash (a range), plus the calendar
|
|
36
|
+
* button. Owns the focus ring (focus-within across both groups) and the empty-state
|
|
37
|
+
* placeholder. The segment editing lives in {@link DateSegments}.
|
|
38
|
+
*/
|
|
39
|
+
export function DateField(props: DateFieldProps) {
|
|
40
|
+
const {
|
|
41
|
+
parts,
|
|
42
|
+
onPartChange,
|
|
43
|
+
hasTime,
|
|
44
|
+
segmentLabels,
|
|
45
|
+
locale,
|
|
46
|
+
disabled,
|
|
47
|
+
placeholder,
|
|
48
|
+
partLabels,
|
|
49
|
+
testID,
|
|
50
|
+
onActivate,
|
|
51
|
+
onBlur,
|
|
52
|
+
rightSlot,
|
|
53
|
+
triggerRef,
|
|
54
|
+
style,
|
|
55
|
+
} = props;
|
|
56
|
+
|
|
57
|
+
const [focused, setFocused] = useState(false);
|
|
58
|
+
const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
59
|
+
|
|
60
|
+
useEffect(
|
|
61
|
+
() => () => {
|
|
62
|
+
if (blurTimer.current) clearTimeout(blurTimer.current);
|
|
63
|
+
},
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Debounce focus-within: tabbing from one part's last segment to the next part's
|
|
68
|
+
// first fires blur-then-focus, which must not flip the ring off.
|
|
69
|
+
const handleSegmentFocus = useCallback(() => {
|
|
70
|
+
if (blurTimer.current) {
|
|
71
|
+
clearTimeout(blurTimer.current);
|
|
72
|
+
blurTimer.current = null;
|
|
73
|
+
}
|
|
74
|
+
setFocused(true);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const handleSegmentBlur = useCallback(() => {
|
|
78
|
+
blurTimer.current = setTimeout(() => {
|
|
79
|
+
setFocused(false);
|
|
80
|
+
onBlur?.();
|
|
81
|
+
}, 0);
|
|
82
|
+
}, [onBlur]);
|
|
83
|
+
|
|
84
|
+
// The whole field is pressable: a pointer click anywhere opens the calendar.
|
|
85
|
+
const handlePointerDown = useCallback(() => {
|
|
86
|
+
if (!disabled) onActivate?.();
|
|
87
|
+
}, [disabled, onActivate]);
|
|
88
|
+
|
|
89
|
+
const config = useMemo(() => dateSegmentsConfig(locale ?? "en-US", hasTime), [locale, hasTime]);
|
|
90
|
+
|
|
91
|
+
const isEmpty = parts.every((part) => !part);
|
|
92
|
+
const showPlaceholder = !!placeholder && isEmpty && !focused;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<View
|
|
96
|
+
ref={triggerRef}
|
|
97
|
+
onPointerDown={handlePointerDown}
|
|
98
|
+
style={[
|
|
99
|
+
styles.frame,
|
|
100
|
+
focused && !disabled && styles.frameFocused,
|
|
101
|
+
disabled && styles.frameDisabled,
|
|
102
|
+
style,
|
|
103
|
+
]}
|
|
104
|
+
testID={testID}
|
|
105
|
+
>
|
|
106
|
+
<View style={styles.segmentsArea}>
|
|
107
|
+
{parts.map((part, index) => (
|
|
108
|
+
<React.Fragment key={index}>
|
|
109
|
+
{index > 0 && (
|
|
110
|
+
<Text size="sm" color="muted">
|
|
111
|
+
{" – "}
|
|
112
|
+
</Text>
|
|
113
|
+
)}
|
|
114
|
+
<DateSegments
|
|
115
|
+
value={part}
|
|
116
|
+
onChange={(next) => onPartChange(index, next)}
|
|
117
|
+
config={config}
|
|
118
|
+
segmentLabels={segmentLabels}
|
|
119
|
+
disabled={disabled}
|
|
120
|
+
accessibilityLabel={partLabels?.[index]}
|
|
121
|
+
onFocus={handleSegmentFocus}
|
|
122
|
+
onBlur={handleSegmentBlur}
|
|
123
|
+
/>
|
|
124
|
+
</React.Fragment>
|
|
125
|
+
))}
|
|
126
|
+
{/* Rendered last so it paints over the segments; click-transparent. */}
|
|
127
|
+
{showPlaceholder && (
|
|
128
|
+
<View style={styles.placeholderOverlay}>
|
|
129
|
+
<Text size="sm" color="muted" numberOfLines={1}>
|
|
130
|
+
{placeholder}
|
|
131
|
+
</Text>
|
|
132
|
+
</View>
|
|
133
|
+
)}
|
|
134
|
+
</View>
|
|
135
|
+
{rightSlot}
|
|
136
|
+
</View>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const styles = StyleSheet.create({
|
|
141
|
+
frame: {
|
|
142
|
+
flexDirection: "row",
|
|
143
|
+
alignItems: "center",
|
|
144
|
+
height: 40,
|
|
145
|
+
paddingHorizontal: 8,
|
|
146
|
+
gap: 4,
|
|
147
|
+
borderRadius: 8,
|
|
148
|
+
borderWidth: 1,
|
|
149
|
+
borderColor: colors.border,
|
|
150
|
+
backgroundColor: colors.background,
|
|
151
|
+
},
|
|
152
|
+
frameFocused: {
|
|
153
|
+
outlineColor: colors.black,
|
|
154
|
+
outlineWidth: 2,
|
|
155
|
+
outlineStyle: "solid",
|
|
156
|
+
outlineOffset: -2,
|
|
157
|
+
},
|
|
158
|
+
frameDisabled: {
|
|
159
|
+
backgroundColor: colors.zinc["50"],
|
|
160
|
+
},
|
|
161
|
+
segmentsArea: {
|
|
162
|
+
flex: 1,
|
|
163
|
+
flexDirection: "row",
|
|
164
|
+
alignItems: "center",
|
|
165
|
+
},
|
|
166
|
+
placeholderOverlay: {
|
|
167
|
+
...StyleSheet.absoluteFill,
|
|
168
|
+
justifyContent: "center",
|
|
169
|
+
backgroundColor: colors.background,
|
|
170
|
+
pointerEvents: "none",
|
|
171
|
+
},
|
|
172
|
+
});
|
package/src/date_picker.tsx
CHANGED
|
@@ -1,45 +1,420 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { Popover, PopoverContent } from "./popover";
|
|
6
|
+
import { Calendar, CalendarRangeValue } from "./date_calendar";
|
|
7
|
+
import { TimeField } from "./time_field";
|
|
8
|
+
import { DateField } from "./date_field";
|
|
9
|
+
import { SegmentLabels } from "./date_segments";
|
|
10
|
+
import { Icon } from "./icon";
|
|
11
|
+
import { Button } from "./button";
|
|
12
|
+
import { Divider } from "./divider";
|
|
13
|
+
import {
|
|
14
|
+
DatePickerFormat,
|
|
15
|
+
hasTimeFormat,
|
|
16
|
+
isoToDate,
|
|
17
|
+
isRangeFormat,
|
|
18
|
+
nowIso,
|
|
19
|
+
timeText,
|
|
20
|
+
withCalendarDate,
|
|
21
|
+
withTimeOfDay,
|
|
22
|
+
} from "./date_picker_value";
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
export interface DatePickerLabels extends SegmentLabels {
|
|
29
|
+
/** Quick action: set the value to today. Shown for the `date` format. */
|
|
30
|
+
today: string;
|
|
31
|
+
/** Quick action: set the value to the current date and time. Shown for `datetime`. */
|
|
32
|
+
now: string;
|
|
33
|
+
/** Quick action: clear the value. */
|
|
34
|
+
clear: string;
|
|
35
|
+
/** Accessible name for the calendar button. */
|
|
36
|
+
openCalendar: string;
|
|
37
|
+
/** Accessible name for the time field (single datetime). */
|
|
38
|
+
time: string;
|
|
39
|
+
/** Accessible name for the start-time field (datetime range). */
|
|
40
|
+
startTime: string;
|
|
41
|
+
/** Accessible name for the end-time field (datetime range). */
|
|
42
|
+
endTime: string;
|
|
43
|
+
/** Footer action: commit and close the popover. */
|
|
44
|
+
done: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_LABELS: DatePickerLabels = {
|
|
48
|
+
today: "Today",
|
|
49
|
+
now: "Now",
|
|
50
|
+
clear: "Clear",
|
|
51
|
+
done: "Done",
|
|
52
|
+
openCalendar: "Open calendar",
|
|
53
|
+
time: "Time",
|
|
54
|
+
startTime: "Start time",
|
|
55
|
+
endTime: "End time",
|
|
56
|
+
year: "Year",
|
|
57
|
+
month: "Month",
|
|
58
|
+
day: "Day",
|
|
59
|
+
hour: "Hour",
|
|
60
|
+
minute: "Minute",
|
|
61
|
+
dayPeriod: "AM/PM",
|
|
62
|
+
};
|
|
3
63
|
|
|
4
64
|
export interface DatePickerProps {
|
|
5
65
|
value?: string | null;
|
|
6
66
|
onValueChange: (value: string) => void;
|
|
7
|
-
format?:
|
|
67
|
+
format?: DatePickerFormat;
|
|
8
68
|
disabled?: boolean;
|
|
69
|
+
/** Fires when the text input loses focus, after any typed value is committed. */
|
|
9
70
|
onBlur?: () => void;
|
|
10
71
|
testID?: string;
|
|
72
|
+
/** Translated labels for the popover quick actions and accessible names. Defaults to English. */
|
|
73
|
+
labels?: Partial<DatePickerLabels>;
|
|
74
|
+
/** BCP-47 locale for the calendar's weekday/month names. Defaults to "en-US". */
|
|
75
|
+
locale?: string;
|
|
76
|
+
/** Shown in the field when nothing is selected yet. */
|
|
77
|
+
placeholder?: string;
|
|
11
78
|
}
|
|
12
79
|
|
|
13
|
-
|
|
14
|
-
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// DatePickerPanel — the calendar / time / quick-action surface
|
|
82
|
+
//
|
|
83
|
+
// Rendered inside the DatePicker popover, and directly by grid cell editors
|
|
84
|
+
// that own their own overlay.
|
|
85
|
+
// =============================================================================
|
|
86
|
+
|
|
87
|
+
export interface DatePickerPanelProps {
|
|
88
|
+
value?: string | null;
|
|
89
|
+
onValueChange: (value: string) => void;
|
|
90
|
+
format: DatePickerFormat;
|
|
91
|
+
labels?: Partial<DatePickerLabels>;
|
|
92
|
+
/** BCP-47 locale for the calendar's weekday/month names. Defaults to "en-US". */
|
|
93
|
+
locale?: string;
|
|
94
|
+
/** Called when a selection completes and the surrounding surface should dismiss. */
|
|
95
|
+
onRequestClose?: () => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
99
|
+
const { value, onValueChange, format, labels, locale, onRequestClose } = props;
|
|
100
|
+
|
|
101
|
+
const isRange = isRangeFormat(format);
|
|
102
|
+
const hasTime = hasTimeFormat(format);
|
|
103
|
+
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
|
|
104
|
+
|
|
105
|
+
const [startIso, endIso] = useMemo<[string, string]>(() => {
|
|
106
|
+
if (!value) return ["", ""];
|
|
107
|
+
if (isRange) {
|
|
108
|
+
const [a = "", b = ""] = value.split("/");
|
|
109
|
+
return [a, b];
|
|
110
|
+
}
|
|
111
|
+
return [value, ""];
|
|
112
|
+
}, [value, isRange]);
|
|
113
|
+
|
|
114
|
+
// In-progress range selection is held locally so a half-picked range
|
|
115
|
+
// ("start" with no "end") is never emitted as a structurally-invalid value.
|
|
116
|
+
// The panel remounts per editing session, so this initializes fresh each open.
|
|
117
|
+
const [draftRange, setDraftRange] = useState<CalendarRangeValue | null>(null);
|
|
118
|
+
|
|
119
|
+
const singleValue = useMemo(() => isoToDate(value ?? ""), [value]);
|
|
120
|
+
const rangeValue = useMemo<CalendarRangeValue>(
|
|
121
|
+
() => draftRange ?? { start: isoToDate(startIso), end: isoToDate(endIso) },
|
|
122
|
+
[draftRange, startIso, endIso],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const handleSingleCalendar = useCallback(
|
|
126
|
+
(date: Date | null) => {
|
|
127
|
+
onValueChange(date ? withCalendarDate(date, value ?? "", hasTime) : "");
|
|
128
|
+
if (date && format === "date") onRequestClose?.();
|
|
129
|
+
},
|
|
130
|
+
[value, hasTime, format, onValueChange, onRequestClose],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const handleRangeCalendar = useCallback(
|
|
134
|
+
(range: CalendarRangeValue) => {
|
|
135
|
+
if (!range.start || !range.end) {
|
|
136
|
+
// Half-picked range — hold locally, never emit a "start/" partial value.
|
|
137
|
+
setDraftRange(range);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setDraftRange(null);
|
|
141
|
+
const start = withCalendarDate(range.start, startIso, hasTime);
|
|
142
|
+
const end = withCalendarDate(range.end, endIso, hasTime);
|
|
143
|
+
onValueChange(`${start}/${end}`);
|
|
144
|
+
if (format === "date_range") onRequestClose?.();
|
|
145
|
+
},
|
|
146
|
+
[hasTime, format, startIso, endIso, onValueChange, onRequestClose],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const handleSingleTime = useCallback(
|
|
150
|
+
(time: string) => onValueChange(withTimeOfDay(value ?? "", time)),
|
|
151
|
+
[value, onValueChange],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Only reachable when the range is complete (the time row is hidden otherwise),
|
|
155
|
+
// so both sides always resolve and a partial range can never be emitted here.
|
|
156
|
+
const handleRangeTime = useCallback(
|
|
157
|
+
(which: "start" | "end", time: string) => {
|
|
158
|
+
const updated = withTimeOfDay(which === "start" ? startIso : endIso, time);
|
|
159
|
+
onValueChange(which === "start" ? `${updated}/${endIso}` : `${startIso}/${updated}`);
|
|
160
|
+
},
|
|
161
|
+
[startIso, endIso, onValueChange],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const closeAndSet = useCallback(
|
|
165
|
+
(next: string) => {
|
|
166
|
+
setDraftRange(null);
|
|
167
|
+
onValueChange(next);
|
|
168
|
+
onRequestClose?.();
|
|
169
|
+
},
|
|
170
|
+
[onValueChange, onRequestClose],
|
|
171
|
+
);
|
|
15
172
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
173
|
+
// The range time row is interactive only once both ends exist; this keeps
|
|
174
|
+
// `handleRangeTime` from ever composing a partial range.
|
|
175
|
+
const showTimeRow = hasTime && (!isRange || (!!startIso && !!endIso));
|
|
19
176
|
|
|
20
177
|
return (
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
178
|
+
<View style={styles.panel}>
|
|
179
|
+
{isRange ? (
|
|
180
|
+
<Calendar
|
|
181
|
+
mode="range"
|
|
182
|
+
value={rangeValue}
|
|
183
|
+
onValueChange={handleRangeCalendar}
|
|
184
|
+
locale={locale}
|
|
185
|
+
/>
|
|
186
|
+
) : (
|
|
187
|
+
<Calendar
|
|
188
|
+
mode="single"
|
|
189
|
+
value={singleValue}
|
|
190
|
+
onValueChange={handleSingleCalendar}
|
|
191
|
+
locale={locale}
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{showTimeRow && (
|
|
196
|
+
<>
|
|
197
|
+
<Divider paddingVertical={8} />
|
|
198
|
+
<View style={styles.timeRow}>
|
|
199
|
+
{isRange ? (
|
|
200
|
+
<>
|
|
201
|
+
<View style={styles.timeCol}>
|
|
202
|
+
<Text size="sm" color="muted">
|
|
203
|
+
{mergedLabels.startTime}
|
|
204
|
+
</Text>
|
|
205
|
+
<TimeField
|
|
206
|
+
accessibilityLabel={mergedLabels.startTime}
|
|
207
|
+
segmentLabels={mergedLabels}
|
|
208
|
+
value={timeText(startIso)}
|
|
209
|
+
onChange={(t) => handleRangeTime("start", t)}
|
|
210
|
+
locale={locale}
|
|
211
|
+
/>
|
|
212
|
+
</View>
|
|
213
|
+
<View style={styles.timeCol}>
|
|
214
|
+
<Text size="sm" color="muted">
|
|
215
|
+
{mergedLabels.endTime}
|
|
216
|
+
</Text>
|
|
217
|
+
<TimeField
|
|
218
|
+
accessibilityLabel={mergedLabels.endTime}
|
|
219
|
+
segmentLabels={mergedLabels}
|
|
220
|
+
value={timeText(endIso)}
|
|
221
|
+
onChange={(t) => handleRangeTime("end", t)}
|
|
222
|
+
locale={locale}
|
|
223
|
+
/>
|
|
224
|
+
</View>
|
|
225
|
+
</>
|
|
226
|
+
) : (
|
|
227
|
+
<View style={styles.timeCol}>
|
|
228
|
+
<Text size="sm" color="muted">
|
|
229
|
+
{mergedLabels.time}
|
|
230
|
+
</Text>
|
|
231
|
+
<TimeField
|
|
232
|
+
accessibilityLabel={mergedLabels.time}
|
|
233
|
+
segmentLabels={mergedLabels}
|
|
234
|
+
value={timeText(value ?? "")}
|
|
235
|
+
onChange={handleSingleTime}
|
|
236
|
+
locale={locale}
|
|
237
|
+
/>
|
|
238
|
+
</View>
|
|
239
|
+
)}
|
|
240
|
+
</View>
|
|
241
|
+
</>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
<Divider paddingVertical={8} />
|
|
245
|
+
<View style={styles.footer}>
|
|
246
|
+
<Button title={mergedLabels.clear} color="muted" onPress={() => closeAndSet("")} />
|
|
247
|
+
<View style={styles.footerRight}>
|
|
248
|
+
{format === "date" && (
|
|
249
|
+
<Button
|
|
250
|
+
title={mergedLabels.today}
|
|
251
|
+
color="secondary"
|
|
252
|
+
onPress={() => closeAndSet(nowIso(false))}
|
|
253
|
+
/>
|
|
254
|
+
)}
|
|
255
|
+
{format === "datetime" && (
|
|
256
|
+
<Button
|
|
257
|
+
title={mergedLabels.now}
|
|
258
|
+
color="secondary"
|
|
259
|
+
onPress={() => closeAndSet(nowIso(true))}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
{format !== "date" && (
|
|
263
|
+
<Button title={mergedLabels.done} color="secondary" onPress={() => onRequestClose?.()} />
|
|
264
|
+
)}
|
|
265
|
+
</View>
|
|
266
|
+
</View>
|
|
267
|
+
</View>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// =============================================================================
|
|
272
|
+
// DatePicker
|
|
273
|
+
// =============================================================================
|
|
274
|
+
|
|
275
|
+
function splitRange(value: string | null | undefined): [string, string] {
|
|
276
|
+
if (!value) return ["", ""];
|
|
277
|
+
const [a = "", b = ""] = value.split("/");
|
|
278
|
+
return [a, b];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Universal date / datetime / range picker.
|
|
283
|
+
*
|
|
284
|
+
* The trigger is a segmented, locale-aware editable field (the accessible primary
|
|
285
|
+
* path) plus a calendar button that opens a popover (web) or bottom sheet (native)
|
|
286
|
+
* with a calendar, quick actions, and — for datetime formats — a time field.
|
|
287
|
+
*/
|
|
288
|
+
export function DatePicker(props: DatePickerProps) {
|
|
289
|
+
const {
|
|
290
|
+
value,
|
|
291
|
+
onValueChange,
|
|
292
|
+
format = "date",
|
|
293
|
+
disabled,
|
|
294
|
+
onBlur,
|
|
295
|
+
testID,
|
|
296
|
+
labels,
|
|
297
|
+
locale,
|
|
298
|
+
placeholder,
|
|
299
|
+
} = props;
|
|
300
|
+
|
|
301
|
+
const isRange = isRangeFormat(format);
|
|
302
|
+
const hasTime = hasTimeFormat(format);
|
|
303
|
+
const mergedLabels = useMemo<DatePickerLabels>(
|
|
304
|
+
() => ({ ...DEFAULT_LABELS, ...labels }),
|
|
305
|
+
[labels],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const [open, setOpen] = useState(false);
|
|
309
|
+
const triggerRef = useRef<View>(null);
|
|
310
|
+
|
|
311
|
+
const closeOverlay = useCallback(() => setOpen(false), []);
|
|
312
|
+
const openOverlay = useCallback(() => setOpen(true), []);
|
|
313
|
+
|
|
314
|
+
// Range halves are held locally so a half-typed range is never emitted as a
|
|
315
|
+
// structurally-invalid value; resynced when `value` changes from outside (the
|
|
316
|
+
// calendar, a parent reset).
|
|
317
|
+
const [halves, setHalves] = useState<[string, string]>(() => splitRange(value));
|
|
318
|
+
const lastRangeEmit = useRef<string>(value ?? "");
|
|
319
|
+
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
if (!isRange) return;
|
|
322
|
+
const v = value ?? "";
|
|
323
|
+
if (v !== lastRangeEmit.current) {
|
|
324
|
+
lastRangeEmit.current = v;
|
|
325
|
+
setHalves(splitRange(v));
|
|
326
|
+
}
|
|
327
|
+
}, [isRange, value]);
|
|
328
|
+
|
|
329
|
+
const setHalf = useCallback(
|
|
330
|
+
(index: number, iso: string) => {
|
|
331
|
+
let next: [string, string] = index === 0 ? [iso, halves[1]] : [halves[0], iso];
|
|
332
|
+
if (next[0] && next[1] && next[0] > next[1]) next = [next[1], next[0]]; // keep ascending
|
|
333
|
+
setHalves(next);
|
|
334
|
+
// Side effects stay out of the state updater (it must be pure). One side only:
|
|
335
|
+
// hold locally, emit nothing canonical.
|
|
336
|
+
if (!next[0] && !next[1]) {
|
|
337
|
+
lastRangeEmit.current = "";
|
|
338
|
+
onValueChange("");
|
|
339
|
+
} else if (next[0] && next[1]) {
|
|
340
|
+
const emit = `${next[0]}/${next[1]}`;
|
|
341
|
+
lastRangeEmit.current = emit;
|
|
342
|
+
onValueChange(emit);
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
[halves, onValueChange],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// Display-only — the whole field is pressable (DateField opens the calendar).
|
|
349
|
+
const calendarIcon = (
|
|
350
|
+
<Icon name="calendar" size={18} color={disabled ? colors.zinc["300"] : colors.zinc["400"]} />
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const trigger = (
|
|
354
|
+
<DateField
|
|
355
|
+
triggerRef={triggerRef}
|
|
356
|
+
testID={testID}
|
|
357
|
+
parts={isRange ? halves : [value ?? ""]}
|
|
358
|
+
onPartChange={isRange ? setHalf : (_, next) => onValueChange(next)}
|
|
359
|
+
hasTime={hasTime}
|
|
360
|
+
segmentLabels={mergedLabels}
|
|
361
|
+
locale={locale}
|
|
26
362
|
disabled={disabled}
|
|
363
|
+
placeholder={placeholder}
|
|
27
364
|
onBlur={onBlur}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
borderRadius: 8,
|
|
31
|
-
paddingLeft: 8,
|
|
32
|
-
paddingRight: 8,
|
|
33
|
-
borderWidth: 1,
|
|
34
|
-
boxShadow: "none",
|
|
35
|
-
borderStyle: "solid",
|
|
36
|
-
boxSizing: "border-box",
|
|
37
|
-
borderColor: colors.border,
|
|
38
|
-
backgroundColor: colors.background,
|
|
39
|
-
fontFamily: fontFamilyRegular,
|
|
40
|
-
...inputTextStyleWeb,
|
|
41
|
-
letterSpacing: -0.4,
|
|
42
|
-
}}
|
|
365
|
+
onActivate={openOverlay}
|
|
366
|
+
rightSlot={calendarIcon}
|
|
43
367
|
/>
|
|
44
368
|
);
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<>
|
|
372
|
+
{trigger}
|
|
373
|
+
<Popover
|
|
374
|
+
open={open && !disabled}
|
|
375
|
+
onOpenChange={setOpen}
|
|
376
|
+
triggerRef={triggerRef}
|
|
377
|
+
side="bottom"
|
|
378
|
+
align="start"
|
|
379
|
+
>
|
|
380
|
+
<PopoverContent
|
|
381
|
+
testID={testID ? `${testID}_popover` : undefined}
|
|
382
|
+
disableBodyScroll
|
|
383
|
+
>
|
|
384
|
+
<DatePickerPanel
|
|
385
|
+
value={value}
|
|
386
|
+
onValueChange={onValueChange}
|
|
387
|
+
format={format}
|
|
388
|
+
labels={labels}
|
|
389
|
+
locale={locale}
|
|
390
|
+
onRequestClose={closeOverlay}
|
|
391
|
+
/>
|
|
392
|
+
</PopoverContent>
|
|
393
|
+
</Popover>
|
|
394
|
+
</>
|
|
395
|
+
);
|
|
45
396
|
}
|
|
397
|
+
|
|
398
|
+
const styles = StyleSheet.create({
|
|
399
|
+
panel: {
|
|
400
|
+
padding: 4,
|
|
401
|
+
},
|
|
402
|
+
footer: {
|
|
403
|
+
flexDirection: "row",
|
|
404
|
+
alignItems: "center",
|
|
405
|
+
justifyContent: "space-between",
|
|
406
|
+
},
|
|
407
|
+
footerRight: {
|
|
408
|
+
flexDirection: "row",
|
|
409
|
+
alignItems: "center",
|
|
410
|
+
gap: 8,
|
|
411
|
+
},
|
|
412
|
+
timeRow: {
|
|
413
|
+
flexDirection: "row",
|
|
414
|
+
gap: 12,
|
|
415
|
+
},
|
|
416
|
+
timeCol: {
|
|
417
|
+
flex: 1,
|
|
418
|
+
gap: 4,
|
|
419
|
+
},
|
|
420
|
+
});
|