From 3661bb0eede6766a2abea60570c9b685eb1de1f9 Mon Sep 17 00:00:00 2001 From: Andrew Seier Date: Fri, 2 Feb 2024 13:38:13 -0800 Subject: [PATCH] Improve style sheet adoption ergonomics. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One common pattern for element authors (now that import attributes enable folks to import css files directly) is to adopt imported style sheets into a shadow root at custom element initialization time. This adds one static getter — `styles`. It’s still possible to do something more custom in `createRenderRoot`, but this adds a simple, declarative interface for the task of adding styles to a shadow root. Closes #52. --- CHANGELOG.md | 8 ++++ SPEC.md | 34 +++++++++++++---- test/index.js | 1 + test/test-render-root.js | 10 ++--- test/test-styles.css.js | 12 ++++++ test/test-styles.html | 8 ++++ test/test-styles.js | 79 ++++++++++++++++++++++++++++++++++++++++ x-element.d.ts | 1 + x-element.js | 29 +++++++++++++-- 9 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 test/test-styles.css.js create mode 100644 test/test-styles.html create mode 100644 test/test-styles.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 24afd9a..767260c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New support for static `styles` getter for `adoptedStyleSheets` ergonomics (#52). + +### Fixed + +- The `map` function now works with properties / attributes bound across template contexts (#179). + ## [1.0.0] - 2024-02-29 ### Added diff --git a/SPEC.md b/SPEC.md index 6e64857..ca00eea 100644 --- a/SPEC.md +++ b/SPEC.md @@ -254,28 +254,48 @@ class MyElement extends XElement { } ``` +## Styles + +The recommended way to add styles to your shadow root is to author a separate +`.css` file, import it as a `CSSStyleSheet` and declare it in your `.styles`. +For more control, you can alternatively use `createRenderRoot` (see below). + +```javascript +import styleSheet from './my-element-style.css' with { type: 'css' }; + +class MyElement extends XElement { + static get styles() { + return [styleSheet]; + } +} +``` + ## Render Root -By default, XElement will create an open shadow root. However, you can change -this behavior by overriding the `createRenderRoot` method. There are a few -reasons why you might want to do this as shown below. +By default, XElement will create an open shadow root and use that as your render +root. However, you may want to customize or not attach a shadow root at all. -### No Shadow Root +### Custom Shadow Root Initialization + +Control special behavior like “focus delegation” by overriding the default +shadow root configuration. ```javascript class MyElement extends XElement { static createRenderRoot(host) { - return host; + return host.attachShadow({ mode: 'open', delegatesFocus: true }); } } ``` -### Focus Delegation +### No Shadow Root + +Sometimes, you don’t want encapsulation. No problem — just return the `host`. ```javascript class MyElement extends XElement { static createRenderRoot(host) { - return host.attachShadowRoot({ mode: 'open', delegatesFocus: true }); + return host; } } ``` diff --git a/test/index.js b/test/index.js index 6ae711a..3615cb8 100644 --- a/test/index.js +++ b/test/index.js @@ -13,6 +13,7 @@ test('./test-element-upgrade.html'); test('./test-template-engine.html'); test('./test-render.html'); test('./test-render-root.html'); +test('./test-styles.html'); test('./test-basic-properties.html'); test('./test-initial-properties.html'); test('./test-default-properties.html'); diff --git a/test/test-render-root.js b/test/test-render-root.js index 12dcee9..586b8b6 100644 --- a/test/test-render-root.js +++ b/test/test-render-root.js @@ -1,7 +1,7 @@ import XElement from '../x-element.js'; import { assert, it } from './x-test.js'; -class TestElement extends XElement { +class TestElement1 extends XElement { static createRenderRoot(host) { return host; } @@ -11,21 +11,21 @@ class TestElement extends XElement { }; } } -customElements.define('test-element', TestElement); - +customElements.define('test-element-1', TestElement1); it('test render root was respected', () => { - const el = document.createElement('test-element'); + const el = document.createElement('test-element-1'); document.body.append(el); assert(el.shadowRoot === null); assert(el.textContent === `I'm not in a shadow root.`); + el.remove(); }); it('errors are thrown in for creating a bad render root', () => { class BadElement extends XElement { static createRenderRoot() {} } - customElements.define('test-element-1', BadElement); + customElements.define('test-element-2', BadElement); let passed = false; let message = 'no error was thrown'; try { diff --git a/test/test-styles.css.js b/test/test-styles.css.js new file mode 100644 index 0000000..f04ac28 --- /dev/null +++ b/test/test-styles.css.js @@ -0,0 +1,12 @@ +// TODO: Replace with actual css file when ESLint accepts import attributes. +const css = `\ + :host { + display: block; + background-color: coral; + width: 100px; + height: 100px; + } +`; +const styleSheet = new CSSStyleSheet(); +styleSheet.replaceSync(css); +export default styleSheet; diff --git a/test/test-styles.html b/test/test-styles.html new file mode 100644 index 0000000..8c0b4f9 --- /dev/null +++ b/test/test-styles.html @@ -0,0 +1,8 @@ + + + + + +

Test Styles

+ + diff --git a/test/test-styles.js b/test/test-styles.js new file mode 100644 index 0000000..1471eef --- /dev/null +++ b/test/test-styles.js @@ -0,0 +1,79 @@ +import { assert, it } from './x-test.js'; +import styleSheet from './test-styles.css.js'; +import XElement from '../x-element.js'; + +class TestElement1 extends XElement { + static count = 0; + static get styles() { + TestElement1.count++; + return [styleSheet]; + } + static template(html) { + return () => { + return html``; + }; + } +} +customElements.define('test-element-1', TestElement1); + +it('provided style sheets are adopted', () => { + const el = document.createElement('test-element-1'); + document.body.append(el); + const boundingClientRect = el.getBoundingClientRect(); + assert(boundingClientRect.width === 100); + assert(boundingClientRect.height === 100); + el.remove(); +}); + +it('should only get styles _once_ per constructor', () => { + for (let iii = 0; iii < 10; iii++) { + // No matter how many times you do this, styles must only be accessed once. + const el = document.createElement('test-element-1'); + document.body.append(el); + const boundingClientRect = el.getBoundingClientRect(); + assert(boundingClientRect.width === 100); + assert(boundingClientRect.height === 100); + el.remove(); + assert(TestElement1.count === 1); + } +}); + +it('errors are thrown when providing styles without a shadow root', () => { + class BadElement extends XElement { + static get styles() { return [styleSheet]; } + static createRenderRoot(host) { return host; } + } + customElements.define('test-element-2', BadElement); + let passed = false; + let message = 'no error was thrown'; + try { + new BadElement(); + } catch (error) { + const expected = 'Unexpected "styles" declared without a shadow root.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); + +it('errors are thrown when styles already exist on shadow root.', () => { + class BadElement extends XElement { + static get styles() { return [styleSheet]; } + static createRenderRoot(host) { + host.attachShadow({ mode: 'open' }); + host.shadowRoot.adoptedStyleSheets = [styleSheet]; + return host.shadowRoot; + } + } + customElements.define('test-element-3', BadElement); + let passed = false; + let message = 'no error was thrown'; + try { + new BadElement(); + } catch (error) { + const expected = 'Unexpected "styles" declared when preexisting "adoptedStyleSheets" exist.'; + message = error.message; + passed = error.message === expected; + } + assert(passed, message); +}); diff --git a/x-element.d.ts b/x-element.d.ts index 50cac1a..2424938 100644 --- a/x-element.d.ts +++ b/x-element.d.ts @@ -61,6 +61,7 @@ export class XElement extends HTMLElement { render: (container: HTMLElement, result: any) => void, html: (strings: TemplateStringsArray, ...any) => any, } + static readonly styles: [CSSStyleSheet] static createRenderRoot(host: XElement): HTMLElement; static template( html: (strings: TemplateStringsArray, ...any) => any, diff --git a/x-element.js b/x-element.js index f593d21..dbfcd14 100644 --- a/x-element.js +++ b/x-element.js @@ -11,7 +11,8 @@ export default class XElement extends HTMLElement { return TemplateEngine.interface; } - /** Configured templating engine. Defaults to "defaultTemplateEngine". + /** + * Configured templating engine. Defaults to "defaultTemplateEngine". * * Override this as needed if x-element's default template engine does not * meet your needs. A "render" method is the only required field. An "html" @@ -21,6 +22,15 @@ export default class XElement extends HTMLElement { return XElement.defaultTemplateEngine; } + /** + * Declare an array of CSSStyleSheet objects to adopt on the shadow root. + * Note that a CSSStyleSheet object is the type returned when importing a + * stylesheet file via import attributes. + */ + static get styles() { + return []; + } + /** * Declare watched properties (and related attributes) on an element. * @@ -197,7 +207,7 @@ export default class XElement extends HTMLElement { // Called once per class — kicked off from "static get observedAttributes". static #analyzeConstructor(constructor) { - const { properties, listeners } = constructor; + const { styles, properties, listeners } = constructor; const propertiesEntries = Object.entries(properties); const listenersEntries = Object.entries(listeners); XElement.#validateProperties(constructor, properties, propertiesEntries); @@ -222,7 +232,7 @@ export default class XElement extends HTMLElement { } const listenerMap = new Map(listenersEntries); XElement.#constructors.set(constructor, { - propertyMap, internalPropertyMap, attributeMap, listenerMap, + styles, propertyMap, internalPropertyMap, attributeMap, listenerMap, propertiesTarget, internalTarget, }); } @@ -534,7 +544,18 @@ export default class XElement extends HTMLElement { const computeMap = new Map(); const observeMap = new Map(); const defaultMap = new Map(); - const { propertyMap } = XElement.#constructors.get(host.constructor); + const { styles, propertyMap } = XElement.#constructors.get(host.constructor); + if (styles.length > 0) { + if (renderRoot === host.shadowRoot) { + if (renderRoot.adoptedStyleSheets.length === 0) { + renderRoot.adoptedStyleSheets = styles; + } else { + throw new Error('Unexpected "styles" declared when preexisting "adoptedStyleSheets" exist.'); + } + } else { + throw new Error('Unexpected "styles" declared without a shadow root.'); + } + } for (const property of propertyMap.values()) { if (property.compute) { computeMap.set(property, { valid: false, args: undefined });