Skip to content

Commit 1b568f2

Browse files
authored
feat(framework): Add dynamic language change and on-demand rerendering (#1746)
1 parent 33fa055 commit 1b568f2

38 files changed

+269
-71
lines changed

docs/Configuration.md

+13-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,18 @@ The `theme` setting values above are the technical names of our themes.
3434

3535
In order to have RTL mode, just set the HTML attribute `dir` to `rtl` on the `body`, `html` or any other relevant region of your application.
3636

37-
This configuration setting should not be used by applications. It is only internally used for specific integration scenarios.
37+
The `RTL` configuration setting should not be used by applications. It is only internally used for specific integration scenarios.
38+
39+
*Note:* Whenever you change `dir` dynamically, make sure you call the `applyDirection` method to re-render the RTL-aware components.
40+
41+
Example:
42+
```js
43+
import applyDirection from "@ui5/webcomponents-base/dist/locale/applyDirection.js";
44+
45+
document.body.dir = "rtl";
46+
applyDirection();
47+
```
48+
3849

3950
<a name="animationMode"></a>
4051
### Animation Mode
@@ -122,7 +133,7 @@ To do so, please import the desired functionality from the respective `"@ui5/web
122133
import { getTheme, setTheme } from "@ui5/webcomponents-base/dist/config/Theme.js";
123134
import { getNoConflict, setNoConflict } from "@ui5/webcomponents-base/dist/config/NoConflict.js";
124135
import { getAnimationMode } from "@ui5/webcomponents-base/dist/config/AnimationMode.js";
125-
import { getLanguage } from "@ui5/webcomponents-base/dist/config/Language.js";
136+
import { getLanguage, setLanguage } from "@ui5/webcomponents-base/dist/config/Language.js";
126137
import { getCalendarType } from "@ui5/webcomponents-base/dist/config/CalendarType.js";
127138
import { getFirstDayOfWeek } from "@ui5/webcomponents-base/dist/config/FormatSettings.js";
128139
```

packages/base/src/CustomElementsRegistry.js

+4-10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import setToArray from "./util/setToArray.js";
2+
13
const Definitions = new Set();
24
const Failures = new Set();
35
let failureTimeout;
@@ -11,11 +13,7 @@ const isTagRegistered = tag => {
1113
};
1214

1315
const getAllRegisteredTags = () => {
14-
const arr = [];
15-
Definitions.forEach(tag => {
16-
arr.push(tag);
17-
});
18-
return arr;
16+
return setToArray(Definitions);
1917
};
2018

2119
const recordTagRegistrationFailure = tag => {
@@ -29,11 +27,7 @@ const recordTagRegistrationFailure = tag => {
2927
};
3028

3129
const displayFailedRegistrations = () => {
32-
const tags = []; // IE only supports Set.prototype.forEach
33-
Failures.forEach(tag => {
34-
tags.push(tag);
35-
});
36-
console.warn(`The following tags have already been defined by a different UI5 Web Components version: ${tags.join(", ")}`); // eslint-disable-line
30+
console.warn(`The following tags have already been defined by a different UI5 Web Components version: ${setToArray(Failures).join(", ")}`); // eslint-disable-line
3731
Failures.clear();
3832
};
3933

packages/base/src/EventProvider.js

+11-3
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,24 @@ class EventProvider {
3434
}
3535
}
3636

37+
/**
38+
* Fires an event and returns the results of all event listeners as an array.
39+
* Example: If listeners return promises, you can: await fireEvent("myEvent") to know when all listeners have finished.
40+
*
41+
* @param eventName the event to fire
42+
* @param data optional data to pass to each event listener
43+
* @returns {Array} an array with the results of all event listeners
44+
*/
3745
fireEvent(eventName, data) {
3846
const eventRegistry = this._eventRegistry;
3947
const eventListeners = eventRegistry[eventName];
4048

4149
if (!eventListeners) {
42-
return;
50+
return [];
4351
}
4452

45-
eventListeners.forEach(event => {
46-
event["function"].call(this, data); // eslint-disable-line
53+
return eventListeners.map(event => {
54+
return event["function"].call(this, data); // eslint-disable-line
4755
});
4856
}
4957

packages/base/src/RenderScheduler.js

+32
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import RenderQueue from "./RenderQueue.js";
22
import { getAllRegisteredTags } from "./CustomElementsRegistry.js";
3+
import { isRtlAware } from "./locale/RTLAwareRegistry.js";
34

45
const MAX_RERENDER_COUNT = 10;
6+
const registeredElements = new Set();
57

68
// Tells whether a render task is currently scheduled
79
let renderTaskId;
@@ -141,6 +143,36 @@ class RenderScheduler {
141143
renderTaskPromise = undefined;
142144
}
143145
}
146+
147+
static register(element) {
148+
registeredElements.add(element);
149+
}
150+
151+
static deregister(element) {
152+
registeredElements.delete(element);
153+
}
154+
155+
/**
156+
* Re-renders all UI5 Elements on the page, with the option to specify filters to rerender only some components.
157+
*
158+
* Usage:
159+
* reRenderAllUI5Elements() -> rerenders all components
160+
* reRenderAllUI5Elements({rtlAware: true}) -> re-renders only rtlAware components
161+
* reRenderAllUI5Elements({languageAware: true}) -> re-renders only languageAware components
162+
* reRenderAllUI5Elements({rtlAware: true, languageAware: true}) -> re-renders components that are rtlAware or languageAware
163+
*
164+
* @public
165+
* @param {Object|undefined} filters - Object with keys that can be "rtlAware" or "languageAware"
166+
*/
167+
static reRenderAllUI5Elements(filters) {
168+
registeredElements.forEach(element => {
169+
const rtlAware = isRtlAware(element.constructor);
170+
const languageAware = element.constructor.getMetadata().isLanguageAware();
171+
if (!filters || (filters.rtlAware && rtlAware) || (filters.languageAware && languageAware)) {
172+
RenderScheduler.renderDeferred(element);
173+
}
174+
});
175+
}
144176
}
145177

146178
export default RenderScheduler;

packages/base/src/UI5Element.js

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Float from "./types/Float.js";
1515
import { kebabToCamelCase, camelToKebabCase } from "./util/StringHelper.js";
1616
import isValidPropertyName from "./util/isValidPropertyName.js";
1717
import isSlot from "./util/isSlot.js";
18+
import { markAsRtlAware } from "./locale/RTLAwareRegistry.js";
1819

1920
const metadata = {
2021
events: {
@@ -114,6 +115,7 @@ class UI5Element extends HTMLElement {
114115
await Promise.resolve();
115116
}
116117

118+
RenderScheduler.register(this);
117119
await RenderScheduler.renderImmediately(this);
118120
this._domRefReadyPromise._deferredResolve();
119121
if (typeof this.onEnterDOM === "function") {
@@ -136,6 +138,7 @@ class UI5Element extends HTMLElement {
136138
this._stopObservingDOMChildren();
137139
}
138140

141+
RenderScheduler.deregister(this);
139142
if (typeof this.onExitDOM === "function") {
140143
this.onExitDOM();
141144
}
@@ -672,6 +675,8 @@ class UI5Element extends HTMLElement {
672675
* @returns {String|undefined}
673676
*/
674677
get effectiveDir() {
678+
markAsRtlAware(this.constructor); // if a UI5 Element calls this method, it's considered to be rtl-aware
679+
675680
const doc = window.document;
676681
const dirValues = ["ltr", "rtl"]; // exclude "auto" and "" from all calculations
677682
const locallyAppliedDir = getComputedStyle(this).getPropertyValue(GLOBAL_DIR_CSS_VAR);

packages/base/src/UI5ElementMetadata.js

+8
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ class UI5ElementMetadata {
126126
getEvents() {
127127
return this.metadata.events || {};
128128
}
129+
130+
/**
131+
* Determines whether this UI5 Element has any translatable texts (needs to be invalidated upon language change)
132+
* @returns {boolean}
133+
*/
134+
isLanguageAware() {
135+
return !!this.metadata.languageAware;
136+
}
129137
}
130138

131139
const validateSingleProperty = (value, propData) => {

packages/base/src/asset-registries/i18n.js

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getFeature } from "../FeaturesRegistry.js";
22
import getLocale from "../locale/getLocale.js";
3+
import { attachLanguageChange } from "../locale/languageChange.js";
34
import { fetchTextOnce } from "../util/FetchHelper.js";
45
import normalizeLocale from "../locale/normalizeLocale.js";
56
import nextFallbackLocale from "../locale/nextFallbackLocale.js";
@@ -63,6 +64,7 @@ const fetchI18nBundle = async packageName => {
6364
}
6465

6566
if (!bundlesForPackage[localeId]) {
67+
setI18nBundleData(packageName, null); // reset for the default language (if data was set for a previous language)
6668
return;
6769
}
6870

@@ -90,6 +92,12 @@ const fetchI18nBundle = async packageName => {
9092
setI18nBundleData(packageName, data);
9193
};
9294

95+
// When the language changes dynamically (the user calls setLanguage), re-fetch all previously fetched bundles
96+
attachLanguageChange(() => {
97+
const allPackages = [...bundleData.keys()];
98+
return Promise.all(allPackages.map(fetchI18nBundle));
99+
});
100+
93101
export {
94102
fetchI18nBundle,
95103
registerI18nBundle,

packages/base/src/config/Language.js

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,41 @@
11
import { getLanguage as getConfiguredLanguage } from "../InitialConfiguration.js";
2+
import { fireLanguageChange } from "../locale/languageChange.js";
3+
import RenderScheduler from "../RenderScheduler.js";
24

35
let language;
46

7+
/**
8+
* Returns the currently configured language, or the browser language as a fallback
9+
* @returns {String}
10+
*/
511
const getLanguage = () => {
612
if (language === undefined) {
713
language = getConfiguredLanguage();
814
}
915
return language;
1016
};
1117

12-
export { getLanguage }; // eslint-disable-line
18+
/**
19+
* Changes the current language, re-fetches all message bundles, updates all language-aware components
20+
* and returns a promise that resolves when all rendering is done
21+
*
22+
* @param newLanguage
23+
* @returns {Promise<void>}
24+
*/
25+
const setLanguage = async newLanguage => {
26+
if (language === newLanguage) {
27+
return;
28+
}
29+
30+
language = newLanguage;
31+
32+
const listenersResults = fireLanguageChange(newLanguage);
33+
await Promise.all(listenersResults);
34+
RenderScheduler.reRenderAllUI5Elements({ languageAware: true });
35+
return RenderScheduler.whenFinished();
36+
};
37+
38+
export {
39+
getLanguage,
40+
setLanguage,
41+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const rtlAwareSet = new Set();
2+
3+
const markAsRtlAware = klass => {
4+
rtlAwareSet.add(klass);
5+
};
6+
7+
const isRtlAware = klass => {
8+
return rtlAwareSet.has(klass);
9+
};
10+
11+
export {
12+
markAsRtlAware,
13+
isRtlAware,
14+
};
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import RenderScheduler from "../RenderScheduler.js";
2+
3+
/**
4+
* Re-renders all RTL-aware UI5 Elements.
5+
* Call this method whenever you change the "dir" property anywhere in your HTML page
6+
* Example: document.body.dir = "rtl"; applyDirection();
7+
*
8+
* @returns {Promise<void>}
9+
*/
10+
const applyDirection = () => {
11+
RenderScheduler.reRenderAllUI5Elements({ rtlAware: true });
12+
return RenderScheduler.whenFinished();
13+
};
14+
15+
export default applyDirection;
+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import EventProvider from "../EventProvider.js";
2+
3+
const eventProvider = new EventProvider();
4+
const LANG_CHANGE = "languageChange";
5+
6+
const attachLanguageChange = listener => {
7+
eventProvider.attachEvent(LANG_CHANGE, listener);
8+
};
9+
10+
const detachLanguageChange = listener => {
11+
eventProvider.detachEvent(LANG_CHANGE, listener);
12+
};
13+
14+
const fireLanguageChange = lang => {
15+
return eventProvider.fireEvent(LANG_CHANGE, lang);
16+
};
17+
18+
export {
19+
attachLanguageChange,
20+
detachLanguageChange,
21+
fireLanguageChange,
22+
};

packages/base/src/util/setToArray.js

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// This is needed as IE11 doesn't have Set.prototype.keys/values/entries, so [...mySet.values()] is not an option
2+
const setToArray = s => {
3+
const arr = [];
4+
s.forEach(item => {
5+
arr.push(item);
6+
});
7+
return arr;
8+
};
9+
10+
export default setToArray;

packages/fiori/src/NotificationListGroupItem.js

-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import NotificationListGroupItemCss from "./generated/themes/NotificationListGro
2929
*/
3030
const metadata = {
3131
tag: "ui5-li-notification-group",
32-
rtlAware: true,
3332
languageAware: true,
3433
managedSlots: true,
3534
properties: /** @lends sap.ui.webcomponents.fiori.NotificationListGroupItem.prototype */ {

packages/fiori/src/NotificationListItem.js

-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ const MAX_WRAP_HEIGHT = 32; // px.
3838
*/
3939
const metadata = {
4040
tag: "ui5-li-notification",
41-
rtlAware: true,
4241
languageAware: true,
4342
managedSlots: true,
4443
properties: /** @lends sap.ui.webcomponents.fiori.NotificationListItem.prototype */ {

packages/fiori/src/ShellBar.js

-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import styles from "./generated/themes/ShellBar.css.js";
3838
*/
3939
const metadata = {
4040
tag: "ui5-shellbar",
41-
rtlAware: true,
4241
languageAware: true,
4342
properties: /** @lends sap.ui.webcomponents.fiori.ShellBar.prototype */ {
4443

packages/main/bundle.es5.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,24 @@ import "./bundle.esm.js";
55

66
import { getAnimationMode } from "@ui5/webcomponents-base/dist/config/AnimationMode.js";
77
import { getTheme, setTheme } from "@ui5/webcomponents-base/dist/config/Theme.js";
8+
import { getLanguage, setLanguage } from "@ui5/webcomponents-base/dist/config/Language.js";
89
import { setNoConflict } from "@ui5/webcomponents-base/dist/config/NoConflict.js";
910
import { getRTL } from "@ui5/webcomponents-base/dist/config/RTL.js";
1011
import { getFirstDayOfWeek } from "@ui5/webcomponents-base/dist/config/FormatSettings.js";
11-
import { getRegisteredNames as getIconNames } from "@ui5/webcomponents-base/dist/SVGIconRegistry.js"
12+
import { getRegisteredNames as getIconNames } from "@ui5/webcomponents-base/dist/SVGIconRegistry.js";
13+
import applyDirection from "@ui5/webcomponents-base/dist/locale/applyDirection.js";
1214
const configuration = {
1315
getAnimationMode,
1416
getTheme,
1517
setTheme,
18+
getLanguage,
19+
setLanguage,
1620
setNoConflict,
1721
getRTL,
1822
getFirstDayOfWeek,
1923
};
2024
export {
2125
configuration,
2226
getIconNames,
27+
applyDirection,
2328
};

packages/main/bundle.esm.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,24 @@ window.isIE = isIE; // attached to the window object for testing purposes
9090
// Note: keep in sync with rollup.config value for IIFE
9191
import { getAnimationMode } from "@ui5/webcomponents-base/dist/config/AnimationMode.js";
9292
import { getTheme, setTheme } from "@ui5/webcomponents-base/dist/config/Theme.js";
93+
import { getLanguage, setLanguage } from "@ui5/webcomponents-base/dist/config/Language.js";
9394
import { setNoConflict } from "@ui5/webcomponents-base/dist/config/NoConflict.js";
9495
import { getRTL } from "@ui5/webcomponents-base/dist/config/RTL.js";
9596
import { getFirstDayOfWeek } from "@ui5/webcomponents-base/dist/config/FormatSettings.js";
96-
import { getRegisteredNames as getIconNames } from "@ui5/webcomponents-base/dist/SVGIconRegistry.js"
97+
import { getRegisteredNames as getIconNames } from "@ui5/webcomponents-base/dist/SVGIconRegistry.js";
98+
import applyDirection from "@ui5/webcomponents-base/dist/locale/applyDirection.js";
9799
window["sap-ui-webcomponents-bundle"] = {
98100
configuration : {
99101
getAnimationMode,
100102
getTheme,
101103
setTheme,
104+
getLanguage,
105+
setLanguage,
102106
setNoConflict,
103107
getRTL,
104108
getFirstDayOfWeek,
105109
},
106110
getIconNames,
107111
getLocaleData,
112+
applyDirection,
108113
};

0 commit comments

Comments
 (0)