diff --git a/README.md b/README.md index 95d9936..61f7fcf 100644 --- a/README.md +++ b/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). ![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 { - static props = props - - get template() { - this.props.variant // string - this.props.disabled // boolean - this.props.disabled = 'yes' // ❌ compile error - return html`` - } -} -``` - -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: diff --git a/demo/examples/constructed-styles/index.html b/demo/examples/constructed-styles/index.html index be40af2..f5d0b94 100644 --- a/demo/examples/constructed-styles/index.html +++ b/demo/examples/constructed-styles/index.html @@ -10,6 +10,22 @@ +

Constructable styles

+

+ static styles is adopted into the shadow root as a + constructable stylesheet. It takes a single string, or an array adopted in + order. +

+ +

A single string

+ +

An array of sheets

+

+ Shared tokens, then a ready-made CSSStyleSheet, then local + styles — so a design system composes a base sheet with per-component CSS + instead of inlining the base in every component. +

+ diff --git a/demo/examples/constructed-styles/index.js b/demo/examples/constructed-styles/index.js index a2876b7..76020f8 100644 --- a/demo/examples/constructed-styles/index.js +++ b/demo/examples/constructed-styles/index.js @@ -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` +
+

Three sheets: tokens, a shared CSSStyleSheet, and local styles.

+
+ ` + } +} + +customElements.define('composed-styles', ComposedStyles) diff --git a/docs/src/content/docs/guides/styling.md b/docs/src/content/docs/guides/styling.md index ed40a47..96d9484 100644 --- a/docs/src/content/docs/guides/styling.md +++ b/docs/src/content/docs/guides/styling.md @@ -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. diff --git a/src/WebComponent.js b/src/WebComponent.js index 5b07aef..7bc7a91 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -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} + */ 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( diff --git a/test/WebComponent.test.mjs b/test/WebComponent.test.mjs index 7923a0f..08a0b21 100644 --- a/test/WebComponent.test.mjs +++ b/test/WebComponent.test.mjs @@ -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`

hi

` + } + } + 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`

hi

` + } + } + 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`

hi

` + } + } + 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`

hi

` + } + } + expect(() => mount(LightStyled)).not.toThrow() + expect(error).toHaveBeenCalledWith( + expect.stringContaining('shadow roots'), + expect.anything() + ) + error.mockRestore() + }) }) describe('shadow DOM', () => { diff --git a/test/e2e/constructed-styles.test.mjs b/test/e2e/constructed-styles.test.mjs index 97be1b3..652ee88 100644 --- a/test/e2e/constructed-styles.test.mjs +++ b/test/e2e/constructed-styles.test.mjs @@ -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 = '' + 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') +})