tachyon-dom 0.1.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/LICENSE +21 -0
- package/README.md +339 -0
- package/dist/adapters/lambda.d.ts +65 -0
- package/dist/adapters/lambda.js +211 -0
- package/dist/adapters/node.d.ts +25 -0
- package/dist/adapters/node.js +360 -0
- package/dist/adapters/workers.d.ts +149 -0
- package/dist/adapters/workers.js +272 -0
- package/dist/adapters.d.ts +3 -0
- package/dist/adapters.js +3 -0
- package/dist/app.d.ts +98 -0
- package/dist/app.js +306 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.js +711 -0
- package/dist/compiler/expression.d.ts +55 -0
- package/dist/compiler/expression.js +858 -0
- package/dist/compiler/index.d.ts +8 -0
- package/dist/compiler/index.js +50 -0
- package/dist/compiler/ir.d.ts +4 -0
- package/dist/compiler/ir.js +255 -0
- package/dist/compiler/parser.d.ts +3 -0
- package/dist/compiler/parser.js +236 -0
- package/dist/compiler/sfc.d.ts +33 -0
- package/dist/compiler/sfc.js +439 -0
- package/dist/compiler/targets/client.d.ts +3 -0
- package/dist/compiler/targets/client.js +568 -0
- package/dist/compiler/targets/server.d.ts +7 -0
- package/dist/compiler/targets/server.js +396 -0
- package/dist/compiler/targets/stream.d.ts +2 -0
- package/dist/compiler/targets/stream.js +204 -0
- package/dist/compiler/types.d.ts +185 -0
- package/dist/compiler/types.js +1 -0
- package/dist/compiler/utils.d.ts +40 -0
- package/dist/compiler/utils.js +185 -0
- package/dist/compiler/whitespace.d.ts +2 -0
- package/dist/compiler/whitespace.js +66 -0
- package/dist/compiler.d.ts +1 -0
- package/dist/compiler.js +1 -0
- package/dist/constant-time.d.ts +1 -0
- package/dist/constant-time.js +26 -0
- package/dist/cookies.d.ts +44 -0
- package/dist/cookies.js +199 -0
- package/dist/diagnostics.d.ts +22 -0
- package/dist/diagnostics.js +45 -0
- package/dist/env.d.ts +27 -0
- package/dist/env.js +37 -0
- package/dist/html-escape.d.ts +2 -0
- package/dist/html-escape.js +20 -0
- package/dist/html-whitespace.d.ts +6 -0
- package/dist/html-whitespace.js +83 -0
- package/dist/i18n.d.ts +19 -0
- package/dist/i18n.js +93 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -0
- package/dist/language-server.d.ts +17 -0
- package/dist/language-server.js +79 -0
- package/dist/package-integrity.d.ts +9 -0
- package/dist/package-integrity.js +64 -0
- package/dist/result.d.ts +13 -0
- package/dist/result.js +4 -0
- package/dist/router-node.d.ts +2 -0
- package/dist/router-node.js +12 -0
- package/dist/router.d.ts +324 -0
- package/dist/router.js +1024 -0
- package/dist/runtime/attr.d.ts +3 -0
- package/dist/runtime/attr.js +66 -0
- package/dist/runtime/class.d.ts +2 -0
- package/dist/runtime/class.js +10 -0
- package/dist/runtime/conditional.d.ts +78 -0
- package/dist/runtime/conditional.js +140 -0
- package/dist/runtime/enhancement.d.ts +11 -0
- package/dist/runtime/enhancement.js +70 -0
- package/dist/runtime/error-boundary.d.ts +8 -0
- package/dist/runtime/error-boundary.js +27 -0
- package/dist/runtime/event.d.ts +1 -0
- package/dist/runtime/event.js +11 -0
- package/dist/runtime/form.d.ts +41 -0
- package/dist/runtime/form.js +167 -0
- package/dist/runtime/fragment.d.ts +6 -0
- package/dist/runtime/fragment.js +18 -0
- package/dist/runtime/html.d.ts +8 -0
- package/dist/runtime/html.js +6 -0
- package/dist/runtime/hydrate.d.ts +68 -0
- package/dist/runtime/hydrate.js +205 -0
- package/dist/runtime/keyed-rows.d.ts +61 -0
- package/dist/runtime/keyed-rows.js +192 -0
- package/dist/runtime/list.d.ts +84 -0
- package/dist/runtime/list.js +424 -0
- package/dist/runtime/portal.d.ts +6 -0
- package/dist/runtime/portal.js +12 -0
- package/dist/runtime/router.d.ts +102 -0
- package/dist/runtime/router.js +649 -0
- package/dist/runtime/signal.d.ts +30 -0
- package/dist/runtime/signal.js +275 -0
- package/dist/runtime/store.d.ts +1 -0
- package/dist/runtime/store.js +1 -0
- package/dist/runtime/stream-client.d.ts +8 -0
- package/dist/runtime/stream-client.js +33 -0
- package/dist/runtime/text.d.ts +2 -0
- package/dist/runtime/text.js +10 -0
- package/dist/runtime/virtual-list.d.ts +15 -0
- package/dist/runtime/virtual-list.js +89 -0
- package/dist/security.d.ts +27 -0
- package/dist/security.js +173 -0
- package/dist/server/form-action.d.ts +52 -0
- package/dist/server/form-action.js +102 -0
- package/dist/server/html.d.ts +19 -0
- package/dist/server/html.js +101 -0
- package/dist/server/stream.d.ts +4 -0
- package/dist/server/stream.js +72 -0
- package/dist/source-map.d.ts +17 -0
- package/dist/source-map.js +18 -0
- package/dist/tachyon-html.d.ts +110 -0
- package/dist/testing.d.ts +14 -0
- package/dist/testing.js +36 -0
- package/dist/typed.d.ts +9 -0
- package/dist/typed.js +4 -0
- package/dist/vite.d.ts +84 -0
- package/dist/vite.js +426 -0
- package/package.json +604 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
declare const clientHtmlBrand: unique symbol;
|
|
2
|
+
export type ClientHtml = {
|
|
3
|
+
readonly [clientHtmlBrand]: true;
|
|
4
|
+
toString(): string;
|
|
5
|
+
};
|
|
6
|
+
export declare const rawHtml: (value: string) => ClientHtml;
|
|
7
|
+
export declare const isClientHtml: (value: unknown) => value is ClientHtml;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const clientHtmlBrand = Symbol("tachyon.clientHtml");
|
|
2
|
+
export const rawHtml = (value) => ({
|
|
3
|
+
[clientHtmlBrand]: true,
|
|
4
|
+
toString: () => value,
|
|
5
|
+
});
|
|
6
|
+
export const isClientHtml = (value) => Boolean(value && typeof value === "object" && value[clientHtmlBrand] === true);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type Result } from "../result.js";
|
|
2
|
+
export type HydrationBoundaryError = {
|
|
3
|
+
message: string;
|
|
4
|
+
};
|
|
5
|
+
export type LocatedHydrationBoundary = {
|
|
6
|
+
id: string;
|
|
7
|
+
start: Comment;
|
|
8
|
+
end: Comment;
|
|
9
|
+
element: Element;
|
|
10
|
+
};
|
|
11
|
+
export type HydrationBoundaryDiagnostic = {
|
|
12
|
+
id: string;
|
|
13
|
+
type: "missing-start" | "missing-end" | "duplicate" | "missing-element";
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
export type HydrationDiagnosticsHot = {
|
|
17
|
+
send: (event: string, payload: {
|
|
18
|
+
err: {
|
|
19
|
+
message: string;
|
|
20
|
+
stack?: string;
|
|
21
|
+
};
|
|
22
|
+
}) => void;
|
|
23
|
+
};
|
|
24
|
+
export type HydrationBoundaryHandle = {
|
|
25
|
+
hydrate: () => void;
|
|
26
|
+
hydrated: () => boolean;
|
|
27
|
+
element: () => Element;
|
|
28
|
+
dispose: () => void;
|
|
29
|
+
};
|
|
30
|
+
export type HydrationStrategy = "load" | "idle" | "visible" | "media" | "interaction";
|
|
31
|
+
export type HydrationScheduleOptions = {
|
|
32
|
+
strategy: HydrationStrategy;
|
|
33
|
+
media?: string;
|
|
34
|
+
interaction?: keyof HTMLElementEventMap | string;
|
|
35
|
+
rootMargin?: string;
|
|
36
|
+
matchMedia?: (query: string) => MediaQueryList;
|
|
37
|
+
};
|
|
38
|
+
export type CompiledHydrationBoundary = {
|
|
39
|
+
id: string;
|
|
40
|
+
idKind?: "expression" | "static";
|
|
41
|
+
strategy?: HydrationStrategy;
|
|
42
|
+
media?: string;
|
|
43
|
+
interaction?: keyof HTMLElementEventMap | string;
|
|
44
|
+
rootMargin?: string;
|
|
45
|
+
};
|
|
46
|
+
export type ScheduleHydrationBoundariesOptions = {
|
|
47
|
+
resolveId?: (boundary: CompiledHydrationBoundary) => string | undefined;
|
|
48
|
+
onError?: (error: HydrationBoundaryError, boundary: CompiledHydrationBoundary) => void;
|
|
49
|
+
matchMedia?: (query: string) => MediaQueryList;
|
|
50
|
+
};
|
|
51
|
+
type HydrationCleanup = void | (() => void);
|
|
52
|
+
type HydrationCommentIndex = {
|
|
53
|
+
starts: Map<string, Comment[]>;
|
|
54
|
+
ends: Map<string, Comment[]>;
|
|
55
|
+
};
|
|
56
|
+
export declare const locateHydrationBoundary: (root: ParentNode, id: string, index?: HydrationCommentIndex) => Result<LocatedHydrationBoundary, HydrationBoundaryError>;
|
|
57
|
+
export declare const diagnoseHydrationBoundaries: (root: ParentNode, expectedIds: readonly string[]) => HydrationBoundaryDiagnostic[];
|
|
58
|
+
export declare const reportHydrationDiagnostics: (root: ParentNode, expectedIds: readonly string[], options?: {
|
|
59
|
+
hot?: HydrationDiagnosticsHot;
|
|
60
|
+
}) => HydrationBoundaryDiagnostic[];
|
|
61
|
+
export declare const createHydrationBoundary: (root: ParentNode, id: string, bind: (element: Element) => HydrationCleanup, index?: HydrationCommentIndex) => Result<HydrationBoundaryHandle, HydrationBoundaryError>;
|
|
62
|
+
export declare const serializeHydrationState: (id: string, state: unknown, options?: {
|
|
63
|
+
nonce?: string;
|
|
64
|
+
}) => string;
|
|
65
|
+
export declare const readHydrationState: <T>(root: ParentNode, id: string) => Result<T, HydrationBoundaryError>;
|
|
66
|
+
export declare const scheduleHydration: (handle: HydrationBoundaryHandle, options?: HydrationScheduleOptions) => (() => void);
|
|
67
|
+
export declare const scheduleHydrationBoundaries: (root: ParentNode, boundaries: readonly CompiledHydrationBoundary[], bind: (element: Element, boundary: CompiledHydrationBoundary) => HydrationCleanup, options?: ScheduleHydrationBoundariesOptions) => (() => void);
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { err, ok } from "../result.js";
|
|
2
|
+
const escapeScriptJson = (value) => value.replaceAll("<", "\\u003c").replaceAll(">", "\\u003e");
|
|
3
|
+
const escapeAttribute = (value) => value.replaceAll("&", "&").replaceAll(`"`, """).replaceAll("<", "<");
|
|
4
|
+
const commentsIn = (root) => {
|
|
5
|
+
const ownerDocument = root instanceof Document ? root : (root.ownerDocument ?? document);
|
|
6
|
+
const walker = ownerDocument.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
|
|
7
|
+
const comments = [];
|
|
8
|
+
let current = walker.nextNode();
|
|
9
|
+
while (current) {
|
|
10
|
+
if (current instanceof Comment) {
|
|
11
|
+
comments.push(current);
|
|
12
|
+
}
|
|
13
|
+
current = walker.nextNode();
|
|
14
|
+
}
|
|
15
|
+
return comments;
|
|
16
|
+
};
|
|
17
|
+
const hydrationCommentIndex = (root) => {
|
|
18
|
+
const starts = new Map();
|
|
19
|
+
const ends = new Map();
|
|
20
|
+
for (const comment of commentsIn(root)) {
|
|
21
|
+
if (!comment.data.startsWith("tachyon-hydrate:")) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (comment.data.endsWith(":start")) {
|
|
25
|
+
const id = comment.data.slice("tachyon-hydrate:".length, -":start".length);
|
|
26
|
+
starts.set(id, [...(starts.get(id) ?? []), comment]);
|
|
27
|
+
}
|
|
28
|
+
else if (comment.data.endsWith(":end")) {
|
|
29
|
+
const id = comment.data.slice("tachyon-hydrate:".length, -":end".length);
|
|
30
|
+
ends.set(id, [...(ends.get(id) ?? []), comment]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { starts, ends };
|
|
34
|
+
};
|
|
35
|
+
const nextElementBetween = (start, end) => {
|
|
36
|
+
let current = start.nextSibling;
|
|
37
|
+
while (current && current !== end) {
|
|
38
|
+
if (current instanceof Element) {
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
current = current.nextSibling;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
};
|
|
45
|
+
export const locateHydrationBoundary = (root, id, index = hydrationCommentIndex(root)) => {
|
|
46
|
+
const start = index.starts.get(id)?.[0];
|
|
47
|
+
const end = index.ends.get(id)?.[0];
|
|
48
|
+
if (!start || !end) {
|
|
49
|
+
return err({ message: `Missing hydrate boundary markers for ${id}.` });
|
|
50
|
+
}
|
|
51
|
+
const element = nextElementBetween(start, end);
|
|
52
|
+
if (!element) {
|
|
53
|
+
return err({ message: `Missing hydrate boundary element for ${id}.` });
|
|
54
|
+
}
|
|
55
|
+
return ok({ id, start, end, element });
|
|
56
|
+
};
|
|
57
|
+
export const diagnoseHydrationBoundaries = (root, expectedIds) => {
|
|
58
|
+
const index = hydrationCommentIndex(root);
|
|
59
|
+
const diagnostics = [];
|
|
60
|
+
for (const id of expectedIds) {
|
|
61
|
+
const starts = index.starts.get(id) ?? [];
|
|
62
|
+
const ends = index.ends.get(id) ?? [];
|
|
63
|
+
if (starts.length === 0) {
|
|
64
|
+
diagnostics.push({ id, type: "missing-start", message: `Missing hydrate boundary start marker for ${id}.` });
|
|
65
|
+
}
|
|
66
|
+
if (ends.length === 0) {
|
|
67
|
+
diagnostics.push({ id, type: "missing-end", message: `Missing hydrate boundary end marker for ${id}.` });
|
|
68
|
+
}
|
|
69
|
+
if (starts.length > 1 || ends.length > 1) {
|
|
70
|
+
diagnostics.push({ id, type: "duplicate", message: `Duplicate hydrate boundary markers for ${id}.` });
|
|
71
|
+
}
|
|
72
|
+
if (starts.length === 1 && ends.length === 1 && !nextElementBetween(starts[0], ends[0])) {
|
|
73
|
+
diagnostics.push({ id, type: "missing-element", message: `Missing hydrate boundary element for ${id}.` });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return diagnostics;
|
|
77
|
+
};
|
|
78
|
+
export const reportHydrationDiagnostics = (root, expectedIds, options = {}) => {
|
|
79
|
+
const diagnostics = diagnoseHydrationBoundaries(root, expectedIds);
|
|
80
|
+
for (const diagnostic of diagnostics) {
|
|
81
|
+
options.hot?.send("vite:error", {
|
|
82
|
+
err: {
|
|
83
|
+
message: diagnostic.message,
|
|
84
|
+
stack: diagnostic.message,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return diagnostics;
|
|
89
|
+
};
|
|
90
|
+
export const createHydrationBoundary = (root, id, bind, index) => {
|
|
91
|
+
const located = locateHydrationBoundary(root, id, index);
|
|
92
|
+
if (!located.ok) {
|
|
93
|
+
return err(located.error);
|
|
94
|
+
}
|
|
95
|
+
let cleanup;
|
|
96
|
+
let isHydrated = false;
|
|
97
|
+
const hydrate = () => {
|
|
98
|
+
if (isHydrated) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
cleanup = bind(located.value.element);
|
|
102
|
+
isHydrated = true;
|
|
103
|
+
};
|
|
104
|
+
return ok({
|
|
105
|
+
hydrate,
|
|
106
|
+
hydrated: () => isHydrated,
|
|
107
|
+
element: () => located.value.element,
|
|
108
|
+
dispose: () => {
|
|
109
|
+
if (typeof cleanup === "function") {
|
|
110
|
+
cleanup();
|
|
111
|
+
}
|
|
112
|
+
isHydrated = false;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
export const serializeHydrationState = (id, state, options = {}) => {
|
|
117
|
+
const nonce = options.nonce ? ` nonce="${escapeAttribute(options.nonce)}"` : "";
|
|
118
|
+
return `<script type="application/json" data-tachyon-state="${escapeAttribute(id)}"${nonce}>${escapeScriptJson(JSON.stringify(state) ?? "null")}</script>`;
|
|
119
|
+
};
|
|
120
|
+
export const readHydrationState = (root, id) => {
|
|
121
|
+
const script = Array.from(root.querySelectorAll(`script[type="application/json"][data-tachyon-state]`)).find((candidate) => candidate.getAttribute("data-tachyon-state") === id);
|
|
122
|
+
if (!script) {
|
|
123
|
+
return err({ message: `Missing hydration state for ${id}.` });
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return ok(JSON.parse(script.textContent ?? "null"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return err({ message: `Invalid hydration state for ${id}.` });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
export const scheduleHydration = (handle, options = { strategy: "load" }) => {
|
|
133
|
+
if (options.strategy === "load") {
|
|
134
|
+
handle.hydrate();
|
|
135
|
+
return () => undefined;
|
|
136
|
+
}
|
|
137
|
+
if (options.strategy === "idle") {
|
|
138
|
+
const requestIdle = globalThis.requestIdleCallback ??
|
|
139
|
+
((callback) => setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 0 }), 0));
|
|
140
|
+
const cancelIdle = globalThis.cancelIdleCallback ?? clearTimeout;
|
|
141
|
+
const id = requestIdle(() => handle.hydrate());
|
|
142
|
+
return () => cancelIdle(id);
|
|
143
|
+
}
|
|
144
|
+
if (options.strategy === "media") {
|
|
145
|
+
const query = options.media;
|
|
146
|
+
if (!query) {
|
|
147
|
+
return () => undefined;
|
|
148
|
+
}
|
|
149
|
+
const matcher = options.matchMedia ?? globalThis.matchMedia;
|
|
150
|
+
const media = matcher(query);
|
|
151
|
+
const listener = () => {
|
|
152
|
+
if (media.matches) {
|
|
153
|
+
handle.hydrate();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
media.addEventListener("change", listener);
|
|
157
|
+
listener();
|
|
158
|
+
return () => media.removeEventListener("change", listener);
|
|
159
|
+
}
|
|
160
|
+
if (options.strategy === "visible") {
|
|
161
|
+
const observer = new IntersectionObserver((entries) => {
|
|
162
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
163
|
+
handle.hydrate();
|
|
164
|
+
observer.disconnect();
|
|
165
|
+
}
|
|
166
|
+
}, options.rootMargin === undefined ? {} : { rootMargin: options.rootMargin });
|
|
167
|
+
observer.observe(handle.element());
|
|
168
|
+
return () => observer.disconnect();
|
|
169
|
+
}
|
|
170
|
+
const eventName = options.interaction ?? "click";
|
|
171
|
+
const listener = () => {
|
|
172
|
+
handle.hydrate();
|
|
173
|
+
document.removeEventListener(eventName, listener, true);
|
|
174
|
+
};
|
|
175
|
+
document.addEventListener(eventName, listener, true);
|
|
176
|
+
return () => document.removeEventListener(eventName, listener, true);
|
|
177
|
+
};
|
|
178
|
+
export const scheduleHydrationBoundaries = (root, boundaries, bind, options = {}) => {
|
|
179
|
+
const cleanups = [];
|
|
180
|
+
const index = hydrationCommentIndex(root);
|
|
181
|
+
for (const boundary of boundaries) {
|
|
182
|
+
const id = boundary.idKind === "expression" ? options.resolveId?.(boundary) : boundary.id;
|
|
183
|
+
if (!id) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const handle = createHydrationBoundary(root, id, (element) => bind(element, boundary), index);
|
|
187
|
+
if (!handle.ok) {
|
|
188
|
+
options.onError?.(handle.error, boundary);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
cleanups.push(scheduleHydration(handle.value, {
|
|
192
|
+
strategy: boundary.strategy ?? "load",
|
|
193
|
+
...(boundary.media ? { media: boundary.media } : {}),
|
|
194
|
+
...(boundary.interaction ? { interaction: boundary.interaction } : {}),
|
|
195
|
+
...(boundary.rootMargin ? { rootMargin: boundary.rootMargin } : {}),
|
|
196
|
+
...(options.matchMedia ? { matchMedia: options.matchMedia } : {}),
|
|
197
|
+
}));
|
|
198
|
+
cleanups.push(() => handle.value.dispose());
|
|
199
|
+
}
|
|
200
|
+
return () => {
|
|
201
|
+
for (const cleanup of cleanups.splice(0).reverse()) {
|
|
202
|
+
cleanup();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keyed-rows: a high-performance keyed row list rendered directly into a `<tbody>`.
|
|
3
|
+
*
|
|
4
|
+
* The live DOM is the single source of truth. Unlike a virtual-DOM or shadow-array
|
|
5
|
+
* renderer, this primitive keeps no parallel `items[]` / `rowNodes[]` arrays, so:
|
|
6
|
+
*
|
|
7
|
+
* - memory stays close to the raw element cost (no bookkeeping arrays, no row pool),
|
|
8
|
+
* - `removeAt` / `swap` / `select` are O(1) DOM operations instead of array shifts,
|
|
9
|
+
* - `clear()` simply drops the subtree so it can be garbage collected immediately.
|
|
10
|
+
*
|
|
11
|
+
* Bulk creation binds a reusable multi-row chunk once and clones it, keeping the
|
|
12
|
+
* number of `cloneNode()` / `appendChild()` boundary crossings small and roughly
|
|
13
|
+
* constant regardless of row count. The body is detached during a full rebuild so
|
|
14
|
+
* the browser performs a single layout pass.
|
|
15
|
+
*
|
|
16
|
+
* This module intentionally has no imports: it is the hot path, and keeping it
|
|
17
|
+
* dependency-free keeps both its parse cost and its dev-server module graph minimal.
|
|
18
|
+
*/
|
|
19
|
+
export type KeyedRowsOptions<T> = {
|
|
20
|
+
/** Table body the rows live in. The DOM beneath it is the source of truth. */
|
|
21
|
+
tbody: HTMLTableSectionElement;
|
|
22
|
+
/** Single-row markup, e.g. `"<tr><td></td>...</tr>"`. Parsed once into a template. */
|
|
23
|
+
row: string;
|
|
24
|
+
/** Write an item's fields into a row before it is cloned into the table. */
|
|
25
|
+
bind: (row: HTMLTableRowElement, item: T, index: number) => void;
|
|
26
|
+
/** Class toggled on the selected row. Defaults to `"selected"`. */
|
|
27
|
+
selectedClass?: string;
|
|
28
|
+
/** Target number of DOM insertions per bulk build. Defaults to `50`. */
|
|
29
|
+
chunks?: number;
|
|
30
|
+
};
|
|
31
|
+
export type KeyedRows<T> = {
|
|
32
|
+
/** Replace every row from an array of items. */
|
|
33
|
+
replace: (items: readonly T[]) => void;
|
|
34
|
+
/** Append rows from an array of items. */
|
|
35
|
+
append: (items: readonly T[]) => void;
|
|
36
|
+
/** Replace every row, producing each item lazily (no intermediate array). */
|
|
37
|
+
replaceEach: (count: number, make: (index: number) => T) => void;
|
|
38
|
+
/** Append rows, producing each item lazily (no intermediate array). */
|
|
39
|
+
appendEach: (count: number, make: (index: number) => T) => void;
|
|
40
|
+
/** Run `patch` against every `stride`-th live row. */
|
|
41
|
+
update: (stride: number, patch: (row: HTMLTableRowElement, index: number) => void) => void;
|
|
42
|
+
/** Remove the row at `index`. */
|
|
43
|
+
removeAt: (index: number) => void;
|
|
44
|
+
/** Remove a specific row element. */
|
|
45
|
+
removeRow: (row: HTMLTableRowElement) => void;
|
|
46
|
+
/** Swap the rows at `a` and `b`, preserving element identity. */
|
|
47
|
+
swap: (a: number, b: number) => void;
|
|
48
|
+
/** Remove every row. */
|
|
49
|
+
clear: () => void;
|
|
50
|
+
/** Select the row at `index`. */
|
|
51
|
+
selectAt: (index: number) => void;
|
|
52
|
+
/** Select a specific row element, or pass `null` to clear the selection. */
|
|
53
|
+
selectRow: (row: HTMLTableRowElement | null) => void;
|
|
54
|
+
/** Index of the selected row, or `-1`. Recomputed from the live DOM. */
|
|
55
|
+
selectedIndex: () => number;
|
|
56
|
+
/** Number of rows currently in the body. */
|
|
57
|
+
length: () => number;
|
|
58
|
+
/** Row element at `index`, if any. */
|
|
59
|
+
rowAt: (index: number) => HTMLTableRowElement | undefined;
|
|
60
|
+
};
|
|
61
|
+
export declare const createKeyedRows: <T>(options: KeyedRowsOptions<T>) => KeyedRows<T>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keyed-rows: a high-performance keyed row list rendered directly into a `<tbody>`.
|
|
3
|
+
*
|
|
4
|
+
* The live DOM is the single source of truth. Unlike a virtual-DOM or shadow-array
|
|
5
|
+
* renderer, this primitive keeps no parallel `items[]` / `rowNodes[]` arrays, so:
|
|
6
|
+
*
|
|
7
|
+
* - memory stays close to the raw element cost (no bookkeeping arrays, no row pool),
|
|
8
|
+
* - `removeAt` / `swap` / `select` are O(1) DOM operations instead of array shifts,
|
|
9
|
+
* - `clear()` simply drops the subtree so it can be garbage collected immediately.
|
|
10
|
+
*
|
|
11
|
+
* Bulk creation binds a reusable multi-row chunk once and clones it, keeping the
|
|
12
|
+
* number of `cloneNode()` / `appendChild()` boundary crossings small and roughly
|
|
13
|
+
* constant regardless of row count. The body is detached during a full rebuild so
|
|
14
|
+
* the browser performs a single layout pass.
|
|
15
|
+
*
|
|
16
|
+
* This module intentionally has no imports: it is the hot path, and keeping it
|
|
17
|
+
* dependency-free keeps both its parse cost and its dev-server module graph minimal.
|
|
18
|
+
*/
|
|
19
|
+
const defaultChunks = 50;
|
|
20
|
+
const positiveInteger = (name, value) => {
|
|
21
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
22
|
+
throw new TypeError(`keyed-rows: \`${name}\` must be a positive finite integer.`);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
export const createKeyedRows = (options) => {
|
|
26
|
+
const tbody = options.tbody;
|
|
27
|
+
const bind = options.bind;
|
|
28
|
+
const selectedClass = options.selectedClass ?? "selected";
|
|
29
|
+
const targetChunks = options.chunks ?? defaultChunks;
|
|
30
|
+
positiveInteger("chunks", targetChunks);
|
|
31
|
+
const single = document.createElement("template");
|
|
32
|
+
single.innerHTML = options.row;
|
|
33
|
+
const baseRow = single.content.firstElementChild;
|
|
34
|
+
if (!(baseRow instanceof HTMLTableRowElement)) {
|
|
35
|
+
throw new TypeError("keyed-rows: `row` markup must have a single <tr> root.");
|
|
36
|
+
}
|
|
37
|
+
// Reusable chunk of template rows; rebuilt only when the per-chunk size changes.
|
|
38
|
+
const chunk = document.createDocumentFragment();
|
|
39
|
+
let chunkRows = 0;
|
|
40
|
+
let selectedRow;
|
|
41
|
+
const ensureChunk = (rowsPerChunk) => {
|
|
42
|
+
if (chunkRows === rowsPerChunk) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
chunk.replaceChildren();
|
|
46
|
+
for (let i = 0; i < rowsPerChunk; i++) {
|
|
47
|
+
chunk.appendChild(baseRow.cloneNode(true));
|
|
48
|
+
}
|
|
49
|
+
chunkRows = rowsPerChunk;
|
|
50
|
+
};
|
|
51
|
+
const build = (count, make, start) => {
|
|
52
|
+
if (count <= 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const perChunk = count < targetChunks ? count : Math.ceil(count / targetChunks);
|
|
56
|
+
ensureChunk(perChunk);
|
|
57
|
+
const templateRows = chunk.children;
|
|
58
|
+
let done = 0;
|
|
59
|
+
while (done < count) {
|
|
60
|
+
const size = Math.min(perChunk, count - done);
|
|
61
|
+
for (let local = 0; local < size; local++) {
|
|
62
|
+
const index = start + done + local;
|
|
63
|
+
bind(templateRows[local], make(index), index);
|
|
64
|
+
}
|
|
65
|
+
if (size === perChunk) {
|
|
66
|
+
tbody.appendChild(chunk.cloneNode(true));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const partial = document.createDocumentFragment();
|
|
70
|
+
for (let local = 0; local < size; local++) {
|
|
71
|
+
partial.appendChild(templateRows[local].cloneNode(true));
|
|
72
|
+
}
|
|
73
|
+
tbody.appendChild(partial);
|
|
74
|
+
}
|
|
75
|
+
done += size;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
// Detach the body during a full rebuild so layout/paint happens once on reattach.
|
|
79
|
+
const rebuild = (count, make) => {
|
|
80
|
+
const parent = tbody.parentNode;
|
|
81
|
+
const nextSibling = tbody.nextSibling;
|
|
82
|
+
if (parent) {
|
|
83
|
+
parent.removeChild(tbody);
|
|
84
|
+
}
|
|
85
|
+
tbody.textContent = "";
|
|
86
|
+
selectedRow = undefined;
|
|
87
|
+
build(count, make, 0);
|
|
88
|
+
if (parent) {
|
|
89
|
+
parent.insertBefore(tbody, nextSibling);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const replace = (items) => rebuild(items.length, (index) => items[index]);
|
|
93
|
+
const replaceEach = (count, make) => {
|
|
94
|
+
positiveInteger("count", count);
|
|
95
|
+
rebuild(count, make);
|
|
96
|
+
};
|
|
97
|
+
const append = (items) => {
|
|
98
|
+
const start = tbody.childElementCount;
|
|
99
|
+
build(items.length, (index) => items[index - start], start);
|
|
100
|
+
};
|
|
101
|
+
const appendEach = (count, make) => {
|
|
102
|
+
positiveInteger("count", count);
|
|
103
|
+
build(count, make, tbody.childElementCount);
|
|
104
|
+
};
|
|
105
|
+
const update = (stride, patch) => {
|
|
106
|
+
positiveInteger("stride", stride);
|
|
107
|
+
const rows = tbody.children;
|
|
108
|
+
const total = rows.length;
|
|
109
|
+
for (let index = 0; index < total; index += stride) {
|
|
110
|
+
patch(rows[index], index);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const removeRow = (row) => {
|
|
114
|
+
if (row === selectedRow) {
|
|
115
|
+
selectedRow = undefined;
|
|
116
|
+
}
|
|
117
|
+
row.remove();
|
|
118
|
+
};
|
|
119
|
+
const removeAt = (index) => {
|
|
120
|
+
const row = tbody.children[index];
|
|
121
|
+
if (row) {
|
|
122
|
+
removeRow(row);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const swap = (a, b) => {
|
|
126
|
+
if (a === b) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const rows = tbody.children;
|
|
130
|
+
const rowA = rows[a];
|
|
131
|
+
const rowB = rows[b];
|
|
132
|
+
if (!rowA || !rowB) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const activeElement = tbody.ownerDocument.activeElement;
|
|
136
|
+
const restoreFocus = activeElement &&
|
|
137
|
+
activeElement !== tbody.ownerDocument.body &&
|
|
138
|
+
(rowA.contains(activeElement) || rowB.contains(activeElement)) &&
|
|
139
|
+
"focus" in activeElement
|
|
140
|
+
? activeElement
|
|
141
|
+
: undefined;
|
|
142
|
+
const nextA = rowA.nextSibling;
|
|
143
|
+
if (nextA === rowB) {
|
|
144
|
+
tbody.insertBefore(rowB, rowA);
|
|
145
|
+
restoreFocus?.focus({ preventScroll: true });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const nextB = rowB.nextSibling;
|
|
149
|
+
tbody.insertBefore(rowB, nextA);
|
|
150
|
+
tbody.insertBefore(rowA, nextB);
|
|
151
|
+
restoreFocus?.focus({ preventScroll: true });
|
|
152
|
+
};
|
|
153
|
+
const clear = () => {
|
|
154
|
+
selectedRow = undefined;
|
|
155
|
+
tbody.textContent = "";
|
|
156
|
+
};
|
|
157
|
+
const selectRow = (row) => {
|
|
158
|
+
if (selectedRow === row) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (selectedRow) {
|
|
162
|
+
selectedRow.classList.remove(selectedClass);
|
|
163
|
+
}
|
|
164
|
+
selectedRow = row ?? undefined;
|
|
165
|
+
if (selectedRow) {
|
|
166
|
+
selectedRow.classList.add(selectedClass);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const selectAt = (index) => {
|
|
170
|
+
const row = tbody.children[index];
|
|
171
|
+
if (row) {
|
|
172
|
+
selectRow(row);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const selectedIndex = () => selectedRow && selectedRow.parentNode === tbody ? selectedRow.sectionRowIndex : -1;
|
|
176
|
+
return {
|
|
177
|
+
replace,
|
|
178
|
+
append,
|
|
179
|
+
replaceEach,
|
|
180
|
+
appendEach,
|
|
181
|
+
update,
|
|
182
|
+
removeAt,
|
|
183
|
+
removeRow,
|
|
184
|
+
swap,
|
|
185
|
+
clear,
|
|
186
|
+
selectAt,
|
|
187
|
+
selectRow,
|
|
188
|
+
selectedIndex,
|
|
189
|
+
length: () => tbody.childElementCount,
|
|
190
|
+
rowAt: (index) => tbody.children[index],
|
|
191
|
+
};
|
|
192
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
type ExpressionReader = (scope: Record<string, unknown>) => unknown;
|
|
2
|
+
type ExpressionWriter = (scope: Record<string, unknown>, value: unknown) => void;
|
|
3
|
+
type TextBinding = {
|
|
4
|
+
kind: "text";
|
|
5
|
+
path: number[];
|
|
6
|
+
expression: string;
|
|
7
|
+
read?: ExpressionReader;
|
|
8
|
+
};
|
|
9
|
+
type ClassBinding = {
|
|
10
|
+
kind: "class";
|
|
11
|
+
path: number[];
|
|
12
|
+
className: string;
|
|
13
|
+
expression: string;
|
|
14
|
+
read?: ExpressionReader;
|
|
15
|
+
};
|
|
16
|
+
type EventBinding = {
|
|
17
|
+
kind: "event";
|
|
18
|
+
path: number[];
|
|
19
|
+
eventName: string;
|
|
20
|
+
handler: string;
|
|
21
|
+
read?: ExpressionReader;
|
|
22
|
+
};
|
|
23
|
+
type AttributeBinding = {
|
|
24
|
+
kind: "attr";
|
|
25
|
+
path: number[];
|
|
26
|
+
name: string;
|
|
27
|
+
expression: string;
|
|
28
|
+
read?: ExpressionReader;
|
|
29
|
+
};
|
|
30
|
+
type StyleBinding = {
|
|
31
|
+
kind: "style";
|
|
32
|
+
path: number[];
|
|
33
|
+
name: string;
|
|
34
|
+
expression: string;
|
|
35
|
+
read?: ExpressionReader;
|
|
36
|
+
};
|
|
37
|
+
type RefBinding = {
|
|
38
|
+
kind: "ref";
|
|
39
|
+
path: number[];
|
|
40
|
+
expression: string;
|
|
41
|
+
};
|
|
42
|
+
type ModelBinding = {
|
|
43
|
+
kind: "model";
|
|
44
|
+
path: number[];
|
|
45
|
+
property: "value" | "checked";
|
|
46
|
+
expression: string;
|
|
47
|
+
read?: ExpressionReader;
|
|
48
|
+
write?: ExpressionWriter;
|
|
49
|
+
};
|
|
50
|
+
type NestedListBinding = {
|
|
51
|
+
kind: "list";
|
|
52
|
+
signature?: string;
|
|
53
|
+
path: number[];
|
|
54
|
+
each: string;
|
|
55
|
+
read?: ExpressionReader;
|
|
56
|
+
key: string;
|
|
57
|
+
keyRead?: ExpressionReader;
|
|
58
|
+
keyReadItem?: (item: unknown) => unknown;
|
|
59
|
+
itemName: string;
|
|
60
|
+
templateHtml: string;
|
|
61
|
+
bindings: Binding[];
|
|
62
|
+
};
|
|
63
|
+
type NestedConditionalBinding = {
|
|
64
|
+
kind: "if";
|
|
65
|
+
signature?: string;
|
|
66
|
+
path: number[];
|
|
67
|
+
test: string;
|
|
68
|
+
read?: ExpressionReader;
|
|
69
|
+
templateHtml: string;
|
|
70
|
+
bindings: Binding[];
|
|
71
|
+
};
|
|
72
|
+
type Binding = TextBinding | ClassBinding | EventBinding | AttributeBinding | StyleBinding | RefBinding | ModelBinding | NestedListBinding | NestedConditionalBinding;
|
|
73
|
+
type KeyedListOptions = {
|
|
74
|
+
signature?: string;
|
|
75
|
+
key: string;
|
|
76
|
+
keyRead?: ExpressionReader;
|
|
77
|
+
keyReadItem?: (item: unknown) => unknown;
|
|
78
|
+
itemName: string;
|
|
79
|
+
scope?: Record<string, unknown>;
|
|
80
|
+
templateHtml: string;
|
|
81
|
+
bindings: Binding[];
|
|
82
|
+
};
|
|
83
|
+
export declare const mountKeyedList: (root: Element, path: readonly number[], items: readonly unknown[] | undefined, options: KeyedListOptions) => void;
|
|
84
|
+
export {};
|