@lotics/ui 11.8.0 → 11.8.1

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.
@@ -39,9 +39,12 @@ input-swap editor — `InlineTextInput`, `InlineNumberInput`, `InlineTimePicker`
39
39
  `InlineDatePicker` — opens edit mode immediately with the input focused; commit-on-blur then
40
40
  makes Tab itself the commit, so the chain is type → Tab → type with the next editor already
41
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.
42
+ — `interaction_modality.ts`), so the click path is exactly what it always was. When a
43
+ **keyboard** close (Enter/Escape) leaves focus on `<body>`, the editor returns focus to its
44
+ resting view so the next Tab continues from the field. A **pointer** close (click ✓/✕, or
45
+ click away onto non-focusable space) does NOT restore — a mouse user has no next Tab to
46
+ preserve, and a bare `.focus()` would scroll the target into view, jerking the page (the
47
+ restore is gated on modality via `shouldRestoreFocusOnClose`).
45
48
 
46
49
  **Typed dates.** `InlineDatePicker`'s keyboard mode is the kit's segmented `DateField`: type
47
50
  the date in the locale's own field order (dd/MM/yyyy where the locale says so — pass
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "11.8.0",
3
+ "version": "11.8.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -12,7 +12,7 @@ import { ActivityIndicator } from "./activity_indicator";
12
12
  import { type InlineEditBackground, InlineEditView } from "./inline_edit";
13
13
  import { useLoticsLocale } from "./locale";
14
14
  import { getInteractionModality } from "./interaction_modality";
15
- import { shouldOpenOnFocus } from "./inline_focus";
15
+ import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
16
16
 
17
17
  export interface InlineDatePickerProps {
18
18
  /** ISO date (`2026-05-22`) or datetime (`2026-05-22T14:30`). */
@@ -196,8 +196,19 @@ export function InlineDatePicker(props: InlineDatePickerProps) {
196
196
  // from the top of the page. Return focus to the resting view, arming the
197
197
  // suppression window so the restore doesn't re-open the session. A Tab-away
198
198
  // blur-commit leaves focus on the next field, so this never steals it back.
199
+ //
200
+ // Gate on KEYBOARD modality (see InlineEditFrame): the restore serves keyboard
201
+ // Tab-order only. A pointer close (click the calendar button, or click away)
202
+ // also lands on <body>, but restoring focus there jumps the scroll (a bare
203
+ // `.focus()` scrolls the target into view, often to the top). Skip for pointer.
199
204
  useEffect(() => {
200
- if (wasEditing.current && !editing && typeof document !== "undefined" && document.activeElement === document.body) {
205
+ if (
206
+ wasEditing.current &&
207
+ !editing &&
208
+ typeof document !== "undefined" &&
209
+ document.activeElement === document.body &&
210
+ shouldRestoreFocusOnClose(getInteractionModality())
211
+ ) {
201
212
  suppressedAt.current = Date.now();
202
213
  anchorRef.current?.focus();
203
214
  }
@@ -9,7 +9,7 @@ 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
11
  import { getInteractionModality } from "./interaction_modality";
12
- import { shouldOpenOnFocus } from "./inline_focus";
12
+ import { shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
13
13
 
14
14
  /** The kit's standard control height (TextInputField, NumberInput, Picker, …).
15
15
  * The view box matches it — same height, padding, and a 1px transparent border
@@ -241,8 +241,21 @@ export function InlineEditFrame(props: InlineEditFrameProps) {
241
241
  // arming the suppression window so the programmatic focus doesn't re-open the
242
242
  // editor it just closed. A Tab-away blur-commit leaves focus on the next field
243
243
  // (not <body>), so this never steals focus back.
244
+ //
245
+ // Gate on KEYBOARD modality: this restore exists solely for keyboard Tab-order
246
+ // continuity. A POINTER close (click ✓/✕, or click away onto non-focusable
247
+ // space) also drops focus to <body>, but restoring it there both steals focus
248
+ // from a mouse user AND — because a bare `.focus()` scrolls the target into
249
+ // view — jumps the scroll position (often to the top of the page). A mouse
250
+ // user has no "next Tab" to preserve, so skip the restore for pointer closes.
244
251
  useEffect(() => {
245
- if (wasEditing.current && !editing && typeof document !== "undefined" && document.activeElement === document.body) {
252
+ if (
253
+ wasEditing.current &&
254
+ !editing &&
255
+ typeof document !== "undefined" &&
256
+ document.activeElement === document.body &&
257
+ shouldRestoreFocusOnClose(getInteractionModality())
258
+ ) {
246
259
  suppressedAt.current = Date.now();
247
260
  viewRef.current?.focus();
248
261
  }
@@ -1,7 +1,17 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, it, expect } from "vitest";
3
3
  import { ensureModalityListeners, getInteractionModality } from "./interaction_modality";
4
- import { FOCUS_OPEN_SUPPRESS_MS, shouldOpenOnFocus } from "./inline_focus";
4
+ import { FOCUS_OPEN_SUPPRESS_MS, shouldOpenOnFocus, shouldRestoreFocusOnClose } from "./inline_focus";
5
+
6
+ describe("shouldRestoreFocusOnClose — no scroll jump on a pointer close", () => {
7
+ it("restores focus on a keyboard close (Enter/Escape) — preserves Tab order", () => {
8
+ expect(shouldRestoreFocusOnClose("keyboard")).toBe(true);
9
+ });
10
+
11
+ it("does NOT restore on a pointer close (click ✓/✕ or click away) — a bare focus() would scroll the page", () => {
12
+ expect(shouldRestoreFocusOnClose("pointer")).toBe(false);
13
+ });
14
+ });
5
15
 
6
16
  describe("shouldOpenOnFocus — the inline keyboard-entry gate", () => {
7
17
  it("keyboard focus opens the editor", () => {
@@ -38,3 +38,15 @@ export function shouldOpenOnFocus(
38
38
  if (modality !== "keyboard") return false;
39
39
  return suppressedAt === null || now - suppressedAt >= FOCUS_OPEN_SUPPRESS_MS;
40
40
  }
41
+
42
+ /**
43
+ * When an inline editor closes and focus has dropped to `<body>`, should it
44
+ * restore focus to its resting view? ONLY for a keyboard close — the restore
45
+ * exists solely to preserve Tab order. A POINTER close (click ✓/✕, or click
46
+ * away onto non-focusable space) must NOT restore: a bare `.focus()` scrolls
47
+ * the target into view, jumping the scroll position (often to the top), and a
48
+ * mouse user has no "next Tab" to preserve.
49
+ */
50
+ export function shouldRestoreFocusOnClose(modality: InteractionModality): boolean {
51
+ return modality === "keyboard";
52
+ }