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 22, 2022
1 parent bf71d4a commit 29466b8
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 0 deletions.
92 changes: 92 additions & 0 deletions docs/_guide/styleable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
---
chapter: 8
subtitle: Bringing CSS into ShadowDOM
hidden: false
---

Components with ShadowDOM typically want to introduce some CSS into their ShadowRoots. This is done with the use of [`adoptedStyleSheets`](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/adoptedStyleSheets). Catalyst provides a `@style` decorator 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' })
// adoptedStyleSheets now includes our 3 stylesheets!
console.assert(this.shadowRoot.adoptedStyleSheets.length === 3)
}
}
```

### Defining CSS in JS

The `@style` decorator takes a constructed [`CSSStyleSheet`](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet) object. These must be constructed in JavaScript, but can be generated by helper libraries or custom loaders. If, for example, you like writing your CSS the same file as your element, you can manually create a CSSStyleSheet:

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

const sheet = new CSSStyleSheet()
sheet.replaceSync(`
:host {
display: flex
}
`)

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

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

Alternatively you can import one, as long as the return value is a [Constructable CSSStyleSheet](https://web.dev/constructable-stylesheets/):

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

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

connectedCallback() {
this.attachShadow({ mode: 'open' })
console.assert(this.componentCSS instanceof CSSStyleSheet)
}
}
```
43 changes: 43 additions & 0 deletions src/styleable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {CustomElementClass, CustomElement} from './custom-element.js'
import {controllable, attachShadowCallback} from './controllable.js'
import {createMark} from './mark.js'
import {createAbility} from './ability.js'

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]
}
}
)
62 changes: 62 additions & 0 deletions test/styleable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {expect, fixture} from '@open-wc/testing'
import {style, stylable} from '../src/styleable.js'
const html = String.raw

type TemplateString = {raw: readonly string[] | ArrayLike<string>}
const css = (strings: TemplateString, ...values: unknown[]): CSSStyleSheet => {
const sheet = new CSSStyleSheet()
sheet.replaceSync(String.raw(strings, ...values))
return sheet
}

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 = html`<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 29466b8

Please sign in to comment.