diff --git a/examples/ScopedCSS.ipynb b/examples/ScopedCSS.ipynb new file mode 100644 index 0000000..29cb68d --- /dev/null +++ b/examples/ScopedCSS.ipynb @@ -0,0 +1,99 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Scoped CSS\n", + "\n", + "By default, CSS in ipyvue templates is **global** — it affects all elements on the page with matching selectors. Scoped CSS limits styles to the component that defines them.\n", + "\n", + "**How it works:** ipyvue uses Vue 3's `compileStyle` to add a unique `data-v-*` attribute to your component's elements and rewrites your CSS selectors to include it (e.g., `.my-class` → `.my-class[data-v-1]`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipyvue as vue\n", + "import ipywidgets as widgets\n", + "from traitlets import default" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Without scoped CSS (the problem)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class GlobalStyle(vue.VueTemplate):\n", + " @default(\"template\")\n", + " def _default_template(self):\n", + " return \"\"\"\n", + " \n", + " \n", + " \"\"\"\n", + "\n", + "widget_b = vue.Html(tag=\"span\", children=[\"Widget B (innocent bystander)\"], class_=\"demo-text\")\n", + "\n", + "widgets.VBox([GlobalStyle(), widget_b]) # Both turn red!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## With `\n", + " \"\"\"\n", + "\n", + "widget_b = vue.Html(tag=\"span\", children=[\"Widget B (unaffected)\"], class_=\"demo-text-2\")\n", + "\n", + "widgets.VBox([ScopedStyle(), widget_b]) # Only Widget A is green" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/js/src/esmVueTemplate.js b/js/src/esmVueTemplate.js index 6a94f60..26f54d4 100644 --- a/js/src/esmVueTemplate.js +++ b/js/src/esmVueTemplate.js @@ -1,16 +1,25 @@ import * as Vue from 'vue' -import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc' +import { parse, compileScript, compileTemplate, compileStyle } from 'vue/compiler-sfc' import esModuleShims from './es-module-shims-txt.js' import {transform} from "sucrase"; window.esmsInitOptions = { shimMode: true }; +let scopeIdCounter = 0; +function generateScopeId() { + return `data-v-${(++scopeIdCounter).toString(36)}`; +} + export async function compileSfc(sfcStr, mixin) { await init() const parsedTemplate = parse(sfcStr) const { descriptor: {script, scriptSetup, template, styles} } = parsedTemplate; - styles && styles.forEach(({content, attrs}) => { + // Check if any style block has scoped attribute + const hasScoped = styles && styles.some(s => s.scoped); + const scopeId = hasScoped ? generateScopeId() : null; + + styles && styles.forEach(({content, attrs, scoped}) => { const prefixedCssId = attrs.id && `ipyvue-${attrs.id}`; let style = prefixedCssId && document.getElementById(prefixedCssId); if (!style) { @@ -20,8 +29,23 @@ export async function compileSfc(sfcStr, mixin) { } document.head.appendChild(style); } - if (style.innerHTML !== content) { - style.innerHTML = content; + + let cssContent = content; + if (scoped && scopeId) { + // Use Vue's compileStyle to transform scoped CSS + const compiled = compileStyle({ + source: content, + id: scopeId, + scoped: true, + }); + if (compiled.errors.length) { + console.warn('CSS compilation errors:', compiled.errors); + } + cssContent = compiled.code; + } + + if (style.innerHTML !== cssContent) { + style.innerHTML = cssContent; } }); @@ -33,7 +57,7 @@ export async function compileSfc(sfcStr, mixin) { script.content = script.content.replace(/^[^{]+(?={)/, "export default "); } } - let compiledScript = (script || scriptSetup) && compileScript(parsedTemplate.descriptor, {id: "abc"}); + let compiledScript = (script || scriptSetup) && compileScript(parsedTemplate.descriptor, {id: scopeId || "abc"}); const code = compiledScript && (compiledScript.lang === "ts" ? transform(compiledScript.content, { transforms: ["typescript"] }).code @@ -43,9 +67,12 @@ export async function compileSfc(sfcStr, mixin) { const compiledTemplate = template && compileTemplate({ source: template.content, + id: scopeId || "abc", + scoped: hasScoped, compilerOptions: { bindingMetadata: compiledScript ? compiledScript.bindings : {}, prefixIdentifiers: true, + scopeId: scopeId, } }); if (compiledTemplate && compiledTemplate.tips.length) { diff --git a/tests/ui/test_template.py b/tests/ui/test_template.py index 727b9bc..c6b6c6c 100644 --- a/tests/ui/test_template.py +++ b/tests/ui/test_template.py @@ -70,3 +70,49 @@ def on_custom(data): page_session.locator("text=Click Me").click() page_session.locator("text=Clicked").wait_for() assert last_event_data == "not-an-event-object" + + +class ScopedStyleTemplate(vue.VueTemplate): + @default("template") + def _default_vue_template(self): + return """ + + + """ + + +def test_template_scoped_style( + ipywidgets_runner, page_session: playwright.sync_api.Page +): + def kernel_code(): + from test_template import ScopedStyleTemplate + import ipyvue as vue + import ipywidgets as widgets + from IPython.display import display + + scoped = ScopedStyleTemplate() + unscoped = vue.Html( + tag="span", + children=["Unscoped text"], + class_="scoped-text", + attributes={"id": "unscoped-text"}, + ) + display(widgets.VBox([scoped, unscoped])) + + ipywidgets_runner(kernel_code) + page_session.locator("#scoped-text").wait_for() + page_session.locator("#unscoped-text").wait_for() + scoped_color = page_session.eval_on_selector( + "#scoped-text", "el => getComputedStyle(el).color" + ) + unscoped_color = page_session.eval_on_selector( + "#unscoped-text", "el => getComputedStyle(el).color" + ) + assert scoped_color == "rgb(255, 0, 0)" + assert unscoped_color != "rgb(255, 0, 0)"