diff --git a/docs/language-charter.md b/docs/language-charter.md index 3a98164a..4b4899db 100644 --- a/docs/language-charter.md +++ b/docs/language-charter.md @@ -1338,9 +1338,12 @@ component Card: ``` - Component CSS is scoped by default. +- A scoped selector may style a child component's top-level DOM root. The + parent's scope marker is attached only to that root (or each top-level root + in a fragment), so arbitrary DOM inside the child remains private. - Production builds extract static CSS. - Global CSS requires `style global:`. -- Velar Web does not create a CSS-in-JS object system. +- VelarScript Web does not create a CSS-in-JS object system. ## 21. Velar Compiler diff --git a/packages/web/src/emitter.ts b/packages/web/src/emitter.ts index 258db073..fd487b8f 100644 --- a/packages/web/src/emitter.ts +++ b/packages/web/src/emitter.ts @@ -282,7 +282,8 @@ export class WebJavaScriptEmitter extends JavaScriptEmitter { if (hasMeaningfulChildren(expression.children)) properties.push(`children: ${this.emitJsxChildren(expression.children, scope, namespace)}`); const props = properties.join(", "); const component = `${expression.tag}({ ${props} }, ${namespace})`; - return asChild ? `__velarUseComponent(${component}, ${scope})` : component; + const parentStyleScope = this.currentStyleScope ? `, ${JSON.stringify(this.currentStyleScope)}` : ""; + return asChild ? `__velarUseComponent(${component}, ${scope}${parentStyleScope})` : component; } const id = ++this.jsxId; @@ -863,7 +864,19 @@ function __velarComponent(node, scope, mounted, cleanup) { }; } -function __velarUseComponent(instance, scope) { +function __velarScopeComponentRoot(node, attribute) { + if (!attribute || !node) return; + if (node.nodeType === 1) { + node.setAttribute(attribute, ""); + return; + } + if (node.nodeType === 11) { + for (const child of node.childNodes) if (child.nodeType === 1) child.setAttribute(attribute, ""); + } +} + +function __velarUseComponent(instance, scope, parentStyleScope = "") { + __velarScopeComponentRoot(instance.node, parentStyleScope); scope.mounts.push(() => instance.__mount()); scope.cleanups.push(() => instance.destroy(false)); return instance.node; diff --git a/tests/browser.acceptance.ts b/tests/browser.acceptance.ts index 8f02647b..dc13f673 100644 --- a/tests/browser.acceptance.ts +++ b/tests/browser.acceptance.ts @@ -152,6 +152,7 @@ async function acceptBrowser(name: string, browserType: BrowserType, baseUrl: st assert.equal(await page.locator("[data-route]").textContent(), "/"); assert.match(await page.locator("[data-environment]").textContent() ?? "", /^online\/visible\/(?:dark|light)$/u); assert.equal(await page.locator("[data-session]").textContent(), "session-ready"); + await page.waitForFunction(() => document.querySelector("[data-frame]")?.textContent === "frame-ready"); assert.equal(await page.locator("[data-frame]").textContent(), "frame-ready"); assert.equal(await page.locator("[data-layout]").textContent(), "measured"); assert.equal(await page.locator("[data-error]").textContent(), "Velar recovery ready"); diff --git a/tests/compiler.test.ts b/tests/compiler.test.ts index f2e0c201..add9b59a 100644 --- a/tests/compiler.test.ts +++ b/tests/compiler.test.ts @@ -7924,6 +7924,28 @@ mount(, "#app") assert.ok(domCommit >= 0 && watchCommit > domCommit); }); +test("scoped CSS can style the top-level DOM root of a child component", () => { + const result = compile(` +component ChildLink: + return Docs + +component Navigation: + style: + nav a { + text-decoration: none; + } + + return +`.trimStart()); + + assert.deepEqual(result.diagnostics, []); + const scope = result.css?.match(/nav\[(data-velar-[a-z0-9]+)\] a\[\1\]/)?.[1]; + assert.ok(scope); + assert.match(result.code ?? "", new RegExp(`__velarUseComponent\\(ChildLink\\(\\{ \\}, __namespace\\), __childScope, "${scope}"\\)`)); + assert.match(result.code ?? "", /function __velarScopeComponentRoot\(node, attribute\)/u); + assert.match(result.code ?? "", /node\.nodeType === 11/u); +}); + test("component resources own typed asynchronous loading, retry, errors, and stale completion", () => { const result = compile(` async def loadLabel() -> string: