feat(store): lit bindings (#289)

This commit is contained in:
rahim
2026-01-06 22:26:09 +11:00
committed by GitHub
parent 36ea73f424
commit 648aae7e31
50 changed files with 3561 additions and 412 deletions
+3
View File
@@ -2,5 +2,8 @@ export { animationFrame } from './animation-frame';
export { onEvent, type OnEventOptions } from './event';
export { idleCallback } from './idle-callback';
export { listen } from './listen';
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
export { getSlottedElement, querySlot } from './slotted';
export { supportsAnimationFrame, supportsIdleCallback } from './supports';
export { serializeTimeRanges } from './time-ranges';
export type { CustomElement, CustomElementCallbacks } from './types';
+11
View File
@@ -0,0 +1,11 @@
export function isHTMLVideoElement(value: unknown): value is HTMLVideoElement {
return value instanceof HTMLVideoElement;
}
export function isHTMLAudioElement(value: unknown): value is HTMLAudioElement {
return value instanceof HTMLAudioElement;
}
export function isHTMLMediaElement(value: unknown): value is HTMLMediaElement {
return value instanceof HTMLMediaElement;
}
+42
View File
@@ -0,0 +1,42 @@
import type { Falsy } from '../types';
/**
* Finds the first element in a slot's assigned elements that matches a predicate.
*
* @param shadowRoot - The shadow root containing the slot
* @param slotName - The slot name to search (empty string for default slot)
* @param predicate - Function that returns the element if it matches, or falsy if not
* @returns The first matching element, or null if not found
*
* @example
* ```ts
* // Find a video element in the default slot
* const video = getSlottedElement(
* this.shadowRoot,
* '',
* el => el instanceof HTMLVideoElement,
* );
* ```
*/
export function getSlottedElement<T extends Element>(
shadowRoot: ShadowRoot,
slotName: string,
predicate: (el: Element) => Falsy<T>,
): T | null {
const slot = querySlot(shadowRoot, slotName);
if (!slot) return null;
for (const el of slot.assignedElements({ flatten: true })) {
const result = predicate(el);
if (result) return result;
}
return null;
}
/**
* Queries a slot element by name in a shadow root.
*/
export function querySlot(shadowRoot: ShadowRoot, name: string): HTMLSlotElement | null {
return shadowRoot.querySelector<HTMLSlotElement>(name ? `slot[name="${name}"]` : 'slot:not([name])');
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from '../predicates';
describe('DOM predicates', () => {
describe('isHTMLVideoElement', () => {
it('returns true for video elements', () => {
const video = document.createElement('video');
expect(isHTMLVideoElement(video)).toBe(true);
});
it('returns false for audio elements', () => {
const audio = document.createElement('audio');
expect(isHTMLVideoElement(audio)).toBe(false);
});
it('returns false for other elements', () => {
const div = document.createElement('div');
expect(isHTMLVideoElement(div)).toBe(false);
});
it('returns false for non-elements', () => {
expect(isHTMLVideoElement(null)).toBe(false);
expect(isHTMLVideoElement(undefined)).toBe(false);
expect(isHTMLVideoElement('video')).toBe(false);
expect(isHTMLVideoElement({})).toBe(false);
});
});
describe('isHTMLAudioElement', () => {
it('returns true for audio elements', () => {
const audio = document.createElement('audio');
expect(isHTMLAudioElement(audio)).toBe(true);
});
it('returns false for video elements', () => {
const video = document.createElement('video');
expect(isHTMLAudioElement(video)).toBe(false);
});
it('returns false for other elements', () => {
const div = document.createElement('div');
expect(isHTMLAudioElement(div)).toBe(false);
});
it('returns false for non-elements', () => {
expect(isHTMLAudioElement(null)).toBe(false);
expect(isHTMLAudioElement(undefined)).toBe(false);
expect(isHTMLAudioElement('audio')).toBe(false);
expect(isHTMLAudioElement({})).toBe(false);
});
});
describe('isHTMLMediaElement', () => {
it('returns true for video elements', () => {
const video = document.createElement('video');
expect(isHTMLMediaElement(video)).toBe(true);
});
it('returns true for audio elements', () => {
const audio = document.createElement('audio');
expect(isHTMLMediaElement(audio)).toBe(true);
});
it('returns false for other elements', () => {
const div = document.createElement('div');
expect(isHTMLMediaElement(div)).toBe(false);
});
it('returns false for non-elements', () => {
expect(isHTMLMediaElement(null)).toBe(false);
expect(isHTMLMediaElement(undefined)).toBe(false);
expect(isHTMLMediaElement('video')).toBe(false);
expect(isHTMLMediaElement({})).toBe(false);
});
});
});
@@ -0,0 +1,175 @@
import { afterEach, describe, expect, it } from 'vitest';
import { getSlottedElement } from '../slotted';
describe('getSlottedElement', () => {
let tagCounter = 0;
function uniqueTag(base: string): string {
return `${base}-${Date.now()}-${tagCounter++}`;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('default slot', () => {
function createHost(slotHtml = '<slot></slot>'): HTMLElement {
const tagName = uniqueTag('test-host');
class TestHost extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot!.innerHTML = slotHtml;
}
}
customElements.define(tagName, TestHost);
const host = document.createElement(tagName);
document.body.appendChild(host);
return host;
}
it('finds slotted element matching predicate', () => {
const host = createHost();
const video = document.createElement('video');
host.appendChild(video);
const result = getSlottedElement(host.shadowRoot!, '', el => (el instanceof HTMLVideoElement ? el : null));
expect(result).toBe(video);
});
it('returns first match when multiple elements exist', () => {
const host = createHost();
const video1 = document.createElement('video');
const video2 = document.createElement('video');
host.appendChild(video1);
host.appendChild(video2);
const result = getSlottedElement(host.shadowRoot!, '', el => (el instanceof HTMLVideoElement ? el : null));
expect(result).toBe(video1);
});
it('returns null when no match found', () => {
const host = createHost();
const span = document.createElement('span');
host.appendChild(span);
const result = getSlottedElement(host.shadowRoot!, '', el => (el instanceof HTMLVideoElement ? el : null));
expect(result).toBeNull();
});
it('returns null when slot is empty', () => {
const host = createHost();
const result = getSlottedElement(host.shadowRoot!, '', el => (el instanceof HTMLVideoElement ? el : null));
expect(result).toBeNull();
});
it('supports predicates returning false', () => {
const host = createHost();
const div = document.createElement('div');
host.appendChild(div);
const result = getSlottedElement(host.shadowRoot!, '', el => (el instanceof HTMLVideoElement ? el : false));
expect(result).toBeNull();
});
it('works with type guard predicates', () => {
const host = createHost();
const video = document.createElement('video');
host.appendChild(video);
const isMedia = (el: Element): el is HTMLMediaElement => el instanceof HTMLMediaElement;
const result = getSlottedElement(host.shadowRoot!, '', el => (isMedia(el) ? el : null));
expect(result).toBe(video);
expect(result?.play).toBeDefined();
});
});
describe('named slot', () => {
function createHostWithNamedSlot(): HTMLElement {
const tagName = uniqueTag('test-named');
class TestHost extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot!.innerHTML = '<slot name="media"></slot><slot></slot>';
}
}
customElements.define(tagName, TestHost);
const host = document.createElement(tagName);
document.body.appendChild(host);
return host;
}
it('finds element in named slot', () => {
const host = createHostWithNamedSlot();
const video = document.createElement('video');
video.slot = 'media';
host.appendChild(video);
const result = getSlottedElement(host.shadowRoot!, 'media', el => (el instanceof HTMLVideoElement ? el : null));
expect(result).toBe(video);
});
it('ignores elements in other slots', () => {
const host = createHostWithNamedSlot();
const video = document.createElement('video');
// No slot attribute - goes to default slot
host.appendChild(video);
const result = getSlottedElement(host.shadowRoot!, 'media', el => (el instanceof HTMLVideoElement ? el : null));
expect(result).toBeNull();
});
it('returns null when named slot does not exist', () => {
const host = createHostWithNamedSlot();
const video = document.createElement('video');
video.slot = 'nonexistent';
host.appendChild(video);
const result = getSlottedElement(host.shadowRoot!, 'nonexistent', el =>
el instanceof HTMLVideoElement ? el : null);
expect(result).toBeNull();
});
});
describe('no slot in shadow root', () => {
it('returns null when shadow root has no matching slot', () => {
const tagName = uniqueTag('test-no-slot');
class TestHost extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot!.innerHTML = '<div>No slot here</div>';
}
}
customElements.define(tagName, TestHost);
const host = document.createElement(tagName);
document.body.appendChild(host);
const video = document.createElement('video');
host.appendChild(video);
const result = getSlottedElement(host.shadowRoot!, '', el => (el instanceof HTMLVideoElement ? el : null));
expect(result).toBeNull();
});
});
});
+17
View File
@@ -0,0 +1,17 @@
/* eslint-disable ts/method-signature-style */
// Method syntax is required here for TypeScript's class inheritance checking.
// Using property syntax (e.g., `connectedCallback?: () => void`) causes TS2425
// when a class extends a generic mixin that defines lifecycle callbacks.
export interface CustomElementCallbacks {
connectedCallback?(): void;
disconnectedCallback?(): void;
adoptedCallback?(): void;
attributeChangedCallback?(name: string, oldValue: string | null, newValue: string | null): void;
}
/* eslint-enable ts/method-signature-style */
export interface CustomElement extends HTMLElement, CustomElementCallbacks {}
export interface CustomElementConstructor {
new (): CustomElement;
}
+1
View File
@@ -1,2 +1,3 @@
export { composeCallbacks } from './compose-callbacks';
export { noop } from './noop';
export { tryCatch } from './try-catch';
+1
View File
@@ -0,0 +1 @@
export function noop(): void {}
+14
View File
@@ -1 +1,15 @@
export type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
export type Constructor<T, Arguments extends unknown[] = any[]> = new (...args: Arguments) => T;
export type AbstractConstructor<T, Arguments extends unknown[] = any[]> = abstract new (...args: Arguments) => T;
export type AnyConstructor<T, Arguments extends unknown[] = any[]>
= | Constructor<T, Arguments>
| AbstractConstructor<T, Arguments>;
export type Mixin<Base, Result> = <T extends Constructor<Base>>(Base: T) => T & Constructor<Result>;
export type Falsy<T> = T | false | null | undefined;
export type EnsureFunction<T> = T extends (...args: any[]) => any ? T : never;