@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.
@@ -1,24 +1,28 @@
1
- import { useCallback, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { colors } from "./colors";
6
6
  import { Popover, PopoverTrigger, PopoverContent } from "./popover";
7
7
  import { DatePickerPanel } from "./date_picker";
8
- import { isoHasTime } from "./date_picker_value";
8
+ import { DateField } from "./date_field";
9
+ import { isoHasTime, resolveDateCommit } from "./date_picker_value";
9
10
  import { formatDate } from "./format_date";
10
11
  import { ActivityIndicator } from "./activity_indicator";
11
12
  import { type InlineEditBackground, InlineEditView } from "./inline_edit";
12
13
  import { useLoticsLocale } from "./locale";
14
+ import { getInteractionModality } from "./interaction_modality";
15
+ import { shouldOpenOnFocus } from "./inline_focus";
13
16
 
14
17
  export interface InlineDatePickerProps {
15
18
  /** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
16
19
  value: string | null;
17
20
  onSave: (next: string) => void | Promise<void>;
18
21
  /** Unset the date to empty. Provide it to make the value clearable: the
19
- * calendar's own "Clear" button then unsets the field through this callback
20
- * (without `onClear` an empty selection is ignored — a required date can't be
21
- * emptied). Kept separate from `onSave` (whose next is a non-empty string). */
22
+ * calendar's own "Clear" button and an emptied typed entry then unset the
23
+ * field through this callback (without `onClear` an empty result is ignored —
24
+ * a required date can't be emptied). Kept separate from `onSave` (whose next
25
+ * is a non-empty string). */
22
26
  onClear?: () => void | Promise<void>;
23
27
  /** "date" (default) or "datetime". */
24
28
  format?: "date" | "datetime";
@@ -26,7 +30,7 @@ export interface InlineDatePickerProps {
26
30
  * until a time is added). Ignored when `format` is "datetime" (time always on). */
27
31
  optionalTime?: boolean;
28
32
  placeholder?: string;
29
- /** BCP-47 locale for the calendar and the displayed date. */
33
+ /** BCP-47 locale for the calendar, the displayed date, and the typed segment order. */
30
34
  locale?: string;
31
35
  disabled?: boolean;
32
36
  accessibilityLabel?: string;
@@ -35,105 +39,235 @@ export interface InlineDatePickerProps {
35
39
  }
36
40
 
37
41
  /**
38
- * An inline-editable date / datetime. The value reads as a formatted date;
39
- * clicking it floats the calendar (`DatePickerPanel`) in a popover anchored to
40
- * the view, so the row never changes height. The selection commits when the
41
- * panel closes (immediately for a single date, after time entry for datetime);
42
- * dismissing without a change reverts.
42
+ * An inline-editable date / datetime with TWO entry paths:
43
+ *
44
+ * - **Pointer** (unchanged): clicking the resting value floats the calendar
45
+ * (`DatePickerPanel`) in a popover anchored to the view; the selection
46
+ * commits when the panel closes, dismissing without a change reverts.
47
+ * - **Keyboard** (typed): Tab/keyboard focus swaps the value for the kit's
48
+ * segmented `DateField` with the first segment focused — type the date in the
49
+ * locale's own order (dd/MM/yyyy where the locale says so; separators advance
50
+ * a single-digit day/month), Enter or blur commits, Escape reverts, and
51
+ * Alt+ArrowDown still opens the calendar. A partial entry never commits and
52
+ * never clears the stored value — the error affordance shows instead.
53
+ *
54
+ * The row never changes height in either mode.
43
55
  */
44
56
  export function InlineDatePicker(props: InlineDatePickerProps) {
45
57
  const { value, onSave, onClear, format = "date", optionalTime, placeholder, locale, disabled, accessibilityLabel , background } = props;
46
- const labels = useLoticsLocale().inline;
58
+ const locales = useLoticsLocale();
59
+ const inlineLabels = locales.inline;
60
+ const dateLabels = locales.datePicker;
61
+
62
+ const [editing, setEditing] = useState(false);
47
63
  const [open, setOpen] = useState(false);
48
- const [draft, setDraft] = useState<string | null>(value);
64
+ // Canonical session draft ("" when empty) written by the segments AND the
65
+ // calendar panel, so both paths commit through the same resolution.
66
+ const [draft, setDraft] = useState<string>(value ?? "");
49
67
  const [saving, setSaving] = useState(false);
50
68
  const [error, setError] = useState<string | null>(null);
51
- // The latest panel value, read at close time (the draft state can be stale in
52
- // the close handler's closure).
53
- const draftRef = useRef<string | null>(value);
54
-
55
- const commit = useCallback(async () => {
56
- const next = draftRef.current;
57
- if (next === value) return;
58
- // The panel's "Clear" empties the draft (onValueChange("")). Treat that as an
59
- // explicit unset when the field is clearable (onClear + a current value);
60
- // otherwise ignore an empty draft a required date can't be emptied.
61
- if (!next) {
62
- if (!onClear || !value) return;
63
- setSaving(true);
64
- setError(null);
65
- try {
66
- await onClear();
67
- } catch (e) {
68
- setError(e instanceof Error && e.message ? e.message : labels.saveError);
69
- } finally {
70
- setSaving(false);
71
- }
69
+
70
+ // The latest draft, read at commit time (state can be stale in the close
71
+ // handler's closure).
72
+ const draftRef = useRef<string>(value ?? "");
73
+ // True while the segments hold a partial typed entry (no complete value).
74
+ const incompleteRef = useRef(false);
75
+ // A commit in flight — a trailing blur/close must not commit twice.
76
+ const committing = useRef(false);
77
+ // Timestamp of the last programmatic focus restore (ours or the popover's)
78
+ // that focus must not re-open the typing session. See `inline_focus.ts`.
79
+ const suppressedAt = useRef<number | null>(null);
80
+ // The popover anchor AND the focus-restore target: the resting view in view
81
+ // mode (via `PopoverTrigger`), the `DateField` frame while editing.
82
+ const anchorRef = useRef<View>(null);
83
+ const wasEditing = useRef(false);
84
+
85
+ const startSession = useCallback(() => {
86
+ draftRef.current = value ?? "";
87
+ setDraft(value ?? "");
88
+ incompleteRef.current = false;
89
+ setError(null);
90
+ }, [value]);
91
+
92
+ const updateDraft = useCallback((next: string) => {
93
+ draftRef.current = next;
94
+ setDraft(next);
95
+ setError(null);
96
+ }, []);
97
+
98
+ const commitSession = useCallback(async () => {
99
+ if (committing.current) return;
100
+ const decision = resolveDateCommit({
101
+ draft: draftRef.current,
102
+ value,
103
+ incomplete: incompleteRef.current,
104
+ clearable: !!onClear,
105
+ });
106
+ if (decision.kind === "invalid") {
107
+ // Partial typed entry: never commit, never wipe the stored value — show
108
+ // the error and keep the session (the entry stays fixable).
109
+ setError(dateLabels.invalidDate);
110
+ return;
111
+ }
112
+ if (decision.kind === "none") {
113
+ setEditing(false);
72
114
  return;
73
115
  }
116
+ committing.current = true;
74
117
  setSaving(true);
75
118
  setError(null);
76
119
  try {
77
- await onSave(next);
120
+ if (decision.kind === "clear") {
121
+ if (onClear) await onClear();
122
+ } else {
123
+ await onSave(decision.value);
124
+ }
125
+ setEditing(false);
78
126
  } catch (e) {
79
- setError(e instanceof Error && e.message ? e.message : labels.saveError);
127
+ // Stay in the session so the entry isn't lost — show the error.
128
+ setError(e instanceof Error && e.message ? e.message : inlineLabels.saveError);
80
129
  } finally {
130
+ committing.current = false;
81
131
  setSaving(false);
82
132
  }
83
- }, [value, onSave, onClear, labels.saveError]);
133
+ }, [value, onSave, onClear, dateLabels.invalidDate, inlineLabels.saveError]);
134
+
135
+ const closeAndCommit = useCallback(() => {
136
+ setOpen(false);
137
+ // The popover restores focus to its trigger on close — that programmatic
138
+ // focus must not immediately re-open the typing session.
139
+ suppressedAt.current = Date.now();
140
+ void commitSession();
141
+ }, [commitSession]);
84
142
 
85
- const onOpenChange = useCallback(
143
+ const handleOpenChange = useCallback(
86
144
  (next: boolean) => {
87
145
  if (next) {
88
- draftRef.current = value;
89
- setDraft(value);
146
+ // A fresh popover session from the resting view (the typing session,
147
+ // when editing, is already running on the same draft).
148
+ if (!editing) startSession();
90
149
  setOpen(true);
91
150
  } else {
92
- setOpen(false);
93
- void commit();
151
+ // Commit on panel close: a single-date pick and "Today" auto-close
152
+ // through onRequestClose → closeAndCommit; outside-click/Escape land here.
153
+ closeAndCommit();
94
154
  }
95
155
  },
96
- [value, commit],
156
+ [editing, startSession, closeAndCommit],
97
157
  );
98
158
 
99
- // `format` here is the field config (date vs datetime); the display formatter only needs
100
- // whether to show a time map it to the orthogonal `time` flag. In optionalTime mode the
101
- // value's own shape decides.
159
+ // Keyboard focus on the resting value opens the TYPING session (popover shut,
160
+ // first segment focused). Pointer focus never lands here mousedown records
161
+ // "pointer" modality before focus fires — so a click still opens the calendar
162
+ // exactly once, through the popover trigger press.
163
+ const handleViewFocus = useCallback(() => {
164
+ if (disabled) return;
165
+ if (!shouldOpenOnFocus(getInteractionModality(), suppressedAt.current, Date.now())) return;
166
+ startSession();
167
+ setEditing(true);
168
+ }, [disabled, startSession]);
169
+
170
+ // Pointer down in the segments area, or Alt+ArrowDown in a segment.
171
+ const openPicker = useCallback(() => {
172
+ if (!disabled) setOpen(true);
173
+ }, [disabled]);
174
+
175
+ // Commit once focus leaves the whole field — unless the calendar is open
176
+ // (it steals focus while it floats; its close commits instead).
177
+ const handleFieldBlur = useCallback(() => {
178
+ if (open) return;
179
+ void commitSession();
180
+ }, [open, commitSession]);
181
+
182
+ const handleEscape = useCallback(() => {
183
+ // With the calendar open, Escape belongs to the popover (its document-level
184
+ // handler closes it and commits through onOpenChange).
185
+ if (open) return;
186
+ setError(null);
187
+ setEditing(false);
188
+ }, [open]);
189
+
190
+ const handleIncompleteChange = useCallback((incomplete: boolean) => {
191
+ incompleteRef.current = incomplete;
192
+ }, []);
193
+
194
+ // When the typing session closes while a segment still holds focus (Enter,
195
+ // Escape), the unmount drops focus to <body> and the next Tab would restart
196
+ // from the top of the page. Return focus to the resting view, arming the
197
+ // suppression window so the restore doesn't re-open the session. A Tab-away
198
+ // blur-commit leaves focus on the next field, so this never steals it back.
199
+ useEffect(() => {
200
+ if (wasEditing.current && !editing && typeof document !== "undefined" && document.activeElement === document.body) {
201
+ suppressedAt.current = Date.now();
202
+ anchorRef.current?.focus();
203
+ }
204
+ wasEditing.current = editing;
205
+ }, [editing]);
206
+
207
+ // `format` is the field config (date vs datetime); the display/segments only need
208
+ // whether a time shows. In optionalTime mode the value's own shape decides — the
209
+ // resting view follows the stored value, the segments follow the session draft
210
+ // (the panel's "Add time" grows the segments live).
102
211
  const showTime = optionalTime ? isoHasTime(value) : format === "datetime";
103
212
  const display = formatDate(value, { time: showTime, locale, emptyLabel: "" });
213
+ const fieldHasTime = optionalTime ? isoHasTime(draft) : format === "datetime";
214
+
215
+ const trailing = saving ? (
216
+ <ActivityIndicator size={16} color={colors.zinc[400]} />
217
+ ) : (
218
+ <Icon name={format === "datetime" ? "calendar-clock" : "calendar"} size={18} color={colors.zinc[400]} />
219
+ );
104
220
 
105
221
  return (
106
222
  <View>
107
- <Popover open={open && !disabled} onOpenChange={onOpenChange} side="bottom" align="start">
108
- <PopoverTrigger>
109
- <InlineEditView
110
- background={background}
111
- display={display}
112
- placeholder={placeholder}
223
+ <Popover open={open && !disabled} onOpenChange={handleOpenChange} triggerRef={anchorRef} side="bottom" align="start">
224
+ {editing ? (
225
+ <DateField
226
+ triggerRef={anchorRef}
227
+ parts={[draft]}
228
+ onPartChange={(_, next) => updateDraft(next)}
229
+ hasTime={fieldHasTime}
230
+ segmentLabels={dateLabels}
231
+ locale={locale}
113
232
  disabled={disabled}
114
- active={open && !disabled}
115
- accessibilityLabel={accessibilityLabel}
116
- // A rest affordance (like the select's chevron): a calendar glyph marks
117
- // the field as a tappable date control even when empty — no hover / pointer
118
- // cursor needed, so it reads as interactive on touch.
119
- trailing={saving ? <ActivityIndicator size={16} color={colors.zinc[400]} /> : <Icon name={format === "datetime" ? "calendar-clock" : "calendar"} size={18} color={colors.zinc[400]} />}
233
+ placeholder={placeholder}
234
+ partLabels={accessibilityLabel ? [accessibilityLabel] : undefined}
235
+ autoFocus
236
+ onBlur={handleFieldBlur}
237
+ onEscape={handleEscape}
238
+ onOpenPicker={openPicker}
239
+ onIncompleteChange={handleIncompleteChange}
240
+ onActivate={openPicker}
241
+ rightSlot={trailing}
120
242
  />
121
- </PopoverTrigger>
243
+ ) : (
244
+ <PopoverTrigger>
245
+ <InlineEditView
246
+ background={background}
247
+ display={display}
248
+ placeholder={placeholder}
249
+ disabled={disabled}
250
+ active={open && !disabled}
251
+ accessibilityLabel={accessibilityLabel}
252
+ onFocus={handleViewFocus}
253
+ // A rest affordance (like the select's chevron): a calendar glyph marks
254
+ // the field as a tappable date control even when empty — no hover / pointer
255
+ // cursor needed, so it reads as interactive on touch.
256
+ trailing={trailing}
257
+ />
258
+ </PopoverTrigger>
259
+ )}
122
260
  <PopoverContent>
123
261
  <DatePickerPanel
124
262
  value={draft}
125
- onValueChange={(v) => {
126
- draftRef.current = v;
127
- setDraft(v);
128
- }}
263
+ onValueChange={updateDraft}
129
264
  format={format}
130
265
  optionalTime={optionalTime}
131
266
  locale={locale}
132
267
  // Commit on panel close. A single-date pick and the "Today" button
133
- // auto-close through here — route it through onOpenChange so commit
134
- // runs. A bare setOpen(false) is a controlled close the Popover never
135
- // reports to onOpenChange, so the pick/clear would be silently dropped.
136
- onRequestClose={() => onOpenChange(false)}
268
+ // auto-close through here — a bare setOpen(false) would be a
269
+ // controlled close the Popover never reports, silently dropping it.
270
+ onRequestClose={closeAndCommit}
137
271
  />
138
272
  </PopoverContent>
139
273
  </Popover>
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
1
+ import { useCallback, useEffect, useRef, useState, type ReactNode, type Ref } from "react";
2
2
  import { View, StyleSheet, type GestureResponderEvent, type TextStyle } from "react-native";
3
3
  import { Text } from "./text";
4
4
  import { IconButton } from "./icon_button";
@@ -8,6 +8,8 @@ import { FocusRingPressable } from "./focus_ring_pressable";
8
8
  import { colors } from "./colors";
9
9
  import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
10
10
  import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
11
+ import { getInteractionModality } from "./interaction_modality";
12
+ import { shouldOpenOnFocus } from "./inline_focus";
11
13
 
12
14
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
13
15
  * The view box matches it — same height, padding, and a 1px transparent border
@@ -132,6 +134,11 @@ interface InlineEditViewProps {
132
134
  /** True while the field's popover (select/date) is open: wears the 2px active
133
135
  * ring so a mouse-opened trigger reads like a focused input (no `:focus-visible`). */
134
136
  active?: boolean;
137
+ /** Raw focus on the resting view, any modality. The input-swap editors use it
138
+ * (via `InlineEditFrame`) to open edit mode on KEYBOARD focus; the popover
139
+ * editors leave it unset — focus never auto-opens an overlay (the WAI-ARIA
140
+ * combobox contract: the popup opens on Enter/Space, not on Tab arrival). */
141
+ onFocus?: () => void;
135
142
  /** Strike + mute the resting value (a completed/superseded item that stays editable). */
136
143
  struck?: boolean;
137
144
  /** Resting surface — see {@link InlineEditBackground}. Default "tint". */
@@ -150,7 +157,7 @@ interface InlineEditViewProps {
150
157
  * select/date inline editors (it forwards ref + onPress to a `PopoverTrigger`).
151
158
  */
152
159
  export function InlineEditView(props: InlineEditViewProps) {
153
- const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, background = "tint", ref } = props;
160
+ const { display, placeholder, onPress, disabled, accessibilityLabel, trailing, active, struck, background = "tint", onFocus, ref } = props;
154
161
  // Stop the press here so an inline editor nested in a pressable row (a task
155
162
  // row that expands on press) edits the field instead of triggering the row.
156
163
  const handlePress = onPress
@@ -164,6 +171,7 @@ export function InlineEditView(props: InlineEditViewProps) {
164
171
  ref={ref}
165
172
  disabled={disabled}
166
173
  onPress={handlePress}
174
+ onFocus={onFocus}
167
175
  accessibilityRole="button"
168
176
  accessibilityLabel={accessibilityLabel}
169
177
  userSelect="none"
@@ -187,7 +195,9 @@ export function InlineEditView(props: InlineEditViewProps) {
187
195
  /**
188
196
  * The shared shell of an inline-editable INPUT (text, number). View mode is an
189
197
  * `InlineEditView`; on press it swaps to the input at the same height — no
190
- * layout shift — plus the optional save/cancel controls. Compose it with
198
+ * layout shift — plus the optional save/cancel controls. KEYBOARD focus on the
199
+ * closed view also opens edit mode (type → Tab → type through a form); pointer
200
+ * focus never does — the press handler owns the click path. Compose it with
191
201
  * `useInlineEdit`. (Overlay-based editors — select, date — use `InlineEditView`
192
202
  * directly as a `Popover` trigger instead.)
193
203
  */
@@ -211,13 +221,43 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
211
221
  affordance,
212
222
  } = props;
213
223
 
224
+ const viewRef = useRef<View>(null);
225
+ const suppressedAt = useRef<number | null>(null);
226
+ const wasEditing = useRef(editing);
227
+
228
+ // Keyboard focus opens edit mode immediately; the input's own `autoFocus`
229
+ // then moves focus into it. Pointer focus never lands here (mousedown records
230
+ // "pointer" modality before focus fires), so a click still opens exactly once,
231
+ // through onPress.
232
+ const handleViewFocus = useCallback(() => {
233
+ if (disabled) return;
234
+ if (!shouldOpenOnFocus(getInteractionModality(), suppressedAt.current, Date.now())) return;
235
+ onBegin();
236
+ }, [disabled, onBegin]);
237
+
238
+ // When edit mode closes while its input (or a ✓/✕ button) still holds focus —
239
+ // Enter, Escape, the buttons — the unmount drops focus to <body> and the next
240
+ // Tab would restart from the top of the page. Return focus to the view button,
241
+ // arming the suppression window so the programmatic focus doesn't re-open the
242
+ // editor it just closed. A Tab-away blur-commit leaves focus on the next field
243
+ // (not <body>), so this never steals focus back.
244
+ useEffect(() => {
245
+ if (wasEditing.current && !editing && typeof document !== "undefined" && document.activeElement === document.body) {
246
+ suppressedAt.current = Date.now();
247
+ viewRef.current?.focus();
248
+ }
249
+ wasEditing.current = editing;
250
+ }, [editing]);
251
+
214
252
  if (!editing) {
215
253
  return (
216
254
  <InlineEditView
255
+ ref={viewRef}
217
256
  background={background}
218
257
  display={display}
219
258
  placeholder={placeholder}
220
259
  onPress={onBegin}
260
+ onFocus={handleViewFocus}
221
261
  disabled={disabled}
222
262
  accessibilityLabel={accessibilityLabel}
223
263
  struck={struck}
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import { ensureModalityListeners, getInteractionModality } from "./interaction_modality";
4
+ import { FOCUS_OPEN_SUPPRESS_MS, shouldOpenOnFocus } from "./inline_focus";
5
+
6
+ describe("shouldOpenOnFocus — the inline keyboard-entry gate", () => {
7
+ it("keyboard focus opens the editor", () => {
8
+ expect(shouldOpenOnFocus("keyboard", null, 1_000)).toBe(true);
9
+ });
10
+
11
+ it("pointer focus never opens the editor — the click path stays press-driven", () => {
12
+ expect(shouldOpenOnFocus("pointer", null, 1_000)).toBe(false);
13
+ // …even outside any suppression window.
14
+ expect(shouldOpenOnFocus("pointer", 100, 100_000)).toBe(false);
15
+ });
16
+
17
+ it("a programmatic focus restore inside the suppression window does not re-open", () => {
18
+ const restoredAt = 5_000;
19
+ expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt)).toBe(false);
20
+ expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS - 1)).toBe(false);
21
+ });
22
+
23
+ it("suppression expires — a later Tab arrival opens again", () => {
24
+ const restoredAt = 5_000;
25
+ expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS)).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("interaction modality tracker", () => {
30
+ it("records keyboard on keydown and pointer on mousedown (Tab-vs-click focus)", () => {
31
+ ensureModalityListeners();
32
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
33
+ expect(getInteractionModality()).toBe("keyboard");
34
+ document.dispatchEvent(new MouseEvent("mousedown"));
35
+ expect(getInteractionModality()).toBe("pointer");
36
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
37
+ expect(getInteractionModality()).toBe("keyboard");
38
+ });
39
+ });
@@ -0,0 +1,40 @@
1
+ // =============================================================================
2
+ // Inline editor focus-entry policy
3
+ //
4
+ // The keyboard-entry contract for the input-swap inline editors (text, number,
5
+ // time, date): a KEYBOARD focus landing on the closed editor opens edit mode
6
+ // immediately with the input focused — a typist gets type → Tab → type without
7
+ // pressing Enter on every field. Pointer focus never opens here: mousedown
8
+ // records "pointer" modality before the focus event fires, and the press
9
+ // handler already owns the click path — so a click can never double-trigger.
10
+ //
11
+ // A PROGRAMMATIC focus restore (the editor returning focus to its closed view
12
+ // after Enter/Escape, or a popover restoring focus to its trigger on close)
13
+ // arms a short suppression window first, so the restored focus doesn't
14
+ // immediately re-open the editor it just closed.
15
+ //
16
+ // Pure logic, kept RN-free so it can be unit-tested directly.
17
+ // =============================================================================
18
+
19
+ import { type InteractionModality } from "./interaction_modality";
20
+
21
+ /** How long (ms) after a programmatic focus restore a focus event is treated as
22
+ * the restore itself rather than a fresh keyboard arrival. Restores run
23
+ * synchronously (effect cleanup) or within a frame — 250ms covers both with
24
+ * margin while never swallowing a real Tab press later. */
25
+ export const FOCUS_OPEN_SUPPRESS_MS = 250;
26
+
27
+ /**
28
+ * Should a focus event on a CLOSED inline editor open edit mode?
29
+ * `suppressedAt` is the timestamp of the last programmatic focus restore
30
+ * (null when none) — the caller keeps it in a ref and stamps it right before
31
+ * calling `.focus()`.
32
+ */
33
+ export function shouldOpenOnFocus(
34
+ modality: InteractionModality,
35
+ suppressedAt: number | null,
36
+ now: number,
37
+ ): boolean {
38
+ if (modality !== "keyboard") return false;
39
+ return suppressedAt === null || now - suppressedAt >= FOCUS_OPEN_SUPPRESS_MS;
40
+ }
@@ -0,0 +1,38 @@
1
+ // =============================================================================
2
+ // Interaction modality — the document-level keyboard-vs-pointer tracker
3
+ //
4
+ // `:focus-visible` is a MODALITY heuristic: a control reacts to keyboard focus
5
+ // but not pointer focus. One focus event carries no modality, so it can only be
6
+ // read at the document level: a single module-level tracker records the last
7
+ // interaction. Consumed by `useFocusRing` (paint the ring on keyboard focus
8
+ // only) and the inline editors (keyboard focus opens edit mode — type → Tab →
9
+ // type — while pointer focus defers to the press handler). Web-only — on
10
+ // native / SSR there is no `document`, so it stays "pointer" and everything
11
+ // keyboard-gated stays off.
12
+ // =============================================================================
13
+
14
+ export type InteractionModality = "keyboard" | "pointer";
15
+
16
+ let lastModality: InteractionModality = "pointer";
17
+ let listenersInstalled = false;
18
+
19
+ export function ensureModalityListeners(): void {
20
+ if (listenersInstalled) return;
21
+ if (typeof document === "undefined") return;
22
+ listenersInstalled = true;
23
+ // Capture phase so the modality is recorded BEFORE any control's focus handler
24
+ // runs. Installed once for the app's lifetime (the browser's own `:focus-visible`
25
+ // heuristic listens the same way) — per-mount add/remove would be the bug.
26
+ const opts = { capture: true, passive: true } as const;
27
+ document.addEventListener("keydown", () => { lastModality = "keyboard"; }, opts);
28
+ document.addEventListener("pointerdown", () => { lastModality = "pointer"; }, opts);
29
+ document.addEventListener("mousedown", () => { lastModality = "pointer"; }, opts);
30
+ document.addEventListener("touchstart", () => { lastModality = "pointer"; }, opts);
31
+ }
32
+
33
+ /** The modality of the most recent user interaction. Read it inside a focus
34
+ * handler to tell a Tab-focus ("keyboard") from a click-focus ("pointer") —
35
+ * the same signal the browser's `:focus-visible` uses. */
36
+ export function getInteractionModality(): InteractionModality {
37
+ return lastModality;
38
+ }
package/src/locale.tsx CHANGED
@@ -107,7 +107,7 @@ export const en: LoticsLocale = {
107
107
  descending: ", descending",
108
108
  },
109
109
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
110
- datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM" },
110
+ datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
111
111
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
112
112
  filterChip: { clear: "Clear" },
113
113
  floatingActionBar: { clear: "Clear" },
@@ -187,7 +187,7 @@ export const vi: LoticsLocale = {
187
187
  descending: " (giảm dần)",
188
188
  },
189
189
  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…" },
190
- 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", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH" },
190
+ 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", 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 đủ" },
191
191
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
192
192
  filterChip: { clear: "Xóa" },
193
193
  floatingActionBar: { clear: "Bỏ chọn" },