feat: composing stylesheets
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run

This commit is contained in:
ayo 2026-07-19 21:19:01 +02:00
parent 2da6cd5afc
commit 133881a127
7 changed files with 207 additions and 57 deletions

View file

@ -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).
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).
![counter example code snippet](https://git.sr.ht/~ayoayco/wcb/blob/main/assets/IMG_0682.png)
@ -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.
## 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?
There are many ways to get in touch:

View file

@ -10,6 +10,22 @@
<script type="module" src="../../shell.js"></script>
</head>
<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>
<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>
</html>

View file

@ -28,3 +28,46 @@ class StyledElements extends WebComponent {
}
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)

View file

@ -55,7 +55,7 @@ customElements.define('styled-elements', StyledElement)
## 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)
@ -88,3 +88,47 @@ class StyledElement extends WebComponent {
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.

View file

@ -80,7 +80,12 @@ export class WebComponent extends HTMLElement {
*/
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
/**
@ -290,13 +295,19 @@ export class WebComponent extends HTMLElement {
}
#applyStyles() {
if (this.constructor.styles !== undefined)
const styles = this.constructor.styles
if (styles !== undefined)
try {
const styleObj = new CSSStyleSheet()
styleObj.replaceSync(this.constructor.styles)
// one sheet or many, in declaration order — a design system can put a
// shared tokens sheet first and component styles after it
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) {
console.error(

View file

@ -182,6 +182,72 @@ describe('styling', () => {
const el = mount(ShadowStyled)
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', () => {

View file

@ -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.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')
})