@bodil/dom 0.1.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 (82) hide show
  1. package/LICENCE.md +288 -0
  2. package/dist/component.d.ts +147 -0
  3. package/dist/component.js +405 -0
  4. package/dist/component.js.map +1 -0
  5. package/dist/component.test.d.ts +1 -0
  6. package/dist/component.test.js +141 -0
  7. package/dist/component.test.js.map +1 -0
  8. package/dist/css.d.ts +14 -0
  9. package/dist/css.js +63 -0
  10. package/dist/css.js.map +1 -0
  11. package/dist/decorators/attribute.d.ts +26 -0
  12. package/dist/decorators/attribute.js +115 -0
  13. package/dist/decorators/attribute.js.map +1 -0
  14. package/dist/decorators/attribute.test.d.ts +1 -0
  15. package/dist/decorators/attribute.test.js +148 -0
  16. package/dist/decorators/attribute.test.js.map +1 -0
  17. package/dist/decorators/connect.d.ts +9 -0
  18. package/dist/decorators/connect.js +44 -0
  19. package/dist/decorators/connect.js.map +1 -0
  20. package/dist/decorators/connect.test.d.ts +1 -0
  21. package/dist/decorators/connect.test.js +196 -0
  22. package/dist/decorators/connect.test.js.map +1 -0
  23. package/dist/decorators/reactive.d.ts +7 -0
  24. package/dist/decorators/reactive.js +45 -0
  25. package/dist/decorators/reactive.js.map +1 -0
  26. package/dist/decorators/reactive.test.d.ts +1 -0
  27. package/dist/decorators/reactive.test.js +113 -0
  28. package/dist/decorators/reactive.test.js.map +1 -0
  29. package/dist/decorators/require.d.ts +3 -0
  30. package/dist/decorators/require.js +6 -0
  31. package/dist/decorators/require.js.map +1 -0
  32. package/dist/decorators/require.test.d.ts +1 -0
  33. package/dist/decorators/require.test.js +117 -0
  34. package/dist/decorators/require.test.js.map +1 -0
  35. package/dist/dom.d.ts +92 -0
  36. package/dist/dom.js +354 -0
  37. package/dist/dom.js.map +1 -0
  38. package/dist/emitter.d.ts +10 -0
  39. package/dist/emitter.js +17 -0
  40. package/dist/emitter.js.map +1 -0
  41. package/dist/event.d.ts +70 -0
  42. package/dist/event.js +14 -0
  43. package/dist/event.js.map +1 -0
  44. package/dist/event.test.d.ts +1 -0
  45. package/dist/event.test.js +20 -0
  46. package/dist/event.test.js.map +1 -0
  47. package/dist/geometry.d.ts +48 -0
  48. package/dist/geometry.js +117 -0
  49. package/dist/geometry.js.map +1 -0
  50. package/dist/index.d.ts +10 -0
  51. package/dist/index.js +11 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/scheduler.d.ts +2 -0
  54. package/dist/scheduler.js +54 -0
  55. package/dist/scheduler.js.map +1 -0
  56. package/dist/slot.d.ts +31 -0
  57. package/dist/slot.js +67 -0
  58. package/dist/slot.js.map +1 -0
  59. package/dist/test-global.d.ts +0 -0
  60. package/dist/test-global.js +5 -0
  61. package/dist/test-global.js.map +1 -0
  62. package/package.json +116 -0
  63. package/src/component.test.ts +90 -0
  64. package/src/component.ts +607 -0
  65. package/src/css.ts +69 -0
  66. package/src/decorators/attribute.test.ts +77 -0
  67. package/src/decorators/attribute.ts +197 -0
  68. package/src/decorators/connect.test.ts +119 -0
  69. package/src/decorators/connect.ts +85 -0
  70. package/src/decorators/reactive.test.ts +45 -0
  71. package/src/decorators/reactive.ts +80 -0
  72. package/src/decorators/require.test.ts +57 -0
  73. package/src/decorators/require.ts +11 -0
  74. package/src/dom.ts +456 -0
  75. package/src/emitter.ts +32 -0
  76. package/src/event.test.ts +22 -0
  77. package/src/event.ts +74 -0
  78. package/src/geometry.ts +147 -0
  79. package/src/index.ts +12 -0
  80. package/src/scheduler.ts +58 -0
  81. package/src/slot.ts +95 -0
  82. package/src/test-global.ts +5 -0
package/src/dom.ts ADDED
@@ -0,0 +1,456 @@
1
+ import { assertNever, isNullish, unreachable } from "@bodil/core/assert";
2
+ import { toDisposable } from "@bodil/core/disposable";
3
+ import type { ReactiveController, ReactiveControllerHost } from "lit";
4
+ import type { OptionalKeysOf } from "type-fest";
5
+
6
+ import type { Pixels } from "./css";
7
+ import { contains } from "./geometry";
8
+
9
+ /**
10
+ * Given the name of a custom DOM element registered in
11
+ * {@link Window.customElements}, test whether an object is an instance of that
12
+ * element.
13
+ *
14
+ * The element must also be declared in {@link HTMLElementTagNameMap} for the
15
+ * type checker to follow along.
16
+ */
17
+ export function isElement<Name extends keyof HTMLElementTagNameMap>(
18
+ name: Name,
19
+ item: unknown,
20
+ ): item is HTMLElementTagNameMap[Name] {
21
+ const constructor = customElements.get(name);
22
+ return constructor === undefined ? false : item instanceof constructor;
23
+ }
24
+
25
+ /**
26
+ * Call {@link window.requestAnimationFrame} with the provided callback and return a
27
+ * {@link Disposable} which will cancel the request when disposed.
28
+ */
29
+ export function animationFrame(fn: (time: number) => void): Disposable {
30
+ // @ts-ignore requestAnimationFrame is only available in browser contexts
31
+ let id: number | null = globalThis.requestAnimationFrame((time) => {
32
+ id = null;
33
+ fn(time);
34
+ disposable[Symbol.dispose]();
35
+ });
36
+ const disposable = toDisposable(() => {
37
+ if (id !== null) {
38
+ // @ts-ignore cancelAnimationFrame is only available in browser contexts
39
+ globalThis.cancelAnimationFrame(id);
40
+ }
41
+ });
42
+ return disposable;
43
+ }
44
+
45
+ /**
46
+ * Return a promise which will resolve when the next animation frame happens.
47
+ */
48
+ export function awaitFrame(): Promise<void> {
49
+ return new Promise((resolve) => animationFrame(() => resolve()));
50
+ }
51
+
52
+ export function intersectionObserver(
53
+ element: Element,
54
+ callback: IntersectionObserverCallback,
55
+ options?: IntersectionObserverInit,
56
+ ): Disposable {
57
+ let observer: ResizeObserver | undefined = new IntersectionObserver(callback, options);
58
+ observer.observe(element);
59
+ return toDisposable(() => {
60
+ observer?.disconnect();
61
+ observer = undefined;
62
+ });
63
+ }
64
+
65
+ export function resizeObserver(
66
+ element: Element,
67
+ callback: ResizeObserverCallback,
68
+ options?: ResizeObserverOptions,
69
+ ): Disposable {
70
+ let observer: ResizeObserver | undefined = new ResizeObserver(callback);
71
+ observer.observe(element, options);
72
+ return toDisposable(() => {
73
+ observer?.disconnect();
74
+ observer = undefined;
75
+ });
76
+ }
77
+
78
+ export function visibilityObserver(
79
+ element: Element,
80
+ callback: (oldState: boolean, newState: boolean) => void,
81
+ ): Disposable {
82
+ let oldState = false;
83
+ return intersectionObserver(element, (entries) => {
84
+ const newState = entries[0].isIntersecting;
85
+ if (oldState !== newState) {
86
+ callback(oldState, newState);
87
+ }
88
+ oldState = newState;
89
+ });
90
+ }
91
+
92
+ export class DOMIterator extends Iterator<Node> {
93
+ private currentNode: Node | null;
94
+ private goingForward = true;
95
+
96
+ constructor(startNode: Node | null) {
97
+ super();
98
+ this.currentNode = startNode;
99
+ }
100
+
101
+ reverse(): this {
102
+ this.goingForward = !this.goingForward;
103
+ return this;
104
+ }
105
+
106
+ skip(): this {
107
+ this.next();
108
+ return this;
109
+ }
110
+
111
+ next(): IteratorResult<Node> {
112
+ if (this.currentNode === null) {
113
+ return { done: true, value: undefined };
114
+ }
115
+ const resultNode = this.currentNode;
116
+ this.currentNode = this.goingForward
117
+ ? this.currentNode.nextSibling
118
+ : this.currentNode.previousSibling;
119
+ return { done: false, value: resultNode };
120
+ }
121
+ }
122
+
123
+ export function childElements(parent: Element | DocumentFragment | null): IteratorObject<Element> {
124
+ if (parent === null) {
125
+ return Iterator.from([]);
126
+ }
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());
135
+ }
136
+
137
+ /** Test whether the user has asked for reduced motion. */
138
+ export function prefersReducedMotion(): boolean {
139
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
140
+ }
141
+
142
+ /**
143
+ * Animates an element using keyframes. Returns a promise that resolves after
144
+ * the animation completes or gets cancelled.
145
+ */
146
+ export function animateTo(
147
+ el: HTMLElement,
148
+ keyframes: Array<Keyframe>,
149
+ options?: KeyframeAnimationOptions,
150
+ ): Promise<void> {
151
+ return new Promise((resolve) => {
152
+ if (options?.duration === Infinity) {
153
+ throw new Error("Promise-based animations must be finite.");
154
+ }
155
+
156
+ const animation = el.animate(keyframes, {
157
+ ...options,
158
+ duration: prefersReducedMotion() ? 0 : options!.duration,
159
+ });
160
+
161
+ animation.addEventListener("cancel", () => resolve(), { once: true });
162
+ animation.addEventListener("finish", () => resolve(), { once: true });
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Stops all active animations on the target element. Returns a promise that
168
+ * resolves after all animations are cancelled.
169
+ */
170
+ export async function stopAnimations(el: HTMLElement): Promise<void> {
171
+ await Promise.all(
172
+ el.getAnimations().map((animation) => {
173
+ return new Promise((resolve) => {
174
+ const handleAnimationEvent = () => requestAnimationFrame(resolve);
175
+
176
+ animation.addEventListener("cancel", () => handleAnimationEvent(), { once: true });
177
+ animation.addEventListener("finish", () => handleAnimationEvent(), { once: true });
178
+ animation.cancel();
179
+ });
180
+ }),
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Wait for any animations running on the element to complete.
186
+ */
187
+ export async function animationEnd(el: HTMLElement): Promise<void> {
188
+ await Promise.all(
189
+ el.getAnimations().map((animation) => {
190
+ return new Promise((resolve) => {
191
+ const handleAnimationEvent = () => requestAnimationFrame(resolve);
192
+ animation.addEventListener("cancel", () => handleAnimationEvent(), { once: true });
193
+ animation.addEventListener("finish", () => handleAnimationEvent(), { once: true });
194
+ });
195
+ }),
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Check if any animations are currently active on an element.
201
+ */
202
+ export function isAnimating(el: HTMLElement): boolean {
203
+ function findParent(el: HTMLElement): HTMLElement | undefined {
204
+ if (!isNullish(el.parentElement)) {
205
+ return el.parentElement;
206
+ }
207
+ if (el.parentNode instanceof ShadowRoot && el.parentNode.host instanceof HTMLElement) {
208
+ return el.parentNode.host;
209
+ }
210
+ return undefined;
211
+ }
212
+ const path = [el];
213
+ let current: HTMLElement | undefined = el;
214
+ while ((current = findParent(current))) {
215
+ path.push(current);
216
+ }
217
+ return path.some((item) => item.getAnimations().length > 0);
218
+ }
219
+
220
+ export function findEventTarget<T extends EventTarget>(
221
+ e: Event,
222
+ predicate: (target: EventTarget) => target is T,
223
+ ): T | null {
224
+ const path = e.composedPath();
225
+ while (true) {
226
+ const target = path.pop();
227
+ if (target === undefined) {
228
+ return null;
229
+ }
230
+ if (predicate(target)) {
231
+ return target;
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Dig through shadow roots to find the actual element that currently has focus.
238
+ * If nothing has focus in or below the given root, return undefined.
239
+ */
240
+ export function findActiveElement(
241
+ root: Document | ShadowRoot | null = document,
242
+ ): Element | undefined {
243
+ return activeElementPath(root)?.pop();
244
+ }
245
+
246
+ /**
247
+ * As {@link findActiveElement}, but returns a list of the active elements as
248
+ * seen by each {@link Document} and {@link ShadowRoot} down to the actually
249
+ * active element.
250
+ */
251
+ export function activeElementPath(
252
+ root: Document | ShadowRoot | null = document,
253
+ ): Array<Element> | undefined {
254
+ if (root === null) {
255
+ return undefined;
256
+ }
257
+ const path = [];
258
+ let currentRoot = root;
259
+ while (true) {
260
+ const element = currentRoot.activeElement;
261
+ if (element === null) {
262
+ return undefined;
263
+ }
264
+ path.push(element);
265
+ if (element.shadowRoot !== null && element.shadowRoot.activeElement !== null) {
266
+ currentRoot = element.shadowRoot;
267
+ } else {
268
+ return path;
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Find the first ancestor of the given `child` element matching the provided
275
+ * `predicate` function, walking up through shadow root boundaries.
276
+ */
277
+ export function findAncestor<T extends Element>(
278
+ child: Element | null | undefined,
279
+ predicate: (el: Element) => el is T,
280
+ ): T | undefined;
281
+ export function findAncestor(
282
+ child: Element | null | undefined,
283
+ predicate: (el: Element) => boolean,
284
+ ): Element | undefined;
285
+ export function findAncestor(
286
+ child: Element | null | undefined,
287
+ predicate: (el: Element) => boolean,
288
+ ): Element | undefined {
289
+ if (isNullish(child)) {
290
+ return undefined;
291
+ }
292
+ function nextParent(child: Element): Element | undefined {
293
+ const node = child.parentNode;
294
+ if (node instanceof Element) {
295
+ return node;
296
+ }
297
+ if (node instanceof ShadowRoot) {
298
+ return node.host;
299
+ }
300
+ return undefined;
301
+ }
302
+ let parent = nextParent(child);
303
+ while (parent !== undefined) {
304
+ if (predicate(parent)) {
305
+ return parent;
306
+ }
307
+ parent = nextParent(parent);
308
+ }
309
+ return undefined;
310
+ }
311
+
312
+ export function isEditor(element: Element | null | undefined): boolean {
313
+ if (isNullish(element)) {
314
+ return false;
315
+ }
316
+ return (
317
+ element instanceof HTMLInputElement ||
318
+ element instanceof HTMLTextAreaElement ||
319
+ element.getAttribute("contenteditable") === "true"
320
+ );
321
+ }
322
+
323
+ export type ScrollAlign = "start" | "end" | "center" | "fit";
324
+ export type ScrollToItemOptions = {
325
+ viewport: HTMLElement;
326
+ axis: "horizontal" | "vertical";
327
+ align: ScrollAlign;
328
+ behavior?: ScrollBehavior;
329
+ padStart?: Pixels;
330
+ padEnd?: Pixels;
331
+ };
332
+
333
+ const scrollToItemOptionsDefault: Required<
334
+ Pick<ScrollToItemOptions, OptionalKeysOf<ScrollToItemOptions>>
335
+ > = {
336
+ behavior: "auto",
337
+ padStart: 0,
338
+ padEnd: 0,
339
+ };
340
+
341
+ export function scrollToItem(el: HTMLElement, scrollToItemOptions: ScrollToItemOptions): void {
342
+ const options: Required<ScrollToItemOptions> = {
343
+ ...scrollToItemOptionsDefault,
344
+ ...scrollToItemOptions,
345
+ };
346
+
347
+ if (options.behavior === "auto") {
348
+ options.behavior = prefersReducedMotion() ? "instant" : "smooth";
349
+ }
350
+
351
+ const horiz = options.axis === "horizontal";
352
+ const getTop = horiz ? (rect: DOMRect) => rect.left : (rect: DOMRect) => rect.top;
353
+ const getBottom = horiz ? (rect: DOMRect) => rect.right : (rect: DOMRect) => rect.bottom;
354
+ const getHeight = horiz ? (rect: DOMRect) => rect.width : (rect: DOMRect) => rect.height;
355
+
356
+ const viewRect = options.viewport.getBoundingClientRect();
357
+ const childRect = el.getBoundingClientRect();
358
+ const childTop = getTop(childRect) - getTop(viewRect);
359
+ const align = options.align;
360
+ let top = 0;
361
+ if (align === "start" || getHeight(childRect) > getHeight(viewRect)) {
362
+ top = childTop - options.padStart;
363
+ } else if (align === "end") {
364
+ top = childTop + getHeight(childRect) - getHeight(viewRect) + options.padEnd;
365
+ } else if (align === "center") {
366
+ top = childTop + getHeight(childRect) / 2 - getHeight(viewRect) / 2;
367
+ } else if (align === "fit") {
368
+ if (contains(childRect, viewRect)) {
369
+ return;
370
+ }
371
+ if (getTop(childRect) < getTop(viewRect)) {
372
+ return scrollToItem(el, { ...options, align: "start" });
373
+ }
374
+ if (getBottom(childRect) > getBottom(viewRect)) {
375
+ return scrollToItem(el, { ...options, align: "end" });
376
+ }
377
+ return unreachable();
378
+ } else {
379
+ assertNever(align);
380
+ }
381
+ if (horiz) {
382
+ options.viewport.scroll({
383
+ left: top + options.viewport.scrollLeft,
384
+ behavior: options.behavior,
385
+ });
386
+ } else {
387
+ options.viewport.scroll({
388
+ top: top + options.viewport.scrollTop,
389
+ behavior: options.behavior,
390
+ });
391
+ }
392
+ }
393
+
394
+ // HasSlotController nicked largely verbatim from Shoelace
395
+ // https://github.com/shoelace-style/shoelace/blob/next/src/internal/slot.ts
396
+ export class HasSlotController implements ReactiveController {
397
+ host: ReactiveControllerHost & Element;
398
+ slotNames: Array<string> = [];
399
+
400
+ constructor(host: ReactiveControllerHost & Element, ...slotNames: Array<string>) {
401
+ (this.host = host).addController(this);
402
+ this.slotNames = slotNames;
403
+ }
404
+
405
+ private hasDefaultSlot() {
406
+ return [...this.host.childNodes].some((node) => {
407
+ if (node.nodeType === node.TEXT_NODE && node.textContent!.trim() !== "") {
408
+ return true;
409
+ }
410
+
411
+ if (node.nodeType === node.ELEMENT_NODE) {
412
+ const el = node as HTMLElement;
413
+ const tagName = el.tagName.toLowerCase();
414
+
415
+ // Ignore visually hidden elements since they aren't rendered
416
+ if (tagName === "sl-visually-hidden") {
417
+ return false;
418
+ }
419
+
420
+ // If it doesn't have a slot attribute, it's part of the default slot
421
+ if (!el.hasAttribute("slot")) {
422
+ return true;
423
+ }
424
+ }
425
+
426
+ return false;
427
+ });
428
+ }
429
+
430
+ private hasNamedSlot(name: string) {
431
+ return this.host.querySelector(`:scope > [slot="${name}"]`) !== null;
432
+ }
433
+
434
+ test(slotName: string) {
435
+ return slotName === "[default]" ? this.hasDefaultSlot() : this.hasNamedSlot(slotName);
436
+ }
437
+
438
+ hostConnected() {
439
+ this.host.shadowRoot!.addEventListener("slotchange", this.handleSlotChange);
440
+ }
441
+
442
+ hostDisconnected() {
443
+ this.host.shadowRoot!.removeEventListener("slotchange", this.handleSlotChange);
444
+ }
445
+
446
+ handleSlotChange = (event: Event) => {
447
+ const slot = event.target as HTMLSlotElement;
448
+
449
+ if (
450
+ (this.slotNames.includes("[default]") && !slot.name) ||
451
+ (slot.name && this.slotNames.includes(slot.name))
452
+ ) {
453
+ this.host.requestUpdate();
454
+ }
455
+ };
456
+ }
package/src/emitter.ts ADDED
@@ -0,0 +1,32 @@
1
+ export type CustomEventTypes<T extends keyof HTMLElementEventMap> = {
2
+ [Key in T]: HTMLElementEventMap[Key] extends CustomEvent<infer D> ? D : never;
3
+ };
4
+
5
+ export class EmitterElement extends HTMLElement {
6
+ emits!: { [K in keyof HTMLElementEventMap]?: never };
7
+
8
+ emit<
9
+ M extends CustomEventTypes<keyof this["emits"] & keyof HTMLElementEventMap>,
10
+ K extends Extract<keyof M, string>,
11
+ D extends M[K],
12
+ >(name: K, detail?: D, options?: EventInit): CustomEvent<D> {
13
+ const event = new CustomEvent(name, {
14
+ bubbles: true,
15
+ composed: true,
16
+ detail,
17
+ ...(options ?? {}),
18
+ });
19
+ this.dispatchEvent(event);
20
+ return event;
21
+ }
22
+
23
+ emitEvent<
24
+ K extends keyof this["emits"] & keyof HTMLElementEventMap,
25
+ E extends HTMLElementEventMap[K],
26
+ C extends new (type: string, ...args: Args) => E,
27
+ Args extends Array<any>,
28
+ >(name: K, constructor: C, ...args: Args) {
29
+ const event = new constructor(name, ...args);
30
+ this.dispatchEvent(event);
31
+ }
32
+ }
@@ -0,0 +1,22 @@
1
+ import { expect, expectTypeOf, test } from "vitest";
2
+
3
+ import { eventListener } from "./event";
4
+
5
+ test("eventListener", () => {
6
+ const button = document.createElement("button");
7
+ let clicked = 0;
8
+ const listener = eventListener(button, "click", (e) => {
9
+ expectTypeOf(e).toEqualTypeOf<PointerEvent>();
10
+ expect(e.target).toBe(button);
11
+ clicked++;
12
+ });
13
+ expect(clicked).toBe(0);
14
+ button.click();
15
+ expect(clicked).toBe(1);
16
+ listener[Symbol.dispose]();
17
+ button.click();
18
+ expect(clicked).toBe(1);
19
+
20
+ // @ts-expect-error: unknown event should be a type error
21
+ eventListener(button, "chaos with Ed Miliband", () => {})[Symbol.dispose]();
22
+ });
package/src/event.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Utility types for DOM events.
3
+ * @module
4
+ */
5
+
6
+ import { toDisposable } from "@bodil/core/disposable";
7
+ import type { Present } from "@bodil/core/types";
8
+
9
+ /**
10
+ * An interface a type can extend to associate a map of event names to event
11
+ * types with itself, in the style of `HTMLElementEventMap` et al. This allows
12
+ * the type system to discover the events available for a specific
13
+ * {@link EventTarget} descendant.
14
+ *
15
+ * If you need to implement this for a class you're defining, you need to add
16
+ * `__events!: MyEventMap` as a property on the class.
17
+ */
18
+ export interface DeclareEvents<Events> extends EventTarget {
19
+ __events: Events;
20
+ }
21
+
22
+ /**
23
+ * Get the `Events` type associated with a type that extends
24
+ * {@linkcode DeclareEvents}`<Events>`.
25
+ *
26
+ * Using it on a type that doesn't extend {@link DeclareEvents} results in a
27
+ * type error.
28
+ */
29
+ export type EventMapFor<T extends DeclareEvents<unknown>> = Present<T["__events"]>;
30
+
31
+ /**
32
+ * Get the `Events` type associated with a type that extends
33
+ * {@linkcode DeclareEvents}`<Events>`.
34
+ *
35
+ * A type that doesn't extend {@link DeclareEvents} resolves to an object which
36
+ * will accept all string keys.
37
+ */
38
+ export type ExtractEventMap<T> =
39
+ T extends DeclareEvents<infer Events> ? Events : { [key: string]: Event };
40
+
41
+ declare global {
42
+ interface AbortSignal extends DeclareEvents<AbortSignalEventMap> {}
43
+ interface Animation extends DeclareEvents<AnimationEventMap> {}
44
+ interface BroadcastChannel extends DeclareEvents<BroadcastChannelEventMap> {}
45
+ interface Document extends DeclareEvents<DocumentEventMap> {}
46
+ interface EventSource extends DeclareEvents<EventSourceEventMap> {}
47
+ interface HTMLBodyElement extends DeclareEvents<HTMLBodyElementEventMap> {}
48
+ interface HTMLElement extends DeclareEvents<HTMLElementEventMap> {}
49
+ interface MessagePort extends DeclareEvents<MessagePortEventMap> {}
50
+ interface ShadowRoot extends DeclareEvents<ShadowRootEventMap> {}
51
+ interface SVGElement extends DeclareEvents<SVGElementEventMap> {}
52
+ interface SVGSVGElement extends DeclareEvents<SVGSVGElementEventMap> {}
53
+ interface WebSocket extends DeclareEvents<WebSocketEventMap> {}
54
+ interface Window extends DeclareEvents<WindowEventMap> {}
55
+ interface Worker extends DeclareEvents<WorkerEventMap> {}
56
+ }
57
+
58
+ /**
59
+ * Attach an event listener to an {@link EventTarget} and return a
60
+ * {@link Disposable} which will remove the listener when disposed.
61
+ */
62
+ export function eventListener<
63
+ T extends EventTarget,
64
+ M extends ExtractEventMap<T>,
65
+ K extends keyof M & string,
66
+ >(
67
+ target: T,
68
+ type: K,
69
+ callback: (e: M[K]) => any,
70
+ options?: AddEventListenerOptions | boolean,
71
+ ): Disposable {
72
+ target.addEventListener(type, callback as EventListener, options);
73
+ return toDisposable(() => target.removeEventListener(type, callback as EventListener));
74
+ }