Skip to content

Commit bdcbd42

Browse files
committed
add stylable behavior
1 parent 570c2b1 commit bdcbd42

File tree

3 files changed

+211
-0
lines changed

3 files changed

+211
-0
lines changed

docs/_guide/styleable.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
---
2+
chapter: 8
3+
subtitle: Bringing CSS into ShadowDOM
4+
hidden: true
5+
---
6+
7+
Components with ShadowDOM typically want to introduce some CSS into their ShadowRoots. This is done with the use of `adoptedStyleSheets`, which can be a little cumbersome, so Catalyst provides the `@style` decorator and `css` utility function to more easily add CSS to your component.
8+
9+
If your CSS lives in a different file, you can import the file with using the `assert { type: 'css' }` import assertion. You might need to configure your bundler tool to allow for this. If you're unfamiliar with this feature, you can [check out the web.dev article on CSS Module Scripts](https://web.dev/css-module-scripts/):
10+
11+
```typescript
12+
import {controller, style} from '@github/catalyst'
13+
import DesignSystemCSS from './my-design-system.css' assert { type: 'css' }
14+
15+
@controller
16+
class UserRow extends HTMLElement {
17+
@style designSystem = DesignSystemCSS
18+
19+
connectedCallback() {
20+
this.attachShadow({ mode: 'open' })
21+
// adoptedStyleSheets now includes our DesignSystemCSS!
22+
console.assert(this.shadowRoot.adoptedStyleSheets.includes(this.designSystem))
23+
}
24+
}
25+
```
26+
27+
Multiple `@style` tags are allowed, each one will be applied to the `adoptedStyleSheets` meaning you can split your CSS without worry!
28+
29+
```typescript
30+
import {controller} from '@github/catalyst'
31+
import UtilityCSS from './my-design-system/utilities.css' assert { type: 'css' }
32+
import NormalizeCSS from './my-design-system/normalize.css' assert { type: 'css' }
33+
import UserRowCSS from './my-design-system/components/user-row.css' assert { type: 'css' }
34+
35+
@controller
36+
class UserRow extends HTMLElement {
37+
@style utilityCSS = UtilityCSS
38+
@style normalizeCSS = NormalizeCSS
39+
@style userRowCSS = UserRowCSS
40+
41+
connectedCallback() {
42+
this.attachShadow({ mode: 'open' })
43+
}
44+
}
45+
```
46+
47+
### Defining CSS in JS
48+
49+
Sometimes it can be useful to define small snippets of CSS within JavaScript itself, and so for this we have the `css` helper function which can create a `CSSStyleSheet` object on-the-fly:
50+
51+
```typescript
52+
import {controller, style, css} from '@github/catalyst'
53+
54+
@controller
55+
class UserRow extends HTMLElement {
56+
@style componentCSS = css`:host { display: flex }`
57+
58+
connectedCallback() {
59+
this.attachShadow({ mode: 'open' })
60+
}
61+
}
62+
```
63+
64+
As always though, the best way to handle dynamic per-instance values is with CSS variables:
65+
66+
```typescript
67+
import {controller, style, css} from '@github/catalyst'
68+
69+
const sizeCSS = (size = 1) => css`:host { font-size: var(--font-size, ${size}em); }`
70+
71+
@controller
72+
class UserRow extends HTMLElement {
73+
@style componentCSS = sizeCSS
74+
75+
@attr set fontSize(n: number) {
76+
this.style.setProperty('--font-size', n)
77+
}
78+
}
79+
```
80+
```html
81+
<user-row font-size="1">Alex</user-row>
82+
<user-row font-size="3">Riley</user-row>
83+
```
84+
85+
The `css` function is memoized; it will always return the same `CSSStyleSheet` object for every callsite. This allows you to "lift" it into a function that can change the CSS for all components by calling the function, which will replace the CSS inside it.
86+
87+
```typescript
88+
import {controller, style, css} from '@github/catalyst'
89+
90+
const sizeCSS = (size = 1) => css`:host { font-size: ${size}em; }`
91+
92+
// Calling sizeCSS will always result in the same CSSStyleSheet object
93+
console.assert(sizeCSS(1) === sizeCSS(2))
94+
95+
@controller
96+
class UserRow extends HTMLElement {
97+
@style componentCSS = sizeCSS
98+
99+
#size = 1
100+
makeAllUsersLargerFont() {
101+
sizeCSS(this.#size++)
102+
}
103+
}
104+
```

src/stylable.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type {CustomElementClass, CustomElement} from './custom-element.js'
2+
import {controllable, attachShadowCallback} from './controllable.js'
3+
import {createMark} from './mark.js'
4+
import {createAbility} from './ability.js'
5+
6+
type TemplateString = {raw: readonly string[] | ArrayLike<string>}
7+
8+
const cssMem = new WeakMap<TemplateString, CSSStyleSheet>()
9+
export const css = (strings: TemplateString, ...values: unknown[]): CSSStyleSheet => {
10+
if (!cssMem.has(strings)) cssMem.set(strings, new CSSStyleSheet())
11+
const sheet = cssMem.get(strings)!
12+
sheet.replaceSync(String.raw(strings, ...values))
13+
return sheet
14+
}
15+
16+
const [style, getStyle, initStyle] = createMark<CustomElement>(
17+
({name, kind}) => {
18+
if (kind === 'setter') throw new Error(`@style cannot decorate setter ${String(name)}`)
19+
if (kind === 'method') throw new Error(`@style cannot decorate method ${String(name)}`)
20+
},
21+
(instance: CustomElement, {name, kind, access}) => {
22+
return {
23+
get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
24+
set: () => {
25+
throw new Error(`Cannot set @style ${String(name)}`)
26+
}
27+
}
28+
}
29+
)
30+
31+
export {style, getStyle}
32+
export const stylable = createAbility(
33+
<T extends CustomElementClass>(Class: T): T =>
34+
class extends controllable(Class) {
35+
[key: PropertyKey]: unknown
36+
37+
// TS mandates Constructors that get mixins have `...args: any[]`
38+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
39+
constructor(...args: any[]) {
40+
super(...args)
41+
initStyle(this)
42+
}
43+
44+
[attachShadowCallback](root: ShadowRoot) {
45+
super[attachShadowCallback]?.(root)
46+
const styleProps = getStyle(this)
47+
if (!styleProps.size) return
48+
const styles = new Set([...root.adoptedStyleSheets])
49+
for (const name of styleProps) styles.add(this[name] as CSSStyleSheet)
50+
root.adoptedStyleSheets = [...styles]
51+
}
52+
}
53+
)

test/styleable.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {expect, fixture, html} from '@open-wc/testing'
2+
import {style, css, stylable} from '../src/stylable.js'
3+
4+
describe('Styleable', () => {
5+
const globalCSS = ({color}: {color: string}) =>
6+
css`
7+
:host {
8+
color: ${color};
9+
}
10+
`
11+
12+
@stylable
13+
class StylableTest extends HTMLElement {
14+
@style foo = css`
15+
body {
16+
display: block;
17+
}
18+
`
19+
@style bar = globalCSS({color: 'rgb(255, 105, 180)'})
20+
21+
constructor() {
22+
super()
23+
this.attachShadow({mode: 'open'}).innerHTML = '<p>Hello</p>'
24+
}
25+
}
26+
window.customElements.define('stylable-test', StylableTest)
27+
28+
it('adoptes styles into shadowRoot', async () => {
29+
const instance = await fixture<StylableTest>(html`<stylable-test></stylable-test>`)
30+
expect(instance.foo).to.be.instanceof(CSSStyleSheet)
31+
expect(instance.bar).to.be.instanceof(CSSStyleSheet)
32+
expect(instance.shadowRoot!.adoptedStyleSheets).to.eql([instance.foo, instance.bar])
33+
})
34+
35+
it('updates stylesheets that get recomputed', async () => {
36+
const instance = await fixture<StylableTest>(html`<stylable-test></stylable-test>`)
37+
expect(getComputedStyle(instance.shadowRoot!.children[0]!).color).to.equal('rgb(255, 105, 180)')
38+
globalCSS({color: 'rgb(0, 0, 0)'})
39+
expect(getComputedStyle(instance.shadowRoot!.children[0]!).color).to.equal('rgb(0, 0, 0)')
40+
})
41+
42+
it('throws an error when trying to set stylesheet', async () => {
43+
const instance = await fixture<StylableTest>(html`<stylable-test></stylable-test>`)
44+
expect(() => (instance.foo = css``)).to.throw(/Cannot set @style/)
45+
})
46+
47+
describe('css', () => {
48+
it('returns the same CSSStyleSheet for subsequent calls from same template string', () => {
49+
expect(css``).to.not.equal(css``)
50+
const mySheet = () => css``
51+
expect(mySheet()).to.equal(mySheet())
52+
})
53+
})
54+
})

0 commit comments

Comments
 (0)