@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.
- package/LICENCE.md +288 -0
- package/dist/component.d.ts +147 -0
- package/dist/component.js +405 -0
- package/dist/component.js.map +1 -0
- package/dist/component.test.d.ts +1 -0
- package/dist/component.test.js +141 -0
- package/dist/component.test.js.map +1 -0
- package/dist/css.d.ts +14 -0
- package/dist/css.js +63 -0
- package/dist/css.js.map +1 -0
- package/dist/decorators/attribute.d.ts +26 -0
- package/dist/decorators/attribute.js +115 -0
- package/dist/decorators/attribute.js.map +1 -0
- package/dist/decorators/attribute.test.d.ts +1 -0
- package/dist/decorators/attribute.test.js +148 -0
- package/dist/decorators/attribute.test.js.map +1 -0
- package/dist/decorators/connect.d.ts +9 -0
- package/dist/decorators/connect.js +44 -0
- package/dist/decorators/connect.js.map +1 -0
- package/dist/decorators/connect.test.d.ts +1 -0
- package/dist/decorators/connect.test.js +196 -0
- package/dist/decorators/connect.test.js.map +1 -0
- package/dist/decorators/reactive.d.ts +7 -0
- package/dist/decorators/reactive.js +45 -0
- package/dist/decorators/reactive.js.map +1 -0
- package/dist/decorators/reactive.test.d.ts +1 -0
- package/dist/decorators/reactive.test.js +113 -0
- package/dist/decorators/reactive.test.js.map +1 -0
- package/dist/decorators/require.d.ts +3 -0
- package/dist/decorators/require.js +6 -0
- package/dist/decorators/require.js.map +1 -0
- package/dist/decorators/require.test.d.ts +1 -0
- package/dist/decorators/require.test.js +117 -0
- package/dist/decorators/require.test.js.map +1 -0
- package/dist/dom.d.ts +92 -0
- package/dist/dom.js +354 -0
- package/dist/dom.js.map +1 -0
- package/dist/emitter.d.ts +10 -0
- package/dist/emitter.js +17 -0
- package/dist/emitter.js.map +1 -0
- package/dist/event.d.ts +70 -0
- package/dist/event.js +14 -0
- package/dist/event.js.map +1 -0
- package/dist/event.test.d.ts +1 -0
- package/dist/event.test.js +20 -0
- package/dist/event.test.js.map +1 -0
- package/dist/geometry.d.ts +48 -0
- package/dist/geometry.js +117 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +54 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/slot.d.ts +31 -0
- package/dist/slot.js +67 -0
- package/dist/slot.js.map +1 -0
- package/dist/test-global.d.ts +0 -0
- package/dist/test-global.js +5 -0
- package/dist/test-global.js.map +1 -0
- package/package.json +116 -0
- package/src/component.test.ts +90 -0
- package/src/component.ts +607 -0
- package/src/css.ts +69 -0
- package/src/decorators/attribute.test.ts +77 -0
- package/src/decorators/attribute.ts +197 -0
- package/src/decorators/connect.test.ts +119 -0
- package/src/decorators/connect.ts +85 -0
- package/src/decorators/reactive.test.ts +45 -0
- package/src/decorators/reactive.ts +80 -0
- package/src/decorators/require.test.ts +57 -0
- package/src/decorators/require.ts +11 -0
- package/src/dom.ts +456 -0
- package/src/emitter.ts +32 -0
- package/src/event.test.ts +22 -0
- package/src/event.ts +74 -0
- package/src/geometry.ts +147 -0
- package/src/index.ts +12 -0
- package/src/scheduler.ts +58 -0
- package/src/slot.ts +95 -0
- 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
|
+
}
|