mirror of
https://github.com/zoriya/v10.git
synced 2026-08-13 09:30:38 +00:00
feat(store): lit bindings (#289)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,2 +1,3 @@
|
||||
export { composeCallbacks } from './compose-callbacks';
|
||||
export { noop } from './noop';
|
||||
export { tryCatch } from './try-catch';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function noop(): void {}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user