From 951e0ef87ff7214ea1dda32d6731934432c43cbe Mon Sep 17 00:00:00 2001 From: Maarten Breddels AI Date: Tue, 3 Feb 2026 17:01:42 +0000 Subject: [PATCH 1/2] feat: add scoped CSS support - Implement scoped CSS handling for Vue SFC styles - Add scoped trait for css property - Rewrite CSS selectors with unique data-v-* attributes - Add example notebook demonstrating scoped styles - Add UI tests for scoped CSS functionality --- examples/ScopedCSS.ipynb | 147 ++++++++++++++++++++++++++++++++++ ipyvue/VueTemplateWidget.py | 4 +- js/src/VueTemplateModel.js | 1 + js/src/VueTemplateRenderer.js | 108 +++++++++++++++++++++++-- tests/ui/test_template.py | 89 ++++++++++++++++++++ 5 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 examples/ScopedCSS.ipynb 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 \"\"\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..6305cf3 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 from ipywidgets import DOMWidget from ipywidgets.widgets.widget import widget_serialization @@ -118,6 +118,8 @@ class VueTemplate(DOMWidget, Events): css = Unicode(None, allow_none=True).tag(sync=True) + scoped = Bool(None, allow_none=True).tag(sync=True) + methods = Unicode(None, allow_none=True).tag(sync=True) data = Unicode(None, allow_none=True).tag(sync=True) 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..bd1aaf8 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,10 @@ 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 useScoped = scoped !== null && scoped !== undefined ? scoped : scopedFromTemplate; + const scopeId = useScoped && css ? getScopeId(model, cssId) : null; if (css) { if (cssId) { @@ -42,14 +113,32 @@ function createComponentObject(model, parentView) { style.id = prefixedCssId; document.head.appendChild(style); } - if (style.innerHTML !== css) { - style.innerHTML = css; + if (scopeId) { + if (style.innerHTML !== css || style.getAttribute('data-ipyvue-scope') !== scopeId) { + style.innerHTML = css; + scopeStyleElement(style, scopeId); + style.setAttribute('data-ipyvue-scope', scopeId); + } + } else { + // Reset innerHTML if CSS changed or if transitioning from scoped to unscoped + // (need to reset to remove the scoped CSS rule transformations) + const wasScoped = style.getAttribute('data-ipyvue-scope'); + if (style.innerHTML !== css || wasScoped) { + style.innerHTML = css; + if (wasScoped) { + style.removeAttribute('data-ipyvue-scope'); + } + } } } else { const style = document.createElement('style'); style.id = model.cid; style.innerHTML = css; document.head.appendChild(style); + if (scopeId) { + scopeStyleElement(style, scopeId); + style.setAttribute('data-ipyvue-scope', scopeId); + } parentView.once('remove', () => { document.head.removeChild(style); }); @@ -106,15 +195,18 @@ function createComponentObject(model, parentView) { ? template : vuefile.TEMPLATE, beforeMount() { + applyScopeId(this, scopeId); callVueFn('beforeMount', this); }, mounted() { + applyScopeId(this, scopeId); callVueFn('mounted', this); }, beforeUpdate() { callVueFn('beforeUpdate', this); }, updated() { + applyScopeId(this, scopeId); callVueFn('updated', this); }, beforeDestroy() { @@ -130,7 +222,7 @@ function createComponentObject(model, parentView) { function createDataMapping(model) { return model.keys() .filter(prop => !prop.startsWith('_') - && !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop)) + && !['events', 'template', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop)) .reduce((result, prop) => { result[prop] = _.cloneDeep(model.get(prop)); // eslint-disable-line no-param-reassign return result; @@ -140,7 +232,7 @@ function createDataMapping(model) { function addModelListeners(model, vueModel) { model.keys() .filter(prop => !prop.startsWith('_') - && !['v_model', 'components', 'layout', 'css', 'data', 'methods'].includes(prop)) + && !['v_model', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop)) // eslint-disable-next-line no-param-reassign .forEach(prop => model.on(`change:${prop}`, () => { if (_.isEqual(model.get(prop), vueModel[prop])) { @@ -166,7 +258,7 @@ function addModelListeners(model, vueModel) { function createWatches(model, parentView, templateWatchers) { const modelWatchers = model.keys().filter(prop => !prop.startsWith('_') - && !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop)) + && !['events', 'template', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop)) .reduce((result, prop) => ({ ...result, [prop]: { @@ -349,8 +441,10 @@ function readVueFile(fileContent) { } if (component.styles && component.styles.length > 0) { const { content } = component.styles[0]; - const { id } = component.styles[0].attrs; - result.STYLE = { content, id }; + const { attrs = {} } = component.styles[0]; + const { id } = attrs; + const scoped = Object.prototype.hasOwnProperty.call(attrs, 'scoped'); + result.STYLE = { content, id, scoped }; } return result; diff --git a/tests/ui/test_template.py b/tests/ui/test_template.py index 66ac0b7..d2f593f 100644 --- a/tests/ui/test_template.py +++ b/tests/ui/test_template.py @@ -141,3 +141,92 @@ def kernel_code(template_class_name=template_class_name): widget.wait_for() widget.click() page_session.locator("text=Clicked 1").wait_for() + + +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)" + + +class ScopedCssTemplate(vue.VueTemplate): + @default("template") + def _default_vue_template(self): + return """ + + """ + + +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)" From 1b37d8f5e4d8cb59775a699ead778cd5ec5c25f8 Mon Sep 17 00:00:00 2001 From: Maarten Breddels AI Date: Tue, 10 Feb 2026 10:09:28 +0000 Subject: [PATCH 2/2] fix: make scoped CSS support optional via opt-in Add scoped_css_support trait to VueTemplate that controls whether + """ + +widget = MyComponent(scoped_css_support=True) +``` + +Note: The `css` trait with `scoped=True` always works, regardless of this setting: + +```python +widget = VueTemplate( + template="", + css=".x { color: blue; }", + scoped=True +) +``` + Sponsors -------- diff --git a/ipyvue/VueTemplateWidget.py b/ipyvue/VueTemplateWidget.py index 6305cf3..7bb524b 100644 --- a/ipyvue/VueTemplateWidget.py +++ b/ipyvue/VueTemplateWidget.py @@ -1,5 +1,5 @@ import os -from traitlets import Any, Bool, 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" @@ -120,6 +121,12 @@ class VueTemplate(DOMWidget, Events): 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/VueTemplateRenderer.js b/js/src/VueTemplateRenderer.js index bd1aaf8..2f4bee5 100644 --- a/js/src/VueTemplateRenderer.js +++ b/js/src/VueTemplateRenderer.js @@ -101,7 +101,12 @@ function createComponentObject(model, parentView) { const cssId = (vuefile.STYLE && vuefile.STYLE.id); const scopedFromTemplate = (vuefile.STYLE && vuefile.STYLE.scoped); const scoped = model.get('scoped'); - const useScoped = scoped !== null && scoped !== undefined ? scoped : scopedFromTemplate; + 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