Support [selector, Class] refs and show resolved types for refs, observedAttributes, and attributeFor

This commit is contained in:
Buddy 2026-11-06 21:31:14 -08:00
parent 361a194766
commit b26cff3468
7 changed files with 129 additions and 24 deletions

View File

@ -10,8 +10,12 @@ const SPEC = {
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[]
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 (checked with instanceof)
},
elements: { buttons: 'button' }, // refs.buttons: HTMLButtonElement[]
props: {
userId: number(), // user-id number | undefined
min: number({ default: 1 }), // min number
@ -41,6 +45,7 @@ ExampleElement.observedAttributes; // readonly ('user-id' | 'min' | 'is-ready' |
- **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` and skips matches that aren't instances of it. 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.

View File

@ -45,6 +45,8 @@ export default defineConfig({
// needs its own small class.
'eslint/max-lines-per-function': 'off',
'eslint/max-classes-per-file': 'off',
// Test files grow with coverage; splitting them by line count doesn't help.
'eslint/max-lines': 'off',
},
},
],

View File

@ -19,12 +19,15 @@ export type {
AttributeMap,
AttributeOf,
AttributesOf,
ElementClass,
ElementConstructor,
ObservedAttributes,
PropChange,
PropNameConflict,
PropValues,
RefNameConflict,
RefElement,
RefSpec,
RefType,
Refs,
Spec,

View File

@ -18,10 +18,10 @@ export interface Spec {
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>>;
/** `refs.<name>`: the first match in `root`, or `null`. See {@link RefSpec}. */
readonly element?: Readonly<Record<string, RefSpec>>;
/** `refs.<name>`: every match in `root`, as an array. See {@link RefSpec}. */
readonly elements?: Readonly<Record<string, RefSpec>>;
/** Reflected properties, keyed by property name. */
readonly props?: Readonly<Record<string, AnyPropDef>>;
}
@ -48,12 +48,12 @@ export type EventsOf<S> = S extends {
? E
: never;
type ElementRefsOf<S> = S extends {
readonly element: infer E extends Readonly<Record<string, string>>;
readonly element: infer E extends Readonly<Record<string, RefSpec>>;
}
? E
: Empty;
type ElementsRefsOf<S> = S extends {
readonly elements: infer E extends Readonly<Record<string, string>>;
readonly elements: infer E extends Readonly<Record<string, RefSpec>>;
}
? E
: Empty;
@ -75,9 +75,14 @@ export type AttributeOf<K extends string, D, DataPrefix extends boolean = false>
: `${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];
//
// Written as `keyof` over a key-remapped mapped type, not the more usual
// `{ ... }[keyof ...]` lookup: the lookup's union keeps this alias attached,
// so editors show `ObservedAttributes<{ ...whole spec... }>`, while `keyof`
// builds a fresh union that displays as `"user-id" | "min" | ...`.
export type ObservedAttributes<S> = keyof {
[K in keyof PropsOf<S> & string as AttributeOf<K, PropsOf<S>[K], DataPrefixOf<S>>]: K;
};
/** Prop key → attribute name, for a spec. */
export type AttributeMap<S> = {
@ -131,11 +136,34 @@ export type RefType<Sel extends string> =
: Element
: never;
/** `this.refs` for a spec. */
/** A class a ref's matches are checked against with `instanceof`. */
export type ElementClass<T extends Element = Element> = abstract new (...args: never) => T;
/**
* One entry in `element` / `elements`:
*
* - `'input.name'` — a selector; the type comes from its tag via {@link RefType}
* (`HTMLInputElement` here, plain `Element` for a tagless `'.thing'`).
* - `['.thing', HTMLElement]` — a selector plus the class its matches must be.
* The ref is typed as that class, and matches that aren't instances of it are
* skipped (`null` for `element`, left out for `elements`).
*/
export type RefSpec = string | readonly [selector: string, type: ElementClass];
/** The element type one {@link RefSpec} produces. */
export type RefElement<R> = R extends readonly [string, ElementClass<infer T>]
? T
: R extends string
? RefType<R>
: Element;
/** `this.refs` for a spec: `element` keys are `T | null`, `elements` keys are `T[]`. */
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>[];
readonly [K in keyof ElementRefsOf<S> | keyof ElementsRefsOf<S>]: K extends keyof ElementRefsOf<S>
? RefElement<ElementRefsOf<S>[K]> | null
: K extends keyof ElementsRefsOf<S>
? RefElement<ElementsRefsOf<S>[K]>[]
: never;
};
// ---------------------------------------------------------------------------
@ -152,7 +180,9 @@ export interface WebComponentMembers<S> {
/** 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>;
// Re-mapped inline (not `Refs<S>` directly) so editors show the resolved
// `{ input: HTMLInputElement | null; ... }` rather than the alias.
readonly refs: { readonly [K in keyof Refs<S>]: Refs<S>[K] };
/** Receives every event listed in `events`. */
handleEvent(
event: [EventsOf<S>] extends [never] ? Event : HTMLElementEventMap[EventsOf<S>],
@ -180,7 +210,9 @@ export type WebComponentInstance<S> = InstanceType<BaseOf<S>> &
export interface WebComponentStatics<S> {
readonly observedAttributes: readonly ObservedAttributes<S>[];
/** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */
readonly attributeFor: AttributeMap<S>;
// Re-mapped inline (not `AttributeMap<S>` directly) so editors show the
// resolved `{ fetch: "disable-fetch"; ... }` rather than the alias.
readonly attributeFor: { readonly [K in keyof AttributeMap<S>]: AttributeMap<S>[K] };
}
/** The constructor type {@link WebComponent} returns. */

View File

@ -1,5 +1,19 @@
import { attributeName, type AnyPropDef } from './props.ts';
import type { Spec, ValidateSpec, WebComponentConstructor } from './types.ts';
import type {
ElementClass,
RefSpec,
Spec,
ValidateSpec,
WebComponentConstructor,
} from './types.ts';
type ResolvedRef = [name: string, selector: string, type: ElementClass | undefined];
function resolveRefs(refs: Readonly<Record<string, RefSpec>> | undefined): ResolvedRef[] {
return Object.entries(refs ?? {}).map(([name, ref]) =>
typeof ref === 'string' ? [name, ref, undefined] : [name, ref[0], ref[1]],
);
}
interface ResolvedProp {
key: string;
@ -31,8 +45,8 @@ export function WebComponent<const S extends Spec>(
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 element = resolveRefs(spec.element);
const elements = resolveRefs(spec.elements);
const events = spec.events ?? [];
// Parsed lazily, once per class, so defining the class needs no DOM work.
@ -230,16 +244,26 @@ export function WebComponent<const S extends Spec>(
// 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) {
// With a class given, matches that aren't instances of it are skipped,
// so the typed ref never holds the wrong kind of element.
for (const [name, selector, type] of element) {
Object.defineProperty(refs, name, {
enumerable: true,
get: (): Element | null => this.root.querySelector(selector),
get: (): Element | null => {
if (!type) return this.root.querySelector(selector);
return (
[...this.root.querySelectorAll(selector)].find((el) => el instanceof type) ?? null
);
},
});
}
for (const [name, selector] of elements) {
for (const [name, selector, type] of elements) {
Object.defineProperty(refs, name, {
enumerable: true,
get: (): Element[] => [...this.root.querySelectorAll(selector)],
get: (): Element[] => {
const all = [...this.root.querySelectorAll(selector)];
return type ? all.filter((el) => el instanceof type) : all;
},
});
}
return refs;

View File

@ -140,6 +140,23 @@ describe('members', () => {
expectTypeOf<RefType<'input:checked'>>().toEqualTypeOf<HTMLInputElement>();
});
test('[selector, Class] refs are typed as the class', () => {
class Widget extends HTMLElement {}
const El = WebComponent({
element: { thing: ['.thing', HTMLElement], widget: ['.w', Widget] },
elements: { items: ['.item', HTMLLIElement] },
});
const { refs } = new El();
expectTypeOf(refs.thing).toEqualTypeOf<HTMLElement | null>();
expectTypeOf(refs.widget).toEqualTypeOf<Widget | null>();
expectTypeOf(refs.items).toEqualTypeOf<HTMLLIElement[]>();
});
test('the class must be an Element class', () => {
// @ts-expect-error -- Date isn't an Element
WebComponent({ element: { when: ['.when', Date] } });
});
test('still an HTMLElement', () => {
expectTypeOf(new Example()).toExtend<HTMLElement>();
expectTypeOf(new Example().signal).toEqualTypeOf<AbortSignal>();
@ -192,6 +209,15 @@ describe('ref conflicts', () => {
});
});
test('the tuple form conflicts too', () => {
WebComponent({
// @ts-expect-error -- `input` is also in elements
element: { input: ['input', HTMLInputElement] },
// @ts-expect-error -- `input` is also in element
elements: { input: 'textarea' },
});
});
test('distinct keys are fine', () => {
const El = WebComponent({ element: { input: 'input' }, elements: { inputs: 'input' } });
expectTypeOf(new El().refs.input).toEqualTypeOf<HTMLInputElement | null>();

View File

@ -280,6 +280,19 @@ describe('refs', () => {
instance(HTMLUListElement, el.root.querySelector('ul')).append(document.createElement('li'));
expect(el.refs.items).toHaveLength(3);
});
test('[selector, Class] refs skip matches of the wrong class', () => {
class El extends WebComponent({
shadow: 'open',
template: '<div class="x">div</div><p class="x">p</p><p class="x">p2</p>',
element: { para: ['.x', HTMLParagraphElement], button: ['.x', HTMLButtonElement] },
elements: { paras: ['.x', HTMLParagraphElement] },
}) {}
const el = new (define(El))();
expect(el.refs.para?.textContent).toBe('p');
expect(el.refs.button).toBeNull();
expect(el.refs.paras.map((p) => p.textContent)).toEqual(['p', 'p2']);
});
});
describe('base', () => {