diff --git a/src/component/component.ts b/src/component/component.ts index 2a0e1d72..5b717481 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -6,6 +6,7 @@ import type { ComponentNode } from "./component_node"; export class Component { static template: string = ""; + static style: string = ""; props: any; env: any; diff --git a/src/component/component_node.ts b/src/component/component_node.ts index 1a585689..b975cfc2 100644 --- a/src/component/component_node.ts +++ b/src/component/component_node.ts @@ -12,6 +12,7 @@ import { } from "./fibers"; import { applyDefaultProps } from "./props_validation"; import { STATUS } from "./status"; +import { applyStyles } from "./style"; export function component( name: string | typeof Component, @@ -89,6 +90,9 @@ export class ComponentNode implements VNode will be applied to the DOM in the order they are + * included in the document. We then delete the `style` key from the constructor + * to make sure we do not apply it again. + */ +export function applyStyles(ComponentClass: typeof Component) { + const toApply: [string, string][] = []; + while (ComponentClass && ComponentClass.style) { + if (ComponentClass.hasOwnProperty("style")) { + toApply.push([ComponentClass.style, ComponentClass.name]); + delete (ComponentClass as any).style; + } + ComponentClass = Object.getPrototypeOf(ComponentClass); + } + while (toApply.length) { + const [styleId, componentName] = toApply.pop()!; + activateSheet(styleId, componentName); + } +} + +function activateSheet(id: string, name: string) { + const sheet = globalStylesheets[id]; + if (!sheet) { + throw new Error( + `Invalid css stylesheet for component '${name}'. Did you forget to use the 'css' tag helper?` + ); + } + sheet.dataset.component = name; + document.head.appendChild(sheet); +} + +function processSheet(str: string): string { + const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim()); + const selectorStack: string[][] = []; + const parts: string[] = []; + let rules: string[] = []; + function generateSelector(stackIndex: number, parentSelector?: string) { + const parts: string[] = []; + for (const selector of selectorStack[stackIndex]) { + let part = (parentSelector && parentSelector + " " + selector) || selector; + if (part.includes("&")) { + part = selector.replace(/&/g, parentSelector || ""); + } + if (stackIndex < selectorStack.length - 1) { + part = generateSelector(stackIndex + 1, part); + } + parts.push(part); + } + return parts.join(", "); + } + function generateRules() { + if (rules.length) { + parts.push(generateSelector(0) + " {"); + parts.push(...rules); + parts.push("}"); + rules = []; + } + } + while (tokens.length) { + let token = tokens.shift()!; + if (token === "}") { + generateRules(); + selectorStack.pop(); + } else { + if (tokens[0] === "{") { + generateRules(); + selectorStack.push(token.split(/\s*,\s*/)); + tokens.shift(); + } + if (tokens[0] === ";") { + rules.push(" " + token + ";"); + } + } + } + return parts.join("\n"); +} diff --git a/src/index.ts b/src/index.ts index 05390078..17c73b43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ export function useComponent(): Component { export { status } from "./component/status"; export { Portal } from "./misc/portal"; export { Memo } from "./misc/memo"; -export { xml } from "./tags"; +export { css, xml } from "./tags"; export { useState } from "./reactivity"; export { useRef } from "./refs"; export { EventBus } from "./event_bus"; diff --git a/src/tags.ts b/src/tags.ts index d9a58bf1..fedf101d 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -1,3 +1,4 @@ +import { registerSheet } from "./component/style"; import { globalTemplates } from "./qweb/template_helpers"; // ----------------------------------------------------------------------------- @@ -12,3 +13,16 @@ export function xml(strings: TemplateStringsArray, ...args: any[]) { } xml.nextId = 1; + +// ----------------------------------------------------------------------------- +// Global stylesheets +// ----------------------------------------------------------------------------- + +export function css(strings: TemplateStringsArray, ...args: any[]) { + const name = `__sheet__${css.nextId++}`; + const value = String.raw(strings, ...args); + registerSheet(name, value); + return name; +} + +css.nextId = 1; diff --git a/tests/components/style_tag.test.ts b/tests/components/style_tag.test.ts index 436e331d..c60a5eef 100644 --- a/tests/components/style_tag.test.ts +++ b/tests/components/style_tag.test.ts @@ -1,159 +1,175 @@ -// import { Component, Env } from "../../src/component/component"; -// import { processSheet } from "../../src/component/styles"; -// import { xml, css } from "../../src/tags"; -// import { makeTestFixture, makeTestEnv } from "../helpers"; +import { Component, css, mount, xml } from "../../src"; +import { makeTestFixture } from "../helpers"; -// //------------------------------------------------------------------------------ -// // Setup and helpers -// //------------------------------------------------------------------------------ +let fixture: HTMLElement; +beforeEach(() => { + fixture = makeTestFixture(); + document.head.innerHTML = ""; +}); -// // We create before each test: -// // - fixture: a div, appended to the DOM, intended to be the target of dom -// // manipulations. Note that it is removed after each test. -// // - env: an Env, necessary to create new components - -// let fixture: HTMLElement; -// let env: Env; - -// beforeEach(() => { -// fixture = makeTestFixture(); -// env = makeTestEnv(); -// Component.env = env; -// document.head.innerHTML = ""; -// }); - -// afterEach(() => { -// fixture.remove(); -// }); - -//------------------------------------------------------------------------------ -// Tests -//------------------------------------------------------------------------------ - -describe.skip("styles and component", () => { +describe("styles and component", () => { test("can define an inline stylesheet", async () => { - // class App extends Component { - // static template = xml`
text
`; - // static style = css` - // .app { - // color: red; - // } - // `; - // } - // expect(document.head.innerHTML).toBe(""); - // const app = new App(); - // expect(document.head.innerHTML).toBe(``); - // await app.mount(fixture); - // const style = getComputedStyle(app.el!); - // expect(style.color).toBe("red"); - // expect(fixture.innerHTML).toBe('
text
'); + class App extends Component { + static template = xml`
text
`; + static style = css` + .app { + color: red; + } + `; + } + expect(document.head.innerHTML).toBe(""); + const app = await mount(App, fixture); + expect(document.head.innerHTML).toBe(``); + const style = getComputedStyle(app.el as HTMLElement); + expect(style.color).toBe("red"); + expect(fixture.innerHTML).toBe('
text
'); }); test("inherited components properly apply css", async () => { - // class App extends Component { - // static template = xml`
text
`; - // static style = css` - // .app { - // color: red; - // } - // `; - // } - // class SubApp extends App { - // static style = css` - // .app { - // font-weight: bold; - // } - // `; - // } - // expect(document.head.innerHTML).toBe(""); - // const app = new SubApp(); - // expect(document.head.innerHTML).toBe(``); - // await app.mount(fixture); - // const style = getComputedStyle(app.el!); - // expect(style.color).toBe("red"); - // expect(style.fontWeight).toBe("bold"); - // expect(fixture.innerHTML).toBe('
text
'); + class App extends Component { + static template = xml`
text
`; + static style = css` + .app { + color: red; + } + `; + } + class SubApp extends App { + static style = css` + .app { + font-weight: bold; + } + `; + } + expect(document.head.innerHTML).toBe(""); + const app = await mount(SubApp, fixture); + expect(document.head.innerHTML).toBe(``); + const style = getComputedStyle(app.el as HTMLElement); + expect(style.color).toBe("red"); + expect(style.fontWeight).toBe("bold"); + expect(fixture.innerHTML).toBe('
text
'); + }); + + test("inherited components properly apply css, part 2", async () => { + class App extends Component { + static template = xml`
`; + static style = css` + .app { + color: tomato; + } + `; + } + class BetterApp extends App {} + class EvenBetterApp extends BetterApp { + static style = css` + .app { + background-color: papayawhip; + } + `; + } + expect(document.head.innerHTML).toBe(""); + await mount(EvenBetterApp, fixture); + expect(document.head.innerHTML).toBe(``); }); test("get a meaningful error message if css helper is missing", async () => { - // class App extends Component { - // static template = xml`
text
`; - // static style = `.app {color: red;}`; - // } - // let error; - // try { - // new App(); - // } catch (e) { - // error = e; - // } - // expect(error).toBeDefined(); - // expect(error.message).toBe( - // "Invalid css stylesheet for component 'App'. Did you forget to use the 'css' tag helper?" - // ); - // }); - // test("inline stylesheets are processed", async () => { - // class App extends Component { - // static template = xml`
text
`; - // static style = css` - // .app { - // color: red; - // .some-class { - // font-weight: bold; - // width: 40px; - // } - // display: block; - // } - // `; - // } - // new App(); - // expect(document.head.querySelector("style")!.innerHTML).toBe(`.app { - // color: red; - // } - // .app .some-class { - // font-weight: bold; - // width: 40px; - // } - // .app { - // display: block; - // }`); + class App extends Component { + static template = xml`
text
`; + static style = `.app {color: red;}`; + } + let error; + try { + await mount(App, fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe( + "Invalid css stylesheet for component 'App'. Did you forget to use the 'css' tag helper?" + ); + }); + + test("inline stylesheets are processed", async () => { + class App extends Component { + static template = xml`
text
`; + static style = css` + .app { + color: red; + .some-class { + font-weight: bold; + width: 40px; + } + display: block; + } + `; + } + await mount(App, fixture); + expect(document.head.querySelector("style")!.innerHTML).toBe(`.app { + color: red; +} +.app .some-class { + font-weight: bold; + width: 40px; +} +.app { + display: block; +}`); }); test("properly handle rules with commas", async () => { - // const sheet = processSheet(`.parent-a, .parent-b { - // .child-a, .child-b { - // color: red; - // } - // }`); - // expect(sheet) - // .toBe(`.parent-a .child-a, .parent-a .child-b, .parent-b .child-a, .parent-b .child-b { - // color: red; - // }`); + class App extends Component { + static template = xml`
`; + static style = css` + .parent-a, + .parent-b { + .child-a, + .child-b { + color: red; + } + } + `; + } + await mount(App, fixture); + expect(document.head.querySelector("style")!.innerHTML) + .toBe(`.parent-a .child-a, .parent-a .child-b, .parent-b .child-a, .parent-b .child-b { + color: red; +}`); }); test("handle & selector", async () => { - // let sheet = processSheet(`.btn { - // &.danger { - // color: red; - // } - // }`); - // expect(sheet).toBe(`.btn.danger { - // color: red; - // }`); - // sheet = processSheet(`.some-class { - // &.btn { - // .other-class ~ & { - // color: red; - // } - // } - // }`); - // expect(sheet).toBe(`.other-class ~ .some-class.btn { - // color: red; - // }`); + class App extends Component { + static template = xml`
`; + static style = css` + .btn { + &.danger { + color: red; + } + } + .some-class { + &.btn { + .other-class ~ & { + color: red; + } + } + } + `; + } + await mount(App, fixture); + expect(document.head.querySelector("style")!.innerHTML).toBe(`.btn.danger { + color: red; +} +.other-class ~ .some-class.btn { + color: red; +}`); }); });