From 5245044468d2a8d47a050b5b785b463a913f036c 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 | 92 ++++++++++++++++++++++++++++++++++++++++ src/styleable.ts | 43 +++++++++++++++++++ test/styleable.ts | 47 ++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 docs/_guide/styleable.md create mode 100644 src/styleable.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..e3dc874f --- /dev/null +++ b/docs/_guide/styleable.md @@ -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) + } +} +``` diff --git a/src/styleable.ts b/src/styleable.ts new file mode 100644 index 00000000..cf9ad7bf --- /dev/null +++ b/src/styleable.ts @@ -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( + ({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..70634138 --- /dev/null +++ b/test/styleable.ts @@ -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} +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`

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('throws an error when trying to set stylesheet', async () => { + const instance = await fixture(html``) + expect(() => (instance.foo = css``)).to.throw(/Cannot set @style/) + }) +})