diff --git a/src/types.ts b/src/types.ts index f2ce4e4..58ebde3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -181,8 +181,8 @@ export type WebComponentConstructor = Omit, 'prototype'> & { new (): WebComponentInstance; readonly prototype: WebComponentInstance; readonly observedAttributes: readonly ObservedAttributes[]; - /** Prop key → attribute name. */ - readonly attributes: AttributeMap; + /** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */ + readonly attributeFor: AttributeMap; readonly [spec]?: S; }; @@ -202,19 +202,26 @@ export type AttributesOf = ObservedAttributes>; /** 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. + */ +type ReservedName = MixinMember | 'attributeFor'; + /** 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 (`name`, `title`, `hidden` on `HTMLElement`; `value` on a - * form-ish base) or on the mixin itself is retyped to {@link PropNameConflict}, + * 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. */ export type ValidateSpec = { readonly props?: { - readonly [K in keyof PropsOf]: K extends keyof InstanceType> | MixinMember + readonly [K in keyof PropsOf]: K extends keyof InstanceType> | ReservedName ? PropNameConflict : PropsOf[K]; }; diff --git a/src/web-component.ts b/src/web-component.ts index 4e56cd1..3871e31 100644 --- a/src/web-component.ts +++ b/src/web-component.ts @@ -61,7 +61,7 @@ export function WebComponent( class WebComponentElement extends Base { static readonly observedAttributes = resolved.map((prop) => prop.attribute); - static readonly attributes = Object.fromEntries( + static readonly attributeFor = Object.fromEntries( resolved.map((prop) => [prop.key, prop.attribute]), ); diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 86bd4c6..5e76b13 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -56,9 +56,9 @@ describe('attributes', () => { expectTypeOf>().toEqualTypeOf(); }); - test('attributes maps each key to its attribute', () => { - expectTypeOf(Example.attributes.fetch).toEqualTypeOf<'disable-fetch'>(); - expectTypeOf(Example.attributes.ready).toEqualTypeOf<'is-ready'>(); + test('attributeFor maps each key to its attribute', () => { + expectTypeOf(Example.attributeFor.fetch).toEqualTypeOf<'disable-fetch'>(); + expectTypeOf(Example.attributeFor.ready).toEqualTypeOf<'is-ready'>(); }); test('dataPrefix applies only to derived names', () => { @@ -157,6 +157,8 @@ describe('conflicts', () => { test('prop names colliding with mixin members are rejected', () => { // @ts-expect-error -- `signal` is a mixin member WebComponent({ props: { signal: string() } }); + // @ts-expect-error -- `attributeFor` is reserved for the static lookup + WebComponent({ props: { attributeFor: string() } }); }); test('conflicts are checked against the base class', () => { diff --git a/test/web-component.test.ts b/test/web-component.test.ts index 8a5ea6a..3d09120 100644 --- a/test/web-component.test.ts +++ b/test/web-component.test.ts @@ -109,7 +109,7 @@ describe('props', () => { props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) }, }) {} expect(El.observedAttributes).toEqual(['data-user-id', 'data-disable-fetch', 'is-ready']); - expect(El.attributes).toEqual({ + expect(El.attributeFor).toEqual({ userId: 'data-user-id', fetch: 'data-disable-fetch', ready: 'is-ready',