From 6a27de5e23958ffdf7e99c0ee03ad596ca7b95fe Mon Sep 17 00:00:00 2001
From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com>
Date: Fri, 16 Oct 2026 11:13:44 +0530
Subject: [PATCH] fix(traces): open the service side panel as a non-modal
drawer to avoid flicker (v1.1.0) (#15342) (#15440)
---
web/src/lib/overlay/Drawer/ODrawer.spec.ts | 18 ++++++++
web/src/lib/overlay/Drawer/ODrawer.vue | 9 ++--
.../traces/ServiceGraphNodeSidePanel.spec.ts | 9 ++++
.../traces/ServiceGraphNodeSidePanel.vue | 1 +
.../traces/ServiceGraphSidePanel.spec.ts | 1 +
.../plugins/traces/ServicesCatalog.spec.ts | 42 +++++++++++++++++++
6 files changed, 76 insertions(+), 4 deletions(-)
diff --git a/web/src/lib/overlay/Drawer/ODrawer.spec.ts b/web/src/lib/overlay/Drawer/ODrawer.spec.ts
index 60e3fe6b5a..1c8ac145ff 100644
--- a/web/src/lib/overlay/Drawer/ODrawer.spec.ts
+++ b/web/src/lib/overlay/Drawer/ODrawer.spec.ts
@@ -275,6 +275,24 @@ describe("ODrawer", () => {
expect(document.activeElement).toBe(panel.element);
wrapper.unmount();
});
+
+ it("focuses without scrolling so a contained portal target is not dragged sideways", async () => {
+ const focusSpy = vi.spyOn(HTMLElement.prototype, "focus");
+ const wrapper = mount(ODrawer, {
+ attachTo: document.body,
+ props: { open: true, title: "Contained" },
+ slots: { default: '' },
+ });
+ const panel = findDrawerPanel(wrapper);
+ focusSpy.mockClear();
+
+ await panel.vm.$emit("openAutoFocus", new Event("openAutoFocus", { cancelable: true }));
+ await new Promise((resolve) => setTimeout(resolve));
+
+ expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
+ focusSpy.mockRestore();
+ wrapper.unmount();
+ });
});
describe("Escape key behaviour", () => {
diff --git a/web/src/lib/overlay/Drawer/ODrawer.vue b/web/src/lib/overlay/Drawer/ODrawer.vue
index 3264a134f2..8c42a9fa3a 100644
--- a/web/src/lib/overlay/Drawer/ODrawer.vue
+++ b/web/src/lib/overlay/Drawer/ODrawer.vue
@@ -305,24 +305,25 @@ function findAutoFocusTarget(root: Element): HTMLElement | null {
function handleOpenAutoFocus(event: Event) {
event.preventDefault();
+ // The panel is still translated off-screen by the slide-in, so a scrolling focus would drag a contained portal target sideways.
nextTick(() => {
const body = bodyRef.value;
const field = body ? findAutoFocusTarget(body) : null;
if (field) {
- field.focus();
+ field.focus({ preventScroll: true });
return;
}
const autofocus = body?.querySelector("[autofocus]");
if (autofocus) {
- autofocus.focus();
+ autofocus.focus({ preventScroll: true });
return;
}
const btnEl = (primaryBtnRef.value as any)?.$el as HTMLElement | undefined;
if (btnEl) {
- btnEl.focus();
+ btnEl.focus({ preventScroll: true });
return;
}
- body?.closest("[data-o2-drawer]")?.focus();
+ body?.closest("[data-o2-drawer]")?.focus({ preventScroll: true });
});
}
diff --git a/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts b/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts
index b1bd96f44f..1a7a59e982 100644
--- a/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts
+++ b/web/src/plugins/traces/ServiceGraphNodeSidePanel.spec.ts
@@ -164,6 +164,7 @@ const ODrawerStub = {
"open",
"width",
"seamless",
+ "modal",
"portalTarget",
"title",
"subTitle",
@@ -311,6 +312,14 @@ describe("ServiceGraphNodeSidePanel", () => {
});
});
+ describe("drawer behaviour", () => {
+ it("renders a non-modal drawer so the page behind stays interactive", () => {
+ wrapper = mountPanel({ selectedNode: baseNode });
+ const drawer = wrapper.findComponent(ODrawerStub);
+ expect(drawer.props("modal")).toBe(false);
+ });
+ });
+
// -------------------------------------------------------------------------
// Service name display
// -------------------------------------------------------------------------
diff --git a/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue
index 7a2a94a6c3..8c061a8c8e 100644
--- a/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue
+++ b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue
@@ -20,6 +20,7 @@ along with this program. If not, see .
:open="visible"
:width="60"
:seamless="true"
+ :modal="false"
:portal-target="containerEl"
:title="selectedNode?.name || selectedNode?.label || selectedNode?.id"
data-test="service-graph-side-panel"
diff --git a/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts b/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts
index c3dc661d9a..ba99937288 100644
--- a/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts
+++ b/web/src/plugins/traces/ServiceGraphSidePanel.spec.ts
@@ -181,6 +181,7 @@ const ODrawerStub = {
"open",
"width",
"seamless",
+ "modal",
"portalTarget",
"title",
"subTitle",
diff --git a/web/src/plugins/traces/ServicesCatalog.spec.ts b/web/src/plugins/traces/ServicesCatalog.spec.ts
index 9575e55d64..1d9aff94e7 100644
--- a/web/src/plugins/traces/ServicesCatalog.spec.ts
+++ b/web/src/plugins/traces/ServicesCatalog.spec.ts
@@ -1408,6 +1408,48 @@ describe("ServicesCatalog", () => {
expect(wrapper.vm.showSidePanel).toBe(false);
expect(wrapper.vm.selectedServiceRow).toBeNull();
});
+
+ it("should switch the open side panel to another row in place", async () => {
+ mockFetchQueryDataWithHttpStream.mockImplementation((_req: any, callbacks: any) => {
+ const hits = mockServices.slice(0, 2).map((s) => ({
+ service_name: s.service_name,
+ total_requests: s.total_requests,
+ error_count: s.error_count,
+ error_rate: s.error_rate,
+ avg_duration_ns: s.avg_duration_ns,
+ max_duration_ns: s.max_duration_ns,
+ p50_latency_ns: s.p50_latency_ns,
+ p95_latency_ns: s.p95_latency_ns,
+ p99_latency_ns: s.p99_latency_ns,
+ }));
+
+ if (callbacks?.data) {
+ callbacks.data(null, {
+ type: "search_response_hits",
+ content: { results: { hits } },
+ });
+ }
+ if (callbacks?.complete) {
+ callbacks.complete(null, {});
+ }
+ });
+
+ wrapper = mountServicesCatalog();
+ await flushPromises();
+
+ await wrapper.find('[data-test="services-catalog-row-name-api-gateway"]').trigger("click");
+ expect(wrapper.vm.showSidePanel).toBe(true);
+ expect(wrapper.vm.selectedServiceRow?.service_name).toBe("api-gateway");
+ const panelBefore = wrapper.findComponent('[data-test="services-catalog-node-side-panel"]');
+ expect(panelBefore.exists()).toBe(true);
+
+ await wrapper.find('[data-test="services-catalog-row-name-auth-service"]').trigger("click");
+ expect(wrapper.vm.showSidePanel).toBe(true);
+ expect(wrapper.vm.selectedServiceRow?.service_name).toBe("auth-service");
+ const panelAfter = wrapper.findComponent('[data-test="services-catalog-node-side-panel"]');
+ expect(panelAfter.vm).toBe(panelBefore.vm);
+ expect(panelAfter.props("selectedNode")?.name).toBe("auth-service");
+ });
});
// -----------------------------------------------------------------------