@bodil/dom 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/component.d.ts +227 -9
  2. package/dist/component.js +185 -38
  3. package/dist/component.js.map +1 -1
  4. package/dist/css.d.ts +4 -0
  5. package/dist/css.js +4 -0
  6. package/dist/css.js.map +1 -1
  7. package/dist/decorators/attribute.d.ts +15 -15
  8. package/dist/decorators/attribute.js +28 -9
  9. package/dist/decorators/attribute.js.map +1 -1
  10. package/dist/decorators/attribute.test.js +58 -6
  11. package/dist/decorators/attribute.test.js.map +1 -1
  12. package/dist/decorators/connect.d.ts +2 -0
  13. package/dist/decorators/connect.js.map +1 -1
  14. package/dist/decorators/connect.test.js +1 -1
  15. package/dist/decorators/connect.test.js.map +1 -1
  16. package/dist/decorators/reactive.d.ts +1 -1
  17. package/dist/decorators/reactive.js +1 -1
  18. package/dist/decorators/reactive.js.map +1 -1
  19. package/dist/decorators/reactive.test.js +1 -1
  20. package/dist/decorators/reactive.test.js.map +1 -1
  21. package/dist/decorators/require.test.js +1 -1
  22. package/dist/decorators/require.test.js.map +1 -1
  23. package/dist/dom.d.ts +43 -1
  24. package/dist/dom.js +63 -15
  25. package/dist/dom.js.map +1 -1
  26. package/dist/dom.test.d.ts +1 -0
  27. package/dist/dom.test.js +20 -0
  28. package/dist/dom.test.js.map +1 -0
  29. package/dist/emitter.d.ts +8 -0
  30. package/dist/emitter.js.map +1 -1
  31. package/dist/event.d.ts +4 -0
  32. package/dist/event.js.map +1 -1
  33. package/dist/geometry.d.ts +7 -0
  34. package/dist/geometry.js +4 -0
  35. package/dist/geometry.js.map +1 -1
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +2 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/signal.d.ts +12 -3
  40. package/dist/signal.js +7 -1
  41. package/dist/signal.js.map +1 -1
  42. package/dist/signal.test.js +2 -2
  43. package/dist/signal.test.js.map +1 -1
  44. package/dist/test.d.ts +17 -0
  45. package/dist/test.js +34 -0
  46. package/dist/test.js.map +1 -0
  47. package/package.json +27 -19
  48. package/src/component.ts +289 -53
  49. package/src/css.ts +5 -0
  50. package/src/decorators/attribute.test.ts +41 -14
  51. package/src/decorators/attribute.ts +57 -29
  52. package/src/decorators/reactive.test.ts +1 -1
  53. package/src/decorators/reactive.ts +4 -4
  54. package/src/decorators/require.test.ts +1 -1
  55. package/src/dom.test.ts +23 -0
  56. package/src/dom.ts +87 -15
  57. package/src/emitter.ts +8 -0
  58. package/src/event.ts +4 -0
  59. package/src/geometry.ts +8 -0
  60. package/src/index.ts +2 -1
  61. package/src/signal.test.ts +2 -2
  62. package/src/signal.ts +13 -2
  63. package/src/test.ts +38 -0
  64. package/src/decorators/connect.test.ts +0 -119
  65. 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 | undefined = "Joe";
13
- @attribute({ type: Number, reflect: true }) accessor wobble: number | undefined = 1;
14
- @attribute({ type: Boolean, reflect: true }) accessor noMeansNo: boolean | undefined =
15
- false;
16
- @attribute({ name: "wolp", reactive: false, reflect: true }) accessor welp:
17
- | string
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).toBeUndefined();
33
+ expect(t.wibble).toBeNull();
36
34
  expect(t.getAttribute("wibble")).toBeNull();
37
- expect(wibble.get()).toBe(undefined);
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 = undefined;
43
- expect(t.wibble).toBeUndefined();
40
+ t.wibble = null;
41
+ expect(t.wibble).toBeNull();
44
42
  expect(t.getAttribute("wibble")).toBeNull();
45
- expect(wibble.get()).toBe(undefined);
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 | undefined;
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 | undefined;
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 | undefined;
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 | undefined;
63
+ ): string | number | boolean | null;
64
64
  export function fromAttribute(
65
65
  value: string | null,
66
66
  type: AttributeType,
67
- ): string | number | boolean | undefined {
67
+ ): string | number | boolean | null {
68
68
  switch (type) {
69
69
  case Number: {
70
- const num = value === null ? undefined : Number(value);
71
- if (num !== undefined && Number.isNaN(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 ?? undefined;
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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 | undefined>(
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>,
@@ -281,7 +309,7 @@ function accessor<C extends Component, T>(
281
309
  let initValue: Option<T> = None;
282
310
  const getSig = (obj: object) =>
283
311
  signalForObject(obj, context.name, () =>
284
- Signal(initValue.unwrapExact(), {
312
+ Signal.from(initValue.unwrapExact(), {
285
313
  equals: Object.is,
286
314
  }),
287
315
  ) as Signal.State<T>;
@@ -5,7 +5,7 @@ import { Component, reactive } from "../component";
5
5
  import { customElement } from "lit/decorators.js";
6
6
 
7
7
  test("@reactive", () => {
8
- const name = Signal("Joe");
8
+ const name = Signal.from("Joe");
9
9
  class ReactiveTestClass {
10
10
  @reactive get name(): string {
11
11
  return name.get();
@@ -5,13 +5,13 @@ import type { ClassGetterDecoratorResult, ClassGetterDecoratorTarget } from "./t
5
5
 
6
6
  export const reactiveFields = Symbol("reactiveFields");
7
7
 
8
- const signalCache = new WeakMap<object, Record<PropertyKey, Signal<unknown>>>();
8
+ const signalCache = new WeakMap<object, Record<PropertyKey, Signal.Any<unknown>>>();
9
9
 
10
10
  export function signalForObject(
11
11
  obj: object,
12
12
  key: string | symbol,
13
- createSignal: () => Signal<unknown>,
14
- ): Signal<unknown> {
13
+ createSignal: () => Signal.Any<unknown>,
14
+ ): Signal.Any<unknown> {
15
15
  let sigs = signalCache.get(obj);
16
16
  if (sigs === undefined) {
17
17
  sigs = {};
@@ -48,7 +48,7 @@ export function reactive<C extends object, T>(
48
48
  case "accessor": {
49
49
  const getSig = (obj: object) =>
50
50
  signalForObject(obj, context.name, () =>
51
- Signal((value as ClassAccessorDecoratorTarget<unknown, T>).get.call(obj), {
51
+ Signal.from((value as ClassAccessorDecoratorTarget<unknown, T>).get.call(obj), {
52
52
  equals: Object.is,
53
53
  }),
54
54
  ) as Signal.State<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 | undefined;
14
+ @require @attribute accessor bar: string | null = null;
15
15
 
16
16
  protected override initialised() {
17
17
  inits++;
@@ -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
@@ -1,9 +1,15 @@
1
- import { assertNever, isNullish, unreachable } from "@bodil/core/assert";
2
- import { toDisposable } from "@bodil/core/disposable";
1
+ /**
2
+ * DOM manipulation tools.
3
+ * @module
4
+ */
5
+
6
+ import { assertNever, isEmpty, isNullish, unreachable } from "@bodil/core/assert";
7
+ import { DisposableContext, toDisposable } from "@bodil/core/disposable";
3
8
  import type { ReactiveController, ReactiveControllerHost } from "lit";
4
9
  import type { OptionalKeysOf } from "type-fest";
5
10
 
6
11
  import type { Pixels } from "./css";
12
+ import { eventListener } from "./event";
7
13
  import { contains } from "./geometry";
8
14
 
9
15
  /**
@@ -89,6 +95,9 @@ export function visibilityObserver(
89
95
  });
90
96
  }
91
97
 
98
+ /**
99
+ * An iterator for traversing siblings of a DOM node.
100
+ */
92
101
  export class DOMIterator extends Iterator<Node> {
93
102
  private currentNode: Node | null;
94
103
  private goingForward = true;
@@ -120,18 +129,27 @@ export class DOMIterator extends Iterator<Node> {
120
129
  }
121
130
  }
122
131
 
123
- export function childElements(parent: Element | DocumentFragment | null): IteratorObject<Element> {
124
- if (parent === null) {
132
+ /**
133
+ * Return all descendants of `root` which match the `predicate` function.
134
+ */
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) {
125
148
  return Iterator.from([]);
126
149
  }
127
- const iter = function* () {
128
- let el = parent.firstElementChild;
129
- while (el !== null) {
130
- yield el;
131
- el = el.nextElementSibling;
132
- }
133
- };
134
- return Iterator.from(iter());
150
+ return Iterator.from(root.children).flatMap((el) =>
151
+ predicate(el) ? [el, ...findDescendants(el, predicate)] : findDescendants(el, predicate),
152
+ );
135
153
  }
136
154
 
137
155
  /** Test whether the user has asked for reduced motion. */
@@ -188,9 +206,17 @@ export async function animationEnd(el: HTMLElement): Promise<void> {
188
206
  await Promise.all(
189
207
  el.getAnimations().map((animation) => {
190
208
  return new Promise((resolve) => {
191
- const handleAnimationEvent = () => requestAnimationFrame(resolve);
192
- animation.addEventListener("cancel", () => handleAnimationEvent(), { once: true });
193
- animation.addEventListener("finish", () => handleAnimationEvent(), { once: true });
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
+ );
194
220
  });
195
221
  }),
196
222
  );
@@ -217,6 +243,10 @@ export function isAnimating(el: HTMLElement): boolean {
217
243
  return path.some((item) => item.getAnimations().length > 0);
218
244
  }
219
245
 
246
+ /**
247
+ * Find the first {@link EventTarget} going up an {@link Event.composedPath}
248
+ * which matches the given `predicate`.
249
+ */
220
250
  export function findEventTarget<T extends EventTarget>(
221
251
  e: Event,
222
252
  predicate: (target: EventTarget) => target is T,
@@ -309,6 +339,16 @@ export function findAncestor(
309
339
  return undefined;
310
340
  }
311
341
 
342
+ /**
343
+ * Test whether an {@link Element} should be considered an editor, ie. something
344
+ * which expects to consume `keydown` events.
345
+ *
346
+ * Use this if you have globally defined keybindings which should not be
347
+ * triggered when typing into an input field or other kind of editor.
348
+ *
349
+ * {@link HTMLInputElement}s, {@link HTMLTextAreaElement}s and elements with the
350
+ * `contenteditable` attribute set are defined as editors.
351
+ */
312
352
  export function isEditor(element: Element | null | undefined): boolean {
313
353
  if (isNullish(element)) {
314
354
  return false;
@@ -338,6 +378,11 @@ const scrollToItemOptionsDefault: Required<
338
378
  padEnd: 0,
339
379
  };
340
380
 
381
+ /**
382
+ * Scroll an element into view.
383
+ *
384
+ * This is {@link Element.scrollIntoView} for advanced users.
385
+ */
341
386
  export function scrollToItem(el: HTMLElement, scrollToItemOptions: ScrollToItemOptions): void {
342
387
  const options: Required<ScrollToItemOptions> = {
343
388
  ...scrollToItemOptionsDefault,
@@ -391,8 +436,35 @@ export function scrollToItem(el: HTMLElement, scrollToItemOptions: ScrollToItemO
391
436
  }
392
437
  }
393
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
+
394
463
  // HasSlotController nicked largely verbatim from Shoelace
395
464
  // https://github.com/shoelace-style/shoelace/blob/next/src/internal/slot.ts
465
+ /**
466
+ * A {@link ReactiveController} which tracks whether a slot is populated.
467
+ */
396
468
  export class HasSlotController implements ReactiveController {
397
469
  host: ReactiveControllerHost & Element;
398
470
  slotNames: Array<string> = [];
package/src/emitter.ts CHANGED
@@ -62,6 +62,14 @@ export type ElementEmits<C> = C extends EmitterElement ? keyof C["emits"] : neve
62
62
  * }
63
63
  */
64
64
  export class EmitterElement extends HTMLElement {
65
+ /**
66
+ * Declares the events this element can emit.
67
+ *
68
+ * @example
69
+ * class MyElement extends EmitterElement {
70
+ * emits!: Emits<"my-event" | "my-other-event";
71
+ * }
72
+ */
65
73
  emits!: { [K in keyof HTMLElementEventMap]?: never };
66
74
 
67
75
  /**
package/src/event.ts CHANGED
@@ -16,6 +16,10 @@ import type { Present } from "@bodil/core/types";
16
16
  * `__events!: MyEventMap` as a property on the class.
17
17
  */
18
18
  export interface DeclareEvents<Events> extends EventTarget {
19
+ /**
20
+ * @internal
21
+ * @ignore
22
+ */
19
23
  __events: Events;
20
24
  }
21
25
 
package/src/geometry.ts CHANGED
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Geometry tools.
3
+ * @module
4
+ */
5
+
6
+ /**
7
+ * Valid types representing rectangles.
8
+ */
1
9
  export type Rectangle = DOMRect | Element;
2
10
 
3
11
  function rectFor(value: Rectangle): DOMRect {
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 };
@@ -7,7 +7,7 @@ import { Component } from "./component";
7
7
  import { watch } from "./signal";
8
8
 
9
9
  test("watch directive", async () => {
10
- const counter = Signal(1);
10
+ const counter = Signal.from(1);
11
11
  let renders = 0;
12
12
 
13
13
  @customElement("watch-directive-test")
@@ -48,7 +48,7 @@ test("watch directive", async () => {
48
48
  });
49
49
 
50
50
  test("watch directive with mapper function", async () => {
51
- const counter = Signal(1);
51
+ const counter = Signal.from(1);
52
52
  let renders = 0;
53
53
 
54
54
  @customElement("watch-directive-mapper-test")
package/src/signal.ts CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Lit directives for working with signals.
3
+ * @module
4
+ */
5
+
1
6
  import { defer } from "@bodil/core/async";
2
7
  import { id } from "@bodil/core/fun";
3
8
  import { Signal } from "@bodil/signal";
@@ -23,7 +28,8 @@ const hostlessWatcher = new Signal.subtle.Watcher(() => {
23
28
  }
24
29
  });
25
30
 
26
- class WatchDirective<T> extends AsyncDirective {
31
+ /** @internal */
32
+ export class WatchDirective<T> extends AsyncDirective {
27
33
  #host?: Component;
28
34
  #signal?: Signal.State<T> | Signal.Computed<T>;
29
35
  #mapper: (value: T) => unknown = id;
@@ -86,13 +92,18 @@ class WatchDirective<T> extends AsyncDirective {
86
92
  }
87
93
  }
88
94
 
95
+ /**
96
+ * @internal
97
+ * @ignore
98
+ */
89
99
  export type WatchDirectiveFunction = <T>(
90
- signal: Signal.State<T> | Signal.Computed<T>,
100
+ signal: Signal.Any<T>,
91
101
  mapFn?: (value: T) => unknown,
92
102
  ) => DirectiveResult<typeof WatchDirective<T>>;
93
103
 
94
104
  /**
95
105
  * Render a signal and subscribe to it, updating the part when the signal
96
106
  * changes independently of the host component.
107
+ * @function
97
108
  */
98
109
  export const watch = directive(WatchDirective) as WatchDirectiveFunction;
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
+ }