@lotics/ui 11.7.3 → 11.8.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/AGENTS.md CHANGED
@@ -25,7 +25,7 @@ doc before building any screen, **never from memory**. Exact props are the shipp
25
25
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments, stage gates. |
26
26
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI proposes, the human decides — composer, live run feed (`AgentRun`), review-before-apply, findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
27
27
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
28
- | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import). |
28
+ | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
29
29
 
30
30
  ## Iron rules
31
31
 
package/docs/catalog.md CHANGED
@@ -516,7 +516,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
516
516
  ### Dates & times
517
517
 
518
518
  - **`date_picker`** — `DatePicker` (+ `DatePickerPanel`, `DatePickerLabels`): the field-form
519
- date (and datetime) picker.
519
+ date (and datetime) picker. The trigger is the segmented, locale-ordered `DateField`
520
+ (typed digits auto-advance; a typed separator advances a single-digit day/month) plus the
521
+ calendar popover.
520
522
  - **`date_calendar`** — `Calendar`: the bare month grid — `mode="single"` or `"range"`
521
523
  (`{start,end}` — two months side by side on desktop), month/year pickers + arrows,
522
524
  localized weekday/month names via BCP-47 `locale`, `firstDayOfWeek` (default Monday),
@@ -534,11 +536,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
534
536
  `INLINE_CONTROL_HEIGHT` (40) + `inlineValueTextStyle`: the engine custom inline editors
535
537
  join through — a view ⇄ edit toggle, a draft buffer, async `onSave` with the spinner
536
538
  INSIDE the control and inline error, commit on blur (Enter saves, Escape reverts) or
537
- `controls="buttons"`; `background="tint" | "transparent"` (the zinc-50 editability chip vs
539
+ `controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
540
+ focused (type → Tab → type — see the data-entry keyboard contract), pointer focus never
541
+ does; `background="tint" | "transparent"` (the zinc-50 editability chip vs
538
542
  flat for dense uniformly-editable surfaces).
539
543
  - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
540
544
  **`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
541
- (`format="datetime"`, `optionalTime`), **`inline_time_picker`** the Inline\* per-field
545
+ (`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
546
+ — locale field order, separator advances, Alt+ArrowDown floats the calendar; click still
547
+ opens the calendar popover), **`inline_time_picker`** — the Inline\* per-field
542
548
  editors; `InlineSelect`/`InlineMemberSelect` render the resting value like its option —
543
549
  `renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not
544
550
  just text.
@@ -676,10 +682,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
676
682
  own scroll, like `OptionList`): the anchored floating surface. **NON-MODAL**: the anchored
677
683
  popover has NO blocking overlay — the rest of the page stays interactive, and clicking another
678
684
  control both dismisses this popover AND activates that control in one click; clicking outside,
679
- scrolling an ancestor, or Escape dismisses. Only the `small` (bottom-sheet) presentation is
680
- modal (scrim). `PopoverContent` already insets its body 12px put content directly in it,
681
- NEVER add your own padding `View` (that double-pads); title/actions go in `PopoverHeader` /
682
- `PopoverFooter`.
685
+ scrolling an ancestor, or Escape dismisses. Interactions inside a layer the popover opened
686
+ from within (a nested popover, an `Alert` confirm, a full-screen Modal like the file preview)
687
+ never dismiss it clicks, scrolls, and Escape belong to that layer until it closes. Only the
688
+ `small` (bottom-sheet) presentation is modal (scrim). `PopoverContent` already insets its
689
+ body 12px — put content directly in it, NEVER add your own padding `View` (that double-pads);
690
+ title/actions go in `PopoverHeader` / `PopoverFooter`.
683
691
  - **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
684
692
  built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes,
685
693
  `goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the
@@ -923,5 +931,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
923
931
  input.
924
932
  - **`use_focus_ring`** — `useFocusRing`: keyboard-aware focus state for painting a control's
925
933
  own ring.
934
+ - **`interaction_modality`** — the document-level keyboard-vs-pointer tracker behind
935
+ `useFocusRing` and the inline editors' focus-opens-edit: `getInteractionModality()` read
936
+ inside a focus handler tells a Tab-focus from a click-focus (the `:focus-visible` signal).
937
+ - **`inline_focus`** — `shouldOpenOnFocus` + `FOCUS_OPEN_SUPPRESS_MS`: the inline
938
+ keyboard-entry gate — keyboard focus opens a closed editor, pointer focus never does, and
939
+ a programmatic focus restore inside the suppression window doesn't re-open the editor it
940
+ just closed.
926
941
  - **`json_panel`** — `JsonPanel` (`{title, value}`) + `stringifyData`: a labeled monospace
927
942
  panel for raw/JSON payloads (debug & developer surfaces).
@@ -29,8 +29,31 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
29
29
  When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
30
30
  form mode or a preview↔edit card — make each VALUE inline-editable: it reads as a value on a quiet
31
31
  chip, hover reveals the input-family border (no extra grey wash, no pencil icon that shifts
32
- layout), click swaps the input in **at the same height** (zero reflow, the whole point), and it
33
- commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ primary / ✕).
32
+ layout), click OR keyboard focus swaps the input in **at the same height** (zero reflow, the whole
33
+ point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ / ✕).
34
+
35
+ ### Keyboard entry — type → Tab → type
36
+
37
+ Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
38
+ input-swap editor — `InlineTextInput`, `InlineNumberInput`, `InlineTimePicker`,
39
+ `InlineDatePicker` — opens edit mode immediately with the input focused; commit-on-blur then
40
+ makes Tab itself the commit, so the chain is type → Tab → type with the next editor already
41
+ open. Pointer focus never auto-opens (mousedown records "pointer" modality before focus fires
42
+ — `interaction_modality.ts`), so the click path is exactly what it always was. When an editor
43
+ closes while its input still holds focus (Enter/Escape), it returns focus to its resting view
44
+ — never `<body>` — so the next Tab continues from the field.
45
+
46
+ **Typed dates.** `InlineDatePicker`'s keyboard mode is the kit's segmented `DateField`: type
47
+ the date in the locale's own field order (dd/MM/yyyy where the locale says so — pass
48
+ `locale`), digits auto-advance, and a typed separator (`/` `.` `-`) advances a single-digit
49
+ day/month; Enter or blur commits, Escape reverts, Alt+ArrowDown floats the calendar. The
50
+ calendar popover stays the pointer path (click the resting value, as ever) — it is no longer
51
+ the only path. A PARTIAL entry never commits and never clears the stored value: the field
52
+ stays in edit mode showing the `datePicker.invalidDate` inline error until fixed or Escaped.
53
+
54
+ The POPOVER editors (`InlineSelect` / `InlineMemberSelect` / `InlineTagSelect`) deliberately
55
+ do NOT auto-open on focus — a popup opening on Tab arrival traps traversal; per the WAI-ARIA
56
+ combobox contract they open on Enter/Space from the keyboard.
34
57
 
35
58
  One per type:
36
59
 
package/docs/templates.md CHANGED
@@ -48,6 +48,47 @@ the package index is [../AGENTS.md](../AGENTS.md).
48
48
  | AI ranks answers — look-up-and-explain | `tpl_lookup` |
49
49
  | AI over a record's documents (extract / cross-check / generate) | `tpl_documents` |
50
50
 
51
+ ## Composition rules — how a record surface is assembled
52
+
53
+ Laws for any screen where a record's data is edited and a primary action produces its output
54
+ (a document, a message, an export) — `tpl_record` is the worked example.
55
+
56
+ 1. **Pipeline order.** Data sections run top→bottom in the order work flows (intake/scan near
57
+ the top when AI-driven); the OUTPUT section — the primary action plus everything that
58
+ configures it — comes LAST. The primary action is the output step of the pipeline, never
59
+ header chrome.
60
+ 2. **Static shape.** The page never changes shape from output-selection state. Conditional
61
+ rendering is DATA-driven only (a control appears because two stored values differ), never
62
+ selection-driven. Relevance is an advisory `Callout` with a jump-to-section action — warn,
63
+ never hide, never hard-block.
64
+ 3. **Colocation & ownership.** Every output option (selections, print/export switches) lives in
65
+ the output section beside its action; a per-entity selection carries its owner's name in its
66
+ heading. Persisted record DATA never lives there — data belongs in data sections. Misfiled
67
+ data announces itself as callout special-cases; the exception disappearing is the sign the
68
+ model is right.
69
+ 4. **Resolved state.** Controls show the EFFECTIVE state, never raw storage: a derived/suggested
70
+ selection renders checked; the first manual change persists the full explicit set; a
71
+ reset-to-auto affordance appears only in explicit mode. What's shown = what happens.
72
+ 5. **Decision budget.** Every user decision earns its place — derive it, default it, or
73
+ conditionally render it; ask only the underivable. Suggestions are one-tap-save chips; a
74
+ placeholder states the honest effective fallback (what actually happens when empty), never
75
+ an example that lies.
76
+ 6. **Header = identity + provenance.** Id, the key identifier, created-at. No metrics or badges
77
+ duplicating data a section already owns.
78
+ 7. **Destructive placement.** Solid `danger`, bottom-left after the entity's fields, ONE
79
+ convention page-wide, confirmed by `Alert`. Never in a heading row, never one-tap.
80
+ 8. **Create-then-refine.** An add is one click creating a draft edited in place — drafts render
81
+ as empty inline editors with placeholders, never as fake data. No type-a-name-then-click
82
+ forms.
83
+ 9. **Optimistic feedback.** Every edit echoes locally, saves in the background, reconciles on
84
+ refetch, reverts + surfaces on error. Silent success, loud failure — no persistent "saved"
85
+ chrome.
86
+ 10. **Chrome discipline.** Kit spacing scale only; ONE divider rule (between sibling blocks,
87
+ never doubled with section hairlines, none trailing); no state-echo labels or filler
88
+ captions — explanations live in `info` popovers and empty states (→ [composition grammar
89
+ §Microcopy](./composition.md)). A "why" question about a control is answered by tracing
90
+ its consequences, not by swapping the control.
91
+
51
92
  ## Analytics
52
93
 
53
94
  Read-mostly screens that answer a question about a population, then open doors into the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.7.3",
3
+ "version": "11.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -25,6 +25,15 @@ export interface DateFieldProps {
25
25
  onActivate?: () => void;
26
26
  /** Fires once focus leaves the whole field. */
27
27
  onBlur?: () => void;
28
+ /** Focus the first segment on mount (an inline editor entered by keyboard). */
29
+ autoFocus?: boolean;
30
+ /** Escape pressed in a segment — an inline editor cancels its session. */
31
+ onEscape?: () => void;
32
+ /** Alt+ArrowDown pressed in a segment — open the calendar popover. */
33
+ onOpenPicker?: () => void;
34
+ /** Reports whether ANY part holds a partial entry (typed but not yet a
35
+ * complete valid date) — an inline editor blocks its commit on it. */
36
+ onIncompleteChange?: (incomplete: boolean) => void;
28
37
  /** Rendered inside the border, after the segments (the calendar button). */
29
38
  rightSlot?: React.ReactNode;
30
39
  /** Ref to the frame, used to anchor the popover. */
@@ -51,12 +60,17 @@ export function DateField(props: DateFieldProps) {
51
60
  testID,
52
61
  onActivate,
53
62
  onBlur,
63
+ autoFocus,
64
+ onEscape,
65
+ onOpenPicker,
66
+ onIncompleteChange,
54
67
  rightSlot,
55
68
  triggerRef,
56
69
  style,
57
70
  } = props;
58
71
 
59
72
  const [focused, setFocused] = useState(false);
73
+ const incompleteParts = useRef<boolean[]>([]);
60
74
  const { hovered, hoverProps } = useHover();
61
75
  const blurTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
62
76
 
@@ -89,6 +103,14 @@ export function DateField(props: DateFieldProps) {
89
103
  if (!disabled) onActivate?.();
90
104
  }, [disabled, onActivate]);
91
105
 
106
+ const handleIncomplete = useCallback(
107
+ (index: number, incomplete: boolean) => {
108
+ incompleteParts.current[index] = incomplete;
109
+ onIncompleteChange?.(incompleteParts.current.some(Boolean));
110
+ },
111
+ [onIncompleteChange],
112
+ );
113
+
92
114
  const config = useMemo(() => dateSegmentsConfig(locale ?? "en-US", hasTime), [locale, hasTime]);
93
115
 
94
116
  const isEmpty = parts.every((part) => !part);
@@ -122,9 +144,13 @@ export function DateField(props: DateFieldProps) {
122
144
  config={config}
123
145
  segmentLabels={segmentLabels}
124
146
  disabled={disabled}
147
+ autoFocus={autoFocus && index === 0}
125
148
  accessibilityLabel={partLabels?.[index]}
126
149
  onFocus={handleSegmentFocus}
127
150
  onBlur={handleSegmentBlur}
151
+ onEscape={onEscape}
152
+ onOpenPicker={onOpenPicker}
153
+ onIncompleteChange={onIncompleteChange ? (inc) => handleIncomplete(index, inc) : undefined}
128
154
  />
129
155
  </React.Fragment>
130
156
  ))}
@@ -50,6 +50,8 @@ export interface DatePickerLabels extends SegmentLabels {
50
50
  endTime: string;
51
51
  /** Footer action: commit and close the popover. */
52
52
  done: string;
53
+ /** Inline error when a typed date is left incomplete/invalid (never committed). */
54
+ invalidDate: string;
53
55
  /** Quick action: add a time to a date-only value (`optionalTime`). */
54
56
  addTime: string;
55
57
  /** Accessible name + tooltip: drop the time, keeping the date (`optionalTime`). */
@@ -3,6 +3,7 @@ import {
3
3
  parsePart,
4
4
  partsToIso,
5
5
  parseText,
6
+ resolveDateCommit,
6
7
  timeText,
7
8
  splitTime,
8
9
  isoToDate,
@@ -165,3 +166,39 @@ describe("parseTimeString", () => {
165
166
  expect(parseTimeString("")).toBeNull();
166
167
  });
167
168
  });
169
+
170
+ describe("resolveDateCommit — the inline editor's commit contract", () => {
171
+ it("a typed complete date commits the value", () => {
172
+ expect(
173
+ resolveDateCommit({ draft: "2026-03-15", value: "2026-01-01", incomplete: false, clearable: false }),
174
+ ).toEqual({ kind: "save", value: "2026-03-15" });
175
+ // …including onto an empty field.
176
+ expect(
177
+ resolveDateCommit({ draft: "2026-03-15", value: null, incomplete: false, clearable: false }),
178
+ ).toEqual({ kind: "save", value: "2026-03-15" });
179
+ });
180
+
181
+ it("a partial entry never commits and never clears the stored value", () => {
182
+ expect(
183
+ resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: true, clearable: true }),
184
+ ).toEqual({ kind: "invalid" });
185
+ });
186
+
187
+ it("an unchanged draft exits without a write (a touched-but-equal date is not a diff)", () => {
188
+ expect(
189
+ resolveDateCommit({ draft: "2026-01-01", value: "2026-01-01", incomplete: false, clearable: true }),
190
+ ).toEqual({ kind: "none" });
191
+ expect(resolveDateCommit({ draft: "", value: null, incomplete: false, clearable: true })).toEqual({
192
+ kind: "none",
193
+ });
194
+ });
195
+
196
+ it("an emptied entry clears only when the field is clearable", () => {
197
+ expect(
198
+ resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: true }),
199
+ ).toEqual({ kind: "clear" });
200
+ expect(
201
+ resolveDateCommit({ draft: "", value: "2026-01-01", incomplete: false, clearable: false }),
202
+ ).toEqual({ kind: "none" });
203
+ });
204
+ });
@@ -123,6 +123,36 @@ export function dropTime(iso: string): string {
123
123
  return p ? partsToIso(p, false) : "";
124
124
  }
125
125
 
126
+ /** What committing an inline date edit session should do. */
127
+ export type InlineDateCommit =
128
+ | { kind: "save"; value: string }
129
+ | { kind: "clear" }
130
+ | { kind: "none" }
131
+ | { kind: "invalid" };
132
+
133
+ /**
134
+ * Resolve an inline date editor's commit (blur / Enter / popover close).
135
+ * `draft` is the last COMPLETE value the session produced ("" when emptied);
136
+ * `incomplete` is true while the segments hold a partial entry (a typed day
137
+ * with no year). Partial input never commits and never wipes the stored value —
138
+ * the caller shows the error affordance and keeps the entry ("invalid"). An
139
+ * emptied draft clears only when the field is clearable; otherwise it reverts
140
+ * silently, matching the calendar's own Clear on a required date.
141
+ */
142
+ export function resolveDateCommit(opts: {
143
+ draft: string;
144
+ value: string | null;
145
+ incomplete: boolean;
146
+ clearable: boolean;
147
+ }): InlineDateCommit {
148
+ const { draft, value, incomplete, clearable } = opts;
149
+ if (incomplete) return { kind: "invalid" };
150
+ const stored = value ?? "";
151
+ if (draft === stored) return { kind: "none" };
152
+ if (draft === "") return clearable && stored ? { kind: "clear" } : { kind: "none" };
153
+ return { kind: "save", value: draft };
154
+ }
155
+
126
156
  const TIME_RE = /^(\d{1,2}):(\d{2})$/;
127
157
 
128
158
  /**
@@ -7,6 +7,7 @@ import {
7
7
  from12h,
8
8
  typeDigit,
9
9
  incrementSegment,
10
+ separatorAdvances,
10
11
  setHourField,
11
12
  withDayPeriod,
12
13
  displayValue,
@@ -101,6 +102,50 @@ describe("typeDigit", () => {
101
102
  });
102
103
  });
103
104
 
105
+ describe("typed date entry — the inline keyboard path", () => {
106
+ // Simulate the segment engine's typing pipeline: each keystroke flows through
107
+ // typeDigit in the locale's own field order; the buffer then resolves through
108
+ // segmentsToValue. This is the outcome contract behind "type 15/03/2026 into
109
+ // an inline date editor and it commits".
110
+ it("a typed dd/MM/yyyy date composes the committed ISO value (vi-VN order)", () => {
111
+ expect(fieldOrder(getDateLayout("vi-VN", false))).toEqual(["day", "month", "year"]);
112
+ const day = typeDigit("day", typeDigit("day", "", "1", false).text, "5", false);
113
+ expect(day).toEqual({ text: "15", value: 15, complete: true });
114
+ const month = typeDigit("month", typeDigit("month", "", "0", false).text, "3", false);
115
+ expect(month).toEqual({ text: "03", value: 3, complete: true });
116
+ let yearText = "";
117
+ for (const digit of ["2", "0", "2", "6"]) yearText = typeDigit("year", yearText, digit, false).text;
118
+ const buffer = { year: Number(yearText), month: month.value, day: day.value, hour: null, minute: null };
119
+ expect(segmentsToValue(buffer, false)).toBe("2026-03-15");
120
+ });
121
+
122
+ it("a single-digit d/M/yyyy entry advances on the separator and still commits", () => {
123
+ // "1/1/2026": neither "1" can auto-advance (a second digit could still
124
+ // fit — "12", "11") — the typed "/" is the explicit advance.
125
+ const day = typeDigit("day", "", "1", false);
126
+ expect(day.complete).toBe(false);
127
+ expect(separatorAdvances("/", day.text !== "")).toBe(true);
128
+ const month = typeDigit("month", "", "1", false);
129
+ expect(month.complete).toBe(false);
130
+ expect(separatorAdvances("/", month.text !== "")).toBe(true);
131
+ const buffer = { year: 2026, month: month.value, day: day.value, hour: null, minute: null };
132
+ expect(segmentsToValue(buffer, false)).toBe("2026-01-01");
133
+ });
134
+
135
+ it("separators advance only off a segment that has content", () => {
136
+ for (const key of ["/", ".", "-", ",", ":", " "]) {
137
+ expect(separatorAdvances(key, true)).toBe(true);
138
+ expect(separatorAdvances(key, false)).toBe(false);
139
+ }
140
+ expect(separatorAdvances("a", true)).toBe(false);
141
+ expect(separatorAdvances("ArrowDown", true)).toBe(false);
142
+ });
143
+
144
+ it("an impossible typed date never composes a value", () => {
145
+ expect(segmentsToValue({ year: 2026, month: 2, day: 30, hour: null, minute: null }, false)).toBeNull();
146
+ });
147
+ });
148
+
104
149
  describe("incrementSegment", () => {
105
150
  it("wraps within range", () => {
106
151
  expect(incrementSegment("month", 12, 1, false)).toBe(1);
@@ -230,6 +230,17 @@ export function typeDigit(
230
230
  return { text, value, complete };
231
231
  }
232
232
 
233
+ /**
234
+ * A typed separator ("15/3", "15.3.2026") advances to the next segment — the
235
+ * forgiveness that makes single-digit entry work: "1" alone can't auto-advance
236
+ * (it might be "12"), so the separator is the typist's explicit "done here".
237
+ * Only advances off a segment that HAS content (committed or in-progress);
238
+ * a leading separator is a no-op, matching native date inputs.
239
+ */
240
+ export function separatorAdvances(key: string, segmentHasContent: boolean): boolean {
241
+ return segmentHasContent && (key === "/" || key === "." || key === "-" || key === "," || key === " " || key === ":");
242
+ }
243
+
233
244
  /** Step a numeric segment up/down, wrapping within its range. */
234
245
  export function incrementSegment(
235
246
  type: SegmentType,
@@ -21,6 +21,7 @@ import {
21
21
  fieldOrder,
22
22
  incrementSegment,
23
23
  placeholderFor,
24
+ separatorAdvances,
24
25
  setHourField,
25
26
  to12h,
26
27
  typeDigit,
@@ -44,6 +45,16 @@ export interface DateSegmentsProps {
44
45
  onFocus?: () => void;
45
46
  /** Fires when a segment in this group loses focus. */
46
47
  onBlur?: () => void;
48
+ /** Escape pressed in a segment (web) — an inline editor cancels its session. */
49
+ onEscape?: () => void;
50
+ /** Alt+ArrowDown pressed in a segment (web) — open the calendar popover
51
+ * (the segment-field convention; plain ArrowDown steps the spinbutton). */
52
+ onOpenPicker?: () => void;
53
+ /** Reports whether the buffer holds a PARTIAL entry — non-empty but not yet a
54
+ * complete valid date (a typed day with no year). Complete values emit
55
+ * through `onChange`; an emptied buffer emits `""`; between the two, this is
56
+ * the only signal — an inline editor blocks its commit on it. */
57
+ onIncompleteChange?: (incomplete: boolean) => void;
47
58
  style?: StyleProp<ViewStyle>;
48
59
  }
49
60
 
@@ -127,6 +138,9 @@ export function DateSegments(props: DateSegmentsProps) {
127
138
  accessibilityLabel,
128
139
  onFocus,
129
140
  onBlur,
141
+ onEscape,
142
+ onOpenPicker,
143
+ onIncompleteChange,
130
144
  style,
131
145
  } = props;
132
146
 
@@ -147,27 +161,43 @@ export function DateSegments(props: DateSegmentsProps) {
147
161
 
148
162
  const lastEmitted = useRef<string>(value);
149
163
  const fieldRefs = useRef<Partial<Record<SegmentType, RNTextInput | null>>>({});
164
+ const lastIncomplete = useRef(false);
165
+
166
+ const reportIncomplete = useCallback(
167
+ (incomplete: boolean) => {
168
+ if (incomplete === lastIncomplete.current) return;
169
+ lastIncomplete.current = incomplete;
170
+ onIncompleteChange?.(incomplete);
171
+ },
172
+ [onIncompleteChange],
173
+ );
150
174
 
151
175
  useEffect(() => {
152
176
  if (value !== lastEmitted.current) {
153
177
  lastEmitted.current = value;
154
178
  setBuffer(config.toBuffer(value));
155
179
  setActiveText("");
180
+ // An outside value (calendar pick, parent reset) is never partial.
181
+ reportIncomplete(false);
156
182
  }
157
- }, [value, config]);
183
+ }, [value, config, reportIncomplete]);
158
184
 
159
185
  const commit = useCallback(
160
186
  (next: SegmentBuffer) => {
161
187
  const out = config.toValue(next);
162
188
  if (out !== null) {
163
189
  lastEmitted.current = out;
190
+ reportIncomplete(false);
164
191
  onChange(out);
165
192
  } else if (config.isEmpty(next)) {
166
193
  lastEmitted.current = "";
194
+ reportIncomplete(false);
167
195
  onChange("");
196
+ } else {
197
+ reportIncomplete(true);
168
198
  }
169
199
  },
170
- [config, onChange],
200
+ [config, onChange, reportIncomplete],
171
201
  );
172
202
 
173
203
  const focusField = useCallback((type: SegmentType | undefined) => {
@@ -267,12 +297,26 @@ export function DateSegments(props: DateSegmentsProps) {
267
297
 
268
298
  const handleKeyPress = useCallback(
269
299
  (type: SegmentType, e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
270
- const key = e.nativeEvent.key;
300
+ // On web the nativeEvent is the DOM KeyboardEvent — RN's type only carries
301
+ // `key`, so widen minimally at this boundary for the modifier read.
302
+ const native = e.nativeEvent as TextInputKeyPressEventData & { altKey?: boolean };
303
+ const key = native.key;
271
304
  if (/^[0-9]$/.test(key)) {
272
305
  e.preventDefault();
273
306
  handleDigit(type, key);
274
307
  return;
275
308
  }
309
+ // A typed separator ("15/3/2026", "14:30") advances past a segment that
310
+ // has content — the explicit "done" for a single-digit day/month that
311
+ // can't auto-advance on its own.
312
+ const hasContent =
313
+ (activeType === type && activeText !== "") || fieldNumeric(type, buffer, layout.hour12) != null;
314
+ if (separatorAdvances(key, hasContent)) {
315
+ e.preventDefault();
316
+ setActiveText("");
317
+ focusNext(type);
318
+ return;
319
+ }
276
320
  switch (key) {
277
321
  case "ArrowUp":
278
322
  e.preventDefault();
@@ -280,7 +324,16 @@ export function DateSegments(props: DateSegmentsProps) {
280
324
  break;
281
325
  case "ArrowDown":
282
326
  e.preventDefault();
283
- handleStep(type, -1);
327
+ // Alt+ArrowDown opens the calendar (the segmented-field convention);
328
+ // plain ArrowDown keeps the spinbutton step.
329
+ if (native.altKey && onOpenPicker) onOpenPicker();
330
+ else handleStep(type, -1);
331
+ break;
332
+ case "Escape":
333
+ if (onEscape) {
334
+ e.preventDefault();
335
+ onEscape();
336
+ }
284
337
  break;
285
338
  case "ArrowLeft":
286
339
  e.preventDefault();
@@ -307,7 +360,7 @@ export function DateSegments(props: DateSegmentsProps) {
307
360
  }
308
361
  }
309
362
  },
310
- [handleDigit, handleStep, handleBackspace, focusPrev, focusNext, setHalfDay],
363
+ [activeType, activeText, buffer, layout.hour12, handleDigit, handleStep, handleBackspace, focusPrev, focusNext, setHalfDay, onEscape, onOpenPicker],
311
364
  );
312
365
 
313
366
  const handleChangeText = useCallback(
@@ -115,6 +115,7 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
115
115
  // additionally can't hold filename + every action inline (the close button gets
116
116
  // pushed off-screen), so the remaining actions collapse into a ⋯ menu.
117
117
  const { small } = useScreenSize();
118
+ const locale = useLoticsLocale();
118
119
 
119
120
  const close = useCallback(() => onIndexChange(null), [onIndexChange]);
120
121
  const next = useCallback(() => {
@@ -154,7 +155,7 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
154
155
  const file = files[activeIndex];
155
156
  if (!file) return null;
156
157
 
157
- const l = { ...useLoticsLocale().gallery, ...labels };
158
+ const l = { ...locale.gallery, ...labels };
158
159
  const isImage = isImageMimeType(file.mimeType);
159
160
  const total = files.length;
160
161
  const rotated = rot.rotationFor(file.id) !== 0;