wc-mixin/README.md
Buddy 76d78e1925 Accept a CSSStyleSheet as css; move example CSS and templates into files
css is now string | CSSStyleSheet. A sheet is adopted as-is: into its own
shadow root, an enclosing shadow root, or document.adoptedStyleSheets for
light DOM at the document level. Strings behave as before.

The examples import their styles with `with { type: 'css' }` and their
static templates with `with { type: 'text' }`. Safari doesn't support those
yet, so vite.config.ts rewrites them into modules Vite builds: a constructed
CSSStyleSheet, or the file's text. npm run examples now just runs vite,
with root set in the config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:44:27 -08:00

57 lines
4.3 KiB
Markdown

# 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: 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).