Add typed WebComponent mixin factory

WebComponent(spec) builds a custom-element base class from a spec:
shadow root or declarative shadow DOM, template, shared stylesheet
(adopted, or a <style> once per root for light DOM), reflected props
with two-way attribute sync, a per-connection AbortSignal, host event
wiring, and typed element refs.

Types derive exact observedAttributes, prop value types, ref element
types from selectors, and reject prop names that collide with the base
class or mixin members.

Tooling: strict tsconfig, type-aware oxlint, oxfmt, and Vitest browser
mode (Chromium) with type tests.
This commit is contained in:
Buddy 2026-11-06 00:08:56 -08:00
parent 29ed38fbc2
commit c961d01fc4
16 changed files with 4178 additions and 1 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 lib
Copyright (c) 2026 Buddy Sandidge
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -1,2 +1,50 @@
# wc-mixin
A typed mixin factory that removes custom-element boilerplate.
```ts
import { WebComponent, disable, enable, number, string, type PropChange } from 'wc-mixin';
const SPEC = {
shadow: 'open', // omit for light DOM
template: '<input class="name"><button>Go</button>',
css: ':host { display: block; }',
events: ['click'],
element: { input: 'input.name' }, // refs.input: HTMLInputElement | null
elements: { buttons: 'button' }, // refs.buttons: HTMLButtonElement[]
props: {
userId: number(), // user-id number | undefined
min: number({ default: 1 }), // min number
ready: enable({ attribute: 'is-ready' }), // is-ready boolean (default false)
fetch: disable(), // disable-fetch boolean (default true)
},
} as const;
class ExampleElement extends WebComponent(SPEC) {
override onConnect() {
window.addEventListener('resize', () => this.layout(), { signal: this.signal });
}
override handleEvent(event: PointerEvent) {}
override onPropChange(change: PropChange<typeof SPEC>) {
if (change.name === 'min') change.value; // number
}
layout() {}
}
customElements.define('example-element', ExampleElement);
ExampleElement.observedAttributes; // readonly ('user-id' | 'min' | 'is-ready' | 'disable-fetch')[]
```
- **Props** are stored as values and reflected to attributes; attribute changes decode back. `onPropChange` fires once per real change from either side.
- **Attribute names**: an explicit `attribute` is used verbatim; otherwise `kebab(key)`, with `disable-` for `disable()` and `data-` in front when `dataPrefix: true`.
- **Codecs**: `string`, `number`, `enable`, `disable`, `choice([...])`, `json<T>()`, `prop({ default, decode, encode })`.
- **Shadow DOM**: `shadow` attaches a root and clones `template` into it; `css` is a shared adopted stylesheet. An existing declarative shadow root is used instead (and `shadow`/`template` are ignored). `root` is the shadow root (even when closed) or the element itself.
- **Light DOM**: `template` is appended on first connect; `css` is added once per element type, as a `<style>` in `<head>` or adopted into the enclosing shadow root.
- **`signal`** aborts on disconnect; a new one is created on each connect. `events` are listened on the host with it.
- **`base`** extends another autonomous element class; prop names that collide with its members (or `HTMLElement`'s, like `title`/`hidden`) are type errors.
- Don't declare a subclass field with a prop's name; it would shadow the reflected accessor.
## Scripts
`npm run lint` (oxlint, type-aware), `npm run format` / `format:check` (oxfmt), `npm run typecheck`, `npm test` (Vitest in headless Chromium + type tests), `npm run build`.

9
oxfmt.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'oxfmt';
export default defineConfig({
semi: true,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
tabWidth: 2,
});

56
oxlint.config.ts Normal file
View File

@ -0,0 +1,56 @@
import { defineConfig } from 'oxlint';
export default defineConfig({
plugins: ['typescript', 'unicorn', 'oxc', 'import', 'promise'],
categories: {
correctness: 'error',
suspicious: 'error',
pedantic: 'error',
perf: 'error',
restriction: 'error',
},
rules: {
// Guards for code compiled down to pre-ES2018/2020; this targets ESNext.
'oxc/no-optional-chaining': 'off',
'oxc/no-rest-spread-properties': 'off',
'oxc/no-async-await': 'off',
// `void promise;` is how `no-floating-promises` says to ignore one on purpose.
'eslint/no-void': ['error', { allowAsStatement: true }],
// `undefined` is a real prop value (`userId: number | undefined`).
'eslint/no-undefined': 'off',
// Members are public by default and private via `#`; `public` is noise.
'typescript/explicit-member-accessibility': 'off',
// DOM types can't be deeply readonly, so every `Element` parameter fails.
'typescript/prefer-readonly-parameter-types': 'off',
// `function foo()` over `const foo = () => ...`. Inline callbacks
// (`.map((x) => ...)`) and arrows that use `this` aren't affected.
'eslint/func-style': ['error', 'declaration'],
// The mixin's hooks are deliberately empty methods for subclasses to override.
'eslint/class-methods-use-this': [
'error',
{
exceptMethods: ['handleEvent', 'onConnect', 'onDisconnect', 'onPropChange'],
ignoreOverrideMethods: true,
},
],
},
overrides: [
{
files: ['test/**/*.ts'],
plugins: ['typescript', 'unicorn', 'oxc', 'import', 'promise', 'vitest'],
rules: {
// Tests import the source under test from `../src`.
'import/no-relative-parent-imports': 'off',
// `describe` callbacks are long, and each test of a class factory
// needs its own small class.
'eslint/max-lines-per-function': 'off',
'eslint/max-classes-per-file': 'off',
},
},
],
env: {
builtin: true,
browser: true,
},
ignorePatterns: ['dist/'],
});

2688
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "wc-mixin",
"version": "0.1.0",
"description": "Web Component Mixin",
"license": "MIT",
"author": "Buddy Sandidge",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"dist",
"src",
"README.md",
"LICENSE"
],
"scripts": {
"build": "rm -rf dist && tsc",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "oxlint --type-aware src test",
"format": "oxfmt '*.md' '*.ts' 'src/**/*.ts' 'test/**/*.ts'",
"format:check": "oxfmt --check '*.md' '*.ts' 'src/**/*.ts' 'test/**/*.ts'",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run format:check && npm run lint && npm run typecheck && npm run test && npm run build"
},
"devDependencies": {
"@vitest/browser-playwright": "^5.1.0",
"oxfmt": "^0.76.0",
"oxlint": "^1.91.0",
"oxlint-tsgolint": "^7.0.2006",
"playwright": "^1.65.0",
"typescript": "^7.0.2",
"vitest": "^5.1.0"
}
}

36
src/index.ts Normal file
View File

@ -0,0 +1,36 @@
export { WebComponent } from './web-component.ts';
export {
attributeName,
choice,
disable,
enable,
json,
kebab,
number,
prop,
string,
type AnyPropDef,
type BooleanOptions,
type Kebab,
type PropDef,
type PropOptions,
} from './props.ts';
export type {
AttributeMap,
AttributeOf,
AttributesOf,
ElementConstructor,
ObservedAttributes,
PropChange,
PropNameConflict,
PropValues,
RefType,
Refs,
Spec,
SpecOf,
ValidateSpec,
ValueOf,
WebComponentConstructor,
WebComponentInstance,
WebComponentMembers,
} from './types.ts';

221
src/props.ts Normal file
View File

@ -0,0 +1,221 @@
/**
* Describes one reflected property: how its value decodes from and encodes to
* an HTML attribute, and what it is when the attribute is absent.
* {@link WebComponent} is the only consumer — a spec just declares a table of
* these via the factory functions below.
*
* @typeParam T - the decoded property value type
* @typeParam A - an explicit attribute name, or `undefined` to derive one
* @typeParam P - the prefix put in front of a derived attribute name (`disable-`)
*/
export interface PropDef<
T = unknown,
A extends string | undefined = string | undefined,
P extends string = string,
> {
/** Explicit attribute name, used verbatim (no `prefix`, no `data-`). `undefined` derives it from the property name. */
readonly attribute: A;
/** Prepended to the kebab-cased property name when `attribute` is derived. */
readonly prefix: P;
/** The value when the attribute is absent. */
readonly default: T;
/** Parse the raw attribute string into the value. */
decode(raw: string, el: HTMLElement): T;
/** Serialise the value to an attribute string; `null` removes the attribute. */
encode(value: T, el: HTMLElement): string | null;
}
/** Any {@link PropDef}, for constraints. */
// oxlint-disable-next-line no-explicit-any -- encode/decode make PropDef invariant in T
export type AnyPropDef = PropDef<any>;
/** Options every factory accepts. `const`-inferred so `attribute` keeps its literal type. */
export interface PropOptions<T> {
readonly attribute?: string;
readonly default?: T;
}
/** Options for {@link enable} / {@link disable}, whose defaults are fixed. */
export type BooleanOptions = Omit<PropOptions<boolean>, 'default'>;
/** The explicit attribute literal out of a factory's options, or `undefined`. */
export type AttributeOption<O> = O extends { attribute: infer A extends string } ? A : undefined;
/** `T` when the options carry a (non-`undefined`) default, `T | undefined` otherwise. */
export type DefaultedValue<T, O> = O extends { default: infer D }
? undefined extends D
? T | undefined
: T
: T | undefined;
/**
* `fooBar` → `foo-bar`. The derived attribute name for a property. Mirrors
* the {@link Kebab} type exactly (any character that changes under
* `toLowerCase()` gets a dash), so runtime names and typed names never drift.
*/
export function kebab(name: string): string {
let out = '';
for (const c of name) {
const lower = c.toLowerCase();
out += c === lower ? c : `-${lower}`;
}
return out;
}
/** The type-level twin of {@link kebab}. */
export type Kebab<S extends string> = S extends `${infer H}${infer R}`
? `${H extends Lowercase<H> ? H : `-${Lowercase<H>}`}${Kebab<R>}`
: S;
/** Resolves a prop's attribute name: explicit `attribute` verbatim, else `[data-]` + `prefix` + `kebab(key)`. */
export function attributeName(key: string, def: AnyPropDef, dataPrefix = false): string {
if (def.attribute !== undefined) return def.attribute;
return `${dataPrefix ? 'data-' : ''}${def.prefix}${kebab(key)}`;
}
// Builds a def with the codec checked against the plain value type `T`, then
// hands it back as the factory's precise (conditional) return type `R`, which
// TypeScript can't relate to `T` inside a generic body. `R` exists only to be
// asserted to, which is the point of this helper.
// oxlint-disable-next-line no-unnecessary-type-parameters
function define<R extends AnyPropDef, T = unknown>(
opts: PropOptions<T> | undefined,
prefix: string,
fallback: T,
decode: (raw: string) => T,
encode: (value: T) => string | null,
): R {
const def: PropDef<T> = {
attribute: opts?.attribute,
prefix,
default: opts?.default ?? fallback,
decode,
encode,
};
// oxlint-disable-next-line no-unsafe-type-assertion
return def as R;
}
/** A string attribute. `undefined` (no default) removes the attribute. */
export function string<const O extends PropOptions<string> = PropOptions<string>>(
opts?: O,
): PropDef<DefaultedValue<string, O>, AttributeOption<O>, ''> {
return define<PropDef<DefaultedValue<string, O>, AttributeOption<O>, ''>, string | undefined>(
opts,
'',
undefined,
(raw) => raw,
(value) => value ?? null,
);
}
/** A numeric attribute: `Number` on the way in, `String` on the way out. */
export function number<const O extends PropOptions<number> = PropOptions<number>>(
opts?: O,
): PropDef<DefaultedValue<number, O>, AttributeOption<O>, ''> {
return define<PropDef<DefaultedValue<number, O>, AttributeOption<O>, ''>, number | undefined>(
opts,
'',
undefined,
Number,
(value) => (value === undefined ? null : String(value)),
);
}
/**
* A boolean attribute that is on when present: `<x-foo is-ready>` →
* `ready === true`. `="false"` also reads as `false`. Setting `false` removes
* the attribute.
*/
export function enable<const O extends BooleanOptions = BooleanOptions>(
opts?: O,
): PropDef<boolean, AttributeOption<O>, ''> {
return define<PropDef<boolean, AttributeOption<O>, ''>, boolean>(
opts,
'',
false,
(raw) => raw !== 'false',
(value) => (value ? '' : null),
);
}
/**
* The inverse of {@link enable}: `<x-foo disable-fetch>` → `fetch === false`.
* Defaults to `true`; the derived attribute name gets a `disable-` prefix.
* `="false"` reads as `true`.
*/
export function disable<const O extends BooleanOptions = BooleanOptions>(
opts?: O,
): PropDef<boolean, AttributeOption<O>, 'disable-'> {
return define<PropDef<boolean, AttributeOption<O>, 'disable-'>, boolean>(
opts,
'disable-',
true,
(raw) => raw === 'false',
(value) => (value ? null : ''),
);
}
/**
* A string attribute restricted to `values`. An attribute outside the list
* decodes to the default.
*/
export function choice<
const V extends readonly string[],
const O extends PropOptions<V[number]> = PropOptions<V[number]>,
>(values: V, opts?: O): PropDef<DefaultedValue<V[number], O>, AttributeOption<O>, ''> {
const fallback = opts?.default;
return define<
PropDef<DefaultedValue<V[number], O>, AttributeOption<O>, ''>,
V[number] | undefined
>(
opts,
'',
undefined,
(raw) => (values.includes(raw) ? raw : fallback),
(value) => value ?? null,
);
}
/**
* A JSON attribute (`JSON.parse` / `JSON.stringify`). Pass `T` explicitly; the
* value is `T` when a `default` is given and `T | undefined` otherwise.
*/
export function json<T, const A extends string | undefined = undefined>(opts: {
readonly attribute?: A;
readonly default: T;
}): PropDef<T, A, ''>;
export function json<T, const A extends string | undefined = undefined>(opts?: {
readonly attribute?: A;
}): PropDef<T | undefined, A, ''>;
export function json<T>(opts?: PropOptions<T>): AnyPropDef {
return define<AnyPropDef, T | undefined>(
opts,
'',
undefined,
// oxlint-disable-next-line no-unsafe-type-assertion -- JSON is untyped by nature
(raw) => JSON.parse(raw) as T,
(value) => (value === undefined ? null : JSON.stringify(value)),
);
}
/** A prop with a custom codec. `attribute` and `prefix` are optional. */
export function prop<
T,
const A extends string | undefined = undefined,
const P extends string = '',
>(
def: Omit<PropDef<T, A, P>, 'attribute' | 'prefix'> & {
readonly attribute?: A;
readonly prefix?: P;
},
): PropDef<T, A, P> {
// An omitted `attribute` / `prefix` means `A` / `P` took their defaults
// (`undefined` / `''`), which is exactly what's filled in here.
// oxlint-disable-next-line no-unsafe-type-assertion
return { ...def, attribute: def.attribute as A, prefix: (def.prefix ?? '') as P };
}

221
src/types.ts Normal file
View File

@ -0,0 +1,221 @@
import type { AnyPropDef, Kebab } from './props.ts';
/** A constructor {@link WebComponent} can extend: `HTMLElement` or any autonomous subclass of it. */
// oxlint-disable-next-line no-explicit-any -- the mixin pattern requires an any-args constructor
export type ElementConstructor = new (...args: any[]) => HTMLElement;
/** The spec passed to {@link WebComponent}. Every field is optional. */
export interface Spec {
/** The class to extend (typing and runtime). Defaults to `HTMLElement`. Must be autonomous-safe — not `HTMLInputElement` etc. */
readonly base?: ElementConstructor;
/** Attach a shadow root of this mode. Unset → light DOM. Ignored when a declarative shadow root already exists. */
readonly shadow?: 'open' | 'closed';
/** Cloned into the shadow root, or appended to the light DOM on first connect. Ignored with declarative shadow DOM. */
readonly template?: string | HTMLTemplateElement;
/** Adopted into the shadow root, or added once per root (document `<head>` / enclosing shadow root) for light DOM. */
readonly css?: string;
/** Prefix every *derived* attribute name with `data-`. Explicit `attribute`s are used verbatim. */
readonly dataPrefix?: boolean;
/** Host events routed to `handleEvent`, listened with the element's `signal`. */
readonly events?: readonly (keyof HTMLElementEventMap)[];
/** `refs.<name>`: `root.querySelector(selector)`. */
readonly element?: Readonly<Record<string, string>>;
/** `refs.<name>`: `[...root.querySelectorAll(selector)]`. */
readonly elements?: Readonly<Record<string, string>>;
/** Reflected properties, keyed by property name. */
readonly props?: Readonly<Record<string, AnyPropDef>>;
}
// ---------------------------------------------------------------------------
// Pulling pieces out of a spec
/** No props / refs given: contributes no members. Empty on purpose. */
// oxlint-disable-next-line no-generated-empty-object-type
type Empty = Record<never, never>;
export type PropsOf<S> = S extends {
readonly props: infer P extends Readonly<Record<string, AnyPropDef>>;
}
? P
: Empty;
export type BaseOf<S> = S extends { readonly base: infer B extends ElementConstructor }
? B
: typeof HTMLElement;
export type DataPrefixOf<S> = S extends { readonly dataPrefix: true } ? true : false;
export type EventsOf<S> = S extends {
readonly events: readonly (infer E extends keyof HTMLElementEventMap)[];
}
? E
: never;
type ElementRefsOf<S> = S extends {
readonly element: infer E extends Readonly<Record<string, string>>;
}
? E
: Empty;
type ElementsRefsOf<S> = S extends {
readonly elements: infer E extends Readonly<Record<string, string>>;
}
? E
: Empty;
// ---------------------------------------------------------------------------
// Props and attributes
/** The decoded value type of one prop. */
export type ValueOf<D> = D extends { decode(raw: string, el: HTMLElement): infer T } ? T : never;
/** The element's property shape for a props table: `{ min: number; userId: number | undefined; ... }`. */
export type PropValues<P> = { -readonly [K in keyof P]: ValueOf<P[K]> };
/** The exact attribute name of one prop, computed the same way as the runtime `attributeName`. */
export type AttributeOf<K extends string, D, DataPrefix extends boolean = false> = D extends {
readonly attribute: infer A extends string;
}
? A
: `${DataPrefix extends true ? 'data-' : ''}${D extends { readonly prefix: infer P extends string } ? P : ''}${Kebab<K>}`;
/** Union of every observed attribute name for a spec. */
export type ObservedAttributes<S> = {
[K in keyof PropsOf<S> & string]: AttributeOf<K, PropsOf<S>[K], DataPrefixOf<S>>;
}[keyof PropsOf<S> & string];
/** Prop key → attribute name, for a spec. */
export type AttributeMap<S> = {
readonly [K in keyof PropsOf<S> & string]: AttributeOf<K, PropsOf<S>[K], DataPrefixOf<S>>;
};
/** What `onPropChange` receives: a union discriminated on `name`, so `switch (change.name)` narrows `value`. */
export type PropChange<T> = {
[K in keyof PropValues<PropsOf<SpecOf<T>>>]: {
readonly name: K;
readonly value: PropValues<PropsOf<SpecOf<T>>>[K];
readonly oldValue: PropValues<PropsOf<SpecOf<T>>>[K];
};
}[keyof PropValues<PropsOf<SpecOf<T>>>];
// ---------------------------------------------------------------------------
// Refs
type LastCompound<S extends string> = S extends `${string} ${infer R}`
? LastCompound<R>
: S extends `${string}>${infer R}`
? LastCompound<R>
: S extends `${string}+${infer R}`
? LastCompound<R>
: S extends `${string}~${infer R}`
? LastCompound<R>
: S;
type TagName<S extends string> = S extends `${infer T}.${string}`
? TagName<T>
: S extends `${infer T}#${string}`
? TagName<T>
: S extends `${infer T}[${string}`
? TagName<T>
: S extends `${infer T}:${string}`
? TagName<T>
: S;
/**
* The element type a selector matches, read off the tag of its last compound
* selector (`'form input.name'` → `HTMLInputElement`). Falls back to `Element`
* when there is no tag (`'.name'`) or the tag is unknown. Custom elements added
* to `HTMLElementTagNameMap` resolve too.
*/
export type RefType<Sel extends string> =
Lowercase<TagName<LastCompound<Sel>>> extends infer T
? T extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[T]
: T extends keyof SVGElementTagNameMap
? SVGElementTagNameMap[T]
: Element
: never;
/** `this.refs` for a spec. */
export type Refs<S> = {
readonly [K in keyof ElementRefsOf<S>]: RefType<ElementRefsOf<S>[K] & string> | null;
} & {
readonly [K in keyof ElementsRefsOf<S>]: RefType<ElementsRefsOf<S>[K] & string>[];
};
// ---------------------------------------------------------------------------
// The generated class
/** The members {@link WebComponent} adds on top of the base class and the prop accessors. */
export interface WebComponentMembers<S> {
/** Aborted on disconnect; a fresh one is made on every connect. Pass it to `addEventListener` and `fetch`. */
readonly signal: AbortSignal;
/** Where the template lives and refs are queried: the shadow root, or the element itself for light DOM. */
readonly root: S extends { readonly shadow: 'open' | 'closed' }
? ShadowRoot
: ShadowRoot | HTMLElement;
/** The element's `ElementInternals` (attached by the mixin; don't call `attachInternals()` again). */
readonly internals: ElementInternals;
/** Typed `element` / `elements` lookups, queried live against `root`. */
readonly refs: Refs<S>;
/** Receives every event listed in `events`. */
handleEvent(
event: [EventsOf<S>] extends [never] ? Event : HTMLElementEventMap[EventsOf<S>],
): void;
/** Called at the end of `connectedCallback`. */
onConnect(): void;
/** Called at the end of `disconnectedCallback`, after `signal` aborts. */
onDisconnect(): void;
/** Called once per actual value change, whether it came from the property or the attribute. */
onPropChange(change: PropChange<S>): void;
connectedCallback(): void;
disconnectedCallback(): void;
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
}
/** Type-only key carrying the spec on the generated constructor, so helpers can recover it from `typeof MyElement`. */
declare const spec: unique symbol;
/** The instance type {@link WebComponent} produces. */
export type WebComponentInstance<S> = InstanceType<BaseOf<S>> &
PropValues<PropsOf<S>> &
WebComponentMembers<S>;
/** The constructor type {@link WebComponent} returns. */
export type WebComponentConstructor<S> = Omit<BaseOf<S>, 'prototype'> & {
new (): WebComponentInstance<S>;
readonly prototype: WebComponentInstance<S>;
readonly observedAttributes: readonly ObservedAttributes<S>[];
/** Prop key → attribute name. */
readonly attributes: AttributeMap<S>;
readonly [spec]?: S;
};
/** The spec behind a {@link WebComponent} class (or subclass), or `T` itself when it is already a spec. */
export type SpecOf<T> = T extends abstract new (...args: never) => unknown
? T extends { readonly [spec]?: infer S }
? S
: never
: T;
/** The observed attribute names of a {@link WebComponent} class or spec. */
export type AttributesOf<T> = ObservedAttributes<SpecOf<T>>;
// ---------------------------------------------------------------------------
// Validation
/** Mixin members a prop may not shadow. */
type MixinMember = keyof WebComponentMembers<unknown>;
/** Branded error type: shows up in the message when a prop name collides. */
export type PropNameConflict<K extends PropertyKey> =
`Error: prop "${K & string}" conflicts with an existing element member`;
/**
* Intersected with the spec parameter. Any prop whose name already exists on
* the base instance (`name`, `title`, `hidden` on `HTMLElement`; `value` on a
* form-ish base) or on the mixin itself is retyped to {@link PropNameConflict},
* so the error lands on that key.
*/
export type ValidateSpec<S> = {
readonly props?: {
readonly [K in keyof PropsOf<S>]: K extends keyof InstanceType<BaseOf<S>> | MixinMember
? PropNameConflict<K>
: PropsOf<S>[K];
};
};

253
src/web-component.ts Normal file
View File

@ -0,0 +1,253 @@
import { attributeName, type AnyPropDef } from './props.ts';
import type { Spec, ValidateSpec, WebComponentConstructor } from './types.ts';
interface ResolvedProp {
key: string;
attribute: string;
def: AnyPropDef;
}
/**
* Mixin factory: builds a custom-element base class from a {@link Spec}.
*
* The generated class owns the boilerplate — shadow root (or declarative
* shadow DOM), template, styles, `observedAttributes`, a two-way reflected
* accessor per prop, a per-connection `AbortSignal`, host event wiring, and
* typed element refs. A subclass overrides `onConnect` / `onDisconnect` /
* `onPropChange` / `handleEvent` as needed.
*
* Don't declare class fields with the same name as a prop in a subclass — a
* field is defined on the instance and shadows the reflected accessor.
*/
// The mixin is necessarily one closure around the generated class.
// oxlint-disable-next-line max-lines-per-function
export function WebComponent<const S extends Spec>(
spec: S & ValidateSpec<S>,
): WebComponentConstructor<S> {
const Base = spec.base ?? HTMLElement;
const resolved: ResolvedProp[] = Object.entries(spec.props ?? {}).map(([key, def]) => ({
key,
attribute: attributeName(key, def, spec.dataPrefix),
def,
}));
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
const element: [name: string, selector: string][] = Object.entries(spec.element ?? {});
const elements: [name: string, selector: string][] = Object.entries(spec.elements ?? {});
const events = spec.events ?? [];
// Parsed lazily, once per class, so defining the class needs no DOM work.
let template: HTMLTemplateElement | undefined;
function getTemplate(): HTMLTemplateElement | undefined {
if (spec.template === undefined || typeof spec.template !== 'string') return spec.template;
if (!template) {
template = document.createElement('template');
template.innerHTML = spec.template;
}
return template;
}
let sheet: CSSStyleSheet | undefined;
function getSheet(css: string): CSSStyleSheet {
if (!sheet) {
sheet = new CSSStyleSheet();
sheet.replaceSync(css);
}
return sheet;
}
// Light-DOM styles are added once per root (document or enclosing shadow
// root) for this class, not once per element.
const styledRoots = new WeakSet<Node>();
class WebComponentElement extends Base {
static readonly observedAttributes = resolved.map((prop) => prop.attribute);
static readonly attributes = Object.fromEntries(
resolved.map((prop) => [prop.key, prop.attribute]),
);
static {
for (const prop of resolved) {
Object.defineProperty(this.prototype, prop.key, {
configurable: true,
enumerable: true,
get(this: WebComponentElement) {
return this.#values.get(prop.key);
},
set(this: WebComponentElement, value: unknown) {
this.#write(prop, value);
},
});
}
}
readonly internals: ElementInternals;
readonly #root: ShadowRoot | null;
#values = new Map<string, unknown>(resolved.map((prop) => [prop.key, prop.def.default]));
#controller = new AbortController();
#refs?: object;
#upgraded = false;
#rendered = false;
// The attribute currently being written by a prop setter, so the
// attributeChangedCallback it triggers doesn't decode the value straight
// back. This is the only place a prop/attribute cycle could form.
#syncing: string | null = null;
// TypeScript requires a mixin constructor to take `...args: any[]`.
// oxlint-disable-next-line no-explicit-any, no-unsafe-argument
constructor(...args: any[]) {
// oxlint-disable-next-line no-unsafe-argument
super(...args);
this.internals = this.attachInternals();
// A declarative shadow root (open or closed) was attached by the parser
// before upgrade: use it as-is and skip `shadow` / `template`.
let root = this.internals.shadowRoot;
if (!root && spec.shadow) {
root = this.attachShadow({ mode: spec.shadow });
const content = getTemplate()?.content;
if (content) root.append(content.cloneNode(true));
}
this.#root = root;
if (root && spec.css !== undefined)
root.adoptedStyleSheets = [...root.adoptedStyleSheets, getSheet(spec.css)];
}
get signal(): AbortSignal {
return this.#controller.signal;
}
get root(): ShadowRoot | HTMLElement {
return this.#root ?? this;
}
get refs(): object {
return (this.#refs ??= this.#buildRefs());
}
connectedCallback(): void {
if (this.#controller.signal.aborted) this.#controller = new AbortController();
if (!this.#upgraded) {
this.#upgraded = true;
this.#upgradeProperties();
}
if (!this.#root) this.#renderLightDom();
for (const type of events) {
this.addEventListener(type, this, { signal: this.signal });
}
this.onConnect();
}
disconnectedCallback(): void {
this.#controller.abort();
this.onDisconnect();
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (oldValue === newValue || name === this.#syncing) return;
const prop = byAttribute.get(name);
if (!prop) return;
const value: unknown = newValue === null ? prop.def.default : prop.def.decode(newValue, this);
this.#store(prop, value);
}
handleEvent(_event: Event): void {
// Overridden by subclasses that list `events`.
}
onConnect(): void {
// Overridden by subclasses.
}
onDisconnect(): void {
// Overridden by subclasses.
}
onPropChange(_change: unknown): void {
// Overridden by subclasses.
}
// Property → value + attribute.
#write(prop: ResolvedProp, value: unknown): void {
if (!this.#store(prop, value)) return;
const raw = prop.def.encode(value, this);
if (this.getAttribute(prop.attribute) === raw) return;
this.#syncing = prop.attribute;
try {
if (raw === null) this.removeAttribute(prop.attribute);
else this.setAttribute(prop.attribute, raw);
} finally {
this.#syncing = null;
}
}
// Records a value and notifies; returns whether it actually changed.
#store(prop: ResolvedProp, value: unknown): boolean {
const oldValue = this.#values.get(prop.key);
if (Object.is(oldValue, value)) return false;
this.#values.set(prop.key, value);
this.onPropChange({ name: prop.key, value, oldValue });
return true;
}
// A property set on the element before its class was defined is an own
// data property shadowing the accessor; move it through the accessor.
// Done on first connect rather than in the constructor, where subclass
// fields (and so `onPropChange`) aren't initialized yet.
#upgradeProperties(): void {
for (const prop of resolved) {
if (!Object.hasOwn(this, prop.key)) continue;
const value: unknown = Reflect.get(this, prop.key);
Reflect.deleteProperty(this, prop.key);
this.#write(prop, value);
}
}
#renderLightDom(): void {
const content = getTemplate()?.content;
if (content && !this.#rendered) {
this.#rendered = true;
this.append(content.cloneNode(true));
}
const { css } = spec;
if (css === undefined) return;
const rootNode = this.getRootNode();
if (styledRoots.has(rootNode)) return;
styledRoots.add(rootNode);
if (rootNode instanceof ShadowRoot) {
rootNode.adoptedStyleSheets = [...rootNode.adoptedStyleSheets, getSheet(css)];
} else {
const style = document.createElement('style');
style.dataset['wcMixin'] = this.localName;
style.textContent = css;
document.head.append(style);
}
}
#buildRefs(): object {
// Getter return types are annotated: `PropertyDescriptor.get` returns
// `any`, which the query methods would otherwise infer their result from.
const refs = {};
for (const [name, selector] of element) {
Object.defineProperty(refs, name, {
enumerable: true,
get: (): Element | null => this.root.querySelector(selector),
});
}
for (const [name, selector] of elements) {
Object.defineProperty(refs, name, {
enumerable: true,
get: (): Element[] => [...this.root.querySelectorAll(selector)],
});
}
return refs;
}
}
// The runtime class can't express the spec-derived accessors and members;
// this is the one place the generated type is attached.
// oxlint-disable-next-line no-unsafe-type-assertion
return WebComponentElement as unknown as WebComponentConstructor<S>;
}

74
test/example.test.ts Normal file
View File

@ -0,0 +1,74 @@
import { expect, test } from 'vitest';
import { WebComponent, disable, enable, number, string } from '../src/index.ts';
const SPEC = {
props: {
userId: number(),
userName: string(),
min: number({ default: 1 }),
max: number({ default: 10 }),
ready: enable({ attribute: 'is-ready' }),
fetch: disable(),
},
} as const;
class ExampleElement extends WebComponent(SPEC) {}
customElements.define('example-element', ExampleElement);
function attributes(el: Element): Record<string, string> {
return Object.fromEntries([...el.attributes].map((a) => [a.name, a.value]));
}
test('observedAttributes', () => {
expect(ExampleElement.observedAttributes).toEqual([
'user-id',
'user-name',
'min',
'max',
'is-ready',
'disable-fetch',
]);
});
test('defaults', () => {
const el = new ExampleElement();
expect(el.max).toBe(10);
expect(el.min).toBe(1);
expect(el.fetch).toBe(true);
expect(el.ready).toBe(false);
expect(el.userId).toBeUndefined();
expect(el.userName).toBeUndefined();
expect(el.attributes).toHaveLength(0);
});
test('props reflect to attributes and attributes back to props', () => {
const el = new ExampleElement();
el.max = 9;
el.min = 2;
el.fetch = false;
el.ready = true;
el.userId = 123;
expect(attributes(el)).toEqual({
max: '9',
min: '2',
'disable-fetch': '',
'is-ready': '',
'user-id': '123',
});
el.removeAttribute('disable-fetch');
el.removeAttribute('is-ready');
el.setAttribute('max', '100');
el.setAttribute('min', '10');
el.setAttribute('user-id', '300');
expect(el.fetch).toBe(true);
expect(el.max).toBe(100);
expect(el.min).toBe(10);
expect(el.ready).toBe(false);
expect(el.userId).toBe(300);
expect(attributes(el)).toEqual({ max: '100', min: '10', 'user-id': '300' });
el.userId = undefined;
expect(el.hasAttribute('user-id')).toBe(false);
});

173
test/types.test-d.ts Normal file
View File

@ -0,0 +1,173 @@
import { describe, expectTypeOf, test } from 'vitest';
import {
WebComponent,
choice,
disable,
enable,
json,
number,
string,
type AttributesOf,
type Kebab,
type PropChange,
type RefType,
} from '../src/index.ts';
const SPEC = {
shadow: 'open',
events: ['click', 'keydown'],
element: { input: 'input.name', go: 'form > button#go', thing: '.thing' },
elements: { items: 'ul li' },
props: {
userId: number(),
userName: string(),
min: number({ default: 1 }),
max: number({ default: 10 }),
ready: enable({ attribute: 'is-ready' }),
fetch: disable(),
size: choice(['s', 'm', 'l'], { default: 'm' }),
points: json<[number, number][]>({ default: [] }),
},
} as const;
class Example extends WebComponent(SPEC) {}
describe('kebab', () => {
test('mirrors the runtime conversion', () => {
expectTypeOf<Kebab<'userId'>>().toEqualTypeOf<'user-id'>();
expectTypeOf<Kebab<'aBC'>>().toEqualTypeOf<'a-b-c'>();
expectTypeOf<Kebab<'x2y'>>().toEqualTypeOf<'x2y'>();
});
});
describe('attributes', () => {
test('observedAttributes is the exact union', () => {
type Expected =
| 'user-id'
| 'user-name'
| 'min'
| 'max'
| 'is-ready'
| 'disable-fetch'
| 'size'
| 'points';
expectTypeOf(Example.observedAttributes).toEqualTypeOf<readonly Expected[]>();
expectTypeOf<AttributesOf<typeof Example>>().toEqualTypeOf<Expected>();
expectTypeOf<AttributesOf<typeof SPEC>>().toEqualTypeOf<Expected>();
});
test('attributes maps each key to its attribute', () => {
expectTypeOf(Example.attributes.fetch).toEqualTypeOf<'disable-fetch'>();
expectTypeOf(Example.attributes.ready).toEqualTypeOf<'is-ready'>();
});
test('dataPrefix applies only to derived names', () => {
const Base = WebComponent({
dataPrefix: true,
props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) },
});
expectTypeOf<AttributesOf<typeof Base>>().toEqualTypeOf<
'data-user-id' | 'data-disable-fetch' | 'is-ready'
>();
});
test('inline specs infer without `as const`', () => {
const Base = WebComponent({ props: { fooBar: string({ attribute: 'fb' }), bazQux: number() } });
expectTypeOf<AttributesOf<typeof Base>>().toEqualTypeOf<'fb' | 'baz-qux'>();
});
});
describe('prop values', () => {
test('defaults decide optionality', () => {
const el = new Example();
expectTypeOf(el.userId).toEqualTypeOf<number | undefined>();
expectTypeOf(el.userName).toEqualTypeOf<string | undefined>();
expectTypeOf(el.min).toEqualTypeOf<number>();
expectTypeOf(el.ready).toEqualTypeOf<boolean>();
expectTypeOf(el.fetch).toEqualTypeOf<boolean>();
expectTypeOf(el.size).toEqualTypeOf<'s' | 'm' | 'l'>();
expectTypeOf(el.points).toEqualTypeOf<[number, number][]>();
const Named = WebComponent({
props: { data: json<{ a: number }, 'payload'>({ attribute: 'payload' }) },
});
expectTypeOf(new Named().data).toEqualTypeOf<{ a: number } | undefined>();
expectTypeOf<AttributesOf<typeof Named>>().toEqualTypeOf<'payload'>();
});
test('props are writable and type-checked', () => {
const el = new Example();
el.min = 2;
el.userId = undefined;
// @ts-expect-error -- wrong type
el.min = '2';
// @ts-expect-error -- not in the choice list
el.size = 'xl';
});
test('onPropChange receives a discriminated union', () => {
expectTypeOf<PropChange<typeof Example>>().toEqualTypeOf<PropChange<typeof SPEC>>();
class Watcher extends WebComponent(SPEC) {
override onPropChange(change: PropChange<typeof SPEC>): void {
if (change.name === 'min') expectTypeOf(change.value).toEqualTypeOf<number>();
if (change.name === 'userId')
expectTypeOf(change.oldValue).toEqualTypeOf<number | undefined>();
}
}
expectTypeOf<Watcher>().toExtend<HTMLElement>();
});
});
describe('members', () => {
test('root is a ShadowRoot when shadow is set', () => {
expectTypeOf(new Example().root).toEqualTypeOf<ShadowRoot>();
const Light = WebComponent({});
expectTypeOf(new Light().root).toEqualTypeOf<ShadowRoot | HTMLElement>();
});
test('handleEvent gets the union of listed events', () => {
expectTypeOf<Parameters<Example['handleEvent']>[0]>().toEqualTypeOf<
HTMLElementEventMap['click' | 'keydown']
>();
});
test('refs are typed from their selectors', () => {
const { refs } = new Example();
expectTypeOf(refs.input).toEqualTypeOf<HTMLInputElement | null>();
expectTypeOf(refs.go).toEqualTypeOf<HTMLButtonElement | null>();
expectTypeOf(refs.thing).toEqualTypeOf<Element | null>();
expectTypeOf(refs.items).toEqualTypeOf<HTMLLIElement[]>();
expectTypeOf<RefType<'svg circle.dot'>>().toEqualTypeOf<SVGCircleElement>();
expectTypeOf<RefType<'input:checked'>>().toEqualTypeOf<HTMLInputElement>();
});
test('still an HTMLElement', () => {
expectTypeOf(new Example()).toExtend<HTMLElement>();
expectTypeOf(new Example().signal).toEqualTypeOf<AbortSignal>();
});
});
describe('conflicts', () => {
test('prop names colliding with HTMLElement members are rejected', () => {
// @ts-expect-error -- `title` is on HTMLElement
WebComponent({ props: { title: string() } });
// @ts-expect-error -- `hidden` is on HTMLElement
WebComponent({ props: { hidden: enable() } });
});
test('prop names colliding with mixin members are rejected', () => {
// @ts-expect-error -- `signal` is a mixin member
WebComponent({ props: { signal: string() } });
});
test('conflicts are checked against the base class', () => {
class Fancy extends HTMLElement {
sparkle = true;
}
// @ts-expect-error -- `sparkle` is on the base
WebComponent({ base: Fancy, props: { sparkle: enable() } });
const El = WebComponent({ base: Fancy, props: { glow: enable() } });
expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>();
expectTypeOf(new El().glow).toEqualTypeOf<boolean>();
});
});

295
test/web-component.test.ts Normal file
View File

@ -0,0 +1,295 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
WebComponent,
choice,
disable,
enable,
json,
number,
string,
type PropChange,
} from '../src/index.ts';
let count = 0;
/** A fresh, unused tag name. */
function nextTag(): string {
count += 1;
return `x-test-${count}`;
}
/** Defines `ctor` under a fresh tag name and returns it, so `new ctor()` works. */
function define<T extends CustomElementConstructor>(ctor: T): T {
customElements.define(nextTag(), ctor);
return ctor;
}
/** Narrows `node` to an instance of `ctor`, failing the test otherwise. */
function instance<T>(ctor: abstract new (...args: never) => T, node: unknown): T {
if (!(node instanceof ctor)) throw new TypeError(`expected a ${ctor.name}`);
return node;
}
afterEach(() => {
document.body.replaceChildren();
for (const style of document.head.querySelectorAll('style[data-wc-mixin]')) style.remove();
});
describe('props', () => {
test('onPropChange fires once per change, from either side', () => {
const SPEC = { props: { count: number({ default: 0 }) } } as const;
const changes: PropChange<typeof SPEC>[] = [];
class El extends WebComponent(SPEC) {
override onPropChange(change: PropChange<typeof SPEC>): void {
changes.push(change);
}
}
const el = new (define(El))();
el.count = 1;
el.count = 1;
el.setAttribute('count', '2');
el.removeAttribute('count');
expect(changes).toEqual([
{ name: 'count', value: 1, oldValue: 0 },
{ name: 'count', value: 2, oldValue: 1 },
{ name: 'count', value: 0, oldValue: 2 },
]);
});
test('parsed attributes populate props on upgrade', () => {
class El extends WebComponent({ props: { userId: number(), ready: enable() } }) {}
const tag = nextTag();
document.body.innerHTML = `<${tag} user-id="7" ready></${tag}>`;
customElements.define(tag, El);
const el = instance(El, document.body.firstElementChild);
expect(el.userId).toBe(7);
expect(el.ready).toBe(true);
});
test('properties set before upgrade go through the accessor', () => {
class El extends WebComponent({ props: { label: string() } }) {}
const tag = nextTag();
const el = document.createElement(tag);
Object.assign(el, { label: 'early' });
customElements.define(tag, El);
customElements.upgrade(el);
document.body.append(el);
expect(Object.hasOwn(el, 'label')).toBe(false);
expect(instance(El, el).label).toBe('early');
expect(el.getAttribute('label')).toBe('early');
});
test('boolean codecs accept ="false"', () => {
class El extends WebComponent({ props: { ready: enable(), fetch: disable() } }) {}
const el = new (define(El))();
el.setAttribute('ready', 'false');
el.setAttribute('disable-fetch', 'false');
expect(el.ready).toBe(false);
expect(el.fetch).toBe(true);
});
test('choice and json', () => {
class El extends WebComponent({
props: { size: choice(['s', 'm', 'l'], { default: 'm' }), data: json<{ a: number }>() },
}) {}
const el = new (define(El))();
el.setAttribute('size', 'l');
expect(el.size).toBe('l');
el.setAttribute('size', 'xl');
expect(el.size).toBe('m');
el.data = { a: 1 };
expect(el.getAttribute('data')).toBe('{"a":1}');
el.setAttribute('data', '{"a":2}');
expect(el.data).toEqual({ a: 2 });
});
test('dataPrefix prefixes derived names only', () => {
class El extends WebComponent({
dataPrefix: true,
props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) },
}) {}
expect(El.observedAttributes).toEqual(['data-user-id', 'data-disable-fetch', 'is-ready']);
expect(El.attributes).toEqual({
userId: 'data-user-id',
fetch: 'data-disable-fetch',
ready: 'is-ready',
});
const el = new (define(El))();
el.fetch = false;
expect(el.dataset['disableFetch']).toBe('');
});
});
describe('shadow DOM', () => {
test('open shadow root gets the template and adopted css', () => {
class El extends WebComponent({
shadow: 'open',
template: '<p>hi</p>',
css: 'p { color: red; }',
}) {}
const el = new (define(El))();
expect(el.shadowRoot).toBe(el.root);
expect(el.root.innerHTML).toBe('<p>hi</p>');
expect(el.root.adoptedStyleSheets).toHaveLength(1);
document.body.append(el);
expect(getComputedStyle(instance(HTMLParagraphElement, el.root.querySelector('p'))).color).toBe(
'rgb(255, 0, 0)',
);
});
test('closed shadow root stays closed but is reachable via root', () => {
class El extends WebComponent({ shadow: 'closed', template: '<p>hi</p>' }) {}
const el = new (define(El))();
expect(el.shadowRoot).toBeNull();
expect(el.root).toBeInstanceOf(ShadowRoot);
expect(el.root.mode).toBe('closed');
});
test('instances share one stylesheet', () => {
class El extends WebComponent({ shadow: 'open', css: 'p {}' }) {}
define(El);
const a = new El();
const b = new El();
expect(a.root.adoptedStyleSheets[0]).toBe(b.root.adoptedStyleSheets[0]);
});
test('declarative shadow DOM wins over shadow/template', () => {
class El extends WebComponent({ shadow: 'open', template: '<p>template</p>', css: 'p {}' }) {}
const tag = nextTag();
document.body.setHTMLUnsafe(
`<${tag}><template shadowrootmode="closed"><p>declarative</p></template></${tag}>`,
);
customElements.define(tag, El);
const el = instance(El, document.body.firstElementChild);
expect(el.root.mode).toBe('closed');
expect(el.root.innerHTML).toBe('<p>declarative</p>');
expect(el.root.adoptedStyleSheets).toHaveLength(1);
});
test('template element is accepted', () => {
const template = document.createElement('template');
template.innerHTML = '<b>x</b>';
class El extends WebComponent({ shadow: 'open', template }) {}
const el = new (define(El))();
expect(el.root.innerHTML).toBe('<b>x</b>');
});
});
describe('light DOM', () => {
test('template is appended once, on first connect', () => {
class El extends WebComponent({ template: '<p>hi</p>' }) {}
const el = new (define(El))();
expect(el.root).toBe(el);
expect(el.childNodes).toHaveLength(0);
document.body.append(el);
el.remove();
document.body.append(el);
expect(el.innerHTML).toBe('<p>hi</p>');
});
test('css becomes a single <style> in the head per element type', () => {
class El extends WebComponent({ css: 'p { color: red; }', template: '<p>hi</p>' }) {}
const tag = nextTag();
customElements.define(tag, El);
document.body.append(new El(), new El());
const styles = document.head.querySelectorAll(`style[data-wc-mixin="${tag}"]`);
expect(styles).toHaveLength(1);
expect(
getComputedStyle(instance(HTMLParagraphElement, document.querySelector(`${tag} p`))).color,
).toBe('rgb(255, 0, 0)');
});
test('css is adopted into an enclosing shadow root instead of the head', () => {
class Inner extends WebComponent({ css: 'p { color: red; }', template: '<p>hi</p>' }) {}
const tag = nextTag();
customElements.define(tag, Inner);
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
document.body.append(host);
shadow.append(new Inner(), new Inner());
expect(shadow.adoptedStyleSheets).toHaveLength(1);
expect(document.head.querySelector(`style[data-wc-mixin="${tag}"]`)).toBeNull();
expect(getComputedStyle(instance(HTMLParagraphElement, shadow.querySelector('p'))).color).toBe(
'rgb(255, 0, 0)',
);
});
});
describe('signal and events', () => {
test('events go to handleEvent until disconnect; reconnect re-listens', () => {
const handle = vi.fn();
class El extends WebComponent({ events: ['click'] }) {
override handleEvent(event: PointerEvent): void {
handle(event.type);
}
}
const el = new (define(El))();
document.body.append(el);
const first = el.signal;
el.click();
el.remove();
expect(first.aborted).toBe(true);
el.click();
expect(handle).toHaveBeenCalledTimes(1);
document.body.append(el);
expect(el.signal).not.toBe(first);
expect(el.signal.aborted).toBe(false);
el.click();
expect(handle).toHaveBeenCalledTimes(2);
});
test('onConnect / onDisconnect', () => {
const calls: string[] = [];
class El extends WebComponent({}) {
override onConnect(): void {
calls.push('connect');
document.addEventListener(
'ping',
() => {
calls.push('ping');
},
{ signal: this.signal },
);
}
override onDisconnect(): void {
calls.push('disconnect');
}
}
const el = new (define(El))();
document.body.append(el);
document.dispatchEvent(new Event('ping'));
el.remove();
document.dispatchEvent(new Event('ping'));
expect(calls).toEqual(['connect', 'ping', 'disconnect']);
});
});
describe('refs', () => {
test('element and elements query the root live', () => {
class El extends WebComponent({
shadow: 'open',
template: '<input class="name"><ul><li>a</li><li>b</li></ul>',
element: { input: 'input.name', missing: 'button' },
elements: { items: 'li' },
}) {}
const el = new (define(El))();
expect(el.refs.input).toBe(el.root.querySelector('input'));
expect(el.refs.missing).toBeNull();
expect(el.refs.items.map((li) => li.textContent)).toEqual(['a', 'b']);
instance(HTMLUListElement, el.root.querySelector('ul')).append(document.createElement('li'));
expect(el.refs.items).toHaveLength(3);
});
});
describe('base', () => {
test('extends the given base class', () => {
class Fancy extends HTMLElement {
readonly sparkle = 'sparkle';
}
class El extends WebComponent({ base: Fancy, props: { glow: enable() } }) {}
const el = new (define(El))();
expect(el).toBeInstanceOf(Fancy);
expect(el.sparkle).toBe('sparkle');
});
});

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*"]
}

10
tsconfig.test.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*", "test/**/*"]
}

19
vitest.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
typecheck: {
enabled: true,
include: ['test/**/*.test-d.ts'],
tsconfig: './tsconfig.test.json',
},
},
});