From bdcbd42b01a5fd98238389c30e72ff17de5d2a19 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Mon, 5 Sep 2022 15:41:36 +0100 Subject: [PATCH] add stylable behavior --- docs/_guide/styleable.md | 104 +++++++++++++++++++++++++++++++++++++++ src/stylable.ts | 53 ++++++++++++++++++++ test/styleable.ts | 54 ++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 docs/_guide/styleable.md create mode 100644 src/stylable.ts create mode 100644 test/styleable.ts diff --git a/docs/_guide/styleable.md b/docs/_guide/styleable.md new file mode 100644 index 00000000..9e203600 --- /dev/null +++ b/docs/_guide/styleable.md @@ -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 +Alex +Riley +``` + +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++) + } +} +``` diff --git a/src/stylable.ts b/src/stylable.ts new file mode 100644 index 00000000..cd9cabfc --- /dev/null +++ b/src/stylable.ts @@ -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} + +const cssMem = new WeakMap() +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( + ({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( + (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] + } + } +) diff --git a/test/styleable.ts b/test/styleable.ts new file mode 100644 index 00000000..71b5f94d --- /dev/null +++ b/test/styleable.ts @@ -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 = '

Hello

' + } + } + window.customElements.define('stylable-test', StylableTest) + + it('adoptes styles into shadowRoot', async () => { + const instance = await fixture(html``) + 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(html``) + 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(html``) + 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()) + }) + }) +})