@lotics/ui 17.0.1 → 18.0.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 CHANGED
@@ -4,6 +4,26 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## v18 from 17.x
8
+
9
+ **`TimePicker` is a segmented field, not `<input type="time">`.** A native time input takes
10
+ its 12- vs 24-hour form from the BROWSER's UI locale and ignores the `lang` attribute
11
+ entirely, so a Vietnamese screen on an en-US browser rendered "01:45 PM" beside its own
12
+ 24-hour text. It now uses the same segment engine as `DateField`, with the format derived
13
+ from a `locale` prop (defaulting to the active `LoticsLocaleProvider` locale).
14
+
15
+ The value is unchanged — canonical 24-hour `"HH:mm"` either way. Two prop changes:
16
+
17
+ - **`onKeyDown` is GONE.** It typed the DOM input's key events, which no longer exist. Use
18
+ `onEscape` for cancel; a completed entry emits through `onValueChange` as before.
19
+ - **`onIncompleteChange` is NEW** and matters for inline editors: segments emit only a
20
+ COMPLETE `"HH:mm"`, so a half-typed entry (an hour, no minute) arrives as `""` and would
21
+ otherwise commit as a silent clear. Block the commit on it — `InlineTimePicker` does, and
22
+ surfaces the new `datePicker.invalidTime` label.
23
+
24
+ `DatePickerLabels` gains **`invalidTime`**; a caller that builds the label bag by hand (rather
25
+ than spreading the locale pack) must add it.
26
+
7
27
  ## v17 from 16.x
8
28
 
9
29
  **`Checklist` / `ChecklistRow` are DELETED.** They are replaced by the `Task` compound
package/docs/catalog.md CHANGED
@@ -600,11 +600,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
600
600
  localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday),
601
601
  `ref.navigateToMonth`. The engine `DatePicker`/`DateFilter` wrap in field chrome — reach
602
602
  for it bare only when the calendar lives permanently on the surface, not behind a field.
603
- - **`date_filter`** — `DateFilter`: the single/range date+time filter panel — presets
603
+ - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
604
604
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
605
+ **One selection rule, no modes**: a click OPENS a range, the next CLOSES it (in either
606
+ order — bounds come back sorted), and a single day is the same day clicked twice
607
+ (`start`/`end` equal — which is how a consumer detects "one day" and how the trigger
608
+ renders it as a single date). Both bounds always show, so a half-picked range names the
609
+ end that is still open.
605
610
  - **`date_range_filter_field`** — `DateRangeFilterField`: the register's period filter field
606
611
  (presets + footer + time-segment a11y; localized via the `dateRange` locale slice).
607
- - **`time_picker`** — `TimePicker`: the time-of-day input.
612
+ - **`time_picker`** — `TimePicker`: the time-of-day field. Segments (hour / minute / AM-PM),
613
+ **12- vs 24-hour derived from `locale`** — never from the browser, which is why this is not
614
+ an `<input type="time">`. The value is always canonical 24-hour `"HH:mm"`; `locale` changes
615
+ only what is displayed and typed.
608
616
 
609
617
  ### Inline editing & record surfaces
610
618
 
package/docs/templates.md CHANGED
@@ -355,9 +355,11 @@ billing, and quick-capture templates. Top → bottom:
355
355
  HOME stays their DATA section (the colocation law) with descriptions naming their
356
356
  consumers ("printed on the delivery note"). The picker rows ride `TaskList`/`TaskItem`
357
357
  (`controlWidth={24}` for the `CheckboxInput` in `TaskStatus`) — the compound owns the row
358
- geometry and the indent; hand-rolling the anatomy is how alignment drifts. Warning and fix are CO-LOCATED on the row. The mark NAMES what's missing (the
359
- `TaskFields` line: 13px `circle-alert` + "Needs: …" the actual field labels — muted at rest so
360
- gaps show WITHOUT checking, warning once checked because it now blocks). The FIX sits
358
+ geometry and the indent; hand-rolling the anatomy is how alignment drifts. Warning and fix are CO-LOCATED on the row. The mark NAMES what's missing — a
359
+ **`TaskCaption`**, not a field cell: 13px `circle-alert` + "Needs: …" the actual field labels,
360
+ muted at rest so gaps show WITHOUT checking, warning once checked because it now blocks. It is
361
+ a SENTENCE about the row, and `TaskFields` is the column the reader edits and scans down, so
362
+ prose put there lines up with nothing and reads as a control it is not. The FIX sits
361
363
  right under it in the row's `TaskDetail` — an `Inset` (a FORM surface, NEVER a `Callout`:
362
364
  a warning Callout is an ARIA `alert`, wrong around a form) with a `FormTextInput` per gap
363
365
  (label + the consumer hint as `description`) and a "Save fields" (secondary — the section
@@ -32,7 +32,7 @@ import { CheckCircle } from "@lotics/ui/check_circle";
32
32
  import { ProgressBar } from "@lotics/ui/progress_bar";
33
33
  import { FilterChip } from "@lotics/ui/filter_chip";
34
34
  import { CaptureRow } from "@lotics/ui/capture_row";
35
- import { TaskActions, TaskDetail, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
35
+ import { TaskActions, TaskCaption, TaskDetail, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle, TASK_TEXT_INSET } from "@lotics/ui/task";
36
36
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
37
37
  import { SuggestionChip } from "@lotics/ui/suggestion_chip";
38
38
  import { OptionList } from "@lotics/ui/option_list";
@@ -1900,7 +1900,10 @@ export function TplRecord() {
1900
1900
  <CheckCircle state={sub.done ? "done" : "none"} onChange={(on) => toggleSubtask(t.id, sub.key, on)} accessibilityLabel={sub.label} />
1901
1901
  </TaskStatus>
1902
1902
  <TaskTitle>
1903
- <Text size="sm" color={sub.done ? "muted" : "default"} decoration={sub.done ? "lineThrough" : undefined}>{sub.label}</Text>
1903
+ {/* The parent's title is an inline editor, which insets its
1904
+ own text; a plain `Text` child must add the same or the
1905
+ two columns of titles do not line up. */}
1906
+ <Text size="sm" color={sub.done ? "muted" : "default"} decoration={sub.done ? "lineThrough" : undefined} style={{ paddingHorizontal: TASK_TEXT_INSET }}>{sub.label}</Text>
1904
1907
  </TaskTitle>
1905
1908
  </TaskItem>
1906
1909
  ))}
@@ -2261,15 +2264,23 @@ export function TplRecord() {
2261
2264
  <CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />
2262
2265
  </TaskStatus>
2263
2266
  <TaskTitle>
2264
- <Text size="sm" style={{ flexShrink: 1 }}>{f.label}</Text>
2267
+ {/* A plain-`Text` title carries the cell inset itself, or it sits
2268
+ out of line with the editable titles elsewhere on the page and
2269
+ with its own caption below. */}
2270
+ <Text size="sm" style={{ flexShrink: 1, paddingHorizontal: TASK_TEXT_INSET }}>{f.label}</Text>
2265
2271
  </TaskTitle>
2272
+ {/* "Needs: Tax ID, Delivery address" is a SENTENCE about this row, so it
2273
+ is a `TaskCaption` — its own line under the title. It was a
2274
+ `TaskFields` cell, which is the column the reader EDITS and scans
2275
+ down: prose there lines up with nothing, squeezes the cells beside
2276
+ it, and reads as a control it is not. */}
2266
2277
  {missing.length > 0 ? (
2267
- <TaskFields>
2268
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
2269
- <Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
2270
- <Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
2271
- </View>
2272
- </TaskFields>
2278
+ <TaskCaption>
2279
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>
2280
+ <Icon name="circle-alert" size={13} color={checked ? colors.amber[500] : colors.zinc[400]} />
2281
+ <Text size="xs" color={checked ? "warning" : "muted"} style={{ flexShrink: 1 }}>{`Needs: ${missing.map((k) => NEEDS[k].label).join(", ")}`}</Text>
2282
+ </View>
2283
+ </TaskCaption>
2273
2284
  ) : null}
2274
2285
  {checked && missing.length > 0 ? (
2275
2286
  <TaskDetail>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "17.0.1",
3
+ "version": "18.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -8,6 +8,7 @@ import { IconButton } from "./icon_button";
8
8
  import { FOCUS_RING } from "./control_surface";
9
9
  import { useFocusRing } from "./use_focus_ring";
10
10
  import { useLoticsLocale, useLocaleTag } from "./locale";
11
+ import { nextRangeSelection } from "./date_range_selection";
11
12
 
12
13
  /** Accessible names for the calendar's month-navigation arrows. Backs the
13
14
  * `calendar` locale slice. (The month/weekday NAMES come from `Intl` + the
@@ -529,28 +530,14 @@ function RangeCalendar(props: RangeCalendarInternalProps) {
529
530
  const rightYear = leftMonth === 11 ? leftYear + 1 : leftYear;
530
531
  const rightMonth = leftMonth === 11 ? 0 : leftMonth + 1;
531
532
 
532
- // Track which part of range we're selecting
533
- const [selectingEnd, setSelectingEnd] = useState(false);
534
-
533
+ // Which half of the range a click lands on is READ from the value (a range is
534
+ // open exactly when it has a start and no end), never tracked alongside it —
535
+ // a separate flag can disagree with the value the parent actually holds.
535
536
  const handleDateSelect = useCallback(
536
537
  (date: Date) => {
537
- if (!value.start || (value.start && value.end) || !selectingEnd) {
538
- // Start new selection
539
- onValueChange({ start: date, end: null });
540
- setSelectingEnd(true);
541
- } else {
542
- // Complete selection
543
- const start = value.start;
544
- // Ensure start is before end
545
- if (date < start) {
546
- onValueChange({ start: date, end: start });
547
- } else {
548
- onValueChange({ start, end: date });
549
- }
550
- setSelectingEnd(false);
551
- }
538
+ onValueChange(nextRangeSelection(value, date));
552
539
  },
553
- [value, onValueChange, selectingEnd],
540
+ [value, onValueChange],
554
541
  );
555
542
 
556
543
  const handleMonthChange = useCallback((year: number, month: number) => {
@@ -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
- const [selectionMode, setSelectionMode] = useState<SelectionMode>(() => {
101
- if (value.start.date && value.end.date) {
102
- if (value.start.date.toDateString() !== value.end.date.toDateString()) {
103
- return "range";
104
- }
105
- }
106
- return "single";
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: value.start.date, end: value.end.date }),
111
- [value.start.date, value.end.date],
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
- // In single mode, one click selects a single date immediately
117
- if (selectionMode === "single" && newValue.start && !newValue.end) {
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, selectionMode],
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, selectionMode],
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
- {selectionMode === "range" ? (
247
- <View style={styles.rangeDisplay}>
248
- {value.start.date ? (
249
- <View style={styles.rangeItem}>
250
- <Text size="xs" color="zinc-500">
251
- {labels.from}
252
- </Text>
253
- <Text size="sm" weight="medium">
254
- {formatDateDisplay(value.start.date, localeTag) || labels.selectDate}
255
- </Text>
256
- {includeTime && (
257
- <View style={{ marginTop: 4 }}>
258
- <TimePicker
259
- value={value.start.time || ""}
260
- onValueChange={handleStartTimeChange}
261
- />
262
- </View>
263
- )}
264
- </View>
265
- ) : (
266
- <View style={styles.rangeItem} />
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,
@@ -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
+ });
@@ -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
- const onKeyDown = useCallback(
32
- (e: KeyboardEvent<HTMLInputElement>) => {
33
- if (e.key === "Escape") edit.cancel();
34
- else if (e.key === "Enter") void edit.commit();
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
- onKeyDown={onKeyDown}
62
+ onEscape={edit.cancel}
63
+ onIncompleteChange={(next) => { incomplete.current = next; }}
65
64
  autoFocus
66
65
  accessibilityLabel={accessibilityLabel}
67
66
  />
package/src/locale.tsx CHANGED
@@ -162,7 +162,7 @@ export const en: LoticsLocale = {
162
162
  descending: ", descending",
163
163
  },
164
164
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
165
- datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
165
+ datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
166
166
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
167
167
  filterChip: { clear: "Clear" },
168
168
  floatingActionBar: { clear: "Clear" },
@@ -260,7 +260,7 @@ export const vi: LoticsLocale = {
260
260
  descending: " (giảm dần)",
261
261
  },
262
262
  optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
263
- datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ" },
263
+ datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
264
264
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
265
265
  filterChip: { clear: "Xóa" },
266
266
  floatingActionBar: { clear: "Bỏ chọn" },
@@ -1,56 +1,114 @@
1
- import type { KeyboardEvent } from "react";
2
- import { colors } from "@lotics/ui/colors";
3
- import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
1
+ import { useMemo, useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
4
  import { CONTROL_RADIUS, FOCUS_RING, HOVER_BORDER } from "./control_surface";
5
- import { useFocusRing } from "./use_focus_ring";
6
5
  import { useHover } from "./use_hover";
6
+ import { DateSegments } from "./date_segments_field";
7
+ import { SegmentLabels, timeSegmentsConfig } from "./date_segments";
8
+ import { useLoticsLocale, useLocaleTag } from "./locale";
9
+
7
10
  export interface TimePickerProps {
11
+ /** Canonical 24-hour "HH:mm", `""` when empty — independent of how it displays. */
8
12
  value?: string;
9
13
  onValueChange: (value: string) => void;
10
14
  onBlur?: () => void;
11
- /** Web key handler — e.g. an inline editor committing on Enter / reverting on Escape. */
12
- onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
13
15
  autoFocus?: boolean;
14
16
  disabled?: boolean;
15
17
  accessibilityLabel?: string;
18
+ /**
19
+ * BCP-47 locale deciding 12- vs 24-hour DISPLAY. Defaults to the active
20
+ * `LoticsLocaleProvider` locale.
21
+ *
22
+ * This is the whole reason the field is segments and not `<input type="time">`:
23
+ * a native time input takes its 12/24-hour form from the BROWSER's UI locale
24
+ * and ignores `lang` entirely, so a Vietnamese screen on an en-US browser read
25
+ * "01:45 PM" next to its own 24-hour "13:45 02/01/2023".
26
+ */
27
+ locale?: string;
28
+ /** Accessible names per segment. Defaults to the `datePicker` locale slice. */
29
+ segmentLabels?: SegmentLabels;
30
+ /** Escape pressed in a segment — an inline editor cancels its session. */
31
+ onEscape?: () => void;
32
+ /** True while the entry is non-empty but still half-typed (an hour, no minute).
33
+ * An inline editor blocks its commit on this. */
34
+ onIncompleteChange?: (incomplete: boolean) => void;
35
+ testID?: string;
16
36
  }
17
37
 
38
+ /**
39
+ * A time of day. The value is always canonical 24-hour "HH:mm"; the locale only
40
+ * decides what the user sees and types (an AM/PM segment appears only where the
41
+ * locale uses one).
42
+ */
18
43
  export function TimePicker(props: TimePickerProps) {
19
- const { value, onValueChange, onBlur, onKeyDown, autoFocus, disabled, accessibilityLabel } = props;
20
- const { focusVisible, focusProps } = useFocusRing({ always: true });
44
+ const {
45
+ value = "",
46
+ onValueChange,
47
+ onBlur,
48
+ autoFocus,
49
+ disabled,
50
+ accessibilityLabel,
51
+ locale,
52
+ onEscape,
53
+ onIncompleteChange,
54
+ testID,
55
+ } = props;
56
+ const localeTag = useLocaleTag(locale);
57
+ const loc = useLoticsLocale().datePicker;
58
+ const segmentLabels = props.segmentLabels ?? loc;
59
+ const config = useMemo(() => timeSegmentsConfig(localeTag), [localeTag]);
21
60
  const { hovered, hoverProps } = useHover();
61
+ const [focused, setFocused] = useState(false);
22
62
 
23
63
  return (
24
- <input
25
- value={value}
26
- onChange={(e) => {
27
- onValueChange(e.target.value);
28
- }}
29
- type="time"
30
- {...hoverProps}
31
- onFocus={focusProps.onFocus}
32
- onBlur={() => { focusProps.onBlur(); onBlur?.(); }}
33
- onKeyDown={onKeyDown}
34
- autoFocus={autoFocus}
35
- disabled={disabled}
36
- aria-label={accessibilityLabel}
37
- style={{
38
- height: 40,
39
- paddingLeft: 8,
40
- paddingRight: 8,
41
- borderRadius: CONTROL_RADIUS,
42
- borderWidth: 1,
43
- borderStyle: "solid",
44
- borderColor: hovered && !disabled ? HOVER_BORDER : colors.border,
45
- backgroundColor: colors.background,
46
- fontFamily: fontFamilyRegular,
47
- ...inputTextStyleWeb,
48
- letterSpacing: -0.4,
49
- boxShadow: focusVisible ? FOCUS_RING : "none",
50
- outline: "none",
51
- boxSizing: "border-box",
52
- transition: "border-color 0.12s, box-shadow 0.12s",
53
- }}
54
- />
64
+ <View
65
+ {...(hoverProps as object)}
66
+ style={[
67
+ styles.frame,
68
+ hovered && !disabled && styles.frameHovered,
69
+ focused && !disabled && styles.frameFocused,
70
+ disabled && styles.frameDisabled,
71
+ ]}
72
+ testID={testID}
73
+ >
74
+ <DateSegments
75
+ value={value}
76
+ onChange={onValueChange}
77
+ config={config}
78
+ segmentLabels={segmentLabels}
79
+ disabled={disabled}
80
+ autoFocus={autoFocus}
81
+ accessibilityLabel={accessibilityLabel}
82
+ onFocus={() => setFocused(true)}
83
+ onBlur={() => {
84
+ setFocused(false);
85
+ onBlur?.();
86
+ }}
87
+ onEscape={onEscape}
88
+ onIncompleteChange={onIncompleteChange}
89
+ />
90
+ </View>
55
91
  );
56
92
  }
93
+
94
+ const styles = StyleSheet.create({
95
+ frame: {
96
+ flexDirection: "row",
97
+ alignItems: "center",
98
+ height: 40,
99
+ paddingHorizontal: 8,
100
+ borderRadius: CONTROL_RADIUS,
101
+ borderWidth: 1,
102
+ borderColor: colors.border,
103
+ backgroundColor: colors.background,
104
+ },
105
+ frameHovered: {
106
+ borderColor: HOVER_BORDER,
107
+ },
108
+ frameFocused: {
109
+ boxShadow: FOCUS_RING,
110
+ },
111
+ frameDisabled: {
112
+ backgroundColor: colors.zinc[50],
113
+ },
114
+ });