Go to file
Buddy 689b97b506 Ignore .idea
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:48:53 -08:00
examples Accept a CSSStyleSheet as css; move example CSS and templates into files 2026-11-10 11:44:27 -08:00
src Accept a CSSStyleSheet as css; move example CSS and templates into files 2026-11-10 11:44:27 -08:00
test Accept a CSSStyleSheet as css; move example CSS and templates into files 2026-11-10 11:44:27 -08:00
.gitignore Ignore .idea 2026-11-10 11:48:53 -08:00
jsr.json Add jsr.json for publishing to JSR as @buddy/wc-mixin 2026-11-07 11:51:24 -08:00
LICENSE Add typed WebComponent mixin factory 2026-11-06 00:08:56 -08:00
oxfmt.config.ts Add typed WebComponent mixin factory 2026-11-06 00:08:56 -08:00
oxlint.config.ts Accept a CSSStyleSheet as css; move example CSS and templates into files 2026-11-10 11:44:27 -08:00
package-lock.json Add examples page with a context manager 2026-11-10 09:59:09 -08:00
package.json Accept a CSSStyleSheet as css; move example CSS and templates into files 2026-11-10 11:44:27 -08:00
README.md Accept a CSSStyleSheet as css; move example CSS and templates into files 2026-11-10 11:44:27 -08:00
tsconfig.json Add typed WebComponent mixin factory 2026-11-06 00:08:56 -08:00
tsconfig.test.json Add examples page with a context manager 2026-11-10 09:59:09 -08:00
vite.config.ts Accept a CSSStyleSheet as css; move example CSS and templates into files 2026-11-10 11:44:27 -08:00
vitest.config.ts Add typed WebComponent mixin factory 2026-11-06 00:08:56 -08:00

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. 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 (a string or a CSSStyleSheet, e.g. import sheet from './x.css' with { type: '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: adopted into the enclosing shadow root, or at the document level a string becomes a <style> in <head> and a CSSStyleSheet is adopted into document.adoptedStyleSheets.
  • Refs: a selector string is typed from its tag (last compound: 'form > button#go' → HTMLButtonElement), falling back to Element. [selector, Class] types the ref as Class; reading it throws a TypeError if a match isn't an instance of Class. A key can't be in both element and elements.
  • signal aborts on disconnect; a new one is created on each connect. events are listened on the host with it.
  • Events: events maps a name to true, listener options, listen<E>(options?) for an explicit event type, or custom<T>(options?) for a CustomEvent<T> (the same as listen<CustomEvent<T>>()): { click: true, scroll: { passive: true }, 'wc:key': listen<KeyboardEvent>(), 'wc:pick': custom<number>() }. Other names are typed from HTMLElementEventMap (click → PointerEvent), else Event. handleEvent receives HandledEvent<typeof SPEC>, a union whose type is literal, so if (event.type === 'wc:pick') event.detail narrows. A host listener can't see into a closed shadow root, so composedPath() stops at the host there.
  • 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, 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).