Go to file
Buddy 6ece00c8cc Allow static names as props and rename attributeFor back to attributes
Props live on instances, so statics on the generated class or the base
can't collide with them. Only instance members of the base and the
mixin are reserved now.
2026-11-07 12:25:11 -08:00
src Allow static names as props and rename attributeFor back to attributes 2026-11-07 12:25:11 -08:00
test Allow static names as props and rename attributeFor back to attributes 2026-11-07 12:25:11 -08:00
.gitignore Initial commit 2026-11-05 07:53:57 +00: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 Support [selector, Class] refs and show resolved types for refs, observedAttributes, and attributeFor 2026-11-06 21:31:14 -08:00
package-lock.json chore: bump oxlint oxfmt 2026-11-06 15:54:55 -08:00
package.json chore: bump oxlint oxfmt 2026-11-06 15:54:55 -08:00
README.md Throw on wrong-class [selector, Class] refs instead of skipping them 2026-11-06 21:33:42 -08:00
tsconfig.json Add typed WebComponent mixin factory 2026-11-06 00:08:56 -08:00
tsconfig.test.json Add typed WebComponent mixin factory 2026-11-06 00:08:56 -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'],
  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 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.
  • 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.
  • 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.