Skip to content

Commit

Permalink
Improve style sheet adoption ergonomics.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
theengineear committed Oct 8, 2024
1 parent ce67bc9 commit 3661bb0
Show file tree
Hide file tree
Showing 9 changed files with 166 additions and 16 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
```
Expand Down
1 change: 1 addition & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
10 changes: 5 additions & 5 deletions test/test-render-root.js
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions test/test-styles.css.js
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions test/test-styles.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<html>
<body>
<meta charset="UTF-8">
<script type="module" src="test-styles.js"></script>
<h3>Test Styles</h3>
</body>
</html>
79 changes: 79 additions & 0 deletions test/test-styles.js
Original file line number Diff line number Diff line change
@@ -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);
});
1 change: 1 addition & 0 deletions x-element.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 25 additions & 4 deletions x-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
*
Expand Down Expand Up @@ -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);
Expand All @@ -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,
});
}
Expand Down Expand Up @@ -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 });
Expand Down

0 comments on commit 3661bb0

Please sign in to comment.