Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions examples/ScopedCSS.ipynb
Original file line number Diff line number Diff line change
@@ -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",
" <template>\n",
" <span class=\"demo-text\">Widget A</span>\n",
" </template>\n",
" <style>\n",
" .demo-text { color: red; }\n",
" </style>\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 `<style scoped>`"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class ScopedStyle(vue.VueTemplate):\n",
" @default(\"template\")\n",
" def _default_template(self):\n",
" return \"\"\"\n",
" <template>\n",
" <span class=\"demo-text-2\">Widget A (scoped)</span>\n",
" </template>\n",
" <style scoped>\n",
" .demo-text-2 { color: green; }\n",
" </style>\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
}
37 changes: 32 additions & 5 deletions js/src/esmVueTemplate.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
}
});

Expand All @@ -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
Expand All @@ -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) {
Expand Down
46 changes: 46 additions & 0 deletions tests/ui/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
<template>
<div class="scoped-container">
<span id="scoped-text" class="scoped-text">Scoped text</span>
</div>
</template>
<style scoped>
.scoped-text { color: rgb(255, 0, 0); }
</style>
"""


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)"
Loading