@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,232 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect, vi } from "vitest";
|
|
3
|
-
import { act, renderHook } from "@testing-library/react";
|
|
4
|
-
import { useOptionList, type UseOptionListParams } from "./use_option_list";
|
|
5
|
-
|
|
6
|
-
const OPTS = [
|
|
7
|
-
{ value: "a", label: "Apple" },
|
|
8
|
-
{ value: "b", label: "Banana" },
|
|
9
|
-
{ value: "c", label: "Cherry" },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
function setupSingle(overrides: Partial<UseOptionListParams<string, false>> = {}) {
|
|
13
|
-
const onValueChange = vi.fn();
|
|
14
|
-
const onRequestClose = vi.fn();
|
|
15
|
-
const onCustomCommit = vi.fn();
|
|
16
|
-
const params: UseOptionListParams<string, false> = {
|
|
17
|
-
options: OPTS,
|
|
18
|
-
search: { mode: "none" },
|
|
19
|
-
onValueChange,
|
|
20
|
-
onRequestClose,
|
|
21
|
-
onCustomCommit,
|
|
22
|
-
...overrides,
|
|
23
|
-
};
|
|
24
|
-
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
25
|
-
initialProps: params,
|
|
26
|
-
});
|
|
27
|
-
return { view, onValueChange, onRequestClose, onCustomCommit };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function setupMulti(overrides: Partial<UseOptionListParams<string, true>> = {}) {
|
|
31
|
-
const onValueChange = vi.fn();
|
|
32
|
-
const onRequestClose = vi.fn();
|
|
33
|
-
const params: UseOptionListParams<string, true> = {
|
|
34
|
-
options: OPTS,
|
|
35
|
-
multi: true,
|
|
36
|
-
search: { mode: "none" },
|
|
37
|
-
onValueChange,
|
|
38
|
-
onRequestClose,
|
|
39
|
-
...overrides,
|
|
40
|
-
};
|
|
41
|
-
const view = renderHook((p: UseOptionListParams<string, true>) => useOptionList(p), {
|
|
42
|
-
initialProps: params,
|
|
43
|
-
});
|
|
44
|
-
return { view, onValueChange, onRequestClose };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe("useOptionList", () => {
|
|
48
|
-
it("none mode lists every option and seats the active row on the first one", () => {
|
|
49
|
-
const { view } = setupSingle();
|
|
50
|
-
const { rows, activeIndex } = view.result.current;
|
|
51
|
-
expect(rows.map((r) => r.option.value)).toEqual(["a", "b", "c"]);
|
|
52
|
-
expect(rows.every((r) => r.kind === "option")).toBe(true);
|
|
53
|
-
expect(activeIndex).toBe(0);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("none mode seats the active row on the selected option", () => {
|
|
57
|
-
const { view } = setupSingle({ value: "c" });
|
|
58
|
-
expect(view.result.current.activeIndex).toBe(2);
|
|
59
|
-
expect(view.result.current.rows[2].selected).toBe(true);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("single pick emits the value and requests close", () => {
|
|
63
|
-
const { view, onValueChange, onRequestClose } = setupSingle();
|
|
64
|
-
act(() => view.result.current.pickRow(1));
|
|
65
|
-
expect(onValueChange).toHaveBeenCalledWith("b");
|
|
66
|
-
expect(onRequestClose).toHaveBeenCalledTimes(1);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("multi pick toggles the value and stays open", () => {
|
|
70
|
-
const { view, onValueChange, onRequestClose } = setupMulti({ value: [] });
|
|
71
|
-
act(() => view.result.current.pickRow(0));
|
|
72
|
-
expect(onValueChange).toHaveBeenCalledWith(["a"]);
|
|
73
|
-
expect(onRequestClose).not.toHaveBeenCalled();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("multi pick removes an already-selected value", () => {
|
|
77
|
-
const { view, onValueChange } = setupMulti({ value: ["a", "b"] });
|
|
78
|
-
act(() => view.result.current.pickRow(0)); // toggle off "a"
|
|
79
|
-
expect(onValueChange).toHaveBeenCalledWith(["b"]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("internal search filters the rows locally", () => {
|
|
83
|
-
const { view } = setupSingle({ search: { mode: "internal" } });
|
|
84
|
-
act(() => view.result.current.setQuery("ap"));
|
|
85
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["a"]);
|
|
86
|
-
expect(view.result.current.searching).toBe(true);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("allowCustom adds a create row for an unmatched query; picking it commits the raw query", () => {
|
|
90
|
-
const { view, onCustomCommit } = setupSingle({
|
|
91
|
-
search: { mode: "internal" },
|
|
92
|
-
allowCustom: true,
|
|
93
|
-
});
|
|
94
|
-
act(() => view.result.current.setQuery("kiwi"));
|
|
95
|
-
const rows = view.result.current.rows;
|
|
96
|
-
const custom = rows.find((r) => r.kind === "custom");
|
|
97
|
-
expect(custom).toBeTruthy();
|
|
98
|
-
expect(custom?.option.label).toBe('Add "kiwi"');
|
|
99
|
-
act(() => view.result.current.pickRow(custom!.index));
|
|
100
|
-
expect(onCustomCommit).toHaveBeenCalledWith("kiwi");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("keeps the active row on the first match, never the create row (placement top)", () => {
|
|
104
|
-
const { view } = setupSingle({
|
|
105
|
-
search: { mode: "internal" },
|
|
106
|
-
allowCustom: true,
|
|
107
|
-
customOptionPlacement: "top",
|
|
108
|
-
});
|
|
109
|
-
act(() => view.result.current.setQuery("ap")); // create row + the Apple match
|
|
110
|
-
const rows = view.result.current.rows;
|
|
111
|
-
expect(rows[0].kind).toBe("custom");
|
|
112
|
-
expect(rows[view.result.current.activeIndex].kind).toBe("option");
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("includeEmptyOption prepends a clear row that empties the value", () => {
|
|
116
|
-
const { view, onValueChange, onRequestClose } = setupSingle({
|
|
117
|
-
value: "a",
|
|
118
|
-
includeEmptyOption: true,
|
|
119
|
-
});
|
|
120
|
-
expect(view.result.current.rows[0].kind).toBe("empty");
|
|
121
|
-
act(() => view.result.current.pickRow(0));
|
|
122
|
-
expect(onValueChange).toHaveBeenCalledWith("");
|
|
123
|
-
expect(onRequestClose).toHaveBeenCalledTimes(1);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("select-all / deselect-all emit the full set and the empty set", () => {
|
|
127
|
-
const { view, onValueChange } = setupMulti({ value: ["a"], enableSelectAll: true });
|
|
128
|
-
expect(view.result.current.showSelectAll).toBe(true);
|
|
129
|
-
expect(view.result.current.showDeselectAll).toBe(true);
|
|
130
|
-
act(() => view.result.current.selectAll());
|
|
131
|
-
expect(onValueChange).toHaveBeenCalledWith(["a", "b", "c"]);
|
|
132
|
-
act(() => view.result.current.deselectAll());
|
|
133
|
-
expect(onValueChange).toHaveBeenLastCalledWith([]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("a none-mode multi toggle keeps the active row where it is (no re-seat on selection)", () => {
|
|
137
|
-
const onValueChange = vi.fn();
|
|
138
|
-
const params: UseOptionListParams<string, true> = {
|
|
139
|
-
options: OPTS,
|
|
140
|
-
multi: true,
|
|
141
|
-
search: { mode: "none" },
|
|
142
|
-
value: ["a"],
|
|
143
|
-
onValueChange,
|
|
144
|
-
};
|
|
145
|
-
const view = renderHook((p: UseOptionListParams<string, true>) => useOptionList(p), {
|
|
146
|
-
initialProps: params,
|
|
147
|
-
});
|
|
148
|
-
act(() => view.result.current.setActiveIndex(2)); // arrow down to "c"
|
|
149
|
-
act(() => view.result.current.pickRow(2)); // toggle "c" on, popover stays open
|
|
150
|
-
expect(onValueChange).toHaveBeenCalledWith(["a", "c"]);
|
|
151
|
-
view.rerender({ ...params, value: ["a", "c"] }); // consumer applies the new value
|
|
152
|
-
// Regression: must NOT jump back to the first selected option.
|
|
153
|
-
expect(view.result.current.activeIndex).toBe(2);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("re-seats the active row to the first match when the search query changes", () => {
|
|
157
|
-
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
158
|
-
initialProps: { options: OPTS, search: { mode: "internal" }, onValueChange: vi.fn() },
|
|
159
|
-
});
|
|
160
|
-
act(() => view.result.current.setActiveIndex(2));
|
|
161
|
-
expect(view.result.current.activeIndex).toBe(2);
|
|
162
|
-
act(() => view.result.current.setQuery("ban")); // filters to "Banana"
|
|
163
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["b"]);
|
|
164
|
-
expect(view.result.current.activeIndex).toBe(0);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("matches options accent-insensitively (cafe -> Café, dieu -> Điều)", () => {
|
|
168
|
-
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
169
|
-
initialProps: {
|
|
170
|
-
options: [
|
|
171
|
-
{ value: "cf", label: "Café" },
|
|
172
|
-
{ value: "dd", label: "Điều độ" },
|
|
173
|
-
{ value: "x", label: "Other" },
|
|
174
|
-
],
|
|
175
|
-
search: { mode: "internal" },
|
|
176
|
-
onValueChange: vi.fn(),
|
|
177
|
-
},
|
|
178
|
-
});
|
|
179
|
-
act(() => view.result.current.setQuery("dieu"));
|
|
180
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["dd"]);
|
|
181
|
-
act(() => view.result.current.setQuery("cafe"));
|
|
182
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["cf"]);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("controlled server-filtered mode shows options as-is (no local re-filter)", () => {
|
|
186
|
-
const { view } = setupSingle({
|
|
187
|
-
// The server already filtered to one result; the hook must not re-filter it out.
|
|
188
|
-
options: [{ value: "z", label: "Zucchini" }],
|
|
189
|
-
search: { mode: "controlled", query: "xyz", serverFiltered: true },
|
|
190
|
-
});
|
|
191
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["z"]);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("controlled server-filtered mode browses recents on an empty query", () => {
|
|
195
|
-
const recentOptions = [
|
|
196
|
-
{ value: "r1", label: "Recent one" },
|
|
197
|
-
{ value: "r2", label: "Recent two" },
|
|
198
|
-
];
|
|
199
|
-
// A server search has nothing for an empty term — recents are the idle list.
|
|
200
|
-
const params: UseOptionListParams<string, false> = {
|
|
201
|
-
options: [],
|
|
202
|
-
search: { mode: "controlled", query: "", serverFiltered: true },
|
|
203
|
-
recentOptions,
|
|
204
|
-
};
|
|
205
|
-
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
206
|
-
initialProps: params,
|
|
207
|
-
});
|
|
208
|
-
expect(view.result.current.searching).toBe(false);
|
|
209
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["r1", "r2"]);
|
|
210
|
-
// Typing hands the list back to the server's results.
|
|
211
|
-
view.rerender({
|
|
212
|
-
...params,
|
|
213
|
-
options: [{ value: "z", label: "Zucchini" }],
|
|
214
|
-
search: { mode: "controlled", query: "zu", serverFiltered: true },
|
|
215
|
-
});
|
|
216
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["z"]);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("an empty controlled query without recents browses the full option set", () => {
|
|
220
|
-
// The `recentOptions ?? options` idle contract — a consumer that supplies
|
|
221
|
-
// browse options (server or local) and no recents keeps its full list.
|
|
222
|
-
for (const serverFiltered of [true, false]) {
|
|
223
|
-
const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
|
|
224
|
-
initialProps: {
|
|
225
|
-
options: OPTS,
|
|
226
|
-
search: { mode: "controlled" as const, query: "", serverFiltered },
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["a", "b", "c"]);
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
});
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
-
import { act, renderHook } from "@testing-library/react";
|
|
4
|
-
import { useSectionNav } from "./use_section_nav";
|
|
5
|
-
import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native";
|
|
6
|
-
|
|
7
|
-
// RN-web's layout event carries the measured node as `nativeEvent.target`; RN's
|
|
8
|
-
// own (native-only) types don't model it, so it goes on after the cast.
|
|
9
|
-
const layoutAt = (y: number, target?: Element) => {
|
|
10
|
-
const e = { nativeEvent: { layout: { y } } } as LayoutChangeEvent;
|
|
11
|
-
if (target) Object.assign(e.nativeEvent, { target });
|
|
12
|
-
return e;
|
|
13
|
-
};
|
|
14
|
-
const scrollTo = (y: number) => ({ nativeEvent: { contentOffset: { y } } }) as NativeSyntheticEvent<NativeScrollEvent>;
|
|
15
|
-
|
|
16
|
-
function mounted() {
|
|
17
|
-
const hook = renderHook(() => useSectionNav(["a", "b", "c"] as const));
|
|
18
|
-
act(() => {
|
|
19
|
-
hook.result.current.register("a")(layoutAt(0));
|
|
20
|
-
hook.result.current.register("b")(layoutAt(400));
|
|
21
|
-
hook.result.current.register("c")(layoutAt(900));
|
|
22
|
-
});
|
|
23
|
-
return hook;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const rect = (top: number): DOMRect => ({
|
|
27
|
-
top,
|
|
28
|
-
bottom: top,
|
|
29
|
-
left: 0,
|
|
30
|
-
right: 0,
|
|
31
|
-
width: 0,
|
|
32
|
-
height: 0,
|
|
33
|
-
x: 0,
|
|
34
|
-
y: top,
|
|
35
|
-
toJSON: () => ({}),
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* A web page in miniature: a scroll container pinned at the viewport top plus
|
|
40
|
-
* section nodes sitting at live content offsets. `scrolledTo` moves the scroll
|
|
41
|
-
* position and every rect together (as a browser does), `shift` is the async
|
|
42
|
-
* content growth that moves sections WITHOUT an `onLayout` — the case a cached
|
|
43
|
-
* offset cannot see. A detached node measures as 0, like a real one.
|
|
44
|
-
*/
|
|
45
|
-
function webPage<K extends string>(keys: readonly K[], contentY: Record<K, number>) {
|
|
46
|
-
let scrollY = 0;
|
|
47
|
-
let live: Record<K, number> = { ...contentY };
|
|
48
|
-
const node = document.createElement("div");
|
|
49
|
-
document.body.appendChild(node);
|
|
50
|
-
Object.defineProperty(node, "scrollTop", { get: () => scrollY });
|
|
51
|
-
node.getBoundingClientRect = () => rect(0);
|
|
52
|
-
const els = {} as Record<K, HTMLElement>;
|
|
53
|
-
for (const key of keys) {
|
|
54
|
-
const el = document.createElement("div");
|
|
55
|
-
document.body.appendChild(el);
|
|
56
|
-
el.getBoundingClientRect = () => (el.isConnected ? rect(live[key] - scrollY) : rect(0));
|
|
57
|
-
els[key] = el;
|
|
58
|
-
}
|
|
59
|
-
return {
|
|
60
|
-
node,
|
|
61
|
-
els,
|
|
62
|
-
shift: (next: Partial<Record<K, number>>) => {
|
|
63
|
-
live = { ...live, ...next };
|
|
64
|
-
},
|
|
65
|
-
scrolledTo: (y: number) => {
|
|
66
|
-
scrollY = y;
|
|
67
|
-
return scrollTo(y);
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** The registered page above, wired to a hook whose `scrollTo` calls are recorded. */
|
|
73
|
-
function webMounted(contentY: Record<"a" | "b" | "c", number>) {
|
|
74
|
-
const keys = ["a", "b", "c"] as const;
|
|
75
|
-
const page = webPage(keys, contentY);
|
|
76
|
-
const calls: { y: number }[] = [];
|
|
77
|
-
const hook = renderHook(() => useSectionNav(keys));
|
|
78
|
-
(hook.result.current.scrollRef as { current: unknown }).current = {
|
|
79
|
-
getScrollableNode: () => page.node,
|
|
80
|
-
scrollTo: (opts: { y: number }) => calls.push(opts),
|
|
81
|
-
};
|
|
82
|
-
act(() => {
|
|
83
|
-
for (const key of keys) hook.result.current.register(key)(layoutAt(contentY[key], page.els[key]));
|
|
84
|
-
});
|
|
85
|
-
return { result: hook.result, page, calls };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
describe("useSectionNav", () => {
|
|
89
|
-
it("starts on the first key", () => {
|
|
90
|
-
const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
|
|
91
|
-
expect(result.current.activeKey).toBe("a");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("activates the last section whose top passed the viewport edge (+80 threshold)", () => {
|
|
95
|
-
const { result } = mounted();
|
|
96
|
-
act(() => result.current.onScroll(scrollTo(0)));
|
|
97
|
-
expect(result.current.activeKey).toBe("a");
|
|
98
|
-
// 321 + 80 threshold = 401 >= b's 400 → b is active
|
|
99
|
-
act(() => result.current.onScroll(scrollTo(321)));
|
|
100
|
-
expect(result.current.activeKey).toBe("b");
|
|
101
|
-
// just short of the threshold stays on a
|
|
102
|
-
act(() => result.current.onScroll(scrollTo(319)));
|
|
103
|
-
expect(result.current.activeKey).toBe("a");
|
|
104
|
-
act(() => result.current.onScroll(scrollTo(2000)));
|
|
105
|
-
expect(result.current.activeKey).toBe("c");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("keys walk in page order — a later key with a smaller offset never wins", () => {
|
|
109
|
-
const { result } = renderHook(() => useSectionNav(["a", "b"] as const));
|
|
110
|
-
act(() => {
|
|
111
|
-
result.current.register("a")(layoutAt(500));
|
|
112
|
-
result.current.register("b")(layoutAt(100));
|
|
113
|
-
});
|
|
114
|
-
act(() => result.current.onScroll(scrollTo(600)));
|
|
115
|
-
// both tops passed; the LAST key in page order wins
|
|
116
|
-
expect(result.current.activeKey).toBe("b");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("ignores keys that never registered", () => {
|
|
120
|
-
const { result } = renderHook(() => useSectionNav(["a", "b", "c"] as const));
|
|
121
|
-
act(() => result.current.register("a")(layoutAt(0)));
|
|
122
|
-
act(() => result.current.onScroll(scrollTo(999)));
|
|
123
|
-
expect(result.current.activeKey).toBe("a");
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("jumpTo scrolls to the section's offset minus the 12px breathing room, clamped at 0", () => {
|
|
127
|
-
const { result } = mounted();
|
|
128
|
-
const calls: { y: number }[] = [];
|
|
129
|
-
(result.current.scrollRef as { current: unknown }).current = {
|
|
130
|
-
scrollTo: (opts: { y: number }) => calls.push(opts),
|
|
131
|
-
};
|
|
132
|
-
act(() => result.current.jumpTo("b"));
|
|
133
|
-
expect(calls[0]?.y).toBe(388);
|
|
134
|
-
act(() => result.current.jumpTo("a"));
|
|
135
|
-
expect(calls[1]?.y).toBe(0);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("on web the highlight follows content that shifted after layout", () => {
|
|
139
|
-
const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
|
|
140
|
-
act(() => result.current.onScroll(page.scrolledTo(321)));
|
|
141
|
-
expect(result.current.activeKey).toBe("b");
|
|
142
|
-
|
|
143
|
-
// a table above b finishes loading and pushes everything below it down 300px
|
|
144
|
-
page.shift({ b: 700, c: 1200 });
|
|
145
|
-
act(() => result.current.onScroll(page.scrolledTo(321)));
|
|
146
|
-
expect(result.current.activeKey).toBe("a");
|
|
147
|
-
act(() => result.current.onScroll(page.scrolledTo(700)));
|
|
148
|
-
expect(result.current.activeKey).toBe("b");
|
|
149
|
-
|
|
150
|
-
// and back: content above collapses, pulling the sections up again
|
|
151
|
-
page.shift({ b: 200, c: 500 });
|
|
152
|
-
act(() => result.current.onScroll(page.scrolledTo(450)));
|
|
153
|
-
expect(result.current.activeKey).toBe("c");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("on web jumpTo lands on the section's shifted position", () => {
|
|
157
|
-
const { result, page, calls } = webMounted({ a: 0, b: 400, c: 900 });
|
|
158
|
-
page.shift({ b: 700 });
|
|
159
|
-
act(() => result.current.jumpTo("b"));
|
|
160
|
-
expect(calls[0]?.y).toBe(688);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("a section removed from the DOM keeps its last offset instead of measuring as the page top", () => {
|
|
164
|
-
const { result, page } = webMounted({ a: 0, b: 400, c: 900 });
|
|
165
|
-
page.els.c.remove();
|
|
166
|
-
// 500 + 80 = 580: past b, still short of c — a detached c measured live
|
|
167
|
-
// would read as the current scroll position and steal the highlight.
|
|
168
|
-
act(() => result.current.onScroll(page.scrolledTo(500)));
|
|
169
|
-
expect(result.current.activeKey).toBe("b");
|
|
170
|
-
});
|
|
171
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
-
import { act, renderHook } from "@testing-library/react";
|
|
4
|
-
import { useSelection } from "./use_selection";
|
|
5
|
-
|
|
6
|
-
describe("useSelection", () => {
|
|
7
|
-
it("toggles an id on and off, and sets it explicitly with `on`", () => {
|
|
8
|
-
const { result } = renderHook(() => useSelection());
|
|
9
|
-
expect(result.current.count).toBe(0);
|
|
10
|
-
expect(result.current.has("a")).toBe(false);
|
|
11
|
-
|
|
12
|
-
act(() => result.current.toggle("a"));
|
|
13
|
-
expect(result.current.has("a")).toBe(true);
|
|
14
|
-
expect(result.current.count).toBe(1);
|
|
15
|
-
|
|
16
|
-
act(() => result.current.toggle("a"));
|
|
17
|
-
expect(result.current.has("a")).toBe(false);
|
|
18
|
-
|
|
19
|
-
act(() => result.current.toggle("a", true));
|
|
20
|
-
act(() => result.current.toggle("a", true)); // explicit on is idempotent
|
|
21
|
-
expect(result.current.count).toBe(1);
|
|
22
|
-
act(() => result.current.toggle("a", false));
|
|
23
|
-
expect(result.current.has("a")).toBe(false);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("setAll selects/deselects a batch; `selected` exposes the picked ids", () => {
|
|
27
|
-
const { result } = renderHook(() => useSelection());
|
|
28
|
-
act(() => result.current.setAll(["a", "b", "c"], true));
|
|
29
|
-
expect(result.current.count).toBe(3);
|
|
30
|
-
expect([...result.current.selected].sort()).toEqual(["a", "b", "c"]);
|
|
31
|
-
|
|
32
|
-
act(() => result.current.setAll(["a", "b"], false));
|
|
33
|
-
expect([...result.current.selected]).toEqual(["c"]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("allSelected / indeterminate drive a select-all checkbox", () => {
|
|
37
|
-
const { result } = renderHook(() => useSelection());
|
|
38
|
-
const page = ["a", "b", "c"];
|
|
39
|
-
expect(result.current.allSelected(page)).toBe(false);
|
|
40
|
-
expect(result.current.indeterminate(page)).toBe(false);
|
|
41
|
-
expect(result.current.allSelected([])).toBe(false); // an empty set is never "all"
|
|
42
|
-
|
|
43
|
-
act(() => result.current.toggle("a", true));
|
|
44
|
-
expect(result.current.allSelected(page)).toBe(false);
|
|
45
|
-
expect(result.current.indeterminate(page)).toBe(true);
|
|
46
|
-
|
|
47
|
-
act(() => result.current.setAll(page, true));
|
|
48
|
-
expect(result.current.allSelected(page)).toBe(true);
|
|
49
|
-
expect(result.current.indeterminate(page)).toBe(false);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("only the passed ids count — a selection outside the set isn't 'all'/'indeterminate' for it", () => {
|
|
53
|
-
const { result } = renderHook(() => useSelection());
|
|
54
|
-
act(() => result.current.setAll(["x", "y"], true));
|
|
55
|
-
expect(result.current.allSelected(["a", "b"])).toBe(false);
|
|
56
|
-
expect(result.current.indeterminate(["a", "b"])).toBe(false);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("clear empties the whole selection", () => {
|
|
60
|
-
const { result } = renderHook(() => useSelection());
|
|
61
|
-
act(() => result.current.setAll(["a", "b"], true));
|
|
62
|
-
act(() => result.current.clear());
|
|
63
|
-
expect(result.current.count).toBe(0);
|
|
64
|
-
expect(result.current.has("a")).toBe(false);
|
|
65
|
-
});
|
|
66
|
-
});
|
package/src/vite.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import * as fs from "node:fs";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { loticsOptimizeDeps } from "./vite.mjs";
|
|
5
|
-
|
|
6
|
-
describe("@lotics/ui/vite", () => {
|
|
7
|
-
it("exports a non-empty list of module-specifier strings", () => {
|
|
8
|
-
expect(Array.isArray(loticsOptimizeDeps)).toBe(true);
|
|
9
|
-
expect(loticsOptimizeDeps.length).toBeGreaterThan(0);
|
|
10
|
-
for (const entry of loticsOptimizeDeps) {
|
|
11
|
-
expect(typeof entry).toBe("string");
|
|
12
|
-
expect(entry.length).toBeGreaterThan(0);
|
|
13
|
-
}
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("is a zero-import leaf (it loads inside Vite's Node config context)", () => {
|
|
17
|
-
// The module is imported by an app's `vite.config.ts`, evaluated by Node
|
|
18
|
-
// before any resolve.alias or RN-Web shim exists. A single import would drag
|
|
19
|
-
// react-native-web (and its Metro globals) into the Node config load and
|
|
20
|
-
// crash it — so the runtime module must import nothing at all. Strip comments
|
|
21
|
-
// first so the usage example in the header (which shows an `import` line) is
|
|
22
|
-
// not mistaken for real code.
|
|
23
|
-
const raw = fs.readFileSync(path.join(__dirname, "vite.mjs"), "utf-8");
|
|
24
|
-
const code = raw
|
|
25
|
-
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
26
|
-
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
27
|
-
expect(code).not.toMatch(/^\s*import\b/m);
|
|
28
|
-
expect(code).not.toMatch(/\bfrom\s+["']/);
|
|
29
|
-
expect(code).not.toMatch(/\brequire\s*\(/);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("carries the load-bearing RN-Web interop entries (a missing one blanks the dev iframe)", () => {
|
|
33
|
-
// RN-Web does `import normalizeColor from "@react-native/normalize-colors"`
|
|
34
|
-
// (nested CJS): un-prebundled, the dev server serves it as ESM with no
|
|
35
|
-
// default export and the iframe blanks the moment a colour-touching
|
|
36
|
-
// component mounts. These membership pins port the scaffold-era literal
|
|
37
|
-
// assertions to the list's owner.
|
|
38
|
-
expect(loticsOptimizeDeps).toContain("react-native-web");
|
|
39
|
-
expect(loticsOptimizeDeps).toContain("@react-native/normalize-colors");
|
|
40
|
-
});
|
|
41
|
-
});
|