@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
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Primitives purity test.
|
|
7
|
-
*
|
|
8
|
-
* `@lotics/ui` is the public, published, universal-rendering primitives
|
|
9
|
-
* package. Every file in `src/` is a primitive. It must not import any
|
|
10
|
-
* dependency that ties it to a specific Lotics runtime context: no chat
|
|
11
|
-
* SDK, no analytics, no i18n, and no Lotics domain types (which live in
|
|
12
|
-
* `@lotics/shared`). Lotics-coupled UI is in the workspace-private
|
|
13
|
-
* `@lotics/ui-internal` package, which may import from here and from
|
|
14
|
-
* `@lotics/shared` freely.
|
|
15
|
-
*
|
|
16
|
-
* Markdown (react-markdown/remark) is the one heavy third-party dependency
|
|
17
|
-
* the kit carries on purpose — it's the single canonical renderer for chat,
|
|
18
|
-
* apps, and `AgentRun`. It's generic, not a Lotics coupling; it stays
|
|
19
|
-
* universal via the `markdown.web.tsx` (DOM) / `markdown.tsx` (native
|
|
20
|
-
* plain-text) split and is tree-shaken out of any consumer that doesn't
|
|
21
|
-
* import `./markdown`. So it's allowed — the guards below remain for the
|
|
22
|
-
* genuine context couplings.
|
|
23
|
-
*
|
|
24
|
-
* The ONE allowed `ai` coupling: the `AgentRun` family renders the ai-sdk
|
|
25
|
-
* `UIMessage.parts` shape directly — that IS its contract (both chat and app
|
|
26
|
-
* agents emit it), so there is no bespoke transcript type to keep in sync.
|
|
27
|
-
* `agent_transform.ts` (the single place that folds parts into the render
|
|
28
|
-
* timeline) `import type`s from `ai` — erased at runtime, so no `ai` enters any
|
|
29
|
-
* consumer's bundle. That file alone is exempt from the `ai`-package guard; every
|
|
30
|
-
* other primitive stays ai-free, and the `@ai-sdk/*` runtime packages are never
|
|
31
|
-
* allowed anywhere.
|
|
32
|
-
*
|
|
33
|
-
* The boundary is enforced by this test — without it primitives drift
|
|
34
|
-
* back into the parent app's contexts over months.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
const AI_PACKAGE_PATTERN = /from\s+["']ai["']/;
|
|
38
|
-
const FORBIDDEN_PATTERNS = [
|
|
39
|
-
/from\s+["']posthog-js/,
|
|
40
|
-
/from\s+["']@lingui\//,
|
|
41
|
-
/from\s+["']@ai-sdk\//,
|
|
42
|
-
AI_PACKAGE_PATTERN,
|
|
43
|
-
/from\s+["']@lotics\/shared/,
|
|
44
|
-
];
|
|
45
|
-
|
|
46
|
-
// The sole file permitted to `import type` from the `ai` package (see above).
|
|
47
|
-
const AI_PACKAGE_ALLOWED = new Set(["agent_transform.ts"]);
|
|
48
|
-
|
|
49
|
-
const SRC_DIR = path.resolve(__dirname);
|
|
50
|
-
|
|
51
|
-
function listTopLevelSourceFiles(): string[] {
|
|
52
|
-
const entries = fs.readdirSync(SRC_DIR, { withFileTypes: true });
|
|
53
|
-
return entries
|
|
54
|
-
.filter((e) => e.isFile() && (e.name.endsWith(".ts") || e.name.endsWith(".tsx")))
|
|
55
|
-
.filter((e) => !e.name.endsWith(".test.ts") && !e.name.endsWith(".test.tsx"))
|
|
56
|
-
.map((e) => path.join(SRC_DIR, e.name));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
describe("packages/ui primitives — purity", () => {
|
|
60
|
-
it("no primitive imports posthog, lingui, ai-sdk, or @lotics/shared", () => {
|
|
61
|
-
const violations: string[] = [];
|
|
62
|
-
for (const file of listTopLevelSourceFiles()) {
|
|
63
|
-
const src = fs.readFileSync(file, "utf-8");
|
|
64
|
-
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
65
|
-
if (pattern === AI_PACKAGE_PATTERN && AI_PACKAGE_ALLOWED.has(path.basename(file))) continue;
|
|
66
|
-
if (pattern.test(src)) {
|
|
67
|
-
violations.push(`${path.basename(file)} matches ${pattern}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
expect(violations).toEqual([]);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { unified } from "unified";
|
|
3
|
-
import remarkParse from "remark-parse";
|
|
4
|
-
import { remarkGfmSafe } from "./remark_gfm_safe";
|
|
5
|
-
|
|
6
|
-
/** Parse markdown to an mdast tree and serialize it, so tests assert on the
|
|
7
|
-
* resulting document structure without depending on mdast node typings. */
|
|
8
|
-
function parse(markdown: string): string {
|
|
9
|
-
return JSON.stringify(unified().use(remarkParse).use(remarkGfmSafe).parse(markdown));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
describe("remarkGfmSafe", () => {
|
|
13
|
-
it("autolinks a bare URL in normal text (micromark tokenizer, no lookbehind)", () => {
|
|
14
|
-
expect(parse("see https://example.com now")).toContain('"url":"https://example.com"');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("autolinks a bare email in normal text (the case the removed sweep also handled)", () => {
|
|
18
|
-
expect(parse("contact me at a@b.com please")).toContain('"url":"mailto:a@b.com"');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("autolinks a bare www link", () => {
|
|
22
|
-
expect(parse("visit www.example.com today")).toContain('"url":"http://www.example.com"');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("still parses GFM tables", () => {
|
|
26
|
-
expect(parse("| a | b |\n| --- | --- |\n| 1 | 2 |")).toContain('"type":"table"');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("still parses strikethrough and task lists", () => {
|
|
30
|
-
expect(parse("~~gone~~")).toContain('"type":"delete"');
|
|
31
|
-
expect(parse("- [x] done")).toContain('"checked":true');
|
|
32
|
-
});
|
|
33
|
-
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import { shareOrDownloadFiles, prepareShareFiles, shareFiles } from "./share_or_download";
|
|
3
|
-
|
|
4
|
-
afterEach(() => {
|
|
5
|
-
vi.restoreAllMocks();
|
|
6
|
-
vi.unstubAllGlobals();
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
describe("shareOrDownloadFiles", () => {
|
|
10
|
-
it("is a no-op (download/0) when there are no usable files", async () => {
|
|
11
|
-
expect(await shareOrDownloadFiles([])).toEqual({ delivered: "download", count: 0 });
|
|
12
|
-
expect(await shareOrDownloadFiles([{ url: "", filename: "x.png" }])).toEqual({ delivered: "download", count: 0 });
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("hands the BYTES (not the URL) to the OS share sheet when files can be shared", async () => {
|
|
16
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
|
|
17
|
-
// navigator.share is a true external boundary — assert WHAT crosses it. Typing
|
|
18
|
-
// the mock's param makes mock.calls[0][0] the shared payload.
|
|
19
|
-
const share = vi.fn(async (_data: { files: File[]; title?: string }) => undefined);
|
|
20
|
-
vi.stubGlobal("navigator", { canShare: () => true, share });
|
|
21
|
-
|
|
22
|
-
const res = await shareOrDownloadFiles([{ url: "https://r2/presigned", filename: "a.png", mimeType: "image/png" }], { title: "Ảnh" });
|
|
23
|
-
|
|
24
|
-
expect(res).toEqual({ delivered: "share", count: 1 });
|
|
25
|
-
const shared = share.mock.calls[0][0];
|
|
26
|
-
expect(shared.files).toHaveLength(1);
|
|
27
|
-
expect(shared.files[0]).toBeInstanceOf(File); // bytes, not a link → no expiry problem
|
|
28
|
-
expect(shared.files[0].name).toBe("a.png");
|
|
29
|
-
expect(shared.title).toBe("Ảnh");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("rides the given credentials on the blob fetch so an auth-gated proxy URL authorizes", async () => {
|
|
33
|
-
// Type the init param so mock.calls[0][1] is the RequestInit we assert on.
|
|
34
|
-
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
|
|
35
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
36
|
-
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn(async () => undefined) });
|
|
37
|
-
|
|
38
|
-
await shareOrDownloadFiles([{ url: "https://api.example/v1/files/k", filename: "a.png" }], { credentials: "include" });
|
|
39
|
-
|
|
40
|
-
expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("treats a cancelled share sheet (AbortError) as done — never falls back to downloading", async () => {
|
|
44
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
|
|
45
|
-
const share = vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); });
|
|
46
|
-
vi.stubGlobal("navigator", { canShare: () => true, share });
|
|
47
|
-
|
|
48
|
-
expect(await shareOrDownloadFiles([{ url: "u", filename: "a.png" }])).toEqual({ delivered: "share", count: 0 });
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("prepareShareFiles + shareFiles (iOS: fetch ahead of the gesture, share synchronously)", () => {
|
|
53
|
-
it("prepareShareFiles fetches the bytes into File[] with the given credentials", async () => {
|
|
54
|
-
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
|
|
55
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
56
|
-
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
|
|
57
|
-
|
|
58
|
-
const files = await prepareShareFiles([{ url: "https://api/v1/files/k", filename: "a.png" }], { credentials: "include" });
|
|
59
|
-
|
|
60
|
-
expect(files).not.toBeNull();
|
|
61
|
-
expect(files?.[0]).toBeInstanceOf(File);
|
|
62
|
-
expect(files?.[0].name).toBe("a.png");
|
|
63
|
-
expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("prepareShareFiles returns null when Web Share (files) is unsupported", async () => {
|
|
67
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"]))));
|
|
68
|
-
vi.stubGlobal("navigator", {}); // no canShare / share
|
|
69
|
-
expect(await prepareShareFiles([{ url: "u", filename: "a.png" }])).toBeNull();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("prepareShareFiles THROWS on a fetch failure (caller logs it, not a silent download)", async () => {
|
|
73
|
-
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 401 })));
|
|
74
|
-
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn() });
|
|
75
|
-
await expect(prepareShareFiles([{ url: "u", filename: "a.png" }])).rejects.toThrow(/401/);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("shareFiles shares the SAME pre-fetched File (no re-fetch) and maps cancel/unsupported", async () => {
|
|
79
|
-
const share = vi.fn(async (_d: { files: File[] }) => undefined);
|
|
80
|
-
vi.stubGlobal("navigator", { canShare: () => true, share });
|
|
81
|
-
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
82
|
-
|
|
83
|
-
expect(await shareFiles([file], { title: "T" })).toBe("shared");
|
|
84
|
-
expect(share.mock.calls[0][0].files[0]).toBe(file); // the exact pre-fetched File crosses the boundary
|
|
85
|
-
|
|
86
|
-
vi.stubGlobal("navigator", { share: vi.fn(async () => { throw new DOMException("x", "AbortError"); }) });
|
|
87
|
-
expect(await shareFiles([file])).toBe("cancelled");
|
|
88
|
-
|
|
89
|
-
vi.stubGlobal("navigator", {}); // no share
|
|
90
|
-
expect(await shareFiles([file])).toBe("unsupported");
|
|
91
|
-
});
|
|
92
|
-
});
|
package/src/table.test.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { computeTableFit, type TableFitColumn } from "./table_fit";
|
|
3
|
-
|
|
4
|
-
// The canonical register's columns (tpl_item_list): a fixed identity column,
|
|
5
|
-
// a flexible name column, then fixed data columns — plus a 24px leading
|
|
6
|
-
// checkbox gutter and a 132px trailing actions gutter.
|
|
7
|
-
const REGISTER: TableFitColumn[] = [
|
|
8
|
-
{ key: "ma", width: 112 },
|
|
9
|
-
{ key: "khach" }, // flex
|
|
10
|
-
{ key: "trangThai", width: 112 },
|
|
11
|
-
{ key: "viec", width: 82 },
|
|
12
|
-
{ key: "phi", width: 116 },
|
|
13
|
-
{ key: "ngayNhan", width: 72 },
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
function visible(fit: ReturnType<typeof computeTableFit>): string[] {
|
|
17
|
-
return REGISTER.filter((c) => fit.visibleKeys.has(c.key)).map((c) => c.key);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("computeTableFit", () => {
|
|
21
|
-
it("keeps every column when the container fits them all", () => {
|
|
22
|
-
const fit = computeTableFit(REGISTER, 24, 132, 1040);
|
|
23
|
-
expect(fit.stacked).toBe(false);
|
|
24
|
-
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec", "phi", "ngayNhan"]);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("drops columns right-to-left by default, one at a time, until the rest fit", () => {
|
|
28
|
-
// 700px: the full set (908px) and the set minus "ngayNhan" (822px) overflow;
|
|
29
|
-
// minus "phi" too (692px) fits.
|
|
30
|
-
const fit = computeTableFit(REGISTER, 24, 132, 700);
|
|
31
|
-
expect(fit.stacked).toBe(false);
|
|
32
|
-
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec"]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("keeps a high-priority column alive while lower-priority ones drop", () => {
|
|
36
|
-
const withPriority = REGISTER.map((c) => (c.key === "phi" ? { ...c, priority: 1 } : c));
|
|
37
|
-
const fit = computeTableFit(withPriority, 24, 132, 700);
|
|
38
|
-
expect(fit.stacked).toBe(false);
|
|
39
|
-
// "phi" (priority 1) outlives "trangThai"/"viec"/"ngayNhan" (default = index).
|
|
40
|
-
expect(fit.visibleKeys.has("phi")).toBe(true);
|
|
41
|
-
expect(fit.visibleKeys.has("trangThai")).toBe(false);
|
|
42
|
-
expect(fit.visibleKeys.has("viec")).toBe(false);
|
|
43
|
-
expect(fit.visibleKeys.has("ngayNhan")).toBe(false);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("never drops the first column", () => {
|
|
47
|
-
// Wide enough for exactly the identity + flex pair, no more.
|
|
48
|
-
const fit = computeTableFit(REGISTER, 0, 0, 320);
|
|
49
|
-
expect(fit.stacked).toBe(false);
|
|
50
|
-
expect(fit.visibleKeys.has("ma")).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("never drops the first column even when it carries the worst priority", () => {
|
|
54
|
-
const hostile = REGISTER.map((c) => (c.key === "ma" ? { ...c, priority: 99 } : c));
|
|
55
|
-
const fit = computeTableFit(hostile, 24, 132, 700);
|
|
56
|
-
expect(fit.stacked).toBe(false);
|
|
57
|
-
expect(fit.visibleKeys.has("ma")).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("stacks when even the minimum column set overflows (the phone case)", () => {
|
|
61
|
-
// A 390px phone minus the page padding: the canonical register's identity +
|
|
62
|
-
// customer + gutters still need ~470px.
|
|
63
|
-
const fit = computeTableFit(REGISTER, 24, 132, 334);
|
|
64
|
-
expect(fit.stacked).toBe(true);
|
|
65
|
-
// Stacked mode shows every column — vertical space is free.
|
|
66
|
-
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec", "phi", "ngayNhan"]);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("counts the leading/trailing gutters and gaps in the fit math", () => {
|
|
70
|
-
// Two fixed columns: 40 padding + 100 + 100 + 1 gap (14) = 254 exactly.
|
|
71
|
-
const two: TableFitColumn[] = [
|
|
72
|
-
{ key: "a", width: 100 },
|
|
73
|
-
{ key: "b", width: 100 },
|
|
74
|
-
];
|
|
75
|
-
expect(computeTableFit(two, 0, 0, 254).stacked).toBe(false);
|
|
76
|
-
expect(computeTableFit(two, 0, 0, 253).stacked).toBe(true);
|
|
77
|
-
// Adding a 24px leading gutter adds the gutter and one more gap.
|
|
78
|
-
expect(computeTableFit(two, 24, 0, 292).stacked).toBe(false);
|
|
79
|
-
expect(computeTableFit(two, 24, 0, 291).stacked).toBe(true);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("a single-column table fits until the gutters overflow, then stacks", () => {
|
|
83
|
-
const one: TableFitColumn[] = [{ key: "a" }];
|
|
84
|
-
// 40 padding + 120 flex minimum = 160.
|
|
85
|
-
expect(computeTableFit(one, 0, 0, 200).stacked).toBe(false);
|
|
86
|
-
const tiny = computeTableFit(one, 0, 0, 100);
|
|
87
|
-
expect(tiny.stacked).toBe(true);
|
|
88
|
-
expect(tiny.visibleKeys.has("a")).toBe(true);
|
|
89
|
-
});
|
|
90
|
-
});
|
package/src/task_metrics.test.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { TASK_ROW_BAND, TASK_ROW_GAP, TASK_TITLE_LINE, taskGutter, taskTitleSlack } from "./task_metrics";
|
|
3
|
-
|
|
4
|
-
describe("task row geometry", () => {
|
|
5
|
-
it("puts the control's centre and the title's first text line on the SAME y, at every density", () => {
|
|
6
|
-
for (const band of Object.values(TASK_ROW_BAND)) {
|
|
7
|
-
// The control centres in the band; the title's first line centres at its slack + half a
|
|
8
|
-
// line. A density whose band breaks this equality sags the control off its own title.
|
|
9
|
-
expect(taskTitleSlack(band) + TASK_TITLE_LINE / 2).toBe(band / 2);
|
|
10
|
-
}
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("pins the first line on any band the family builds, not only the density table's", () => {
|
|
14
|
-
// A sub-field's line is the TALLER of the row band and the control it holds (a 40px inline
|
|
15
|
-
// editor does not fit a `dense` 32 row), so the law has to hold on bands the density table
|
|
16
|
-
// never names.
|
|
17
|
-
for (let band = TASK_TITLE_LINE; band <= 64; band += 2) {
|
|
18
|
-
expect(taskTitleSlack(band) + TASK_TITLE_LINE / 2).toBe(band / 2);
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("lands every density on whole pixels", () => {
|
|
23
|
-
for (const band of Object.values(TASK_ROW_BAND)) {
|
|
24
|
-
expect(Number.isInteger(taskTitleSlack(band))).toBe(true);
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("never pulls a title above its row when the band is shorter than a line of text", () => {
|
|
29
|
-
expect(taskTitleSlack(TASK_TITLE_LINE - 8)).toBe(0);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe("task row gutters", () => {
|
|
34
|
-
it("gives a pinned control its own width plus the gap that separates it from the content", () => {
|
|
35
|
-
// ONE rule for BOTH edges: the leading ring's column and the trailing ⋯'s column are the same
|
|
36
|
-
// arithmetic, which is what lets a row reserve them as plain padding and leave a content box
|
|
37
|
-
// every line — title, caption, sub-row, detail, nested list — can span exactly.
|
|
38
|
-
for (const width of [20, 24, 28, 40]) {
|
|
39
|
-
expect(taskGutter(width)).toBe(width + TASK_ROW_GAP);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("charges NOTHING for a gutter a list declines", () => {
|
|
44
|
-
// A list whose rows carry no ⋯ passes `actionWidth={0}` and its content must run to the
|
|
45
|
-
// container's edge. Leaving the gap behind would bill it 12px for a column it never uses.
|
|
46
|
-
expect(taskGutter(0)).toBe(0);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
3
|
-
import { act, renderHook } from "@testing-library/react";
|
|
4
|
-
import { useListKeyboardNav, type ListKeyboardNavOptions } from "./use_list_keyboard_nav";
|
|
5
|
-
|
|
6
|
-
function setup(overrides: Partial<ListKeyboardNavOptions> = {}) {
|
|
7
|
-
const onSelect = vi.fn();
|
|
8
|
-
const onClose = vi.fn();
|
|
9
|
-
const onActiveChange = vi.fn();
|
|
10
|
-
const opts: ListKeyboardNavOptions = {
|
|
11
|
-
count: 4,
|
|
12
|
-
onSelect,
|
|
13
|
-
onClose,
|
|
14
|
-
onActiveChange,
|
|
15
|
-
...overrides,
|
|
16
|
-
};
|
|
17
|
-
const view = renderHook((p: ListKeyboardNavOptions) => useListKeyboardNav(p), {
|
|
18
|
-
initialProps: opts,
|
|
19
|
-
});
|
|
20
|
-
return { view, onSelect, onClose, onActiveChange };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe("useListKeyboardNav", () => {
|
|
24
|
-
it("starts at index 0", () => {
|
|
25
|
-
const { view } = setup();
|
|
26
|
-
expect(view.result.current.activeIndex).toBe(0);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("seats the initial active row at initialIndex", () => {
|
|
30
|
-
const { view } = setup({ initialIndex: 2 });
|
|
31
|
-
expect(view.result.current.activeIndex).toBe(2);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("ArrowDown / ArrowUp move within bounds and report handled", () => {
|
|
35
|
-
const { view } = setup();
|
|
36
|
-
let handled = false;
|
|
37
|
-
act(() => {
|
|
38
|
-
handled = view.result.current.handleKey("ArrowDown");
|
|
39
|
-
});
|
|
40
|
-
expect(handled).toBe(true);
|
|
41
|
-
expect(view.result.current.activeIndex).toBe(1);
|
|
42
|
-
act(() => void view.result.current.handleKey("ArrowUp"));
|
|
43
|
-
expect(view.result.current.activeIndex).toBe(0);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("does not move past the ends", () => {
|
|
47
|
-
const { view } = setup({ count: 2 });
|
|
48
|
-
act(() => void view.result.current.handleKey("ArrowUp"));
|
|
49
|
-
expect(view.result.current.activeIndex).toBe(0);
|
|
50
|
-
act(() => void view.result.current.handleKey("ArrowDown"));
|
|
51
|
-
act(() => void view.result.current.handleKey("ArrowDown"));
|
|
52
|
-
expect(view.result.current.activeIndex).toBe(1);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("skips disabled rows", () => {
|
|
56
|
-
const { view } = setup({ count: 4, isDisabled: (i) => i === 1 || i === 2 });
|
|
57
|
-
act(() => void view.result.current.handleKey("ArrowDown"));
|
|
58
|
-
expect(view.result.current.activeIndex).toBe(3);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("Home / End jump to the first / last enabled row", () => {
|
|
62
|
-
const { view } = setup({ count: 4, isDisabled: (i) => i === 3 });
|
|
63
|
-
act(() => void view.result.current.handleKey("End"));
|
|
64
|
-
expect(view.result.current.activeIndex).toBe(2);
|
|
65
|
-
act(() => void view.result.current.handleKey("Home"));
|
|
66
|
-
expect(view.result.current.activeIndex).toBe(0);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("Enter selects the active row", () => {
|
|
70
|
-
const { view, onSelect } = setup();
|
|
71
|
-
act(() => void view.result.current.handleKey("ArrowDown"));
|
|
72
|
-
act(() => void view.result.current.handleKey("Enter"));
|
|
73
|
-
expect(onSelect).toHaveBeenCalledWith(1);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("Enter does not select a disabled active row", () => {
|
|
77
|
-
const { view, onSelect } = setup({ count: 2, isDisabled: () => true });
|
|
78
|
-
act(() => void view.result.current.handleKey("Enter"));
|
|
79
|
-
expect(onSelect).not.toHaveBeenCalled();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("Escape closes and is handled; Tab closes but is not handled", () => {
|
|
83
|
-
const { view, onClose } = setup();
|
|
84
|
-
let escHandled = true;
|
|
85
|
-
let tabHandled = true;
|
|
86
|
-
act(() => {
|
|
87
|
-
escHandled = view.result.current.handleKey("Escape");
|
|
88
|
-
});
|
|
89
|
-
act(() => {
|
|
90
|
-
tabHandled = view.result.current.handleKey("Tab");
|
|
91
|
-
});
|
|
92
|
-
expect(onClose).toHaveBeenCalledTimes(2);
|
|
93
|
-
expect(escHandled).toBe(true);
|
|
94
|
-
expect(tabHandled).toBe(false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("reports unknown keys as not handled", () => {
|
|
98
|
-
const { view } = setup();
|
|
99
|
-
let handled = true;
|
|
100
|
-
act(() => {
|
|
101
|
-
handled = view.result.current.handleKey("a");
|
|
102
|
-
});
|
|
103
|
-
expect(handled).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("notifies onActiveChange when the active row moves", () => {
|
|
107
|
-
const { view, onActiveChange } = setup();
|
|
108
|
-
act(() => void view.result.current.handleKey("ArrowDown"));
|
|
109
|
-
expect(onActiveChange).toHaveBeenCalledWith(1);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("setActiveIndex moves the active row but skips onActiveChange when scrollIntoView is false (mouse hover)", () => {
|
|
113
|
-
const { view, onActiveChange } = setup();
|
|
114
|
-
act(() => view.result.current.setActiveIndex(2, false));
|
|
115
|
-
expect(view.result.current.activeIndex).toBe(2);
|
|
116
|
-
expect(onActiveChange).not.toHaveBeenCalled();
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe("useListKeyboardNav typeahead", () => {
|
|
121
|
-
const labels = ["Apple", "Banana", "Blueberry", "Cherry"];
|
|
122
|
-
function setupTypeahead(overrides: Partial<ListKeyboardNavOptions> = {}) {
|
|
123
|
-
return setup({
|
|
124
|
-
count: labels.length,
|
|
125
|
-
typeahead: { labelAt: (i) => labels[i] },
|
|
126
|
-
...overrides,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
it("jumps to the first label-prefix match and reports the key handled", () => {
|
|
131
|
-
const { view } = setupTypeahead();
|
|
132
|
-
let handled = false;
|
|
133
|
-
act(() => {
|
|
134
|
-
handled = view.result.current.handleKey("c");
|
|
135
|
-
});
|
|
136
|
-
expect(handled).toBe(true);
|
|
137
|
-
expect(view.result.current.activeIndex).toBe(3); // Cherry
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("accumulates the buffer while typing", () => {
|
|
141
|
-
vi.useFakeTimers();
|
|
142
|
-
try {
|
|
143
|
-
const { view } = setupTypeahead();
|
|
144
|
-
act(() => void view.result.current.handleKey("b")); // "b" → Banana
|
|
145
|
-
expect(view.result.current.activeIndex).toBe(1);
|
|
146
|
-
act(() => void view.result.current.handleKey("l")); // "bl" → Blueberry
|
|
147
|
-
expect(view.result.current.activeIndex).toBe(2);
|
|
148
|
-
} finally {
|
|
149
|
-
vi.useRealTimers();
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("resets the buffer after the idle timeout", () => {
|
|
154
|
-
vi.useFakeTimers();
|
|
155
|
-
try {
|
|
156
|
-
const { view } = setupTypeahead();
|
|
157
|
-
act(() => void view.result.current.handleKey("b")); // "b" → Banana
|
|
158
|
-
expect(view.result.current.activeIndex).toBe(1);
|
|
159
|
-
act(() => {
|
|
160
|
-
vi.advanceTimersByTime(700);
|
|
161
|
-
});
|
|
162
|
-
act(() => void view.result.current.handleKey("c")); // fresh "c" → Cherry, not "bc"
|
|
163
|
-
expect(view.result.current.activeIndex).toBe(3);
|
|
164
|
-
} finally {
|
|
165
|
-
vi.useRealTimers();
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("skips disabled rows when jumping", () => {
|
|
170
|
-
const { view } = setupTypeahead({ isDisabled: (i) => i === 1 }); // Banana disabled
|
|
171
|
-
act(() => void view.result.current.handleKey("b"));
|
|
172
|
-
expect(view.result.current.activeIndex).toBe(2); // Blueberry, the first enabled b*
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("without a typeahead option, a printable key is not handled and does not move the row", () => {
|
|
176
|
-
const { view } = setup(); // no typeahead
|
|
177
|
-
let handled = true;
|
|
178
|
-
act(() => {
|
|
179
|
-
handled = view.result.current.handleKey("b");
|
|
180
|
-
});
|
|
181
|
-
expect(handled).toBe(false);
|
|
182
|
-
expect(view.result.current.activeIndex).toBe(0);
|
|
183
|
-
});
|
|
184
|
-
});
|