diff --git a/src/types.ts b/src/types.ts index b2e653e..d124b49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,10 +209,10 @@ export type WebComponentInstance = InstanceType> & /** 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'`. */ + /** Prop key → attribute name: `El.attributes.fetch` is `'disable-fetch'`. */ // Re-mapped inline (not `AttributeMap` directly) so editors show the // resolved `{ fetch: "disable-fetch"; ... }` rather than the alias. - readonly attributeFor: { readonly [K in keyof AttributeMap]: AttributeMap[K] }; + readonly attributes: { readonly [K in keyof AttributeMap]: AttributeMap[K] }; } /** The constructor type {@link WebComponent} returns. */ @@ -238,16 +238,11 @@ export type AttributesOf = ObservedAttributes>; /** * 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. + * `hidden`, `id` on `HTMLElement`; `value` on a form-ish base) and the members + * the mixin adds. Statics aren't included; props live on instances, so they + * can't collide. */ -type ReservedName = - | keyof InstanceType> - | keyof BaseOf - | keyof WebComponentMembers - | keyof WebComponentStatics; +type ReservedName = keyof InstanceType> | keyof WebComponentMembers; /** Branded error type: shows up in the message when a prop name collides. */ export type PropNameConflict = diff --git a/src/web-component.ts b/src/web-component.ts index 3e885a2..4e4c592 100644 --- a/src/web-component.ts +++ b/src/web-component.ts @@ -73,9 +73,12 @@ export function WebComponent( // root) for this class, not once per element. const styledRoots = new WeakSet(); - class WebComponentElement extends Base { + // The runtime class can't express the spec-derived accessors and members; + // the cast at the end is the one place the generated type is attached. + // oxlint-disable-next-line no-unsafe-type-assertion + return class WebComponentElement extends Base { static readonly observedAttributes = resolved.map((prop) => prop.attribute); - static readonly attributeFor = Object.fromEntries( + static readonly attributes = Object.fromEntries( resolved.map((prop) => [prop.key, prop.attribute]), ); @@ -279,10 +282,5 @@ export function WebComponent( `<${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; - // this is the one place the generated type is attached. - // oxlint-disable-next-line no-unsafe-type-assertion - return WebComponentElement as unknown as WebComponentConstructor; + } as unknown as WebComponentConstructor; } diff --git a/test/types.test-d.ts b/test/types.test-d.ts index 08e81a0..0356a70 100644 --- a/test/types.test-d.ts +++ b/test/types.test-d.ts @@ -56,9 +56,9 @@ describe('attributes', () => { expectTypeOf>().toEqualTypeOf(); }); - test('attributeFor maps each key to its attribute', () => { - expectTypeOf(Example.attributeFor.fetch).toEqualTypeOf<'disable-fetch'>(); - expectTypeOf(Example.attributeFor.ready).toEqualTypeOf<'is-ready'>(); + test('attributes maps each key to its attribute', () => { + expectTypeOf(Example.attributes.fetch).toEqualTypeOf<'disable-fetch'>(); + expectTypeOf(Example.attributes.ready).toEqualTypeOf<'is-ready'>(); }); test('dataPrefix applies only to derived names', () => { @@ -174,10 +174,18 @@ 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() } }); - // @ts-expect-error -- `observedAttributes` is a static of the class - WebComponent({ props: { observedAttributes: string() } }); + }); + + test('static names are allowed as props', () => { + class WithStatic extends HTMLElement { + static shine = 1; + } + const El = WebComponent({ + base: WithStatic, + props: { shine: enable(), observedAttributes: string() }, + }); + expectTypeOf(new El().shine).toEqualTypeOf(); + expectTypeOf(El.shine).toEqualTypeOf(); }); test('conflicts are checked against the base class', () => { @@ -187,12 +195,6 @@ 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(); diff --git a/test/web-component.test.ts b/test/web-component.test.ts index 12c1f8f..221a806 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.attributeFor).toEqual({ + expect(El.attributes).toEqual({ userId: 'data-user-id', fetch: 'data-disable-fetch', ready: 'is-ready',