diff --git a/README.md b/README.md
index e4a1073..efa6dae 100755
--- a/README.md
+++ b/README.md
@@ -26,6 +26,51 @@ For a development installation (requires npm),
$ jupyter nbextension enable --py --sys-prefix ipyvue
$ jupyter labextension develop . --overwrite
+Scoped CSS Support
+------------------
+
+`
+ """
+
+widget = MyComponent(scoped_css_support=True)
+```
+
+Note: The `css` trait with `scoped=True` always works, regardless of this setting:
+
+```python
+widget = VueTemplate(
+ template="Hi",
+ css=".x { color: blue; }",
+ scoped=True
+)
+```
+
Sponsors
--------
diff --git a/examples/ScopedCSS.ipynb b/examples/ScopedCSS.ipynb
new file mode 100644
index 0000000..3f04f0e
--- /dev/null
+++ b/examples/ScopedCSS.ipynb
@@ -0,0 +1,147 @@
+{
+ "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 adds 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-abc123]`)."
+ ]
+ },
+ {
+ "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": [
+ "## Enable scoped CSS support\n",
+ "\n",
+ "For backwards compatibility, `\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"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Using the `css` trait with `scoped=True`\n",
+ "\n",
+ "Alternative syntax when defining CSS outside the template:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "class CssTrait(vue.VueTemplate):\n",
+ " @default(\"template\")\n",
+ " def _default_template(self):\n",
+ " return \"Widget C (scoped via trait)\"\n",
+ "\n",
+ "widget_c = CssTrait(css=\".trait-demo { color: blue; }\", scoped=True)\n",
+ "widget_d = vue.Html(tag=\"span\", children=[\"Widget D (unaffected)\"], class_=\"trait-demo\")\n",
+ "\n",
+ "widgets.VBox([widget_c, widget_d])"
+ ]
+ }
+ ],
+ "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/ipyvue/VueTemplateWidget.py b/ipyvue/VueTemplateWidget.py
index 54b00c2..7bb524b 100644
--- a/ipyvue/VueTemplateWidget.py
+++ b/ipyvue/VueTemplateWidget.py
@@ -1,5 +1,5 @@
import os
-from traitlets import Any, Unicode, List, Dict, Union, Instance
+from traitlets import Any, Bool, Unicode, List, Dict, Union, Instance, default
from ipywidgets import DOMWidget
from ipywidgets.widgets.widget import widget_serialization
@@ -8,6 +8,7 @@
from .ForceLoad import force_load_instance
import inspect
from importlib import import_module
+import ipyvue
OBJECT_REF = "objectRef"
FUNCTION_REF = "functionRef"
@@ -118,6 +119,14 @@ class VueTemplate(DOMWidget, Events):
css = Unicode(None, allow_none=True).tag(sync=True)
+ scoped = Bool(None, allow_none=True).tag(sync=True)
+
+ scoped_css_support = Bool(allow_none=False).tag(sync=True)
+
+ @default("scoped_css_support")
+ def _default_scoped_css_support(self):
+ return ipyvue.scoped_css_support
+
methods = Unicode(None, allow_none=True).tag(sync=True)
data = Unicode(None, allow_none=True).tag(sync=True)
diff --git a/ipyvue/__init__.py b/ipyvue/__init__.py
index eb7fdbd..8482a96 100755
--- a/ipyvue/__init__.py
+++ b/ipyvue/__init__.py
@@ -1,3 +1,5 @@
+import os
+
from ._version import __version__
from .Html import Html
from .Template import Template, watch
@@ -10,6 +12,22 @@
)
+def _parse_bool_env(key: str, default: bool = False) -> bool:
+ """Parse boolean from environment variable."""
+ val = os.environ.get(key, "").lower()
+ if val in ("1", "true", "yes", "on"):
+ return True
+ if val in ("0", "false", "no", "off"):
+ return False
+ return default
+
+
+# Global default for scoped CSS support in VueTemplate.
+# Can be set via environment variable IPYVUE_SCOPED_CSS_SUPPORT=1
+# or changed at runtime: ipyvue.scoped_css_support = True
+scoped_css_support = _parse_bool_env("IPYVUE_SCOPED_CSS_SUPPORT", False)
+
+
def _jupyter_labextension_paths():
return [
{
diff --git a/js/src/VueTemplateModel.js b/js/src/VueTemplateModel.js
index c0d3a60..cea6026 100644
--- a/js/src/VueTemplateModel.js
+++ b/js/src/VueTemplateModel.js
@@ -15,6 +15,7 @@ export class VueTemplateModel extends DOMWidgetModel {
_model_module_version: '^0.0.3',
template: null,
css: null,
+ scoped: null,
methods: null,
data: null,
events: null,
diff --git a/js/src/VueTemplateRenderer.js b/js/src/VueTemplateRenderer.js
index fb40f6e..2f4bee5 100644
--- a/js/src/VueTemplateRenderer.js
+++ b/js/src/VueTemplateRenderer.js
@@ -9,6 +9,73 @@ import { VueTemplateModel } from './VueTemplateModel';
import httpVueLoader from './httpVueLoader';
import { TemplateModel } from './Template';
+function normalizeScopeId(value) {
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
+}
+
+function getScopeId(model, cssId) {
+ const base = cssId || model.cid;
+ return `data-s-${normalizeScopeId(base)}`;
+}
+
+function applyScopeId(vm, scopeId) {
+ if (!scopeId || !vm || !vm.$el) {
+ return;
+ }
+ vm.$el.setAttribute(scopeId, '');
+}
+
+function scopeStyleElement(styleElt, scopeId) {
+ const scopeSelector = `[${scopeId}]`;
+
+ function scopeRules(rules, insertRule, deleteRule) {
+ for (let i = 0; i < rules.length; ++i) {
+ const rule = rules[i];
+ if (rule.type === 1 && rule.selectorText) {
+ const scopedSelectors = [];
+ rule.selectorText.split(/\s*,\s*/).forEach((sel) => {
+ scopedSelectors.push(`${scopeSelector} ${sel}`);
+ const segments = sel.match(/([^ :]+)(.+)?/);
+ if (segments) {
+ scopedSelectors.push(`${segments[1]}${scopeSelector}${segments[2] || ''}`);
+ }
+ });
+ const scopedRule = scopedSelectors.join(',') + rule.cssText.substring(rule.selectorText.length);
+ deleteRule(i);
+ insertRule(scopedRule, i);
+ }
+ if (rule.cssRules && rule.cssRules.length && rule.insertRule && rule.deleteRule) {
+ scopeRules(rule.cssRules, rule.insertRule.bind(rule), rule.deleteRule.bind(rule));
+ }
+ }
+ }
+
+ function process() {
+ const sheet = styleElt.sheet;
+ if (!sheet) {
+ return;
+ }
+ scopeRules(sheet.cssRules, sheet.insertRule.bind(sheet), sheet.deleteRule.bind(sheet));
+ }
+
+ try {
+ process();
+ } catch (ex) {
+ if (typeof DOMException !== 'undefined' && ex instanceof DOMException && ex.code === DOMException.INVALID_ACCESS_ERR) {
+ styleElt.sheet.disabled = true;
+ styleElt.addEventListener('load', function onStyleLoaded() {
+ styleElt.removeEventListener('load', onStyleLoaded);
+ setTimeout(() => {
+ process();
+ styleElt.sheet.disabled = false;
+ });
+ });
+ return;
+ }
+ throw ex;
+ }
+}
+
export function vueTemplateRender(createElement, model, parentView) {
return createElement(createComponentObject(model, parentView));
}
@@ -32,6 +99,15 @@ function createComponentObject(model, parentView) {
const css = model.get('css') || (vuefile.STYLE && vuefile.STYLE.content);
const cssId = (vuefile.STYLE && vuefile.STYLE.id);
+ const scopedFromTemplate = (vuefile.STYLE && vuefile.STYLE.scoped);
+ const scoped = model.get('scoped');
+ const scopedCssSupport = model.get('scoped_css_support');
+ // If scoped trait is explicitly set, use it (for css trait with scoped=True/False)
+ // If scoped is not set (None), only honor
+ """
+
+
+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(scoped_css_support=True)
+ 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)"
+
+
+class ScopedCssTemplate(vue.VueTemplate):
+ @default("template")
+ def _default_vue_template(self):
+ return """
+
+ Scoped css text
+
+ """
+
+
+def test_template_scoped_css_trait(
+ ipywidgets_runner, page_session: playwright.sync_api.Page
+):
+ def kernel_code():
+ from test_template import ScopedCssTemplate
+ import ipyvue as vue
+ import ipywidgets as widgets
+ from IPython.display import display
+
+ scoped = ScopedCssTemplate(
+ css=".scoped-css-text { color: rgb(0, 128, 0); }", scoped=True
+ )
+ unscoped = vue.Html(
+ tag="span",
+ children=["Unscoped css text"],
+ class_="scoped-css-text",
+ attributes={"id": "unscoped-css-text"},
+ )
+ display(widgets.VBox([scoped, unscoped]))
+
+ ipywidgets_runner(kernel_code)
+ page_session.locator("#scoped-css-text").wait_for()
+ page_session.locator("#unscoped-css-text").wait_for()
+ scoped_color = page_session.eval_on_selector(
+ "#scoped-css-text", "el => getComputedStyle(el).color"
+ )
+ unscoped_color = page_session.eval_on_selector(
+ "#unscoped-css-text", "el => getComputedStyle(el).color"
+ )
+ assert scoped_color == "rgb(0, 128, 0)"
+ assert unscoped_color != "rgb(0, 128, 0)"