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
34 changes: 29 additions & 5 deletions framework/core/composables/usePopup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function usePopupInternal() {
export function usePopup<T extends Component = Component>(options?: MaybeRef<UsePopupProps<T>>): IUsePopup {
const { t } = useI18n({ useScope: "global" });
const popupInstance = usePopupInternal();
const closeFallbackTimers = new WeakMap<object, ReturnType<typeof setTimeout>>();
let rawPopup: (UsePopupProps & UsePopupInternal) | undefined;

if (options) {
Expand All @@ -87,7 +88,7 @@ export function usePopup<T extends Component = Component>(options?: MaybeRef<Use
return;
}

const popupInstanceInternal = usePopupInternal();
const popupInstanceInternal = popupInstance;
// Match by the unique instance id, not structural equality: each popup carries
// its own Symbol, so reference-by-id is the correct — and only safe — comparison
// when several popups are stacked.
Expand All @@ -107,11 +108,18 @@ export function usePopup<T extends Component = Component>(options?: MaybeRef<Use
return;
}

const alreadyMounted = popupInstance?.popups.some((instance) => instance.id === popup.id);

// Every path into the stack goes through here — `open()`, `showConfirmation()`,
// `showError()`, `showInfo()` — so this is the one place that reliably sees the
// control the user activated, before the popup takes focus.
const opener = document.activeElement;
popup.opener = opener instanceof HTMLElement && opener !== document.body ? markRaw(opener) : undefined;
if (!alreadyMounted) {
const opener = document.activeElement;
popup.opener = opener instanceof HTMLElement && opener !== document.body ? markRaw(opener) : undefined;
}

cancelCloseFallback(popup);
popup.closing = false;

destroy(popup);
popupInstance?.popups?.push(popup);
Expand All @@ -123,6 +131,7 @@ export function usePopup<T extends Component = Component>(options?: MaybeRef<Use
}

function removeInstance(instance: UsePopupProps & Partial<UsePopupInternal>) {
cancelCloseFallback(instance);
const index = popupInstance?.popups.indexOf(instance);
if (typeof index === "number" && index !== -1) {
popupInstance?.popups?.splice(index, 1);
Expand All @@ -131,6 +140,14 @@ export function usePopup<T extends Component = Component>(options?: MaybeRef<Use
instance.opener = undefined;
}

function cancelCloseFallback(instance: UsePopupProps & Partial<UsePopupInternal>) {
const timer = closeFallbackTimers.get(instance);
if (timer !== undefined) {
clearTimeout(timer);
closeFallbackTimers.delete(instance);
}
}

/**
* Returns focus to the control that opened the popup (WCAG 2.4.3 Focus Order).
*
Expand Down Expand Up @@ -184,7 +201,12 @@ export function usePopup<T extends Component = Component>(options?: MaybeRef<Use
}

instanceToClose.closing = true;
setTimeout(() => removeInstance(instanceToClose), CLOSE_TRANSITION_FALLBACK_MS);
const timer = setTimeout(() => {
if (closeFallbackTimers.get(instanceToClose) !== timer) return;
closeFallbackTimers.delete(instanceToClose);
removeInstance(instanceToClose);
}, CLOSE_TRANSITION_FALLBACK_MS);
closeFallbackTimers.set(instanceToClose, timer);
}

function showSimplePopup(
Expand Down Expand Up @@ -274,7 +296,9 @@ export function usePopup<T extends Component = Component>(options?: MaybeRef<Use

popup.close = () => close(popup);
popup.open = () => open(popup);
popup.finalize = () => removeInstance(popup);
popup.finalize = () => {
if (popup.closing) removeInstance(popup);
};

return popup;
}
Expand Down
82 changes: 82 additions & 0 deletions framework/core/composables/usePopup/usePopup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,88 @@ describe("usePopup", () => {
}
});

it("reopens the same popup instance as visible and keeps it mounted past the close fallback", async () => {
vi.useFakeTimers();
try {
const { result, popupPlugin } = mountWithPopup(() =>
usePopup({
component: FakePopup as any,
props: { title: "Test" },
emits: { onConfirm: () => {}, onClose: () => {} },
}),
);

await result.open();
result.close();
await result.open();

vi.advanceTimersByTime(400);

expect(popupPlugin.popups).toHaveLength(1);
expect(popupPlugin.popups[0].closing).toBe(false);
} finally {
vi.useRealTimers();
}
});

it("ignores a stale transition finalize after the popup reopens", async () => {
const trigger = document.createElement("button");
document.body.appendChild(trigger);
const focus = vi.spyOn(trigger, "focus");
try {
trigger.focus();
focus.mockClear();

const { result, popupPlugin } = mountWithPopup(() =>
usePopup({
component: FakePopup as any,
props: { title: "Test" },
emits: { onConfirm: () => {}, onClose: () => {} },
}),
);

await result.open();
const popup = popupPlugin.popups[0];
result.close();
await result.open();

popup.finalize();
await nextTick();

expect(popupPlugin.popups).toHaveLength(1);
expect(focus).not.toHaveBeenCalled();
} finally {
trigger.remove();
}
});

it("does not let the first close fallback finish a later close early", async () => {
vi.useFakeTimers();
try {
const { result, popupPlugin } = mountWithPopup(() =>
usePopup({
component: FakePopup as any,
props: { title: "Test" },
emits: { onConfirm: () => {}, onClose: () => {} },
}),
);

await result.open();
result.close();
vi.advanceTimersByTime(200);

await result.open();
result.close();
vi.advanceTimersByTime(200);
expect(popupPlugin.popups).toHaveLength(1);

vi.advanceTimersByTime(200);
expect(popupPlugin.popups).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});

// The path the browser actually takes: the popup's leave transition ends and
// the container calls finalize. It beats the fallback timer every time, so the
// restore has to live here — the first attempt at VCST-5632 restored focus only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AiAgentServiceKey } from "@framework/injection-keys";

const mountedWrappers: VueWrapper[] = [];

function mountPanel(isOpenValue: boolean, isExpandedValue = false) {
function mountPanel(isOpenValue: boolean, isExpandedValue = false, props: { inert?: boolean } = {}) {
const isOpen = ref(isOpenValue);
const isExpanded = ref(isExpandedValue);
const closePanel = vi.fn();
Expand All @@ -24,6 +24,7 @@ function mountPanel(isOpenValue: boolean, isExpandedValue = false) {
};

const wrapper = shallowMount(VcAiAgentPanel as any, {
props,
global: {
provide: {
[AiAgentServiceKey as unknown as symbol]: mockService,
Expand All @@ -32,7 +33,7 @@ function mountPanel(isOpenValue: boolean, isExpandedValue = false) {
});
mountedWrappers.push(wrapper);

return { closePanel, expandPanel, collapsePanel };
return { wrapper, closePanel, expandPanel, collapsePanel };
}

function dispatchModBackslash(): KeyboardEvent {
Expand Down Expand Up @@ -144,4 +145,10 @@ describe("VcAiAgentPanel - mod+\\ expand/collapse handling", () => {
expect(collapsePanel).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});

it("applies inert to the rendered panel when requested", () => {
const { wrapper } = mountPanel(true, false, { inert: true });

expect(wrapper.find(".vc-ai-agent-panel").attributes()).toHaveProperty("inert");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
'vc-ai-agent-panel--expanded': isExpanded,
}"
:style="panelStyle"
:inert="inert || undefined"
>
<VcAiAgentHeader
:title="config.title"
Expand Down Expand Up @@ -38,6 +39,10 @@ import { hotkey, matchesEvent, useKeyboardShortcuts } from "@core/composables/us

const { isMac } = useKeyboardShortcuts();

defineProps<{
inert?: boolean;
}>();

// Inject AI agent service
const aiAgentService = inject(AiAgentServiceKey) as IAiAgentServiceInternal | undefined;
const isEmbedded = inject(EmbeddedModeKey, false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function mountSidebar(
},
VcSidebar: {
name: "VcSidebar",
props: ["modelValue", "position", "closeButton"],
props: ["modelValue", "position", "closeButton", "inert"],
template: '<div class="vc-sidebar-stub"><slot /></div>',
},
},
Expand Down Expand Up @@ -154,4 +154,10 @@ describe("MenuSidebar", () => {

expect(wrapper.find(".vc-sidebar-stub").exists()).toBe(true);
});

it("passes inert to the mobile VcSidebar root", () => {
const wrapper = mountSidebar({ inert: true }, { isMobile: true, isDesktop: false });

expect(wrapper.findComponent({ name: "VcSidebar" }).props("inert")).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { VcSidebar } from "@ui/components/organisms/vc-sidebar";
const props = defineProps<{
isOpened: boolean;
expanded: MaybeRef<boolean>;
inert?: boolean;
}>();

const emit = defineEmits<{
Expand Down Expand Up @@ -104,6 +105,7 @@ const wrapperProps = computed<Record<string, unknown>>(() => {
modelValue: props.isOpened,
position: "left",
closeButton: false,
inert: props.inert,
"onUpdate:modelValue": (value: boolean) => {
if (!value) {
emit("update:is-opened", false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,11 @@ describe("MobileLayout", () => {

expect(tabButtons[0].classes()).toContain("mobile-layout__tab--active");
});

it("makes the slide-out MenuSidebar inert while a maximized blade covers mobile navigation", () => {
const wrapper = mountLayout({ inertNavigation: true });

expect(wrapper.find(".mobile-layout").attributes()).toHaveProperty("inert");
expect(wrapper.find(".stub-menu-sidebar").attributes()).toHaveProperty("inert");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<MenuSidebar
:is-opened="sidebar.isMenuOpen.value"
:expanded="true"
:inert="inertNavigation || undefined"
@update:is-opened="!$event && sidebar.closeMenu()"
>
<template #navmenu>
Expand Down
29 changes: 28 additions & 1 deletion framework/ui/components/organisms/vc-app/vc-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ vi.mock("@core/composables/useSidebarState", () => ({
provideSidebarState: () => mockSidebar,
}));

const mockBladeStack = {
blades: ref<{ id: string }[]>([]),
isMaximized: vi.fn<(id: string) => boolean>(() => false),
};

vi.mock("@ui/components/organisms/vc-app/_internal/app-bar/composables/useAppBarState", () => ({
provideAppBarState: vi.fn(),
}));
Expand Down Expand Up @@ -129,25 +134,29 @@ const VcLoadingStub = defineComponent({
});

function mountApp(propsOverride: Record<string, unknown> = {}, mountOptions: Record<string, unknown> = {}) {
const customGlobal = (mountOptions.global ?? {}) as Record<string, any>;
return mount(VcApp, {
...mountOptions,
props: {
isReady: false,
...propsOverride,
},
global: {
...customGlobal,
provide: {
[BladeRoutesKey as symbol]: [],
[ModulesLoadErrorKey as symbol]: ref(false),
[BladeStackKey as symbol]: { blades: ref([]) },
[BladeStackKey as symbol]: mockBladeStack,
[BladeMessagingKey as symbol]: { on: vi.fn(), off: vi.fn() },
[IsMobileKey as symbol]: isMobileRef,
[IsDesktopKey as symbol]: isDesktopRef,
aiAgentConfig: undefined,
aiAgentAddGlobalToolbarButton: true,
...(customGlobal.provide ?? {}),
},
components: {
VcLoading: VcLoadingStub,
...(customGlobal.components ?? {}),
},
},
});
Expand All @@ -164,6 +173,9 @@ describe("vc-app", () => {
mockIsAuthenticated.value = false;
isMobileRef.value = false;
isDesktopRef.value = true;
mockBladeStack.blades.value = [];
mockBladeStack.isMaximized.mockReset();
mockBladeStack.isMaximized.mockReturnValue(false);
});

it("shows loading state when app is not ready", () => {
Expand Down Expand Up @@ -257,6 +269,21 @@ describe("vc-app", () => {
expect(wrapper.find(".mock-popup-container").exists()).toBe(true);
});

it("makes the AI panel sibling inert while a blade is maximized", async () => {
mockIsAppReady.value = true;
mockIsAuthenticated.value = true;
mockBladeStack.blades.value = [{ id: "detail" }];
mockBladeStack.isMaximized.mockReturnValue(true);

const wrapper = mountApp(
{ isReady: true },
{ global: { provide: { aiAgentConfig: { url: "https://chat.example.com" } } } },
);
await nextTick();

expect(wrapper.find(".mock-ai-panel").attributes()).toHaveProperty("inert");
});

it("calls useShellBootstrap with correct options", () => {
mountApp({ isReady: true });
expect(mockBootstrapArgs).toHaveBeenCalledWith(
Expand Down
5 changes: 4 additions & 1 deletion framework/ui/components/organisms/vc-app/vc-app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@
>
<VcBladeNavigation v-if="hasBladeNavigation" />
<!-- AI Agent Panel (shown when plugin is installed) -->
<VcAiAgentPanel v-if="aiAgentConfig?.url" />
<VcAiAgentPanel
v-if="aiAgentConfig?.url"
:inert="hasMaximizedBlade || undefined"
/>
</main>
</slot>

Expand Down
10 changes: 10 additions & 0 deletions framework/ui/components/organisms/vc-sidebar/vc-sidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { nextTick } from "vue";
import VcSidebar from "@ui/components/organisms/vc-sidebar/vc-sidebar.vue";

describe("VcSidebar", () => {
it("applies inert to its teleported root when requested", () => {
const wrapper = mount(VcSidebar, {
props: { modelValue: true, inert: true },
global: { stubs: { teleport: true } },
});

expect(wrapper.find(".vc-sidebar").attributes()).toHaveProperty("inert");
wrapper.unmount();
});

it("closes by Escape and emits close reason", async () => {
const wrapper = mount(VcSidebar, {
props: {
Expand Down
Loading
Loading