Skip to content

Add style decorator #279

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]
}
}
)
47 changes: 47 additions & 0 deletions test/styleable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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('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/)
})
})