Skip to content

Commit 0610951

Browse files
author
Maarten Breddels AI
committed
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
1 parent cc2842f commit 0610951

File tree

5 files changed

+319
-8
lines changed

5 files changed

+319
-8
lines changed

examples/ScopedCSS.ipynb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Scoped CSS\n",
8+
"\n",
9+
"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",
10+
"\n",
11+
"**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]`)."
12+
]
13+
},
14+
{
15+
"cell_type": "code",
16+
"execution_count": null,
17+
"metadata": {},
18+
"outputs": [],
19+
"source": [
20+
"import ipyvue as vue\n",
21+
"import ipywidgets as widgets\n",
22+
"from traitlets import default"
23+
]
24+
},
25+
{
26+
"cell_type": "markdown",
27+
"metadata": {},
28+
"source": [
29+
"## Without scoped CSS (the problem)"
30+
]
31+
},
32+
{
33+
"cell_type": "code",
34+
"execution_count": null,
35+
"metadata": {},
36+
"outputs": [],
37+
"source": [
38+
"class GlobalStyle(vue.VueTemplate):\n",
39+
" @default(\"template\")\n",
40+
" def _default_template(self):\n",
41+
" return \"\"\"\n",
42+
" <template>\n",
43+
" <span class=\"demo-text\">Widget A</span>\n",
44+
" </template>\n",
45+
" <style>\n",
46+
" .demo-text { color: red; }\n",
47+
" </style>\n",
48+
" \"\"\"\n",
49+
"\n",
50+
"widget_b = vue.Html(tag=\"span\", children=[\"Widget B (innocent bystander)\"], class_=\"demo-text\")\n",
51+
"\n",
52+
"widgets.VBox([GlobalStyle(), widget_b]) # Both turn red!"
53+
]
54+
},
55+
{
56+
"cell_type": "markdown",
57+
"metadata": {},
58+
"source": [
59+
"## With `<style scoped>`"
60+
]
61+
},
62+
{
63+
"cell_type": "code",
64+
"execution_count": null,
65+
"metadata": {},
66+
"outputs": [],
67+
"source": [
68+
"class ScopedStyle(vue.VueTemplate):\n",
69+
" @default(\"template\")\n",
70+
" def _default_template(self):\n",
71+
" return \"\"\"\n",
72+
" <template>\n",
73+
" <span class=\"demo-text-2\">Widget A (scoped)</span>\n",
74+
" </template>\n",
75+
" <style scoped>\n",
76+
" .demo-text-2 { color: green; }\n",
77+
" </style>\n",
78+
" \"\"\"\n",
79+
"\n",
80+
"widget_b = vue.Html(tag=\"span\", children=[\"Widget B (unaffected)\"], class_=\"demo-text-2\")\n",
81+
"\n",
82+
"widgets.VBox([ScopedStyle(), widget_b]) # Only Widget A is green"
83+
]
84+
},
85+
{
86+
"cell_type": "markdown",
87+
"metadata": {},
88+
"source": [
89+
"## Using the `css` trait with `scoped=True`\n",
90+
"\n",
91+
"Alternative syntax when defining CSS outside the template:"
92+
]
93+
},
94+
{
95+
"cell_type": "code",
96+
"execution_count": null,
97+
"metadata": {},
98+
"outputs": [],
99+
"source": [
100+
"class CssTrait(vue.VueTemplate):\n",
101+
" @default(\"template\")\n",
102+
" def _default_template(self):\n",
103+
" return \"<template><span class='trait-demo'>Widget C (scoped via trait)</span></template>\"\n",
104+
"\n",
105+
"widget_c = CssTrait(css=\".trait-demo { color: blue; }\", scoped=True)\n",
106+
"widget_d = vue.Html(tag=\"span\", children=[\"Widget D (unaffected)\"], class_=\"trait-demo\")\n",
107+
"\n",
108+
"widgets.VBox([widget_c, widget_d])"
109+
]
110+
}
111+
],
112+
"metadata": {
113+
"kernelspec": {
114+
"display_name": "Python 3",
115+
"language": "python",
116+
"name": "python3"
117+
},
118+
"language_info": {
119+
"name": "python",
120+
"version": "3.9.0"
121+
}
122+
},
123+
"nbformat": 4,
124+
"nbformat_minor": 4
125+
}

ipyvue/VueTemplateWidget.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import os
2-
from traitlets import Any, Unicode, List, Dict, Union, Instance
2+
from traitlets import Any, Bool, Unicode, List, Dict, Union, Instance
33
from ipywidgets import DOMWidget
44
from ipywidgets.widgets.widget import widget_serialization
55

@@ -118,6 +118,8 @@ class VueTemplate(DOMWidget, Events):
118118

119119
css = Unicode(None, allow_none=True).tag(sync=True)
120120

121+
scoped = Bool(None, allow_none=True).tag(sync=True)
122+
121123
methods = Unicode(None, allow_none=True).tag(sync=True)
122124

123125
data = Unicode(None, allow_none=True).tag(sync=True)

js/src/VueTemplateModel.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export class VueTemplateModel extends DOMWidgetModel {
1515
_model_module_version: '^0.0.3',
1616
template: null,
1717
css: null,
18+
scoped: null,
1819
methods: null,
1920
data: null,
2021
events: null,

js/src/VueTemplateRenderer.js

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,73 @@ import { VueTemplateModel } from './VueTemplateModel';
99
import httpVueLoader from './httpVueLoader';
1010
import { TemplateModel } from './Template';
1111

12+
function normalizeScopeId(value) {
13+
return String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
14+
}
15+
16+
function getScopeId(model, cssId) {
17+
const base = cssId || model.cid;
18+
return `data-s-${normalizeScopeId(base)}`;
19+
}
20+
21+
function applyScopeId(vm, scopeId) {
22+
if (!scopeId || !vm || !vm.$el) {
23+
return;
24+
}
25+
vm.$el.setAttribute(scopeId, '');
26+
}
27+
28+
function scopeStyleElement(styleElt, scopeId) {
29+
const scopeSelector = `[${scopeId}]`;
30+
31+
function scopeRules(rules, insertRule, deleteRule) {
32+
for (let i = 0; i < rules.length; ++i) {
33+
const rule = rules[i];
34+
if (rule.type === 1 && rule.selectorText) {
35+
const scopedSelectors = [];
36+
rule.selectorText.split(/\s*,\s*/).forEach((sel) => {
37+
scopedSelectors.push(`${scopeSelector} ${sel}`);
38+
const segments = sel.match(/([^ :]+)(.+)?/);
39+
if (segments) {
40+
scopedSelectors.push(`${segments[1]}${scopeSelector}${segments[2] || ''}`);
41+
}
42+
});
43+
const scopedRule = scopedSelectors.join(',') + rule.cssText.substring(rule.selectorText.length);
44+
deleteRule(i);
45+
insertRule(scopedRule, i);
46+
}
47+
if (rule.cssRules && rule.cssRules.length && rule.insertRule && rule.deleteRule) {
48+
scopeRules(rule.cssRules, rule.insertRule.bind(rule), rule.deleteRule.bind(rule));
49+
}
50+
}
51+
}
52+
53+
function process() {
54+
const sheet = styleElt.sheet;
55+
if (!sheet) {
56+
return;
57+
}
58+
scopeRules(sheet.cssRules, sheet.insertRule.bind(sheet), sheet.deleteRule.bind(sheet));
59+
}
60+
61+
try {
62+
process();
63+
} catch (ex) {
64+
if (typeof DOMException !== 'undefined' && ex instanceof DOMException && ex.code === DOMException.INVALID_ACCESS_ERR) {
65+
styleElt.sheet.disabled = true;
66+
styleElt.addEventListener('load', function onStyleLoaded() {
67+
styleElt.removeEventListener('load', onStyleLoaded);
68+
setTimeout(() => {
69+
process();
70+
styleElt.sheet.disabled = false;
71+
});
72+
});
73+
return;
74+
}
75+
throw ex;
76+
}
77+
}
78+
1279
export function vueTemplateRender(createElement, model, parentView) {
1380
return createElement(createComponentObject(model, parentView));
1481
}
@@ -32,6 +99,10 @@ function createComponentObject(model, parentView) {
3299

33100
const css = model.get('css') || (vuefile.STYLE && vuefile.STYLE.content);
34101
const cssId = (vuefile.STYLE && vuefile.STYLE.id);
102+
const scopedFromTemplate = (vuefile.STYLE && vuefile.STYLE.scoped);
103+
const scoped = model.get('scoped');
104+
const useScoped = scoped !== null && scoped !== undefined ? scoped : scopedFromTemplate;
105+
const scopeId = useScoped && css ? getScopeId(model, cssId) : null;
35106

36107
if (css) {
37108
if (cssId) {
@@ -42,14 +113,32 @@ function createComponentObject(model, parentView) {
42113
style.id = prefixedCssId;
43114
document.head.appendChild(style);
44115
}
45-
if (style.innerHTML !== css) {
46-
style.innerHTML = css;
116+
if (scopeId) {
117+
if (style.innerHTML !== css || style.getAttribute('data-ipyvue-scope') !== scopeId) {
118+
style.innerHTML = css;
119+
scopeStyleElement(style, scopeId);
120+
style.setAttribute('data-ipyvue-scope', scopeId);
121+
}
122+
} else {
123+
// Reset innerHTML if CSS changed or if transitioning from scoped to unscoped
124+
// (need to reset to remove the scoped CSS rule transformations)
125+
const wasScoped = style.getAttribute('data-ipyvue-scope');
126+
if (style.innerHTML !== css || wasScoped) {
127+
style.innerHTML = css;
128+
if (wasScoped) {
129+
style.removeAttribute('data-ipyvue-scope');
130+
}
131+
}
47132
}
48133
} else {
49134
const style = document.createElement('style');
50135
style.id = model.cid;
51136
style.innerHTML = css;
52137
document.head.appendChild(style);
138+
if (scopeId) {
139+
scopeStyleElement(style, scopeId);
140+
style.setAttribute('data-ipyvue-scope', scopeId);
141+
}
53142
parentView.once('remove', () => {
54143
document.head.removeChild(style);
55144
});
@@ -106,15 +195,18 @@ function createComponentObject(model, parentView) {
106195
? template
107196
: vuefile.TEMPLATE,
108197
beforeMount() {
198+
applyScopeId(this, scopeId);
109199
callVueFn('beforeMount', this);
110200
},
111201
mounted() {
202+
applyScopeId(this, scopeId);
112203
callVueFn('mounted', this);
113204
},
114205
beforeUpdate() {
115206
callVueFn('beforeUpdate', this);
116207
},
117208
updated() {
209+
applyScopeId(this, scopeId);
118210
callVueFn('updated', this);
119211
},
120212
beforeDestroy() {
@@ -130,7 +222,7 @@ function createComponentObject(model, parentView) {
130222
function createDataMapping(model) {
131223
return model.keys()
132224
.filter(prop => !prop.startsWith('_')
133-
&& !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
225+
&& !['events', 'template', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop))
134226
.reduce((result, prop) => {
135227
result[prop] = _.cloneDeep(model.get(prop)); // eslint-disable-line no-param-reassign
136228
return result;
@@ -140,7 +232,7 @@ function createDataMapping(model) {
140232
function addModelListeners(model, vueModel) {
141233
model.keys()
142234
.filter(prop => !prop.startsWith('_')
143-
&& !['v_model', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
235+
&& !['v_model', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop))
144236
// eslint-disable-next-line no-param-reassign
145237
.forEach(prop => model.on(`change:${prop}`, () => {
146238
if (_.isEqual(model.get(prop), vueModel[prop])) {
@@ -166,7 +258,7 @@ function addModelListeners(model, vueModel) {
166258

167259
function createWatches(model, parentView, templateWatchers) {
168260
const modelWatchers = model.keys().filter(prop => !prop.startsWith('_')
169-
&& !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
261+
&& !['events', 'template', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop))
170262
.reduce((result, prop) => ({
171263
...result,
172264
[prop]: {
@@ -349,8 +441,10 @@ function readVueFile(fileContent) {
349441
}
350442
if (component.styles && component.styles.length > 0) {
351443
const { content } = component.styles[0];
352-
const { id } = component.styles[0].attrs;
353-
result.STYLE = { content, id };
444+
const { attrs = {} } = component.styles[0];
445+
const { id } = attrs;
446+
const scoped = Object.prototype.hasOwnProperty.call(attrs, 'scoped');
447+
result.STYLE = { content, id, scoped };
354448
}
355449

356450
return result;

0 commit comments

Comments
 (0)