Skip to content

Commit 3588542

Browse files
authored
feat(framework): dynamic custom elements scoping (#2091)
1 parent 9128264 commit 3588542

File tree

104 files changed

+972
-460
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

104 files changed

+972
-460
lines changed

package.json

+6
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,18 @@
2727
"clean:fiori": "cd packages/fiori && yarn clean",
2828
"prepare:main": "cd packages/main && nps prepare",
2929
"prepare:fiori": "cd packages/fiori && nps prepare",
30+
"scopePrepare:main": "cd packages/main && nps scope.prepare",
31+
"scopePrepare:fiori": "cd packages/fiori && nps scope.prepare",
3032
"dev:base": "cd packages/base && nps watch",
3133
"dev:localization": "cd packages/localization && nps watch",
3234
"dev:main": "cd packages/main && nps dev",
3335
"dev:fiori": "cd packages/fiori && nps dev",
36+
"scopeDev:main": "cd packages/main && nps scope.dev",
37+
"scopeDev:fiori": "cd packages/fiori && nps scope.dev",
3438
"start": "npm-run-all --sequential build:base build:localization build:theme-base build:icons prepare:main prepare:fiori start:all",
39+
"startWithScope": "npm-run-all --sequential build:base build:localization build:theme-base build:icons scopePrepare:main scopePrepare:fiori scopeStart:all",
3540
"start:all": "npm-run-all --parallel dev:base dev:localization dev:main dev:fiori",
41+
"scopeStart:all": "npm-run-all --parallel dev:base dev:localization scopeDev:main scopeDev:fiori",
3642
"start:base": "cd packages/base && yarn start",
3743
"start:main": "cd packages/main && yarn start",
3844
"start:fiori": "cd packages/fiori && yarn start",
+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
let suf;
2+
let rulesObj = {
3+
include: [/^ui5-/],
4+
exclude: [],
5+
};
6+
const tagsCache = new Map(); // true/false means the tag should/should not be cached, undefined means not known yet.
7+
8+
/**
9+
* Sets the suffix to be used for custom elements scoping, f.e. pass "demo" to get tags such as "ui5-button-demo".
10+
* Note: by default all tags starting with "ui5-" will be scoped, unless you change this by calling "setCustomElementsScopingRules"
11+
*
12+
* @public
13+
* @param suffix The scoping suffix
14+
*/
15+
const setCustomElementsScopingSuffix = suffix => {
16+
if (!suffix.match(/^[a-zA-Z0-9_-]+$/)) {
17+
throw new Error("Only alphanumeric characters and dashes allowed for the scoping suffix");
18+
}
19+
20+
suf = suffix;
21+
};
22+
23+
/**
24+
* Returns the currently set scoping suffix, or undefined if not set.
25+
*
26+
* @public
27+
* @returns {String|undefined}
28+
*/
29+
const getCustomElementsScopingSuffix = () => {
30+
return suf;
31+
};
32+
33+
/**
34+
* Sets the rules, governing which custom element tags to scope and which not, f.e.
35+
* setCustomElementsScopingRules({include: [/^ui5-/]}, exclude: [/^ui5-mylib-/, /^ui5-carousel$/]);
36+
* will scope all elements starting with "ui5-" but not the ones starting with "ui5-mylib-" and not "ui5-carousel".
37+
*
38+
* @public
39+
* @param rules Object with "include" and "exclude" properties, both arrays of regular expressions. Note that "include"
40+
* rules are applied first and "exclude" rules second.
41+
*/
42+
const setCustomElementsScopingRules = rules => {
43+
if (!rules || !rules.include) {
44+
throw new Error(`"rules" must be an object with at least an "include" property`);
45+
}
46+
47+
if (!Array.isArray(rules.include) || rules.include.some(rule => !(rule instanceof RegExp))) {
48+
throw new Error(`"rules.include" must be an array of regular expressions`);
49+
}
50+
51+
if (rules.exclude && (!Array.isArray(rules.exclude) || rules.exclude.some(rule => !(rule instanceof RegExp)))) {
52+
throw new Error(`"rules.exclude" must be an array of regular expressions`);
53+
}
54+
55+
rules.exclude = rules.exclude || [];
56+
rulesObj = rules;
57+
tagsCache.clear(); // reset the cache upon setting new rules
58+
};
59+
60+
/**
61+
* Returns the rules, governing which custom element tags to scope and which not. By default, all elements
62+
* starting with "ui5-" are scoped. The default rules are: {include: [/^ui5-/]}.
63+
*
64+
* @public
65+
* @returns {Object}
66+
*/
67+
const getCustomElementsScopingRules = () => {
68+
return rulesObj;
69+
};
70+
71+
/**
72+
* Determines whether custom elements with the given tag should be scoped or not.
73+
* The tag is first matched against the "include" rules and then against the "exclude" rules and the
74+
* result is cached until new rules are set.
75+
*
76+
* @public
77+
* @param tag
78+
*/
79+
const shouldScopeCustomElement = tag => {
80+
if (!tagsCache.has(tag)) {
81+
const result = rulesObj.include.some(rule => tag.match(rule)) && !rulesObj.exclude.some(rule => tag.match(rule));
82+
tagsCache.set(tag, result);
83+
}
84+
85+
return tagsCache.get(tag);
86+
};
87+
88+
/**
89+
* Returns the currently set scoping suffix, if any and if the tag should be scoped, or undefined otherwise.
90+
*
91+
* @public
92+
* @param tag
93+
* @returns {String}
94+
*/
95+
const getEffectiveScopingSuffixForTag = tag => {
96+
if (shouldScopeCustomElement(tag)) {
97+
return getCustomElementsScopingSuffix();
98+
}
99+
};
100+
101+
export {
102+
setCustomElementsScopingSuffix,
103+
getCustomElementsScopingSuffix,
104+
setCustomElementsScopingRules,
105+
getCustomElementsScopingRules,
106+
shouldScopeCustomElement,
107+
getEffectiveScopingSuffixForTag,
108+
};

packages/base/src/StaticAreaItem.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getStaticAreaInstance, removeStaticArea } from "./StaticArea.js";
22
import RenderScheduler from "./RenderScheduler.js";
33
import getStylesString from "./theming/getStylesString.js";
4+
import executeTemplate from "./renderer/executeTemplate.js";
45

56
/**
67
* @class
@@ -22,7 +23,7 @@ class StaticAreaItem {
2223
* @protected
2324
*/
2425
_updateFragment() {
25-
const renderResult = this.ui5ElementContext.constructor.staticAreaTemplate(this.ui5ElementContext),
26+
const renderResult = executeTemplate(this.ui5ElementContext.constructor.staticAreaTemplate, this.ui5ElementContext),
2627
stylesToAdd = window.ShadyDOM ? false : getStylesString(this.ui5ElementContext.constructor.staticAreaStyles);
2728

2829
if (!this.staticAreaItemDomRef) {

packages/base/src/UI5Element.js

+53-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import merge from "./thirdparty/merge.js";
22
import boot from "./boot.js";
33
import UI5ElementMetadata from "./UI5ElementMetadata.js";
4+
import executeTemplate from "./renderer/executeTemplate.js";
45
import StaticAreaItem from "./StaticAreaItem.js";
56
import RenderScheduler from "./RenderScheduler.js";
67
import { registerTag, isTagRegistered, recordTagRegistrationFailure } from "./CustomElementsRegistry.js";
@@ -26,6 +27,7 @@ const metadata = {
2627
let autoId = 0;
2728

2829
const elementTimeouts = new Map();
30+
const uniqueDependenciesCache = new Map();
2931

3032
const GLOBAL_CONTENT_DENSITY_CSS_VAR = "--_ui5_content_density";
3133
const GLOBAL_DIR_CSS_VAR = "--_ui5_dir";
@@ -98,6 +100,8 @@ class UI5Element extends HTMLElement {
98100
* @private
99101
*/
100102
async connectedCallback() {
103+
this.setAttribute(this.constructor.getMetadata().getPureTag(), "");
104+
101105
const needsShadowDOM = this.constructor._needsShadowDOM();
102106
const slotsAreManaged = this.constructor.getMetadata().slotsAreManaged();
103107

@@ -549,7 +553,7 @@ class UI5Element extends HTMLElement {
549553
}
550554

551555
let styleToPrepend;
552-
const renderResult = this.constructor.template(this);
556+
const renderResult = executeTemplate(this.constructor.template, this);
553557

554558
// IE11, Edge
555559
if (window.ShadyDOM) {
@@ -968,6 +972,50 @@ class UI5Element extends HTMLElement {
968972
return "";
969973
}
970974

975+
/**
976+
* Returns an array with the dependencies for this UI5 Web Component, which could be:
977+
* - composed components (used in its shadow root or static area item)
978+
* - slotted components that the component may need to communicate with
979+
*
980+
* @protected
981+
*/
982+
static get dependencies() {
983+
return [];
984+
}
985+
986+
/**
987+
* Returns a list of the unique dependencies for this UI5 Web Component
988+
*
989+
* @public
990+
*/
991+
static getUniqueDependencies() {
992+
if (!uniqueDependenciesCache.has(this)) {
993+
const filtered = this.dependencies.filter((dep, index, deps) => deps.indexOf(dep) === index);
994+
uniqueDependenciesCache.set(this, filtered);
995+
}
996+
997+
return uniqueDependenciesCache.get(this);
998+
}
999+
1000+
/**
1001+
* Returns a promise that resolves whenever all dependencies for this UI5 Web Component have resolved
1002+
*
1003+
* @returns {Promise<any[]>}
1004+
*/
1005+
static whenDependenciesDefined() {
1006+
return Promise.all(this.getUniqueDependencies().map(dep => dep.define()));
1007+
}
1008+
1009+
/**
1010+
* Hook that will be called upon custom element definition
1011+
*
1012+
* @protected
1013+
* @returns {Promise<void>}
1014+
*/
1015+
static async onDefine() {
1016+
return Promise.resolve();
1017+
}
1018+
9711019
/**
9721020
* Registers a UI5 Web Component in the browser window object
9731021
* @public
@@ -976,9 +1024,10 @@ class UI5Element extends HTMLElement {
9761024
static async define() {
9771025
await boot();
9781026

979-
if (this.onDefine) {
980-
await this.onDefine();
981-
}
1027+
await Promise.all([
1028+
this.whenDependenciesDefined(),
1029+
this.onDefine(),
1030+
]);
9821031

9831032
const tag = this.getMetadata().getTag();
9841033
const altTag = this.getMetadata().getAltTag();

packages/base/src/UI5ElementMetadata.js

+27-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import DataType from "./types/DataType.js";
22
import isDescendantOf from "./util/isDescendantOf.js";
33
import { camelToKebabCase } from "./util/StringHelper.js";
44
import isSlot from "./util/isSlot.js";
5+
import { getEffectiveScopingSuffixForTag } from "./CustomElementsScope.js";
56

67
/**
78
*
@@ -33,20 +34,44 @@ class UI5ElementMetadata {
3334
return validateSingleSlot(value, slotData);
3435
}
3536

37+
/**
38+
* Returns the tag of the UI5 Element without the scope
39+
* @public
40+
*/
41+
getPureTag() {
42+
return this.metadata.tag;
43+
}
44+
3645
/**
3746
* Returns the tag of the UI5 Element
3847
* @public
3948
*/
4049
getTag() {
41-
return this.metadata.tag;
50+
const pureTag = this.metadata.tag;
51+
const suffix = getEffectiveScopingSuffixForTag(pureTag);
52+
if (!suffix) {
53+
return pureTag;
54+
}
55+
56+
return `${pureTag}-${suffix}`;
4257
}
4358

4459
/**
4560
* Used to get the tag we need to register for backwards compatibility
4661
* @public
4762
*/
4863
getAltTag() {
49-
return this.metadata.altTag;
64+
const pureAltTag = this.metadata.altTag;
65+
if (!pureAltTag) {
66+
return;
67+
}
68+
69+
const suffix = getEffectiveScopingSuffixForTag(pureAltTag);
70+
if (!suffix) {
71+
return pureAltTag;
72+
}
73+
74+
return `${pureAltTag}-${suffix}`;
5075
}
5176

5277
/**

packages/base/src/renderer/LitRenderer.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import { html, render } from "lit-html/lit-html.js";
1+
import { html, svg, render } from "lit-html/lit-html.js";
2+
import scopeHTML from "./scopeHTML.js";
3+
4+
let tags;
5+
let suffix;
6+
7+
const setTags = t => {
8+
tags = t;
9+
};
10+
const setSuffix = s => {
11+
suffix = s;
12+
};
213

314
const litRender = (templateResult, domNode, styles, { eventContext } = {}) => {
415
if (styles) {
@@ -7,7 +18,11 @@ const litRender = (templateResult, domNode, styles, { eventContext } = {}) => {
718
render(templateResult, domNode, { eventContext });
819
};
920

10-
export { html, svg } from "lit-html/lit-html.js";
21+
const scopedHtml = (strings, ...values) => html(scopeHTML(strings, tags, suffix), ...values);
22+
const scopedSvg = (strings, ...values) => svg(scopeHTML(strings, tags, suffix), ...values);
23+
24+
export { setTags, setSuffix };
25+
export { scopedHtml as html, scopedSvg as svg };
1126
export { repeat } from "lit-html/directives/repeat.js";
1227
export { classMap } from "lit-html/directives/class-map.js";
1328
export { styleMap } from "lit-html/directives/style-map.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { getCustomElementsScopingSuffix, shouldScopeCustomElement } from "../CustomElementsScope.js";
2+
3+
/**
4+
* Runs a component's template with the component's current state, while also scoping HTML
5+
*
6+
* @param template - the template to execute
7+
* @param component - the component
8+
* @public
9+
* @returns {*}
10+
*/
11+
const executeTemplate = (template, component) => {
12+
const tagsToScope = component.constructor.getUniqueDependencies().map(dep => dep.getMetadata().getPureTag()).filter(shouldScopeCustomElement);
13+
const scope = getCustomElementsScopingSuffix();
14+
return template(component, tagsToScope, scope);
15+
};
16+
17+
export default executeTemplate;
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const cache = new Map();
2+
3+
const scopeHTML = (strings, tags, suffix) => {
4+
if (suffix && tags && tags.length) {
5+
strings = strings.map(string => {
6+
if (cache.has(string)) {
7+
return cache.get(string);
8+
}
9+
10+
/*
11+
const allTags = [...string.matchAll(/<(ui5-.*?)[> ]/g)].map(x => x[1]);
12+
allTags.forEach(t => {
13+
if (!tags.includes(t)) {
14+
throw new Error(`${t} not found in ${string}`);
15+
// console.log(t, " in ", string);
16+
}
17+
});
18+
*/
19+
20+
let result = string;
21+
tags.forEach(tag => {
22+
result = result.replace(new RegExp(`(</?)(${tag})(/?[> \t\n])`, "g"), `$1$2-${suffix}$3`);
23+
});
24+
cache.set(string, result);
25+
return result;
26+
});
27+
}
28+
29+
return strings;
30+
};
31+
32+
export default scopeHTML;

0 commit comments

Comments
 (0)