@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.
@@ -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
+ });
@@ -1,45 +1,420 @@
1
- import { colors } from "@lotics/ui/colors";
2
- import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
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?: "date" | "datetime" | "date_range" | "datetime_range";
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
- export function DatePicker(props: DatePickerProps) {
14
- const { value, onValueChange, format, disabled, onBlur, testID } = props;
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
- // Map format to HTML input type (range formats use datetime-local for their components)
17
- const inputType =
18
- format === "datetime" || format === "datetime_range" ? "datetime-local" : "date";
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
- <input
22
- data-testid={testID}
23
- type={inputType}
24
- value={value || ""}
25
- onChange={(e) => onValueChange(e.target.value)}
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
- style={{
29
- height: 40,
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
+ });