Skip to content
30 changes: 30 additions & 0 deletions packages/base/cypress/specs/UI5ElementFocus.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Focusable from "../../test/test-elements/Focusable.js";

describe("UI5Element focus", () => {
it("focuses synchronously when the shadow DOM is already rendered", () => {
cy.mount(<>
<Focusable id="webc"></Focusable>
<button id="native">Native</button>
</>);

// Wait for the shadow DOM to render, then reproduce Component_Focus.html:
// focus the UI5 element and then synchronously focus a native button.
// focus() must be synchronous so the native button (focused last) wins and
// is not stolen back on a later microtask. (With focus inside a shadow root,
// a regression surfaces as activeElement === the host #webc.)
cy.get("#webc").shadow().find("button").should("exist");

cy.get("#webc").then($webc => {
const webc = $webc.get(0)!;
const native = webc.ownerDocument.getElementById("native")!;

const focusPromise = webc.focus();
native.focus();

// Still focused once focus()'s promise settles (regression re-focuses here).
return Cypress.Promise.resolve(focusPromise).then(() => {
expect(webc.ownerDocument.activeElement).to.equal(native);
});
});
});
});
10 changes: 10 additions & 0 deletions packages/base/src/Render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ const renderDeferred = async (webComponent: UI5Element) => {
await scheduleRenderTask();
};

/**
* Returns true if the web component is currently in the invalidation queue, i.e. it is awaiting a (re-)render.
*
* @param webComponent
*/
const isRenderScheduled = (webComponent: UI5Element) => {
return invalidatedWebComponents.isAdded(webComponent);
};

/**
* Register all web components attached to the DOM
*/
Expand Down Expand Up @@ -187,6 +196,7 @@ export {
renderDeferred,
renderImmediately,
cancelRender,
isRenderScheduled,
registerElement,
unregisterElement,
renderFinished,
Expand Down
17 changes: 17 additions & 0 deletions packages/base/src/UI5Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
renderDeferred,
renderImmediately,
cancelRender,
isRenderScheduled,
unregisterElement,
registerElement,
} from "./Render.js";
Expand Down Expand Up @@ -1043,8 +1044,24 @@ abstract class UI5Element extends HTMLElement {
* @public
*/
async focus(focusOptions?: FocusOptions): Promise<void> {
// Focus synchronously when the shadow DOM is already rendered so that focus
// ordering matches native elements (await always defers to a microtask, even
// for an already-resolved promise, which lets a later sync focus() be stolen back).
// If the element is still queued for a (re-)render, its focus DOM ref may be
// stale or missing, so wait for the pending render before focusing.
if (this._rendered && !isRenderScheduled(this)) {
this._focusDomRef(focusOptions);
return;
}

await this._waitForDomRef();
this._focusDomRef(focusOptions);
}

/**
* @private
*/
_focusDomRef(focusOptions?: FocusOptions): void {
const focusDomRef = this.getFocusDomRef();
if (focusDomRef === this || !this.isConnected) {
HTMLElement.prototype.focus.call(this, focusOptions);
Expand Down
19 changes: 19 additions & 0 deletions packages/base/test/test-elements/Focusable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import UI5Element from "../../src/UI5Element.js";
import customElement from "../../src/decorators/customElement.js";
import jsxRenderer from "../../src/renderer/JsxRenderer.js";

@customElement({
tag: "ui5-test-focusable",
renderer: jsxRenderer,
})
class Focusable extends UI5Element {
static get template() {
return () => <div>
<button data-sap-focus-ref>Inner</button>
</div>;
}
}

Focusable.define();

export default Focusable;
3 changes: 2 additions & 1 deletion packages/main/src/ToolbarItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,10 @@ class ToolbarItem extends ToolbarItemBase {
};
}

focusForToolbarNavigation(isForward: boolean) {
async focusForToolbarNavigation(isForward: boolean): Promise<void> {
const target = this.getFocusDomRefForNavigation(isForward);
target?.focus();
return Promise.resolve();
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/main/src/ToolbarItemBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ class ToolbarItemBase extends UI5Element {
* @private
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
focusForToolbarNavigation(isForward: boolean) {
async focusForToolbarNavigation(isForward: boolean) {
await this._waitForDomRef();
this.getFocusDomRef()?.focus();
}

Expand Down
33 changes: 33 additions & 0 deletions packages/main/test/pages/UI5ElementFocus.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>

<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta charset="utf-8">

<title>Button</title>
<script>
// delete Document.prototype.adoptedStyleSheets
</script>


<script src="%VITE_BUNDLE_PATH%" type="module"></script>
</head>

<body class="button1auto">
<ui5-button id="webCButton">Web Component Button</ui5-button>
<br>
<button id="htmlButton">HTML Button</button>
<br>
<button onclick="doFocus()">Focus</button>
<script>
function doFocus() {
setTimeout(() => {
document.getElementById("webCButton").focus();
document.getElementById("htmlButton").focus();
}, 100);
}
</script>
</body>

</html>
Loading