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 + + + +``` + +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 @@ + + + + + + + + + + + + + + + + + + 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..f38bc6eebdb --- /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).foobar = "world"; + } + + public setMultiple() { + (this as any).foo = "updated"; + (this as any).foobar = "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" });