From e36f7fa49029c50823986caf5f424c0e7910d68d Mon Sep 17 00:00:00 2001 From: Maksim Date: Mon, 4 May 2026 12:53:48 +0300 Subject: [PATCH 01/11] fix(vc-blade): keep close/expand controls usable during loading Fold loading-state rendering into BladeHeader and BladeToolbar instead of swapping in separate skeleton components. The header now renders skeleton placeholders only for icon/title/subtitle while keeping close, expand and collapse buttons operational, so a long-loading blade can always be dismissed. The toolbar renders inline skeleton buttons/widgets when loading and is omitted on mobile, matching previous behavior. Removes obsolete BladeHeaderSkeleton and BladeToolbarSkeleton components along with their tests; corresponding loading coverage is added to BladeHeader.test.ts and BladeToolbar.test.ts. --- .../vc-blade/_internal/BladeHeader.test.ts | 48 +++++++ .../vc-blade/_internal/BladeHeader.vue | 74 +++++++---- .../_internal/BladeHeaderSkeleton.test.ts | 37 ------ .../_internal/BladeHeaderSkeleton.vue | 52 -------- .../vc-blade/_internal/BladeToolbar.test.ts | 40 ++++++ .../vc-blade/_internal/BladeToolbar.vue | 96 +++++++++++--- .../_internal/BladeToolbarSkeleton.test.ts | 51 -------- .../_internal/BladeToolbarSkeleton.vue | 77 ------------ .../organisms/vc-blade/vc-blade.a11y.test.ts | 2 - .../organisms/vc-blade/vc-blade.docs.md | 4 + .../organisms/vc-blade/vc-blade.vue | 117 ++++++++---------- 11 files changed, 278 insertions(+), 320 deletions(-) delete mode 100644 framework/ui/components/organisms/vc-blade/_internal/BladeHeaderSkeleton.test.ts delete mode 100644 framework/ui/components/organisms/vc-blade/_internal/BladeHeaderSkeleton.vue delete mode 100644 framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.test.ts delete mode 100644 framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.vue diff --git a/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.test.ts b/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.test.ts index 2a7c56ff2..7ff75b706 100644 --- a/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.test.ts +++ b/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.test.ts @@ -228,4 +228,52 @@ describe("BladeHeader", () => { const wrapper = factory({ title: "Test", titleId: "blade-title-42" }); expect(wrapper.find(".vc-blade-header__title").attributes("id")).toBe("blade-title-42"); }); + + describe("loading state", () => { + it("hides title/subtitle text when loading=true", () => { + const wrapper = factory({ title: "My Blade", subtitle: "Sub", loading: true }); + expect(wrapper.find(".vc-blade-header__title").exists()).toBe(false); + expect(wrapper.find(".vc-blade-header__subtitle").exists()).toBe(false); + }); + + it("renders skeleton placeholders inside content when loading=true", () => { + const wrapper = factory({ loading: true }); + expect(wrapper.find(".vc-blade-header__content--loading").exists()).toBe(true); + }); + + it("keeps close button operational when loading=true", async () => { + const wrapper = factory({ closable: true, loading: true }, { maximized: false }); + const buttons = wrapper.findAll(".vc-blade-header__button"); + // expand + close + expect(buttons.length).toBe(2); + await buttons[buttons.length - 1].trigger("click"); + expect(wrapper.emitted("close")).toBeTruthy(); + }); + + it("hides modified status dot when loading=true", () => { + const wrapper = factory({ modified: true, loading: true }); + expect(wrapper.find(".vc-blade-header__status-edited").exists()).toBe(false); + }); + + it("hides actions slot when loading=true", () => { + const wrapper = mount(BladeHeader, { + props: { title: "Test", loading: true }, + slots: { actions: '' }, + global: { + provide: { + [BladeDescriptorKey as symbol]: computed(() => ({ + id: "blade-1", + name: "TestBlade", + visible: true, + })), + [IsMobileKey as symbol]: ref(false), + [IsDesktopKey as symbol]: ref(true), + }, + stubs: { VcIcon: true, teleport: true }, + mocks: { $t: (k: string) => k }, + }, + }); + expect(wrapper.find(".vc-blade-header__actions").exists()).toBe(false); + }); + }); }); diff --git a/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.vue b/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.vue index 6371f844f..a499f6a76 100644 --- a/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.vue +++ b/framework/ui/components/organisms/vc-blade/_internal/BladeHeader.vue @@ -9,7 +9,7 @@
+ +
+
-
+
+ +
+ +
+
+
@@ -73,27 +103,24 @@
-
+ +
+
+ +
+
@@ -106,6 +133,7 @@ - - diff --git a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.test.ts b/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.test.ts index dacec07e8..cff0d1ee0 100644 --- a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.test.ts +++ b/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.test.ts @@ -114,4 +114,44 @@ describe("BladeToolbar", () => { const desktop = wrapper.findComponent({ name: "ToolbarDesktop" }); expect(desktop.exists()).toBe(true); }); + + describe("loading state", () => { + it("renders skeleton placeholders instead of toolbar buttons when loading=true", () => { + const wrapper = factory({ loading: true }); + expect(wrapper.find(".vc-blade-toolbar-skeleton__buttons").exists()).toBe(true); + expect(wrapper.find(".toolbar-desktop-stub").exists()).toBe(false); + expect(wrapper.find(".toolbar-mobile-stub").exists()).toBe(false); + }); + + it("hides widgets-container slot when loading=true", () => { + const wrapper = factory( + { loading: true }, + { "widgets-container": '
Widgets
' }, + ); + expect(wrapper.find(".widget-container-test").exists()).toBe(false); + expect(wrapper.find(".vc-blade-toolbar-skeleton__widgets").exists()).toBe(true); + }); + + it("applies loading class on toolbar root", () => { + const wrapper = factory({ loading: true }); + expect(wrapper.find(".vc-blade-toolbar--loading").exists()).toBe(true); + }); + + it("does not render toolbar at all on mobile when loading=true", () => { + const wrapper = mount(BladeToolbar, { + props: { items: [], loading: true }, + global: { + stubs: { + ToolbarMobile: { template: "
" }, + ToolbarDesktop: { template: "
" }, + }, + provide: { + [IsMobileKey as symbol]: ref(true), + [IsDesktopKey as symbol]: ref(false), + }, + }, + }); + expect(wrapper.find(".vc-blade-toolbar").exists()).toBe(false); + }); + }); }); diff --git a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.vue b/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.vue index 322556be1..2a229e808 100644 --- a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.vue +++ b/framework/ui/components/organisms/vc-blade/_internal/BladeToolbar.vue @@ -1,27 +1,71 @@ @@ -30,12 +74,15 @@ import { toRef } from "vue"; import { useResponsive } from "@framework/core/composables/useResponsive"; import type { IBladeToolbar } from "@core/types"; +import { VcSkeleton } from "@ui/components/atoms/vc-skeleton"; import ToolbarMobile from "@ui/components/organisms/vc-blade/_internal/toolbar/ToolbarMobile.vue"; import ToolbarDesktop from "@ui/components/organisms/vc-blade/_internal/toolbar/ToolbarDesktop.vue"; import { useToolbarRegistration } from "@ui/components/organisms/vc-blade/_internal/composables/useToolbarRegistration"; export interface Props { items?: IBladeToolbar[]; + /** When true, renders skeleton placeholders instead of toolbar buttons/widgets */ + loading?: boolean; } const props = withDefaults(defineProps(), { @@ -71,4 +118,23 @@ const { visibleItems } = useToolbarRegistration(toRef(props, "items")); @apply tw-fixed tw-bottom-0 tw-right-0 tw-z-[var(--z-layout-sidebar)] tw-p-4 tw-bg-transparent tw-border-0 tw-w-auto; } } + +.vc-blade-toolbar-skeleton { + &__buttons { + @apply tw-flex tw-items-center tw-px-4 tw-flex-1 tw-min-w-0; + } + + &__button { + @apply tw-shrink-0 tw-px-3 tw-inline-flex tw-flex-col tw-items-center tw-gap-1; + } + + &__widgets { + @apply tw-flex tw-flex-row tw-gap-1 tw-shrink-0; + background-color: var(--blade-toolbar-widgets-bg-color); + } + + &__widget { + @apply tw-shrink-0 tw-px-2 tw-py-1.5 tw-flex tw-flex-col tw-items-center tw-justify-center; + } +} diff --git a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.test.ts b/framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.test.ts deleted file mode 100644 index 4467f1b5a..000000000 --- a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { mount } from "@vue/test-utils"; -import { defineComponent, h, ref } from "vue"; -import { IsMobileKey } from "@framework/injection-keys"; -import BladeToolbarSkeleton from "./BladeToolbarSkeleton.vue"; - -vi.mock("@ui/components/atoms/vc-skeleton", () => ({ - VcSkeleton: defineComponent({ - name: "VcSkeleton", - props: ["variant", "width", "height"], - setup() { - return () => h("div", { class: "mock-skeleton" }); - }, - }), -})); - -describe("BladeToolbarSkeleton.vue", () => { - it("renders the root element with toolbar class", () => { - const wrapper = mount(BladeToolbarSkeleton, { - global: { - provide: { - [IsMobileKey as symbol]: ref(false), - }, - }, - }); - expect(wrapper.find(".vc-blade-toolbar-skeleton").exists()).toBe(true); - }); - - it("renders 3 skeleton button placeholders", () => { - const wrapper = mount(BladeToolbarSkeleton, { - global: { - provide: { - [IsMobileKey as symbol]: ref(false), - }, - }, - }); - const buttonPlaceholders = wrapper.findAll(".vc-blade-toolbar-skeleton__button"); - expect(buttonPlaceholders.length).toBe(3); - }); - - it("does not render when $isMobile is true", () => { - const wrapper = mount(BladeToolbarSkeleton, { - global: { - provide: { - [IsMobileKey as symbol]: ref(true), - }, - }, - }); - expect(wrapper.find(".vc-blade-toolbar-skeleton").exists()).toBe(false); - }); -}); diff --git a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.vue b/framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.vue deleted file mode 100644 index 9f0327d84..000000000 --- a/framework/ui/components/organisms/vc-blade/_internal/BladeToolbarSkeleton.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - - - diff --git a/framework/ui/components/organisms/vc-blade/vc-blade.a11y.test.ts b/framework/ui/components/organisms/vc-blade/vc-blade.a11y.test.ts index b97008a5f..0e0a375cb 100644 --- a/framework/ui/components/organisms/vc-blade/vc-blade.a11y.test.ts +++ b/framework/ui/components/organisms/vc-blade/vc-blade.a11y.test.ts @@ -111,9 +111,7 @@ describe("VcBlade a11y", () => { template: '

{{ title }}

', }, - BladeHeaderSkeleton: true, BladeToolbar: true, - BladeToolbarSkeleton: true, BladeContentSkeleton: true, BladeStatusBanners: true, WidgetContainer: true, diff --git a/framework/ui/components/organisms/vc-blade/vc-blade.docs.md b/framework/ui/components/organisms/vc-blade/vc-blade.docs.md index 102eed633..a4b3f9b6a 100644 --- a/framework/ui/components/organisms/vc-blade/vc-blade.docs.md +++ b/framework/ui/components/organisms/vc-blade/vc-blade.docs.md @@ -384,6 +384,10 @@ interface IBladeToolbar { > **Tip:** Content is hidden (not unmounted) while loading, so `onMounted` hooks still fire. +> **Note:** During `loading=true`, the header keeps its **close/expand controls operational** — only the icon, title, and subtitle are replaced with skeleton placeholders. Users can always close or maximize a loading blade. The `actions` slot, breadcrumbs, and modified-status dot are hidden until loading finishes. + +> **Note:** The toolbar zone uses an in-place skeleton: `BladeToolbar` itself renders skeleton buttons/widgets when its `loading` prop is true (no separate skeleton component is mounted). On mobile, the toolbar is omitted entirely while loading. + Merge multiple loading sources with `useLoading`: ```ts diff --git a/framework/ui/components/organisms/vc-blade/vc-blade.vue b/framework/ui/components/organisms/vc-blade/vc-blade.vue index 1a6f7351f..fbb1130e7 100644 --- a/framework/ui/components/organisms/vc-blade/vc-blade.vue +++ b/framework/ui/components/organisms/vc-blade/vc-blade.vue @@ -15,64 +15,59 @@ :aria-labelledby="props.title && !showSkeleton ? bladeTitleId : undefined" :aria-label="!props.title || showSkeleton ? $t('COMPONENTS.ORGANISMS.VC_BLADE.PANEL') : undefined" > - - + + + -