@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.
@@ -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
  />