Support [selector, Class] refs and show resolved types for refs, observedAttributes, and attributeFor
This commit is contained in:
parent
361a194766
commit
b26cff3468
@ -10,8 +10,12 @@ const SPEC = {
|
|||||||
template: '<input class="name"><button>Go</button>',
|
template: '<input class="name"><button>Go</button>',
|
||||||
css: ':host { display: block; }',
|
css: ':host { display: block; }',
|
||||||
events: ['click'],
|
events: ['click'],
|
||||||
element: { input: 'input.name' }, // refs.input: HTMLInputElement | null
|
element: {
|
||||||
elements: { buttons: 'button' }, // refs.buttons: HTMLButtonElement[]
|
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: {
|
props: {
|
||||||
userId: number(), // user-id number | undefined
|
userId: number(), // user-id number | undefined
|
||||||
min: number({ default: 1 }), // min number
|
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 })`.
|
- **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.
|
- **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.
|
- **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.
|
- **`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.
|
- **`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.
|
- Don't declare a subclass field with a prop's name; it would shadow the reflected accessor.
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export default defineConfig({
|
|||||||
// needs its own small class.
|
// needs its own small class.
|
||||||
'eslint/max-lines-per-function': 'off',
|
'eslint/max-lines-per-function': 'off',
|
||||||
'eslint/max-classes-per-file': 'off',
|
'eslint/max-classes-per-file': 'off',
|
||||||
|
// Test files grow with coverage; splitting them by line count doesn't help.
|
||||||
|
'eslint/max-lines': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -19,12 +19,15 @@ export type {
|
|||||||
AttributeMap,
|
AttributeMap,
|
||||||
AttributeOf,
|
AttributeOf,
|
||||||
AttributesOf,
|
AttributesOf,
|
||||||
|
ElementClass,
|
||||||
ElementConstructor,
|
ElementConstructor,
|
||||||
ObservedAttributes,
|
ObservedAttributes,
|
||||||
PropChange,
|
PropChange,
|
||||||
PropNameConflict,
|
PropNameConflict,
|
||||||
PropValues,
|
PropValues,
|
||||||
RefNameConflict,
|
RefNameConflict,
|
||||||
|
RefElement,
|
||||||
|
RefSpec,
|
||||||
RefType,
|
RefType,
|
||||||
Refs,
|
Refs,
|
||||||
Spec,
|
Spec,
|
||||||
|
|||||||
62
src/types.ts
62
src/types.ts
@ -18,10 +18,10 @@ export interface Spec {
|
|||||||
readonly dataPrefix?: boolean;
|
readonly dataPrefix?: boolean;
|
||||||
/** Host events routed to `handleEvent`, listened with the element's `signal`. */
|
/** Host events routed to `handleEvent`, listened with the element's `signal`. */
|
||||||
readonly events?: readonly (keyof HTMLElementEventMap)[];
|
readonly events?: readonly (keyof HTMLElementEventMap)[];
|
||||||
/** `refs.<name>`: `root.querySelector(selector)`. */
|
/** `refs.<name>`: the first match in `root`, or `null`. See {@link RefSpec}. */
|
||||||
readonly element?: Readonly<Record<string, string>>;
|
readonly element?: Readonly<Record<string, RefSpec>>;
|
||||||
/** `refs.<name>`: `[...root.querySelectorAll(selector)]`. */
|
/** `refs.<name>`: every match in `root`, as an array. See {@link RefSpec}. */
|
||||||
readonly elements?: Readonly<Record<string, string>>;
|
readonly elements?: Readonly<Record<string, RefSpec>>;
|
||||||
/** Reflected properties, keyed by property name. */
|
/** Reflected properties, keyed by property name. */
|
||||||
readonly props?: Readonly<Record<string, AnyPropDef>>;
|
readonly props?: Readonly<Record<string, AnyPropDef>>;
|
||||||
}
|
}
|
||||||
@ -48,12 +48,12 @@ export type EventsOf<S> = S extends {
|
|||||||
? E
|
? E
|
||||||
: never;
|
: never;
|
||||||
type ElementRefsOf<S> = S extends {
|
type ElementRefsOf<S> = S extends {
|
||||||
readonly element: infer E extends Readonly<Record<string, string>>;
|
readonly element: infer E extends Readonly<Record<string, RefSpec>>;
|
||||||
}
|
}
|
||||||
? E
|
? E
|
||||||
: Empty;
|
: Empty;
|
||||||
type ElementsRefsOf<S> = S extends {
|
type ElementsRefsOf<S> = S extends {
|
||||||
readonly elements: infer E extends Readonly<Record<string, string>>;
|
readonly elements: infer E extends Readonly<Record<string, RefSpec>>;
|
||||||
}
|
}
|
||||||
? E
|
? E
|
||||||
: Empty;
|
: 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>}`;
|
: `${DataPrefix extends true ? 'data-' : ''}${D extends { readonly prefix: infer P extends string } ? P : ''}${Kebab<K>}`;
|
||||||
|
|
||||||
/** Union of every observed attribute name for a spec. */
|
/** 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>>;
|
// Written as `keyof` over a key-remapped mapped type, not the more usual
|
||||||
}[keyof PropsOf<S> & string];
|
// `{ ... }[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. */
|
/** Prop key → attribute name, for a spec. */
|
||||||
export type AttributeMap<S> = {
|
export type AttributeMap<S> = {
|
||||||
@ -131,11 +136,34 @@ export type RefType<Sel extends string> =
|
|||||||
: Element
|
: Element
|
||||||
: never;
|
: 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> = {
|
export type Refs<S> = {
|
||||||
readonly [K in keyof ElementRefsOf<S>]: RefType<ElementRefsOf<S>[K] & string> | null;
|
readonly [K in keyof ElementRefsOf<S> | keyof ElementsRefsOf<S>]: K extends keyof ElementRefsOf<S>
|
||||||
} & {
|
? RefElement<ElementRefsOf<S>[K]> | null
|
||||||
readonly [K in keyof ElementsRefsOf<S>]: RefType<ElementsRefsOf<S>[K] & string>[];
|
: 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). */
|
/** The element's `ElementInternals` (attached by the mixin; don't call `attachInternals()` again). */
|
||||||
readonly internals: ElementInternals;
|
readonly internals: ElementInternals;
|
||||||
/** Typed `element` / `elements` lookups, queried live against `root`. */
|
/** 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`. */
|
/** Receives every event listed in `events`. */
|
||||||
handleEvent(
|
handleEvent(
|
||||||
event: [EventsOf<S>] extends [never] ? Event : HTMLElementEventMap[EventsOf<S>],
|
event: [EventsOf<S>] extends [never] ? Event : HTMLElementEventMap[EventsOf<S>],
|
||||||
@ -180,7 +210,9 @@ export type WebComponentInstance<S> = InstanceType<BaseOf<S>> &
|
|||||||
export interface WebComponentStatics<S> {
|
export interface WebComponentStatics<S> {
|
||||||
readonly observedAttributes: readonly ObservedAttributes<S>[];
|
readonly observedAttributes: readonly ObservedAttributes<S>[];
|
||||||
/** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */
|
/** 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. */
|
/** The constructor type {@link WebComponent} returns. */
|
||||||
|
|||||||
@ -1,5 +1,19 @@
|
|||||||
import { attributeName, type AnyPropDef } from './props.ts';
|
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 {
|
interface ResolvedProp {
|
||||||
key: string;
|
key: string;
|
||||||
@ -31,8 +45,8 @@ export function WebComponent<const S extends Spec>(
|
|||||||
def,
|
def,
|
||||||
}));
|
}));
|
||||||
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
|
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
|
||||||
const element: [name: string, selector: string][] = Object.entries(spec.element ?? {});
|
const element = resolveRefs(spec.element);
|
||||||
const elements: [name: string, selector: string][] = Object.entries(spec.elements ?? {});
|
const elements = resolveRefs(spec.elements);
|
||||||
const events = spec.events ?? [];
|
const events = spec.events ?? [];
|
||||||
|
|
||||||
// Parsed lazily, once per class, so defining the class needs no DOM work.
|
// 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
|
// Getter return types are annotated: `PropertyDescriptor.get` returns
|
||||||
// `any`, which the query methods would otherwise infer their result from.
|
// `any`, which the query methods would otherwise infer their result from.
|
||||||
const refs = {};
|
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, {
|
Object.defineProperty(refs, name, {
|
||||||
enumerable: true,
|
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, {
|
Object.defineProperty(refs, name, {
|
||||||
enumerable: true,
|
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;
|
return refs;
|
||||||
|
|||||||
@ -140,6 +140,23 @@ describe('members', () => {
|
|||||||
expectTypeOf<RefType<'input:checked'>>().toEqualTypeOf<HTMLInputElement>();
|
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', () => {
|
test('still an HTMLElement', () => {
|
||||||
expectTypeOf(new Example()).toExtend<HTMLElement>();
|
expectTypeOf(new Example()).toExtend<HTMLElement>();
|
||||||
expectTypeOf(new Example().signal).toEqualTypeOf<AbortSignal>();
|
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', () => {
|
test('distinct keys are fine', () => {
|
||||||
const El = WebComponent({ element: { input: 'input' }, elements: { inputs: 'input' } });
|
const El = WebComponent({ element: { input: 'input' }, elements: { inputs: 'input' } });
|
||||||
expectTypeOf(new El().refs.input).toEqualTypeOf<HTMLInputElement | null>();
|
expectTypeOf(new El().refs.input).toEqualTypeOf<HTMLInputElement | null>();
|
||||||
|
|||||||
@ -280,6 +280,19 @@ describe('refs', () => {
|
|||||||
instance(HTMLUListElement, el.root.querySelector('ul')).append(document.createElement('li'));
|
instance(HTMLUListElement, el.root.querySelector('ul')).append(document.createElement('li'));
|
||||||
expect(el.refs.items).toHaveLength(3);
|
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', () => {
|
describe('base', () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user