-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
211 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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++) | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | ||
} | ||
} | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
}) | ||
}) | ||
}) |