diff --git a/docs/dev/Metadata.md b/docs/dev/Metadata.md index d39474186a10..1f2e24fa6662 100644 --- a/docs/dev/Metadata.md +++ b/docs/dev/Metadata.md @@ -109,12 +109,101 @@ Defines the `slots` that will be provided by this UI5 Web Component. Setting | Type | Default | Description --------|------|--------|----------- -`type` | `HTMLElement` or `Node` | N/A | The type of the children that can go into that slot +`type` * | `HTMLElement` or `Node` | N/A | The type of the children that can go into that slot `individualSlots` | `Boolean` | false | If set to `true`, each child will have its own slot, allowing you to arrange/wrap the children arbitrarily. -`propertyName` | `String` | N/A | Allows to set the name of the property on the Web Component, where the children belonging to this slot will be stored. -`listenFor` | `Object` | N/A | **Experimental, do not use.** If set, whenever the children, belonging to this slot have their properties changed, the Web Component will be invalidated. -`invalidateParent` | `Boolean` | false | **Experimental, do not use.** Defines whether every invalidation of a UI5 Web Component in this slot should trigger an invalidation of the parent UI5 Web Component. -The `type` setting is required. +`propertyName` | `String` | N/A | Allows to set the name of the property on the Web Component, where the children belonging to this slot will be stored. +`invalidateOnChildChange` ** | `Boolean` or `Object` | false | **Experimental, do not use.** Defines whether every invalidation of a UI5 Web Component in this slot should trigger an invalidation of the parent UI5 Web Component. + +`*` The `type` setting is required. + +`**` +**Important:** `invalidateOnChildChange` is not meant to be used with standard DOM Elements and is not to be confused with `MutationObserver`-like functionality. +It rather targets the use case of components that slot abstract items (`UI5Element` instances without a template) and require to be invalidated in turn whenever these items are invalidated. + +The `invalidateOnChildChange` setting can be either a `Boolean` (`true` meaning invalidate the component on any change of a child in this slot) or an `Object` with `properties` and `slots` fields. They in turn can be either of +type `Boolean` (`true` meaning invalidate on any property change or any slot change) or `Array` of strings indicating exactly which properties or slots lead to invalidation. + +Examples: + + - In the following example, since `invalidateOnChildChange` is not used (`false` by default), the component will be invalidated whenever children are added/removed in the `tabs` slot, + but not whenever a child in that slot changes. + ```json +{ + managedSlots: true, + slots: { + "default": { + "type": "HTMLElement", + "propertyName": "tabs", + } + } +} +``` + + - Setting `invalidateOnChildChange` to `true` means: invalidate the component whenever a child in the `tabs` slot gets invalidated, regardless of the reason. + ```json +{ + managedSlots: true, + slots: { + "default": { + "type": "HTMLElement", + "propertyName": "tabs", + "invalidateOnChildChange": true + } + } +} +``` + + - The example below results in exactly the same behavior as the one above, but it uses the more explicit `Object` format: + ```json +{ + managedSlots: true, + slots: { + "default": { + "type": "HTMLElement", + "propertyName": "tabs", + "invalidateOnChildChange": { + "properties": true, + "slots": true + } + } + } +} +``` + + - The following example uses the `Object` format again and means: invalidate the component whenever the children in this slot are invalidated due to property changes, but not due + to slot changes. Here `"slots": false` is added for completeness (as `false` is the default value for both `properties` and `slots`) + ```json +{ + managedSlots: true, + slots: { + "default": { + "type": "HTMLElement", + "propertyName": "tabs", + "invalidateOnChildChange": { + "properties": true, + "slots": false + } + } + } +} +``` + + - The final example shows the most complex format of `invalidateOnChildChange` which allows to define which slots or properties in the children inside that slot lead to invalidation of the component: + ```json +{ + managedSlots: true, + slots: { + "default": { + "type": "HTMLElement", + "propertyName": "tabs", + "invalidateOnChildChange": { + "properties": ["text", "selected", "disabled"], + "slots": ["default"] + } + } + } +} +``` Notes: - Children without a `slot` attribute will be assigned to the `default` slot. @@ -135,7 +224,7 @@ Notes: Determines whether the framework should manage the slots of this UI5 Web Component. -This setting is useful for UI5 Web Components that dont' just slot children, but additionally base their own +This setting is useful for UI5 Web Components that don't just slot children, but additionally base their own rendering on the presence/absence/type of children. ```json @@ -145,7 +234,7 @@ rendering on the presence/absence/type of children. ``` When `managedSlots` is set to `true`: - - The framework will invalidate this UI5 Web Component, whenever its children are added/removed/changed. + - The framework will invalidate this UI5 Web Component, whenever its children are added/removed/rearranged (and additionally when invalidated, if `invalidateOnChildChange` is set). - If any of this UI5 Web Component's children are custom elements, the framework will await until they are all defined and upgraded, before rendering the component for the first time. - The framework will create properties for each slot on this UI5 Web Component's instances for easier access diff --git a/packages/base/src/RenderScheduler.js b/packages/base/src/RenderScheduler.js index 65b0ad02298c..d8b827e0c6e9 100644 --- a/packages/base/src/RenderScheduler.js +++ b/packages/base/src/RenderScheduler.js @@ -63,7 +63,10 @@ class RenderScheduler { queuePromise = new Promise(resolve => { window.requestAnimationFrame(() => { // Render all components in the queue + + // console.log(`--------------------RENDER TASK START------------------------------`); // eslint-disable-line invalidatedWebComponents.process(component => component._render()); + // console.log(`--------------------RENDER TASK END------------------------------`); // eslint-disable-line // Resolve the promise so that callers of renderDeferred can continue queuePromise = null; diff --git a/packages/base/src/UI5Element.js b/packages/base/src/UI5Element.js index 9fb75c58e4e1..6a062da889e8 100644 --- a/packages/base/src/UI5Element.js +++ b/packages/base/src/UI5Element.js @@ -1,6 +1,7 @@ import merge from "./thirdparty/merge.js"; import boot from "./boot.js"; import UI5ElementMetadata from "./UI5ElementMetadata.js"; +import EventProvider from "./EventProvider.js"; import executeTemplate from "./renderer/executeTemplate.js"; import StaticAreaItem from "./StaticAreaItem.js"; import RenderScheduler from "./RenderScheduler.js"; @@ -16,14 +17,9 @@ import Float from "./types/Float.js"; import { kebabToCamelCase, camelToKebabCase } from "./util/StringHelper.js"; import isValidPropertyName from "./util/isValidPropertyName.js"; import isSlot from "./util/isSlot.js"; +import arraysAreEqual from "./util/arraysAreEqual.js"; import { markAsRtlAware } from "./locale/RTLAwareRegistry.js"; -const metadata = { - events: { - "_property-change": {}, - }, -}; - let autoId = 0; const elementTimeouts = new Map(); @@ -32,6 +28,27 @@ const uniqueDependenciesCache = new Map(); const GLOBAL_CONTENT_DENSITY_CSS_VAR = "--_ui5_content_density"; const GLOBAL_DIR_CSS_VAR = "--_ui5_dir"; +/** + * Triggers re-rendering of a UI5Element instance due to state change. + * + * @param changeInfo An object with information about the change that caused invalidation. + * @private + */ +function _invalidate(changeInfo) { + // Invalidation should be suppressed: 1) before the component is rendered for the first time 2) and during the execution of onBeforeRendering + // This is necessary not only as an optimization, but also to avoid infinite loops on invalidation between children and parents (when invalidateOnChildChange is used) + if (this._suppressInvalidation) { + return; + } + + // Call the onInvalidation hook + this.onInvalidation(changeInfo); + + this._changedState.push(changeInfo); + RenderScheduler.renderDeferred(this); + this._eventProvider.fireEvent("change", { ...changeInfo, target: this }); +} + /** * Base class for all UI5 Web Components * @@ -45,40 +62,23 @@ const GLOBAL_DIR_CSS_VAR = "--_ui5_dir"; class UI5Element extends HTMLElement { constructor() { super(); - this._propertyChangeListeners = new Set(); - this._initializeState(); - this._upgradeAllProperties(); - this._initializeContainers(); - this._upToDate = false; - this._inDOM = false; - this._fullyConnected = false; + this._changedState = []; // Filled on each invalidation, cleared on re-render (used for debugging) + this._suppressInvalidation = true; // A flag telling whether all invalidations should be ignored. Initialized with "true" because a UI5Element can not be invalidated until it is rendered for the first time + this._inDOM = false; // A flag telling whether the UI5Element is currently in the DOM tree of the document or not + this._fullyConnected = false; // A flag telling whether the UI5Element's onEnterDOM hook was called (since it's possible to have the element removed from DOM before that) + this._childChangeListeners = new Map(); // used to store lazy listeners per slot for the child change event of every child inside that slot + this._slotChangeListeners = new Map(); // used to store lazy listeners per slot for the slotchange event of all slot children inside that slot + this._eventProvider = new EventProvider(); // used by parent components for listening to changes to child components let deferredResolve; this._domRefReadyPromise = new Promise(resolve => { deferredResolve = resolve; }); this._domRefReadyPromise._deferredResolve = deferredResolve; - this._monitoredChildProps = new Map(); - this._shouldInvalidateParent = false; - } - - addEventListener(type, listener, options) { - if (type === "_property-change") { - this._propertyChangeListeners.add(listener); - } - return super.addEventListener(type, listener, options); - } - - removeEventListener(type, listener, options) { - if (type === "_property-change") { - this._propertyChangeListeners.delete(listener); - } - return super.removeEventListener(type, listener, options); - } - - _hasPropertyChangeListeners() { - return !!this._propertyChangeListeners.size; + this._initializeState(); + this._upgradeAllProperties(); + this._initializeContainers(); } /** @@ -131,23 +131,20 @@ class UI5Element extends HTMLElement { await this._processChildren(); } - // Render the Shadow DOM - if (needsShadowDOM) { - if (!this.shadowRoot) { // Workaround for Firefox74 bug - await Promise.resolve(); - } + if (needsShadowDOM && !this.shadowRoot) { // Workaround for Firefox74 bug + await Promise.resolve(); + } - if (!this._inDOM) { // Component removed from DOM while _processChildren was running - return; - } + if (!this._inDOM) { // Component removed from DOM while _processChildren was running + return; + } - RenderScheduler.register(this); - RenderScheduler.renderImmediately(this); - this._domRefReadyPromise._deferredResolve(); - this._fullyConnected = true; - if (typeof this.onEnterDOM === "function") { - this.onEnterDOM(); - } + RenderScheduler.register(this); + RenderScheduler.renderImmediately(this); + this._domRefReadyPromise._deferredResolve(); + this._fullyConnected = true; + if (typeof this.onEnterDOM === "function") { + this.onEnterDOM(); } } @@ -196,7 +193,7 @@ class UI5Element extends HTMLElement { const mutationObserverOptions = { childList: true, subtree: canSlotText, - characterData: true, + characterData: canSlotText, }; DOMObserver.observeDOMNode(this, this._processChildren.bind(this), mutationObserverOptions); } @@ -227,8 +224,14 @@ class UI5Element extends HTMLElement { const canSlotText = this.constructor.getMetadata().canSlotText(); const domChildren = Array.from(canSlotText ? this.childNodes : this.children); - // Init the _state object based on the supported slots + const slotsCachedContentMap = new Map(); // Store here the content of each slot before the mutation occurred + const propertyNameToSlotMap = new Map(); // Used for reverse lookup to determine to which slot the property name corresponds + + // Init the _state object based on the supported slots and store the previous values for (const [slotName, slotData] of Object.entries(slotsMap)) { // eslint-disable-line + const propertyName = slotData.propertyName || slotName; + propertyNameToSlotMap.set(propertyName, slotName); + slotsCachedContentMap.set(propertyName, [...this._state[propertyName]]); this._clearSlot(slotName, slotData); } @@ -275,16 +278,14 @@ class UI5Element extends HTMLElement { child = this.constructor.getMetadata().constructor.validateSlotValue(child, slotData); - if (child.isUI5Element && slotData.listenFor) { - this._attachChildPropertyUpdated(child, slotData.listenFor); - } - - if (child.isUI5Element && slotData.invalidateParent) { - child._shouldInvalidateParent = true; + // Listen for any invalidation on the child if invalidateOnChildChange is true or an object (ignore when false or not set) + if (child.isUI5Element && slotData.invalidateOnChildChange) { + child._attachChange(this._getChildChangeListener(slotName)); } + // Listen for the slotchange event if the child is a slot itself if (isSlot(child)) { - this._attachSlotChange(child); + this._attachSlotChange(child, slotName); } const propertyName = slotData.propertyName || slotName; @@ -300,10 +301,33 @@ class UI5Element extends HTMLElement { // Distribute the child in the _state object, keeping the Light DOM order, // not the order elements are defined. - slottedChildrenMap.forEach((children, slot) => { - this._state[slot] = children.sort((a, b) => a.idx - b.idx).map(_ => _.child); + slottedChildrenMap.forEach((children, propertyName) => { + this._state[propertyName] = children.sort((a, b) => a.idx - b.idx).map(_ => _.child); }); - this._invalidate("slots"); + + // Compare the content of each slot with the cached values and invalidate for the ones that changed + let invalidated = false; + for (const [slotName, slotData] of Object.entries(slotsMap)) { // eslint-disable-line + const propertyName = slotData.propertyName || slotName; + if (!arraysAreEqual(slotsCachedContentMap.get(propertyName), this._state[propertyName])) { + _invalidate.call(this, { + type: "slot", + name: propertyNameToSlotMap.get(propertyName), + reason: "children", + }); + invalidated = true; + } + } + + // If none of the slots had an invalidation due to changes to immediate children, + // the change is considered to be text content of the default slot + if (!invalidated) { + _invalidate.call(this, { + type: "slot", + name: "default", + reason: "textcontent", + }); + } } /** @@ -312,25 +336,61 @@ class UI5Element extends HTMLElement { */ _clearSlot(slotName, slotData) { const propertyName = slotData.propertyName || slotName; - - let children = this._state[propertyName]; - if (!Array.isArray(children)) { - children = [children]; - } + const children = this._state[propertyName]; children.forEach(child => { if (child && child.isUI5Element) { - this._detachChildPropertyUpdated(child); - child._shouldInvalidateParent = false; + child._detachChange(this._getChildChangeListener(slotName)); } if (isSlot(child)) { - this._detachSlotChange(child); + this._detachSlotChange(child, slotName); } }); this._state[propertyName] = []; - this._invalidate(propertyName, []); + } + + /** + * Attach a callback that will be executed whenever the component is invalidated + * + * @param callback + * @protected + */ + _attachChange(callback) { + this._eventProvider.attachEvent("change", callback); + } + + /** + * Detach the callback that is executed whenever the component is invalidated + * + * @param callback + * @protected + */ + _detachChange(callback) { + this._eventProvider.detachEvent("change", callback); + } + + /** + * Callback that is executed whenever a monitored child changes its state + * + * @param slotName the slot in which a child was invalidated + * @param childChangeInfo the changeInfo object for the child in the given slot + * @private + */ + _onChildChange(slotName, childChangeInfo) { + if (!this.constructor.getMetadata().shouldInvalidateOnChildChange(slotName, childChangeInfo.type, childChangeInfo.name)) { + return; + } + + // The component should be invalidated as this type of change on the child is listened for + // However, no matter what changed on the child (property/slot), the invalidation is registered as "type=slot" for the component itself + _invalidate.call(this, { + type: "slot", + name: slotName, + reason: "childchange", + child: childChangeInfo.target, + }); } /** @@ -409,115 +469,89 @@ class UI5Element extends HTMLElement { } /** + * Returns a singleton event listener for the "change" event of a child in a given slot + * + * @param slotName the name of the slot, where the child is + * @returns {any} * @private */ - _attachChildPropertyUpdated(child, listenFor) { - const slotName = this.constructor._getSlotName(child); // all slotted children have the same configuration - - let observedProps = [], - notObservedProps = []; - - if (Array.isArray(listenFor)) { - observedProps = listenFor; - } else { - observedProps = Array.isArray(listenFor.include) ? listenFor.include : []; - notObservedProps = Array.isArray(listenFor.exclude) ? listenFor.exclude : []; + _getChildChangeListener(slotName) { + if (!this._childChangeListeners.has(slotName)) { + this._childChangeListeners.set(slotName, this._onChildChange.bind(this, slotName)); } - - if (!this._monitoredChildProps.has(slotName)) { - this._monitoredChildProps.set(slotName, { observedProps, notObservedProps }); - } - - child.addEventListener("_property-change", this._invalidateParentOnPropertyUpdate); + return this._childChangeListeners.get(slotName); } /** + * Returns a singleton slotchange event listener that invalidates the component due to changes in the given slot + * + * @param slotName the name of the slot, where the slot element (whose slotchange event we're listening to) is + * @returns {any} * @private */ - _detachChildPropertyUpdated(child) { - child.removeEventListener("_property-change", this._invalidateParentOnPropertyUpdate); - } - - /** - * @private - */ - _propertyChange(name, value) { - this._updateAttribute(name, value); - - if (this._hasPropertyChangeListeners()) { - this.dispatchEvent(new CustomEvent("_property-change", { - detail: { name, newValue: value }, - composed: false, - bubbles: false, - })); + _getSlotChangeListener(slotName) { + if (!this._slotChangeListeners.has(slotName)) { + this._slotChangeListeners.set(slotName, this._onSlotChange.bind(this, slotName)); } + return this._slotChangeListeners.get(slotName); } /** * @private */ - _invalidateParentOnPropertyUpdate(prop) { - // The web component to be invalidated - const parentNode = this.parentNode; - if (!parentNode) { - return; - } - - const slotName = parentNode.constructor._getSlotName(this); - const propsMetadata = parentNode._monitoredChildProps.get(slotName); - - if (!propsMetadata) { - return; - } - const { observedProps, notObservedProps } = propsMetadata; - - const allPropertiesAreObserved = observedProps.length === 1 && observedProps[0] === "*"; - const shouldObserve = allPropertiesAreObserved || observedProps.includes(prop.detail.name); - const shouldSkip = notObservedProps.includes(prop.detail.name); - if (shouldObserve && !shouldSkip) { - parentNode._invalidate("_parent_", this); - } + _attachSlotChange(child, slotName) { + child.addEventListener("slotchange", this._getSlotChangeListener(slotName)); } /** * @private */ - _attachSlotChange(child) { - if (!this._invalidateOnSlotChange) { - this._invalidateOnSlotChange = () => { - this._invalidate("slotchange"); - }; - } - child.addEventListener("slotchange", this._invalidateOnSlotChange); + _detachSlotChange(child, slotName) { + child.removeEventListener("slotchange", this._getSlotChangeListener(slotName)); } /** + * Whenever a slot element is slotted inside a UI5 Web Component, its slotchange event invalidates the component + * + * @param slotName the name of the slot, where the slot element (whose slotchange event we're listening to) is * @private */ - _detachSlotChange(child) { - child.removeEventListener("slotchange", this._invalidateOnSlotChange); + _onSlotChange(slotName) { + _invalidate.call(this, { + type: "slot", + name: slotName, + reason: "slotchange", + }); } /** - * Asynchronously re-renders an already rendered web component - * @private + * A callback that is executed each time an already rendered component is invalidated (scheduled for re-rendering) + * + * @param changeInfo An object with information about the change that caused invalidation. + * The object can have the following properties: + * - type: (property|slot) tells what caused the invalidation + * 1) property: a property value was changed either directly or as a result of changing the corresponding attribute + * 2) slot: a slotted node(nodes) changed in one of several ways (see "reason") + * + * - name: the name of the property or slot that caused the invalidation + * + * - reason: (children|textcontent|childchange|slotchange) relevant only for type="slot" only and tells exactly what changed in the slot + * 1) children: immediate children (HTML elements or text nodes) were added, removed or reordered in the slot + * 2) textcontent: text nodes in the slot changed value (or nested text nodes were added or changed value). Can only trigger for slots of "type: Node" + * 3) slotchange: a slot element, slotted inside that slot had its "slotchange" event listener called. This practically means that transitively slotted children changed. + * Can only trigger if the child of a slot is a slot element itself. + * 4) childchange: indicates that a UI5Element child in that slot was invalidated and in turn invalidated the component. + * Can only trigger for slots with "invalidateOnChildChange" metadata descriptor + * + * - newValue: the new value of the property (for type="property" only) + * + * - oldValue: the old value of the property (for type="property" only) + * + * - child the child that was changed (for type="slot" and reason="childchange" only) + * + * @public */ - _invalidate() { - if (this._shouldInvalidateParent) { - this.parentNode._invalidate(); - } - - if (!this._upToDate) { - // console.log("already invalidated", this, ...arguments); - return; - } - - if (this.getDomRef() && !this._suppressInvalidation) { - this._upToDate = false; - // console.log("INVAL", this, ...arguments); - RenderScheduler.renderDeferred(this); - } - } + onInvalidation(changeInfo) {} /** * Do not call this method directly, only intended to be called by RenderScheduler.js @@ -539,13 +573,33 @@ class UI5Element extends HTMLElement { } // resume normal invalidation handling - delete this._suppressInvalidation; + this._suppressInvalidation = false; // Update the shadow root with the render result - // console.log(this.getDomRef() ? "RE-RENDER" : "FIRST RENDER", this); - this._upToDate = true; - this._updateShadowRoot(); + /* + if (this._changedState.length) { + let element = this.localName; + if (this.id) { + element = `${element}#${this.id}`; + } + console.log("Re-rendering:", element, this._changedState.map(x => { // eslint-disable-line + let res = `${x.type}`; + if (x.reason) { + res = `${res}(${x.reason})`; + } + res = `${res}: ${x.name}`; + if (x.type === "property") { + res = `${res} ${x.oldValue} => ${x.newValue}`; + } + return res; + })); + } + */ + this._changedState = []; + + // Update shadow root and static area item + this._updateShadowRoot(); if (this._shouldUpdateFragment()) { this.staticAreaItem._updateFragment(this); this.staticAreaItemDomRef = this.staticAreaItem.staticAreaItemDomRef.shadowRoot; @@ -850,7 +904,7 @@ class UI5Element extends HTMLElement { * @private */ static _getDefaultState() { - if (this._defaultState) { + if (Object.prototype.hasOwnProperty.call(this, "_defaultState")) { return this._defaultState; } @@ -949,8 +1003,13 @@ class UI5Element extends HTMLElement { if (oldState !== value) { this._state[prop] = value; - this._invalidate(prop, value); - this._propertyChange(prop, value); + _invalidate.call(this, { + type: "property", + name: prop, + newValue: value, + oldValue: oldState, + }); + this._updateAttribute(prop, value); } }, }); @@ -973,7 +1032,7 @@ class UI5Element extends HTMLElement { return []; }, set() { - throw new Error("Cannot set slots directly, use the DOM APIs"); + throw new Error("Cannot set slot content directly, use the DOM APIs (appendChild, removeChild, etc...)"); }, }); } @@ -985,7 +1044,7 @@ class UI5Element extends HTMLElement { * @protected */ static get metadata() { - return metadata; + return {}; } /** diff --git a/packages/base/src/UI5ElementMetadata.js b/packages/base/src/UI5ElementMetadata.js index 09d2c8b716de..de2c193682ab 100644 --- a/packages/base/src/UI5ElementMetadata.js +++ b/packages/base/src/UI5ElementMetadata.js @@ -167,6 +167,74 @@ class UI5ElementMetadata { isLanguageAware() { return !!this.metadata.languageAware; } + + /** + * Matches a changed entity (property/slot) with the given name against the "invalidateOnChildChange" configuration + * and determines whether this should cause and invalidation + * + * @param slotName the name of the slot in which a child was changed + * @param type the type of change in the child: "property" or "slot" + * @param name the name of the property/slot that changed + * @returns {boolean} + */ + shouldInvalidateOnChildChange(slotName, type, name) { + const config = this.getSlots()[slotName].invalidateOnChildChange; + + // invalidateOnChildChange was not set in the slot metadata - by default child changes do not affect the component + if (config === undefined) { + return false; + } + + // The simple format was used: invalidateOnChildChange: true/false; + if (typeof config === "boolean") { + return config; + } + + // The complex format was used: invalidateOnChildChange: { properties, slots } + if (typeof config === "object") { + // A property was changed + if (type === "property") { + // The config object does not have a properties field + if (config.properties === undefined) { + return false; + } + + // The config object has the short format: properties: true/false + if (typeof config.properties === "boolean") { + return config.properties; + } + + // The config object has the complex format: properties: [...] + if (Array.isArray(config.properties)) { + return config.properties.includes(name); + } + + throw new Error("Wrong format for invalidateOnChildChange.properties: boolean or array is expected"); + } + + // A slot was changed + if (type === "slot") { + // The config object does not have a slots field + if (config.slots === undefined) { + return false; + } + + // The config object has the short format: slots: true/false + if (typeof config.slots === "boolean") { + return config.slots; + } + + // The config object has the complex format: slots: [...] + if (Array.isArray(config.slots)) { + return config.slots.includes(name); + } + + throw new Error("Wrong format for invalidateOnChildChange.slots: boolean or array is expected"); + } + } + + throw new Error("Wrong format for invalidateOnChildChange: boolean or object is expected"); + } } const validateSingleProperty = (value, propData) => { diff --git a/packages/base/src/delegate/ItemNavigation.js b/packages/base/src/delegate/ItemNavigation.js index a698fc8eb731..3ab7a2ace844 100644 --- a/packages/base/src/delegate/ItemNavigation.js +++ b/packages/base/src/delegate/ItemNavigation.js @@ -9,13 +9,62 @@ import { isPageUp, isPageDown, } from "../Keys.js"; +import getActiveElement from "../util/getActiveElement.js"; import EventProvider from "../EventProvider.js"; import NavigationMode from "../types/NavigationMode.js"; import ItemNavigationBehavior from "../types/ItemNavigationBehavior.js"; -// navigatable items must have id and tabindex +/** + * The ItemNavigation class manages the calculations to determine the correct "tabindex" for a group of related items inside a root component. + * Important: ItemNavigation only does the calculations and does not change "tabindex" directly, this is a responsibility of the developer. + * + * The keys that trigger ItemNavigation are: + * - Up/down + * - Left/right + * - Home/End + * - PageUp/PageDown + * + * Usage: + * 1) Use the "getItemsCallback" constructor property to pass a callback to ItemNavigation, which, whenever called, will return the list of items to navigate among. + * + * Each item passed to ItemNavigation via "getItemsCallback" must be: + * - A) either a UI5Element with a "_tabIndex" property + * - B) or an Object with "id" and "_tabIndex" properties which represents a part of the root component's shadow DOM. + * The "id" must be a valid ID within the shadow root of the component ItemNavigation operates on. + * This object must not be a DOM object because, as said, ItemNavigation will not set "tabindex" on it. It must be a representation of a DOM object only + * and the developer has the responsibility to update the "tabindex" in the component's DOM. + * - C) a combination of the above + * + * Whenever the user navigates with the keyboard, ItemNavigation will modify the "_tabIndex" properties of the items. + * It is the items' responsibilities to re-render themselves and apply the correct value of "tabindex" (i.e. to map the "_tabIndex" ItemNavigation set to them to the "tabindex" property). + * If the items of the ItemNavigation are UI5Elements themselves, this can happen naturally since they will be invalidated by their "_tabIndex" property. + * If the items are Objects with "id" and "_tabIndex" however, it is the developer's responsibility to apply these and the easiest way is to have the root component invalidated by ItemNavigation. + * To do so, set the "affectedPropertiesNames" constructor property to point to one or more of the root component's properties that need refreshing when "_tabIndex" is changed deeply. + * + * 2) Call the "update" method of ItemNavigation whenever you want to change the current item. + * This is most commonly required if the user for example clicks on an item and thus selects it directly. + * Pass as the only argument to "update" the item that becomes current (must be one of the items, returned by "getItemsCallback"). + * + * @class + * @public + */ class ItemNavigation extends EventProvider { + /** + * + * @param rootWebComponent the component to operate on (component that slots or contains within its shadow root the items the user navigates among) + * @param options Object with configuration options: + * - currentIndex: the index of the item that will be initially selected (from which navigation will begin) + * - navigationMode (Auto|Horizontal|Vertical): whether the items are displayed horizontally (Horizontal), vertically (Vertical) or as a matrix (Auto) meaning the user can navigate in both directions (up/down and left/right) + * - rowSize: tells how many items per row there are when the items are not rendered as a flat list but rather as a matrix. Relevant for navigationMode=Auto + * - behavior (Static|Cycling|Paging): tells what to do when trying to navigate beyond the first and last items + * Static means that nothing happens if the user tries to navigate beyond the first/last item. + * Cycling means that when the user navigates beyond the last item they go to the first and vice versa. + * Paging means that when the urse navigates beyond the first/last item, a new "page" of items appears (as commonly observed with calendars for example) + * - pageSize: tells how many items the user skips by using the PageUp/PageDown keys + * - getItemsCallback: function that, when called, returns an array with all items the user can navigate among + * - affectedPropertiesNames: a list of metadata properties on the root component which, upon user navigation, will be reassigned by address thus causing the root component to invalidate + */ constructor(rootWebComponent, options = {}) { super(); @@ -31,6 +80,14 @@ class ItemNavigation extends EventProvider { this.pageSize = options.pageSize; + if (options.affectedPropertiesNames) { + this.affectedPropertiesNames = options.affectedPropertiesNames; + } + + if (options.getItemsCallback) { + this._getItems = options.getItemsCallback; + } + this.rootWebComponent = rootWebComponent; this.rootWebComponent.addEventListener("keydown", this.onkeydown.bind(this)); this.rootWebComponent._onComponentStateFinalized = () => { @@ -160,6 +217,13 @@ class ItemNavigation extends EventProvider { } } + /** + * Call this method to set a new "current" (selected) item in the item navigation + * Note: the item passed to this function must be one of the items, returned by the getItemsCallback function + * + * @public + * @param current the new selected item + */ update(current) { const origItems = this._getItems(); @@ -178,10 +242,18 @@ class ItemNavigation extends EventProvider { items[i]._tabIndex = (i === this.currentIndex ? "0" : "-1"); } - - this.rootWebComponent._invalidate(); + if (Array.isArray(this.affectedPropertiesNames)) { + this.affectedPropertiesNames.forEach(propName => { + const prop = this.rootWebComponent[propName]; + this.rootWebComponent[propName] = Array.isArray(prop) ? [...prop] : { ...prop }; + }); + } } + /** + * @public + * @deprecated + */ focusCurrent() { const currentItem = this._getCurrentItem(); if (currentItem) { @@ -191,12 +263,7 @@ class ItemNavigation extends EventProvider { _canNavigate() { const currentItem = this._getCurrentItem(); - - let activeElement = document.activeElement; - - while (activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { - activeElement = activeElement.shadowRoot.activeElement; - } + const activeElement = getActiveElement(); return currentItem && currentItem === activeElement; } @@ -234,10 +301,20 @@ class ItemNavigation extends EventProvider { return this.rootWebComponent.getDomRef().querySelector(`#${currentItem.id}`); } - set getItemsCallback(fn) { - this._getItems = fn; + /** + * Set to callback that returns the list of items to navigate among + * @public + * @param callback a function that returns an array of items to navigate among + */ + set getItemsCallback(callback) { + this._getItems = callback; } + /** + * @public + * @deprecated + * @param val + */ set current(val) { this.currentIndex = val; } diff --git a/packages/base/src/util/arraysAreEqual.js b/packages/base/src/util/arraysAreEqual.js new file mode 100644 index 000000000000..908236629845 --- /dev/null +++ b/packages/base/src/util/arraysAreEqual.js @@ -0,0 +1,15 @@ +const arraysAreEqual = (arr1, arr2) => { + if (arr1.length !== arr2.length) { + return false; + } + + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + + return true; +}; + +export default arraysAreEqual; diff --git a/packages/base/src/util/getActiveElement.js b/packages/base/src/util/getActiveElement.js new file mode 100644 index 000000000000..4c424523c30b --- /dev/null +++ b/packages/base/src/util/getActiveElement.js @@ -0,0 +1,11 @@ +const getActiveElement = () => { + let element = document.activeElement; + + while (element && element.shadowRoot && element.shadowRoot.activeElement) { + element = element.shadowRoot.activeElement; + } + + return element; +}; + +export default getActiveElement; diff --git a/packages/base/test/elements/Parent.js b/packages/base/test/elements/Parent.js index 38b9213a52f6..5c1e0eda924e 100644 --- a/packages/base/test/elements/Parent.js +++ b/packages/base/test/elements/Parent.js @@ -7,11 +7,15 @@ const metadata = { slots: { default: { type: Node, - listenFor: ["prop1"], + invalidateOnChildChange: { + properties: ["prop1"] + }, }, items: { type: HTMLElement, - listenFor: { include: ["*"], exclude: ["prop3"] } + invalidateOnChildChange: { + properties: true + }, } } }; diff --git a/packages/base/test/specs/UI5ElementInvalidation.js b/packages/base/test/specs/UI5ElementInvalidation.js index 9045e068aa6d..44d9e95cb90d 100644 --- a/packages/base/test/specs/UI5ElementInvalidation.js +++ b/packages/base/test/specs/UI5ElementInvalidation.js @@ -12,9 +12,7 @@ describe("Invalidation works", () => { // Exactly 1 invalidation for each property change let invalidations = 0; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidations++; }; @@ -40,9 +38,7 @@ describe("Invalidation works", () => { let invalidations = 0; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidations++; }; @@ -68,9 +64,7 @@ describe("Invalidation works", () => { let invalidations = 0; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidations++; }; @@ -96,9 +90,7 @@ describe("Invalidation works", () => { let invalidations = 0; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidations++; }; @@ -121,9 +113,7 @@ describe("Invalidation works", () => { // Number of invalidations may vary with children/slots count, so just check for invalidation let invalidated = false; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidated = true; }; @@ -150,9 +140,7 @@ describe("Invalidation works", () => { let invalidated = false; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidated = true; }; @@ -177,9 +165,7 @@ describe("Invalidation works", () => { // Number of invalidations may vary with children/slots count, so just check for invalidation let invalidated = false; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidated = true; }; @@ -204,9 +190,7 @@ describe("Invalidation works", () => { // Number of invalidations may vary with children/slots count, so just check for invalidation let invalidated = false; - const original = el._invalidate; - el._invalidate = () => { - original.apply(el, arguments); + el.onInvalidation = () => { invalidated = true; }; @@ -231,9 +215,7 @@ describe("Invalidation works", () => { rendering: 0, }; - const originalInvalidate = el._invalidate; - el._invalidate = () => { - originalInvalidate.apply(el, arguments); + el.onInvalidation = () => { operations.invalidation++; }; diff --git a/packages/base/test/specs/UI5ElementListenForChildPropChanges.spec.js b/packages/base/test/specs/UI5ElementListenForChildPropChanges.spec.js index 25efb0ef3f29..6fd1249dde35 100644 --- a/packages/base/test/specs/UI5ElementListenForChildPropChanges.spec.js +++ b/packages/base/test/specs/UI5ElementListenForChildPropChanges.spec.js @@ -1,19 +1,17 @@ const assert = require("chai").assert; -describe("Metadata slot listenFor works", () => { +describe("Metadata slot invalidateOnChildChange works", () => { browser.url("http://localhost:9191/test-resources/pages/AllTestElements.html"); - it("Tests that changing a listenFor property of a child invalidates the parent", () => { + it("Tests that changing a monitored property of a child invalidates the parent", () => { const res = browser.executeAsync( async (done) => { const parent = document.getElementById("parent"); const child = document.getElementById("child1"); - const parentInvalidate = parent._invalidate; let parentInvalidated = false; - parent._invalidate = () => { - parentInvalidate.apply(parent, arguments); + parent.onInvalidation = () => { parentInvalidated = true; }; @@ -27,17 +25,15 @@ describe("Metadata slot listenFor works", () => { assert.strictEqual(res, true, "Parent invalidated"); }); - it("Tests that changing a non-listenFor property of a child does not invalidate the parent", () => { + it("Tests that changing a non-monitored property of a child does not invalidate the parent", () => { const res = browser.executeAsync( async (done) => { const parent = document.getElementById("parent"); const child = document.getElementById("child1"); - const parentInvalidate = parent._invalidate; let parentInvalidated = false; - parent._invalidate = () => { - parentInvalidate.apply(parent, arguments); + parent.onInvalidation = () => { parentInvalidated = true; }; @@ -51,51 +47,27 @@ describe("Metadata slot listenFor works", () => { assert.strictEqual(res, false, "Parent not invalidated"); }); - it("Tests that listenFor include works", () => { + it("Tests that listening for all properties works", () => { const res = browser.executeAsync( async (done) => { const parent = document.getElementById("parent"); const child = document.getElementById("child2"); - const parentInvalidate = parent._invalidate; - let parentInvalidated = false; + let parentInvalidatedCount = 0; - parent._invalidate = () => { - parentInvalidate.apply(parent, arguments); - parentInvalidated = true; + parent.onInvalidation = () => { + parentInvalidatedCount++; }; - child.prop1 = "c"; // child2(items slot) prop1 invalidates + child.prop1 = "c"; + child.prop2 = "c"; + child.prop3 = "c"; await window.RenderScheduler.whenFinished(); - return done(parentInvalidated); + return done(parentInvalidatedCount); }); - assert.strictEqual(res, true, "Parent invalidated"); - }); - - it("Tests that listenFor exclude works", () => { - - const res = browser.executeAsync( async (done) => { - const parent = document.getElementById("parent"); - const child = document.getElementById("child2"); - - const parentInvalidate = parent._invalidate; - let parentInvalidated = false; - - parent._invalidate = () => { - parentInvalidate.apply(parent, arguments); - parentInvalidated = true; - }; - - child.prop3 = "d"; //child2(items slot) prop3 does not - - await window.RenderScheduler.whenFinished(); - - return done(parentInvalidated); - }); - - assert.strictEqual(res, false, "Parent not invalidated"); + assert.strictEqual(res, 3, "Parent invalidated 3 times"); }); }); diff --git a/packages/fiori/src/ShellBar.js b/packages/fiori/src/ShellBar.js index 7ff80a452adf..547cb1d1e0ef 100644 --- a/packages/fiori/src/ShellBar.js +++ b/packages/fiori/src/ShellBar.js @@ -181,7 +181,7 @@ const metadata = { "default": { propertyName: "items", type: HTMLElement, - invalidateParent: true, + invalidateOnChildChange: true, }, /** diff --git a/packages/fiori/src/SideNavigation.js b/packages/fiori/src/SideNavigation.js index cd2f0c11c6c9..eb11126a040f 100644 --- a/packages/fiori/src/SideNavigation.js +++ b/packages/fiori/src/SideNavigation.js @@ -47,7 +47,7 @@ const metadata = { */ "default": { propertyName: "items", - invalidateParent: true, + invalidateOnChildChange: true, type: HTMLElement, }, @@ -76,7 +76,7 @@ const metadata = { */ fixedItems: { type: HTMLElement, - invalidateParent: true, + invalidateOnChildChange: true, }, }, events: /** @lends sap.ui.webcomponents.fiori.SideNavigation.prototype */ { diff --git a/packages/fiori/src/SideNavigationItem.js b/packages/fiori/src/SideNavigationItem.js index 37f5598f9473..dc4f914c80b7 100644 --- a/packages/fiori/src/SideNavigationItem.js +++ b/packages/fiori/src/SideNavigationItem.js @@ -91,7 +91,7 @@ const metadata = { */ "default": { propertyName: "items", - invalidateParent: true, + invalidateOnChildChange: true, type: HTMLElement, }, }, diff --git a/packages/fiori/src/Wizard.js b/packages/fiori/src/Wizard.js index 6c4174c85e92..eaeda7659418 100644 --- a/packages/fiori/src/Wizard.js +++ b/packages/fiori/src/Wizard.js @@ -63,7 +63,7 @@ const metadata = { propertyName: "steps", type: HTMLElement, "individualSlots": true, - listenFor: { include: ["*"] }, + invalidateOnChildChange: true, }, }, events: /** @lends sap.ui.webcomponents.fiori.Wizard.prototype */ { diff --git a/packages/main/src/ComboBox.js b/packages/main/src/ComboBox.js index 911562af5135..013b8899d5a8 100644 --- a/packages/main/src/ComboBox.js +++ b/packages/main/src/ComboBox.js @@ -248,7 +248,7 @@ const metadata = { "default": { propertyName: "items", type: HTMLElement, - listenFor: { include: ["*"] }, + invalidateOnChildChange: true, }, /** diff --git a/packages/main/src/ComboBoxItem.hbs b/packages/main/src/ComboBoxItem.hbs deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/main/src/ComboBoxItem.js b/packages/main/src/ComboBoxItem.js index b791f217aaba..07edd2371fa2 100644 --- a/packages/main/src/ComboBoxItem.js +++ b/packages/main/src/ComboBoxItem.js @@ -1,9 +1,4 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; -import litRender from "@ui5/webcomponents-base/dist/renderer/LitRenderer.js"; -import ComboBoxItemTemplate from "./generated/templates/ComboBoxItemTemplate.lit.js"; - -// Styles -import ComboBoxItemCss from "./generated/themes/ComboBoxItem.css.js"; /** * @public @@ -29,12 +24,6 @@ const metadata = { */ additionalText: { type: String }, }, - slots: { - // - }, - events: { - // - }, }; /** @@ -52,18 +41,6 @@ class ComboBoxItem extends UI5Element { static get metadata() { return metadata; } - - static get render() { - return litRender; - } - - static get styles() { - return ComboBoxItemCss; - } - - static get template() { - return ComboBoxItemTemplate; - } } ComboBoxItem.define(); diff --git a/packages/main/src/DayPicker.js b/packages/main/src/DayPicker.js index 609115e8e9b3..77d7e203e4a1 100644 --- a/packages/main/src/DayPicker.js +++ b/packages/main/src/DayPicker.js @@ -236,11 +236,10 @@ class DayPicker extends UI5Element { rowSize: 7, pageSize: 42, behavior: ItemNavigationBehavior.Paging, + affectedPropertiesNames: ["_weeks"], }); - this._itemNav.getItemsCallback = function getItemsCallback() { - return this.focusableDays; - }.bind(this); + this._itemNav.getItemsCallback = () => this.focusableDays; this._itemNav.attachEvent( ItemNavigation.BORDER_REACH, diff --git a/packages/main/src/List.js b/packages/main/src/List.js index cd2cb95b06fa..16c316df60ba 100644 --- a/packages/main/src/List.js +++ b/packages/main/src/List.js @@ -382,7 +382,7 @@ class List extends UI5Element { } get hasData() { - return this.items.length !== 0; + return this.getSlottedNodes("items").length !== 0; } get showNoDataText() { diff --git a/packages/main/src/MonthPicker.js b/packages/main/src/MonthPicker.js index 29180c7653ea..89cc8c503a36 100644 --- a/packages/main/src/MonthPicker.js +++ b/packages/main/src/MonthPicker.js @@ -144,9 +144,10 @@ class MonthPicker extends UI5Element { pageSize: 12, rowSize: 3, behavior: ItemNavigationBehavior.Paging, + affectedPropertiesNames: ["_quarters"], }); - this._itemNav.getItemsCallback = function getItemsCallback() { + this._itemNav.getItemsCallback = () => { const focusableMonths = []; for (let i = 0; i < this._quarters.length; i++) { @@ -155,11 +156,7 @@ class MonthPicker extends UI5Element { } return [].concat(...focusableMonths); - }.bind(this); - - this._itemNav.setItemsCallback = function setItemsCallback(items) { - this._quarters = items; - }.bind(this); + }; this._itemNav.attachEvent( ItemNavigation.BORDER_REACH, diff --git a/packages/main/src/MultiComboBox.js b/packages/main/src/MultiComboBox.js index 71f53c852437..91fe5bf6cd75 100644 --- a/packages/main/src/MultiComboBox.js +++ b/packages/main/src/MultiComboBox.js @@ -73,7 +73,7 @@ const metadata = { "default": { propertyName: "items", type: HTMLElement, - listenFor: { include: ["*"] }, + invalidateOnChildChange: true, }, /** diff --git a/packages/main/src/Option.js b/packages/main/src/Option.js index fe9f23dd29c6..303a112b8d88 100644 --- a/packages/main/src/Option.js +++ b/packages/main/src/Option.js @@ -5,6 +5,7 @@ import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; */ const metadata = { tag: "ui5-option", + managedSlots: true, properties: /** @lends sap.ui.webcomponents.main.Option.prototype */ { /** @@ -43,7 +44,11 @@ const metadata = { type: String, }, }, - + slots: { + "default": { + type: Node, + }, + }, events: /** @lends sap.ui.webcomponents.main.Option.prototype */ {}, }; diff --git a/packages/main/src/Select.js b/packages/main/src/Select.js index 2426a8c5886f..a86d1c05b545 100644 --- a/packages/main/src/Select.js +++ b/packages/main/src/Select.js @@ -69,7 +69,7 @@ const metadata = { "default": { propertyName: "options", type: HTMLElement, - listenFor: { include: ["*"] }, + invalidateOnChildChange: true, }, /** diff --git a/packages/main/src/TabContainer.js b/packages/main/src/TabContainer.js index b10d957629f0..eddb0ad4f33e 100644 --- a/packages/main/src/TabContainer.js +++ b/packages/main/src/TabContainer.js @@ -55,7 +55,10 @@ const metadata = { propertyName: "items", type: HTMLElement, individualSlots: true, - listenFor: { include: ["*"] }, + invalidateOnChildChange: { + properties: true, + slots: false, + }, }, /** diff --git a/packages/main/src/Table.hbs b/packages/main/src/Table.hbs index 67e5969a65f4..74afc6c4323a 100644 --- a/packages/main/src/Table.hbs +++ b/packages/main/src/Table.hbs @@ -1,7 +1,7 @@ - + {{#each visibleColumns}} {{/each}} @@ -26,4 +26,4 @@ {{/if}} {{/unless}} -
\ No newline at end of file + diff --git a/packages/main/src/Table.js b/packages/main/src/Table.js index a92f1a509c91..4cf6704d09e8 100644 --- a/packages/main/src/Table.js +++ b/packages/main/src/Table.js @@ -43,7 +43,10 @@ const metadata = { columns: { type: HTMLElement, individualSlots: true, - listenFor: { include: ["*"] }, + invalidateOnChildChange: { + properties: true, + slots: false, + }, }, }, properties: /** @lends sap.ui.webcomponents.main.Table.prototype */ { @@ -107,6 +110,14 @@ const metadata = { _noDataDisplayed: { type: Boolean, }, + + /** + * Used to represent the table column header for the purpose of the item navigation as it does not work with DOM objects directly + * @private + */ + _columnHeader: { + type: Object, + }, }, events: /** @lends sap.ui.webcomponents.main.Table.prototype */ { /** @@ -191,15 +202,18 @@ class Table extends UI5Element { constructor() { super(); + // The ItemNavigation requires each item to 1) have a "_tabIndex" property and 2) be either a UI5Element, or have an id property (to find it in the component's shadow DOM by) + this._columnHeader = { + id: `${this._id}-columnHeader`, + _tabIndex: "0", + }; + this._itemNavigation = new ItemNavigation(this, { navigationMode: NavigationMode.Vertical, + affectedPropertiesNames: ["_columnHeader"], + getItemsCallback: () => [this._columnHeader, ...this.rows], }); - this._itemNavigation.getItemsCallback = function getItemsCallback() { - const columnHeader = this.getColumnHeader(); - return columnHeader ? [columnHeader, ...this.rows] : this.rows; - }.bind(this); - this.fnOnRowFocused = this.onRowFocused.bind(this); this._handleResize = this.popinContent.bind(this); @@ -242,7 +256,7 @@ class Table extends UI5Element { _onColumnHeaderClick(event) { this.getColumnHeader().focus(); - this._itemNavigation.update(event.target); + this._itemNavigation.update(this._columnHeader); } getColumnHeader() { diff --git a/packages/main/src/Tree.hbs b/packages/main/src/Tree.hbs index 617d0dd58ada..7f7e1c8d3261 100644 --- a/packages/main/src/Tree.hbs +++ b/packages/main/src/Tree.hbs @@ -15,7 +15,7 @@ type="Active" level="{{this.level}}" icon="{{this.treeItem.icon}}" - ?_toggle-button-end="{{../_toggleButtonEnd}}" + ?_toggle-button-end="{{ ../_toggleButtonEnd}}" ?_minimal="{{../_minimal}}" .treeItem="{{this.treeItem}}" .expanded="{{this.treeItem.expanded}}" diff --git a/packages/main/src/Tree.js b/packages/main/src/Tree.js index 477853fdd879..6fcbec639755 100644 --- a/packages/main/src/Tree.js +++ b/packages/main/src/Tree.js @@ -113,6 +113,7 @@ const metadata = { "default": { type: HTMLElement, propertyName: "items", + invalidateOnChildChange: true, }, /** @@ -246,32 +247,11 @@ class Tree extends UI5Element { ]; } - constructor() { - super(); - this._observer = new MutationObserver(this.onTreeStructureChange.bind(this)); - } - onBeforeRendering() { this._listItems = []; buildTree(this, 1, this._listItems); } - onEnterDOM() { - this._observer.observe(this, { attributes: true, childList: true, subtree: true }); - } - - onExitDOM() { - this._observer.disconnect(); - } - - onTreeStructureChange() { - // setTimeout is needed for IE11 so that it does not interfere with ItemNavigation.js and its await on RenderScheduler. - // Otherwise this invalidation happens too soon and the ItemNavigation is blocked on waiting the tree to finish - setTimeout(() => { - this._listItems = []; // trigger onBeforeRendering by modifying the tracked property and force tree re-build - }, 0); - } - get list() { return this.getDomRef(); } diff --git a/packages/main/src/TreeItem.js b/packages/main/src/TreeItem.js index 4fbbe7961602..3cf944df82c8 100644 --- a/packages/main/src/TreeItem.js +++ b/packages/main/src/TreeItem.js @@ -67,6 +67,7 @@ const metadata = { type: String, }, }, + managedSlots: true, slots: /** @lends sap.ui.webcomponents.main.TreeItem.prototype */ { /** @@ -77,7 +78,9 @@ const metadata = { * @public */ "default": { + propertyName: "items", type: HTMLElement, + invalidateOnChildChange: true, }, }, }; @@ -110,10 +113,6 @@ class TreeItem extends UI5Element { return metadata; } - get items() { - return [...this.children]; - } - get requiresToggleButton() { return this.hasChildren || this.items.length > 0; } diff --git a/packages/main/src/YearPicker.js b/packages/main/src/YearPicker.js index a01efa790cdb..e51d2613f4ed 100644 --- a/packages/main/src/YearPicker.js +++ b/packages/main/src/YearPicker.js @@ -151,9 +151,10 @@ class YearPicker extends UI5Element { pageSize: 20, rowSize: 4, behavior: ItemNavigationBehavior.Paging, + affectedPropertiesNames: ["_yearIntervals"], }); - this._itemNav.getItemsCallback = function getItemsCallback() { + this._itemNav.getItemsCallback = () => { const focusableYears = []; for (let i = 0; i < this._yearIntervals.length; i++) { @@ -162,7 +163,7 @@ class YearPicker extends UI5Element { } return [].concat(...focusableYears); - }.bind(this); + }; this._itemNav.attachEvent( ItemNavigation.BORDER_REACH, diff --git a/packages/main/src/popup-utils/PopupUtils.js b/packages/main/src/popup-utils/PopupUtils.js index 7cc5aece31de..64d63cb301d4 100644 --- a/packages/main/src/popup-utils/PopupUtils.js +++ b/packages/main/src/popup-utils/PopupUtils.js @@ -1,12 +1,9 @@ +import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; + let currentZIndex = 100; const getFocusedElement = () => { - let element = document.activeElement; - - while (element && element.shadowRoot && element.shadowRoot.activeElement) { - element = element.shadowRoot.activeElement; - } - + const element = getActiveElement(); return (element && typeof element.focus === "function") ? element : null; }; diff --git a/packages/main/src/themes/ComboBoxItem.css b/packages/main/src/themes/ComboBoxItem.css deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/main/test/pages/Button.html b/packages/main/test/pages/Button.html index e1f25fbce374..19074e57c6ef 100644 --- a/packages/main/test/pages/Button.html +++ b/packages/main/test/pages/Button.html @@ -60,7 +60,7 @@ - Action
Bar
Button
+ Action
Bar
Button
Primary button Secondary button Inactive diff --git a/packages/main/test/pages/Simple.html b/packages/main/test/pages/Simple.html index 4267bb4b36f9..ace9f065aff2 100644 --- a/packages/main/test/pages/Simple.html +++ b/packages/main/test/pages/Simple.html @@ -17,8 +17,14 @@ - +test + + + Cozy + Compact + +