Skip to content
Open
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
35 changes: 34 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 @@ -128,8 +128,9 @@ const VcLoadingStub = defineComponent({
template: "<div class='mock-vc-loading' v-if='active'>Loading...</div>",
});

function mountApp(propsOverride: Record<string, unknown> = {}) {
function mountApp(propsOverride: Record<string, unknown> = {}, mountOptions: Record<string, unknown> = {}) {
return mount(VcApp, {
...mountOptions,
props: {
isReady: false,
...propsOverride,
Expand Down Expand Up @@ -201,6 +202,38 @@ describe("vc-app", () => {
expect(wrapper.find(".mock-blade-nav").exists()).toBe(true);
});

// Sign-in navigates before this component exists, and the workspace mounts later
// still, once the app reports ready — so the route watcher never sees it and focus
// is left on <body> (WCAG 2.4.3). The workspace element appearing is the signal.
it("moves focus to the workspace once it appears", async () => {
mockIsAppReady.value = true;
mockIsAuthenticated.value = true;
const wrapper = mountApp({ isReady: true }, { attachTo: document.body });

await nextTick();
await nextTick();

expect(document.activeElement).toBe(wrapper.find("main.vc-app__workspace").element);
wrapper.unmount();
});

it("leaves focus alone when something already holds it", async () => {
const opener = document.createElement("button");
document.body.appendChild(opener);
opener.focus();

mockIsAppReady.value = true;
mockIsAuthenticated.value = true;
const wrapper = mountApp({ isReady: true }, { attachTo: document.body });

await nextTick();
await nextTick();

expect(document.activeElement).toBe(opener);
wrapper.unmount();
opener.remove();
});

it("renders the workspace as a <main> landmark when authenticated", async () => {
mockIsAppReady.value = true;
mockIsAuthenticated.value = true;
Expand Down
12 changes: 12 additions & 0 deletions framework/ui/components/organisms/vc-app/vc-app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ watch(
() => focusIfLoose(() => workspaceRef.value),
);

// Sign-in is not a route change this component can observe: `/login` and the shell
// are sibling routes, so the watcher above is created only after that navigation
// has finished. The workspace then mounts later still, when the app reports ready,
// without any further route change. Its element appearing is the signal.
watch(
workspaceRef,
(workspace) => {
if (workspace) focusIfLoose(() => workspace);
},
{ flush: "post" },
);

// App root element ref (for scoped Teleport targets)
const appRootRef = ref<HTMLElement>();
provide(AppRootElementKey, appRootRef);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { ref, nextTick } from "vue";
import { useBladeSkeleton } from "./useBladeSkeleton";

describe("useBladeSkeleton", () => {
it("shows the skeleton while the blade has nothing to display yet", () => {
const loading = ref(true);
expect(useBladeSkeleton(() => loading.value).value).toBe(true);
});

it("hides the skeleton once loading finishes", async () => {
const loading = ref(true);
const skeleton = useBladeSkeleton(() => loading.value);

loading.value = false;
await nextTick();

expect(skeleton.value).toBe(false);
});

// The point of the whole thing: a save re-raises `loading`, and replacing the
// controls then would unmount whatever the user has focused (WCAG 2.4.3).
it("does not return to the skeleton when loading is raised again after content has shown", async () => {
const loading = ref(true);
const skeleton = useBladeSkeleton(() => loading.value);

loading.value = false;
await nextTick();
loading.value = true;
await nextTick();

expect(skeleton.value).toBe(false);
});

it("never shows the skeleton for a blade that starts with its content ready", async () => {
const loading = ref(false);
const skeleton = useBladeSkeleton(() => loading.value);

expect(skeleton.value).toBe(false);

loading.value = true;
await nextTick();

expect(skeleton.value).toBe(false);
});
});
28 changes: 28 additions & 0 deletions framework/ui/components/organisms/vc-blade/useBladeSkeleton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { computed, ref, watch, type ComputedRef } from "vue";

/**
* Decides when a blade may replace its content with skeletons.
*
* A skeleton stands in for content that has not rendered yet. Once the blade has
* shown its real content, `loading` means something is in flight — a save, a
* delete — and swapping the controls out then unmounts whatever the user has
* focused, dropping focus to `<body>` (WCAG 2.4.3 Focus Order). It also takes
* the field they were typing in out from under them.
*
* So the skeleton is available exactly once, before the first render of real
* content. After that `loading` is reported through `aria-busy` and each
* control's own pending state instead.
*/
export function useBladeSkeleton(isLoading: () => boolean): ComputedRef<boolean> {
const hasRenderedContent = ref(false);

watch(
isLoading,
(loading) => {
if (!loading) hasRenderedContent.value = true;
},
{ immediate: true },
);

return computed(() => isLoading() && !hasRenderedContent.value);
}
6 changes: 5 additions & 1 deletion framework/ui/components/organisms/vc-blade/vc-blade.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
]"
:style="{ width: typeof width === 'number' ? `${width}px` : width }"
:aria-busy="props.loading || undefined"
:aria-labelledby="props.title && !showSkeleton ? bladeTitleId : undefined"
:aria-label="!props.title || showSkeleton ? $t('COMPONENTS.ORGANISMS.VC_BLADE.PANEL') : undefined"
>
Expand Down Expand Up @@ -125,6 +126,7 @@ import BladeStatusBanners from "@ui/components/organisms/vc-blade/_internal/Blad
import { VcButton } from "@ui/components/atoms/vc-button";
import { VcBreadcrumbs } from "@ui/components/molecules/vc-breadcrumbs";
import { BladeBackButtonKey, BladeFormKey, BladeLoadingKey } from "@framework/injection-keys";
import { useBladeSkeleton } from "@ui/components/organisms/vc-blade/useBladeSkeleton";
import WidgetContainer from "@ui/components/organisms/vc-blade/_internal/widgets/WidgetContainer.vue";
import { useBlade } from "../../../../core/composables";
import { useResponsive } from "@framework/core/composables/useResponsive";
Expand Down Expand Up @@ -184,7 +186,9 @@ const effectiveModified = computed(() => {
const instanceUid = getCurrentInstance()?.uid ?? 0;
const bladeTitleId = `blade-title-${instanceUid}`;

const showSkeleton = computed(() => Boolean(props.loading));
// Only before the first render of real content — see useBladeSkeleton. A later
// `loading` (a save, a delete) leaves the controls mounted so focus survives.
const showSkeleton = useBladeSkeleton(() => Boolean(props.loading));

provide(BladeLoadingKey, showSkeleton);

Expand Down
Loading