@lotics/ui 16.0.0 → 16.2.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/src/locale.tsx CHANGED
@@ -69,6 +69,18 @@ export interface LoticsLocale {
69
69
  trendFooter: { up: string; down: string };
70
70
  /** `SuggestionChip`: the press target's default name and the ✕ tooltip. */
71
71
  suggestionChip: { add: (label: string) => string; dismiss: string };
72
+ /** `ChecklistRow`'s per-row NOTE: the add affordance's name/tooltip, the note
73
+ * line's press name (it WRAPS the note text so the line is announced with
74
+ * it), and the editor's placeholder — plus the SUBTASK expander's name,
75
+ * given the count of unchecked children. The expander's open/closed state
76
+ * rides `aria-expanded`, so `subtasks` must NOT name it: one stable name,
77
+ * or every toggle re-announces the control. */
78
+ checklist: {
79
+ addNote: string;
80
+ editNote: (note: string) => string;
81
+ notePlaceholder: string;
82
+ subtasks: (missing: number) => string;
83
+ };
72
84
  /** `Confidence`: the full level phrase ("High confidence" …). */
73
85
  confidence: ConfidenceLabels;
74
86
  /** `Finding`: the severity badge word ("Critical" …). */
@@ -162,7 +174,7 @@ export const en: LoticsLocale = {
162
174
  descending: ", descending",
163
175
  },
164
176
  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", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date" },
177
+ 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" },
166
178
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
167
179
  filterChip: { clear: "Clear" },
168
180
  floatingActionBar: { clear: "Clear" },
@@ -176,6 +188,12 @@ export const en: LoticsLocale = {
176
188
  chip: { remove: "Remove" },
177
189
  trendFooter: { up: "Up", down: "Down" },
178
190
  suggestionChip: { add: (label) => `Add: ${label}`, dismiss: "Dismiss suggestion" },
191
+ checklist: {
192
+ addNote: "Add note",
193
+ editNote: (note) => `Edit note: ${note}`,
194
+ notePlaceholder: "Add a note…",
195
+ subtasks: (missing) => (missing > 0 ? `Subtasks: ${missing} left` : "Subtasks: all done"),
196
+ },
179
197
  confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
180
198
  remainderMeter: {
181
199
  applied: (allocated, total) => `${allocated} of ${total} applied`,
@@ -260,7 +278,7 @@ export const vi: LoticsLocale = {
260
278
  descending: " (giảm dần)",
261
279
  },
262
280
  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", 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 đủ" },
281
+ 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 đủ" },
264
282
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
265
283
  filterChip: { clear: "Xóa" },
266
284
  floatingActionBar: { clear: "Bỏ chọn" },
@@ -274,6 +292,12 @@ export const vi: LoticsLocale = {
274
292
  chip: { remove: "Xóa" },
275
293
  trendFooter: { up: "Tăng", down: "Giảm" },
276
294
  suggestionChip: { add: (label) => `Thêm: ${label}`, dismiss: "Bỏ gợi ý" },
295
+ checklist: {
296
+ addNote: "Thêm ghi chú",
297
+ editNote: (note) => `Sửa ghi chú: ${note}`,
298
+ notePlaceholder: "Thêm ghi chú…",
299
+ subtasks: (missing) => (missing > 0 ? `Việc con: còn ${missing}` : "Việc con: đã xong"),
300
+ },
277
301
  confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
278
302
  remainderMeter: {
279
303
  applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { pendingCommits, trackCommit } from "./pending_commits";
3
+
4
+ /** A field write that lands in `stored` after `ms`, like a save round trip. */
5
+ const commit = (stored: { value: string }, next: string, ms: number) =>
6
+ new Promise<void>((resolve) =>
7
+ setTimeout(() => {
8
+ stored.value = next;
9
+ resolve();
10
+ }, ms),
11
+ );
12
+
13
+ /** Let the registry's own settle callback run before asserting on it. */
14
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
15
+
16
+ describe("pendingCommits", () => {
17
+ it("is null with nothing in flight — a press keeps its synchronous path", () => {
18
+ expect(pendingCommits()).toBeNull();
19
+ });
20
+
21
+ it("holds until a commit still in flight has landed", async () => {
22
+ const stored = { value: "BKG CŨ: " };
23
+ void trackCommit(commit(stored, "BKG CŨ: SGNA52926200", 20));
24
+ const gate = pendingCommits();
25
+ expect(gate).not.toBeNull();
26
+ await gate;
27
+ expect(stored.value).toBe("BKG CŨ: SGNA52926200");
28
+ });
29
+
30
+ it("holds for EVERY commit in flight, not just the first", async () => {
31
+ const landed: string[] = [];
32
+ const land = (name: string, ms: number) =>
33
+ new Promise<void>((resolve) =>
34
+ setTimeout(() => {
35
+ landed.push(name);
36
+ resolve();
37
+ }, ms),
38
+ );
39
+ void trackCommit(land("slow", 30));
40
+ void trackCommit(land("fast", 5));
41
+ await pendingCommits();
42
+ expect([...landed].sort()).toEqual(["fast", "slow"]);
43
+ });
44
+
45
+ it("releases again once the commits have landed", async () => {
46
+ await trackCommit(Promise.resolve());
47
+ await tick();
48
+ expect(pendingCommits()).toBeNull();
49
+ });
50
+
51
+ it("is released by a FAILED commit too — a stuck gate would wedge every action", async () => {
52
+ const failed = trackCommit(Promise.reject(new Error("Save failed")));
53
+ // The caller owns the error: the inline editor keeps the edit and shows it.
54
+ await expect(failed).rejects.toThrow("Save failed");
55
+ await expect(pendingCommits() ?? Promise.resolve()).resolves.toBeUndefined();
56
+ await tick();
57
+ expect(pendingCommits()).toBeNull();
58
+ });
59
+
60
+ it("ignores a commit started AFTER the gate was taken — it holds the press for the edit that caused it, not later work", async () => {
61
+ const stored = { value: "before" };
62
+ void trackCommit(commit(stored, "first", 5));
63
+ const gate = pendingCommits();
64
+ void trackCommit(commit(stored, "later", 40));
65
+ await gate;
66
+ expect(stored.value).toBe("first");
67
+ });
68
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inline commits that are still writing, app-wide.
3
+ *
4
+ * An inline editor commits on BLUR, so pressing an action while a field is focused
5
+ * starts TWO things in ONE gesture: the field's write (the blur, on mousedown) and
6
+ * the action's handler (on mouseup). A handler that re-reads the record — to print a
7
+ * document, mint an invoice, copy the row — otherwise overtakes the still-in-flight
8
+ * write and acts on the PRE-EDIT value. It fails silently: the screen shows the new
9
+ * value and the output carries the old one.
10
+ *
11
+ * `useInlineEdit` registers every commit here and `Button`/`IconButton` hold their
12
+ * press for the ones already in flight, so the press lands AFTER the edit that caused
13
+ * it. There is nothing for an app to wire up, and nothing it can forget.
14
+ */
15
+
16
+ const inFlight = new Set<Promise<void>>();
17
+
18
+ /**
19
+ * Register a commit. Returns it UNCHANGED — the caller keeps its own result and error
20
+ * handling; the gate only needs to know when the write SETTLED, so a failed commit
21
+ * releases it just like a successful one (the field surfaces its own error).
22
+ */
23
+ export function trackCommit<T>(commit: Promise<T>): Promise<T> {
24
+ const settled = commit.then(
25
+ () => undefined,
26
+ () => undefined,
27
+ );
28
+ inFlight.add(settled);
29
+ void settled.then(() => {
30
+ inFlight.delete(settled);
31
+ });
32
+ return commit;
33
+ }
34
+
35
+ /**
36
+ * The commits in flight RIGHT NOW, or `null` when there are none — a caller takes its
37
+ * synchronous path on `null`, so a press with nothing pending behaves exactly as it
38
+ * always did. Commits started after this call are deliberately NOT awaited: the gate
39
+ * holds a press for the edit that caused it, never for later work.
40
+ */
41
+ export function pendingCommits(): Promise<void> | null {
42
+ if (inFlight.size === 0) return null;
43
+ const snapshot = Array.from(inFlight);
44
+ return Promise.all(snapshot).then(() => undefined);
45
+ }
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { pendingCommits } from "./pending_commits";
3
+
4
+ /**
5
+ * Makes an action's press land AFTER any inline commit the same gesture started —
6
+ * see {@link pendingCommits} for the race it closes.
7
+ *
8
+ * With nothing in flight the press dispatches SYNCHRONOUSLY, exactly as before, so the
9
+ * gate is invisible outside the racing case. While waiting the action reports busy, so
10
+ * the gesture reads as "working" rather than "ignored" and cannot double-fire.
11
+ */
12
+ export function useGatedPress<E>(onPress?: (event: E) => void) {
13
+ const [waiting, setWaiting] = useState(false);
14
+ // Re-armed on mount: a StrictMode remount must not leave the ref stuck at false.
15
+ const mounted = useRef(true);
16
+ useEffect(() => {
17
+ mounted.current = true;
18
+ return () => {
19
+ mounted.current = false;
20
+ };
21
+ }, []);
22
+
23
+ const handlePress = useCallback(
24
+ (event: E) => {
25
+ const pending = pendingCommits();
26
+ if (!pending) {
27
+ onPress?.(event);
28
+ return;
29
+ }
30
+ setWaiting(true);
31
+ void pending.then(() => {
32
+ if (!mounted.current) return;
33
+ setWaiting(false);
34
+ onPress?.(event);
35
+ });
36
+ },
37
+ [onPress],
38
+ );
39
+
40
+ return { handlePress, waiting };
41
+ }