|
|
||
|---|---|---|
| examples | ||
| src | ||
| test | ||
| .gitignore | ||
| jsr.json | ||
| LICENSE | ||
| oxfmt.config.ts | ||
| oxlint.config.ts | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsconfig.test.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
wc-mixin
A typed mixin factory that removes custom-element boilerplate.
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: true },
element: {
input: 'input.name', // refs.input: HTMLInputElement | null (from the tag)
thing: '.thing', // refs.thing: Element | null (no tag to go on)
card: ['.card', HTMLElement], // refs.card: HTMLElement | null (throws if a match isn't one)
},
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.
onPropChangefires once per real change from either side. - Attribute names: an explicit
attributeis used verbatim; otherwisekebab(key), withdisable-fordisable()anddata-in front whendataPrefix: true. - Codecs:
string,number,enable,disable,choice([...]),json<T>(),prop({ default, decode, encode }). - Shadow DOM:
shadowattaches a root and clonestemplateinto it;css(a string or aCSSStyleSheet, e.g.import sheet from './x.css' with { type: 'css' }) is a shared adopted stylesheet. An existing declarative shadow root is used instead (andshadow/templateare ignored).rootis the shadow root (even when closed) or the element itself. - Light DOM:
templateis appended on first connect;cssis added once per element type: adopted into the enclosing shadow root, or at the document level a string becomes a<style>in<head>and aCSSStyleSheetis adopted intodocument.adoptedStyleSheets. - Refs: a selector string is typed from its tag (last compound:
'form > button#go'→HTMLButtonElement), falling back toElement.[selector, Class]types the ref asClass; reading it throws aTypeErrorif a match isn't an instance ofClass. A key can't be in bothelementandelements. signalaborts on disconnect; a new one is created on each connect.eventsare listened on the host with it.- Events:
eventsmaps a name totrue, listener options,listen<E>(options?)for an explicit event type, orcustom<T>(options?)for aCustomEvent<T>(the same aslisten<CustomEvent<T>>()):{ click: true, scroll: { passive: true }, 'wc:key': listen<KeyboardEvent>(), 'wc:pick': custom<number>() }. Other names are typed fromHTMLElementEventMap(click→PointerEvent), elseEvent.handleEventreceivesHandledEvent<typeof SPEC>, a union whosetypeis literal, soif (event.type === 'wc:pick') event.detailnarrows. A host listener can't see into a closed shadow root, socomposedPath()stops at the host there. baseextends another autonomous element class; prop names that collide with its members (orHTMLElement's, liketitle/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, npm run examples (Vite dev server for examples/; vite.config.ts builds with { type: 'css' } / with { type: 'text' } imports for browsers without import-attribute support).