From 6ece00c8ccd2084a490a3f237111c98cc73b0d3d Mon Sep 17 00:00:00 2001 From: Buddy Date: Sat, 7 Nov 2026 12:25:11 -0800 Subject: [PATCH] Allow static names as props and rename attributeFor back to attributes Props live on instances, so statics on the generated class or the base can't collide with them. Only instance members of the base and the mixin are reserved now. --- src/types.ts | 17 ++++++----------- src/web-component.ts | 14 ++++++-------- test/types.test-d.ts | 28 +++++++++++++++------------- test/web-component.test.ts | 2 +- 4 files changed, 28 insertions(+), 33 deletions(-) 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',