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:
parent
767fa0eb41
commit
6ece00c8cc
17
src/types.ts
17
src/types.ts
@ -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> =
|
||||||
|
|||||||
@ -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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user