From 23ebd7a9f58578ca5873311e11efbcf3203ee793 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:32:19 -0400 Subject: [PATCH] feat: add whitespaceFilter for slotted and children node observation --- ...-16c6ae86-bc69-4572-9453-0d5ad5d66a41.json | 7 +++ packages/fast-element/SIZES.md | 6 +- packages/fast-element/docs/api-report.api.md | 3 + packages/fast-element/src/index.ts | 1 + .../src/templating/children.pw.spec.ts | 42 +++++++++++++- .../src/templating/node-observation.ts | 13 +++++ .../src/templating/slotted.pw.spec.ts | 58 +++++++++++++++++++ packages/fast-element/test/main.ts | 2 +- .../website/src/docs/3.x/api/fast-element.md | 11 ++++ .../3.x/api/fast-element.whitespacefilter.md | 31 ++++++++++ .../3.x/getting-started/html-directives.md | 15 +++++ .../src/docs/3.x/resources/export-sizes.md | 6 +- 12 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 change/@microsoft-fast-element-16c6ae86-bc69-4572-9453-0d5ad5d66a41.json create mode 100644 sites/website/src/docs/3.x/api/fast-element.whitespacefilter.md diff --git a/change/@microsoft-fast-element-16c6ae86-bc69-4572-9453-0d5ad5d66a41.json b/change/@microsoft-fast-element-16c6ae86-bc69-4572-9453-0d5ad5d66a41.json new file mode 100644 index 00000000000..8c2709662bd --- /dev/null +++ b/change/@microsoft-fast-element-16c6ae86-bc69-4572-9453-0d5ad5d66a41.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add whitespaceFilter, an ElementsFilter that removes whitespace-only text nodes from slotted and children node collections.", + "packageName": "@microsoft/fast-element", + "email": "144495202+AKnassa@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/fast-element/SIZES.md b/packages/fast-element/SIZES.md index 92d854e5126..6dbfb70c442 100644 --- a/packages/fast-element/SIZES.md +++ b/packages/fast-element/SIZES.md @@ -4,13 +4,13 @@ Bundle sizes for `@microsoft/fast-element` exports. | Export | Minified | Gzip | Brotli | |--------|----------|------|--------| -| CDN Rollup Bundle | 78.61 KB | 23.40 KB | 20.77 KB | +| CDN Rollup Bundle | 78.73 KB | 23.44 KB | 20.80 KB | | FASTElement (@microsoft/fast-element/fast-element.js) | 23.11 KB | 7.12 KB | 6.41 KB | | Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 290 B | | Observable (@microsoft/fast-element/observable.js) | 6.75 KB | 2.51 KB | 2.23 KB | | observable (@microsoft/fast-element/observable.js) | 6.79 KB | 2.52 KB | 2.24 KB | | attr (@microsoft/fast-element/attr.js) | 477 B | 287 B | 244 B | -| children (@microsoft/fast-element/children.js) | 4.87 KB | 1.88 KB | 1.66 KB | +| children (@microsoft/fast-element/children.js) | 4.87 KB | 1.88 KB | 1.65 KB | | ref (@microsoft/fast-element/ref.js) | 3.84 KB | 1.54 KB | 1.34 KB | | slotted (@microsoft/fast-element/slotted.js) | 4.66 KB | 1.81 KB | 1.59 KB | | volatile (@microsoft/fast-element/volatile.js) | 6.84 KB | 2.54 KB | 2.26 KB | @@ -19,7 +19,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) | 62.15 KB | 19.46 KB | 17.41 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 | diff --git a/packages/fast-element/docs/api-report.api.md b/packages/fast-element/docs/api-report.api.md index 5a660bf37f0..84d98a19beb 100644 --- a/packages/fast-element/docs/api-report.api.md +++ b/packages/fast-element/docs/api-report.api.md @@ -1280,4 +1280,7 @@ export function watch(object: any, subscriber: Subscriber | ((subject: any, args // @public export function when(condition: Expression | boolean, templateOrTemplateBinding: SyntheticViewTemplate | Expression, TParent>, elseTemplateOrTemplateBinding?: SyntheticViewTemplate | Expression, TParent>): CaptureType; +// @public +export const whitespaceFilter: ElementsFilter; + ``` diff --git a/packages/fast-element/src/index.ts b/packages/fast-element/src/index.ts index 7f604432d19..aa922ac5d90 100644 --- a/packages/fast-element/src/index.ts +++ b/packages/fast-element/src/index.ts @@ -167,6 +167,7 @@ export { elements, type NodeBehaviorOptions, NodeObservationDirective, + whitespaceFilter, } from "./templating/node-observation.js"; export { RefDirective, ref } from "./templating/ref.js"; export { RenderBehavior, RenderDirective, render } from "./templating/render.js"; diff --git a/packages/fast-element/src/templating/children.pw.spec.ts b/packages/fast-element/src/templating/children.pw.spec.ts index 96f66575118..df1bcc53c41 100644 --- a/packages/fast-element/src/templating/children.pw.spec.ts +++ b/packages/fast-element/src/templating/children.pw.spec.ts @@ -29,7 +29,7 @@ test.describe("The children", () => { typeof ChildrenDirective >; const behavior = directive.createBehavior(); - return behavior === behavior; + return behavior === directive; }); expect(isSame).toBe(true); @@ -143,6 +143,46 @@ test.describe("The children", () => { expect(result.allMatch).toBe(true); }); + test("gathers child nodes with the whitespace filter", async ({ page }) => { + const result = await page.evaluate(async () => { + // @ts-expect-error: Client module. + const { ChildrenDirective, Observable, Fake, whitespaceFilter } = + await import("/main.js"); + + class Model { + nodes: any; + reference: any; + } + + Observable.defineProperty(Model.prototype, "nodes"); + + const host = document.createElement("div"); + host.appendChild(document.createTextNode("\n ")); + host.appendChild(document.createElement("foo-bar")); + host.appendChild(document.createTextNode("hello")); + host.appendChild(document.createTextNode("")); + + const nodeId = "r"; + const targets = { [nodeId]: host }; + + const behavior = new ChildrenDirective({ + property: "nodes", + filter: whitespaceFilter, + }); + behavior.targetNodeId = nodeId; + const model = new Model(); + const controller = Fake.viewController(targets, behavior); + + controller.bind(model); + + return model.nodes.map((x: Node) => + x.nodeType === 1 ? (x as Element).tagName : x.nodeValue, + ); + }); + + expect(result).toEqual(["FOO-BAR", "hello"]); + }); + test("updates child nodes when they change", async ({ page }) => { const result = await page.evaluate(async () => { // @ts-expect-error: Client module. diff --git a/packages/fast-element/src/templating/node-observation.ts b/packages/fast-element/src/templating/node-observation.ts index f4e70ce1d68..63268b60299 100644 --- a/packages/fast-element/src/templating/node-observation.ts +++ b/packages/fast-element/src/templating/node-observation.ts @@ -43,6 +43,19 @@ export const elements = (selector?: string): ElementsFilter => ? value => value.nodeType === 1 && (value as HTMLElement).matches(selector) : selectElements; +/** + * Filters out text nodes that contain only whitespace, such as the indentation + * between elements in markup. Note that a node containing only non-breaking + * spaces is considered whitespace and is filtered out as well. + * @public + * @example + * ```ts + * html`` + * ``` + */ +export const whitespaceFilter: ElementsFilter = value => + value.nodeType !== 3 || !!value.nodeValue?.trim().length; + /** * A base class for node observation. * @public diff --git a/packages/fast-element/src/templating/slotted.pw.spec.ts b/packages/fast-element/src/templating/slotted.pw.spec.ts index 7675f26bb9d..a1e7d532611 100644 --- a/packages/fast-element/src/templating/slotted.pw.spec.ts +++ b/packages/fast-element/src/templating/slotted.pw.spec.ts @@ -172,6 +172,64 @@ test.describe("The slotted", () => { expect(result).toBe(true); }); + test("gathers nodes from a slot with the whitespace filter", async ({ page }) => { + await page.goto("/"); + + const result = await page.evaluate(async () => { + // @ts-expect-error: Client module. + const { SlottedDirective, Observable, Fake, Updates, whitespaceFilter } = + await import("/main.js"); + + class Model { + nodes; + reference; + } + Observable.defineProperty(Model.prototype, "nodes"); + + const host = document.createElement("div"); + const slot = document.createElement("slot"); + const shadowRoot = host.attachShadow({ mode: "open" }); + const nodeId = "r"; + const targets = { [nodeId]: slot }; + shadowRoot.appendChild(slot); + + host.appendChild(document.createTextNode("\n ")); + host.appendChild(document.createElement("foo-bar")); + host.appendChild(document.createTextNode("hello")); + host.appendChild(document.createTextNode("\n")); + + const behavior = new SlottedDirective({ + property: "nodes", + filter: whitespaceFilter, + }); + behavior.targetNodeId = nodeId; + const model = new Model(); + const controller = { + source: model, + targets, + context: Fake.executionContext(), + isBound: false, + onUnbind() {}, + }; + + behavior.bind(controller); + + const describe = nodes => + nodes.map(x => (x.nodeType === 1 ? x.tagName : x.nodeValue)); + const bound = describe(model.nodes); + + host.appendChild(document.createTextNode(" \t")); + host.appendChild(document.createElement("foo-bar")); + + await Updates.next(); + + return { bound, updated: describe(model.nodes) }; + }); + + expect(result.bound).toEqual(["FOO-BAR", "hello"]); + expect(result.updated).toEqual(["FOO-BAR", "hello", "FOO-BAR"]); + }); + test("updates when slotted nodes change", async ({ page }) => { await page.goto("/"); diff --git a/packages/fast-element/test/main.ts b/packages/fast-element/test/main.ts index 4dc195ba11f..23576f065ba 100644 --- a/packages/fast-element/test/main.ts +++ b/packages/fast-element/test/main.ts @@ -90,7 +90,7 @@ export { Compiler } from "../src/templating/compiler.js"; export { HTMLBindingDirective } from "../src/templating/html-binding-directive.js"; export { HTMLDirective, htmlDirective } from "../src/templating/html-directive.js"; export { Markup, nextId, Parser } from "../src/templating/markup.js"; -export { elements } from "../src/templating/node-observation.js"; +export { elements, whitespaceFilter } from "../src/templating/node-observation.js"; export { NodeTemplate, RenderBehavior, diff --git a/sites/website/src/docs/3.x/api/fast-element.md b/sites/website/src/docs/3.x/api/fast-element.md index 403198a13ba..c3176d4567a 100644 --- a/sites/website/src/docs/3.x/api/fast-element.md +++ b/sites/website/src/docs/3.x/api/fast-element.md @@ -1741,6 +1741,17 @@ The default UpdateQueue. Bridges between ViewBehaviors and HostBehaviors, enabling a host to control ViewBehaviors. + + + +[whitespaceFilter](../fast-element.whitespacefilter/) + + + + +Filters out text nodes that contain only whitespace, such as the indentation between elements in markup. Note that a node containing only non-breaking spaces is considered whitespace and is filtered out as well. + + diff --git a/sites/website/src/docs/3.x/api/fast-element.whitespacefilter.md b/sites/website/src/docs/3.x/api/fast-element.whitespacefilter.md new file mode 100644 index 00000000000..278c59a56ca --- /dev/null +++ b/sites/website/src/docs/3.x/api/fast-element.whitespacefilter.md @@ -0,0 +1,31 @@ +--- +id: "fast-element.whitespacefilter" +title: "whitespaceFilter variable" +layout: 3x-api +eleventyNavigation: + key: "api3xfast-element.whitespacefilter" + parent: "api3xfast-element" + title: "whitespaceFilter variable" +navigationOptions: + activeKey: "api3xfast-element.whitespacefilter" +--- + + +[@microsoft/fast-element](../fast-element/index.html) > [whitespaceFilter](../fast-element.whitespacefilter/index.html) + +## whitespaceFilter variable + +Filters out text nodes that contain only whitespace, such as the indentation between elements in markup. Note that a node containing only non-breaking spaces is considered whitespace and is filtered out as well. + +**Signature:** + +```typescript +whitespaceFilter: ElementsFilter +``` + +## Example + + +```ts +html`` +``` diff --git a/sites/website/src/docs/3.x/getting-started/html-directives.md b/sites/website/src/docs/3.x/getting-started/html-directives.md index a8bd25f0050..5a50db50c34 100644 --- a/sites/website/src/docs/3.x/getting-started/html-directives.md +++ b/sites/website/src/docs/3.x/getting-started/html-directives.md @@ -289,6 +289,21 @@ const template = html` `; ``` +Because a `` is assigned text nodes as well as elements, the indentation between elements arrives as whitespace-only text nodes. Use `whitespaceFilter` to drop those while keeping elements and meaningful text: + +```ts +import { html, slotted, whitespaceFilter } from "@microsoft/fast-element"; + +const template = html` + +`; +``` + #### Flattening The configuration object also accepts a `flatten` option. By default, `slotted()` references the nodes directly assigned to the slot. Setting `flatten: true` references the slot's flattened assigned nodes instead, resolving any nested slots to their distributed content and falling back to the slot's default content when nothing is assigned. The option is passed through to the underlying [`assignedNodes()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assignedNodes) call. 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..70e69f340d7 100644 --- a/sites/website/src/docs/3.x/resources/export-sizes.md +++ b/sites/website/src/docs/3.x/resources/export-sizes.md @@ -19,13 +19,13 @@ Bundle sizes for `@microsoft/fast-element` exports. | Export | Minified | Gzip | Brotli | |--------|----------|------|--------| -| CDN Rollup Bundle | 78.61 KB | 23.40 KB | 20.77 KB | +| CDN Rollup Bundle | 78.73 KB | 23.44 KB | 20.80 KB | | FASTElement (@microsoft/fast-element/fast-element.js) | 23.11 KB | 7.12 KB | 6.41 KB | | Updates (@microsoft/fast-element/updates.js) | 473 B | 335 B | 290 B | | Observable (@microsoft/fast-element/observable.js) | 6.75 KB | 2.51 KB | 2.23 KB | | observable (@microsoft/fast-element/observable.js) | 6.79 KB | 2.52 KB | 2.24 KB | | attr (@microsoft/fast-element/attr.js) | 477 B | 287 B | 244 B | -| children (@microsoft/fast-element/children.js) | 4.87 KB | 1.88 KB | 1.66 KB | +| children (@microsoft/fast-element/children.js) | 4.87 KB | 1.88 KB | 1.65 KB | | ref (@microsoft/fast-element/ref.js) | 3.84 KB | 1.54 KB | 1.34 KB | | slotted (@microsoft/fast-element/slotted.js) | 4.66 KB | 1.81 KB | 1.59 KB | | volatile (@microsoft/fast-element/volatile.js) | 6.84 KB | 2.54 KB | 2.26 KB | @@ -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) | 62.15 KB | 19.46 KB | 17.41 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 |