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",
+ " Widget A\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 """
+
+
+ Scoped text
+
+
+
+ """
+
+
+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)"