From f9f78580531808c5dbb278a39c55a13fd85b81ab Mon Sep 17 00:00:00 2001 From: Jane Chu <7559015+janechu@users.noreply.github.com> Date: Tue, 15 Feb 2022 13:13:51 -0800 Subject: [PATCH] Add the fast-style web component (#5600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request ## 📖 Description This branch adds the following: - A FAST Style element that will hydrate styles client side - A webpack config that will build the minimal JS for the logic for this web component so that the file `fast-style.min.js` can be referred to in server side generated HTML ### 🎫 Issues Closes #5575 ## 👩‍💻 Reviewer Notes If you run the test server after building all the necessary files, you can navigate to `localhost:8080/fast-style` and see the component working there. One thing is the fixture uses `shadowroot="open"` to be able to see which styles are applied to nested elements during Playwright testing. This does not need the case for actual implementation, the shadowroot can be closed, this has been manually verified. ## 📑 Test Plan The added tests are Playwright tests, I'll let reviewers comment as to whether more tests are needed, this however tests the base case of applying styles to the container of the fast-style web component. ## ✅ Checklist ### General - [ ] I have included a change request file using `$ yarn change` - [x] I have added tests for my changes. - [x] I have tested my changes. - [ ] I have updated the project documentation to reflect my changes. - [ ] I have read the [CONTRIBUTING](https://github.com/Microsoft/fast/blob/master/CONTRIBUTING.md) documentation and followed the [standards](https://www.fast.design/docs/community/code-of-conduct/#our-standards) for this project. ## ⏭ Next Steps - Integrate the FASTStyle wc to the HTML renderer #5576 --- .../web-components/fast-ssr/server/server.ts | 47 ++ .../src/fast-style/index.fixture.html | 436 ++++++++++++++++++ .../fast-ssr/src/fast-style/index.ts | 79 ++++ .../fast-ssr/test/fast-style.spec.ts | 62 +++ 4 files changed, 624 insertions(+) create mode 100644 packages/web-components/fast-ssr/src/fast-style/index.fixture.html create mode 100644 packages/web-components/fast-ssr/src/fast-style/index.ts create mode 100644 packages/web-components/fast-ssr/test/fast-style.spec.ts diff --git a/packages/web-components/fast-ssr/server/server.ts b/packages/web-components/fast-ssr/server/server.ts index e47e01fad16..866fa10b5e8 100644 --- a/packages/web-components/fast-ssr/server/server.ts +++ b/packages/web-components/fast-ssr/server/server.ts @@ -1,6 +1,9 @@ import { Readable } from "stream"; import express, { Request, Response } from "express"; +import fs from "fs"; +import path from "path"; +const __dirname = path.resolve(path.dirname("")); const PORT = 8080; function handleRequest(req: Request, res: Response) { res.set("Content-Type", "text/html"); @@ -20,6 +23,50 @@ function handleRequest(req: Request, res: Response) { }); } +function handleStyleRequest(req: Request, res: Response) { + res.set("Content-Type", "text/html"); + fs.readFile( + path.resolve(__dirname, "./src/fast-style/index.fixture.html"), + { encoding: "utf8" }, + (err, data) => { + const stream = (Readable as any).from(data); + stream.on("readable", function (this: any) { + while ((data = this.read())) { + res.write(data); + } + }); + stream.on("close", () => res.end()); + stream.on("error", (e: Error) => { + console.error(e); + process.exit(1); + }); + } + ); +} + +function handleStyleScriptRequest(req: Request, res: Response) { + res.set("Content-Type", "application/javascript"); + fs.readFile( + path.resolve(__dirname, "./dist/fast-style/index.js"), + { encoding: "utf8" }, + (err, data) => { + const stream = (Readable as any).from(data); + stream.on("readable", function (this: any) { + while ((data = this.read())) { + res.write(data); + } + }); + stream.on("close", () => res.end()); + stream.on("error", (e: Error) => { + console.error(e); + process.exit(1); + }); + } + ); +} + const app = express(); app.get("/", handleRequest); +app.get("/fast-style", handleStyleRequest); +app.get("/fast-style.js", handleStyleScriptRequest); app.listen(PORT); diff --git a/packages/web-components/fast-ssr/src/fast-style/index.fixture.html b/packages/web-components/fast-ssr/src/fast-style/index.fixture.html new file mode 100644 index 00000000000..92820fa5ab9 --- /dev/null +++ b/packages/web-components/fast-ssr/src/fast-style/index.fixture.html @@ -0,0 +1,436 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web-components/fast-ssr/src/fast-style/index.ts b/packages/web-components/fast-ssr/src/fast-style/index.ts new file mode 100644 index 00000000000..cb480301651 --- /dev/null +++ b/packages/web-components/fast-ssr/src/fast-style/index.ts @@ -0,0 +1,79 @@ +interface StyleCache { + [key: string]: CSSStyleSheet | string; +} + +/** + * The FASTStyle web component that takes attributes: + * - css - a string version of the CSS to be applied to the parent web component, this can be omitted if the data-style-id has been used in the DOM as the cached value will be fetched + * - data-style-id - a dataset attribute used as an identifier for the CSS attr string + */ +export default class FASTStyle extends HTMLElement { + private static cache: StyleCache = {}; + private static hashIdDataSetName: string = "data-style-id"; + private static supportsAdoptedStyleSheets: boolean = + Array.isArray((document as any).adoptedStyleSheets) && + "replace" in CSSStyleSheet.prototype; + + /** + * @internal + */ + public connectedCallback(): void { + const hashId: string = this.getAttribute(FASTStyle.hashIdDataSetName) as string; + const css: string = this.getAttribute("css") as string; + this.registerStyles(hashId, css); + } + + /** + * Register styles if they are not part of the cache and attach them + */ + private registerStyles = (hashId: string, css: string): void => { + if (FASTStyle.supportsAdoptedStyleSheets) { + if (!(hashId in FASTStyle.cache)) { + this.memoizeAdoptedStylesheetStyles(hashId, css); + } + this.attachAdoptedStylesheetStyles(this.parentNode as ShadowRoot, hashId); + } else { + if (!(hashId in FASTStyle.cache)) { + this.memoizeStyleElementStyles(hashId, css); + } + this.attachStyleElementStyles(this.parentNode as ShadowRoot, hashId); + } + }; + + /** + * Memoize CSSStyleSheets + */ + private memoizeAdoptedStylesheetStyles(hashId: string, css: string) { + const sheet = new CSSStyleSheet(); + (sheet as any).replaceSync(css); + FASTStyle.cache[hashId] = sheet; + } + + /** + * Attach CSSStyleSheets + */ + private attachAdoptedStylesheetStyles(shadowRoot: ShadowRoot, hashId: string) { + (shadowRoot as any).adoptedStyleSheets = [ + ...(shadowRoot as any).adoptedStyleSheets!, + FASTStyle.cache[hashId] as CSSStyleSheet, + ]; + } + + /** + * Memoize css strings + */ + private memoizeStyleElementStyles(hashId: string, css: string) { + FASTStyle.cache[hashId] = css; + } + + /** + * Attach style elements + */ + private attachStyleElementStyles(shadowRoot: ShadowRoot, hashId: string) { + const element = document.createElement("style"); + element.innerHTML = FASTStyle.cache[hashId] as string; + shadowRoot.append(element); + } +} + +customElements.define("fast-style", FASTStyle); diff --git a/packages/web-components/fast-ssr/test/fast-style.spec.ts b/packages/web-components/fast-ssr/test/fast-style.spec.ts new file mode 100644 index 00000000000..08f74b18e39 --- /dev/null +++ b/packages/web-components/fast-ssr/test/fast-style.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test" + +test("Check that the first element has styles assigned", async ({ page }) => { + await page.goto("/fast-style"); + + const cards = page.locator("fast-card"); + const styles = await cards.evaluateAll((cardList) => { + return cardList.map((card) => { + return window.getComputedStyle(card, null).getPropertyValue("background-color"); + }); + }); + + expect(styles[0]).toEqual("rgb(26, 26, 26)"); +}); +test("Check that the nested element in the first element has styles assigned", async ({ page }) => { + await page.goto("/fast-style"); + + const cards = page.locator("fast-card"); + const styles = await cards.evaluateAll((cardList) => { + return cardList.map((card) => { + return window.getComputedStyle( + (card.shadowRoot?.querySelector("fast-button") as Element), null + ).getPropertyValue("background-color"); + }); + }); + + expect(styles[0]).toEqual("rgb(43, 43, 43)"); +}); +test("Check that all elements have styles assigned", async ({ page }) => { + await page.goto("/fast-style"); + + const cards = page.locator("fast-card"); + const styles = await cards.evaluateAll((cardList) => { + return cardList.map((card) => { + return window.getComputedStyle(card, null).getPropertyValue("background-color"); + }); + }); + + expect(styles).toHaveLength(10); + + styles.forEach((style) => { + expect(style).toEqual("rgb(26, 26, 26)"); + }); +}); +test("Check that all nested elements have styles assigned", async ({ page}) => { + await page.goto("/fast-style"); + + const cards = page.locator("fast-card"); + const styles = await cards.evaluateAll((cardList) => { + return cardList.map((card) => { + return window.getComputedStyle( + (card.shadowRoot?.querySelector("fast-button") as Element), null + ).getPropertyValue("background-color"); + }); + }); + + expect(styles).toHaveLength(10); + + styles.forEach((style) => { + expect(style).toEqual("rgb(43, 43, 43)"); + }); +});