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).
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## 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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue