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..d10372a542e --- /dev/null +++ b/change/@microsoft-fast-html-90d4b1e4-b272-4691-9b04-e6290ebc8b2f.json @@ -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" +} diff --git a/packages/fast-html/DESIGN.md b/packages/fast-html/DESIGN.md index e14fdf780c4..5e482aebef0 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** and **property name** are both the binding key exactly as written in the template (e.g. `{{foo-bar}}` → attribute `foo-bar`, property `foo-bar`). No normalization is applied. +- Because HTML attributes are case-insensitive, binding keys should use lowercase names (optionally dash-separated). +- 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,16 @@ 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 both the **attribute name** and the **JS property name** — no conversion is applied. +2. A new `AttributeDefinition` is registered via `Observable.defineProperty`. +3. `FASTElementDefinition.attributeLookup` and `propertyLookup` are updated so `attributeChangedCallback` can route attribute changes to the correct property. + +Because property names may contain dashes, they must be accessed via bracket notation (e.g. `element["foo-bar"]`). + --- ## Lifecycle diff --git a/packages/fast-html/README.md b/packages/fast-html/README.md index ba9d2826e26..5201d06e395 100644 --- a/packages/fast-html/README.md +++ b/packages/fast-html/README.md @@ -217,6 +217,35 @@ 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** and **property name** are both the binding key exactly as written in the template — no normalization is applied. Because HTML attributes are case-insensitive, binding keys should use lowercase names (optionally dash-separated). Properties with dashes must be accessed via bracket notation (e.g. `element["foo-bar"]`). + +Properties already decorated with `@attr` or `@observable` on the class are left untouched. + +```typescript +TemplateElement.options({ + "my-element": { + attributeMap: "all", + }, +}).define({ name: "f-template" }); +``` + +With the template: + +```html + + + +``` + +This registers `greeting` (attribute `greeting`, property `greeting`) and `first-name` (attribute `first-name`, property `first-name`) 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..61ab6385653 --- /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", () => { + 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 dash-case property", async ({ page }) => { + const element = page.locator("attribute-map-test-element"); + + const hasFooBarAccessor = await element.evaluate(node => { + const desc = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(node), + "foo-bar", + ); + return typeof desc?.get === "function"; + }); + + expect(hasFooBarAccessor).toBeTruthy(); + }); + + test("should use binding key as-is for both attribute and property name", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + // Setting the foo-bar attribute should update the foo-bar property (no conversion) + await element.evaluate(node => node.setAttribute("foo-bar", "dash-case-test")); + const propValue = await element.evaluate(node => (node as any)["foo-bar"]); + + 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 for dash-case properties", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + // setAttribute with foo-bar triggers attributeChangedCallback for the foo-bar property + await element.evaluate(node => node.setAttribute("foo-bar", "lookup-bar-test")); + const propValue = await element.evaluate(node => (node as any)["foo-bar"]); + + 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..40a6a5fa3a4 --- /dev/null +++ b/packages/fast-html/src/components/attribute-map.ts @@ -0,0 +1,102 @@ +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}}". + * + * Attribute names are **not** normalized — the binding key as written in the template + * is used as both the attribute name and the property name. Because HTML attributes are + * case-insensitive, binding keys should be lowercase (optionally dash-separated). + * For example, {{foo-bar}} results in attribute `foo-bar` and property `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 existingAccessorNames = new Set( + Observable.getAccessors(this.classPrototype).map(a => a.name), + ); + + 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 (existingAccessorNames.has(propertyName)) { + continue; + } + + const attrDef = new AttributeDefinition( + this.classPrototype.constructor, + propertyName, + propertyName, + ); + + Observable.defineProperty(this.classPrototype, attrDef); + + // Mutate the existing observedAttributes array on the class. + // FAST's FASTElementDefinition sets observedAttributes via + // Reflect.defineProperty with a concrete array value (non-configurable, + // non-writable), so the descriptor cannot be replaced. However, the + // array itself is mutable, and pushing into it works because + // registry.define() — which causes the browser to snapshot + // observedAttributes — is called AFTER this method runs. + const existingObservedAttrs: string[] | undefined = ( + this.classPrototype.constructor as any + ).observedAttributes; + if ( + Array.isArray(existingObservedAttrs) && + !existingObservedAttrs.includes(propertyName) + ) { + existingObservedAttrs.push(propertyName); + } + + if (this.definition) { + (this.definition.attributeLookup as Record)[ + propertyName + ] = attrDef; + (this.definition.propertyLookup as Record)[ + propertyName + ] = attrDef; + + const attrs = (this.definition as any).attributes; + if ( + Array.isArray(attrs) && + !attrs.some( + (existing: AttributeDefinition) => + existing.name === attrDef.name || + existing.attribute === attrDef.attribute, + ) + ) { + attrs.push(attrDef); + } + } + } + } +} 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..b7c59e808f2 100644 --- a/packages/fast-html/src/components/template.ts +++ b/packages/fast-html/src/components/template.ts @@ -17,6 +17,7 @@ import { } from "@microsoft/fast-element"; import "@microsoft/fast-element/install-hydratable-view-templates.js"; import { Message } from "../interfaces.js"; +import { AttributeMap } from "./attribute-map.js"; import { ObserverMap } from "./observer-map.js"; import { Schema } from "./schema.js"; import { @@ -56,11 +57,25 @@ export const ObserverMapOption = { export type ObserverMapOption = (typeof ObserverMapOption)[keyof typeof ObserverMapOption]; +/** + * Values for the attributeMap element option. + */ +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; } /** @@ -108,6 +123,11 @@ class TemplateElement extends FASTElement { */ private observerMap?: ObserverMap; + /** + * AttributeMap instance for defining @attr properties + */ + private attributeMap?: AttributeMap; + /** * Default element options */ @@ -151,6 +171,7 @@ class TemplateElement extends FASTElement { const value = elementOptions[key]; result[key] = { observerMap: value.observerMap, + attributeMap: value.attributeMap, }; } @@ -209,6 +230,15 @@ class TemplateElement extends FASTElement { const registeredFastElement: FASTElementDefinition | undefined = fastElementRegistry.getByType(value); + + if (TemplateElement.elementOptions[name]?.attributeMap === "all") { + this.attributeMap = new AttributeMap( + value.prototype, + this.schema as Schema, + registeredFastElement, + ); + } + const template = this.getElementsByTagName("template").item(0); if (template) { @@ -231,6 +261,9 @@ class TemplateElement extends FASTElement { // Define the root properties cached in the observer map as observable (only if observerMap exists) this.observerMap?.defineProperties(); + // Define the leaf properties as @attr (only if attributeMap exists) + this.attributeMap?.defineProperties(); + if (registeredFastElement) { // Attach lifecycle callbacks to the definition before assigning template // This allows the Observable notification to trigger the callbacks 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..5d695401646 --- /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", () => { + 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"), "foo-bar": isAccessor("foo-bar") }; + }); + + expect(accessors.foo).toBeTruthy(); + expect(accessors["foo-bar"]).toBeTruthy(); + }); + + test("should use binding key as-is for attribute and property name", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + // Setting foo-bar attribute should update the foo-bar property (no conversion) + await element.evaluate(node => node.setAttribute("foo-bar", "dash-test")); + const propValue = await element.evaluate(node => (node as any)["foo-bar"]); + + 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 foo-bar attribute is set via setAttribute", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => node.setAttribute("foo-bar", "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("foo-bar", "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 foo-bar property value back to foo-bar attribute", async ({ + page, + }) => { + const element = page.locator("attribute-map-test-element"); + + await element.evaluate(node => { + (node as any)["foo-bar"] = "bar-reflected"; + }); + + await page.evaluate(() => new Promise(r => requestAnimationFrame(r))); + + const attrValue = await element.evaluate(node => node.getAttribute("foo-bar")); + 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/entry.html b/packages/fast-html/test/fixtures/attribute-map/entry.html new file mode 100644 index 00000000000..7fdcce650ae --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/entry.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + 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..aec0d892b22 --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/fast-html/test/fixtures/attribute-map/main.ts b/packages/fast-html/test/fixtures/attribute-map/main.ts new file mode 100644 index 00000000000..cf2483c441f --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/main.ts @@ -0,0 +1,37 @@ +import { attr, FASTElement } from "@microsoft/fast-element"; +import { TemplateElement } from "@microsoft/fast-html"; + +class AttributeMapTestElement extends FASTElement { + public setFoo() { + (this as any).foo = "hello"; + } + + public setFooBar() { + (this as any)["foo-bar"] = "world"; + } + + public setMultiple() { + (this as any).foo = "updated"; + (this as any)["foo-bar"] = "also-updated"; + } +} + +AttributeMapTestElement.defineAsync({ name: "attribute-map-test-element" }); + +class AttributeMapWithExistingAttrElement extends FASTElement { + @attr + foo: string = "original"; +} + +AttributeMapWithExistingAttrElement.defineAsync({ + name: "attribute-map-existing-attr-test-element", +}); + +TemplateElement.options({ + "attribute-map-test-element": { + attributeMap: "all", + }, + "attribute-map-existing-attr-test-element": { + attributeMap: "all", + }, +}).define({ name: "f-template" }); diff --git a/packages/fast-html/test/fixtures/attribute-map/state.json b/packages/fast-html/test/fixtures/attribute-map/state.json new file mode 100644 index 00000000000..82182617b39 --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/state.json @@ -0,0 +1,4 @@ +{ + "foo": "", + "foo-bar": "" +} diff --git a/packages/fast-html/test/fixtures/attribute-map/templates.html b/packages/fast-html/test/fixtures/attribute-map/templates.html new file mode 100644 index 00000000000..9f63f63d286 --- /dev/null +++ b/packages/fast-html/test/fixtures/attribute-map/templates.html @@ -0,0 +1,14 @@ + + + + + +