@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/emitter.d.ts CHANGED
@@ -67,6 +67,8 @@ export declare class EmitterElement extends HTMLElement {
67
67
  * class MyElement extends EmitterElement {
68
68
  * emits!: Emits<"my-event" | "my-other-event";
69
69
  * }
70
+ *
71
+ * @category Events
70
72
  */
71
73
  emits: {
72
74
  [K in keyof HTMLElementEventMap]?: never;
@@ -75,6 +77,8 @@ export declare class EmitterElement extends HTMLElement {
75
77
  * Emit a custom event with the given name and detail.
76
78
  *
77
79
  * Event init options default to `{ bubbles: true, composed: true }`.
80
+ *
81
+ * @category Events
78
82
  */
79
83
  emit<M extends CustomEventTypes<keyof this["emits"] & keyof HTMLElementEventMap>, K extends Extract<keyof M, string>, D extends M[K]>(name: K, detail?: D, options?: EventInit): CustomEvent<D>;
80
84
  /**
@@ -86,6 +90,8 @@ export declare class EmitterElement extends HTMLElement {
86
90
  * @example
87
91
  * this.emitEvent("click", MouseEvent, { button: 2 });
88
92
  * // emits: new MouseEvent("click", { button: 2 });
93
+ *
94
+ * @category Events
89
95
  */
90
96
  emitEvent<K extends keyof this["emits"] & keyof HTMLElementEventMap, E extends HTMLElementEventMap[K], C extends new (type: string, ...args: Args) => E, Args extends Array<any>>(name: K, constructor: C, ...args: Args): E;
91
97
  }
package/dist/emitter.js CHANGED
@@ -27,6 +27,8 @@ export class EmitterElement extends HTMLElement {
27
27
  * Emit a custom event with the given name and detail.
28
28
  *
29
29
  * Event init options default to `{ bubbles: true, composed: true }`.
30
+ *
31
+ * @category Events
30
32
  */
31
33
  emit(name, detail, options) {
32
34
  const event = new CustomEvent(name, {
@@ -47,6 +49,8 @@ export class EmitterElement extends HTMLElement {
47
49
  * @example
48
50
  * this.emitEvent("click", MouseEvent, { button: 2 });
49
51
  * // emits: new MouseEvent("click", { button: 2 });
52
+ *
53
+ * @category Events
50
54
  */
51
55
  emitEvent(name, constructor, ...args) {
52
56
  const event = new constructor(name, ...args);
@@ -1 +1 @@
1
- {"version":3,"file":"emitter.js","sourceRoot":"","sources":["../src/emitter.ts"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,cAAe,SAAQ,WAAW;IAW3C;;;;OAIG;IACH,IAAI,CAIF,IAAO,EAAE,MAAU,EAAE,OAAmB;QACtC,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,IAAI,EAAE;YAChC,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,IAAI;YACd,MAAM;YACN,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;SACrB,CAAC,CAAC;QACH,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC1B,OAAO,KAAK,CAAC;IACjB,CAAC;IAED;;;;;;;;;OASG;IACH,SAAS,CAKP,IAAO,EAAE,WAAc,EAAE,GAAG,IAAU;QACpC,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,CAAC;QAC7C,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC1B,OAAO,KAAK,CAAC;IACjB,CAAC;CACJ"}
1
+ {"version":3,"file":"emitter.js","sourceRoot":"","sources":["../src/emitter.ts"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,cAAe,SAAQ,WAAW;IAa3C;;;;;;OAMG;IACH,IAAI,CAIF,IAAO,EAAE,MAAU,EAAE,OAAmB;QACtC,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,IAAI,EAAE;YAChC,OAAO,EAAE,IAAI;YACb,QAAQ,EAAE,IAAI;YACd,MAAM;YACN,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;SACrB,CAAC,CAAC;QACH,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC1B,OAAO,KAAK,CAAC;IACjB,CAAC;IAED;;;;;;;;;;;OAWG;IACH,SAAS,CAKP,IAAO,EAAE,WAAc,EAAE,GAAG,IAAU;QACpC,MAAM,KAAK,GAAG,IAAI,WAAW,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,CAAC;QAC7C,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC1B,OAAO,KAAK,CAAC;IACjB,CAAC;CACJ"}
package/dist/index.d.ts CHANGED
@@ -8,4 +8,5 @@ import * as dom from "./dom";
8
8
  import * as event from "./event";
9
9
  import * as geometry from "./geometry";
10
10
  import * as signal from "./signal";
11
- export { component, css, dom, event, geometry, signal };
11
+ import * as test from "./test";
12
+ export { component, css, dom, event, geometry, signal, test };
package/dist/index.js CHANGED
@@ -8,5 +8,6 @@ import * as dom from "./dom";
8
8
  import * as event from "./event";
9
9
  import * as geometry from "./geometry";
10
10
  import * as signal from "./signal";
11
- export { component, css, dom, event, geometry, signal };
11
+ import * as test from "./test";
12
+ export { component, css, dom, event, geometry, signal, test };
12
13
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,SAAS,MAAM,aAAa,CAAC;AACzC,OAAO,KAAK,GAAG,MAAM,OAAO,CAAC;AAC7B,OAAO,KAAK,GAAG,MAAM,OAAO,CAAC;AAC7B,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AACjC,OAAO,KAAK,QAAQ,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,MAAM,MAAM,UAAU,CAAC;AAEnC,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,SAAS,MAAM,aAAa,CAAC;AACzC,OAAO,KAAK,GAAG,MAAM,OAAO,CAAC;AAC7B,OAAO,KAAK,GAAG,MAAM,OAAO,CAAC;AAC7B,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AACjC,OAAO,KAAK,QAAQ,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,MAAM,MAAM,UAAU,CAAC;AACnC,OAAO,KAAK,IAAI,MAAM,QAAQ,CAAC;AAE/B,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC"}
package/dist/test.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Tools for testing web components.
3
+ * @module
4
+ */
5
+ /**
6
+ * Render a Lit template and wait for any {@link Component}s inside it to
7
+ * stabilise before returning the {@link HTMLElement} containing the rendered
8
+ * template.
9
+ *
10
+ * The root element is also a {@link Disposable} which will remove itself from
11
+ * the DOM and dispose its {@link Component}s when disposed.
12
+ *
13
+ * @example
14
+ * using root = await testHTML(html`<my-component></my-component>`);
15
+ * expect(root.querySelector("my-component")).toBeInstanceOf(MyComponent);
16
+ */
17
+ export declare function testHTML(template: unknown): Promise<HTMLElement & Disposable>;
package/dist/test.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Tools for testing web components.
3
+ * @module
4
+ */
5
+ import { render } from "lit";
6
+ import { Component } from "./component";
7
+ /**
8
+ * Render a Lit template and wait for any {@link Component}s inside it to
9
+ * stabilise before returning the {@link HTMLElement} containing the rendered
10
+ * template.
11
+ *
12
+ * The root element is also a {@link Disposable} which will remove itself from
13
+ * the DOM and dispose its {@link Component}s when disposed.
14
+ *
15
+ * @example
16
+ * using root = await testHTML(html`<my-component></my-component>`);
17
+ * expect(root.querySelector("my-component")).toBeInstanceOf(MyComponent);
18
+ */
19
+ export async function testHTML(template) {
20
+ const root = document.createElement("section");
21
+ document.body.append(root);
22
+ render(template, root, { host: root });
23
+ root[Symbol.dispose] = () => {
24
+ Iterator.from(root.children)
25
+ .filter((el) => el instanceof Component)
26
+ .forEach((comp) => comp[Symbol.dispose]());
27
+ root.remove();
28
+ };
29
+ await Promise.all(Iterator.from(root.children)
30
+ .filter((el) => el instanceof Component)
31
+ .map((el) => el.hasStabilised));
32
+ return root;
33
+ }
34
+ //# sourceMappingURL=test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test.js","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,CAAC;AAE7B,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,QAAiB;IAC5C,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,SAAS,CAA6B,CAAC;IAC3E,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC3B,MAAM,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,GAAG,EAAE;QACxB,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC;aACvB,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,YAAY,SAAS,CAAC;aACvC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC/C,IAAI,CAAC,MAAM,EAAE,CAAC;IAClB,CAAC,CAAC;IACF,MAAM,OAAO,CAAC,GAAG,CACb,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC;SACvB,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,YAAY,SAAS,CAAC;SACvC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,aAAa,CAAC,CACrC,CAAC;IACF,OAAO,IAAI,CAAC;AAChB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bodil/dom",
3
- "version": "0.1.10",
3
+ "version": "0.2.1",
4
4
  "description": "DOM and web component tools",
5
5
  "homepage": "https://codeberg.org/bodil/dom",
6
6
  "repository": {
@@ -56,40 +56,46 @@
56
56
  "types": "./dist/signal.d.ts",
57
57
  "import": "./dist/signal.js"
58
58
  }
59
+ },
60
+ "./test": {
61
+ "import": {
62
+ "types": "./dist/test.d.ts",
63
+ "import": "./dist/test.js"
64
+ }
59
65
  }
60
66
  },
61
67
  "publishConfig": {
62
68
  "access": "public"
63
69
  },
64
70
  "dependencies": {
65
- "@bodil/core": "^0.5.2",
66
- "@bodil/opt": "^0.4.3",
67
- "@bodil/signal": "^0.5.1",
71
+ "@bodil/core": "^0.5.3",
72
+ "@bodil/opt": "^1.0.0",
73
+ "@bodil/signal": "^0.5.2",
68
74
  "lit": "^3.3.2",
69
- "type-fest": "^5.3.1"
75
+ "type-fest": "^5.4.4"
70
76
  },
71
77
  "devDependencies": {
72
78
  "@eslint/eslintrc": "^3.3.3",
73
- "@eslint/js": "^9.39.2",
74
- "@ianvs/prettier-plugin-sort-imports": "^4.7.0",
75
- "@typescript-eslint/eslint-plugin": "^8.52.0",
76
- "@typescript-eslint/parser": "^8.52.0",
79
+ "@eslint/js": "^10.0.1",
80
+ "@ianvs/prettier-plugin-sort-imports": "^4.7.1",
81
+ "@typescript-eslint/eslint-plugin": "^8.56.0",
82
+ "@typescript-eslint/parser": "^8.56.0",
77
83
  "@typhonjs-typedoc/ts-lib-docs": "^2024.12.25",
78
- "@vitest/browser-playwright": "^4.0.16",
79
- "@vitest/coverage-v8": "^4.0.16",
80
- "eslint": "^9.39.2",
84
+ "@vitest/browser-playwright": "^4.0.18",
85
+ "@vitest/coverage-v8": "^4.0.18",
86
+ "eslint": "^10.0.1",
81
87
  "eslint-config-prettier": "^10.1.8",
82
- "eslint-plugin-jsdoc": "^61.5.0",
83
- "globals": "^17.0.0",
84
- "happy-dom": "^20.0.11",
88
+ "eslint-plugin-jsdoc": "^62.7.0",
89
+ "globals": "^17.3.0",
90
+ "happy-dom": "^20.7.0",
85
91
  "npm-run-all2": "^8.0.4",
86
- "prettier": "^3.7.4",
87
- "typedoc": "^0.28.15",
92
+ "prettier": "^3.8.1",
93
+ "typedoc": "^0.28.17",
88
94
  "typedoc-plugin-extras": "^4.0.1",
89
- "typedoc-plugin-mdn-links": "^5.0.10",
95
+ "typedoc-plugin-mdn-links": "^5.1.1",
90
96
  "typedoc-theme-fresh": "^0.2.3",
91
97
  "typescript": "^5.9.3",
92
- "vitest": "^4.0.16"
98
+ "vitest": "^4.0.18"
93
99
  },
94
100
  "prettier": {
95
101
  "editorconfig": true,
package/src/component.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * @module
4
4
  */
5
5
 
6
- import { isIterable, isNullish } from "@bodil/core/assert";
6
+ import { isNullish, present } from "@bodil/core/assert";
7
7
  import { DisposableContext, toDisposable, type Disposifiable } from "@bodil/core/disposable";
8
8
  import { Signal } from "@bodil/signal";
9
9
  import {
@@ -29,10 +29,9 @@ import {
29
29
  toAttribute,
30
30
  type AttributeConfig,
31
31
  } from "./decorators/attribute";
32
- import { connectedJobs } from "./decorators/connect";
33
32
  import { reactiveFields, signalForObject } from "./decorators/reactive";
34
33
  import { requiredProperties } from "./decorators/require";
35
- import { childElements } from "./dom";
34
+ import { findDescendants } from "./dom";
36
35
  import { EmitterElement } from "./emitter";
37
36
  import { eventListener } from "./event";
38
37
  import { scheduler } from "./scheduler";
@@ -54,12 +53,6 @@ export {
54
53
  type AttributeGetterSetterOptions,
55
54
  type AttributeType,
56
55
  } from "./decorators/attribute";
57
- export {
58
- connect,
59
- connectEffect,
60
- type ConnectFunction,
61
- type ConnectFunctionReturnValue,
62
- } from "./decorators/connect";
63
56
  export { reactive } from "./decorators/reactive";
64
57
  export { require } from "./decorators/require";
65
58
  export { EmitterElement } from "./emitter";
@@ -74,6 +67,8 @@ export type UpdateConfig = {
74
67
 
75
68
  const finalised = Symbol("finalised");
76
69
 
70
+ export type Disposables = Disposifiable | Iterable<Disposifiable | undefined> | undefined | void;
71
+
77
72
  export type Deps = Array<typeof HTMLElement>;
78
73
 
79
74
  export type CSSStyleSpec = CSSResult | CSSStyleSheet | string;
@@ -112,6 +107,8 @@ export abstract class Component
112
107
  * class MyComponent extends Component {
113
108
  * static deps: Deps = [ MySubcomponent, MyOtherSubcomponent ];
114
109
  * }
110
+ *
111
+ * @category Declarations
115
112
  */
116
113
  static deps: Deps = [];
117
114
 
@@ -132,9 +129,14 @@ export abstract class Component
132
129
  * }
133
130
  * `;
134
131
  * }
132
+ *
133
+ * @category Declarations
135
134
  */
136
135
  static styles?: CSSStyleSpecDeclaration;
137
136
 
137
+ /**
138
+ * @category Advanced
139
+ */
138
140
  static shadowRootOptions: ShadowRootInit = { mode: "open" };
139
141
 
140
142
  private static initialisers = new Set<() => void>();
@@ -147,11 +149,20 @@ export abstract class Component
147
149
  protected static requiredProperties = new Set<string | symbol>();
148
150
  private static [finalised] = true;
149
151
 
152
+ /**
153
+ * @category Advanced
154
+ */
150
155
  renderOptions: RenderOptions = { host: this };
156
+
157
+ /**
158
+ * {@inheritDoc EmitterElement.emits}
159
+ * @category Declarations
160
+ */
151
161
  emits!: object;
152
162
 
153
163
  readonly #controllers = new Set<ReactiveController>();
154
164
  readonly #connectedContext = new DisposableContext();
165
+ readonly #finalCleanupContext = new DisposableContext();
155
166
  #dirty = false;
156
167
  #isUpdatePending = false;
157
168
  #updateSignal?: Signal.Computed<void>;
@@ -164,15 +175,23 @@ export abstract class Component
164
175
  #viewTransitionRequested = false;
165
176
  #ignoreAttributeUpdates: number;
166
177
 
178
+ /**
179
+ * @category Advanced
180
+ */
167
181
  readonly renderRoot: ShadowRoot | HTMLElement = this.createRenderRoot();
168
182
 
169
183
  /**
170
184
  * True if this component had scheduled an update which has not yet started.
185
+ *
186
+ * @category Render
171
187
  */
172
188
  get isUpdatePending(): boolean {
173
189
  return this.#isUpdatePending;
174
190
  }
175
191
 
192
+ /**
193
+ * @category Render
194
+ */
176
195
  get updateComplete(): Promise<boolean> {
177
196
  return this.#updateResult.promise;
178
197
  }
@@ -184,6 +203,8 @@ export abstract class Component
184
203
  *
185
204
  * By default, a component considers itself stabilised when all of its
186
205
  * children which are also {@link Component}s are reporting as stabilised.
206
+ *
207
+ * @category Render
187
208
  */
188
209
  get hasStabilised(): Promise<void> {
189
210
  return this.#stabilised.promise;
@@ -196,6 +217,8 @@ export abstract class Component
196
217
  * This can be passed along to asynchronous tasks such as {@link fetch}
197
218
  * initiated by this component, which will then be aborted automatically if
198
219
  * the component is removed from the DOM.
220
+ *
221
+ * @category Advanced
199
222
  */
200
223
  get abortSignal(): AbortSignal {
201
224
  return this.#abortController.signal;
@@ -205,6 +228,8 @@ export abstract class Component
205
228
  /**
206
229
  * Register a function which will be executed whenever an instance of this
207
230
  * class is constructed.
231
+ *
232
+ * @category Advanced
208
233
  */
209
234
  static addInitialiser(init: (this: Component) => void) {
210
235
  if (!Object.hasOwn(this, "initialisers")) {
@@ -307,27 +332,12 @@ export abstract class Component
307
332
  }
308
333
  }
309
334
 
310
- /**
311
- * This lifecycle callback is called when the component is attached to the
312
- * DOM.
313
- *
314
- * Generally, prefer using methods with @{@link connect} decorators to
315
- * overriding this method.
316
- */
317
- protected connectedCallback() {
335
+ private connectedCallback() {
318
336
  this.#connectedContext.dispose();
319
337
  this.#controllers.forEach((c) => c.hostConnected?.());
320
338
  this.#rootPart?.setConnected(true);
321
339
 
322
- for (const job of connectedJobs(this)) {
323
- const result = job.call(this);
324
- const items = isNullish(result)
325
- ? Iterator.from<Disposifiable>([])
326
- : isIterable(result)
327
- ? Iterator.from(result)
328
- : Iterator.from([result]);
329
- items.filter((i) => i !== undefined).forEach(this.useWhileConnected.bind(this));
330
- }
340
+ this.connected();
331
341
 
332
342
  this.useWhileConnected(
333
343
  Signal.effect(() => {
@@ -345,19 +355,13 @@ export abstract class Component
345
355
  this.requestUpdate();
346
356
  }
347
357
 
348
- /**
349
- * This lifecycle callback is called when the component is removed from the
350
- * DOM.
351
- *
352
- * Generally, prefer using methods with @{@link connect} decorators to
353
- * overriding this method.
354
- */
355
- protected disconnectedCallback() {
358
+ private disconnectedCallback() {
356
359
  if (this.#updateSignal !== undefined) {
357
360
  this.#updateSignalWatcher.unwatch(this.#updateSignal);
358
361
  this.#updateSignal = undefined;
359
362
  }
360
363
  this.#isUpdatePending = false;
364
+ this.disconnected();
361
365
  this.#abortController.abort(
362
366
  new DOMException(`${this.tagName} element disconnected`, "AbortError"),
363
367
  );
@@ -394,7 +398,10 @@ export abstract class Component
394
398
  const attrConfig = (this.constructor as typeof Component).attributeConfig.get(name);
395
399
  if (attrConfig !== undefined) {
396
400
  const value = fromAttribute(valueString, attrConfig.type);
397
- this[attrConfig.property as keyof this] = value as any;
401
+ try {
402
+ this[attrConfig.property as keyof this] = value as any;
403
+ // Silently swallow any write errors.
404
+ } catch (_e) {}
398
405
  }
399
406
  }
400
407
 
@@ -410,6 +417,11 @@ export abstract class Component
410
417
  }) as Signal.Computed<V>;
411
418
  }
412
419
 
420
+ /**
421
+ * Adds a controller to the host, which sets up the controller's lifecycle
422
+ * methods to be called with the host's lifecycle.
423
+ * @category Advanced
424
+ */
413
425
  addController(controller: ReactiveController) {
414
426
  this.#controllers.add(controller);
415
427
  if (this.renderRoot !== undefined && this.isConnected) {
@@ -417,6 +429,10 @@ export abstract class Component
417
429
  }
418
430
  }
419
431
 
432
+ /**
433
+ * Removes a controller from the host.
434
+ * @category Advanced
435
+ */
420
436
  removeController(controller: ReactiveController) {
421
437
  this.#controllers.delete(controller);
422
438
  }
@@ -430,6 +446,8 @@ export abstract class Component
430
446
  * If you don't want to use a shadow DOM, you can override this method to
431
447
  * just `return this`, which causes the component's contents to render as
432
448
  * direct children of the component itself.
449
+ *
450
+ * @category Advanced
433
451
  */
434
452
  protected createRenderRoot(): HTMLElement | ShadowRoot {
435
453
  const renderRoot =
@@ -440,8 +458,27 @@ export abstract class Component
440
458
  return renderRoot;
441
459
  }
442
460
 
461
+ /**
462
+ * Add a style sheet to the component's shadow root.
463
+ *
464
+ * @category Advanced
465
+ */
466
+ addStyleSheet(spec: CSSStyleSpecDeclaration) {
467
+ if (!(this.renderRoot instanceof ShadowRoot)) {
468
+ throw new Error("Component needs a shadow root in order to add a style sheet");
469
+ }
470
+ const sheets = (
471
+ Array.isArray(spec)
472
+ ? ((spec as Array<unknown>).flat(Infinity) as Array<CSSStyleSpec>)
473
+ : [spec]
474
+ ).map(processCSSStyleSpec);
475
+ adoptStyles(this.renderRoot, sheets);
476
+ }
477
+
443
478
  /**
444
479
  * Ask this component to update itself.
480
+ *
481
+ * @category Render
445
482
  */
446
483
  requestUpdate(opts?: UpdateConfig) {
447
484
  this.#dirty = true;
@@ -466,9 +503,7 @@ export abstract class Component
466
503
  () => {
467
504
  return requiredProperties.size === 0
468
505
  ? true
469
- : requiredProperties
470
- .keys()
471
- .every((key) => (this as any)[key] !== undefined);
506
+ : requiredProperties.keys().every((key) => !isNullish((this as any)[key]));
472
507
  },
473
508
  // every signal update is relevant, even if the signal value doesn't
474
509
  // change, because the required values probably did.
@@ -553,16 +588,13 @@ export abstract class Component
553
588
  /**
554
589
  * Return an iterator over this component's children which are also
555
590
  * {@link Component}s.
591
+ *
592
+ * @category Queries
556
593
  */
557
594
  protected findChildComponents(
558
595
  root: Element | ShadowRoot = this.renderRoot,
559
596
  ): IteratorObject<Component> {
560
- let all: Array<Element> = childElements(root).toArray();
561
- const top = [...all];
562
- for (const el of top) {
563
- all = [...all, ...this.findChildComponents(el)];
564
- }
565
- return Iterator.from(all).filter((el) => el instanceof Component);
597
+ return findDescendants(root, (el) => el instanceof Component);
566
598
  }
567
599
 
568
600
  /**
@@ -571,6 +603,8 @@ export abstract class Component
571
603
  *
572
604
  * The default implementation waits for any children which are also
573
605
  * {@link Component}s to report that they have also stabilised.
606
+ *
607
+ * @category Render
574
608
  */
575
609
  protected async stabilise(): Promise<void> {
576
610
  // wait for children to stabilise
@@ -579,8 +613,27 @@ export abstract class Component
579
613
  await Promise.all(children.map((child) => child.hasStabilised)).catch(console.error);
580
614
  }
581
615
 
616
+ /**
617
+ * This lifecycle callback is called each time this component is connected
618
+ * to the DOM.
619
+ * @category Lifecycle
620
+ */
621
+ protected connected(): void {
622
+ //
623
+ }
624
+
625
+ /**
626
+ * This lifecycle callback is called each time this component is
627
+ * disconnected from the DOM.
628
+ * @category Lifecycle
629
+ */
630
+ protected disconnected(): void {
631
+ //
632
+ }
633
+
582
634
  /**
583
635
  * This lifecycle callback is called each time an update has completed.
636
+ * @category Lifecycle
584
637
  */
585
638
  protected updated(): void {
586
639
  //
@@ -589,6 +642,7 @@ export abstract class Component
589
642
  /**
590
643
  * This lifecycle callback is called after the component's first update has
591
644
  * completed, and before {@link Component.updated}.
645
+ * @category Lifecycle
592
646
  */
593
647
  protected firstUpdated() {
594
648
  //
@@ -599,6 +653,7 @@ export abstract class Component
599
653
  * stabilised after its first update.
600
654
  *
601
655
  * @see {@link Component.hasStabilised}
656
+ * @category Lifecycle
602
657
  */
603
658
  protected stabilised() {
604
659
  //
@@ -608,6 +663,7 @@ export abstract class Component
608
663
  * This lifecycle callback is called every time a property decorated with
609
664
  * the @{@link require} decorator has changed, but only when every property
610
665
  * marked as such is not `undefined`.
666
+ * @category Lifecycle
611
667
  */
612
668
  protected initialised() {
613
669
  //
@@ -617,6 +673,7 @@ export abstract class Component
617
673
  * This lifecycle callback is called the first time every property decorated
618
674
  * with @{@link require} has been defined, and before
619
675
  * {@link Component.initialised}.
676
+ * @category Lifecycle
620
677
  */
621
678
  protected firstInitialised() {
622
679
  //
@@ -637,19 +694,42 @@ export abstract class Component
637
694
  * `;
638
695
  * }
639
696
  * }
697
+ *
698
+ * @category Render
640
699
  */
641
700
  protected render(): unknown {
642
701
  return nothing;
643
702
  }
644
703
 
645
- [Symbol.dispose]() {
704
+ /** @internal */
705
+ [Symbol.dispose](): void {
646
706
  for (const child of this.findChildComponents()) {
647
707
  child[Symbol.dispose]();
648
708
  }
709
+ this.remove();
649
710
  this.disconnectedCallback();
650
711
  (this.#rootPart as any)?._$clear?.();
651
712
  this.#rootPart = undefined;
652
- this.remove();
713
+ this.#finalCleanupContext.dispose();
714
+ }
715
+
716
+ /**
717
+ * Register a {@link Disposable} for automatic disposal when this component
718
+ * is disposed.
719
+ * @category Resources
720
+ */
721
+ use(disposifiable: undefined): undefined;
722
+ use(disposifiable: null): null;
723
+ use(disposifiable: Disposifiable): Disposable;
724
+ use(disposifiable: Disposifiable | undefined): Disposable | undefined;
725
+ use(disposifiable: Disposifiable | null | undefined): Disposable | null | undefined;
726
+ use(disposifiable: Disposifiable | null | undefined): Disposable | null | undefined {
727
+ if (isNullish(disposifiable)) {
728
+ return disposifiable;
729
+ }
730
+ const disposable = toDisposable(disposifiable);
731
+ this.#finalCleanupContext.use(disposable);
732
+ return disposable;
653
733
  }
654
734
 
655
735
  /**
@@ -667,7 +747,13 @@ export abstract class Component
667
747
  * super.connectedCallback();
668
748
  * this.useWhileConnected(this.on("click", this.handleClick.bind(this)));
669
749
  * }
750
+ * @category Resources
670
751
  */
752
+ useWhileConnected(
753
+ disposifiable: Disposifiable,
754
+ secondDisposable: Disposifiable,
755
+ ...disposifiables: Array<Disposifiable>
756
+ ): Array<Disposable>;
671
757
  useWhileConnected(disposifiable: undefined): undefined;
672
758
  useWhileConnected(disposifiable: null): null;
673
759
  useWhileConnected(disposifiable: Disposifiable): Disposable;
@@ -677,7 +763,15 @@ export abstract class Component
677
763
  ): Disposable | null | undefined;
678
764
  useWhileConnected(
679
765
  disposifiable: Disposifiable | null | undefined,
680
- ): Disposable | null | undefined {
766
+ ...disposifiables: Array<Disposifiable>
767
+ ): Array<Disposable> | Disposable | null | undefined {
768
+ if (disposifiables.length > 0) {
769
+ return [disposifiable, ...disposifiables].map((d) => {
770
+ const disposable = toDisposable(present(d));
771
+ this.#connectedContext.use(disposable);
772
+ return disposable;
773
+ });
774
+ }
681
775
  if (isNullish(disposifiable)) {
682
776
  return disposifiable;
683
777
  }
@@ -690,6 +784,7 @@ export abstract class Component
690
784
  * Attach an event listener to an event on this component.
691
785
  *
692
786
  * @returns A {@link Disposable} to subsequently detach the event listener.
787
+ * @category Events
693
788
  */
694
789
  on<K extends keyof HTMLElementEventMap>(
695
790
  type: K,
@@ -703,6 +798,7 @@ export abstract class Component
703
798
  * If an element that is either inside this element's shadow root or is a
704
799
  * child of this element (ie. slotted) currently has focus, return that
705
800
  * element. Otherwise, return null.
801
+ * @category Queries
706
802
  */
707
803
  getFocusedElement(): Element | null {
708
804
  return this.shadowRoot?.activeElement ?? this.querySelector(":focus");
@@ -728,6 +824,7 @@ export abstract class Component
728
824
  * return html`<button>I am a button</button>`;
729
825
  * }
730
826
  * }
827
+ * @category Queries
731
828
  */
732
829
  query<El extends keyof HTMLElementTagNameMap>(selector: El): HTMLElementTagNameMap[El] | null;
733
830
  query<El extends keyof SVGElementTagNameMap>(selector: El): SVGElementTagNameMap[El] | null;
@@ -766,6 +863,7 @@ export abstract class Component
766
863
  * `;
767
864
  * }
768
865
  * }
866
+ * @category Queries
769
867
  */
770
868
  queryAll<El extends keyof HTMLElementTagNameMap>(
771
869
  selector: El,
@@ -791,6 +889,7 @@ export abstract class Component
791
889
  *
792
890
  * If you include `reactive: true` in your query, the result will be a
793
891
  * signal which updates with the contents of the slot.
892
+ * @category Queries
794
893
  */
795
894
  querySlot(
796
895
  options: QuerySlotOptions & { nodes: true; reactive: true },