diff --git a/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json
new file mode 100644
index 00000000000..bb079029048
--- /dev/null
+++ b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json
@@ -0,0 +1,7 @@
+{
+ "type": "prerelease",
+ "comment": "feat(fast-html): add AttributeMap class for automatic @attr definitions on leaf template bindings",
+ "packageName": "@microsoft/fast-html",
+ "email": "7559015+janechu@users.noreply.github.com",
+ "dependentChangeType": "none"
+}
diff --git a/packages/fast-html/DESIGN.md b/packages/fast-html/DESIGN.md
index 57638d2a314..a1af26553c1 100644
--- a/packages/fast-html/DESIGN.md
+++ b/packages/fast-html/DESIGN.md
@@ -89,6 +89,17 @@ An optional layer that uses the `Schema` to automatically:
Enabled via `TemplateElement.options({ "my-element": { observerMap: "all" } })`.
+### `AttributeMap` — automatic `@attr` definitions
+
+An optional layer that uses the `Schema` to automatically register `@attr`-style reactive properties for every **leaf binding** in the template — i.e. simple expressions like `{{foo}}` or `id="{{foo-bar}}"` that have no nested properties, no explicit type, and no child element references.
+
+- The **attribute name** is the binding key exactly as written in the template (e.g. `foo-bar`).
+- The **property name** is derived by removing all dashes (e.g. `foo-bar` → `foobar`).
+- Properties already decorated with `@attr` or `@observable` are left untouched.
+- `FASTElementDefinition.attributeLookup` and `propertyLookup` are patched so `attributeChangedCallback` correctly delegates to the new `AttributeDefinition`.
+
+Enabled via `TemplateElement.options({ "my-element": { attributeMap: "all" } })`.
+
### Syntax constants (`syntax.ts`)
All delimiters used by the parser are defined in a single `Syntax` interface and exported as named constants from `syntax.ts`. This makes the syntax pluggable and easy to audit.
@@ -284,6 +295,15 @@ flowchart LR
For a deep dive into the schema structure, context tracking, and proxy system see [SCHEMA_OBSERVER_MAP.md](./SCHEMA_OBSERVER_MAP.md).
+### AttributeMap and leaf bindings
+
+When `attributeMap: "all"` is set, `AttributeMap.defineProperties()` is called after parsing. It iterates `Schema.getRootProperties()` and skips any property whose schema entry contains `properties`, `type`, or `anyOf` — keeping only plain leaf bindings. For each leaf:
+
+1. The schema key (e.g. `foo-bar`) is used as the **attribute name**.
+2. Dashes are removed to form the **JS property name** (e.g. `foobar`).
+3. A new `AttributeDefinition` is registered via `Observable.defineProperty`.
+4. `FASTElementDefinition.attributeLookup` and `propertyLookup` are updated so `attributeChangedCallback` can route attribute changes to the correct property.
+
---
## Lifecycle
diff --git a/packages/fast-html/README.md b/packages/fast-html/README.md
index 610adb8f1b6..eabce695720 100644
--- a/packages/fast-html/README.md
+++ b/packages/fast-html/README.md
@@ -217,6 +217,33 @@ if (process.env.NODE_ENV === 'development') {
}
```
+#### `attributeMap`
+
+When `attributeMap: "all"` is configured for an element, `@microsoft/fast-html` automatically creates reactive `@attr` properties for every **leaf binding** in the template — simple expressions like `{{foo}}` or `id="{{foo-bar}}"` that have no nested properties.
+
+The **attribute name** is the binding key as written in the template. The **property name** is derived by removing all dashes (`foo-bar` → `foobar`). Properties already decorated with `@attr` or `@observable` on the class are left untouched.
+
+```typescript
+TemplateElement.options({
+ "my-element": {
+ attributeMap: "all",
+ },
+});
+```
+
+With the template:
+
+```html
+
+
+
{{greeting}}
+
{{first-name}}
+
+
+```
+
+This registers `greeting` (attribute `greeting`, property `greeting`) and `first-name` (attribute `first-name`, property `firstname`) as `@attr` properties on the element prototype, enabling `setAttribute("first-name", "Jane")` to trigger a template re-render automatically.
+
### Syntax
All bindings use a handlebars-like syntax.
diff --git a/packages/fast-html/src/components/attribute-map.spec.ts b/packages/fast-html/src/components/attribute-map.spec.ts
new file mode 100644
index 00000000000..ee84c0ef4c6
--- /dev/null
+++ b/packages/fast-html/src/components/attribute-map.spec.ts
@@ -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 foobar 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 define @attr for a foobar property using the same attribute name", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ // Setting the foobar attribute should update the foobar property
+ await element.evaluate(node => node.setAttribute("foobar", "foobar-test"));
+ const propValue = await element.evaluate(node => (node as any).foobar);
+
+ expect(propValue).toBe("foobar-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 foobar attribute is set via setAttribute", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ await element.evaluate(node => node.setAttribute("foobar", "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 for foobar property", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ // setAttribute triggers attributeChangedCallback for the foobar property
+ await element.evaluate(node => node.setAttribute("foobar", "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");
+ });
+});
diff --git a/packages/fast-html/src/components/attribute-map.ts b/packages/fast-html/src/components/attribute-map.ts
new file mode 100644
index 00000000000..df5a394468e
--- /dev/null
+++ b/packages/fast-html/src/components/attribute-map.ts
@@ -0,0 +1,97 @@
+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="{{foo-bar}}".
+ *
+ * Dash-case attribute names are converted to property names by removing dashes
+ * (e.g. foo-bar → foobar).
+ */
+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)
+ const attributeName = propertyName;
+ const jsPropertyName = this.removeDashes(propertyName);
+ if (existingAccessors.some(accessor => accessor.name === jsPropertyName)) {
+ continue;
+ }
+
+ const attrDef = new AttributeDefinition(
+ this.classPrototype.constructor,
+ jsPropertyName,
+ 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)[
+ attributeName
+ ] = attrDef;
+ (this.definition.propertyLookup as Record)[
+ jsPropertyName
+ ] = attrDef;
+ }
+ }
+ }
+
+ /**
+ * Derives a JS property name from an attribute name by removing all dashes.
+ * Template bindings should not use dashes (e.g. use {{foobar}} not {{foo-bar}}),
+ * but HTML attributes can have dashes (e.g. foo-bar), so this ensures
+ * a valid JS identifier is always produced.
+ * e.g. foo-bar → foobar
+ */
+ private removeDashes(str: string): string {
+ return str.replaceAll("-", "");
+ }
+}
diff --git a/packages/fast-html/src/components/index.ts b/packages/fast-html/src/components/index.ts
index 37bd831adfc..585a5f06f7a 100644
--- a/packages/fast-html/src/components/index.ts
+++ b/packages/fast-html/src/components/index.ts
@@ -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";
diff --git a/packages/fast-html/src/components/template.ts b/packages/fast-html/src/components/template.ts
index 819db7b5c97..c4581ebd2f2 100644
--- a/packages/fast-html/src/components/template.ts
+++ b/packages/fast-html/src/components/template.ts
@@ -56,11 +56,22 @@ export const ObserverMapOption = {
export type ObserverMapOption =
(typeof ObserverMapOption)[keyof typeof ObserverMapOption];
+export const AttributeMapOption = {
+ all: "all",
+} as const;
+
+/**
+ * Type for the attributeMap element option.
+ */
+export type AttributeMapOption =
+ (typeof AttributeMapOption)[keyof typeof AttributeMapOption];
+
/**
* Element options the TemplateElement will use to update the registered element
*/
export interface ElementOptions {
observerMap?: ObserverMapOption;
+ attributeMap?: AttributeMapOption;
}
/**
diff --git a/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts
new file mode 100644
index 00000000000..6f0859b6e94
--- /dev/null
+++ b/packages/fast-html/test/fixtures/attribute-map/attribute-map.spec.ts
@@ -0,0 +1,121 @@
+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 accessors for leaf properties from the template", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ const accessors = await element.evaluate(node => {
+ const proto = Object.getPrototypeOf(node);
+ const isAccessor = (name: string) =>
+ typeof Object.getOwnPropertyDescriptor(proto, name)?.get === "function";
+ return { foo: isAccessor("foo"), foobar: isAccessor("foobar") };
+ });
+
+ expect(accessors.foo).toBeTruthy();
+ expect(accessors.foobar).toBeTruthy();
+ });
+
+ test("should define @attr using binding name as both attribute and property name", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ // Setting foobar attribute should update the foobar property
+ await element.evaluate(node => node.setAttribute("foobar", "dash-test"));
+ const propValue = await element.evaluate(node => (node as any).foobar);
+
+ expect(propValue).toBe("dash-test");
+ });
+
+ test("should use the same name for non-camelCase property", async ({ page }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ // Setting foo attribute should update the foo property
+ await element.evaluate(node => node.setAttribute("foo", "same-name-test"));
+ const propValue = await element.evaluate(node => (node as any).foo);
+
+ expect(propValue).toBe("same-name-test");
+ });
+
+ test("should update template when foo attribute is set via setAttribute", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ await element.evaluate(node => node.setAttribute("foo", "hello-via-attr"));
+
+ await expect(page.locator(".foo-value")).toHaveText("hello-via-attr");
+ });
+
+ test("should update template when foobar attribute is set via setAttribute", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ await element.evaluate(node => node.setAttribute("foobar", "world-via-attr"));
+
+ await expect(page.locator(".foo-bar-value")).toHaveText("world-via-attr");
+ });
+
+ test("should update both properties when set via setAttribute", async ({ page }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ await element.evaluate(node => {
+ node.setAttribute("foo", "multi-foo");
+ node.setAttribute("foobar", "multi-bar");
+ });
+
+ await expect(page.locator(".foo-value")).toHaveText("multi-foo");
+ await expect(page.locator(".foo-bar-value")).toHaveText("multi-bar");
+ });
+
+ test("should reflect foo property value back to foo attribute", async ({ page }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ await element.evaluate(node => {
+ (node as any).foo = "reflected-value";
+ });
+
+ // 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-value");
+ });
+
+ test("should reflect foobar property value back to foobar attribute", async ({
+ page,
+ }) => {
+ const element = page.locator("attribute-map-test-element");
+
+ await element.evaluate(node => {
+ (node as any).foobar = "bar-reflected";
+ });
+
+ await page.evaluate(() => new Promise(r => requestAnimationFrame(r)));
+
+ const attrValue = await element.evaluate(node => node.getAttribute("foobar"));
+ expect(attrValue).toBe("bar-reflected");
+ });
+
+ 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");
+ });
+});
diff --git a/packages/fast-html/test/fixtures/attribute-map/index.html b/packages/fast-html/test/fixtures/attribute-map/index.html
new file mode 100644
index 00000000000..893750267d3
--- /dev/null
+++ b/packages/fast-html/test/fixtures/attribute-map/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+