Rename static attributes map to attributeFor and reserve the name

This commit is contained in:
Buddy 2026-11-06 14:09:38 -08:00
parent c961d01fc4
commit 6efcdd210e
4 changed files with 19 additions and 10 deletions

View File

@ -181,8 +181,8 @@ export type WebComponentConstructor<S> = Omit<BaseOf<S>, 'prototype'> & {
new (): WebComponentInstance<S>;
readonly prototype: WebComponentInstance<S>;
readonly observedAttributes: readonly ObservedAttributes<S>[];
/** Prop key → attribute name. */
readonly attributes: AttributeMap<S>;
/** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */
readonly attributeFor: AttributeMap<S>;
readonly [spec]?: S;
};
@ -202,19 +202,26 @@ export type AttributesOf<T> = ObservedAttributes<SpecOf<T>>;
/** Mixin members a prop may not shadow. */
type MixinMember = keyof WebComponentMembers<unknown>;
/**
* Every name a prop may not use. `attributeFor` is static, so it wouldn't
* collide at runtime; it's reserved so `El.attributeFor` and a prop can't be
* confused.
*/
type ReservedName = MixinMember | 'attributeFor';
/** Branded error type: shows up in the message when a prop name collides. */
export type PropNameConflict<K extends PropertyKey> =
`Error: prop "${K & string}" conflicts with an existing element member`;
/**
* Intersected with the spec parameter. Any prop whose name already exists on
* the base instance (`name`, `title`, `hidden` on `HTMLElement`; `value` on a
* form-ish base) or on the mixin itself is retyped to {@link PropNameConflict},
* the base instance (`title`, `hidden`, `id` on `HTMLElement`; `value` on a
* form-ish base), on the mixin, or is otherwise reserved, is retyped to {@link PropNameConflict},
* so the error lands on that key.
*/
export type ValidateSpec<S> = {
readonly props?: {
readonly [K in keyof PropsOf<S>]: K extends keyof InstanceType<BaseOf<S>> | MixinMember
readonly [K in keyof PropsOf<S>]: K extends keyof InstanceType<BaseOf<S>> | ReservedName
? PropNameConflict<K>
: PropsOf<S>[K];
};

View File

@ -61,7 +61,7 @@ export function WebComponent<const S extends Spec>(
class WebComponentElement extends Base {
static readonly observedAttributes = resolved.map((prop) => prop.attribute);
static readonly attributes = Object.fromEntries(
static readonly attributeFor = Object.fromEntries(
resolved.map((prop) => [prop.key, prop.attribute]),
);

View File

@ -56,9 +56,9 @@ describe('attributes', () => {
expectTypeOf<AttributesOf<typeof SPEC>>().toEqualTypeOf<Expected>();
});
test('attributes maps each key to its attribute', () => {
expectTypeOf(Example.attributes.fetch).toEqualTypeOf<'disable-fetch'>();
expectTypeOf(Example.attributes.ready).toEqualTypeOf<'is-ready'>();
test('attributeFor maps each key to its attribute', () => {
expectTypeOf(Example.attributeFor.fetch).toEqualTypeOf<'disable-fetch'>();
expectTypeOf(Example.attributeFor.ready).toEqualTypeOf<'is-ready'>();
});
test('dataPrefix applies only to derived names', () => {
@ -157,6 +157,8 @@ 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() } });
});
test('conflicts are checked against the base class', () => {

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.attributes).toEqual({
expect(El.attributeFor).toEqual({
userId: 'data-user-id',
fetch: 'data-disable-fetch',
ready: 'is-ready',