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. */
export interface WebComponentStatics<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
// 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. */
@ -238,16 +238,11 @@ export type AttributesOf<T> = ObservedAttributes<SpecOf<T>>;
/**
* 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<S> =
| keyof InstanceType<BaseOf<S>>
| keyof BaseOf<S>
| keyof WebComponentMembers<unknown>
| keyof WebComponentStatics<unknown>;
type ReservedName<S> = keyof InstanceType<BaseOf<S>> | keyof WebComponentMembers<unknown>;
/** Branded error type: shows up in the message when a prop name collides. */
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.
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 attributeFor = Object.fromEntries(
static readonly attributes = Object.fromEntries(
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}`,
);
}
}
// 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>;
} as unknown as WebComponentConstructor<S>;
}

View File

@ -56,9 +56,9 @@ describe('attributes', () => {
expectTypeOf<AttributesOf<typeof SPEC>>().toEqualTypeOf<Expected>();
});
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<boolean>();
expectTypeOf(El.shine).toEqualTypeOf<number>();
});
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<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' }) },
}) {}
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',