feat: composing stylesheets
This commit is contained in:
parent
2da6cd5afc
commit
133881a127
7 changed files with 207 additions and 57 deletions
55
README.md
55
README.md
|
|
@ -12,7 +12,10 @@
|
||||||
|
|
||||||
This is the base class used for web components in Ayo's projects, primarily [cozy-games](https://git.ayo.run/ayo/cozy-games), [mcfly](https://git.ayo.run/ayo/mcfly/), his [personal site](https://ayo.ayco.io), his [blog](https://ayos.blog), and [others](https://git.ayo.run/ayo).
|
This is the base class used for web components in Ayo's projects, primarily [cozy-games](https://git.ayo.run/ayo/cozy-games), [mcfly](https://git.ayo.run/ayo/mcfly/), his [personal site](https://ayo.ayco.io), his [blog](https://ayos.blog), and [others](https://git.ayo.run/ayo).
|
||||||
|
|
||||||
Read more about it on the [docs](https://webcomponent.io) or [view a demo on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwoNOz?editors=1010).
|
Next actions:
|
||||||
|
|
||||||
|
1. [Read the docs](https://webcomponent.io)
|
||||||
|
2. [View a demo on CodePen](https://codepen.io/ayoayco-the-styleful/pen/ZEwoNOz?editors=1010).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -20,56 +23,6 @@ When you extend the `WebComponent` class for your component, you only have to de
|
||||||
|
|
||||||
The result is a reactive UI on property changes.
|
The result is a reactive UI on property changes.
|
||||||
|
|
||||||
|
|
||||||
## TypeScript: typed props
|
|
||||||
|
|
||||||
`this.props` is untyped (`{ [name: string]: any }`) by default. Pass the shape of your defaults as a type argument to get compile-time types on declared props:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const props = { variant: 'primary', disabled: false }
|
|
||||||
|
|
||||||
class CozyButton extends WebComponent<typeof props> {
|
|
||||||
static props = props
|
|
||||||
|
|
||||||
get template() {
|
|
||||||
this.props.variant // string
|
|
||||||
this.props.disabled // boolean
|
|
||||||
this.props.disabled = 'yes' // ❌ compile error
|
|
||||||
return html`<button class=${this.props.variant}></button>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The runtime is unchanged — this is types-only, and omitting the type argument keeps the previous behavior. See the [prop access guide](https://webcomponent.io/prop-access/) for details.
|
|
||||||
|
|
||||||
## Storybook autodocs & controls
|
|
||||||
|
|
||||||
Storybook infers autodocs and controls from a Custom Elements Manifest, but the stock analyzer reads `static props` as one opaque `object` and emits no attributes. `web-component-base/cem-plugin` teaches it the convention — dev-time only, so the core stays zero-dependency:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// custom-elements-manifest.config.mjs
|
|
||||||
import { wcbStaticProps } from 'web-component-base/cem-plugin'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
globs: ['src/**/*.js'],
|
|
||||||
outdir: '.',
|
|
||||||
plugins: [wcbStaticProps()],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`npx cem analyze` then emits a typed attribute per prop — `variant` (string), `disabled` (boolean), `maxCount` → `max-count` (number) — named with wcb's own `getKebabCase` so they match `observedAttributes`, with wcb internals stripped. Point Storybook at the result:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// .storybook/preview.js
|
|
||||||
import { setCustomElementsManifest } from '@storybook/web-components-vite'
|
|
||||||
import manifest from '../custom-elements.json'
|
|
||||||
|
|
||||||
setCustomElementsManifest(manifest)
|
|
||||||
export default { tags: ['autodocs'] }
|
|
||||||
```
|
|
||||||
|
|
||||||
A story only needs `component: 'cozy-button'` — no per-story `argTypes`. See the [full recipe](https://webcomponent.io/cem-plugin/), or the working setup in [`storybook/`](./storybook).
|
|
||||||
|
|
||||||
## Want to get in touch?
|
## Want to get in touch?
|
||||||
|
|
||||||
There are many ways to get in touch:
|
There are many ways to get in touch:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,22 @@
|
||||||
<script type="module" src="../../shell.js"></script>
|
<script type="module" src="../../shell.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<h1>Constructable styles</h1>
|
||||||
|
<p>
|
||||||
|
<code>static styles</code> is adopted into the shadow root as a
|
||||||
|
constructable stylesheet. It takes a single string, or an array adopted in
|
||||||
|
order.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>A single string</h2>
|
||||||
<styled-elements type="warn" condition></styled-elements>
|
<styled-elements type="warn" condition></styled-elements>
|
||||||
|
|
||||||
|
<h2>An array of sheets</h2>
|
||||||
|
<p>
|
||||||
|
Shared tokens, then a ready-made <code>CSSStyleSheet</code>, then local
|
||||||
|
styles — so a design system composes a base sheet with per-component CSS
|
||||||
|
instead of inlining the base in every component.
|
||||||
|
</p>
|
||||||
|
<composed-styles></composed-styles>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,46 @@ class StyledElements extends WebComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('styled-elements', StyledElements)
|
customElements.define('styled-elements', StyledElements)
|
||||||
|
|
||||||
|
// `static styles` also takes an array, adopted in order — a shared base sheet
|
||||||
|
// composed with per-component styles, instead of inlining the base everywhere.
|
||||||
|
const tokens = `
|
||||||
|
:host {
|
||||||
|
--demo-accent: rebeccapurple;
|
||||||
|
--demo-radius: 6px;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// entries may be strings or ready-made CSSStyleSheet objects; a sheet is
|
||||||
|
// adopted as-is, so one instance can be shared by many components
|
||||||
|
const base = new CSSStyleSheet()
|
||||||
|
base.replaceSync(`
|
||||||
|
div {
|
||||||
|
border: 2px solid var(--demo-accent);
|
||||||
|
border-radius: var(--demo-radius);
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
class ComposedStyles extends WebComponent {
|
||||||
|
static shadowRootInit = {
|
||||||
|
mode: 'open',
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
tokens,
|
||||||
|
base,
|
||||||
|
// last one wins on equal specificity
|
||||||
|
`p { color: var(--demo-accent); font-weight: 600; }`,
|
||||||
|
]
|
||||||
|
|
||||||
|
get template() {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<p>Three sheets: tokens, a shared CSSStyleSheet, and local styles.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('composed-styles', ComposedStyles)
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ customElements.define('styled-elements', StyledElement)
|
||||||
|
|
||||||
## Using the Shadow DOM and Constructable Stylesheets
|
## Using the Shadow DOM and Constructable Stylesheets
|
||||||
|
|
||||||
If you [use the Shadow DOM](/shadow-dom), you can add a `static styles` property of type string which will be added in the `shadowRoot`'s [`adoptedStylesheets`](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets).
|
If you [use the Shadow DOM](/shadow-dom), you can add a `static styles` property which will be added to the `shadowRoot`'s [`adoptedStylesheets`](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets). It accepts a string, a `CSSStyleSheet`, or an array of either.
|
||||||
|
|
||||||
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/JojmeEe?editors=1010)
|
Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/JojmeEe?editors=1010)
|
||||||
|
|
||||||
|
|
@ -88,3 +88,47 @@ class StyledElement extends WebComponent {
|
||||||
|
|
||||||
customElements.define('styled-elements', StyledElement)
|
customElements.define('styled-elements', StyledElement)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Composing several stylesheets
|
||||||
|
|
||||||
|
Pass an array to adopt more than one sheet. They are applied **in order**, so later entries win on equal specificity — put shared tokens or a base sheet first and per-component styles after it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tokens.js — shared across every component
|
||||||
|
export const tokens = `
|
||||||
|
:host {
|
||||||
|
--cozy-radius: 6px;
|
||||||
|
--cozy-accent: rebeccapurple;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// cozy-button.js
|
||||||
|
import { tokens } from './tokens.js'
|
||||||
|
|
||||||
|
class CozyButton extends WebComponent {
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = [
|
||||||
|
tokens,
|
||||||
|
`
|
||||||
|
button {
|
||||||
|
border-radius: var(--cozy-radius);
|
||||||
|
background: var(--cozy-accent);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Entries may be strings or ready-made [`CSSStyleSheet`](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet) objects, and the two can be mixed. A `CSSStyleSheet` is adopted as-is rather than re-created, so one shared instance can be constructed once and reused by every component that adopts it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const base = new CSSStyleSheet()
|
||||||
|
base.replaceSync(tokens)
|
||||||
|
|
||||||
|
class CozyBadge extends WebComponent {
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = [base, `span { font-size: 0.8em; }`]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A single string keeps working exactly as before — the array form is additive.
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,12 @@ export class WebComponent extends HTMLElement {
|
||||||
*/
|
*/
|
||||||
static props
|
static props
|
||||||
|
|
||||||
// TODO: support array of styles
|
/**
|
||||||
|
* CSS adopted into the shadow root as constructable stylesheet(s). An array
|
||||||
|
* is adopted in order, so shared/base sheets can be composed with
|
||||||
|
* per-component ones. Requires `static shadowRootInit`.
|
||||||
|
* @type {string | CSSStyleSheet | Array<string | CSSStyleSheet>}
|
||||||
|
*/
|
||||||
static styles
|
static styles
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -290,13 +295,19 @@ export class WebComponent extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
#applyStyles() {
|
#applyStyles() {
|
||||||
if (this.constructor.styles !== undefined)
|
const styles = this.constructor.styles
|
||||||
|
if (styles !== undefined)
|
||||||
try {
|
try {
|
||||||
const styleObj = new CSSStyleSheet()
|
// one sheet or many, in declaration order — a design system can put a
|
||||||
styleObj.replaceSync(this.constructor.styles)
|
// shared tokens sheet first and component styles after it
|
||||||
this.#host.adoptedStyleSheets = [
|
this.#host.adoptedStyleSheets = [
|
||||||
...this.#host.adoptedStyleSheets,
|
...this.#host.adoptedStyleSheets,
|
||||||
styleObj,
|
...[styles].flat().map((s) => {
|
||||||
|
if (typeof s != 'string') return s
|
||||||
|
const sheet = new CSSStyleSheet()
|
||||||
|
sheet.replaceSync(s)
|
||||||
|
return sheet
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,72 @@ describe('styling', () => {
|
||||||
const el = mount(ShadowStyled)
|
const el = mount(ShadowStyled)
|
||||||
expect(el.shadowRoot.adoptedStyleSheets).toHaveLength(1)
|
expect(el.shadowRoot.adoptedStyleSheets).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('adopts an array of styles in declaration order', () => {
|
||||||
|
class MultiStyled extends WebComponent {
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = [`p { color: red; }`, `p { padding: 1em; }`]
|
||||||
|
get template() {
|
||||||
|
return html`<p>hi</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(MultiStyled)
|
||||||
|
const sheets = el.shadowRoot.adoptedStyleSheets
|
||||||
|
expect(sheets).toHaveLength(2)
|
||||||
|
expect(sheets[0].cssRules[0].style.color).toBe('red')
|
||||||
|
expect(sheets[1].cssRules[0].style.padding).toBe('1em')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes CSSStyleSheet entries through, mixed with strings', () => {
|
||||||
|
const shared = new CSSStyleSheet()
|
||||||
|
shared.replaceSync(`p { color: blue; }`)
|
||||||
|
|
||||||
|
class MixedStyled extends WebComponent {
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = [shared, `p { padding: 2em; }`]
|
||||||
|
get template() {
|
||||||
|
return html`<p>hi</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(MixedStyled)
|
||||||
|
const sheets = el.shadowRoot.adoptedStyleSheets
|
||||||
|
expect(sheets).toHaveLength(2)
|
||||||
|
// the existing instance is adopted as-is, not re-created
|
||||||
|
expect(sheets[0]).toBe(shared)
|
||||||
|
expect(sheets[1].cssRules[0].style.padding).toBe('2em')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a lone CSSStyleSheet', () => {
|
||||||
|
const sheet = new CSSStyleSheet()
|
||||||
|
sheet.replaceSync(`p { color: green; }`)
|
||||||
|
|
||||||
|
class SheetStyled extends WebComponent {
|
||||||
|
static shadowRootInit = { mode: 'open' }
|
||||||
|
static styles = sheet
|
||||||
|
get template() {
|
||||||
|
return html`<p>hi</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const el = mount(SheetStyled)
|
||||||
|
expect(el.shadowRoot.adoptedStyleSheets).toEqual([sheet])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the shadow-root guidance instead of throwing in light DOM', () => {
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
class LightStyled extends WebComponent {
|
||||||
|
static styles = [`p { color: red; }`]
|
||||||
|
get template() {
|
||||||
|
return html`<p>hi</p>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(() => mount(LightStyled)).not.toThrow()
|
||||||
|
expect(error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('shadow roots'),
|
||||||
|
expect.anything()
|
||||||
|
)
|
||||||
|
error.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('shadow DOM', () => {
|
describe('shadow DOM', () => {
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,20 @@ test('applies a constructable stylesheet via adoptedStyleSheets in the shadow ro
|
||||||
expect(el.shadowRoot.adoptedStyleSheets.length).toBeGreaterThan(0)
|
expect(el.shadowRoot.adoptedStyleSheets.length).toBeGreaterThan(0)
|
||||||
expect(el.shadowRoot.querySelector('p').textContent.trim()).toBe('Wow!?')
|
expect(el.shadowRoot.querySelector('p').textContent.trim()).toBe('Wow!?')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('adopts an array of styles in order, and the composed CSS actually applies', () => {
|
||||||
|
document.body.innerHTML = '<composed-styles></composed-styles>'
|
||||||
|
const el = document.querySelector('composed-styles')
|
||||||
|
|
||||||
|
// tokens, a shared CSSStyleSheet, then local styles
|
||||||
|
expect(el.shadowRoot.adoptedStyleSheets).toHaveLength(3)
|
||||||
|
|
||||||
|
// the real proof: a custom property declared in sheet 1 resolves inside
|
||||||
|
// sheet 2's rule, and sheet 3's own rule applies — computed, not asserted
|
||||||
|
// against the source text
|
||||||
|
const div = el.shadowRoot.querySelector('div')
|
||||||
|
const p = el.shadowRoot.querySelector('p')
|
||||||
|
expect(getComputedStyle(div).borderRadius).toBe('6px')
|
||||||
|
expect(getComputedStyle(div).borderTopWidth).toBe('2px')
|
||||||
|
expect(getComputedStyle(p).fontWeight).toBe('600')
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue