Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions framework/core/utilities/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,24 @@ import { nextTick } from "vue";
*
* `getTarget` is a callback because the target often does not exist yet at call time.
*/
/**
* Focus is loose when nothing meaningful holds it.
*
* An element inside an `inert` subtree counts: the browser is about to blur it and
* it is unreachable either way, but it can still read as `activeElement` at this
* point — maximizing a blade inerts the sidebar, and a repair that only asked
* "is this `<body>`?" saw the sidebar control still focused, declined, and let the
* browser drop focus a moment later (VCST-5859). Asking about inert is a fact, not
* a guess about when the blur lands.
*/
function focusIsLoose(active: Element | null): boolean {
if (!active || active === document.body || active === document.documentElement) return true;
return Boolean(active.closest("[inert]"));
}

export function focusIfLoose(getTarget: () => HTMLElement | null | undefined): void {
nextTick(() => {
const active = document.activeElement;
const focusIsLoose = !active || active === document.body || active === document.documentElement;
if (!focusIsLoose) return;
if (!focusIsLoose(document.activeElement)) return;

const target = getTarget();
if (!target?.isConnected || typeof target.focus !== "function") return;
Expand Down
147 changes: 147 additions & 0 deletions framework/ui/components/organisms/vc-blade/vc-blade.focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import { computed, defineComponent, h, nextTick, provide, ref } from "vue";
import { mount } from "@vue/test-utils";
import VcBlade from "@ui/components/organisms/vc-blade/vc-blade.vue";
import { BladeBackButtonKey, ToolbarServiceKey, WidgetServiceKey } from "@framework/injection-keys";
import {
BladeStackKey,
BladeMessagingKey,
BladeDescriptorKey,
BladeRenderingStateKey,
} from "@core/blade-navigation/types";
import type { BladeDescriptor } from "@core/blade-navigation/types";
import { createToolbarService } from "@core/services/toolbar-service";
import { createWidgetService } from "@core/services/widget-service";

vi.mock("@shell/_internal/blade-navigation/plugin-v2", () => ({
bladeStackInstance: {
blades: ref([]),
workspace: ref(undefined),
activeBlade: ref(null),
openBlade: vi.fn(),
closeBlade: vi.fn(),
replaceCurrentBlade: vi.fn(),
registerBeforeClose: vi.fn(),
setBladeError: vi.fn(),
clearBladeError: vi.fn(),
setBladeTitle: vi.fn(),
},
bladeMessagingInstance: { callParent: vi.fn(), exposeToChildren: vi.fn() },
bladeRegistryInstance: undefined,
bladeNavigationInstance: {
router: { currentRoute: ref({ path: "/", params: {}, query: {} }), push: vi.fn(), replace: vi.fn() },
},
}));
vi.mock("@core/blade-navigation/utils/urlSync", () => ({
buildUrlFromStack: vi.fn().mockReturnValue("/"),
createUrlSync: vi.fn().mockReturnValue({ syncUrlPush: vi.fn(), syncUrlReplace: vi.fn() }),
getTenantPrefix: vi.fn().mockReturnValue(""),
}));

const maximized = ref(false);

function mountBlade() {
const Wrapper = defineComponent({
setup() {
provide(BladeBackButtonKey, null as never);
provide(ToolbarServiceKey, createToolbarService());
provide(WidgetServiceKey, createWidgetService());
provide(BladeRenderingStateKey, computed(() => ({ maximized: maximized.value })) as never);
provide(BladeStackKey, {
blades: ref([]),
activeBlade: ref(null),
openBlade: async () => {},
closeBlade: async () => {},
closeSelf: async () => {},
closeChildren: async () => {},
replaceBlade: async () => {},
setBladeTitle: vi.fn(),
} as never);
provide(BladeMessagingKey, { callParent: async () => undefined, onParentCall: () => () => {} } as never);
provide(
BladeDescriptorKey,
computed<BladeDescriptor>(() => ({ id: "b", name: "TestBlade", visible: true }) as never),
);

return () => h(VcBlade as never, { title: "Order" }, { default: () => h("div", "body") });
},
});

return mount(Wrapper, {
attachTo: document.body,
global: { mocks: { $t: (k: string) => k }, stubs: { VcIcon: true, BladeHeader: true, BladeToolbar: true } },
});
}

/**
* Maximizing makes the regions the blade covers inert, and the browser drops focus
* from a node that becomes inert. jsdom does not implement that rule — the same trap
* as `disabled` — so these tests blur the origin themselves and assert what the blade
* is responsible for: repairing focus that is already loose. The browser side is
* covered by the live A/B on the ticket.
*/
describe("VcBlade focus across maximize", () => {
it("takes focus when maximizing left it nowhere", async () => {
const navButton = document.createElement("button");
document.body.appendChild(navButton);
navButton.focus();
const w = mountBlade();
try {
// Let the mount-time repair run and decline first — focus is still held here.
// Without this the test passes on that repair rather than on the maximize.
await nextTick();
await nextTick();
expect(document.activeElement).toBe(navButton);

// What `inert` does to the sidebar control the user was on.
navButton.blur();
maximized.value = true;
await nextTick();
await nextTick();

expect(document.activeElement).toBe(w.find(".vc-blade").element);
} finally {
maximized.value = false;
w.unmount();
navButton.remove();
}
});

it("recovers on restore too, so the user is not stranded", async () => {
const w = mountBlade();
try {
maximized.value = true;
await nextTick();
(document.activeElement as HTMLElement | null)?.blur?.();

maximized.value = false;
await nextTick();
await nextTick();

expect(document.activeElement).toBe(w.find(".vc-blade").element);
} finally {
maximized.value = false;
w.unmount();
}
});

// The header hands focus between its own two expand controls. This must not
// compete with that, nor yank focus from a user who is somewhere live.
it("leaves focus alone when something still holds it", async () => {
const elsewhere = document.createElement("button");
document.body.appendChild(elsewhere);
const w = mountBlade();
try {
elsewhere.focus();
maximized.value = true;
await nextTick();
await nextTick();

expect(document.activeElement).toBe(elsewhere);
} finally {
maximized.value = false;
w.unmount();
elsewhere.remove();
}
});
});
14 changes: 14 additions & 0 deletions framework/ui/components/organisms/vc-blade/vc-blade.vue
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,20 @@ watch(showSkeleton, (skeleton) => {
// This only repairs the case where focus was dropped on `<body>` — which happened
// whenever the control that opened the blade was re-rendered away.
onMounted(() => focusIfLoose(() => bladeRef.value));

// Maximizing makes everything the blade covers inert, and a node that becomes inert
// loses focus. Nobody owned repairing that: the header hands focus between its own
// two expand controls and declines otherwise — correctly, it is not a general rescue
// — so focus that started anywhere else, the sidebar or the app bar, died with the
// region it was in. Restoring did not bring it back either, because nothing was
// looking (VCST-5859).
//
// Repair, not seizure: a user whose focus is still somewhere live keeps it, which is
// what leaves the header's handoff in charge of its own case.
watch(
() => renderingState?.value?.maximized,
() => focusIfLoose(() => bladeRef.value),
);
</script>

<style lang="scss">
Expand Down
Loading