Throw on wrong-class [selector, Class] refs instead of skipping them
This commit is contained in:
parent
b26cff3468
commit
98c209741d
@ -13,7 +13,7 @@ const SPEC = {
|
||||
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)
|
||||
card: ['.card', HTMLElement], // refs.card: HTMLElement | null (throws if a match isn't one)
|
||||
},
|
||||
elements: { buttons: 'button' }, // refs.buttons: HTMLButtonElement[]
|
||||
props: {
|
||||
@ -45,7 +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`.
|
||||
- **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.
|
||||
|
||||
@ -136,7 +136,7 @@ export type RefType<Sel extends string> =
|
||||
: Element
|
||||
: never;
|
||||
|
||||
/** A class a ref's matches are checked against with `instanceof`. */
|
||||
/** A class a ref's matches must be instances of (checked when the ref is read). */
|
||||
export type ElementClass<T extends Element = Element> = abstract new (...args: never) => T;
|
||||
|
||||
/**
|
||||
@ -145,8 +145,8 @@ export type ElementClass<T extends Element = Element> = abstract new (...args: n
|
||||
* - `'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`).
|
||||
* The ref is typed as that class, and reading it throws a `TypeError` if a
|
||||
* match isn't an instance of it.
|
||||
*/
|
||||
export type RefSpec = string | readonly [selector: string, type: ElementClass];
|
||||
|
||||
|
||||
@ -244,16 +244,13 @@ 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 = {};
|
||||
// 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 => {
|
||||
if (!type) return this.root.querySelector(selector);
|
||||
return (
|
||||
[...this.root.querySelectorAll(selector)].find((el) => el instanceof type) ?? null
|
||||
);
|
||||
const found = this.root.querySelector(selector);
|
||||
if (found && type) this.#checkRef(name, selector, type, found);
|
||||
return found;
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -262,12 +259,26 @@ export function WebComponent<const S extends Spec>(
|
||||
enumerable: true,
|
||||
get: (): Element[] => {
|
||||
const all = [...this.root.querySelectorAll(selector)];
|
||||
return type ? all.filter((el) => el instanceof type) : all;
|
||||
if (type) for (const found of all) this.#checkRef(name, selector, type, found);
|
||||
return all;
|
||||
},
|
||||
});
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
// A `[selector, Class]` ref is typed as `Class`, so a match of any other
|
||||
// class is a bug in the template or the spec: fail loudly rather than hand
|
||||
// back a wrongly typed element.
|
||||
#checkRef(name: string, selector: string, type: ElementClass, found: Element): void {
|
||||
// Read before the check: TypeScript narrows `found` to `never` after a
|
||||
// failed `instanceof`, since `type` is typed as constructing Elements.
|
||||
const tag = found.localName;
|
||||
if (found instanceof type) return;
|
||||
throw new TypeError(
|
||||
`<${this.localName}> ref "${name}": "${selector}" matched <${tag}>, which is not a ${type.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime class can't express the spec-derived accessors and members;
|
||||
|
||||
@ -281,18 +281,38 @@ describe('refs', () => {
|
||||
expect(el.refs.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('[selector, Class] refs skip matches of the wrong class', () => {
|
||||
test('[selector, Class] refs return matches of the right 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] },
|
||||
template: '<p class="x">p</p><p class="x">p2</p>',
|
||||
element: { para: ['.x', HTMLParagraphElement], missing: ['.nope', HTMLParagraphElement] },
|
||||
elements: { paras: ['.x', HTMLParagraphElement] },
|
||||
}) {}
|
||||
const el = new (define(El))();
|
||||
expect(el.refs.para?.textContent).toBe('p');
|
||||
expect(el.refs.button).toBeNull();
|
||||
expect(el.refs.missing).toBeNull();
|
||||
expect(el.refs.paras.map((p) => p.textContent)).toEqual(['p', 'p2']);
|
||||
});
|
||||
|
||||
test('[selector, Class] refs throw on a match of the wrong class', () => {
|
||||
class El extends WebComponent({
|
||||
shadow: 'open',
|
||||
template: '<p class="x">p</p><div class="x">div</div>',
|
||||
element: { button: ['.x', HTMLButtonElement] },
|
||||
elements: { paras: ['.x', HTMLParagraphElement] },
|
||||
}) {}
|
||||
const el = new (define(El))();
|
||||
expect(() => el.refs.button).toThrow(
|
||||
new TypeError(
|
||||
`<${el.localName}> ref "button": ".x" matched <p>, which is not a HTMLButtonElement`,
|
||||
),
|
||||
);
|
||||
expect(() => el.refs.paras).toThrow(
|
||||
new TypeError(
|
||||
`<${el.localName}> ref "paras": ".x" matched <div>, which is not a HTMLParagraphElement`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user