Skip to content

Commit 784cd5f

Browse files
committed
Improve style sheet adoption ergonomics.
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.
1 parent ce67bc9 commit 784cd5f

File tree

8 files changed

+158
-16
lines changed

8 files changed

+158
-16
lines changed

SPEC.md

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -254,28 +254,48 @@ class MyElement extends XElement {
254254
}
255255
```
256256

257+
## Styles
258+
259+
The recommended way to add styles to your shadow root is to author a separate
260+
`.css` file, import it as a `CSSStyleSheet` and declare it in your `.styles`.
261+
For more control, you can alternatively use `createRenderRoot` (see below).
262+
263+
```javascript
264+
import styleSheet from './my-element-style.css' with { type: 'css' };
265+
266+
class MyElement extends XElement {
267+
static get styles() {
268+
return [styleSheet];
269+
}
270+
}
271+
```
272+
257273
## Render Root
258274

259-
By default, XElement will create an open shadow root. However, you can change
260-
this behavior by overriding the `createRenderRoot` method. There are a few
261-
reasons why you might want to do this as shown below.
275+
By default, XElement will create an open shadow root and use that as your render
276+
root. However, you may want to customize or not attach a shadow root at all.
262277

263-
### No Shadow Root
278+
### Custom Shadow Root Initialization
279+
280+
Control special behavior like “focus delegation” by overriding the default
281+
shadow root configuration.
264282

265283
```javascript
266284
class MyElement extends XElement {
267285
static createRenderRoot(host) {
268-
return host;
286+
return host.attachShadow({ mode: 'open', delegatesFocus: true });
269287
}
270288
}
271289
```
272290

273-
### Focus Delegation
291+
### No Shadow Root
292+
293+
Sometimes, you don’t want encapsulation. No problem — just return the `host`.
274294

275295
```javascript
276296
class MyElement extends XElement {
277297
static createRenderRoot(host) {
278-
return host.attachShadowRoot({ mode: 'open', delegatesFocus: true });
298+
return host;
279299
}
280300
}
281301
```

test/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ test('./test-element-upgrade.html');
1313
test('./test-template-engine.html');
1414
test('./test-render.html');
1515
test('./test-render-root.html');
16+
test('./test-styles.html');
1617
test('./test-basic-properties.html');
1718
test('./test-initial-properties.html');
1819
test('./test-default-properties.html');

test/test-render-root.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import XElement from '../x-element.js';
22
import { assert, it } from './x-test.js';
33

4-
class TestElement extends XElement {
4+
class TestElement1 extends XElement {
55
static createRenderRoot(host) {
66
return host;
77
}
@@ -11,21 +11,21 @@ class TestElement extends XElement {
1111
};
1212
}
1313
}
14-
customElements.define('test-element', TestElement);
15-
14+
customElements.define('test-element-1', TestElement1);
1615

1716
it('test render root was respected', () => {
18-
const el = document.createElement('test-element');
17+
const el = document.createElement('test-element-1');
1918
document.body.append(el);
2019
assert(el.shadowRoot === null);
2120
assert(el.textContent === `I'm not in a shadow root.`);
21+
el.remove();
2222
});
2323

2424
it('errors are thrown in for creating a bad render root', () => {
2525
class BadElement extends XElement {
2626
static createRenderRoot() {}
2727
}
28-
customElements.define('test-element-1', BadElement);
28+
customElements.define('test-element-2', BadElement);
2929
let passed = false;
3030
let message = 'no error was thrown';
3131
try {

test/test-styles.css.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// TODO: Replace with actual css file when ESLint accepts import attributes.
2+
const css = `\
3+
:host {
4+
display: block;
5+
background-color: coral;
6+
width: 100px;
7+
height: 100px;
8+
}
9+
`;
10+
const styleSheet = new CSSStyleSheet();
11+
styleSheet.replaceSync(css);
12+
export default styleSheet;

test/test-styles.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<!doctype html>
2+
<html>
3+
<body>
4+
<meta charset="UTF-8">
5+
<script type="module" src="test-styles.js"></script>
6+
<h3>Test Styles</h3>
7+
</body>
8+
</html>

test/test-styles.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { assert, it } from './x-test.js';
2+
import styleSheet from './test-styles.css.js';
3+
import XElement from '../x-element.js';
4+
5+
class TestElement1 extends XElement {
6+
static count = 0;
7+
static get styles() {
8+
TestElement1.count++;
9+
return [styleSheet];
10+
}
11+
static template(html) {
12+
return () => {
13+
return html``;
14+
};
15+
}
16+
}
17+
customElements.define('test-element-1', TestElement1);
18+
19+
it('provided style sheets are adopted', () => {
20+
const el = document.createElement('test-element-1');
21+
document.body.append(el);
22+
const boundingClientRect = el.getBoundingClientRect();
23+
assert(boundingClientRect.width === 100);
24+
assert(boundingClientRect.height === 100);
25+
el.remove();
26+
});
27+
28+
it('should only get styles _once_ per constructor', () => {
29+
for (let iii = 0; iii < 10; iii++) {
30+
// No matter how many times you do this, styles must only be accessed once.
31+
const el = document.createElement('test-element-1');
32+
document.body.append(el);
33+
const boundingClientRect = el.getBoundingClientRect();
34+
assert(boundingClientRect.width === 100);
35+
assert(boundingClientRect.height === 100);
36+
el.remove();
37+
assert(TestElement1.count === 1);
38+
}
39+
});
40+
41+
it('errors are thrown when providing styles without a shadow root', () => {
42+
class BadElement extends XElement {
43+
static get styles() { return [styleSheet]; }
44+
static createRenderRoot(host) { return host; }
45+
}
46+
customElements.define('test-element-2', BadElement);
47+
let passed = false;
48+
let message = 'no error was thrown';
49+
try {
50+
new BadElement();
51+
} catch (error) {
52+
const expected = 'Unexpected "styles" declared without a shadow root.';
53+
message = error.message;
54+
passed = error.message === expected;
55+
}
56+
assert(passed, message);
57+
});
58+
59+
it('errors are thrown when styles already exist on shadow root.', () => {
60+
class BadElement extends XElement {
61+
static get styles() { return [styleSheet]; }
62+
static createRenderRoot(host) {
63+
host.attachShadow({ mode: 'open' });
64+
host.shadowRoot.adoptedStyleSheets = [styleSheet];
65+
return host.shadowRoot;
66+
}
67+
}
68+
customElements.define('test-element-3', BadElement);
69+
let passed = false;
70+
let message = 'no error was thrown';
71+
try {
72+
new BadElement();
73+
} catch (error) {
74+
const expected = 'Unexpected "styles" declared when preexisting "adoptedStyleSheets" exist.';
75+
message = error.message;
76+
passed = error.message === expected;
77+
}
78+
assert(passed, message);
79+
});

x-element.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export class XElement extends HTMLElement {
6161
render: (container: HTMLElement, result: any) => void,
6262
html: (strings: TemplateStringsArray, ...any) => any,
6363
}
64+
static readonly styles: [CSSStyleSheet]
6465
static createRenderRoot(host: XElement): HTMLElement;
6566
static template(
6667
html: (strings: TemplateStringsArray, ...any) => any,

x-element.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export default class XElement extends HTMLElement {
1111
return TemplateEngine.interface;
1212
}
1313

14-
/** Configured templating engine. Defaults to "defaultTemplateEngine".
14+
/**
15+
* Configured templating engine. Defaults to "defaultTemplateEngine".
1516
*
1617
* Override this as needed if x-element's default template engine does not
1718
* meet your needs. A "render" method is the only required field. An "html"
@@ -21,6 +22,15 @@ export default class XElement extends HTMLElement {
2122
return XElement.defaultTemplateEngine;
2223
}
2324

25+
/**
26+
* Declare an array of CSSStyleSheet objects to adopt on the shadow root.
27+
* Note that a CSSStyleSheet object is the type returned when importing a
28+
* stylesheet file via import attributes.
29+
*/
30+
static get styles() {
31+
return [];
32+
}
33+
2434
/**
2535
* Declare watched properties (and related attributes) on an element.
2636
*
@@ -197,7 +207,7 @@ export default class XElement extends HTMLElement {
197207

198208
// Called once per class — kicked off from "static get observedAttributes".
199209
static #analyzeConstructor(constructor) {
200-
const { properties, listeners } = constructor;
210+
const { styles, properties, listeners } = constructor;
201211
const propertiesEntries = Object.entries(properties);
202212
const listenersEntries = Object.entries(listeners);
203213
XElement.#validateProperties(constructor, properties, propertiesEntries);
@@ -222,7 +232,7 @@ export default class XElement extends HTMLElement {
222232
}
223233
const listenerMap = new Map(listenersEntries);
224234
XElement.#constructors.set(constructor, {
225-
propertyMap, internalPropertyMap, attributeMap, listenerMap,
235+
styles, propertyMap, internalPropertyMap, attributeMap, listenerMap,
226236
propertiesTarget, internalTarget,
227237
});
228238
}
@@ -534,7 +544,18 @@ export default class XElement extends HTMLElement {
534544
const computeMap = new Map();
535545
const observeMap = new Map();
536546
const defaultMap = new Map();
537-
const { propertyMap } = XElement.#constructors.get(host.constructor);
547+
const { styles, propertyMap } = XElement.#constructors.get(host.constructor);
548+
if (styles.length > 0) {
549+
if (renderRoot === host.shadowRoot) {
550+
if (renderRoot.adoptedStyleSheets.length === 0) {
551+
renderRoot.adoptedStyleSheets = styles;
552+
} else {
553+
throw new Error('Unexpected "styles" declared when preexisting "adoptedStyleSheets" exist.');
554+
}
555+
} else {
556+
throw new Error('Unexpected "styles" declared without a shadow root.');
557+
}
558+
}
538559
for (const property of propertyMap.values()) {
539560
if (property.compute) {
540561
computeMap.set(property, { valid: false, args: undefined });

0 commit comments

Comments
 (0)