Skip to content

Commit

Permalink
add stylable behavior
Browse files Browse the repository at this point in the history
  • Loading branch information
keithamus committed Sep 5, 2022
1 parent 570c2b1 commit bdcbd42
Show file tree
Hide file tree
Showing 3 changed files with 211 additions and 0 deletions.
104 changes: 104 additions & 0 deletions docs/_guide/styleable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
chapter: 8
subtitle: Bringing CSS into ShadowDOM
hidden: true
---

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.

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/):

```typescript
import {controller, style} from '@github/catalyst'
import DesignSystemCSS from './my-design-system.css' assert { type: 'css' }

@controller
class UserRow extends HTMLElement {
@style designSystem = DesignSystemCSS

connectedCallback() {
this.attachShadow({ mode: 'open' })
// adoptedStyleSheets now includes our DesignSystemCSS!
console.assert(this.shadowRoot.adoptedStyleSheets.includes(this.designSystem))
}
}
```

Multiple `@style` tags are allowed, each one will be applied to the `adoptedStyleSheets` meaning you can split your CSS without worry!

```typescript
import {controller} from '@github/catalyst'
import UtilityCSS from './my-design-system/utilities.css' assert { type: 'css' }
import NormalizeCSS from './my-design-system/normalize.css' assert { type: 'css' }
import UserRowCSS from './my-design-system/components/user-row.css' assert { type: 'css' }

@controller
class UserRow extends HTMLElement {
@style utilityCSS = UtilityCSS
@style normalizeCSS = NormalizeCSS
@style userRowCSS = UserRowCSS

connectedCallback() {
this.attachShadow({ mode: 'open' })
}
}
```

### Defining CSS in JS

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:

```typescript
import {controller, style, css} from '@github/catalyst'

@controller
class UserRow extends HTMLElement {
@style componentCSS = css`:host { display: flex }`

connectedCallback() {
this.attachShadow({ mode: 'open' })
}
}
```

As always though, the best way to handle dynamic per-instance values is with CSS variables:

```typescript
import {controller, style, css} from '@github/catalyst'

const sizeCSS = (size = 1) => css`:host { font-size: var(--font-size, ${size}em); }`

@controller
class UserRow extends HTMLElement {
@style componentCSS = sizeCSS

@attr set fontSize(n: number) {
this.style.setProperty('--font-size', n)
}
}
```
```html
<user-row font-size="1">Alex</user-row>
<user-row font-size="3">Riley</user-row>
```

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.

```typescript
import {controller, style, css} from '@github/catalyst'

const sizeCSS = (size = 1) => css`:host { font-size: ${size}em; }`

// Calling sizeCSS will always result in the same CSSStyleSheet object
console.assert(sizeCSS(1) === sizeCSS(2))

@controller
class UserRow extends HTMLElement {
@style componentCSS = sizeCSS

#size = 1
makeAllUsersLargerFont() {
sizeCSS(this.#size++)
}
}
```
53 changes: 53 additions & 0 deletions src/stylable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type {CustomElementClass, CustomElement} from './custom-element.js'
import {controllable, attachShadowCallback} from './controllable.js'
import {createMark} from './mark.js'
import {createAbility} from './ability.js'

type TemplateString = {raw: readonly string[] | ArrayLike<string>}

const cssMem = new WeakMap<TemplateString, CSSStyleSheet>()
export const css = (strings: TemplateString, ...values: unknown[]): CSSStyleSheet => {
if (!cssMem.has(strings)) cssMem.set(strings, new CSSStyleSheet())
const sheet = cssMem.get(strings)!
sheet.replaceSync(String.raw(strings, ...values))
return sheet
}

const [style, getStyle, initStyle] = createMark<CustomElement>(
({name, kind}) => {
if (kind === 'setter') throw new Error(`@style cannot decorate setter ${String(name)}`)
if (kind === 'method') throw new Error(`@style cannot decorate method ${String(name)}`)
},
(instance: CustomElement, {name, kind, access}) => {
return {
get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
set: () => {
throw new Error(`Cannot set @style ${String(name)}`)
}
}
}
)

export {style, getStyle}
export const stylable = createAbility(
<T extends CustomElementClass>(Class: T): T =>
class extends controllable(Class) {
[key: PropertyKey]: unknown

// TS mandates Constructors that get mixins have `...args: any[]`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
super(...args)
initStyle(this)
}

[attachShadowCallback](root: ShadowRoot) {
super[attachShadowCallback]?.(root)
const styleProps = getStyle(this)
if (!styleProps.size) return
const styles = new Set([...root.adoptedStyleSheets])
for (const name of styleProps) styles.add(this[name] as CSSStyleSheet)
root.adoptedStyleSheets = [...styles]
}
}
)
54 changes: 54 additions & 0 deletions test/styleable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {expect, fixture, html} from '@open-wc/testing'
import {style, css, stylable} from '../src/stylable.js'

describe('Styleable', () => {
const globalCSS = ({color}: {color: string}) =>
css`
:host {
color: ${color};
}
`

@stylable
class StylableTest extends HTMLElement {
@style foo = css`
body {
display: block;
}
`
@style bar = globalCSS({color: 'rgb(255, 105, 180)'})

constructor() {
super()
this.attachShadow({mode: 'open'}).innerHTML = '<p>Hello</p>'
}
}
window.customElements.define('stylable-test', StylableTest)

it('adoptes styles into shadowRoot', async () => {
const instance = await fixture<StylableTest>(html`<stylable-test></stylable-test>`)
expect(instance.foo).to.be.instanceof(CSSStyleSheet)
expect(instance.bar).to.be.instanceof(CSSStyleSheet)
expect(instance.shadowRoot!.adoptedStyleSheets).to.eql([instance.foo, instance.bar])
})

it('updates stylesheets that get recomputed', async () => {
const instance = await fixture<StylableTest>(html`<stylable-test></stylable-test>`)
expect(getComputedStyle(instance.shadowRoot!.children[0]!).color).to.equal('rgb(255, 105, 180)')
globalCSS({color: 'rgb(0, 0, 0)'})
expect(getComputedStyle(instance.shadowRoot!.children[0]!).color).to.equal('rgb(0, 0, 0)')
})

it('throws an error when trying to set stylesheet', async () => {
const instance = await fixture<StylableTest>(html`<stylable-test></stylable-test>`)
expect(() => (instance.foo = css``)).to.throw(/Cannot set @style/)
})

describe('css', () => {
it('returns the same CSSStyleSheet for subsequent calls from same template string', () => {
expect(css``).to.not.equal(css``)
const mySheet = () => css``
expect(mySheet()).to.equal(mySheet())
})
})
})

0 comments on commit bdcbd42

Please sign in to comment.