Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions packages/fast-element/src/declarative/template-parser.pw.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test";

// Vite serves local filesystem files under the /@fs/ prefix.
// Using .pathname (not fileURLToPath) mirrors the template-bridge pattern and
// correctly produces a leading slash on Windows (e.g. /@fs/E:/fast/...).
const pureDeclarativeEntrypointUrl = `/@fs${
new URL("../../test/pure-declarative-main.ts", import.meta.url).pathname
}`;

test.describe("TemplateParser", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We test in browser for declarative format, please check the fixture files for examples of how to do this. In this case you should see fast-element/test/declarative/fixtures/directives/children.

test.describe("f-children directive", () => {
test("without filter produces a ChildrenDirective with the correct property", async ({
page,
}) => {
await page.goto("/");

const result = await page.evaluate(async declarativeUrl => {
// @ts-expect-error: Client module.
const { Schema, TemplateParser } = await import(declarativeUrl);
// @ts-expect-error: Client module.
const { ChildrenDirective } = await import("/main.js");

const parser = new TemplateParser();
const schema = new Schema("test-element");
const { values } = parser.parse(
'<ul f-children="{listItems}"></ul>',
schema,
);
const directive = values[0];

return {
isChildrenDirective: directive instanceof ChildrenDirective,
property: directive.options.property,
hasFilter: "filter" in directive.options,
};
}, pureDeclarativeEntrypointUrl);

expect(result.isChildrenDirective).toBe(true);
expect(result.property).toBe("listItems");
expect(result.hasFilter).toBe(false);
});

test("with filter elements() produces a ChildrenDirective with an element filter", async ({
page,
}) => {
await page.goto("/");

const result = await page.evaluate(async declarativeUrl => {
// @ts-expect-error: Client module.
const { Schema, TemplateParser } = await import(declarativeUrl);
// @ts-expect-error: Client module.
const { ChildrenDirective } = await import("/main.js");

const parser = new TemplateParser();
const schema = new Schema("test-element");
const { values } = parser.parse(
'<ul f-children="{childItems filter elements()}"></ul>',
schema,
);
const directive = values[0];
const filter = directive.options.filter;

// Use real DOM nodes so element.matches() is available.
const elementNode = document.createElement("div");
const textNode = document.createTextNode("hello");

return {
isChildrenDirective: directive instanceof ChildrenDirective,
property: directive.options.property,
hasFilter: typeof filter === "function",
filterAcceptsElement: filter(elementNode),
filterRejectsText: filter(textNode),
};
}, pureDeclarativeEntrypointUrl);

expect(result.isChildrenDirective).toBe(true);
expect(result.property).toBe("childItems");
expect(result.hasFilter).toBe(true);
expect(result.filterAcceptsElement).toBe(true);
expect(result.filterRejectsText).toBe(false);
});

test("with filter elements(li) produces a ChildrenDirective with a selector filter", async ({
page,
}) => {
await page.goto("/");

const result = await page.evaluate(async declarativeUrl => {
// @ts-expect-error: Client module.
const { Schema, TemplateParser } = await import(declarativeUrl);
// @ts-expect-error: Client module.
const { ChildrenDirective } = await import("/main.js");

const parser = new TemplateParser();
const schema = new Schema("test-element");
const { values } = parser.parse(
'<ul f-children="{childItems filter elements(li)}"></ul>',
schema,
);
const directive = values[0];
const filter = directive.options.filter;

const li = document.createElement("li");
const div = document.createElement("div");
const text = document.createTextNode("hello");

return {
isChildrenDirective: directive instanceof ChildrenDirective,
property: directive.options.property,
hasFilter: typeof filter === "function",
filterAcceptsLi: filter(li),
filterResultDiv: filter(div),
filterResultText: filter(text),
};
}, pureDeclarativeEntrypointUrl);

expect(result.isChildrenDirective).toBe(true);
expect(result.property).toBe("childItems");
expect(result.hasFilter).toBe(true);
expect(result.filterAcceptsLi).toBe(true);
expect(result.filterResultDiv).toBe(false);
expect(result.filterResultText).toBe(false);
});
});
});
47 changes: 30 additions & 17 deletions packages/fast-element/src/declarative/template-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,29 @@ export class TemplateParser {
}
}

/**
* Parses a directive attribute value that may include a " filter elements(...)" suffix.
* Returns an options object with `property` and, when a filter is specified, `filter`.
* Shared by the `children` and `slotted` directive cases.
*/
private parseNodeDirectiveOptions(propName: string): {
property: string;
filter?: ReturnType<typeof elements>;
} {
const parts = propName.trim().split(" filter ");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

split(" filter ") also splits inside valid CSS selectors. For example, f-children="{items filter elements([data-kind="a filter b"])}" leaves parts[1] as elements([data-kind="a, so lastIndexOf(")") is -1 and this falls back to elements(undefined), matching every element instead of the requested selector. Please parse the filter elements(...) suffix without discarding the rest of the selector (for example, split only on the first directive delimiter and keep the remaining selector text). As more directive modifiers are added, it would also be better to make this a small modifier parser/registry supplied by the directive rather than hard-coding each new option in TemplateParser, which keeps the pure declarative parser from growing with every directive-specific feature.

const options: { property: string; filter?: ReturnType<typeof elements> } = {
property: parts[0],
};

if (parts[1]?.startsWith("elements(")) {
const inner = parts[1].slice("elements(".length);
const params = inner.substring(0, inner.lastIndexOf(")"));
options.filter = elements(params || undefined);
}

return options;
}

/**
* Resolve an attribute directive (children/slotted/ref).
* @param name - The name of the directive.
Expand All @@ -232,27 +255,17 @@ export class TemplateParser {
): void {
switch (name) {
case "children": {
externalValues.push(children(propName));
const options = this.parseNodeDirectiveOptions(propName);
externalValues.push(
options.filter !== undefined
? children(options)
: children(options.property),
);

break;
}
case "slotted": {
const parts = propName.trim().split(" filter ");
const slottedOption = {
property: parts[0],
};

if (parts[1]) {
if (parts[1].startsWith("elements(")) {
let params = parts[1].replace("elements(", "");
params = params.substring(0, params.lastIndexOf(")"));
Object.assign(slottedOption, {
filter: elements(params || undefined),
});
}
}

externalValues.push(slotted(slottedOption));
externalValues.push(slotted(this.parseNodeDirectiveOptions(propName)));

break;
}
Expand Down