@joist/element 4.2.3-next.0 → 4.2.3-next.1

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/lib/attr-changed.test.ts +0 -1
  3. package/src/lib/templating/README.md +115 -1
  4. package/src/lib/templating/define.ts +1 -0
  5. package/src/lib/templating/elements/async.element.test.ts +90 -0
  6. package/src/lib/templating/elements/async.element.ts +122 -0
  7. package/src/lib/templating/elements/for.element.test.ts +182 -0
  8. package/src/lib/templating/elements/for.element.ts +6 -4
  9. package/src/lib/templating/elements/if.element.test.ts +35 -0
  10. package/src/lib/templating/elements/if.element.ts +42 -16
  11. package/target/lib/attr-changed.test.js +0 -1
  12. package/target/lib/attr-changed.test.js.map +1 -1
  13. package/target/lib/templating/define.d.ts +1 -0
  14. package/target/lib/templating/define.js +1 -0
  15. package/target/lib/templating/define.js.map +1 -1
  16. package/target/lib/templating/elements/async.element.d.ts +17 -0
  17. package/target/lib/templating/elements/async.element.js +115 -0
  18. package/target/lib/templating/elements/async.element.js.map +1 -0
  19. package/target/lib/templating/elements/async.element.test.d.ts +1 -0
  20. package/target/lib/templating/elements/async.element.test.js +75 -0
  21. package/target/lib/templating/elements/async.element.test.js.map +1 -0
  22. package/target/lib/templating/elements/for.element.js +5 -2
  23. package/target/lib/templating/elements/for.element.js.map +1 -1
  24. package/target/lib/templating/elements/for.element.test.js +119 -0
  25. package/target/lib/templating/elements/for.element.test.js.map +1 -1
  26. package/target/lib/templating/elements/if.element.d.ts +1 -2
  27. package/target/lib/templating/elements/if.element.js +28 -14
  28. package/target/lib/templating/elements/if.element.js.map +1 -1
  29. package/target/lib/templating/elements/if.element.test.js +31 -0
  30. package/target/lib/templating/elements/if.element.test.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joist/element",
3
- "version": "4.2.3-next.0",
3
+ "version": "4.2.3-next.1",
4
4
  "type": "module",
5
5
  "main": "./target/lib.js",
6
6
  "module": "./target/lib.js",
@@ -16,7 +16,6 @@ it("should call specific attrbute callback", () => {
16
16
 
17
17
  @attrChanged("test")
18
18
  onTestChanged(name: string, oldValue: string, newValue: string) {
19
- console.log("onTestChanged", name, oldValue, newValue);
20
19
  args = [name, oldValue, newValue];
21
20
  }
22
21
  }
@@ -65,8 +65,31 @@ Conditionally renders content based on a boolean expression:
65
65
  <div>This content is shown when isHidden is false</div>
66
66
  </template>
67
67
  </j-if>
68
+
69
+ <!-- With else template -->
70
+ <j-if bind="isLoggedIn">
71
+ <template>
72
+ <div>Welcome back!</div>
73
+ </template>
74
+ <template else>
75
+ <div>Please log in</div>
76
+ </template>
77
+ </j-if>
68
78
  ```
69
79
 
80
+ The `j-if` element supports:
81
+ - Boolean expressions for conditional rendering
82
+ - Negation operator (`!`) for inverse conditions
83
+ - Optional `else` template for fallback content
84
+ - Automatic cleanup of removed content
85
+
86
+ Common use cases:
87
+ - Toggling visibility of UI elements
88
+ - Conditional form fields
89
+ - Feature flags
90
+ - Authentication states
91
+ - Loading states
92
+
70
93
  ### Property Binding (`j-props`)
71
94
 
72
95
  Binds values to element properties (rather than attributes). This is particularly useful for boolean properties, form inputs, and other cases where attribute binding isn't sufficient:
@@ -134,6 +157,48 @@ Displays a bound value as text content:
134
157
  <j-value bind="formattedPrice"></j-value>
135
158
  ```
136
159
 
160
+ ### Async State Handling (`j-async`)
161
+
162
+ Handles asynchronous operations and state management with loading, success, and error states:
163
+
164
+ ```html
165
+ <j-async bind="userPromise">
166
+ <template loading>Loading user data...</template>
167
+ <template success>
168
+ <div>Welcome, <j-value bind="data.name"></j-value>!</div>
169
+ </template>
170
+ <template error>
171
+ <div>Error loading user data: <j-value bind="error"></j-value></div>
172
+ </template>
173
+ </j-async>
174
+ ```
175
+
176
+ The `j-async` element supports:
177
+ - Promise handling with automatic state transitions
178
+ - Loading, success, and error templates
179
+ - Automatic cleanup on disconnection
180
+
181
+ Example usage:
182
+ ```typescript
183
+ // In your component
184
+ @bind()
185
+ accessor userPromise = fetch('/api/user').then(r => r.json());
186
+ ```
187
+
188
+ ```html
189
+ <j-async bind="userPromise">
190
+ <template loading>Loading...</template>
191
+
192
+ <template success>
193
+ <div>Welcome, <j-value bind="state.data.name"></j-value>!</div>
194
+ </template>
195
+
196
+ <template error>
197
+ <div>Error: <j-value bind="state.error"></j-value></div>
198
+ </template>
199
+ </j-async>
200
+ ```
201
+
137
202
  ## Complete Example
138
203
 
139
204
  Here's a complete todo application in a single component:
@@ -289,4 +354,53 @@ The templating system is built on top of Joist's observable system (`@joist/obse
289
354
  2. Keep binding expressions simple and avoid deep nesting
290
355
  3. Consider performance implications when binding to frequently changing values
291
356
  4. Always use a `key` attribute with `j-for` when items can be reordered
292
- 5. Place template content directly inside `j-if` and `j-for` elements
357
+ 5. Place template content directly inside `j-if` and `j-for` elements
358
+
359
+ ## Manual Value Handling
360
+
361
+ You can manually handle value requests and updates by listening for the `joist::value` event. This is useful when you need more control over the binding process or want to implement custom binding logic:
362
+
363
+ ```typescript
364
+ class MyElement extends HTMLElement {
365
+ connectedCallback() {
366
+ // Listen for value requests
367
+ this.addEventListener('joist::value', (e: JoistValueEvent) => {
368
+ const token = e.token;
369
+
370
+ // Handle the value request
371
+ if (token.bindTo === 'myValue') {
372
+ // Update the value
373
+ e.update({
374
+ oldValue: this.myValue,
375
+ newValue: this.myValue
376
+ });
377
+ }
378
+ });
379
+ }
380
+ }
381
+ ```
382
+
383
+ Example with async value handling:
384
+ ```typescript
385
+ class MyElement extends HTMLElement {
386
+ connectedCallback() {
387
+ this.addEventListener('joist::value', (e: JoistValueEvent) => {
388
+ const token = e.token;
389
+
390
+ if (token.bindTo === 'userData') {
391
+ e.update({
392
+ oldValue: this.userData,
393
+ newValue: data
394
+ });
395
+ }
396
+ });
397
+ }
398
+ }
399
+ ```
400
+
401
+ Common use cases for manual value handling:
402
+ - Custom data transformation before binding
403
+ - Async data loading and caching
404
+ - Complex state management
405
+ - Integration with external data sources
406
+ - Custom validation or error handling
@@ -1,3 +1,4 @@
1
+ import "./elements/async.element.js";
1
2
  import "./elements/for.element.js";
2
3
  import "./elements/if.element.js";
3
4
  import "./elements/props.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
+ }
@@ -37,3 +37,185 @@ it("should iterate over an iterable", () => {
37
37
  assert.equal(listItems[0].textContent?.trim(), "Hello");
38
38
  assert.equal(listItems[1].textContent?.trim(), "World");
39
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
+ });
@@ -109,18 +109,20 @@ export class JositForElement extends HTMLElement {
109
109
  value: item,
110
110
  };
111
111
 
112
- if (!scope.isConnected) {
113
- const child = this.children[index + 1]; // skip first child since it should be the template element
112
+ const child = this.children[index + 1]; // skip first child since it should be the template element
114
113
 
114
+ if (!scope.isConnected) {
115
115
  if (child) {
116
116
  child.before(scope);
117
117
  } else {
118
118
  this.append(scope);
119
119
  }
120
-
121
- this.#scopes.set(key, scope);
120
+ } else if (child !== scope) {
121
+ child.before(scope);
122
122
  }
123
123
 
124
+ this.#scopes.set(key, scope);
125
+
124
126
  index++;
125
127
  }
126
128
 
@@ -53,3 +53,38 @@ it("should handle negated tokens correctly", () => {
53
53
 
54
54
  assert.equal(element.textContent?.trim(), "Visible Content");
55
55
  });
56
+
57
+ it("should render else template when condition is falsy", () => {
58
+ const element = fixtureSync(html`
59
+ <div
60
+ @joist::value=${(e: JoistValueEvent) => {
61
+ e.update({ oldValue: null, newValue: false });
62
+ }}
63
+ >
64
+ <j-if bind="test">
65
+ <template>If Content</template>
66
+ <template else>Else Content</template>
67
+ </j-if>
68
+ </div>
69
+ `);
70
+
71
+ assert.equal(element.textContent?.trim(), "Else Content");
72
+ });
73
+
74
+ it("should switch between if and else templates", () => {
75
+ const element = fixtureSync(html`
76
+ <div
77
+ @joist::value=${(e: JoistValueEvent) => {
78
+ e.update({ oldValue: null, newValue: false });
79
+ e.update({ oldValue: false, newValue: true });
80
+ }}
81
+ >
82
+ <j-if bind="test">
83
+ <template>If Content</template>
84
+ <template else>Else Content</template>
85
+ </j-if>
86
+ </div>
87
+ `);
88
+
89
+ assert.equal(element.textContent?.trim(), "If Content");
90
+ });