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="", + 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 \"\"\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 """ + + """ + + +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)"