Skip to content

Commit

Permalink
Add attribute and attribute bindings (#7075)
Browse files Browse the repository at this point in the history
# Pull Request

## 📖 Description

This change adds logic for converting:
- child nodes into strings
- attributes into strings
- attribute bindings (not inclusive of boolean attributes, events, or properties which will be separate PRs)

## ✅ Checklist

### General

<!--- Review the list and put an x in the boxes that apply. -->

- [ ] I have included a change request file using `$ npm run change`
- [x] I have added tests for my changes.
- [x] I have tested my changes.
- [x] I have updated the project documentation to reflect my changes.
- [x] I have read the [CONTRIBUTING](https://github.com/microsoft/fast/blob/main/CONTRIBUTING.md) documentation and followed the [standards](https://github.com/microsoft/fast/blob/main/CODE_OF_CONDUCT.md#our-standards) for this project.
  • Loading branch information
janechu authored Feb 27, 2025
1 parent f27b750 commit 8555606
Show file tree
Hide file tree
Showing 11 changed files with 223 additions and 18 deletions.
3 changes: 2 additions & 1 deletion packages/web-components/fast-btr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@
"clean": "tsc -b --clean src",
"clean:dist": "node ../../../build/clean.js dist",
"build": "tsc -b src && npm run doc",
"build:attribute": "webpack --config ./src/fixtures/attribute/webpack.config.js",
"build:binding": "webpack --config ./src/fixtures/binding/webpack.config.js",
"build-app": "npm run build:binding",
"build-app": "npm run build:attribute && npm run build:binding",
"build-server": "tsc -b server",
"doc": "api-extractor run --local",
"doc:ci": "api-extractor run",
Expand Down
11 changes: 11 additions & 0 deletions packages/web-components/fast-btr/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ function handlePathRequest(
}

const app = express();
app.get("/attribute", (req: Request, res: Response) =>
handlePathRequest(
"./src/fixtures/attribute/attribute.fixture.html",
"text/html",
req,
res
)
);
app.get("/attribute/main.js", (req: Request, res: Response) =>
handlePathRequest("./server/dist/attribute/main.js", "text/javascript", req, res)
);
app.get("/binding", (req: Request, res: Response) =>
handlePathRequest(
"./src/fixtures/binding/binding.fixture.html",
Expand Down
133 changes: 119 additions & 14 deletions packages/web-components/fast-btr/src/components/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class TemplateElement extends FASTElement {
@attr
public name?: string;

/**
* The binding regex used to identify declarative HTML bindings.
*/
private bindingRegex: RegExp = /{{(?:.*?)}}/g;

private openBinding: string = "{{";
Expand All @@ -41,19 +44,7 @@ class TemplateElement extends FASTElement {
const strings: any[] = [];
const values: any[] = []; // these can be bindings, directives, etc.

childNodes.forEach(childNode => {
switch (childNode.nodeType) {
case 1: // HTMLElement
break;
case 3: // text
this.resolveTextBindings(childNode, strings, values);
break;
default:
break;
}
});

strings.push("");
this.resolveChildNodes(childNodes, strings, values);

(strings as any).raw = strings.map(value =>
String.raw({ raw: value })
Expand All @@ -74,6 +65,118 @@ class TemplateElement extends FASTElement {
}
}

/**
* Resolves child nodes
* @param childNode The child node to interpret.
* @param strings The strings array.
* @param values The interpreted values.
*/
private resolveChildNodes(
childNodes: NodeListOf<ChildNode>,
strings: Array<string>,
values: Array<any>
): void {
childNodes.forEach(childNode => {
switch (childNode.nodeType) {
case 1: // HTMLElement
this.resolveHTMLElement(childNode, strings, values);
break;
case 3: // text
this.resolveTextBindings(childNode, strings, values);
break;
default:
break;
}
});
}

/**
* Resolves the last string in the array and appends a new string to it
* @param newString The new string for the template.
* @param strings The strings array.
* @param values The interpreted values.
*/
private resolveLastStringItem(
newString: string,
strings: Array<string>,
stringsLength: number
): void {
strings[stringsLength - 1] = `${strings[stringsLength - 1]}${newString}`;
}

/**
* Resolves a string to either the previous string value or as a new string in the array
* @param newString The new string for the template.
* @param strings The strings array.
* @param values The interpreted values.
*/
private resolveString(
newString: string,
strings: Array<string>,
values: Array<any>
): void {
const stringsLength = strings.length;

if (stringsLength > values.length) {
this.resolveLastStringItem(newString, strings, stringsLength);
} else {
strings.push(newString);
}
}

/**
* Resolves an HTMLElement
* @param childNode The child node to interpret.
* @param strings The strings array.
* @param values The interpreted values.
*/
private resolveHTMLElement(
childNode: ChildNode,
strings: Array<string>,
values: Array<any>
): void {
const tagName = childNode.nodeName.toLowerCase();

strings.push(`<${tagName}`);

const attributes = (childNode as HTMLElement).attributes;

for (let i = 0, attributeLength = attributes.length; i < attributeLength; i++) {
const bindingAttr = attributes.item(i)?.value.match(this.bindingRegex);

this.resolveLastStringItem(" ", strings, strings.length);

if (bindingAttr) {
this.resolveLastStringItem(
`${attributes.item(i)?.name}="`,
strings,
strings.length
);
// create a binding
const sansBindingStrings = bindingAttr[0]
.replace(this.openBinding, "")
.replace(this.closeBinding, "")
.trim();
const bindingItem = (x: any) => x[sansBindingStrings];
values.push(bindingItem);
strings.push('"');
} else {
this.resolveString(
` ${attributes.item(i)?.name}="${attributes.item(i)?.value}"`,
strings,
values
);
}
}

this.resolveString(`>`, strings, values);

if (childNode.hasChildNodes()) {
this.resolveChildNodes(childNode.childNodes, strings, values);
this.resolveLastStringItem(`</${tagName}>`, strings, strings.length);
}
}

/**
* Resolve a text binding
* @param childNode The child node to interpret.
Expand All @@ -84,7 +187,7 @@ class TemplateElement extends FASTElement {
childNode: ChildNode,
strings: Array<string>,
values: Array<any>
) {
): void {
const textContent = childNode.textContent || "";
const bindingArray = textContent.match(this.bindingRegex);
const stringArray = textContent.split(this.bindingRegex);
Expand All @@ -103,6 +206,8 @@ class TemplateElement extends FASTElement {
} else {
strings.push(textContent);
}

strings.push("");
}
}

Expand Down
16 changes: 15 additions & 1 deletion packages/web-components/fast-btr/src/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ The `main.ts` is for the client bundle, which is created by the `webpack.config.
**Additional changes outside of this package when creating new fixtures should include:**
- Addition of this webpack config to the packages `package.json` as `build:<fixture-name>`
- Inclusion into the `build-app` script of the new `build:<fixture-name>`
- Addition of the routes for this new fixture in the `./server/server.ts`
- Addition of the routes for this new fixture in the `./server/server.ts`

## Fixtures

- [x] Binding - a data binding
- [x] Attribute - an attribute binding
- [ ] Boolean Attribute - a boolean attribute binding (`?`)
- [ ] Property - a property binding (`:`)
- [ ] Event - an event binding (`@`)
- [ ] When - the when directive
- [ ] Repeat - the repeat directive
- [ ] Ref - the ref directive
- [ ] Slotted - the slotted directive
- [ ] Children - the children directive
- [ ] Template - template references
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<test-element type="checkbox">
<template shadowrootmode="open"><input type="checkbox" disabled></template>
</test-element>
<f-template name="test-element">
<template><input type="{{type}}" disabled></template>
</f-template>
<script type="module" src="/attribute/main.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<input type="{{type}}" disabled>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "checkbox"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";

test.describe("f-template", async () => {
test("create a non-binding attribute", async ({ page }) => {
await page.goto("/attribute");

const customElement = page.locator("test-element");
const customElementInput = customElement.locator("input");
await expect(customElementInput).toHaveAttribute("disabled");
});
test("create an attribute binding", async ({ page }) => {
await page.goto("/attribute");

const customElement = page.locator("test-element");

await expect(customElement).toHaveAttribute("type", "checkbox");
await expect(customElement.locator("input[type='checkbox']")).toHaveCount(1);

await page.evaluate(() => {
const customElement = document.getElementsByTagName("test-element");
customElement.item(0)?.setAttribute("type", "radio");
});

await expect(customElement).toHaveAttribute("type", "radio");
await expect(customElement.locator("input[type='radio']")).toHaveCount(1);
});
});
14 changes: 14 additions & 0 deletions packages/web-components/fast-btr/src/fixtures/attribute/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { TemplateElement } from "@microsoft/fast-btr";
import { attr, FASTElement } from "@microsoft/fast-element";

class TestElement extends FASTElement {
@attr
type: string = "radio";
}
TestElement.define({
name: "test-element",
});

TemplateElement.define({
name: "f-template",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import path from "path";
import { fileURLToPath } from "url";
import { merge } from "webpack-merge";
import config from "../../../webpack.common.config.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.resolve(path.dirname(__filename), "./");

export default merge(config, {
entry: path.resolve(__dirname, "./main.ts"),
output: {
filename: "main.js",
path: path.resolve(__dirname, "../../../server/dist/attribute"),
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import config from "../../../webpack.common.config.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.resolve(path.dirname(__filename), "./");

console.log("dir", __dirname)

export default merge(config, {
entry: path.resolve(__dirname, "./main.ts"),
output: {
Expand Down

0 comments on commit 8555606

Please sign in to comment.