= {
+ "0": "\0",
+ "\\": "\\",
+ "'": "'",
+ '"': '"',
+ b: "\b",
+ f: "\f",
+ n: "\n",
+ r: "\r",
+ t: "\t",
+ v: "\v",
+};
+
+/**
+ * Splits an event argument list on the commas that sit outside of string
+ * literals, so that a literal such as `'a,b'` remains a single argument.
+ * @param argsString - The raw arguments string from between the parentheses.
+ * @returns The untrimmed argument tokens.
+ */
+function splitEventArgs(argsString: string): string[] {
+ const args: string[] = [];
+ let current = "";
+ let quote = "";
+
+ for (let i = 0; i < argsString.length; i++) {
+ const char = argsString[i];
+
+ if (quote !== "") {
+ if (char === "\\" && i + 1 < argsString.length) {
+ current += char + argsString[++i];
+ continue;
+ }
+
+ if (char === quote) {
+ quote = "";
+ }
+ } else if (char === "'" || char === '"') {
+ quote = char;
+ } else if (char === ",") {
+ args.push(current);
+ current = "";
+ continue;
+ }
+
+ current += char;
+ }
+
+ if (quote !== "") {
+ throw new Error(`Unclosed string literal in event arguments "${argsString}".`);
+ }
+
+ args.push(current);
+
+ return args;
+}
+
+/**
+ * Unescapes a quoted string literal token and rejects any trailing characters.
+ * @param arg - The trimmed argument token, starting with `quote`.
+ * @param quote - The opening quote character.
+ * @returns The literal string value.
+ */
+function parseEventArgStringLiteral(arg: string, quote: string): string {
+ let value = "";
+
+ for (let i = 1; i < arg.length; i++) {
+ const char = arg[i];
+
+ if (char === "\\") {
+ const escaped = eventArgEscapes[arg[++i]];
+
+ if (escaped === undefined) {
+ throw new Error(
+ `Invalid escape sequence "\\${arg[i]}" in event argument "${arg}".`,
+ );
+ }
+
+ value += escaped;
+ continue;
+ }
+
+ if (char === quote) {
+ if (i !== arg.length - 1) {
+ throw new Error(
+ `Unexpected text after the string literal in event argument "${arg}".`,
+ );
+ }
+
+ return value;
+ }
+
+ value += char;
+ }
+
+ throw new Error(`Unclosed string literal in event argument "${arg}".`);
+}
+
+/**
+ * Resolves a single event argument token to its descriptor.
+ * @param arg - The trimmed argument token.
+ * @returns A {@link ParsedEventArg} descriptor.
+ */
+function parseEventArg(arg: string): ParsedEventArg {
+ switch (arg) {
+ case eventArgAccessor:
+ return { type: "event" };
+ case executionContextAccessor:
+ return { type: "context" };
+ case "true":
+ return { type: "boolean", value: true };
+ case "false":
+ return { type: "boolean", value: false };
+ case "null":
+ return { type: "null", value: null };
+ }
+
+ const quote = arg[0];
+
+ if (quote === "'" || quote === '"') {
+ return { type: "string", value: parseEventArgStringLiteral(arg, quote) };
+ }
+
+ if (eventArgNumberPattern.test(arg)) {
+ return { type: "number", value: Number(arg) };
+ }
+
+ if (eventArgPathPattern.test(arg)) {
+ return { type: "binding", rawArg: arg };
+ }
+
+ throw new Error(`Invalid event argument "${arg}".`);
}
/**
* Parses the arguments string of an event handler binding into an array of
- * typed argument descriptors. Unrecognised tokens are returned as `"binding"`
- * type with their raw string preserved.
+ * typed argument descriptors. Malformed argument lists are rejected rather than
+ * normalised.
*
* Special arguments:
* - `$e` — resolves to the DOM event object
* - `$c` — resolves to the full execution context object
*
- * Any other token is treated as a binding path and resolved against the current
- * data source.
+ * Quoted tokens are string literals, and `true`, `false`, `null` and numeric
+ * tokens are their respective literals. Any other token is treated as a binding
+ * path and resolved against the current data source.
*
* @param argsString - The raw arguments string from between the parentheses,
- * e.g. `""`, `"$e"`, `"$c"`, or `"$e, $c"`.
+ * e.g. `""`, `"$e"`, `"$c"`, or `"item.id, 'static', 1, true, null, $e"`.
* @returns An array of {@link ParsedEventArg} descriptors.
* @public
*/
export function parseEventArgs(argsString: string): ParsedEventArg[] {
if (argsString.trim() === "") return [];
- return argsString
- .split(",")
- .map(arg => arg.trim())
- .filter(arg => arg !== "")
- .map((arg): ParsedEventArg => {
- switch (arg) {
- case eventArgAccessor:
- return { type: "event" };
- case executionContextAccessor:
- return { type: "context" };
- default:
- return { type: "binding", rawArg: arg };
+ return splitEventArgs(argsString).map(arg => {
+ const trimmed = arg.trim();
+
+ if (trimmed === "") {
+ throw new Error(`Empty event argument in "${argsString}".`);
+ }
+
+ return parseEventArg(trimmed);
+ });
+}
+
+/**
+ * Finds the index of the parenthesis closing the call opened at
+ * `openingParenthesis`, ignoring parentheses inside string literals.
+ * @param binding - The trimmed event binding expression.
+ * @param openingParenthesis - The index of the opening parenthesis.
+ * @returns The index of the closing parenthesis.
+ */
+function findEventArgsClose(binding: string, openingParenthesis: number): number {
+ let quote = "";
+
+ for (let i = openingParenthesis + 1; i < binding.length; i++) {
+ const char = binding[i];
+
+ if (quote !== "") {
+ if (char === "\\") {
+ i++;
+ } else if (char === quote) {
+ quote = "";
}
- });
+ } else if (char === "'" || char === '"') {
+ quote = char;
+ } else if (char === ")") {
+ return i;
+ }
+ }
+
+ throw new Error(`Event binding "${binding}" is missing a closing parenthesis.`);
+}
+
+/**
+ * Parses an event handler binding expression, e.g.
+ * `$c.parent.selectItem(item.id, 'from-list', $e)`, into the handler path and
+ * its typed argument descriptors. Handler calls that are malformed — a missing
+ * or unbalanced call, an invalid handler name, or trailing text — are rejected.
+ *
+ * @param bindingHTML - The event binding expression from between the braces.
+ * @returns A {@link ParsedEventHandler}.
+ * @public
+ */
+export function parseEventHandler(bindingHTML: string): ParsedEventHandler {
+ const binding = bindingHTML.trim();
+ const openingParenthesis = binding.indexOf("(");
+
+ if (openingParenthesis === -1) {
+ throw new Error(`Event binding "${bindingHTML}" is missing a function call.`);
+ }
+
+ const closingParenthesis = findEventArgsClose(binding, openingParenthesis);
+
+ if (binding.slice(closingParenthesis + 1).trim() !== "") {
+ throw new Error(
+ `Unexpected text after the event handler call in "${bindingHTML}".`,
+ );
+ }
+
+ const name = binding.slice(0, openingParenthesis).trim();
+
+ if (!eventArgPathPattern.test(name)) {
+ throw new Error(`Invalid event handler name "${name}".`);
+ }
+
+ return {
+ name,
+ args: parseEventArgs(binding.slice(openingParenthesis + 1, closingParenthesis)),
+ };
}
const startInnerHTMLDiv = ` {
expect(message).toEqual("click");
});
+ test("create an event attribute with literal arguments", async ({ page }) => {
+ const hydrationCompleted = page.waitForFunction(
+ () => (window as any).hydrationCompleted === true,
+ );
+ await page.goto("/fixtures/bindings/event/");
+ await hydrationCompleted;
+
+ const customElement = page.locator("test-element");
+
+ let message;
+ page.on("console", msg => (message = msg.text()));
+
+ await customElement.locator("button").nth(6).click();
+
+ expect(message).toEqual("a,b)c,string,1.5,number,true,boolean,null,click");
+ });
+ test("create an event attribute with repeat scope and literal arguments", async ({
+ page,
+ }) => {
+ const hydrationCompleted = page.waitForFunction(
+ () => (window as any).hydrationCompleted === true,
+ );
+ await page.goto("/fixtures/bindings/event/");
+ await hydrationCompleted;
+
+ const customElement = page.locator("test-element");
+
+ let message;
+ page.on("console", msg => (message = msg.text()));
+
+ await customElement.locator("button").nth(7).click();
+
+ expect(message).toEqual("id-1,from-list,click");
+
+ await customElement.locator("button").nth(8).click();
+
+ expect(message).toEqual("id-2,from-list,click");
+ });
});
diff --git a/packages/fast-element/test/declarative/fixtures/bindings/event/index.html b/packages/fast-element/test/declarative/fixtures/bindings/event/index.html
index f4c4e123011..fe393249e00 100644
--- a/packages/fast-element/test/declarative/fixtures/bindings/event/index.html
+++ b/packages/fast-element/test/declarative/fixtures/bindings/event/index.html
@@ -18,7 +18,19 @@
+
+
+
+
+
+
+
@@ -35,6 +47,14 @@
+
+
+
+
diff --git a/packages/fast-element/test/declarative/fixtures/bindings/event/main.ts b/packages/fast-element/test/declarative/fixtures/bindings/event/main.ts
index 118c8c730d8..fea49c272ed 100644
--- a/packages/fast-element/test/declarative/fixtures/bindings/event/main.ts
+++ b/packages/fast-element/test/declarative/fixtures/bindings/event/main.ts
@@ -2,11 +2,18 @@ import { attr } from "@microsoft/fast-element/attr.js";
import { declarativeTemplate } from "@microsoft/fast-element/declarative.js";
import { FASTElement } from "@microsoft/fast-element/fast-element.js";
import { enableHydration } from "@microsoft/fast-element/hydration.js";
+import { observable } from "@microsoft/fast-element/observable.js";
class TestElement extends FASTElement {
@attr
foo: string = "";
+ @observable
+ items: Array<{ id: string; name: string }> = [
+ { id: "id-1", name: "Item 1" },
+ { id: "id-2", name: "Item 2" },
+ ];
+
public handleNoArgsClick = (): void => {
console.log("no args");
};
@@ -30,6 +37,22 @@ class TestElement extends FASTElement {
public handleContextEventArgClick = (e: MouseEvent): void => {
console.log(e.type);
};
+
+ public handleLiteralArgsClick = (
+ text: string,
+ count: number,
+ enabled: boolean,
+ empty: null,
+ e: MouseEvent,
+ ): void => {
+ console.log(
+ `${text},${typeof text},${count},${typeof count},${enabled},${typeof enabled},${empty},${e.type}`,
+ );
+ };
+
+ public handleRepeatArgsClick = (id: string, source: string, e: MouseEvent): void => {
+ console.log(`${id},${source},${e.type}`);
+ };
}
TestElement.define({
name: "test-element",
diff --git a/packages/fast-element/test/declarative/fixtures/bindings/event/state.json b/packages/fast-element/test/declarative/fixtures/bindings/event/state.json
index e63d37b65a8..c84a5b322ef 100644
--- a/packages/fast-element/test/declarative/fixtures/bindings/event/state.json
+++ b/packages/fast-element/test/declarative/fixtures/bindings/event/state.json
@@ -1,3 +1,4 @@
{
- "foo": "bar"
+ "foo": "bar",
+ "items": [{ "id": "id-1", "name": "Item 1" }, { "id": "id-2", "name": "Item 2" }]
}
diff --git a/packages/fast-element/test/declarative/fixtures/bindings/event/templates.html b/packages/fast-element/test/declarative/fixtures/bindings/event/templates.html
index 2dc3b527f18..59b8780f7e3 100644
--- a/packages/fast-element/test/declarative/fixtures/bindings/event/templates.html
+++ b/packages/fast-element/test/declarative/fixtures/bindings/event/templates.html
@@ -14,5 +14,13 @@
+
+
+
+
diff --git a/sites/website/src/docs/3.x/resources/export-sizes.md b/sites/website/src/docs/3.x/resources/export-sizes.md
index 8cc65070e6d..f0b70ae20d7 100644
--- a/sites/website/src/docs/3.x/resources/export-sizes.md
+++ b/sites/website/src/docs/3.x/resources/export-sizes.md
@@ -34,7 +34,7 @@ Bundle sizes for `@microsoft/fast-element` exports.
| repeat (@microsoft/fast-element/repeat.js) | 31.80 KB | 10.00 KB | 9.03 KB |
| css (@microsoft/fast-element/css.js) | 2.43 KB | 1.00 KB | 911 B |
| enableHydration (@microsoft/fast-element/hydration.js) | 46.53 KB | 13.91 KB | 12.49 KB |
-| declarativeTemplate (@microsoft/fast-element/declarative.js) | 62.15 KB | 19.46 KB | 17.42 KB |
+| declarativeTemplate (@microsoft/fast-element/declarative.js) | 63.94 KB | 20.10 KB | 17.98 KB |
| ArrayObserver (@microsoft/fast-element/arrays.js) | 12.55 KB | 4.46 KB | 4.03 KB |
| observerMap (@microsoft/fast-element/observer-map.js) | 21.96 KB | 7.73 KB | 6.97 KB |
| attributeMap (@microsoft/fast-element/attribute-map.js) | 15.31 KB | 5.41 KB | 4.88 KB |