@lotics/ui 20.0.0 → 20.0.2
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/LICENSE.md +39 -0
- package/docs/data_entry.md +5 -0
- package/package.json +5 -3
- package/src/chip_group.tsx +8 -1
- package/src/control_surface.ts +7 -0
- package/src/detail_row.tsx +2 -5
- package/src/popover.tsx +19 -1
- package/src/agent_transform.test.ts +0 -96
- package/src/calendar/layout.test.ts +0 -103
- package/src/colors.test.ts +0 -45
- package/src/date_filter_presets.test.ts +0 -62
- package/src/date_picker_value.test.ts +0 -204
- package/src/date_range_selection.test.ts +0 -60
- package/src/date_segments.test.ts +0 -335
- package/src/file_badge_fit.test.ts +0 -49
- package/src/file_intake.test.ts +0 -124
- package/src/format_date.test.ts +0 -138
- package/src/format_date_negative_zone.test.ts +0 -26
- package/src/gantt/scale.test.ts +0 -47
- package/src/inline_focus.test.ts +0 -49
- package/src/matrix.test.ts +0 -54
- package/src/pending_commits.test.ts +0 -68
- package/src/popover_layers.test.ts +0 -113
- package/src/primitives_purity.test.ts +0 -73
- package/src/remark_gfm_safe.test.ts +0 -33
- package/src/share_or_download.test.ts +0 -92
- package/src/table.test.ts +0 -90
- package/src/task_metrics.test.ts +0 -48
- package/src/use_list_keyboard_nav.test.ts +0 -184
- package/src/use_option_list.test.ts +0 -232
- package/src/use_section_nav.test.ts +0 -171
- package/src/use_selection.test.ts +0 -66
- package/src/vite.test.ts +0 -41
package/src/file_intake.test.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { matchesAccept, filesFromTransfer, selectPasteSink, type FileTransferLike, type PasteSinkEntry } from "./file_intake";
|
|
3
|
-
|
|
4
|
-
const file = (name: string, type: string) => new File([new Uint8Array([1])], name, { type });
|
|
5
|
-
|
|
6
|
-
/** A clipboard/drag payload that surfaces its files only through `items` —
|
|
7
|
-
* what a pasted screenshot looks like on the engines that do that. */
|
|
8
|
-
const itemsOnly = (files: File[]): FileTransferLike => ({
|
|
9
|
-
files: [],
|
|
10
|
-
items: files.map((f) => ({ kind: "file" as const, getAsFile: () => f })),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("matchesAccept", () => {
|
|
14
|
-
it("accepts everything when the list is absent or empty", () => {
|
|
15
|
-
expect(matchesAccept(file("a.pdf", "application/pdf"), undefined)).toBe(true);
|
|
16
|
-
expect(matchesAccept(file("a.pdf", "application/pdf"), "")).toBe(true);
|
|
17
|
-
expect(matchesAccept(file("a.pdf", "application/pdf"), " , ")).toBe(true);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("matches an exact MIME, case-insensitively", () => {
|
|
21
|
-
expect(matchesAccept(file("a.pdf", "APPLICATION/PDF"), "application/pdf")).toBe(true);
|
|
22
|
-
expect(matchesAccept(file("a.png", "image/png"), "application/pdf")).toBe(false);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("matches a wildcard type", () => {
|
|
26
|
-
expect(matchesAccept(file("a.png", "image/png"), "image/*")).toBe(true);
|
|
27
|
-
expect(matchesAccept(file("a.pdf", "application/pdf"), "image/*")).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("matches an extension pattern by filename when the MIME is unknown", () => {
|
|
31
|
-
expect(matchesAccept(file("Report.CSV", ""), ".csv")).toBe(true);
|
|
32
|
-
expect(matchesAccept(file("report.csvx", ""), ".csv")).toBe(false);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("accepts a file matching ANY pattern in the list", () => {
|
|
36
|
-
expect(matchesAccept(file("a.png", "image/png"), "application/pdf, image/*")).toBe(true);
|
|
37
|
-
expect(matchesAccept(file("a.zip", "application/zip"), "application/pdf, image/*")).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("filesFromTransfer", () => {
|
|
42
|
-
it("returns [] for a missing transfer", () => {
|
|
43
|
-
expect(filesFromTransfer(null)).toEqual([]);
|
|
44
|
-
expect(filesFromTransfer(undefined)).toEqual([]);
|
|
45
|
-
expect(filesFromTransfer({})).toEqual([]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("reads the direct file list", () => {
|
|
49
|
-
const a = file("a.pdf", "application/pdf");
|
|
50
|
-
expect(filesFromTransfer({ files: [a] })).toEqual([a]);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("falls back to clipboard items when the file list is empty", () => {
|
|
54
|
-
const shot = file("image.png", "image/png");
|
|
55
|
-
expect(filesFromTransfer(itemsOnly([shot]))).toEqual([shot]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("never doubles a file present in BOTH files and items", () => {
|
|
59
|
-
const a = file("a.png", "image/png");
|
|
60
|
-
expect(filesFromTransfer({ files: [a], items: [{ kind: "file", getAsFile: () => a }] })).toEqual([a]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("skips non-file clipboard items and null getAsFile results", () => {
|
|
64
|
-
const a = file("a.png", "image/png");
|
|
65
|
-
const transfer: FileTransferLike = {
|
|
66
|
-
items: [
|
|
67
|
-
{ kind: "string", getAsFile: () => null },
|
|
68
|
-
{ kind: "file", getAsFile: () => null },
|
|
69
|
-
{ kind: "file", getAsFile: () => a },
|
|
70
|
-
],
|
|
71
|
-
};
|
|
72
|
-
expect(filesFromTransfer(transfer)).toEqual([a]);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("filters by accept", () => {
|
|
76
|
-
const pdf = file("a.pdf", "application/pdf");
|
|
77
|
-
const zip = file("a.zip", "application/zip");
|
|
78
|
-
expect(filesFromTransfer({ files: [pdf, zip] }, { accept: "application/pdf" })).toEqual([pdf]);
|
|
79
|
-
expect(filesFromTransfer(itemsOnly([pdf, zip]), { accept: "application/pdf" })).toEqual([pdf]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("keeps every file by default and caps at one when multiple is false", () => {
|
|
83
|
-
const a = file("a.pdf", "application/pdf");
|
|
84
|
-
const b = file("b.pdf", "application/pdf");
|
|
85
|
-
expect(filesFromTransfer({ files: [a, b] })).toEqual([a, b]);
|
|
86
|
-
expect(filesFromTransfer({ files: [a, b] }, { multiple: false })).toEqual([a]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("caps AFTER filtering, so a rejected first file does not eat the slot", () => {
|
|
90
|
-
const zip = file("a.zip", "application/zip");
|
|
91
|
-
const pdf = file("b.pdf", "application/pdf");
|
|
92
|
-
expect(filesFromTransfer({ files: [zip, pdf] }, { accept: "application/pdf", multiple: false })).toEqual([pdf]);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("selectPasteSink", () => {
|
|
97
|
-
const entry = (sink: string, focused: boolean): PasteSinkEntry<string> => ({ sink, focused });
|
|
98
|
-
|
|
99
|
-
it("returns undefined for an empty stack", () => {
|
|
100
|
-
expect(selectPasteSink<string>([])).toBeUndefined();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("falls back to the TOP of the stack when nothing is focused", () => {
|
|
104
|
-
expect(selectPasteSink([entry("a", false), entry("b", false), entry("c", false)])).toBe("c");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("routes to a focused region over the (later-mounted) top of the stack", () => {
|
|
108
|
-
// b holds focus; c is on top but unfocused — b wins.
|
|
109
|
-
expect(selectPasteSink([entry("a", false), entry("b", true), entry("c", false)])).toBe("b");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("picks the TOP-MOST focused region when several are focused", () => {
|
|
113
|
-
// Nested regions can both contain activeElement; the innermost/last wins.
|
|
114
|
-
expect(selectPasteSink([entry("a", true), entry("b", true), entry("c", false)])).toBe("b");
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("a single region-less sink still gets the paste (byte-for-byte old behavior)", () => {
|
|
118
|
-
expect(selectPasteSink([entry("only", false)])).toBe("only");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("a focused region beats an unfocused one mounted after it", () => {
|
|
122
|
-
expect(selectPasteSink([entry("focused", true), entry("later", false)])).toBe("focused");
|
|
123
|
-
});
|
|
124
|
-
});
|
package/src/format_date.test.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { formatDate, parseDate, toISODate } from "./format_date";
|
|
3
|
-
|
|
4
|
-
describe("formatDate", () => {
|
|
5
|
-
test("formats an ISO date in the home market (dd/MM/yyyy)", () => {
|
|
6
|
-
expect(formatDate("2026-05-22", { locale: "vi-VN" })).toBe("22/05/2026");
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
test("formats a Date object, not just an ISO string", () => {
|
|
10
|
-
expect(formatDate(new Date(2026, 4, 22), { locale: "vi-VN" })).toBe("22/05/2026");
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("compact drops the year (dd/MM)", () => {
|
|
14
|
-
expect(formatDate("2026-05-22", { locale: "vi-VN", compact: true })).toBe("22/05");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("time prepends the 24h time, then the locale date", () => {
|
|
18
|
-
expect(formatDate("2026-05-22T14:30", { locale: "vi-VN", time: true })).toBe("14:30 22/05/2026");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("time + compact is time-first and drops the year", () => {
|
|
22
|
-
expect(formatDate("2026-05-22T09:05", { locale: "vi-VN", time: true, compact: true })).toBe("09:05 22/05");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("time composes with a readable style — the orthogonal axis", () => {
|
|
26
|
-
expect(formatDate("2026-09-22T14:30", { format: "long", time: true, locale: "en-US" })).toBe("14:30 September 22, 2026");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("medium — readable, abbreviated month", () => {
|
|
30
|
-
expect(formatDate("2026-05-22", { format: "medium", locale: "en-US" })).toBe("May 22, 2026");
|
|
31
|
-
// vi word output is ICU-dependent; assert the day + year are present (day-first locale).
|
|
32
|
-
expect(formatDate("2026-05-22", { format: "medium", locale: "vi-VN" })).toMatch(/22.*2026/);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("long — readable, full month name", () => {
|
|
36
|
-
expect(formatDate("2026-09-22", { format: "long", locale: "en-US" })).toBe("September 22, 2026");
|
|
37
|
-
expect(formatDate("2026-09-22", { format: "long", locale: "vi-VN" })).toMatch(/22.*2026/);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("dayMonth — day + abbreviated month, no year", () => {
|
|
41
|
-
expect(formatDate("2026-09-22", { format: "dayMonth", locale: "en-US" })).toBe("Sep 22");
|
|
42
|
-
expect(formatDate("2026-09-22", { format: "dayMonth", locale: "vi-VN" })).not.toMatch(/2026/);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("monthYear — a period label, sentence-cased", () => {
|
|
46
|
-
expect(formatDate("2026-05-22", { format: "monthYear", locale: "en-US" })).toBe("May 2026");
|
|
47
|
-
// Leading character is upper-cased even where the locale lowercases the month name.
|
|
48
|
-
const vi = formatDate("2026-05-22", { format: "monthYear", locale: "vi-VN" });
|
|
49
|
-
expect(vi.charAt(0)).toBe(vi.charAt(0).toUpperCase());
|
|
50
|
-
expect(vi).toMatch(/2026/);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("date-only ISO does not drift across local timezone (wall-clock parse)", () => {
|
|
54
|
-
// `new Date('2026-01-01')` is UTC midnight; in negative-offset zones it formats as Dec 31.
|
|
55
|
-
expect(formatDate("2026-01-01", { locale: "vi-VN" })).toBe("01/01/2026");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("empty input returns the empty label", () => {
|
|
59
|
-
expect(formatDate(null)).toBe("");
|
|
60
|
-
expect(formatDate("")).toBe("");
|
|
61
|
-
expect(formatDate(undefined, { emptyLabel: "—" })).toBe("—");
|
|
62
|
-
expect(formatDate("not-a-date", { emptyLabel: "—" })).toBe("—");
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("formatDate — reduced precision", () => {
|
|
67
|
-
test("month value renders MM/YYYY (numeric, locale order), no fabricated day", () => {
|
|
68
|
-
expect(formatDate("2026-05", { locale: "vi-VN" })).toBe("05/2026");
|
|
69
|
-
expect(formatDate("2026-05", { locale: "en-US" })).toBe("05/2026");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("year value renders just the year", () => {
|
|
73
|
-
expect(formatDate("2026", { locale: "vi-VN" })).toBe("2026");
|
|
74
|
-
expect(formatDate("2026", { format: "medium", locale: "en-US" })).toBe("2026");
|
|
75
|
-
expect(formatDate("2026", { format: "monthYear", locale: "en-US" })).toBe("2026");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("compact drops the year on a month value", () => {
|
|
79
|
-
expect(formatDate("2026-05", { locale: "vi-VN", compact: true })).toBe("05");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("readable styles drop the day for a month value", () => {
|
|
83
|
-
expect(formatDate("2026-05", { format: "monthYear", locale: "en-US" })).toBe("May 2026");
|
|
84
|
-
expect(formatDate("2026-05", { format: "medium", locale: "en-US" })).toBe("May 2026");
|
|
85
|
-
expect(formatDate("2026-05", { format: "long", locale: "en-US" })).toBe("May 2026");
|
|
86
|
-
expect(formatDate("2026-05", { format: "dayMonth", locale: "en-US" })).toBe("May");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("time is ignored for a reduced-precision value (a period has no clock)", () => {
|
|
90
|
-
expect(formatDate("2026-05", { locale: "vi-VN", time: true })).toBe("05/2026");
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe("parseDate", () => {
|
|
95
|
-
test("parses an ISO datetime to local wall-clock", () => {
|
|
96
|
-
const d = parseDate("2026-05-22T14:30");
|
|
97
|
-
expect(d).not.toBeNull();
|
|
98
|
-
expect(d?.getFullYear()).toBe(2026);
|
|
99
|
-
expect(d?.getMonth()).toBe(4);
|
|
100
|
-
expect(d?.getDate()).toBe(22);
|
|
101
|
-
expect(d?.getHours()).toBe(14);
|
|
102
|
-
expect(d?.getMinutes()).toBe(30);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("passes a valid Date through and rejects an invalid one", () => {
|
|
106
|
-
const d = new Date(2026, 0, 1);
|
|
107
|
-
expect(parseDate(d)).toBe(d);
|
|
108
|
-
expect(parseDate(new Date("nonsense"))).toBeNull();
|
|
109
|
-
expect(parseDate(null)).toBeNull();
|
|
110
|
-
expect(parseDate("")).toBeNull();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("parses a reduced-precision value to its period start", () => {
|
|
114
|
-
const month = parseDate("2026-05");
|
|
115
|
-
expect(month?.getFullYear()).toBe(2026);
|
|
116
|
-
expect(month?.getMonth()).toBe(4);
|
|
117
|
-
expect(month?.getDate()).toBe(1);
|
|
118
|
-
const year = parseDate("2026");
|
|
119
|
-
expect(year?.getFullYear()).toBe(2026);
|
|
120
|
-
expect(year?.getMonth()).toBe(0);
|
|
121
|
-
expect(year?.getDate()).toBe(1);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe("toISODate", () => {
|
|
126
|
-
test("Date or ISO value → yyyy-MM-dd", () => {
|
|
127
|
-
expect(toISODate(new Date(2026, 4, 22))).toBe("2026-05-22");
|
|
128
|
-
expect(toISODate("2026-05-22T14:30")).toBe("2026-05-22");
|
|
129
|
-
expect(toISODate(null)).toBe("");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("PRESERVES reduced precision verbatim — never fabricates a day", () => {
|
|
133
|
-
expect(toISODate("2026-05")).toBe("2026-05");
|
|
134
|
-
expect(toISODate("2026")).toBe("2026");
|
|
135
|
-
// Whitespace-trimmed but otherwise unchanged.
|
|
136
|
-
expect(toISODate(" 2026-05 ")).toBe("2026-05");
|
|
137
|
-
});
|
|
138
|
-
});
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// Pin a NEGATIVE-offset zone (UTC-5) before importing the formatter. The old
|
|
2
|
-
// fall-through `new Date("2026-05")` parses as UTC midnight, which in a
|
|
3
|
-
// negative-offset zone lands on the PREVIOUS month ("2026-04-30T19:00"), drifting
|
|
4
|
-
// a month-precision value back a month. The naive part-based construction must
|
|
5
|
-
// keep "2026-05" on May regardless of the runtime zone.
|
|
6
|
-
process.env.TZ = "America/New_York";
|
|
7
|
-
|
|
8
|
-
import { describe, expect, test } from "vitest";
|
|
9
|
-
import { formatDate, toISODate } from "./format_date";
|
|
10
|
-
|
|
11
|
-
describe("formatDate — reduced precision in a negative-offset zone", () => {
|
|
12
|
-
test("a month value does not drift to the previous month", () => {
|
|
13
|
-
expect(formatDate("2026-05", { locale: "en-US" })).toBe("05/2026");
|
|
14
|
-
// January is the adversarial case — a backward drift would land in Dec 2025.
|
|
15
|
-
expect(formatDate("2026-01", { locale: "en-US" })).toBe("01/2026");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("a year value stays on its year", () => {
|
|
19
|
-
expect(formatDate("2026", { locale: "en-US" })).toBe("2026");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("toISODate preserves the partial (no drift, no fabricated day)", () => {
|
|
23
|
-
expect(toISODate("2026-01")).toBe("2026-01");
|
|
24
|
-
expect(toISODate("2026")).toBe("2026");
|
|
25
|
-
});
|
|
26
|
-
});
|
package/src/gantt/scale.test.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { axisRange, barGeometry, buildTicks, pxPerDay } from "./scale";
|
|
3
|
-
import type { GanttTask } from "./types";
|
|
4
|
-
|
|
5
|
-
const AXIS = new Date(2026, 0, 1); // Jan 1 2026
|
|
6
|
-
const task = (id: string, s: [number, number], e?: [number, number]): GanttTask => ({
|
|
7
|
-
id,
|
|
8
|
-
label: id,
|
|
9
|
-
start: new Date(2026, s[0], s[1]),
|
|
10
|
-
end: e ? new Date(2026, e[0], e[1]) : null,
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("gantt scale", () => {
|
|
14
|
-
it("zoom widths shrink as the scale widens", () => {
|
|
15
|
-
expect(pxPerDay("day")).toBeGreaterThan(pxPerDay("week"));
|
|
16
|
-
expect(pxPerDay("week")).toBeGreaterThan(pxPerDay("month"));
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("positions a bar by day offset and inclusive span", () => {
|
|
20
|
-
// Jan 3 → Jan 5 inclusive = 3 days, 2 days after the axis start.
|
|
21
|
-
const b = barGeometry(task("a", [0, 3], [0, 5]), AXIS, "day");
|
|
22
|
-
expect(b.left).toBe(2 * 40);
|
|
23
|
-
expect(b.width).toBe(3 * 40);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("gives a single-day task one day of width (floored)", () => {
|
|
27
|
-
const b = barGeometry(task("m", [0, 10]), AXIS, "day");
|
|
28
|
-
expect(b.width).toBe(40);
|
|
29
|
-
const tiny = barGeometry(task("m", [0, 10]), AXIS, "month"); // 1 * 5px → floored to 8
|
|
30
|
-
expect(tiny.width).toBe(8);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("derives a padded axis window from the tasks", () => {
|
|
34
|
-
const { start, end } = axisRange([task("a", [0, 5], [0, 8]), task("b", [0, 3], [0, 20])], new Date(2026, 0, 1), 3);
|
|
35
|
-
expect(start).toEqual(new Date(2026, 0, 0)); // Jan 3 - 3 = Dec 31 (= Jan 0)
|
|
36
|
-
expect(end).toEqual(new Date(2026, 0, 23)); // Jan 20 + 3
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("emits one day-tick per day and flags month starts", () => {
|
|
40
|
-
const ticks = buildTicks(new Date(2026, 0, 30), new Date(2026, 1, 2), "day");
|
|
41
|
-
expect(ticks.map((t) => t.label)).toEqual(["30", "31", "1", "2"]);
|
|
42
|
-
expect(ticks.find((t) => t.label === "1")?.monthStart).toBe(true);
|
|
43
|
-
expect(ticks.find((t) => t.label === "30")?.monthStart).toBe(false);
|
|
44
|
-
expect(ticks[0].left).toBe(0);
|
|
45
|
-
expect(ticks[1].left).toBe(40); // one day across at day scale
|
|
46
|
-
});
|
|
47
|
-
});
|
package/src/inline_focus.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
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, 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
|
-
});
|
|
15
|
-
|
|
16
|
-
describe("shouldOpenOnFocus — the inline keyboard-entry gate", () => {
|
|
17
|
-
it("keyboard focus opens the editor", () => {
|
|
18
|
-
expect(shouldOpenOnFocus("keyboard", null, 1_000)).toBe(true);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("pointer focus never opens the editor — the click path stays press-driven", () => {
|
|
22
|
-
expect(shouldOpenOnFocus("pointer", null, 1_000)).toBe(false);
|
|
23
|
-
// …even outside any suppression window.
|
|
24
|
-
expect(shouldOpenOnFocus("pointer", 100, 100_000)).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("a programmatic focus restore inside the suppression window does not re-open", () => {
|
|
28
|
-
const restoredAt = 5_000;
|
|
29
|
-
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt)).toBe(false);
|
|
30
|
-
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS - 1)).toBe(false);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("suppression expires — a later Tab arrival opens again", () => {
|
|
34
|
-
const restoredAt = 5_000;
|
|
35
|
-
expect(shouldOpenOnFocus("keyboard", restoredAt, restoredAt + FOCUS_OPEN_SUPPRESS_MS)).toBe(true);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("interaction modality tracker", () => {
|
|
40
|
-
it("records keyboard on keydown and pointer on mousedown (Tab-vs-click focus)", () => {
|
|
41
|
-
ensureModalityListeners();
|
|
42
|
-
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
|
|
43
|
-
expect(getInteractionModality()).toBe("keyboard");
|
|
44
|
-
document.dispatchEvent(new MouseEvent("mousedown"));
|
|
45
|
-
expect(getInteractionModality()).toBe("pointer");
|
|
46
|
-
document.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
|
47
|
-
expect(getInteractionModality()).toBe("keyboard");
|
|
48
|
-
});
|
|
49
|
-
});
|
package/src/matrix.test.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { matrixTotals, type MatrixAxisItem } from "./matrix_totals";
|
|
3
|
-
|
|
4
|
-
const rows: MatrixAxisItem[] = [
|
|
5
|
-
{ key: "msc", label: "MSC" },
|
|
6
|
-
{ key: "one", label: "ONE" },
|
|
7
|
-
];
|
|
8
|
-
const cols: MatrixAxisItem[] = [
|
|
9
|
-
{ key: "20", label: "20ft" },
|
|
10
|
-
{ key: "40", label: "40ft" },
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
// MSC: 20→5, 40→3 ; ONE: 20→0, 40→7
|
|
14
|
-
const grid: Record<string, Record<string, number>> = {
|
|
15
|
-
msc: { "20": 5, "40": 3 },
|
|
16
|
-
one: { "20": 0, "40": 7 },
|
|
17
|
-
};
|
|
18
|
-
const value = (r: string, c: string) => grid[r]?.[c] ?? 0;
|
|
19
|
-
|
|
20
|
-
describe("matrixTotals", () => {
|
|
21
|
-
it("sums each row across its columns", () => {
|
|
22
|
-
const { rowTotals } = matrixTotals(rows, cols, value);
|
|
23
|
-
expect(rowTotals.get("msc")).toBe(8);
|
|
24
|
-
expect(rowTotals.get("one")).toBe(7);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("sums each column across its rows", () => {
|
|
28
|
-
const { colTotals } = matrixTotals(rows, cols, value);
|
|
29
|
-
expect(colTotals.get("20")).toBe(5);
|
|
30
|
-
expect(colTotals.get("40")).toBe(10);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("grand total equals the sum of every cell", () => {
|
|
34
|
-
expect(matrixTotals(rows, cols, value).grandTotal).toBe(15);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("max is the largest single cell, for the heat scale", () => {
|
|
38
|
-
expect(matrixTotals(rows, cols, value).max).toBe(7);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("is all-zero for an empty axis", () => {
|
|
42
|
-
const r = matrixTotals([], cols, value);
|
|
43
|
-
expect(r.grandTotal).toBe(0);
|
|
44
|
-
expect(r.max).toBe(0);
|
|
45
|
-
expect(r.colTotals.get("20")).toBeUndefined();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("counts a fully-empty grid as zero without dividing by it", () => {
|
|
49
|
-
const zero = matrixTotals(rows, cols, () => 0);
|
|
50
|
-
expect(zero.grandTotal).toBe(0);
|
|
51
|
-
expect(zero.max).toBe(0);
|
|
52
|
-
expect(zero.rowTotals.get("msc")).toBe(0);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, expect, test, beforeEach } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
hasModalLayerAbove,
|
|
5
|
-
isInLayerAbove,
|
|
6
|
-
snapshotOpenModalLayers,
|
|
7
|
-
} from "./popover_layers";
|
|
8
|
-
|
|
9
|
-
function addModal(): HTMLElement {
|
|
10
|
-
const modal = document.createElement("div");
|
|
11
|
-
modal.setAttribute("aria-modal", "true");
|
|
12
|
-
const inner = document.createElement("button");
|
|
13
|
-
modal.appendChild(inner);
|
|
14
|
-
document.body.appendChild(modal);
|
|
15
|
-
return modal;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function addPopover(level: number): HTMLElement {
|
|
19
|
-
const popover = document.createElement("div");
|
|
20
|
-
popover.setAttribute("data-popover", "true");
|
|
21
|
-
popover.setAttribute("data-popover-level", String(level));
|
|
22
|
-
const inner = document.createElement("button");
|
|
23
|
-
popover.appendChild(inner);
|
|
24
|
-
document.body.appendChild(popover);
|
|
25
|
-
return popover;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
document.body.innerHTML = "";
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("isInLayerAbove", () => {
|
|
33
|
-
test("plain page node is not above", () => {
|
|
34
|
-
const el = document.createElement("div");
|
|
35
|
-
document.body.appendChild(el);
|
|
36
|
-
expect(isInLayerAbove(el, 0, new Set())).toBe(false);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("document itself (page scroll target) is not above", () => {
|
|
40
|
-
expect(isInLayerAbove(document, 0, new Set())).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("text node resolves through its parent element", () => {
|
|
44
|
-
const modal = addModal();
|
|
45
|
-
const text = document.createTextNode("hello");
|
|
46
|
-
modal.appendChild(text);
|
|
47
|
-
expect(isInLayerAbove(text, 0, new Set())).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("node inside a deeper nested popover is above", () => {
|
|
51
|
-
const nested = addPopover(1);
|
|
52
|
-
expect(isInLayerAbove(nested.firstChild as Node, 0, new Set())).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("node inside a same-level popover is not above", () => {
|
|
56
|
-
const sibling = addPopover(0);
|
|
57
|
-
expect(isInLayerAbove(sibling.firstChild as Node, 0, new Set())).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("node inside a modal opened AFTER the popover is above (file preview)", () => {
|
|
61
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
62
|
-
const preview = addModal();
|
|
63
|
-
expect(isInLayerAbove(preview.firstChild as Node, 0, snapshot)).toBe(true);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("node inside the modal the popover LIVES IN is not above (drawer scroll dismisses)", () => {
|
|
67
|
-
const drawer = addModal();
|
|
68
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
69
|
-
expect(isInLayerAbove(drawer.firstChild as Node, 0, snapshot)).toBe(false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("stacked modals: only the one opened after the popover is above", () => {
|
|
73
|
-
const drawer = addModal();
|
|
74
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
75
|
-
const preview = addModal();
|
|
76
|
-
expect(isInLayerAbove(drawer.firstChild as Node, 0, snapshot)).toBe(false);
|
|
77
|
-
expect(isInLayerAbove(preview.firstChild as Node, 0, snapshot)).toBe(true);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("null snapshot treats any modal ancestor as above", () => {
|
|
81
|
-
const modal = addModal();
|
|
82
|
-
expect(isInLayerAbove(modal.firstChild as Node, 0, null)).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe("hasModalLayerAbove", () => {
|
|
87
|
-
test("false with no modals", () => {
|
|
88
|
-
expect(hasModalLayerAbove(document, new Set())).toBe(false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("false while only pre-existing modals are open (popover in a drawer)", () => {
|
|
92
|
-
addModal();
|
|
93
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
94
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("true while a modal opened after the popover is open, false after it closes", () => {
|
|
98
|
-
addModal();
|
|
99
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
100
|
-
const preview = addModal();
|
|
101
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(true);
|
|
102
|
-
preview.remove();
|
|
103
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("a reopened modal is a NEW element and counts as above", () => {
|
|
107
|
-
const snapshot = snapshotOpenModalLayers(document);
|
|
108
|
-
const first = addModal();
|
|
109
|
-
first.remove();
|
|
110
|
-
addModal();
|
|
111
|
-
expect(hasModalLayerAbove(document, snapshot)).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
});
|