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.
This commit is contained in:
Buddy 2026-11-07 12:25:11 -08:00
parent 767fa0eb41
commit 6ece00c8cc
4 changed files with 28 additions and 33 deletions

View File

@ -209,10 +209,10 @@ export type WebComponentInstance<S> = InstanceType<BaseOf<S>> &
/** The static members {@link WebComponent} adds to the generated class. */ /** The static members {@link WebComponent} adds to the generated class. */
export interface WebComponentStatics<S> { export interface WebComponentStatics<S> {
readonly observedAttributes: readonly ObservedAttributes<S>[]; readonly observedAttributes: readonly ObservedAttributes<S>[];
/** 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<S>` directly) so editors show the // Re-mapped inline (not `AttributeMap<S>` directly) so editors show the
// resolved `{ fetch: "disable-fetch"; ... }` rather than the alias. // resolved `{ fetch: "disable-fetch"; ... }` rather than the alias.
readonly attributeFor: { readonly [K in keyof AttributeMap<S>]: AttributeMap<S>[K] }; readonly attributes: { readonly [K in keyof AttributeMap<S>]: AttributeMap<S>[K] };
} }
/** The constructor type {@link WebComponent} returns. */ /** The constructor type {@link WebComponent} returns. */
@ -238,16 +238,11 @@ export type AttributesOf<T> = ObservedAttributes<SpecOf<T>>;
/** /**
* Every name a prop may not use: members of the base instance (`title`, * 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 * `hidden`, `id` on `HTMLElement`; `value` on a form-ish base) and the members
* base class, and the members and statics the mixin adds. The static names * the mixin adds. Statics aren't included; props live on instances, so they
* wouldn't collide at runtime (props live on instances); they're reserved so * can't collide.
* `El.attributeFor` / `El.observedAttributes` and a prop can't be confused.
*/ */
type ReservedName<S> = type ReservedName<S> = keyof InstanceType<BaseOf<S>> | keyof WebComponentMembers<unknown>;
| keyof InstanceType<BaseOf<S>>
| keyof BaseOf<S>
| keyof WebComponentMembers<unknown>
| keyof WebComponentStatics<unknown>;
/** Branded error type: shows up in the message when a prop name collides. */ /** Branded error type: shows up in the message when a prop name collides. */
export type PropNameConflict<K extends PropertyKey> = export type PropNameConflict<K extends PropertyKey> =

View File

@ -73,9 +73,12 @@ export function WebComponent<const S extends Spec>(
// root) for this class, not once per element. // root) for this class, not once per element.
const styledRoots = new WeakSet<Node>(); const styledRoots = new WeakSet<Node>();
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 observedAttributes = resolved.map((prop) => prop.attribute);
static readonly attributeFor = Object.fromEntries( static readonly attributes = Object.fromEntries(
resolved.map((prop) => [prop.key, prop.attribute]), resolved.map((prop) => [prop.key, prop.attribute]),
); );
@ -279,10 +282,5 @@ export function WebComponent<const S extends Spec>(
`<${this.localName}> ref "${name}": "${selector}" matched <${tag}>, which is not a ${type.name}`, `<${this.localName}> ref "${name}": "${selector}" matched <${tag}>, which is not a ${type.name}`,
); );
} }
} } as unknown as WebComponentConstructor<S>;
// 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<S>;
} }

View File

@ -56,9 +56,9 @@ describe('attributes', () => {
expectTypeOf<AttributesOf<typeof SPEC>>().toEqualTypeOf<Expected>(); expectTypeOf<AttributesOf<typeof SPEC>>().toEqualTypeOf<Expected>();
}); });
test('attributeFor maps each key to its attribute', () => { test('attributes maps each key to its attribute', () => {
expectTypeOf(Example.attributeFor.fetch).toEqualTypeOf<'disable-fetch'>(); expectTypeOf(Example.attributes.fetch).toEqualTypeOf<'disable-fetch'>();
expectTypeOf(Example.attributeFor.ready).toEqualTypeOf<'is-ready'>(); expectTypeOf(Example.attributes.ready).toEqualTypeOf<'is-ready'>();
}); });
test('dataPrefix applies only to derived names', () => { test('dataPrefix applies only to derived names', () => {
@ -174,10 +174,18 @@ describe('conflicts', () => {
test('prop names colliding with mixin members are rejected', () => { test('prop names colliding with mixin members are rejected', () => {
// @ts-expect-error -- `signal` is a mixin member // @ts-expect-error -- `signal` is a mixin member
WebComponent({ props: { signal: string() } }); 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 test('static names are allowed as props', () => {
WebComponent({ props: { observedAttributes: string() } }); class WithStatic extends HTMLElement {
static shine = 1;
}
const El = WebComponent({
base: WithStatic,
props: { shine: enable(), observedAttributes: string() },
});
expectTypeOf(new El().shine).toEqualTypeOf<boolean>();
expectTypeOf(El.shine).toEqualTypeOf<number>();
}); });
test('conflicts are checked against the base class', () => { test('conflicts are checked against the base class', () => {
@ -187,12 +195,6 @@ describe('conflicts', () => {
// @ts-expect-error -- `sparkle` is on the base // @ts-expect-error -- `sparkle` is on the base
WebComponent({ base: Fancy, props: { sparkle: enable() } }); 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() } }); const El = WebComponent({ base: Fancy, props: { glow: enable() } });
expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>(); expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>();
expectTypeOf(new El().glow).toEqualTypeOf<boolean>(); expectTypeOf(new El().glow).toEqualTypeOf<boolean>();

View File

@ -109,7 +109,7 @@ describe('props', () => {
props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) }, props: { userId: number(), fetch: disable(), ready: enable({ attribute: 'is-ready' }) },
}) {} }) {}
expect(El.observedAttributes).toEqual(['data-user-id', 'data-disable-fetch', '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', userId: 'data-user-id',
fetch: 'data-disable-fetch', fetch: 'data-disable-fetch',
ready: 'is-ready', ready: 'is-ready',