@lotics/ui 17.0.2 → 18.1.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/MIGRATION.md +20 -0
- package/docs/catalog.md +45 -17
- package/docs/composition.md +23 -12
- package/docs/data_entry.md +3 -1
- package/docs/templates.md +3 -2
- package/examples/tpl_record.tsx +2 -2
- package/package.json +1 -1
- package/src/combobox.tsx +24 -14
- package/src/date_calendar.tsx +6 -19
- package/src/date_filter.tsx +95 -102
- package/src/date_picker.tsx +5 -0
- package/src/date_range_selection.test.ts +60 -0
- package/src/date_range_selection.ts +32 -0
- package/src/date_segments.test.ts +48 -0
- package/src/date_segments.ts +34 -0
- package/src/inline_time_picker.tsx +11 -12
- package/src/linked_record_box.tsx +77 -21
- package/src/locale.tsx +2 -2
- package/src/menu_button.tsx +32 -5
- package/src/pressable_highlight.tsx +7 -0
- package/src/section_heading.tsx +2 -1
- package/src/section_stack.tsx +14 -8
- package/src/text_input_field.tsx +46 -7
- package/src/time_picker.tsx +97 -39
- package/src/use_section_nav.test.ts +103 -1
- package/src/use_section_nav.ts +47 -20
package/src/date_filter.tsx
CHANGED
|
@@ -6,15 +6,12 @@ import { CONTROL_RADIUS } from "./control_surface";
|
|
|
6
6
|
import { MenuButton } from "./menu_button";
|
|
7
7
|
import { Calendar, CalendarRangeValue, CalendarRef } from "./date_calendar";
|
|
8
8
|
import { TimePicker } from "./time_picker";
|
|
9
|
-
import { Switch } from "./switch";
|
|
10
9
|
import { useScreenSize } from "./use_screen_size";
|
|
11
10
|
import { SegmentLabels } from "./date_segments";
|
|
12
11
|
import { PresetId, PRESET_IDS, getPresetValue } from "./date_filter_presets";
|
|
13
12
|
import { formatDate } from "./format_date";
|
|
14
13
|
import { useLoticsLocale, useLocaleTag } from "./locale";
|
|
15
14
|
|
|
16
|
-
type SelectionMode = "single" | "range";
|
|
17
|
-
|
|
18
15
|
// =============================================================================
|
|
19
16
|
// Types
|
|
20
17
|
// =============================================================================
|
|
@@ -39,7 +36,9 @@ export interface DateFilterLabels extends SegmentLabels {
|
|
|
39
36
|
custom: string;
|
|
40
37
|
from: string;
|
|
41
38
|
to: string;
|
|
39
|
+
/** Accessible name of the field that OPENS this panel (`DateRangeFilterField`). */
|
|
42
40
|
selectDateRange: string;
|
|
41
|
+
/** Stands in for a bound that has no date yet — the open half of a range. */
|
|
43
42
|
selectDate: string;
|
|
44
43
|
}
|
|
45
44
|
|
|
@@ -83,6 +82,46 @@ function formatDateDisplay(date: Date | null, locale: string | undefined): strin
|
|
|
83
82
|
return formatDate(date, { format: "medium", locale });
|
|
84
83
|
}
|
|
85
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One end of the range: its name, its date (or a placeholder while it is still
|
|
87
|
+
* open), and — under `includeTime` — its time. The time field is rendered
|
|
88
|
+
* disabled rather than omitted while the bound has no date, so picking a date
|
|
89
|
+
* never shifts the panel under the pointer.
|
|
90
|
+
*/
|
|
91
|
+
function RangeBound(props: {
|
|
92
|
+
label: string;
|
|
93
|
+
placeholder: string;
|
|
94
|
+
bound: { date: Date | null; time: string | null };
|
|
95
|
+
includeTime: boolean;
|
|
96
|
+
localeTag: string;
|
|
97
|
+
segmentLabels: SegmentLabels;
|
|
98
|
+
onTimeChange: (time: string) => void;
|
|
99
|
+
}) {
|
|
100
|
+
const { label, placeholder, bound, includeTime, localeTag, segmentLabels, onTimeChange } = props;
|
|
101
|
+
return (
|
|
102
|
+
<View style={styles.rangeItem}>
|
|
103
|
+
<Text size="xs" color="zinc-500">
|
|
104
|
+
{label}
|
|
105
|
+
</Text>
|
|
106
|
+
<Text size="sm" weight="medium" color={bound.date ? "default" : "muted"}>
|
|
107
|
+
{bound.date ? formatDateDisplay(bound.date, localeTag) : placeholder}
|
|
108
|
+
</Text>
|
|
109
|
+
{includeTime && (
|
|
110
|
+
<View style={{ marginTop: 4 }}>
|
|
111
|
+
<TimePicker
|
|
112
|
+
value={bound.time || ""}
|
|
113
|
+
onValueChange={onTimeChange}
|
|
114
|
+
disabled={!bound.date}
|
|
115
|
+
accessibilityLabel={label}
|
|
116
|
+
locale={localeTag}
|
|
117
|
+
segmentLabels={segmentLabels}
|
|
118
|
+
/>
|
|
119
|
+
</View>
|
|
120
|
+
)}
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
// =============================================================================
|
|
87
126
|
// Main Component
|
|
88
127
|
// =============================================================================
|
|
@@ -97,52 +136,46 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
97
136
|
);
|
|
98
137
|
const screenSize = useScreenSize();
|
|
99
138
|
const calendarRef = useRef<CalendarRef>(null);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
139
|
+
|
|
140
|
+
// A half-picked range is held HERE and never emitted — the same rule
|
|
141
|
+
// `DatePickerPanel` follows. Emitting it would filter on an open-ended range
|
|
142
|
+
// the user never asked for (everything from that day onward) the moment they
|
|
143
|
+
// click a start, which reads as "filtered to that day" while being nothing of
|
|
144
|
+
// the kind. The panel still shows the pending bound; the filter does not move
|
|
145
|
+
// until the range is closed. The popover unmounts on close, so this starts
|
|
146
|
+
// fresh each session.
|
|
147
|
+
const [pendingStart, setPendingStart] = useState<Date | null>(null);
|
|
148
|
+
|
|
149
|
+
/** What the panel DISPLAYS: the pending pick if there is one, else the value. */
|
|
150
|
+
const shown = useMemo<DateFilterValue>(
|
|
151
|
+
() =>
|
|
152
|
+
pendingStart
|
|
153
|
+
? { start: { date: pendingStart, time: value.start.time }, end: { date: null, time: value.end.time } }
|
|
154
|
+
: value,
|
|
155
|
+
[pendingStart, value],
|
|
156
|
+
);
|
|
108
157
|
|
|
109
158
|
const calendarValue: CalendarRangeValue = useMemo(
|
|
110
|
-
() => ({ start:
|
|
111
|
-
[
|
|
159
|
+
() => ({ start: shown.start.date, end: shown.end.date }),
|
|
160
|
+
[shown.start.date, shown.end.date],
|
|
112
161
|
);
|
|
113
162
|
|
|
163
|
+
// The calendar owns which bound a click lands on (see `nextRangeSelection`);
|
|
164
|
+
// this decides whether that lands in the value or stays pending, and carries
|
|
165
|
+
// the times across, since a click never changes them.
|
|
114
166
|
const handleCalendarChange = useCallback(
|
|
115
167
|
(newValue: CalendarRangeValue) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
onValueChange({
|
|
119
|
-
start: { date: newValue.start, time: value.start.time },
|
|
120
|
-
end: { date: newValue.start, time: value.end.time },
|
|
121
|
-
});
|
|
168
|
+
if (newValue.start && !newValue.end) {
|
|
169
|
+
setPendingStart(newValue.start);
|
|
122
170
|
return;
|
|
123
171
|
}
|
|
172
|
+
setPendingStart(null);
|
|
124
173
|
onValueChange({
|
|
125
174
|
start: { date: newValue.start, time: value.start.time },
|
|
126
175
|
end: { date: newValue.end, time: value.end.time },
|
|
127
176
|
});
|
|
128
177
|
},
|
|
129
|
-
[onValueChange, value.start.time, value.end.time
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const handleModeChange = useCallback(
|
|
133
|
-
(isRange: boolean) => {
|
|
134
|
-
const newMode: SelectionMode = isRange ? "range" : "single";
|
|
135
|
-
setSelectionMode(newMode);
|
|
136
|
-
|
|
137
|
-
// Switching to single: collapse range to start date
|
|
138
|
-
if (newMode === "single" && value.start.date) {
|
|
139
|
-
onValueChange({
|
|
140
|
-
start: value.start,
|
|
141
|
-
end: { date: value.start.date, time: value.start.time },
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
},
|
|
145
|
-
[value, onValueChange],
|
|
178
|
+
[onValueChange, value.start.time, value.end.time],
|
|
146
179
|
);
|
|
147
180
|
|
|
148
181
|
const handleStartTimeChange = useCallback(
|
|
@@ -178,6 +211,8 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
178
211
|
|
|
179
212
|
const handlePresetSelect = useCallback(
|
|
180
213
|
(id: PresetId) => {
|
|
214
|
+
// A preset replaces the whole range, so any half-picked start is abandoned.
|
|
215
|
+
setPendingStart(null);
|
|
181
216
|
// Re-clicking the active preset clears the filter
|
|
182
217
|
if (activePresetId === id) {
|
|
183
218
|
onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
|
|
@@ -191,13 +226,6 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
191
226
|
return;
|
|
192
227
|
}
|
|
193
228
|
|
|
194
|
-
// Auto-switch to range mode for multi-day presets
|
|
195
|
-
if (presetValue.start.date && presetValue.end.date) {
|
|
196
|
-
const isSingleDay =
|
|
197
|
-
presetValue.start.date.toDateString() === presetValue.end.date.toDateString();
|
|
198
|
-
if (!isSingleDay && selectionMode === "single") setSelectionMode("range");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
229
|
onValueChange(presetValue);
|
|
202
230
|
|
|
203
231
|
if (presetValue.start.date) {
|
|
@@ -207,7 +235,7 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
207
235
|
);
|
|
208
236
|
}
|
|
209
237
|
},
|
|
210
|
-
[onValueChange, activePresetId
|
|
238
|
+
[onValueChange, activePresetId],
|
|
211
239
|
);
|
|
212
240
|
|
|
213
241
|
const renderPreset = (id: PresetId) => {
|
|
@@ -243,56 +271,28 @@ export function DateFilter(props: DateFilterProps) {
|
|
|
243
271
|
locale={localeTag}
|
|
244
272
|
/>
|
|
245
273
|
|
|
246
|
-
{
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
{value.end.date ? (
|
|
269
|
-
<View style={styles.rangeItem}>
|
|
270
|
-
<Text size="xs" color="zinc-500">
|
|
271
|
-
{labels.to}
|
|
272
|
-
</Text>
|
|
273
|
-
<Text size="sm" weight="medium">
|
|
274
|
-
{formatDateDisplay(value.end.date, localeTag) || labels.selectDate}
|
|
275
|
-
</Text>
|
|
276
|
-
{includeTime && value.end.date && (
|
|
277
|
-
<View style={{ marginTop: 4 }}>
|
|
278
|
-
<TimePicker
|
|
279
|
-
value={value.end.time || ""}
|
|
280
|
-
onValueChange={handleEndTimeChange}
|
|
281
|
-
/>
|
|
282
|
-
</View>
|
|
283
|
-
)}
|
|
284
|
-
</View>
|
|
285
|
-
) : (
|
|
286
|
-
<View style={styles.rangeItem} />
|
|
287
|
-
)}
|
|
288
|
-
</View>
|
|
289
|
-
) : null}
|
|
290
|
-
|
|
291
|
-
<View style={styles.modeToggle}>
|
|
292
|
-
<Switch value={selectionMode === "range"} onChange={handleModeChange} />
|
|
293
|
-
<Text size="sm" color="muted">
|
|
294
|
-
{labels.selectDateRange}
|
|
295
|
-
</Text>
|
|
274
|
+
{/* Both bounds are ALWAYS shown: they are what tells the user a range is
|
|
275
|
+
half-picked and which end is still open. Hiding the empty one (or the
|
|
276
|
+
whole row) is how a click stops explaining itself. */}
|
|
277
|
+
<View style={styles.rangeDisplay}>
|
|
278
|
+
<RangeBound
|
|
279
|
+
label={labels.from}
|
|
280
|
+
placeholder={labels.selectDate}
|
|
281
|
+
bound={shown.start}
|
|
282
|
+
includeTime={includeTime}
|
|
283
|
+
localeTag={localeTag}
|
|
284
|
+
segmentLabels={labels}
|
|
285
|
+
onTimeChange={handleStartTimeChange}
|
|
286
|
+
/>
|
|
287
|
+
<RangeBound
|
|
288
|
+
label={labels.to}
|
|
289
|
+
placeholder={labels.selectDate}
|
|
290
|
+
bound={shown.end}
|
|
291
|
+
includeTime={includeTime}
|
|
292
|
+
localeTag={localeTag}
|
|
293
|
+
segmentLabels={labels}
|
|
294
|
+
onTimeChange={handleEndTimeChange}
|
|
295
|
+
/>
|
|
296
296
|
</View>
|
|
297
297
|
</View>
|
|
298
298
|
|
|
@@ -343,18 +343,11 @@ const styles = StyleSheet.create({
|
|
|
343
343
|
mainContent: {
|
|
344
344
|
flex: 1,
|
|
345
345
|
},
|
|
346
|
-
modeToggle: {
|
|
347
|
-
flexDirection: "row",
|
|
348
|
-
alignItems: "center",
|
|
349
|
-
gap: 8,
|
|
350
|
-
paddingTop: 8,
|
|
351
|
-
paddingBottom: 8,
|
|
352
|
-
paddingLeft: 8,
|
|
353
|
-
},
|
|
354
346
|
rangeDisplay: {
|
|
355
347
|
flexDirection: "row",
|
|
356
348
|
gap: 16,
|
|
357
349
|
paddingTop: 8,
|
|
350
|
+
paddingBottom: 8,
|
|
358
351
|
},
|
|
359
352
|
rangeItem: {
|
|
360
353
|
flex: 1,
|
package/src/date_picker.tsx
CHANGED
|
@@ -56,6 +56,8 @@ export interface DatePickerLabels extends SegmentLabels {
|
|
|
56
56
|
done: string;
|
|
57
57
|
/** Inline error when a typed date is left incomplete/invalid (never committed). */
|
|
58
58
|
invalidDate: string;
|
|
59
|
+
/** Inline error when a typed time is left half-entered (never committed). */
|
|
60
|
+
invalidTime: string;
|
|
59
61
|
/** Quick action: add a time to a date-only value (`optionalTime`). */
|
|
60
62
|
addTime: string;
|
|
61
63
|
/** Accessible name + tooltip: drop the time, keeping the date (`optionalTime`). */
|
|
@@ -228,6 +230,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
228
230
|
accessibilityLabel={mergedLabels.startTime}
|
|
229
231
|
value={timeText(startIso)}
|
|
230
232
|
onValueChange={(t) => handleRangeTime("start", t)}
|
|
233
|
+
locale={locale}
|
|
231
234
|
/>
|
|
232
235
|
</View>
|
|
233
236
|
<View style={styles.timeCol}>
|
|
@@ -238,6 +241,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
238
241
|
accessibilityLabel={mergedLabels.endTime}
|
|
239
242
|
value={timeText(endIso)}
|
|
240
243
|
onValueChange={(t) => handleRangeTime("end", t)}
|
|
244
|
+
locale={locale}
|
|
241
245
|
/>
|
|
242
246
|
</View>
|
|
243
247
|
</>
|
|
@@ -261,6 +265,7 @@ export function DatePickerPanel(props: DatePickerPanelProps) {
|
|
|
261
265
|
accessibilityLabel={mergedLabels.time}
|
|
262
266
|
value={timeText(value ?? "")}
|
|
263
267
|
onValueChange={handleSingleTime}
|
|
268
|
+
locale={locale}
|
|
264
269
|
/>
|
|
265
270
|
</View>
|
|
266
271
|
)}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { nextRangeSelection, type DateRangeSelection } from "./date_range_selection";
|
|
3
|
+
|
|
4
|
+
const d = (day: number): Date => new Date(2026, 6, day);
|
|
5
|
+
const ymd = (x: Date | null): string =>
|
|
6
|
+
x ? `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}-${String(x.getDate()).padStart(2, "0")}` : "null";
|
|
7
|
+
const EMPTY: DateRangeSelection = { start: null, end: null };
|
|
8
|
+
|
|
9
|
+
describe("nextRangeSelection", () => {
|
|
10
|
+
it("opens a range on the first click", () => {
|
|
11
|
+
const next = nextRangeSelection(EMPTY, d(14));
|
|
12
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
13
|
+
expect(next.end).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("closes the open range on the second click", () => {
|
|
17
|
+
const next = nextRangeSelection({ start: d(14), end: null }, d(20));
|
|
18
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
19
|
+
expect(ymd(next.end)).toBe("2026-07-20");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("orders the bounds when the later day is clicked first", () => {
|
|
23
|
+
const next = nextRangeSelection({ start: d(20), end: null }, d(14));
|
|
24
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
25
|
+
expect(ymd(next.end)).toBe("2026-07-20");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// The whole point of dropping the single/range mode: a range must be
|
|
29
|
+
// reachable from EVERY starting value, including one that is already a
|
|
30
|
+
// single day. Under the mode, a single-day value silently disabled range
|
|
31
|
+
// selection entirely — two clicks just moved the one day around.
|
|
32
|
+
it("reaches a range from a single-day value", () => {
|
|
33
|
+
const singleDay: DateRangeSelection = { start: d(31), end: d(31) };
|
|
34
|
+
const opened = nextRangeSelection(singleDay, d(1));
|
|
35
|
+
const closed = nextRangeSelection(opened, d(20));
|
|
36
|
+
expect(ymd(closed.start)).toBe("2026-07-01");
|
|
37
|
+
expect(ymd(closed.end)).toBe("2026-07-20");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("reaches a range from a closed multi-day range", () => {
|
|
41
|
+
const opened = nextRangeSelection({ start: d(1), end: d(5) }, d(10));
|
|
42
|
+
expect(ymd(opened.start)).toBe("2026-07-10");
|
|
43
|
+
expect(opened.end).toBeNull();
|
|
44
|
+
expect(ymd(nextRangeSelection(opened, d(12)).end)).toBe("2026-07-12");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Selecting one day is clicking it twice — the replacement for the mode.
|
|
48
|
+
it("selects a single day when the same day is clicked twice", () => {
|
|
49
|
+
const next = nextRangeSelection(nextRangeSelection(EMPTY, d(14)), d(14));
|
|
50
|
+
expect(ymd(next.start)).toBe("2026-07-14");
|
|
51
|
+
expect(ymd(next.end)).toBe("2026-07-14");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("never mutates the selection it is given", () => {
|
|
55
|
+
const current: DateRangeSelection = { start: d(14), end: null };
|
|
56
|
+
nextRangeSelection(current, d(20));
|
|
57
|
+
expect(ymd(current.start)).toBe("2026-07-14");
|
|
58
|
+
expect(current.end).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Pure click→range math for the range calendar. Kept free of React/RN so the
|
|
2
|
+
// selection contract can be unit-tested directly rather than through a
|
|
3
|
+
// component.
|
|
4
|
+
|
|
5
|
+
/** The two bounds a range calendar edits. `end` is null while a range is open. */
|
|
6
|
+
export interface DateRangeSelection {
|
|
7
|
+
start: Date | null;
|
|
8
|
+
end: Date | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The selection after clicking `date`.
|
|
13
|
+
*
|
|
14
|
+
* One rule, no modes: a click either OPENS a range (when none is open) or
|
|
15
|
+
* CLOSES the open one. Selecting a single day is clicking the same day twice —
|
|
16
|
+
* there is deliberately no separate single-day mode, because a mode that
|
|
17
|
+
* changes what a click means is a mode the user can get stuck in with no way
|
|
18
|
+
* to express a range at all.
|
|
19
|
+
*
|
|
20
|
+
* Bounds come back ordered, so clicking the later day first is just another way
|
|
21
|
+
* to select rather than an error the caller has to undo.
|
|
22
|
+
*/
|
|
23
|
+
export function nextRangeSelection(
|
|
24
|
+
current: DateRangeSelection,
|
|
25
|
+
date: Date,
|
|
26
|
+
): DateRangeSelection {
|
|
27
|
+
// Nothing picked yet, or the last range is already closed → open a new one.
|
|
28
|
+
if (!current.start || current.end) return { start: date, end: null };
|
|
29
|
+
return date < current.start
|
|
30
|
+
? { start: date, end: current.start }
|
|
31
|
+
: { start: current.start, end: date };
|
|
32
|
+
}
|
|
@@ -3,6 +3,8 @@ import { resolveLocaleTag, en as enLocale, vi as viLocale } from "./locale";
|
|
|
3
3
|
import {
|
|
4
4
|
getDateLayout,
|
|
5
5
|
getTimeLayout,
|
|
6
|
+
timeSegmentsConfig,
|
|
7
|
+
segmentsToTime,
|
|
6
8
|
fieldOrder,
|
|
7
9
|
to12h,
|
|
8
10
|
from12h,
|
|
@@ -285,3 +287,49 @@ describe("getTimeLayout", () => {
|
|
|
285
287
|
expect(vn.hour12).toBe(false);
|
|
286
288
|
});
|
|
287
289
|
});
|
|
290
|
+
|
|
291
|
+
// -----------------------------------------------------------------------------
|
|
292
|
+
// Time-only field. The point of the segmented time field is that 12/24-hour is
|
|
293
|
+
// OUR locale's decision — a native `<input type="time">` takes it from the
|
|
294
|
+
// browser's UI locale and ignores `lang`, so a Vietnamese screen on an en-US
|
|
295
|
+
// browser rendered "01:45 PM" beside its own 24-hour text.
|
|
296
|
+
// -----------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
describe("timeSegmentsConfig", () => {
|
|
299
|
+
it("derives 24-hour display from the locale, not the runtime", () => {
|
|
300
|
+
expect(timeSegmentsConfig("vi-VN").layout.hour12).toBe(false);
|
|
301
|
+
expect(timeSegmentsConfig("de-DE").layout.hour12).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("derives 12-hour display where the locale uses one", () => {
|
|
305
|
+
const layout = timeSegmentsConfig("en-US").layout;
|
|
306
|
+
expect(layout.hour12).toBe(true);
|
|
307
|
+
expect(layout.segments.some((s) => s.kind === "field" && s.type === "dayPeriod")).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("keeps the value canonical 24-hour under a 12-hour locale", () => {
|
|
311
|
+
const cfg = timeSegmentsConfig("en-US");
|
|
312
|
+
expect(cfg.toValue(cfg.toBuffer("13:45"))).toBe("13:45");
|
|
313
|
+
expect(cfg.toValue(cfg.toBuffer("00:30"))).toBe("00:30");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("round-trips every hour of the day", () => {
|
|
317
|
+
const cfg = timeSegmentsConfig("en-US");
|
|
318
|
+
for (let h = 0; h < 24; h++) {
|
|
319
|
+
const iso = `${String(h).padStart(2, "0")}:15`;
|
|
320
|
+
expect(cfg.toValue(cfg.toBuffer(iso))).toBe(iso);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("reads an empty or unparseable time as empty", () => {
|
|
325
|
+
const cfg = timeSegmentsConfig("vi-VN");
|
|
326
|
+
for (const bad of ["", "banana", "25:00", "12:99"]) {
|
|
327
|
+
expect(cfg.isEmpty(cfg.toBuffer(bad))).toBe(true);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("emits nothing while only half the time is typed", () => {
|
|
332
|
+
expect(segmentsToTime({ year: null, month: null, day: null, hour: 13, minute: null })).toBeNull();
|
|
333
|
+
expect(segmentsToTime({ year: null, month: null, day: null, hour: null, minute: 45 })).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
});
|
package/src/date_segments.ts
CHANGED
|
@@ -356,3 +356,37 @@ export function dateSegmentsConfig(locale: string, hasTime: boolean): SegmentsCo
|
|
|
356
356
|
parse: (text) => parseText(text, hasTime),
|
|
357
357
|
};
|
|
358
358
|
}
|
|
359
|
+
|
|
360
|
+
// -----------------------------------------------------------------------------
|
|
361
|
+
// Time-only field — same engine, no date part. The canonical value is 24-hour
|
|
362
|
+
// "HH:mm" REGARDLESS of how the locale displays it: `hour12` decides whether the
|
|
363
|
+
// user sees "01:45 PM" or "13:45", never what is stored or emitted.
|
|
364
|
+
// -----------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
const TIME_RE = /^(\d{1,2}):(\d{2})/;
|
|
367
|
+
|
|
368
|
+
/** "HH:mm" → buffer. Anything unparseable or out of range reads as empty. */
|
|
369
|
+
export function timeToSegments(time: string): SegmentBuffer {
|
|
370
|
+
const m = TIME_RE.exec(time);
|
|
371
|
+
if (!m) return emptyBuffer();
|
|
372
|
+
const hour = Number(m[1]);
|
|
373
|
+
const minute = Number(m[2]);
|
|
374
|
+
if (hour > 23 || minute > 59) return emptyBuffer();
|
|
375
|
+
return { year: null, month: null, day: null, hour, minute };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Buffer → canonical "HH:mm", or null while either half is still missing. */
|
|
379
|
+
export function segmentsToTime(buffer: SegmentBuffer): string | null {
|
|
380
|
+
if (buffer.hour == null || buffer.minute == null) return null;
|
|
381
|
+
return `${pad(buffer.hour)}:${pad(buffer.minute)}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function timeSegmentsConfig(locale: string): SegmentsConfig {
|
|
385
|
+
return {
|
|
386
|
+
layout: getTimeLayout(locale),
|
|
387
|
+
toBuffer: timeToSegments,
|
|
388
|
+
toValue: segmentsToTime,
|
|
389
|
+
isEmpty: (buffer) => buffer.hour == null && buffer.minute == null,
|
|
390
|
+
parse: (text) => segmentsToTime(timeToSegments(text.trim())),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import type { KeyboardEvent } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
3
2
|
import { Icon } from "./icon";
|
|
4
3
|
import { colors } from "./colors";
|
|
5
4
|
import { TimePicker } from "./time_picker";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
6
6
|
import { type InlineEditVariant, InlineEditFrame, useInlineEdit, type InlineEditControls } from "./inline_edit";
|
|
7
7
|
|
|
8
8
|
export interface InlineTimePickerProps {
|
|
@@ -27,17 +27,15 @@ export interface InlineTimePickerProps {
|
|
|
27
27
|
export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
28
28
|
const { value, onSave, placeholder, controls = "blur", disabled, accessibilityLabel , variant } = props;
|
|
29
29
|
const edit = useInlineEdit<string>({ value, onSave });
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
[edit],
|
|
37
|
-
);
|
|
30
|
+
const dateLabels = useLoticsLocale().datePicker;
|
|
31
|
+
// Segments emit ONLY on a complete "HH:mm", so a half-typed entry (an hour and
|
|
32
|
+
// no minute) reaches the draft as "" and would otherwise commit as a silent
|
|
33
|
+
// clear. Blocking on it keeps the entry fixable instead of wiping the value.
|
|
34
|
+
const incomplete = useRef(false);
|
|
38
35
|
|
|
39
36
|
const onBlur = useCallback(() => {
|
|
40
37
|
if (controls === "buttons") return;
|
|
38
|
+
if (incomplete.current) return;
|
|
41
39
|
void edit.commit();
|
|
42
40
|
}, [controls, edit]);
|
|
43
41
|
|
|
@@ -52,7 +50,7 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
|
52
50
|
onCommit={() => void edit.commit()}
|
|
53
51
|
onCancel={edit.cancel}
|
|
54
52
|
saving={edit.saving}
|
|
55
|
-
error={edit.error}
|
|
53
|
+
error={edit.error ?? (incomplete.current ? dateLabels.invalidTime : null)}
|
|
56
54
|
disabled={disabled}
|
|
57
55
|
accessibilityLabel={accessibilityLabel}
|
|
58
56
|
affordance={<Icon name="clock" size={18} color={colors.zinc[400]} />}
|
|
@@ -61,7 +59,8 @@ export function InlineTimePicker(props: InlineTimePickerProps) {
|
|
|
61
59
|
value={edit.draft}
|
|
62
60
|
onValueChange={edit.setDraft}
|
|
63
61
|
onBlur={onBlur}
|
|
64
|
-
|
|
62
|
+
onEscape={edit.cancel}
|
|
63
|
+
onIncompleteChange={(next) => { incomplete.current = next; }}
|
|
65
64
|
autoFocus
|
|
66
65
|
accessibilityLabel={accessibilityLabel}
|
|
67
66
|
/>
|