Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Add AttributeMap class for automatic @attr definitions on leaf template bindings",
"packageName": "@microsoft/fast-html",
"email": "7559015+janechu@users.noreply.github.com",
"dependentChangeType": "none"
}
143 changes: 143 additions & 0 deletions packages/fast-html/src/components/attribute-map.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { expect, test } from "@playwright/test";

test.describe("AttributeMap", async () => {
test.beforeEach(async ({ page }) => {
await page.goto("/fixtures/attribute-map/");
await page.waitForSelector("attribute-map-test-element");
});

test("should define @attr for a simple leaf property", async ({ page }) => {
const element = page.locator("attribute-map-test-element");

const hasFooAccessor = await element.evaluate(node => {
const desc = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(node),
"foo",
);
return typeof desc?.get === "function";
});

expect(hasFooAccessor).toBeTruthy();
});

test("should define @attr for a camelCase property", async ({ page }) => {
const element = page.locator("attribute-map-test-element");

const hasFooBarAccessor = await element.evaluate(node => {
const desc = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(node),
"fooBar",
);
return typeof desc?.get === "function";
});

expect(hasFooBarAccessor).toBeTruthy();
});

test("should convert camelCase property name to dash-case attribute name", async ({
page,
}) => {
const element = page.locator("attribute-map-test-element");

// Setting the dash-case attribute should update the camelCase property
await element.evaluate(node => node.setAttribute("foo-bar", "dash-case-test"));
const propValue = await element.evaluate(node => (node as any).fooBar);

expect(propValue).toBe("dash-case-test");
});

test("should not define @attr for event handler methods", async ({ page }) => {
const element = page.locator("attribute-map-test-element");

// @click="{setFoo()}" etc. produce "event" type bindings — excluded from schema.
// Regular methods have a value descriptor, not a getter/setter.
const results = await element.evaluate(node => {
const proto = Object.getPrototypeOf(node);
const isAccessor = (name: string) => {
const desc = Object.getOwnPropertyDescriptor(proto, name);
return typeof desc?.get === "function";
};
return {
setFoo: isAccessor("setFoo"),
setFooBar: isAccessor("setFooBar"),
setMultiple: isAccessor("setMultiple"),
};
});

expect(results.setFoo).toBe(false);
expect(results.setFooBar).toBe(false);
expect(results.setMultiple).toBe(false);
});

test("should update template when attribute is set via setAttribute", async ({
page,
}) => {
const element = page.locator("attribute-map-test-element");

await element.evaluate(node => node.setAttribute("foo", "attr-value"));

await expect(page.locator(".foo-value")).toHaveText("attr-value");
});

test("should update template when dash-case attribute is set via setAttribute", async ({
page,
}) => {
const element = page.locator("attribute-map-test-element");

await element.evaluate(node => node.setAttribute("foo-bar", "bar-attr-value"));

await expect(page.locator(".foo-bar-value")).toHaveText("bar-attr-value");
});

test("should reflect property value back to attribute", async ({ page }) => {
const element = page.locator("attribute-map-test-element");

await element.evaluate(node => {
(node as any).foo = "reflected";
});

// FAST reflects attributes asynchronously via Updates.enqueue
await page.evaluate(() => new Promise(r => requestAnimationFrame(r)));

const attrValue = await element.evaluate(node => node.getAttribute("foo"));
expect(attrValue).toBe("reflected");
});

test("should update definition attributeLookup for simple properties", async ({
page,
}) => {
const element = page.locator("attribute-map-test-element");

// setAttribute triggers attributeChangedCallback via attributeLookup
await element.evaluate(node => node.setAttribute("foo", "lookup-test"));
const propValue = await element.evaluate(node => (node as any).foo);

expect(propValue).toBe("lookup-test");
});

test("should update definition attributeLookup with dash-case for camelCase properties", async ({
page,
}) => {
const element = page.locator("attribute-map-test-element");

// setAttribute with dash-case triggers attributeChangedCallback for the camelCase property
await element.evaluate(node => node.setAttribute("foo-bar", "lookup-bar-test"));
const propValue = await element.evaluate(node => (node as any).fooBar);

expect(propValue).toBe("lookup-bar-test");
});

test("should not overwrite an existing @attr accessor", async ({ page }) => {
await page.waitForSelector("attribute-map-existing-attr-test-element");
const element = page.locator("attribute-map-existing-attr-test-element");

// The @attr default value must survive AttributeMap processing
const defaultValue = await element.evaluate(node => (node as any).foo);
expect(defaultValue).toBe("original");

// setAttribute must still work via the original @attr definition
await element.evaluate(node => node.setAttribute("foo", "updated"));
const updatedValue = await element.evaluate(node => (node as any).foo);
expect(updatedValue).toBe("updated");
});
});
92 changes: 92 additions & 0 deletions packages/fast-html/src/components/attribute-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { FASTElementDefinition } from "@microsoft/fast-element";
import { AttributeDefinition, Observable } from "@microsoft/fast-element";
import type { Schema } from "./schema.js";

/**
* AttributeMap provides functionality for detecting simple (leaf) properties in
* a generated JSON schema and defining them as @attr properties on a class prototype.
*
* A property is a candidate for @attr when its schema entry has no nested `properties`,
* no `type`, and no `anyOf` — i.e. it is a plain binding like {{foo}} or id="{{bar}}".
*
* camelCase property names are converted to dash-case attribute names (e.g. fooBar → foo-bar).
*/
export class AttributeMap {
private schema: Schema;
private classPrototype: any;
private definition: FASTElementDefinition | undefined;

constructor(classPrototype: any, schema: Schema, definition?: FASTElementDefinition) {
this.classPrototype = classPrototype;
this.schema = schema;
this.definition = definition;
}

public defineProperties(): void {
const propertyNames = this.schema.getRootProperties();
const existingAccessors = Observable.getAccessors(this.classPrototype);

for (const propertyName of propertyNames) {
const propertySchema = this.schema.getSchema(propertyName);

// Only create @attr for leaf properties:
// - no nested properties (not a dot-syntax path)
// - no type (not an explicitly typed value like an array)
// - no anyOf (not a child element reference)
if (
!propertySchema ||
propertySchema.properties ||
propertySchema.type ||
propertySchema.anyOf
) {
continue;
}

// Skip if the property already has an accessor (from @attr or @observable)
if (existingAccessors.some(accessor => accessor.name === propertyName)) {
continue;
}

const attributeName = this.camelCaseToDashCase(propertyName);
const attrDef = new AttributeDefinition(
this.classPrototype.constructor,
propertyName,
attributeName,
);

Observable.defineProperty(this.classPrototype, attrDef);

// Push to the existing observedAttributes array on the class.
// For all f-template-registered elements, registry.define() (which causes
// the browser to cache observedAttributes) is called AFTER this method runs.
// Mutating the existing array reference ensures the browser observes these
// attributes, enabling setAttribute() → attributeChangedCallback() → template update.
const existingObservedAttrs: string[] | undefined = (
this.classPrototype.constructor as any
).observedAttributes;
if (
Array.isArray(existingObservedAttrs) &&
!existingObservedAttrs.includes(attributeName)
) {
existingObservedAttrs.push(attributeName);
}

if (this.definition) {
(this.definition.attributeLookup as Record<string, AttributeDefinition>)[
attributeName
] = attrDef;
(this.definition.propertyLookup as Record<string, AttributeDefinition>)[
propertyName
] = attrDef;
}
}
}

/**
* Converts a camelCase string to dash-case.
* e.g. fooBar → foo-bar
*/
private camelCaseToDashCase(str: string): string {
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
}
6 changes: 4 additions & 2 deletions packages/fast-html/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export { AttributeMap } from "./attribute-map.js";
export { RenderableFASTElement } from "./element.js";
export { ObserverMap } from "./observer-map.js";
export {
ObserverMapOption,
TemplateElement,
AttributeMapOption,
type ElementOptions,
type ElementOptionsDictionary,
type HydrationLifecycleCallbacks,
ObserverMapOption,
TemplateElement,
} from "./template.js";
Loading
Loading