@bodil/dom 0.1.10 → 0.2.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.
- package/dist/component.d.ts +91 -17
- package/dist/component.js +110 -39
- package/dist/component.js.map +1 -1
- package/dist/decorators/attribute.d.ts +15 -15
- package/dist/decorators/attribute.js +27 -8
- package/dist/decorators/attribute.js.map +1 -1
- package/dist/decorators/attribute.test.js +58 -6
- package/dist/decorators/attribute.test.js.map +1 -1
- package/dist/decorators/connect.d.ts +2 -0
- package/dist/decorators/connect.js.map +1 -1
- package/dist/decorators/require.test.js +1 -1
- package/dist/decorators/require.test.js.map +1 -1
- package/dist/dom.d.ts +12 -2
- package/dist/dom.js +34 -18
- package/dist/dom.js.map +1 -1
- package/dist/dom.test.d.ts +1 -0
- package/dist/dom.test.js +20 -0
- package/dist/dom.test.js.map +1 -0
- package/dist/emitter.d.ts +6 -0
- package/dist/emitter.js +4 -0
- package/dist/emitter.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/test.d.ts +17 -0
- package/dist/test.js +34 -0
- package/dist/test.js.map +1 -0
- package/package.json +25 -19
- package/src/component.ts +146 -47
- package/src/decorators/attribute.test.ts +41 -14
- package/src/decorators/attribute.ts +56 -28
- package/src/decorators/require.test.ts +1 -1
- package/src/dom.test.ts +23 -0
- package/src/dom.ts +55 -16
- package/src/emitter.ts +6 -0
- package/src/index.ts +2 -1
- package/src/test.ts +38 -0
- package/src/decorators/connect.test.ts +0 -119
- package/src/decorators/connect.ts +0 -85
|
@@ -9,14 +9,12 @@ import { html, nothing } from "lit";
|
|
|
9
9
|
test("@attribute", async () => {
|
|
10
10
|
@customElement("attribute-test-class")
|
|
11
11
|
class AttributeTestClass extends Component {
|
|
12
|
-
@attribute accessor wibble: string |
|
|
13
|
-
@attribute({ type: Number, reflect: true }) accessor wobble: number |
|
|
14
|
-
@attribute({ type: Boolean, reflect: true }) accessor noMeansNo: boolean |
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
| undefined = "Joe";
|
|
19
|
-
@attribute({ reflect: false }) accessor hide: string | undefined = "no";
|
|
12
|
+
@attribute accessor wibble: string | null = "Joe";
|
|
13
|
+
@attribute({ type: Number, reflect: true }) accessor wobble: number | null = 1;
|
|
14
|
+
@attribute({ type: Boolean, reflect: true }) accessor noMeansNo: boolean | null = false;
|
|
15
|
+
@attribute({ name: "wolp", reactive: false, reflect: true }) accessor welp: string | null =
|
|
16
|
+
"Joe";
|
|
17
|
+
@attribute({ reflect: false }) accessor hide: string | null = "no";
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
const t = document.createElement("attribute-test-class") as AttributeTestClass;
|
|
@@ -32,17 +30,17 @@ test("@attribute", async () => {
|
|
|
32
30
|
expect(t.getAttribute("wibble")).toBe("Mike");
|
|
33
31
|
expect(wibble.get()).toBe("Mike");
|
|
34
32
|
t.removeAttribute("wibble");
|
|
35
|
-
expect(t.wibble).
|
|
33
|
+
expect(t.wibble).toBeNull();
|
|
36
34
|
expect(t.getAttribute("wibble")).toBeNull();
|
|
37
|
-
expect(wibble.get()).toBe(
|
|
35
|
+
expect(wibble.get()).toBe(null);
|
|
38
36
|
t.setAttribute("wibble", "Robert");
|
|
39
37
|
expect(t.wibble).toBe("Robert");
|
|
40
38
|
expect(t.getAttribute("wibble")).toBe("Robert");
|
|
41
39
|
expect(wibble.get()).toBe("Robert");
|
|
42
|
-
t.wibble =
|
|
43
|
-
expect(t.wibble).
|
|
40
|
+
t.wibble = null;
|
|
41
|
+
expect(t.wibble).toBeNull();
|
|
44
42
|
expect(t.getAttribute("wibble")).toBeNull();
|
|
45
|
-
expect(wibble.get()).toBe(
|
|
43
|
+
expect(wibble.get()).toBe(null);
|
|
46
44
|
|
|
47
45
|
expect(t.wobble).toBe(1);
|
|
48
46
|
expect(t.getAttribute("wobble")).toBe("1");
|
|
@@ -133,7 +131,7 @@ test("@attribute init ordering", async () => {
|
|
|
133
131
|
class AttributeInitTestClass extends Component {
|
|
134
132
|
@attribute accessor movieStar = "Joe";
|
|
135
133
|
|
|
136
|
-
@attribute accessor foo: string |
|
|
134
|
+
@attribute accessor foo: string | null = null;
|
|
137
135
|
|
|
138
136
|
#wibble = "Joe";
|
|
139
137
|
@attributeGetter get wibble(): string {
|
|
@@ -209,3 +207,32 @@ test("subcomponent attributes are initialised properly", async () => {
|
|
|
209
207
|
`<subcomp-init-sub attr1="Mike" attr2="Mike" attr3="Mike" attr4="Mike"></subcomp-init-sub>`,
|
|
210
208
|
);
|
|
211
209
|
});
|
|
210
|
+
|
|
211
|
+
test("@attributeGetter is reactive", async () => {
|
|
212
|
+
@customElement("reactive-getter")
|
|
213
|
+
class ReactiveGetter extends Component {
|
|
214
|
+
@attribute({ type: Boolean }) accessor disabled = false;
|
|
215
|
+
@attributeGetter({ type: Number, reactive: true }) get tabindex(): number {
|
|
216
|
+
return this.disabled ? -1 : 0;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const t = document.createElement("reactive-getter") as ReactiveGetter;
|
|
221
|
+
// document.body.append(t);
|
|
222
|
+
// await t.updateComplete;
|
|
223
|
+
|
|
224
|
+
expect(t.getAttribute("disabled")).toBeNull();
|
|
225
|
+
expect(t.tabindex).toBe(0);
|
|
226
|
+
// Attributes won't update until we yield, so yield.
|
|
227
|
+
await Promise.resolve();
|
|
228
|
+
expect(t.getAttribute("tabindex")).toBe("0");
|
|
229
|
+
|
|
230
|
+
t.disabled = true;
|
|
231
|
+
|
|
232
|
+
expect(t.getAttribute("disabled")).not.toBeNull();
|
|
233
|
+
// change should be immediately reflected in the computed property
|
|
234
|
+
expect(t.tabindex).toBe(-1);
|
|
235
|
+
// but attributes will only update after a yield
|
|
236
|
+
await Promise.resolve();
|
|
237
|
+
expect(t.getAttribute("tabindex")).toBe("-1");
|
|
238
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/unified-signatures */
|
|
2
2
|
|
|
3
|
-
import { isDeepEqual, isNullish, unreachable } from "@bodil/core/assert";
|
|
3
|
+
import { assertNever, isDeepEqual, isNullish, unreachable } from "@bodil/core/assert";
|
|
4
4
|
import { None, Some, type Option } from "@bodil/opt";
|
|
5
5
|
import { Signal } from "@bodil/signal";
|
|
6
6
|
|
|
@@ -54,21 +54,21 @@ export function toAttribute(value: unknown, type: AttributeType): string | null
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function fromAttribute(value: string | null, type: typeof Number): number |
|
|
57
|
+
export function fromAttribute(value: string | null, type: typeof Number): number | null;
|
|
58
58
|
export function fromAttribute(value: string | null, type: typeof Boolean): boolean;
|
|
59
|
-
export function fromAttribute(value: string | null, type: typeof String): string |
|
|
59
|
+
export function fromAttribute(value: string | null, type: typeof String): string | null;
|
|
60
60
|
export function fromAttribute(
|
|
61
61
|
value: string | null,
|
|
62
62
|
type: AttributeType,
|
|
63
|
-
): string | number | boolean |
|
|
63
|
+
): string | number | boolean | null;
|
|
64
64
|
export function fromAttribute(
|
|
65
65
|
value: string | null,
|
|
66
66
|
type: AttributeType,
|
|
67
|
-
): string | number | boolean |
|
|
67
|
+
): string | number | boolean | null {
|
|
68
68
|
switch (type) {
|
|
69
69
|
case Number: {
|
|
70
|
-
const num = value === null ?
|
|
71
|
-
if (num !==
|
|
70
|
+
const num = value === null ? null : Number(value);
|
|
71
|
+
if (num !== null && Number.isNaN(num)) {
|
|
72
72
|
throw new TypeError(
|
|
73
73
|
`numeric attribute value ${JSON.stringify(value)} parsed as NaN`,
|
|
74
74
|
);
|
|
@@ -78,7 +78,7 @@ export function fromAttribute(
|
|
|
78
78
|
case Boolean:
|
|
79
79
|
return value !== null;
|
|
80
80
|
case String:
|
|
81
|
-
return value ??
|
|
81
|
+
return value ?? null;
|
|
82
82
|
default:
|
|
83
83
|
unreachable();
|
|
84
84
|
}
|
|
@@ -109,22 +109,22 @@ type AttributeDecoratorFunction<C, T> =
|
|
|
109
109
|
| ClassSetterDecoratorFunction<C, T>;
|
|
110
110
|
|
|
111
111
|
// Getter with number type
|
|
112
|
-
export function attributeGetter<C extends Component, T extends number |
|
|
112
|
+
export function attributeGetter<C extends Component, T extends number | null>(
|
|
113
113
|
options: AttributeOptions & { type: typeof Number },
|
|
114
114
|
): ClassGetterDecoratorFunction<C, T>;
|
|
115
115
|
|
|
116
116
|
// Getter with boolean type
|
|
117
|
-
export function attributeGetter<C extends Component, T extends boolean |
|
|
117
|
+
export function attributeGetter<C extends Component, T extends boolean | null>(
|
|
118
118
|
options: AttributeOptions & { type: typeof Boolean },
|
|
119
119
|
): ClassGetterDecoratorFunction<C, T>;
|
|
120
120
|
|
|
121
121
|
// Getter with string type
|
|
122
|
-
export function attributeGetter<C extends Component, T extends string |
|
|
122
|
+
export function attributeGetter<C extends Component, T extends string | null>(
|
|
123
123
|
options: AttributeOptions,
|
|
124
124
|
): ClassGetterDecoratorFunction<C, T>;
|
|
125
125
|
|
|
126
126
|
// Getter with no options
|
|
127
|
-
export function attributeGetter<C extends Component, T extends string |
|
|
127
|
+
export function attributeGetter<C extends Component, T extends string | null>(
|
|
128
128
|
value: ClassGetterDecoratorTarget<C, T>,
|
|
129
129
|
context: ClassGetterDecoratorContext<C, T>,
|
|
130
130
|
): ClassGetterDecoratorResult<C, T>;
|
|
@@ -138,22 +138,22 @@ export function attributeGetter<C extends Component, T>(
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
// Setter with number type
|
|
141
|
-
export function attributeSetter<C extends Component, T extends number |
|
|
141
|
+
export function attributeSetter<C extends Component, T extends number | null>(
|
|
142
142
|
options: AttributeOptions & { type: typeof Number },
|
|
143
143
|
): ClassSetterDecoratorFunction<C, T>;
|
|
144
144
|
|
|
145
145
|
// Setter with boolean type
|
|
146
|
-
export function attributeSetter<C extends Component, T extends boolean |
|
|
146
|
+
export function attributeSetter<C extends Component, T extends boolean | null>(
|
|
147
147
|
options: AttributeOptions & { type: typeof Boolean },
|
|
148
148
|
): ClassSetterDecoratorFunction<C, T>;
|
|
149
149
|
|
|
150
150
|
// Setter with string type
|
|
151
|
-
export function attributeSetter<C extends Component, T extends string |
|
|
151
|
+
export function attributeSetter<C extends Component, T extends string | null>(
|
|
152
152
|
options: AttributeOptions,
|
|
153
153
|
): ClassSetterDecoratorFunction<C, T>;
|
|
154
154
|
|
|
155
155
|
// Setter with no options
|
|
156
|
-
export function attributeSetter<C extends Component, T extends string |
|
|
156
|
+
export function attributeSetter<C extends Component, T extends string | null>(
|
|
157
157
|
value: ClassSetterDecoratorTarget<C, T>,
|
|
158
158
|
context: ClassSetterDecoratorContext<C, T>,
|
|
159
159
|
): ClassSetterDecoratorResult<C, T>;
|
|
@@ -167,27 +167,27 @@ export function attributeSetter<C extends Component, T>(
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
// Accessor with number type
|
|
170
|
-
export function attribute<C extends Component, T extends number |
|
|
170
|
+
export function attribute<C extends Component, T extends number | null>(
|
|
171
171
|
options: AttributeOptions & { type: typeof Number },
|
|
172
172
|
): ClassAccessorDecoratorFunction<C, T>;
|
|
173
173
|
|
|
174
174
|
// Accessor with boolean type
|
|
175
|
-
export function attribute<C extends Component, T extends boolean |
|
|
175
|
+
export function attribute<C extends Component, T extends boolean | null>(
|
|
176
176
|
options: AttributeOptions & { type: typeof Boolean },
|
|
177
177
|
): ClassAccessorDecoratorFunction<C, T>;
|
|
178
178
|
|
|
179
179
|
// Accessor with string type
|
|
180
|
-
export function attribute<C extends Component, T extends string |
|
|
180
|
+
export function attribute<C extends Component, T extends string | null>(
|
|
181
181
|
options: AttributeOptions,
|
|
182
182
|
): ClassAccessorDecoratorFunction<C, T>;
|
|
183
183
|
|
|
184
184
|
// Accessor with no options
|
|
185
|
-
export function attribute<C extends Component, T extends string |
|
|
185
|
+
export function attribute<C extends Component, T extends string | null>(
|
|
186
186
|
value: ClassAccessorDecoratorTarget<C, T>,
|
|
187
187
|
context: ClassAccessorDecoratorContext<C, T>,
|
|
188
188
|
): ClassAccessorDecoratorResult<C, T>;
|
|
189
189
|
|
|
190
|
-
export function attribute<C extends Component, T extends string | number | boolean |
|
|
190
|
+
export function attribute<C extends Component, T extends string | number | boolean | null>(
|
|
191
191
|
valueOrOptions?: AttributeDecoratorTarget<C, T> | AttributeOptions,
|
|
192
192
|
context?: AttributeDecoratorContext<C, T>,
|
|
193
193
|
): AttributeDecoratorResult<C, T> | AttributeDecoratorFunction<C, T> {
|
|
@@ -234,16 +234,21 @@ export function attribute<C extends Component, T extends string | number | boole
|
|
|
234
234
|
return accessor(options, context, value as ClassAccessorDecoratorTarget<C, T>);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
// ensure getters/setters aren't configured with reactive or reflect
|
|
238
|
-
if (options.reactive) {
|
|
239
|
-
throw new TypeError(
|
|
240
|
-
`Getter/setter attributes cannot be declared with reactive: true (on ${JSON.stringify(context.name)})`,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
237
|
if (context.kind === "setter") {
|
|
238
|
+
// ensure setters aren't configured with reactive
|
|
239
|
+
if (options.reactive) {
|
|
240
|
+
throw new TypeError(
|
|
241
|
+
`Setter attributes cannot be declared with reactive: true (on ${JSON.stringify(context.name)})`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
245
244
|
return setter(options, value as ClassSetterDecoratorTarget<C, T>);
|
|
246
245
|
}
|
|
246
|
+
|
|
247
|
+
if (context.kind === "getter") {
|
|
248
|
+
return getter(options, context, value as ClassGetterDecoratorTarget<C, T>);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
assertNever(context);
|
|
247
252
|
};
|
|
248
253
|
}
|
|
249
254
|
|
|
@@ -260,6 +265,29 @@ function syncAttribute<C extends Component, T>(
|
|
|
260
265
|
}
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
function getter<C extends Component, T>(
|
|
269
|
+
options: AttributeConfig,
|
|
270
|
+
context: ClassGetterDecoratorContext<C, T>,
|
|
271
|
+
getter: ClassGetterDecoratorTarget<C, T>,
|
|
272
|
+
): ClassGetterDecoratorResult<C, T> {
|
|
273
|
+
if (options.reactive) {
|
|
274
|
+
const getSig = (obj: Component) =>
|
|
275
|
+
signalForObject(obj, context.name, () => {
|
|
276
|
+
const sig = Signal.computed(getter.bind(obj));
|
|
277
|
+
// FIXME this leaks when obj isn't explicitly disposed
|
|
278
|
+
obj.use(
|
|
279
|
+
Signal.effect(() => {
|
|
280
|
+
syncAttribute(obj, options, sig.get());
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
return sig;
|
|
284
|
+
}) as Signal.Computed<T>;
|
|
285
|
+
return function (this: C): T {
|
|
286
|
+
return getSig(this).get();
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
263
291
|
function setter<C extends Component, T>(
|
|
264
292
|
options: AttributeConfig,
|
|
265
293
|
setter: ClassSetterDecoratorTarget<C, T>,
|
|
@@ -11,7 +11,7 @@ test("@require", async () => {
|
|
|
11
11
|
@customElement("require-test-class")
|
|
12
12
|
class RequireTestClass extends Component {
|
|
13
13
|
@require @reactive foo?: string;
|
|
14
|
-
@require @attribute accessor bar: string |
|
|
14
|
+
@require @attribute accessor bar: string | null = null;
|
|
15
15
|
|
|
16
16
|
protected override initialised() {
|
|
17
17
|
inits++;
|
package/src/dom.test.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { html } from "lit";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { findDescendants } from "./dom";
|
|
5
|
+
import { testHTML } from "./test";
|
|
6
|
+
|
|
7
|
+
test("findDescendants", async () => {
|
|
8
|
+
const root = await testHTML(html`
|
|
9
|
+
<p>foo</p>
|
|
10
|
+
<div><p>bar</p></div>
|
|
11
|
+
<article>
|
|
12
|
+
<div>
|
|
13
|
+
<p>baz</p>
|
|
14
|
+
<p>gazonk</p>
|
|
15
|
+
</div>
|
|
16
|
+
</article>
|
|
17
|
+
`);
|
|
18
|
+
expect(
|
|
19
|
+
findDescendants(root, (el) => el instanceof HTMLParagraphElement)
|
|
20
|
+
.map((el) => el.innerText)
|
|
21
|
+
.toArray(),
|
|
22
|
+
).toEqual(["foo", "bar", "baz", "gazonk"]);
|
|
23
|
+
});
|
package/src/dom.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* @module
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { assertNever, isNullish, unreachable } from "@bodil/core/assert";
|
|
7
|
-
import { toDisposable } from "@bodil/core/disposable";
|
|
6
|
+
import { assertNever, isEmpty, isNullish, unreachable } from "@bodil/core/assert";
|
|
7
|
+
import { DisposableContext, toDisposable } from "@bodil/core/disposable";
|
|
8
8
|
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
|
9
9
|
import type { OptionalKeysOf } from "type-fest";
|
|
10
10
|
|
|
11
11
|
import type { Pixels } from "./css";
|
|
12
|
+
import { eventListener } from "./event";
|
|
12
13
|
import { contains } from "./geometry";
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -129,20 +130,26 @@ export class DOMIterator extends Iterator<Node> {
|
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
/**
|
|
132
|
-
*
|
|
133
|
+
* Return all descendants of `root` which match the `predicate` function.
|
|
133
134
|
*/
|
|
134
|
-
export function
|
|
135
|
-
|
|
135
|
+
export function findDescendants<T extends Element>(
|
|
136
|
+
root: Element | DocumentFragment | null,
|
|
137
|
+
predicate: (child: Element) => child is T,
|
|
138
|
+
): IteratorObject<T>;
|
|
139
|
+
export function findDescendants(
|
|
140
|
+
root: Element | DocumentFragment | null,
|
|
141
|
+
predicate: (child: Element) => boolean,
|
|
142
|
+
): IteratorObject<Element>;
|
|
143
|
+
export function findDescendants(
|
|
144
|
+
root: Element | DocumentFragment | null,
|
|
145
|
+
predicate: (child: Element) => boolean,
|
|
146
|
+
): IteratorObject<Element> {
|
|
147
|
+
if (root === null) {
|
|
136
148
|
return Iterator.from([]);
|
|
137
149
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
yield el;
|
|
142
|
-
el = el.nextElementSibling;
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
return Iterator.from(iter());
|
|
150
|
+
return Iterator.from(root.children).flatMap((el) =>
|
|
151
|
+
predicate(el) ? [el, ...findDescendants(el, predicate)] : findDescendants(el, predicate),
|
|
152
|
+
);
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
/** Test whether the user has asked for reduced motion. */
|
|
@@ -199,9 +206,17 @@ export async function animationEnd(el: HTMLElement): Promise<void> {
|
|
|
199
206
|
await Promise.all(
|
|
200
207
|
el.getAnimations().map((animation) => {
|
|
201
208
|
return new Promise((resolve) => {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
const context = new DisposableContext();
|
|
210
|
+
const handleAnimationEvent = () => {
|
|
211
|
+
context.dispose();
|
|
212
|
+
requestAnimationFrame(resolve);
|
|
213
|
+
};
|
|
214
|
+
context.use(
|
|
215
|
+
eventListener(animation, "cancel", handleAnimationEvent, { once: true }),
|
|
216
|
+
);
|
|
217
|
+
context.use(
|
|
218
|
+
eventListener(animation, "finish", handleAnimationEvent, { once: true }),
|
|
219
|
+
);
|
|
205
220
|
});
|
|
206
221
|
}),
|
|
207
222
|
);
|
|
@@ -421,6 +436,30 @@ export function scrollToItem(el: HTMLElement, scrollToItemOptions: ScrollToItemO
|
|
|
421
436
|
}
|
|
422
437
|
}
|
|
423
438
|
|
|
439
|
+
const ids: Record<string, number> = {};
|
|
440
|
+
|
|
441
|
+
function freshID(tagName: string): number {
|
|
442
|
+
const next = ids[tagName] ?? 1;
|
|
443
|
+
ids[tagName] = next + 1;
|
|
444
|
+
return next;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Assign a unique ID to the target element.
|
|
449
|
+
*
|
|
450
|
+
* Returns the ID that was assigned.
|
|
451
|
+
*
|
|
452
|
+
* If the target element already has an `id` attribute, this will not be
|
|
453
|
+
* overwritten.
|
|
454
|
+
*/
|
|
455
|
+
export function applyUniqueID(target: HTMLElement): string {
|
|
456
|
+
if (isEmpty(target.id)) {
|
|
457
|
+
const name = target.tagName.toLowerCase();
|
|
458
|
+
target.id = `${name}-${freshID(name)}`;
|
|
459
|
+
}
|
|
460
|
+
return target.id;
|
|
461
|
+
}
|
|
462
|
+
|
|
424
463
|
// HasSlotController nicked largely verbatim from Shoelace
|
|
425
464
|
// https://github.com/shoelace-style/shoelace/blob/next/src/internal/slot.ts
|
|
426
465
|
/**
|
package/src/emitter.ts
CHANGED
|
@@ -69,6 +69,8 @@ export class EmitterElement extends HTMLElement {
|
|
|
69
69
|
* class MyElement extends EmitterElement {
|
|
70
70
|
* emits!: Emits<"my-event" | "my-other-event";
|
|
71
71
|
* }
|
|
72
|
+
*
|
|
73
|
+
* @category Events
|
|
72
74
|
*/
|
|
73
75
|
emits!: { [K in keyof HTMLElementEventMap]?: never };
|
|
74
76
|
|
|
@@ -76,6 +78,8 @@ export class EmitterElement extends HTMLElement {
|
|
|
76
78
|
* Emit a custom event with the given name and detail.
|
|
77
79
|
*
|
|
78
80
|
* Event init options default to `{ bubbles: true, composed: true }`.
|
|
81
|
+
*
|
|
82
|
+
* @category Events
|
|
79
83
|
*/
|
|
80
84
|
emit<
|
|
81
85
|
M extends CustomEventTypes<keyof this["emits"] & keyof HTMLElementEventMap>,
|
|
@@ -101,6 +105,8 @@ export class EmitterElement extends HTMLElement {
|
|
|
101
105
|
* @example
|
|
102
106
|
* this.emitEvent("click", MouseEvent, { button: 2 });
|
|
103
107
|
* // emits: new MouseEvent("click", { button: 2 });
|
|
108
|
+
*
|
|
109
|
+
* @category Events
|
|
104
110
|
*/
|
|
105
111
|
emitEvent<
|
|
106
112
|
K extends keyof this["emits"] & keyof HTMLElementEventMap,
|
package/src/index.ts
CHANGED
|
@@ -9,5 +9,6 @@ import * as dom from "./dom";
|
|
|
9
9
|
import * as event from "./event";
|
|
10
10
|
import * as geometry from "./geometry";
|
|
11
11
|
import * as signal from "./signal";
|
|
12
|
+
import * as test from "./test";
|
|
12
13
|
|
|
13
|
-
export { component, css, dom, event, geometry, signal };
|
|
14
|
+
export { component, css, dom, event, geometry, signal, test };
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools for testing web components.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { render } from "lit";
|
|
7
|
+
|
|
8
|
+
import { Component } from "./component";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render a Lit template and wait for any {@link Component}s inside it to
|
|
12
|
+
* stabilise before returning the {@link HTMLElement} containing the rendered
|
|
13
|
+
* template.
|
|
14
|
+
*
|
|
15
|
+
* The root element is also a {@link Disposable} which will remove itself from
|
|
16
|
+
* the DOM and dispose its {@link Component}s when disposed.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* using root = await testHTML(html`<my-component></my-component>`);
|
|
20
|
+
* expect(root.querySelector("my-component")).toBeInstanceOf(MyComponent);
|
|
21
|
+
*/
|
|
22
|
+
export async function testHTML(template: unknown): Promise<HTMLElement & Disposable> {
|
|
23
|
+
const root = document.createElement("section") as HTMLElement & Disposable;
|
|
24
|
+
document.body.append(root);
|
|
25
|
+
render(template, root, { host: root });
|
|
26
|
+
root[Symbol.dispose] = () => {
|
|
27
|
+
Iterator.from(root.children)
|
|
28
|
+
.filter((el) => el instanceof Component)
|
|
29
|
+
.forEach((comp) => comp[Symbol.dispose]());
|
|
30
|
+
root.remove();
|
|
31
|
+
};
|
|
32
|
+
await Promise.all(
|
|
33
|
+
Iterator.from(root.children)
|
|
34
|
+
.filter((el) => el instanceof Component)
|
|
35
|
+
.map((el) => el.hasStabilised),
|
|
36
|
+
);
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { sleep } from "@bodil/core/async";
|
|
2
|
-
import { Signal } from "@bodil/signal";
|
|
3
|
-
import { customElement } from "lit/decorators.js";
|
|
4
|
-
import { expect, test } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { Component, connect, connectEffect } from "../component";
|
|
7
|
-
import type { ConnectFunction } from "./connect";
|
|
8
|
-
|
|
9
|
-
test("@connect", () => {
|
|
10
|
-
let methodConnected = 0,
|
|
11
|
-
methodDisconnected = 0;
|
|
12
|
-
let fieldConnected = 0,
|
|
13
|
-
fieldDisconnected = 0;
|
|
14
|
-
|
|
15
|
-
@customElement("connected-test-class")
|
|
16
|
-
class ConnectedTestClass extends Component {
|
|
17
|
-
@connect onConnected() {
|
|
18
|
-
methodConnected++;
|
|
19
|
-
return () => {
|
|
20
|
-
methodDisconnected++;
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
@connect field: ConnectFunction = () => {
|
|
25
|
-
fieldConnected++;
|
|
26
|
-
return () => {
|
|
27
|
-
fieldDisconnected++;
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const c = document.createElement("connected-test-class") as ConnectedTestClass;
|
|
33
|
-
expect(methodConnected).toBe(0);
|
|
34
|
-
expect(methodDisconnected).toBe(0);
|
|
35
|
-
expect(fieldConnected).toEqual(methodConnected);
|
|
36
|
-
expect(fieldDisconnected).toEqual(methodDisconnected);
|
|
37
|
-
document.body.append(c);
|
|
38
|
-
expect(methodConnected).toBe(1);
|
|
39
|
-
expect(methodDisconnected).toBe(0);
|
|
40
|
-
expect(fieldConnected).toEqual(methodConnected);
|
|
41
|
-
expect(fieldDisconnected).toEqual(methodDisconnected);
|
|
42
|
-
c.remove();
|
|
43
|
-
expect(methodConnected).toBe(1);
|
|
44
|
-
expect(methodDisconnected).toBe(1);
|
|
45
|
-
expect(fieldConnected).toEqual(methodConnected);
|
|
46
|
-
expect(fieldDisconnected).toEqual(methodDisconnected);
|
|
47
|
-
document.body.append(c);
|
|
48
|
-
expect(methodConnected).toBe(2);
|
|
49
|
-
expect(methodDisconnected).toBe(1);
|
|
50
|
-
expect(fieldConnected).toEqual(methodConnected);
|
|
51
|
-
expect(fieldDisconnected).toEqual(methodDisconnected);
|
|
52
|
-
c.remove();
|
|
53
|
-
expect(methodConnected).toBe(2);
|
|
54
|
-
expect(methodDisconnected).toBe(2);
|
|
55
|
-
expect(fieldConnected).toEqual(methodConnected);
|
|
56
|
-
expect(fieldDisconnected).toEqual(methodDisconnected);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("@connectEffect", async () => {
|
|
60
|
-
let methodRun = 0,
|
|
61
|
-
methodDisposed = 0;
|
|
62
|
-
let fieldRun = 0,
|
|
63
|
-
fieldDisposed = 0;
|
|
64
|
-
const signal = Signal.from(1);
|
|
65
|
-
|
|
66
|
-
@customElement("connect-effect-test-class")
|
|
67
|
-
class ConnectEffectTestClass extends Component {
|
|
68
|
-
@connectEffect onConnected() {
|
|
69
|
-
methodRun += signal.get();
|
|
70
|
-
return () => {
|
|
71
|
-
methodDisposed++;
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
@connectEffect field: ConnectFunction = () => {
|
|
76
|
-
fieldRun += signal.get();
|
|
77
|
-
return () => {
|
|
78
|
-
fieldDisposed++;
|
|
79
|
-
};
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const c = document.createElement("connect-effect-test-class") as ConnectEffectTestClass;
|
|
84
|
-
expect(methodRun).toBe(0);
|
|
85
|
-
expect(methodDisposed).toBe(0);
|
|
86
|
-
expect(fieldRun).toEqual(methodRun);
|
|
87
|
-
expect(fieldDisposed).toEqual(methodDisposed);
|
|
88
|
-
document.body.append(c);
|
|
89
|
-
await c.updateComplete;
|
|
90
|
-
expect(methodRun).toBe(1);
|
|
91
|
-
expect(methodDisposed).toBe(0);
|
|
92
|
-
expect(fieldRun).toEqual(methodRun);
|
|
93
|
-
expect(fieldDisposed).toEqual(methodDisposed);
|
|
94
|
-
c.remove();
|
|
95
|
-
expect(methodRun).toBe(1);
|
|
96
|
-
expect(methodDisposed).toBe(1);
|
|
97
|
-
expect(fieldRun).toEqual(methodRun);
|
|
98
|
-
expect(fieldDisposed).toEqual(methodDisposed);
|
|
99
|
-
document.body.append(c);
|
|
100
|
-
expect(methodRun).toBe(2);
|
|
101
|
-
expect(methodDisposed).toBe(1);
|
|
102
|
-
expect(fieldRun).toEqual(methodRun);
|
|
103
|
-
expect(fieldDisposed).toEqual(methodDisposed);
|
|
104
|
-
signal.set(2);
|
|
105
|
-
// wait for the effect to run
|
|
106
|
-
await sleep(1);
|
|
107
|
-
// run should be bumped by 2, the new value of the signal, and
|
|
108
|
-
// disposed should be bumped by 1 because the previous effect is
|
|
109
|
-
// disposed.
|
|
110
|
-
expect(methodRun).toBe(4);
|
|
111
|
-
expect(methodDisposed).toBe(2);
|
|
112
|
-
expect(fieldRun).toEqual(methodRun);
|
|
113
|
-
expect(fieldDisposed).toEqual(methodDisposed);
|
|
114
|
-
c.remove();
|
|
115
|
-
expect(methodRun).toBe(4);
|
|
116
|
-
expect(methodDisposed).toBe(3);
|
|
117
|
-
expect(fieldRun).toEqual(methodRun);
|
|
118
|
-
expect(fieldDisposed).toEqual(methodDisposed);
|
|
119
|
-
});
|