diff --git a/src/index.ts b/src/index.ts index 95def10..06c2d8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export type { PropChange, PropNameConflict, PropValues, + RefNameConflict, RefType, Refs, Spec, @@ -33,4 +34,5 @@ export type { WebComponentConstructor, WebComponentInstance, WebComponentMembers, + WebComponentStatics, } from './types.ts'; diff --git a/src/types.ts b/src/types.ts index 58ebde3..435b437 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,15 +176,20 @@ export type WebComponentInstance = InstanceType> & PropValues> & WebComponentMembers; -/** The constructor type {@link WebComponent} returns. */ -export type WebComponentConstructor = Omit, 'prototype'> & { - new (): WebComponentInstance; - readonly prototype: WebComponentInstance; +/** The static members {@link WebComponent} adds to the generated class. */ +export interface WebComponentStatics { readonly observedAttributes: readonly ObservedAttributes[]; /** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */ readonly attributeFor: AttributeMap; - readonly [spec]?: S; -}; +} + +/** The constructor type {@link WebComponent} returns. */ +export type WebComponentConstructor = Omit, 'prototype'> & + WebComponentStatics & { + new (): WebComponentInstance; + readonly prototype: WebComponentInstance; + readonly [spec]?: S; + }; /** The spec behind a {@link WebComponent} class (or subclass), or `T` itself when it is already a spec. */ export type SpecOf = T extends abstract new (...args: never) => unknown @@ -199,30 +204,53 @@ export type AttributesOf = ObservedAttributes>; // --------------------------------------------------------------------------- // Validation -/** Mixin members a prop may not shadow. */ -type MixinMember = keyof WebComponentMembers; - /** - * Every name a prop may not use. `attributeFor` is static, so it wouldn't - * collide at runtime; it's reserved so `El.attributeFor` and a prop can't be - * confused. + * Every name a prop may not use: members of the base instance (`title`, + * `hidden`, `id` on `HTMLElement`; `value` on a form-ish base), statics of the + * base class, and the members and statics the mixin adds. The static names + * wouldn't collide at runtime (props live on instances); they're reserved so + * `El.attributeFor` / `El.observedAttributes` and a prop can't be confused. */ -type ReservedName = MixinMember | 'attributeFor'; +type ReservedName = + | keyof InstanceType> + | keyof BaseOf + | keyof WebComponentMembers + | keyof WebComponentStatics; /** Branded error type: shows up in the message when a prop name collides. */ export type PropNameConflict = `Error: prop "${K & string}" conflicts with an existing element member`; /** - * Intersected with the spec parameter. Any prop whose name already exists on - * the base instance (`title`, `hidden`, `id` on `HTMLElement`; `value` on a - * form-ish base), on the mixin, or is otherwise reserved, is retyped to {@link PropNameConflict}, - * so the error lands on that key. + * Branded error type: shows up in the message when `element` and `elements` + * share a key. An object rather than a string literal because the selector is + * itself a string literal, and two string literals intersect to `never`, which + * would hide the message. + */ +export type RefNameConflict = { + readonly [M in `Error: ref "${K & string}" is in both element and elements`]: never; +}; + +/** + * Intersected with the spec parameter, so each error lands on the offending + * key: a prop with a {@link ReservedName} is retyped to {@link PropNameConflict}, + * and a key in both `element` and `elements` (both become `refs.`) is + * retyped to {@link RefNameConflict} on each side. */ export type ValidateSpec = { readonly props?: { - readonly [K in keyof PropsOf]: K extends keyof InstanceType> | ReservedName + readonly [K in keyof PropsOf]: K extends ReservedName ? PropNameConflict : PropsOf[K]; }; + readonly element?: { + readonly [K in keyof ElementRefsOf]: K extends keyof ElementsRefsOf + ? RefNameConflict + : ElementRefsOf[K]; + }; + readonly elements?: { + readonly [K in keyof ElementsRefsOf]: K extends keyof ElementRefsOf + ? RefNameConflict + : ElementsRefsOf[K]; + }; }; diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 5e76b13..f0a0219 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -159,6 +159,8 @@ describe('conflicts', () => { WebComponent({ props: { signal: string() } }); // @ts-expect-error -- `attributeFor` is reserved for the static lookup WebComponent({ props: { attributeFor: string() } }); + // @ts-expect-error -- `observedAttributes` is a static of the class + WebComponent({ props: { observedAttributes: string() } }); }); test('conflicts are checked against the base class', () => { @@ -168,8 +170,31 @@ describe('conflicts', () => { // @ts-expect-error -- `sparkle` is on the base WebComponent({ base: Fancy, props: { sparkle: enable() } }); + class WithStatic extends HTMLElement { + static shine = 1; + } + // @ts-expect-error -- `shine` is a static on the base + WebComponent({ base: WithStatic, props: { shine: enable() } }); + const El = WebComponent({ base: Fancy, props: { glow: enable() } }); expectTypeOf(new El().sparkle).toEqualTypeOf(); expectTypeOf(new El().glow).toEqualTypeOf(); }); }); + +describe('ref conflicts', () => { + test('a key in both element and elements is rejected', () => { + WebComponent({ + // @ts-expect-error -- `input` is also in elements + element: { input: 'input', button: 'button' }, + // @ts-expect-error -- `input` is also in element + elements: { input: 'textarea', items: 'li' }, + }); + }); + + test('distinct keys are fine', () => { + const El = WebComponent({ element: { input: 'input' }, elements: { inputs: 'input' } }); + expectTypeOf(new El().refs.input).toEqualTypeOf(); + expectTypeOf(new El().refs.inputs).toEqualTypeOf(); + }); +});