@joist/element 4.2.3-next.13 → 4.2.3-next.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.
Files changed (82) hide show
  1. package/package.json +5 -4
  2. package/src/lib/attr.test.ts +17 -36
  3. package/src/lib/element.test.ts +25 -33
  4. package/src/lib/element.ts +18 -11
  5. package/src/lib/listen.test.ts +0 -75
  6. package/src/lib/templating/README.md +406 -0
  7. package/src/lib/templating/bind.ts +40 -0
  8. package/src/lib/templating/define.ts +5 -0
  9. package/src/lib/templating/elements/async.element.test.ts +90 -0
  10. package/src/lib/templating/elements/async.element.ts +122 -0
  11. package/src/lib/templating/elements/for.element.test.ts +221 -0
  12. package/src/lib/templating/elements/for.element.ts +189 -0
  13. package/src/lib/templating/elements/if.element.test.ts +90 -0
  14. package/src/lib/templating/elements/if.element.ts +93 -0
  15. package/src/lib/templating/elements/props.element.test.ts +62 -0
  16. package/src/lib/templating/elements/props.element.ts +80 -0
  17. package/src/lib/templating/elements/scope.ts +45 -0
  18. package/src/lib/templating/elements/value.element.test.ts +20 -0
  19. package/src/lib/templating/elements/value.element.ts +41 -0
  20. package/src/lib/templating/events.ts +21 -0
  21. package/src/lib/templating/token.test.ts +74 -0
  22. package/src/lib/templating/token.ts +34 -0
  23. package/src/lib/templating.ts +2 -0
  24. package/target/lib/attr.test.js +19 -42
  25. package/target/lib/attr.test.js.map +1 -1
  26. package/target/lib/element.js +8 -8
  27. package/target/lib/element.js.map +1 -1
  28. package/target/lib/element.test.js +0 -68
  29. package/target/lib/element.test.js.map +1 -1
  30. package/target/lib/listen.test.js +0 -97
  31. package/target/lib/listen.test.js.map +1 -1
  32. package/target/lib/templating/bind.d.ts +1 -0
  33. package/target/lib/templating/bind.js +30 -0
  34. package/target/lib/templating/bind.js.map +1 -0
  35. package/target/lib/templating/define.d.ts +5 -0
  36. package/target/lib/templating/define.js +6 -0
  37. package/target/lib/templating/define.js.map +1 -0
  38. package/target/lib/templating/elements/async.element.d.ts +17 -0
  39. package/target/lib/templating/elements/async.element.js +115 -0
  40. package/target/lib/templating/elements/async.element.js.map +1 -0
  41. package/target/lib/templating/elements/async.element.test.d.ts +1 -0
  42. package/target/lib/templating/elements/async.element.test.js +75 -0
  43. package/target/lib/templating/elements/async.element.test.js.map +1 -0
  44. package/target/lib/templating/elements/for.element.d.ts +24 -0
  45. package/target/lib/templating/elements/for.element.js +186 -0
  46. package/target/lib/templating/elements/for.element.js.map +1 -0
  47. package/target/lib/templating/elements/for.element.test.d.ts +2 -0
  48. package/target/lib/templating/elements/for.element.test.js +153 -0
  49. package/target/lib/templating/elements/for.element.test.js.map +1 -0
  50. package/target/lib/templating/elements/if.element.d.ts +12 -0
  51. package/target/lib/templating/elements/if.element.js +85 -0
  52. package/target/lib/templating/elements/if.element.js.map +1 -0
  53. package/target/lib/templating/elements/if.element.test.d.ts +1 -0
  54. package/target/lib/templating/elements/if.element.test.js +78 -0
  55. package/target/lib/templating/elements/if.element.test.js.map +1 -0
  56. package/target/lib/templating/elements/props.element.d.ts +11 -0
  57. package/target/lib/templating/elements/props.element.js +92 -0
  58. package/target/lib/templating/elements/props.element.js.map +1 -0
  59. package/target/lib/templating/elements/props.element.test.d.ts +1 -0
  60. package/target/lib/templating/elements/props.element.test.js +53 -0
  61. package/target/lib/templating/elements/props.element.test.js.map +1 -0
  62. package/target/lib/templating/elements/scope.d.ts +13 -0
  63. package/target/lib/templating/elements/scope.js +59 -0
  64. package/target/lib/templating/elements/scope.js.map +1 -0
  65. package/target/lib/templating/elements/value.element.d.ts +9 -0
  66. package/target/lib/templating/elements/value.element.js +56 -0
  67. package/target/lib/templating/elements/value.element.js.map +1 -0
  68. package/target/lib/templating/elements/value.element.test.d.ts +1 -0
  69. package/target/lib/templating/elements/value.element.test.js +16 -0
  70. package/target/lib/templating/elements/value.element.test.js.map +1 -0
  71. package/target/lib/templating/events.d.ts +12 -0
  72. package/target/lib/templating/events.js +10 -0
  73. package/target/lib/templating/events.js.map +1 -0
  74. package/target/lib/templating/token.d.ts +8 -0
  75. package/target/lib/templating/token.js +27 -0
  76. package/target/lib/templating/token.js.map +1 -0
  77. package/target/lib/templating/token.test.d.ts +1 -0
  78. package/target/lib/templating/token.test.js +56 -0
  79. package/target/lib/templating/token.test.js.map +1 -0
  80. package/target/lib/templating.d.ts +2 -0
  81. package/target/lib/templating.js +3 -0
  82. package/target/lib/templating.js.map +1 -0
@@ -0,0 +1,40 @@
1
+ import { instanceMetadataStore, observe } from "@joist/observable";
2
+
3
+ export function bind() {
4
+ return function bindDecorator<This extends HTMLElement, Value>(
5
+ base: ClassAccessorDecoratorTarget<This, Value>,
6
+ ctx: ClassAccessorDecoratorContext<This, Value>,
7
+ ): ClassAccessorDecoratorResult<This, Value> {
8
+ const internalObserve = observe()(base, ctx);
9
+
10
+ return {
11
+ init(value) {
12
+ this.addEventListener("joist::value", (e) => {
13
+ if (e.token.bindTo === ctx.name) {
14
+ const instanceMeta = instanceMetadataStore.read<This>(this);
15
+
16
+ e.stopPropagation();
17
+
18
+ e.update({ oldValue: null, newValue: ctx.access.get(this) });
19
+
20
+ instanceMeta.bindings.add((changes) => {
21
+ const key = ctx.name as keyof This;
22
+ const res = changes.get(key);
23
+
24
+ if (res) {
25
+ e.update(res);
26
+ }
27
+ });
28
+ }
29
+ });
30
+
31
+ if (internalObserve.init) {
32
+ return internalObserve.init.call(this, value);
33
+ }
34
+
35
+ return value;
36
+ },
37
+ set: internalObserve.set,
38
+ };
39
+ };
40
+ }
@@ -0,0 +1,5 @@
1
+ import "./elements/async.element.js";
2
+ import "./elements/for.element.js";
3
+ import "./elements/if.element.js";
4
+ import "./elements/props.element.js";
5
+ import "./elements/value.element.js";
@@ -0,0 +1,90 @@
1
+ import "./async.element.js";
2
+
3
+ import { fixtureSync, html } from "@open-wc/testing";
4
+ import { assert } from "chai";
5
+
6
+ import type { JoistValueEvent } from "../events.js";
7
+
8
+ it("should show loading template when promise is pending", async () => {
9
+ const element = fixtureSync(html`
10
+ <div
11
+ @joist::value=${(e: JoistValueEvent) => {
12
+ e.update({ oldValue: null, newValue: new Promise(() => {}) });
13
+ }}
14
+ >
15
+ <j-async bind="test">
16
+ <template loading>Loading...</template>
17
+ <template success>Success!</template>
18
+ <template error>Error!</template>
19
+ </j-async>
20
+ </div>
21
+ `);
22
+
23
+ assert.equal(element.textContent?.trim(), "Loading...");
24
+ });
25
+
26
+ it("should show success template when promise resolves", async () => {
27
+ const element = fixtureSync(html`
28
+ <div
29
+ @joist::value=${(e: JoistValueEvent) => {
30
+ e.update({ oldValue: null, newValue: Promise.resolve("data") });
31
+ }}
32
+ >
33
+ <j-async bind="test">
34
+ <template loading>Loading...</template>
35
+ <template success>Success!</template>
36
+ <template error>Error!</template>
37
+ </j-async>
38
+ </div>
39
+ `);
40
+
41
+ // Wait for promise to resolve
42
+ await new Promise((resolve) => setTimeout(resolve, 0));
43
+ assert.equal(element.textContent?.trim(), "Success!");
44
+ });
45
+
46
+ it("should show error template when promise rejects", async () => {
47
+ const element = fixtureSync(html`
48
+ <div
49
+ @joist::value=${(e: JoistValueEvent) => {
50
+ e.update({ oldValue: null, newValue: Promise.reject("error") });
51
+ }}
52
+ >
53
+ <j-async bind="test">
54
+ <template loading>Loading...</template>
55
+ <template success>Success!</template>
56
+ <template error>Error!</template>
57
+ </j-async>
58
+ </div>
59
+ `);
60
+
61
+ // Wait for promise to reject
62
+ await new Promise((resolve) => setTimeout(resolve, 0));
63
+ assert.equal(element.textContent?.trim(), "Error!");
64
+ });
65
+
66
+ it("should handle state transitions", async () => {
67
+ const element = fixtureSync(html`
68
+ <div
69
+ @joist::value=${(e: JoistValueEvent) => {
70
+ const promise = new Promise((resolve) => {
71
+ setTimeout(() => resolve("data"), 100);
72
+ });
73
+ e.update({ oldValue: null, newValue: promise });
74
+ }}
75
+ >
76
+ <j-async bind="test">
77
+ <template loading>Loading...</template>
78
+ <template success>Success!</template>
79
+ <template error>Error!</template>
80
+ </j-async>
81
+ </div>
82
+ `);
83
+
84
+ // Initially should show loading
85
+ assert.equal(element.textContent?.trim(), "Loading...");
86
+
87
+ // Wait for promise to resolve
88
+ await new Promise((resolve) => setTimeout(resolve, 150));
89
+ assert.equal(element.textContent?.trim(), "Success!");
90
+ });
@@ -0,0 +1,122 @@
1
+ import { attr } from "../../attr.js";
2
+ import { element } from "../../element.js";
3
+ import { queryAll } from "../../query-all.js";
4
+ import { css, html } from "../../tags.js";
5
+ import { bind } from "../bind.js";
6
+
7
+ import { JoistValueEvent } from "../events.js";
8
+ import { JToken } from "../token.js";
9
+
10
+ declare global {
11
+ interface HTMLElementTagNameMap {
12
+ "j-async": JoistAsyncElement;
13
+ }
14
+ }
15
+
16
+ export type AsyncState<T = unknown, E = unknown> = {
17
+ status: "loading" | "error" | "success";
18
+ data?: T;
19
+ error?: E;
20
+ };
21
+
22
+ @element({
23
+ tagName: "j-async",
24
+ // prettier-ignore
25
+ shadowDom: [css`:host{display: contents;}`, html`<slot></slot>`],
26
+ })
27
+ export class JoistAsyncElement extends HTMLElement {
28
+ @attr()
29
+ accessor bind = "";
30
+
31
+ @bind()
32
+ accessor state: AsyncState | null = null;
33
+
34
+ #templates = queryAll<HTMLTemplateElement>("template", this);
35
+ #currentNodes: Node[] = [];
36
+ #cachedTemplates: {
37
+ loading?: HTMLTemplateElement;
38
+ error?: HTMLTemplateElement;
39
+ success?: HTMLTemplateElement;
40
+ } = {
41
+ loading: undefined,
42
+ error: undefined,
43
+ success: undefined,
44
+ };
45
+
46
+ connectedCallback(): void {
47
+ this.#clean();
48
+
49
+ // Cache all templates
50
+ const templates = Array.from(this.#templates());
51
+
52
+ this.#cachedTemplates = {
53
+ loading: templates.find((t) => t.hasAttribute("loading")),
54
+ error: templates.find((t) => t.hasAttribute("error")),
55
+ success: templates.find((t) => t.hasAttribute("success")),
56
+ };
57
+
58
+ const token = new JToken(this.bind);
59
+
60
+ this.dispatchEvent(
61
+ new JoistValueEvent(token, ({ newValue, oldValue }) => {
62
+ if (newValue !== oldValue) {
63
+ if (newValue instanceof Promise) {
64
+ this.#handlePromise(newValue);
65
+ } else {
66
+ console.warn("j-async bind value must be a Promise or AsyncState");
67
+ }
68
+ }
69
+ }),
70
+ );
71
+ }
72
+
73
+ async #handlePromise(promise: Promise<unknown>): Promise<void> {
74
+ try {
75
+ this.#handleState({ status: "loading" });
76
+ const data = await promise;
77
+ this.#handleState({ status: "success", data });
78
+ } catch (error) {
79
+ this.#handleState({ status: "error", error });
80
+ }
81
+ }
82
+
83
+ #handleState(state: AsyncState): void {
84
+ this.#clean();
85
+
86
+ let template: HTMLTemplateElement | undefined = undefined;
87
+
88
+ this.state = state;
89
+
90
+ switch (state.status) {
91
+ case "loading":
92
+ template = this.#cachedTemplates.loading;
93
+ break;
94
+
95
+ case "error":
96
+ template = this.#cachedTemplates.error;
97
+ break;
98
+
99
+ case "success":
100
+ template = this.#cachedTemplates.success;
101
+ break;
102
+ }
103
+
104
+ if (template) {
105
+ const content = document.importNode(template.content, true);
106
+ const nodes = Array.from(content.childNodes);
107
+ this.appendChild(content);
108
+ this.#currentNodes = nodes;
109
+ }
110
+ }
111
+
112
+ #clean(): void {
113
+ for (const node of this.#currentNodes) {
114
+ node.parentNode?.removeChild(node);
115
+ }
116
+ this.#currentNodes = [];
117
+ }
118
+
119
+ disconnectedCallback(): void {
120
+ this.#clean();
121
+ }
122
+ }
@@ -0,0 +1,221 @@
1
+ import "./for.element.js";
2
+ import "./value.element.js";
3
+
4
+ import { fixtureSync, html } from "@open-wc/testing";
5
+ import { assert } from "chai";
6
+
7
+ import type { JoistValueEvent } from "../events.js";
8
+
9
+ it("should iterate over an iterable", () => {
10
+ const element = fixtureSync(html`
11
+ <div
12
+ @joist::value=${(e: JoistValueEvent) => {
13
+ e.update({
14
+ oldValue: null,
15
+ newValue: new Set([
16
+ { id: "123", label: "Hello" },
17
+ { id: "456", label: "World" },
18
+ ]),
19
+ });
20
+ }}
21
+ >
22
+ <ul>
23
+ <j-for bind="items" key="id">
24
+ <template>
25
+ <li>
26
+ <j-value bind="each.value.label"></j-value>
27
+ </li>
28
+ </template>
29
+ </j-for>
30
+ </ul>
31
+ </div>
32
+ `);
33
+
34
+ const listItems = element.querySelectorAll("li");
35
+
36
+ assert.equal(listItems.length, 2);
37
+ assert.equal(listItems[0].textContent?.trim(), "Hello");
38
+ assert.equal(listItems[1].textContent?.trim(), "World");
39
+ });
40
+
41
+ it("should handle empty arrays", () => {
42
+ const element = fixtureSync(html`
43
+ <div
44
+ @joist::value=${(e: JoistValueEvent) => {
45
+ e.update({
46
+ oldValue: null,
47
+ newValue: [],
48
+ });
49
+ }}
50
+ >
51
+ <j-for bind="items">
52
+ <template>
53
+ <div>Item</div>
54
+ </template>
55
+ </j-for>
56
+ </div>
57
+ `);
58
+
59
+ assert.equal(element.querySelectorAll("div").length, 0);
60
+ });
61
+
62
+ it("should update when items are added or removed", () => {
63
+ const element = fixtureSync(html`
64
+ <div
65
+ @joist::value=${(e: JoistValueEvent) => {
66
+ // Initial items
67
+ e.update({
68
+ oldValue: null,
69
+ newValue: [
70
+ { id: "1", text: "First" },
71
+ { id: "2", text: "Second" },
72
+ ],
73
+ });
74
+
75
+ // Add an item
76
+ e.update({
77
+ oldValue: null,
78
+ newValue: [
79
+ { id: "1", text: "First" },
80
+ { id: "2", text: "Second" },
81
+ { id: "3", text: "Third" },
82
+ ],
83
+ });
84
+
85
+ // Remove an item
86
+ e.update({
87
+ oldValue: null,
88
+ newValue: [
89
+ { id: "1", text: "First" },
90
+ { id: "3", text: "Third" },
91
+ ],
92
+ });
93
+ }}
94
+ >
95
+ <j-for bind="items" key="id">
96
+ <template>
97
+ <j-value bind="each.value.text"></j-value>
98
+ </template>
99
+ </j-for>
100
+ </div>
101
+ `);
102
+
103
+ const items = element.querySelectorAll("j-value");
104
+ assert.equal(items.length, 2);
105
+ assert.equal(items[0].textContent?.trim(), "First");
106
+ assert.equal(items[1].textContent?.trim(), "Third");
107
+ });
108
+
109
+ it("should provide index and position information", () => {
110
+ const element = fixtureSync(html`
111
+ <div
112
+ @joist::value=${(e: JoistValueEvent) => {
113
+ e.update({
114
+ oldValue: null,
115
+ newValue: ["A", "B", "C"],
116
+ });
117
+ }}
118
+ >
119
+ <j-for bind="items">
120
+ <template>
121
+ <j-value bind="each.value"></j-value>
122
+ (index: <j-value bind="each.index"></j-value>,
123
+ position: <j-value bind="each.position"></j-value>)
124
+ </template>
125
+ </j-for>
126
+ </div>
127
+ `);
128
+
129
+ const items = element.querySelectorAll("j-for-scope");
130
+ assert.equal(items.length, 3);
131
+ assert.equal(
132
+ items[0].textContent?.trim().replaceAll("\n", "").replaceAll(" ", ""),
133
+ "A(index:0,position:1)",
134
+ );
135
+ assert.equal(
136
+ items[1].textContent?.trim().replaceAll("\n", "").replaceAll(" ", ""),
137
+ "B(index:1,position:2)",
138
+ );
139
+ assert.equal(
140
+ items[2].textContent?.trim().replaceAll("\n", "").replaceAll(" ", ""),
141
+ "C(index:2,position:3)",
142
+ );
143
+ });
144
+
145
+ // it("should handle nested j-for elements", () => {
146
+ // const element = fixtureSync(html`
147
+ // <div
148
+ // @joist::value=${(e: JoistValueEvent) => {
149
+ // e.update({
150
+ // oldValue: null,
151
+ // newValue: [
152
+ // { id: "1", items: ["A", "B"] },
153
+ // { id: "2", items: ["C", "D"] },
154
+ // ],
155
+ // });
156
+ // }}
157
+ // >
158
+ // <j-for bind="groups" key="id">
159
+ // <template>
160
+ // <div class="group">
161
+ // <j-for bind="each.value.items">
162
+ // <template>
163
+ // <j-value class="child" bind="each.value"></j-value>
164
+ // </template>
165
+ // </j-for>
166
+ // </div>
167
+ // </template>
168
+ // </j-for>
169
+ // </div>
170
+ // `);
171
+
172
+ // const groups = element.querySelectorAll(".group");
173
+ // assert.equal(groups.length, 2);
174
+
175
+ // const items = element.querySelectorAll(".child");
176
+ // assert.equal(items.length, 4);
177
+ // assert.equal(items[0].textContent?.trim(), "A");
178
+ // assert.equal(items[1].textContent?.trim(), "B");
179
+ // assert.equal(items[2].textContent?.trim(), "C");
180
+ // assert.equal(items[3].textContent?.trim(), "D");
181
+ // });
182
+
183
+ it("should maintain DOM order when items are reordered", () => {
184
+ const element = fixtureSync(html`
185
+ <div
186
+ @joist::value=${(e: JoistValueEvent) => {
187
+ // Initial order
188
+ e.update({
189
+ oldValue: null,
190
+ newValue: [
191
+ { id: "1", text: "First" },
192
+ { id: "2", text: "Second" },
193
+ { id: "3", text: "Third" },
194
+ ],
195
+ });
196
+
197
+ // Reorder items
198
+ e.update({
199
+ oldValue: null,
200
+ newValue: [
201
+ { id: "3", text: "Third" },
202
+ { id: "1", text: "First" },
203
+ { id: "2", text: "Second" },
204
+ ],
205
+ });
206
+ }}
207
+ >
208
+ <j-for bind="items" key="id">
209
+ <template>
210
+ <j-value bind="each.value.text"></j-value>
211
+ </template>
212
+ </j-for>
213
+ </div>
214
+ `);
215
+
216
+ const items = element.querySelectorAll("j-value");
217
+ assert.equal(items.length, 3);
218
+ assert.equal(items[0].textContent?.trim(), "Third");
219
+ assert.equal(items[1].textContent?.trim(), "First");
220
+ assert.equal(items[2].textContent?.trim(), "Second");
221
+ });
@@ -0,0 +1,189 @@
1
+ import { attr } from "../../attr.js";
2
+ import { element } from "../../element.js";
3
+ import { query } from "../../query.js";
4
+ import { css, html } from "../../tags.js";
5
+
6
+ import { bind } from "../bind.js";
7
+ import { JoistValueEvent } from "../events.js";
8
+ import { JToken } from "../token.js";
9
+
10
+ declare global {
11
+ interface HTMLElementTagNameMap {
12
+ "j-for": JositForElement;
13
+ "j-for-scope": JForScope;
14
+ }
15
+ }
16
+
17
+ export interface EachCtx<T> {
18
+ value: T | null;
19
+ index: number | null;
20
+ position: number | null;
21
+ }
22
+
23
+ @element({
24
+ tagName: "j-for-scope",
25
+ // prettier-ignore
26
+ shadowDom: [css`:host{display: contents;}`, html`<slot></slot>`],
27
+ })
28
+ export class JForScope<T = unknown> extends HTMLElement {
29
+ @bind()
30
+ accessor each: EachCtx<T> = {
31
+ value: null,
32
+ index: null,
33
+ position: null,
34
+ };
35
+
36
+ @attr()
37
+ accessor key = "";
38
+ }
39
+
40
+ @element({
41
+ tagName: "j-for",
42
+ // prettier-ignore
43
+ shadowDom: [css`:host{display:contents;}`, html`<slot></slot>`],
44
+ })
45
+ export class JositForElement extends HTMLElement {
46
+ @attr()
47
+ accessor bind = "";
48
+
49
+ @attr()
50
+ accessor key = "";
51
+
52
+ #template = query("template", this);
53
+ #items: Iterable<unknown> = [];
54
+ #scopes = new Map<unknown, JForScope>();
55
+
56
+ connectedCallback(): void {
57
+ const template = this.#template();
58
+
59
+ if (this.firstElementChild !== template) {
60
+ throw new Error("The first Node in j-for needs to be a template");
61
+ }
62
+
63
+ let currentScope: Element | null = this.firstElementChild;
64
+
65
+ while (currentScope instanceof JForScope) {
66
+ this.#scopes.set(currentScope.key, currentScope);
67
+ currentScope = currentScope.nextElementSibling;
68
+ }
69
+
70
+ const token = new JToken(this.bind);
71
+
72
+ this.dispatchEvent(
73
+ new JoistValueEvent(token, ({ newValue, oldValue }) => {
74
+ if (newValue !== oldValue) {
75
+ if (isIterable(newValue)) {
76
+ this.#items = newValue;
77
+ } else {
78
+ this.#items = [];
79
+ }
80
+
81
+ // If there are no existing items in the DOM (template is the only child),
82
+ // create all items from scratch
83
+ if (template.nextSibling === null) {
84
+ this.createFromEmpty();
85
+ } else {
86
+ // Otherwise update existing items, reusing DOM nodes where possible
87
+ this.updateItems();
88
+ }
89
+ }
90
+ }),
91
+ );
92
+ }
93
+
94
+ // Updates the DOM by either inserting new scopes or moving existing ones
95
+ // to their correct positions based on the current iteration order
96
+ createFromEmpty(): void {
97
+ const template = this.#template();
98
+ const fragment = document.createDocumentFragment();
99
+
100
+ let index = 0;
101
+
102
+ for (const value of this.#items) {
103
+ const key = hasProperty(value, this.key) ? value[this.key] : index;
104
+
105
+ const scope = new JForScope();
106
+ scope.append(document.importNode(template.content, true));
107
+ scope.key = String(key);
108
+ scope.each = { position: index + 1, index, value };
109
+
110
+ fragment.appendChild(scope);
111
+ this.#scopes.set(key, scope);
112
+
113
+ index++;
114
+ }
115
+
116
+ this.append(fragment);
117
+ }
118
+
119
+ // Updates the DOM by either inserting new scopes or moving existing ones
120
+ // to their correct positions based on the current iteration order
121
+ updateItems(): void {
122
+ const template = this.#template();
123
+ const leftoverScopes = new Map<unknown, JForScope>(this.#scopes);
124
+
125
+ let index = 0;
126
+
127
+ for (const value of this.#items) {
128
+ const key = hasProperty(value, this.key) ? value[this.key] : index;
129
+
130
+ let scope = leftoverScopes.get(key);
131
+
132
+ if (!scope) {
133
+ scope = new JForScope();
134
+ scope.append(document.importNode(template.content, true));
135
+ this.#scopes.set(key, scope);
136
+ } else {
137
+ leftoverScopes.delete(key); // Remove from map to track unused scopes
138
+ }
139
+
140
+ scope.key = String(key);
141
+ scope.each = { position: index + 1, index, value };
142
+
143
+ const child = this.children[index + 1]; // skip the first child since it's the template
144
+
145
+ index++;
146
+
147
+ // If scope is already in the correct position, no need to move it
148
+ // This optimization prevents unnecessary DOM operations
149
+ if (child === scope) {
150
+ continue;
151
+ }
152
+
153
+ // If there's a child element and either:
154
+ // 1. The scope isn't connected to the DOM yet, or
155
+ // 2. The child isn't the same as our scope
156
+ // Then insert the scope before the child
157
+ if (child && (!scope.isConnected || child !== scope)) {
158
+ child.before(scope);
159
+ } else {
160
+ // Otherwise append the scope to the end of this element
161
+ this.append(scope);
162
+ }
163
+ }
164
+
165
+ // Remove unused scopes
166
+ for (const scope of leftoverScopes.values()) {
167
+ scope.remove();
168
+ }
169
+ }
170
+
171
+ disconnectedCallback(): void {
172
+ for (const scope of this.#scopes.values()) {
173
+ scope.remove();
174
+ }
175
+
176
+ this.#scopes.clear();
177
+ this.#items = [];
178
+ }
179
+ }
180
+
181
+ function isIterable<T = unknown>(obj: any): obj is Iterable<T> {
182
+ return obj != null && typeof obj[Symbol.iterator] === "function";
183
+ }
184
+ function hasProperty(
185
+ item: unknown,
186
+ key: string,
187
+ ): item is Record<string, unknown> {
188
+ return Object.prototype.hasOwnProperty.call(item, key);
189
+ }