Skip to content

Commit a89e0d6

Browse files
feat: add scoped css
1 parent cc2842f commit a89e0d6

File tree

5 files changed

+191
-8
lines changed

5 files changed

+191
-8
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ build/
99
# Compiled javascript
1010
ipyvue/labextension/
1111
ipyvue/nbextension/
12+
ipyvue/static/
1213
js/lib
1314
js/jupyter-vue-*.tgz
1415

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: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,74 @@ 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.substr(rule.selectorText.length);
44+
deleteRule(i);
45+
insertRule(scopedRule, i);
46+
continue;
47+
}
48+
if (rule.cssRules && rule.cssRules.length && rule.insertRule && rule.deleteRule) {
49+
scopeRules(rule.cssRules, rule.insertRule.bind(rule), rule.deleteRule.bind(rule));
50+
}
51+
}
52+
}
53+
54+
function process() {
55+
const sheet = styleElt.sheet;
56+
if (!sheet) {
57+
return;
58+
}
59+
scopeRules(sheet.cssRules, sheet.insertRule.bind(sheet), sheet.deleteRule.bind(sheet));
60+
}
61+
62+
try {
63+
process();
64+
} catch (ex) {
65+
if (typeof DOMException !== 'undefined' && ex instanceof DOMException && ex.code === DOMException.INVALID_ACCESS_ERR) {
66+
styleElt.sheet.disabled = true;
67+
styleElt.addEventListener('load', function onStyleLoaded() {
68+
styleElt.removeEventListener('load', onStyleLoaded);
69+
setTimeout(() => {
70+
process();
71+
styleElt.sheet.disabled = false;
72+
});
73+
});
74+
return;
75+
}
76+
throw ex;
77+
}
78+
}
79+
1280
export function vueTemplateRender(createElement, model, parentView) {
1381
return createElement(createComponentObject(model, parentView));
1482
}
@@ -32,6 +100,10 @@ function createComponentObject(model, parentView) {
32100

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

36108
if (css) {
37109
if (cssId) {
@@ -42,14 +114,27 @@ function createComponentObject(model, parentView) {
42114
style.id = prefixedCssId;
43115
document.head.appendChild(style);
44116
}
45-
if (style.innerHTML !== css) {
46-
style.innerHTML = css;
117+
if (scopeId) {
118+
if (style.innerHTML !== css || style.getAttribute('data-ipyvue-scope') !== scopeId) {
119+
style.innerHTML = css;
120+
scopeStyleElement(style, scopeId);
121+
style.setAttribute('data-ipyvue-scope', scopeId);
122+
}
123+
} else {
124+
if (style.innerHTML !== css) {
125+
style.innerHTML = css;
126+
}
127+
style.removeAttribute('data-ipyvue-scope');
47128
}
48129
} else {
49130
const style = document.createElement('style');
50131
style.id = model.cid;
51132
style.innerHTML = css;
52133
document.head.appendChild(style);
134+
if (scopeId) {
135+
scopeStyleElement(style, scopeId);
136+
style.setAttribute('data-ipyvue-scope', scopeId);
137+
}
53138
parentView.once('remove', () => {
54139
document.head.removeChild(style);
55140
});
@@ -106,15 +191,18 @@ function createComponentObject(model, parentView) {
106191
? template
107192
: vuefile.TEMPLATE,
108193
beforeMount() {
194+
applyScopeId(this, scopeId);
109195
callVueFn('beforeMount', this);
110196
},
111197
mounted() {
198+
applyScopeId(this, scopeId);
112199
callVueFn('mounted', this);
113200
},
114201
beforeUpdate() {
115202
callVueFn('beforeUpdate', this);
116203
},
117204
updated() {
205+
applyScopeId(this, scopeId);
118206
callVueFn('updated', this);
119207
},
120208
beforeDestroy() {
@@ -130,7 +218,7 @@ function createComponentObject(model, parentView) {
130218
function createDataMapping(model) {
131219
return model.keys()
132220
.filter(prop => !prop.startsWith('_')
133-
&& !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
221+
&& !['events', 'template', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop))
134222
.reduce((result, prop) => {
135223
result[prop] = _.cloneDeep(model.get(prop)); // eslint-disable-line no-param-reassign
136224
return result;
@@ -140,7 +228,7 @@ function createDataMapping(model) {
140228
function addModelListeners(model, vueModel) {
141229
model.keys()
142230
.filter(prop => !prop.startsWith('_')
143-
&& !['v_model', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
231+
&& !['v_model', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop))
144232
// eslint-disable-next-line no-param-reassign
145233
.forEach(prop => model.on(`change:${prop}`, () => {
146234
if (_.isEqual(model.get(prop), vueModel[prop])) {
@@ -166,7 +254,7 @@ function addModelListeners(model, vueModel) {
166254

167255
function createWatches(model, parentView, templateWatchers) {
168256
const modelWatchers = model.keys().filter(prop => !prop.startsWith('_')
169-
&& !['events', 'template', 'components', 'layout', 'css', 'data', 'methods'].includes(prop))
257+
&& !['events', 'template', 'components', 'layout', 'css', 'scoped', 'data', 'methods'].includes(prop))
170258
.reduce((result, prop) => ({
171259
...result,
172260
[prop]: {
@@ -349,8 +437,10 @@ function readVueFile(fileContent) {
349437
}
350438
if (component.styles && component.styles.length > 0) {
351439
const { content } = component.styles[0];
352-
const { id } = component.styles[0].attrs;
353-
result.STYLE = { content, id };
440+
const { attrs = {} } = component.styles[0];
441+
const { id } = attrs;
442+
const scoped = Object.prototype.hasOwnProperty.call(attrs, 'scoped');
443+
result.STYLE = { content, id, scoped };
354444
}
355445

356446
return result;

tests/ui/test_template.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,92 @@ def kernel_code(template_class_name=template_class_name):
141141
widget.wait_for()
142142
widget.click()
143143
page_session.locator("text=Clicked 1").wait_for()
144+
145+
146+
class ScopedStyleTemplate(vue.VueTemplate):
147+
@default("template")
148+
def _default_vue_template(self):
149+
return """
150+
<template>
151+
<div class="scoped-container">
152+
<span id="scoped-text" class="scoped-text">Scoped text</span>
153+
</div>
154+
</template>
155+
<style scoped>
156+
.scoped-text { color: rgb(255, 0, 0); }
157+
</style>
158+
"""
159+
160+
161+
def test_template_scoped_style(
162+
ipywidgets_runner, page_session: playwright.sync_api.Page
163+
):
164+
def kernel_code():
165+
from test_template import ScopedStyleTemplate
166+
import ipyvue as vue
167+
import ipywidgets as widgets
168+
from IPython.display import display
169+
170+
scoped = ScopedStyleTemplate()
171+
unscoped = vue.Html(
172+
tag="span",
173+
children=["Unscoped text"],
174+
class_="scoped-text",
175+
attributes={"id": "unscoped-text"},
176+
)
177+
display(widgets.VBox([scoped, unscoped]))
178+
179+
ipywidgets_runner(kernel_code)
180+
page_session.locator("#scoped-text").wait_for()
181+
page_session.locator("#unscoped-text").wait_for()
182+
scoped_color = page_session.eval_on_selector(
183+
"#scoped-text", "el => getComputedStyle(el).color"
184+
)
185+
unscoped_color = page_session.eval_on_selector(
186+
"#unscoped-text", "el => getComputedStyle(el).color"
187+
)
188+
assert scoped_color == "rgb(255, 0, 0)"
189+
assert unscoped_color != "rgb(255, 0, 0)"
190+
191+
192+
class ScopedCssTemplate(vue.VueTemplate):
193+
@default("template")
194+
def _default_vue_template(self):
195+
return """
196+
<template>
197+
<span id="scoped-css-text" class="scoped-css-text">Scoped css text</span>
198+
</template>
199+
"""
200+
201+
202+
def test_template_scoped_css_trait(
203+
ipywidgets_runner, page_session: playwright.sync_api.Page
204+
):
205+
def kernel_code():
206+
from test_template import ScopedCssTemplate
207+
import ipyvue as vue
208+
import ipywidgets as widgets
209+
from IPython.display import display
210+
211+
scoped = ScopedCssTemplate(
212+
css=".scoped-css-text { color: rgb(0, 128, 0); }", scoped=True
213+
)
214+
unscoped = vue.Html(
215+
tag="span",
216+
children=["Unscoped css text"],
217+
class_="scoped-css-text",
218+
attributes={"id": "unscoped-css-text"},
219+
)
220+
display(widgets.VBox([scoped, unscoped]))
221+
222+
ipywidgets_runner(kernel_code)
223+
page_session.locator("#scoped-css-text").wait_for()
224+
page_session.locator("#unscoped-css-text").wait_for()
225+
scoped_color = page_session.eval_on_selector(
226+
"#scoped-css-text", "el => getComputedStyle(el).color"
227+
)
228+
unscoped_color = page_session.eval_on_selector(
229+
"#unscoped-css-text", "el => getComputedStyle(el).color"
230+
)
231+
assert scoped_color == "rgb(0, 128, 0)"
232+
assert unscoped_color != "rgb(0, 128, 0)"

0 commit comments

Comments
 (0)