Support [selector, Class] refs and show resolved types for refs, observedAttributes, and attributeFor
This commit is contained in:
parent
361a194766
commit
b26cff3468
@ -10,7 +10,11 @@ const SPEC = {
|
||||
template: '<input class="name"><button>Go</button>',
|
||||
css: ':host { display: block; }',
|
||||
events: ['click'],
|
||||
element: { input: 'input.name' }, // refs.input: HTMLInputElement | null
|
||||
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
|
||||
@ -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.
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -19,12 +19,15 @@ export type {
|
||||
AttributeMap,
|
||||
AttributeOf,
|
||||
AttributesOf,
|
||||
ElementClass,
|
||||
ElementConstructor,
|
||||
ObservedAttributes,
|
||||
PropChange,
|
||||
PropNameConflict,
|
||||
PropValues,
|
||||
RefNameConflict,
|
||||
RefElement,
|
||||
RefSpec,
|
||||
RefType,
|
||||
Refs,
|
||||
Spec,
|
||||
|
||||
62
src/types.ts
62
src/types.ts
@ -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. */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user