@vc-shell/vc-app-skill 2.0.0-alpha.31 → 2.0.0-alpha.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ # [2.0.0-alpha.32](https://github.com/VirtoCommerce/vc-shell/compare/v2.0.0-alpha.31...v2.0.0-alpha.32) (2026-04-02)
2
+
3
+ **Note:** Version bump only for package @vc-shell/vc-app-skill
4
+
1
5
  # [2.0.0-alpha.31](https://github.com/VirtoCommerce/vc-shell/compare/v2.0.0-alpha.30...v2.0.0-alpha.31) (2026-04-01)
2
6
 
3
7
  **Note:** Version bump only for package @vc-shell/vc-app-skill
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vc-shell/vc-app-skill",
3
- "version": "2.0.0-alpha.31",
3
+ "version": "2.0.0-alpha.32",
4
4
  "description": "AI coding skill for scaffolding and generating VirtoCommerce Shell applications. Works with Claude Code, OpenCode, Gemini, Codex, Cursor.",
5
5
  "bin": "./bin/install.cjs",
6
6
  "files": [
package/runtime/VERSION CHANGED
@@ -1 +1 @@
1
- 2.0.0-alpha.31
1
+ 2.0.0-alpha.32
@@ -1 +1 @@
1
- Synced from framework at commit 2f71da38b on 2026-04-01T16:03:16.012Z
1
+ Synced from framework at commit fc29b1e1b on 2026-04-02T05:22:47.427Z
@@ -0,0 +1,111 @@
1
+ # useBladeContext (defineBladeContext / injectBladeContext)
2
+
3
+ Exposes blade-level data to descendant widgets, extensions, and nested components via Vue's provide/inject mechanism. This pair of functions eliminates the need for prop drilling when child widgets or extension points need access to the parent blade's entity data, loading flags, or other shared state.
4
+
5
+ The pattern follows a "define once, inject anywhere" approach: the blade component calls `defineBladeContext` during setup, and any descendant (no matter how deeply nested) can call `injectBladeContext` to read that data reactively.
6
+
7
+ ## When to Use
8
+
9
+ - Share blade state (current entity, loading flags, disabled state) with child widgets without prop drilling
10
+ - Access parent blade data from an extension or widget component
11
+ - Expose selective fields to widgets (e.g., only the entity ID) via a computed getter
12
+ - When NOT to use: for cross-blade communication between sibling blades (use `useBlade` / blade messaging instead)
13
+
14
+ ## Basic Usage
15
+
16
+ ```typescript
17
+ // In a blade's <script setup>
18
+ import { defineBladeContext, injectBladeContext } from '@vc-shell/framework';
19
+
20
+ // Provide context — refs/computeds are auto-unwrapped for consumers
21
+ defineBladeContext({ item, disabled, loading });
22
+
23
+ // Or with a computed for selective exposure
24
+ defineBladeContext(computed(() => ({ id: item.value?.id })));
25
+ ```
26
+
27
+ ```typescript
28
+ // In a widget or nested component
29
+ import { injectBladeContext } from '@vc-shell/framework';
30
+
31
+ const ctx = injectBladeContext();
32
+ // Refs are already unwrapped — access values directly, no .value needed
33
+ const entityId = computed(() => ctx.value.id as string);
34
+ const item = computed(() => ctx.value.item as { id: string; name: string });
35
+ ```
36
+
37
+ ## API
38
+
39
+ ### defineBladeContext
40
+
41
+ | Parameter | Type | Required | Description |
42
+ |---|---|---|---|
43
+ | `data` | `MaybeRefOrGetter<Record<string, unknown>>` | Yes | Plain object, ref, or getter to expose |
44
+
45
+ Returns `void`. Must be called in the blade's `<script setup>`.
46
+
47
+ ### injectBladeContext
48
+
49
+ Takes no parameters. Returns `ComputedRef<Record<string, unknown>>`.
50
+
51
+ Throws `InjectionError` if no ancestor blade has called `defineBladeContext`.
52
+
53
+ ## Recipe: Widget Consuming Blade Context
54
+
55
+ A typical pattern is a sidebar widget that needs to load related data based on the current blade entity. The widget does not receive any props from the blade -- it reads the entity ID from the blade context:
56
+
57
+ ```vue
58
+ <script setup lang="ts">
59
+ // widgets/RelatedOrdersWidget.vue
60
+ import { computed, watch } from "vue";
61
+ import { injectBladeContext } from "@vc-shell/framework";
62
+
63
+ const ctx = injectBladeContext();
64
+ const customerId = computed(() => ctx.value.id as string | undefined);
65
+
66
+ // Reload orders whenever the customer changes
67
+ watch(customerId, async (id) => {
68
+ if (id) {
69
+ await loadOrders(id);
70
+ }
71
+ });
72
+ </script>
73
+ ```
74
+
75
+ ```vue
76
+ <script setup lang="ts">
77
+ // blades/CustomerDetailBlade.vue
78
+ import { ref, computed } from "vue";
79
+ import { defineBladeContext } from "@vc-shell/framework";
80
+
81
+ const customer = ref({ id: "cust-1", name: "Acme Corp" });
82
+ const loading = ref(false);
83
+
84
+ // Expose the customer data to all descendant widgets
85
+ defineBladeContext(computed(() => ({
86
+ id: customer.value?.id,
87
+ name: customer.value?.name,
88
+ loading: loading.value,
89
+ })));
90
+ </script>
91
+ ```
92
+
93
+ ## Details
94
+
95
+ - **Automatic ref unwrapping**: `defineBladeContext` shallow-unwraps all ref/computed values in the provided object. Consumers get plain values directly (`ctx.value.item` instead of `ctx.value.item.value`). This works reactively — when the source ref changes, the context updates automatically.
96
+ - **Reactivity**: The provided context is always wrapped in a `computed`, so consumers receive a `ComputedRef` regardless of whether the provider passed a plain object, a ref, or a getter. Changes propagate automatically.
97
+ - **Injection key**: Uses `BladeContextKey` from `framework/injection-keys.ts`. This is a framework-level Symbol, so there is no risk of key collision with application code.
98
+ - **Error handling**: `injectBladeContext` throws an `InjectionError` with a descriptive message if called outside a blade component tree. This fails fast during development rather than silently returning `undefined`.
99
+ - **Scope**: The context is scoped to the Vue component subtree. Each blade in the stack has its own context, so nested blades do not leak data upward or sideways.
100
+
101
+ ## Tips
102
+
103
+ - Prefer exposing a computed getter rather than the full reactive object when only a subset of fields is needed. This minimizes unnecessary re-renders in consuming widgets.
104
+ - The context value is untyped (`Record<string, unknown>`). Use type assertions or a typed wrapper in your module if you need type safety (e.g., `ctx.value.id as string`).
105
+ - If a blade does not call `defineBladeContext`, any descendant calling `injectBladeContext` will throw. Make sure all blades that host widgets define their context.
106
+
107
+ ## Related
108
+
109
+ - `BladeContextKey` in `framework/injection-keys.ts`
110
+ - `useBladeWidgets` -- widgets that consume blade context
111
+ - `useBladeStack` -- manages the blade navigation stack
@@ -0,0 +1,113 @@
1
+ # useBladeForm
2
+
3
+ Unified form state management for blades. Replaces manual combination of `useForm` + `useModificationTracker` + `useBeforeUnload` + `onBeforeClose` with a single composable.
4
+
5
+ ## Import
6
+
7
+ ```ts
8
+ import { useBladeForm } from "@vc-shell/framework";
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```ts
14
+ const { item, loadItem, saveItem } = useItemData();
15
+
16
+ const form = useBladeForm({ data: item });
17
+
18
+ onMounted(async () => {
19
+ await loadItem({ id: param.value });
20
+ form.setBaseline(); // snapshot current data as pristine
21
+ });
22
+
23
+ // Toolbar
24
+ const toolbar = ref<IBladeToolbar[]>([
25
+ {
26
+ id: "save",
27
+ title: "Save",
28
+ icon: "lucide-save",
29
+ disabled: computed(() => !form.canSave.value),
30
+ async clickHandler() {
31
+ await saveItem(item.value);
32
+ form.setBaseline(); // snapshot after save
33
+ callParent("reload");
34
+ },
35
+ },
36
+ ]);
37
+ ```
38
+
39
+ ## API
40
+
41
+ ### Options
42
+
43
+ | Option | Type | Default | Description |
44
+ |--------|------|---------|-------------|
45
+ | `data` | `Ref<T>` | required | Reactive data object for the form |
46
+ | `canSaveOverride` | `ComputedRef<boolean>` | — | Additional condition for canSave |
47
+ | `autoBeforeClose` | `boolean \| ComputedRef<boolean>` | `true` | Close guard behavior |
48
+ | `autoBeforeUnload` | `boolean` | `true` | Browser tab close guard |
49
+ | `closeConfirmMessage` | `MaybeRefOrGetter<string>` | — | Custom unsaved changes message |
50
+ | `onRevert` | `() => void \| Promise<void>` | — | Custom revert handler |
51
+
52
+ ### Returns
53
+
54
+ | Property | Type | Description |
55
+ |----------|------|-------------|
56
+ | `setBaseline()` | `() => void` | Snapshot current data as pristine. Call after load and after save |
57
+ | `revert()` | `() => void \| Promise<void>` | Revert data to pristine (or call onRevert) |
58
+ | `canSave` | `ComputedRef<boolean>` | `isReady && valid && modified && canSaveOverride` |
59
+ | `isModified` | `ComputedRef<boolean>` | Data differs from pristine (false until setBaseline) |
60
+ | `isReady` | `ComputedRef<boolean>` | setBaseline() called at least once |
61
+ | `formMeta` | vee-validate meta | Form validation state |
62
+ | `setFieldError` | function | Set field error programmatically |
63
+ | `errorBag` | Ref | All field errors |
64
+
65
+ ## Lifecycle
66
+
67
+ ```
68
+ Mount → Load data → setBaseline() → User edits → Save → setBaseline()
69
+ └→ Cancel → revert()
70
+ ```
71
+
72
+ ## VcBlade Integration
73
+
74
+ `useBladeForm` auto-provides form state to `VcBlade` via inject. No need to pass `:modified` prop:
75
+
76
+ ```vue
77
+ <!-- Before -->
78
+ <VcBlade :modified="isModified" :toolbar-items="toolbar">
79
+
80
+ <!-- After -->
81
+ <VcBlade :toolbar-items="toolbar">
82
+ ```
83
+
84
+ ## Advanced: Readonly Blade
85
+
86
+ ```ts
87
+ const disabled = computed(() => !!param.value && !item.value?.canBeModified);
88
+
89
+ const form = useBladeForm({
90
+ data: item,
91
+ canSaveOverride: computed(() => !disabled.value),
92
+ autoBeforeClose: computed(() => !disabled.value), // no prompt when readonly
93
+ });
94
+ ```
95
+
96
+ ## Advanced: Custom Revert
97
+
98
+ ```ts
99
+ const form = useBladeForm({
100
+ data: item,
101
+ onRevert: () => loadItem({ id: param.value }), // reload from server
102
+ });
103
+ ```
104
+
105
+ ## Constraints
106
+
107
+ - **Must be called from component `setup()`** (or `<script setup>`). Do NOT call from shared data-composables.
108
+ - Validation rules stay in template (`<Field rules="...">`).
109
+ - `setBaseline()` must be called after data is loaded — before that, `canSave` and `isModified` are `false`.
110
+
111
+ ## Migration
112
+
113
+ See `MIGRATION_GUIDE.md` for step-by-step instructions on migrating existing modules.
@@ -0,0 +1,305 @@
1
+ # useBladeWidgets / useWidgetTrigger
2
+
3
+ Two composables for the widget system — one for the **blade side**, one for the **widget side**.
4
+
5
+ | Composable | Called from | Purpose |
6
+ |---|---|---|
7
+ | `useBladeWidgets` | Blade component | Register headless widgets + get `refresh()` / `refreshAll()` |
8
+ | `useWidgetTrigger` | External widget component | Register trigger callbacks (`onRefresh`, `onClick`) via provide/inject |
9
+
10
+ Headless widgets are defined as plain configuration objects with reactive refs for dynamic values like badge counts and loading states. External component-based widgets use `useWidgetTrigger` to register their refresh callbacks so the hosting blade can trigger them.
11
+
12
+ ## When to Use
13
+
14
+ - **`useBladeWidgets`**: Register sidebar widgets (counters, action buttons) for a blade without creating Vue components. Refresh widget data after blade operations (save, delete).
15
+ - **`useWidgetTrigger`**: Inside an external widget component (registered via `registerExternalWidget`) to register `onRefresh` / `onClick` callbacks. The blade can then call `refresh(widgetId)` or `refreshAll()` to trigger them.
16
+ - When NOT to use `useBladeWidgets`: for widgets that need their own template or complex UI (use `registerExternalWidget` + `useWidgetTrigger` instead).
17
+
18
+ ## Basic Usage
19
+
20
+ ```typescript
21
+ import { useBladeWidgets } from '@vc-shell/framework';
22
+
23
+ const { refreshAll } = useBladeWidgets([
24
+ {
25
+ id: 'OffersWidget',
26
+ icon: 'lucide-tag',
27
+ title: 'OFFERS.TITLE',
28
+ badge: offersCount,
29
+ loading: offersLoading,
30
+ onClick: () => openBlade({ name: 'OffersList' }),
31
+ onRefresh: () => reloadOffers(),
32
+ },
33
+ {
34
+ id: 'ReviewsWidget',
35
+ icon: 'lucide-star',
36
+ title: 'REVIEWS.TITLE',
37
+ badge: reviewsCount,
38
+ isVisible: computed(() => !!item.value?.id),
39
+ onClick: () => openBlade({ name: 'ReviewsList' }),
40
+ },
41
+ ]);
42
+
43
+ // After a save, refresh all widget data
44
+ await saveEntity();
45
+ refreshAll();
46
+ ```
47
+
48
+ ## API
49
+
50
+ ### Parameters
51
+
52
+ | Parameter | Type | Required | Description |
53
+ |---|---|---|---|
54
+ | `widgets` | `HeadlessWidgetDeclaration[]` | Yes | Array of widget declarations |
55
+
56
+ ### HeadlessWidgetDeclaration
57
+
58
+ | Field | Type | Required | Description |
59
+ |---|---|---|---|
60
+ | `id` | `string` | Yes | Unique widget identifier |
61
+ | `icon` | `string` | Yes | Icon name (e.g., `"lucide-tag"`) |
62
+ | `title` | `string` | Yes | i18n key or display title |
63
+ | `badge` | `Ref<number \| string>` | No | Badge counter value |
64
+ | `loading` | `Ref<boolean>` | No | Show loading indicator |
65
+ | `disabled` | `Ref<boolean> \| boolean` | No | Disable the widget |
66
+ | `isVisible` | `ComputedRef<boolean> \| boolean` | No | Toggle visibility |
67
+ | `onClick` | `() => void` | No | Action when widget is clicked |
68
+ | `onRefresh` | `() => void \| Promise<void>` | No | Called by `refresh(id)` or `refreshAll()` |
69
+
70
+ ### Returns
71
+
72
+ | Property | Type | Description |
73
+ |---|---|---|
74
+ | `refresh` | `(widgetId: string) => void` | Trigger `onRefresh` on a specific widget |
75
+ | `refreshAll` | `() => void` | Trigger `onRefresh` on all widgets that have one |
76
+
77
+ ## useWidgetTrigger
78
+
79
+ Widget-side composable for external component-based widgets. Registers a trigger contract (`onRefresh`, `onClick`, `badge`) via provide/inject — no props, IDs, or service knowledge required.
80
+
81
+ ### Basic Usage
82
+
83
+ ```typescript
84
+ import { useWidgetTrigger } from '@vc-shell/framework';
85
+
86
+ // Inside an external widget component:
87
+ useWidgetTrigger({ onRefresh: loadData });
88
+ ```
89
+
90
+ ### IWidgetTrigger
91
+
92
+ | Field | Type | Required | Description |
93
+ |---|---|---|---|
94
+ | `icon` | `string` | No | Lucide icon name for dropdown rendering |
95
+ | `title` | `string` | No | Display title (fallback: widget's title) |
96
+ | `badge` | `Ref<number \| string>` | No | Reactive badge value |
97
+ | `onClick` | `() => void` | No | Handler called when widget is clicked in dropdown |
98
+ | `onRefresh` | `() => void \| Promise<void>` | No | Handler called to refresh widget data |
99
+ | `disabled` | `Ref<boolean> \| boolean` | No | Disabled state |
100
+
101
+ ### How It Works
102
+
103
+ 1. `WidgetContainer` wraps each component-based widget in a `WidgetScope` provider
104
+ 2. `WidgetScope` provides a `setTrigger` function scoped to the specific widget ID and blade ID
105
+ 3. `useWidgetTrigger` injects this scope and calls `setTrigger` — no props or IDs needed
106
+ 4. When the blade calls `refresh(widgetId)` or `refreshAll()`, the registered `onRefresh` is invoked
107
+
108
+ ## Recipe: External Widget with Refresh
109
+
110
+ A complete example of an external widget that shows an unread message count and supports refresh from the blade:
111
+
112
+ **1. Register the external widget (module index.ts):**
113
+
114
+ ```typescript
115
+ import { createAppModule, registerExternalWidget, BladeDescriptor } from "@vc-shell/framework";
116
+ import { markRaw } from "vue";
117
+ import { MessageWidget } from "./components/widgets";
118
+
119
+ registerExternalWidget({
120
+ id: "MessageWidget",
121
+ component: markRaw(MessageWidget),
122
+ targetBlades: ["ProductDetails", "OrderDetails"],
123
+ isVisible: (blade?: BladeDescriptor) => !!blade?.param,
124
+ });
125
+ ```
126
+
127
+ **2. Widget component (message-widget.vue):**
128
+
129
+ ```vue
130
+ <template>
131
+ <VcWidget
132
+ v-loading:500="loading"
133
+ :title="$t('MESSENGER.WIDGET.TITLE')"
134
+ icon="lucide-message-circle"
135
+ :value="messageCount"
136
+ @click="openMessageBlade"
137
+ />
138
+ </template>
139
+
140
+ <script setup lang="ts">
141
+ import { ref, computed, onMounted } from "vue";
142
+ import {
143
+ loading as vLoading,
144
+ useBlade,
145
+ injectBladeContext,
146
+ useWidgetTrigger,
147
+ VcWidget,
148
+ } from "@vc-shell/framework";
149
+
150
+ const ctx = injectBladeContext();
151
+ const entityId = computed(() => (ctx.value.item as { id?: string })?.id ?? "");
152
+
153
+ const messageCount = ref(0);
154
+ const loading = ref(false);
155
+
156
+ const loadData = async () => {
157
+ loading.value = true;
158
+ try {
159
+ messageCount.value = await api.getUnreadCount(entityId.value);
160
+ } finally {
161
+ loading.value = false;
162
+ }
163
+ };
164
+
165
+ // Register refresh callback — blade can call refreshAll() after save
166
+ useWidgetTrigger({ onRefresh: loadData });
167
+
168
+ onMounted(() => {
169
+ if (entityId.value) loadData();
170
+ });
171
+ </script>
172
+ ```
173
+
174
+ **3. Blade refreshes widgets after save:**
175
+
176
+ ```vue
177
+ <script setup lang="ts">
178
+ import { useBladeWidgets } from "@vc-shell/framework";
179
+
180
+ // Empty array — blade doesn't register headless widgets,
181
+ // but gets refresh/refreshAll for external widgets
182
+ const { refresh, refreshAll } = useBladeWidgets([]);
183
+
184
+ async function save() {
185
+ await api.saveProduct(product.value);
186
+ refreshAll(); // refresh all widgets (including MessageWidget)
187
+ // or: refresh("MessageWidget"); // refresh a specific widget by ID
188
+ }
189
+ </script>
190
+ ```
191
+
192
+ ## Recipe: Product Detail Blade with Multiple Widgets
193
+
194
+ ```vue
195
+ <script setup lang="ts">
196
+ import { ref, computed } from "vue";
197
+ import { useBladeWidgets, defineBladeContext } from "@vc-shell/framework";
198
+
199
+ const product = ref({ id: "prod-1", name: "Widget A" });
200
+ const offersCount = ref(0);
201
+ const reviewsCount = ref(0);
202
+ const offersLoading = ref(false);
203
+
204
+ // Expose product data to widgets
205
+ defineBladeContext(computed(() => ({ id: product.value?.id })));
206
+
207
+ async function reloadOffers() {
208
+ offersLoading.value = true;
209
+ try {
210
+ const result = await api.searchOffers({ productId: product.value.id });
211
+ offersCount.value = result.totalCount;
212
+ } finally {
213
+ offersLoading.value = false;
214
+ }
215
+ }
216
+
217
+ const { refreshAll } = useBladeWidgets([
218
+ {
219
+ id: "OffersWidget",
220
+ icon: "lucide-tag",
221
+ title: "PRODUCT.WIDGETS.OFFERS",
222
+ badge: offersCount,
223
+ loading: offersLoading,
224
+ isVisible: computed(() => !!product.value?.id),
225
+ onClick: () => openBlade({ name: "OffersList" }),
226
+ onRefresh: reloadOffers,
227
+ },
228
+ {
229
+ id: "ReviewsWidget",
230
+ icon: "lucide-star",
231
+ title: "PRODUCT.WIDGETS.REVIEWS",
232
+ badge: reviewsCount,
233
+ isVisible: computed(() => !!product.value?.id),
234
+ onClick: () => openBlade({ name: "ReviewsList" }),
235
+ },
236
+ ]);
237
+
238
+ async function save() {
239
+ await api.saveProduct(product.value);
240
+ // Refresh all widget counts after saving
241
+ refreshAll();
242
+ }
243
+ </script>
244
+ ```
245
+
246
+ ## Prerequisites
247
+
248
+ **`useBladeWidgets`**:
249
+ - Must be called inside a blade component rendered by `VcBladeSlot` (requires `BladeDescriptorKey` injection).
250
+ - `WidgetService` must be provided in the component tree (automatically available in vc-shell apps).
251
+ - Calling outside a blade context throws an error with a descriptive message.
252
+
253
+ **`useWidgetTrigger`**:
254
+ - Must be called inside a widget component rendered by `WidgetContainer` (requires `WidgetScopeKey` injection).
255
+ - If called outside a widget scope, logs a warning and does nothing (does not throw).
256
+
257
+ ## Details
258
+
259
+ - **Lifecycle management**: Widgets are registered in `onMounted` and unregistered in `onUnmounted`. This ensures the WidgetService always reflects the currently visible blades.
260
+ - **Blade ID resolution**: The composable injects `BladeDescriptorKey` to determine which blade the widgets belong to. Each blade has its own isolated widget list.
261
+ - **Trigger pattern**: The `onRefresh` callback is stored as a `trigger` on the registered widget. When `refresh(id)` or `refreshAll()` is called, the trigger is invoked. Widgets without `onRefresh` are silently skipped.
262
+
263
+ ## Tips
264
+
265
+ - Use `refreshAll()` after any blade operation that might change widget badge counts (save, delete, import).
266
+ - The `badge` field accepts both numbers and strings. Use a string for non-numeric badges like "New" or "!".
267
+ - Keep widget IDs unique within a blade. Duplicate IDs will overwrite previous registrations.
268
+ - Combine with `defineBladeContext` to expose blade entity data that widget components (non-headless) can consume via `injectBladeContext`.
269
+
270
+ ## Common Mistakes
271
+
272
+ ### Calling useWidgetTrigger outside WidgetContainer scope
273
+
274
+ ```typescript
275
+ // Wrong — called in a standalone component, not rendered inside a blade widget slot
276
+ export default defineComponent({
277
+ setup() {
278
+ useWidgetTrigger({ onRefresh: loadData }); // ⚠️ Logs warning, trigger not registered
279
+ },
280
+ });
281
+ ```
282
+
283
+ ```typescript
284
+ // Correct — called inside a widget component registered via registerExternalWidget
285
+ // and rendered by WidgetContainer within a blade
286
+ useWidgetTrigger({ onRefresh: loadData }); // ✓ WidgetScope provides context
287
+ ```
288
+
289
+ ### Forgetting to pass empty array to useBladeWidgets for refresh-only usage
290
+
291
+ ```typescript
292
+ // Wrong — useBladeWidgets requires an array argument
293
+ const { refreshAll } = useBladeWidgets(); // TS error
294
+
295
+ // Correct — pass empty array when you only need refresh/refreshAll
296
+ const { refreshAll } = useBladeWidgets([]);
297
+ ```
298
+
299
+ ## Related
300
+
301
+ - `defineBladeContext` / `injectBladeContext` -- expose/consume blade data in external widgets
302
+ - `registerExternalWidget` -- register a component-based widget globally for target blades
303
+ - `WidgetService` in `@core/services/widget-service` -- underlying service
304
+ - `WidgetScope` in `vc-blade/_internal/widgets/WidgetScope.vue` -- provides `WidgetScopeKey` to widget components
305
+ - `VcBladeSlot` -- the blade wrapper that provides `BladeDescriptorKey`
@@ -0,0 +1,83 @@
1
+ # useMenuExpanded
2
+
3
+ Manages the sidebar menu expanded/collapsed and hover-expanded state with localStorage persistence. This is the low-level composable that powers the sidebar pin/unpin behavior in the vc-shell admin UI. It tracks two independent states: the permanent "pinned" state (persisted across sessions) and the transient "hover-expanded" state (active only while the user hovers over a collapsed sidebar).
4
+
5
+ ## When to Use
6
+
7
+ - Low-level control over sidebar pin and hover state
8
+ - Building a custom sidebar component that needs expand/collapse persistence
9
+ - When NOT to use: prefer `useSidebarState` which wraps this composable and adds mobile menu support, derived `isExpanded` computed, and responsive breakpoint handling
10
+
11
+ ## Basic Usage
12
+
13
+ ```typescript
14
+ import { useMenuExpanded } from '@vc-shell/framework';
15
+
16
+ const { isExpanded, toggleExpanded, isHoverExpanded, toggleHoverExpanded } = useMenuExpanded();
17
+
18
+ // Toggle pin state (persisted to localStorage)
19
+ toggleExpanded();
20
+
21
+ // Hover expand with 200ms delay
22
+ toggleHoverExpanded(true); // opens after delay
23
+ toggleHoverExpanded(false); // closes immediately
24
+ ```
25
+
26
+ ## API
27
+
28
+ ### Returns
29
+
30
+ | Property | Type | Description |
31
+ |---|---|---|
32
+ | `isExpanded` | `Ref<boolean>` | Pinned state, persisted via `useLocalStorage` |
33
+ | `toggleExpanded` | `() => void` | Toggle the pinned state |
34
+ | `isHoverExpanded` | `Ref<boolean>` | Temporary hover expansion (not persisted) |
35
+ | `toggleHoverExpanded` | `(shouldExpand?: boolean) => void` | Set hover state; opening has a 200ms delay, closing is immediate |
36
+
37
+ ## Recipe: Custom Sidebar with Hover Expand
38
+
39
+ A common pattern is binding mouse events on the sidebar rail so the menu previews its full width on hover, without permanently pinning it open:
40
+
41
+ ```vue
42
+ <script setup lang="ts">
43
+ import { computed } from "vue";
44
+ import { useMenuExpanded } from "@vc-shell/framework";
45
+
46
+ const { isExpanded, toggleExpanded, isHoverExpanded, toggleHoverExpanded } = useMenuExpanded();
47
+
48
+ // The sidebar is visually expanded if pinned OR hover-expanded
49
+ const isVisuallyExpanded = computed(() => isExpanded.value || isHoverExpanded.value);
50
+ </script>
51
+
52
+ <template>
53
+ <aside
54
+ :class="{ 'sidebar--expanded': isVisuallyExpanded }"
55
+ @mouseenter="toggleHoverExpanded(true)"
56
+ @mouseleave="toggleHoverExpanded(false)"
57
+ >
58
+ <nav>
59
+ <!-- menu items -->
60
+ </nav>
61
+ <button @click="toggleExpanded">
62
+ {{ isExpanded ? 'Unpin' : 'Pin' }}
63
+ </button>
64
+ </aside>
65
+ </template>
66
+ ```
67
+
68
+ ## Details
69
+
70
+ - **Storage key scoping**: The key is scoped per application using the first URL path segment: `VC_APP_MENU_EXPANDED_{appName}`. For example, if the app is hosted at `/vendor-portal/`, the key is `VC_APP_MENU_EXPANDED_vendor-portal`. This allows multiple vc-shell apps on the same domain to maintain independent sidebar states.
71
+ - **Hover delay**: Opening uses a 200ms debounce to prevent accidental expansion when the cursor briefly passes over the sidebar. Closing is immediate to feel responsive.
72
+ - **Cleanup**: Pending hover timeouts are cleaned up via `onScopeDispose` to prevent memory leaks when the composable's effect scope is destroyed.
73
+ - **Default state**: The sidebar starts pinned open (`true`) on first visit, which is the most user-friendly default for new users.
74
+
75
+ ## Tips
76
+
77
+ - If you call `toggleHoverExpanded(true)` and then `toggleHoverExpanded(false)` within the 200ms window, the expansion is canceled -- the timeout is cleared before it fires.
78
+ - The composable does not handle mobile breakpoints. For responsive behavior, use `useSidebarState` instead, which combines `useMenuExpanded` with mobile detection.
79
+ - Each call to `useMenuExpanded()` creates a new instance with its own timeout tracking, but they share the same localStorage-backed `isExpanded` ref (via `useLocalStorage`). Multiple instances will stay in sync for the pinned state.
80
+
81
+ ## Related
82
+
83
+ - `useSidebarState` -- higher-level composable that adds mobile menu and derived `isExpanded` computed
@@ -0,0 +1,116 @@
1
+ # Thumbnail URL Utility
2
+
3
+ Transforms full-size image URLs into thumbnail variants by appending size suffixes before the file extension. VirtoCommerce backend generates thumbnails automatically with these suffixes.
4
+
5
+ ## When to Use
6
+
7
+ - Use in any component that displays images at a known size smaller than the original
8
+ - Reduces bandwidth and improves page load time significantly
9
+ - Especially important for image lists (tables, galleries, cards)
10
+
11
+ ## Available Sizes
12
+
13
+ ### Named Presets
14
+
15
+ | Preset | Use Case |
16
+ |--------|----------|
17
+ | `sm` | Small icons, table cells, avatar thumbnails |
18
+ | `md` | Medium previews, cards |
19
+ | `lg` | Large previews, hero images |
20
+
21
+ ### Pixel Sizes
22
+
23
+ | Size | Pixels | Use Case |
24
+ |------|--------|----------|
25
+ | `64x64` | 64px | Table cells, tiny thumbnails |
26
+ | `128x128` | 128px | Small tiles, list items |
27
+ | `168x168` | 168px | Medium tiles |
28
+ | `216x216` | 216px | Gallery tiles (md) |
29
+ | `348x348` | 348px | Large gallery tiles |
30
+
31
+ ## API
32
+
33
+ ### `getThumbnailUrl(url, size?)`
34
+
35
+ Transforms an image URL by inserting a size suffix before the file extension.
36
+
37
+ ```ts
38
+ import { getThumbnailUrl } from "@core/utilities/thumbnail";
39
+
40
+ getThumbnailUrl("https://cdn.example.com/photo.jpg", "sm")
41
+ // → "https://cdn.example.com/photo_sm.jpg"
42
+
43
+ getThumbnailUrl("https://cdn.example.com/photo.jpg", "128x128")
44
+ // → "https://cdn.example.com/photo_128x128.jpg"
45
+
46
+ getThumbnailUrl("https://cdn.example.com/photo.jpg")
47
+ // → "https://cdn.example.com/photo.jpg" (unchanged)
48
+
49
+ getThumbnailUrl(undefined, "sm")
50
+ // → undefined
51
+ ```
52
+
53
+ **Parameters:**
54
+ - `url` — Original image URL (string or undefined)
55
+ - `size` — Thumbnail size preset or pixel dimensions (optional)
56
+
57
+ **Returns:** Transformed URL, or original URL if size not specified
58
+
59
+ ### `getBestThumbnailSize(displaySize)`
60
+
61
+ Maps a CSS pixel display size to the best-fit thumbnail preset. Picks the smallest thumbnail that is >= the display size.
62
+
63
+ ```ts
64
+ import { getBestThumbnailSize } from "@core/utilities/thumbnail";
65
+
66
+ getBestThumbnailSize(48) // → "64x64"
67
+ getBestThumbnailSize(96) // → "128x128"
68
+ getBestThumbnailSize(200) // → "216x216"
69
+ getBestThumbnailSize(500) // → "lg"
70
+ ```
71
+
72
+ ## Usage in Components
73
+
74
+ ### VcImage
75
+
76
+ ```vue
77
+ <VcImage
78
+ :src="product.imgSrc"
79
+ thumbnail-size="sm"
80
+ size="s"
81
+ />
82
+ ```
83
+
84
+ ### VcGallery
85
+
86
+ Gallery auto-maps `size` prop to thumbnail size. Override with `thumbnailSize`:
87
+
88
+ ```vue
89
+ <!-- Auto: sm→128x128, md→216x216, lg→348x348 -->
90
+ <VcGallery :images="images" size="md" />
91
+
92
+ <!-- Explicit override -->
93
+ <VcGallery :images="images" thumbnail-size="128x128" />
94
+ ```
95
+
96
+ Gallery preview uses `64x64` for the thumbnail strip and full-size for the main image automatically.
97
+
98
+ ### VcDataTable (CellImage)
99
+
100
+ Table image cells use `sm` thumbnail by default — no action needed.
101
+
102
+ ## Types
103
+
104
+ ```ts
105
+ type ThumbnailPreset = "sm" | "md" | "lg";
106
+ type ThumbnailPixelSize = "64x64" | "128x128" | "168x168" | "216x216" | "348x348";
107
+ type ThumbnailSize = ThumbnailPreset | ThumbnailPixelSize;
108
+ ```
109
+
110
+ ## Notes
111
+
112
+ - The utility only transforms the URL — the backend must have generated the thumbnail for the URL to resolve
113
+ - If a thumbnail doesn't exist on the server, the browser will show a broken image. Ensure your asset pipeline generates all required sizes.
114
+ - The suffix is inserted before the file extension: `photo.jpg` → `photo_sm.jpg`
115
+ - URLs without a file extension are returned unchanged
116
+ - Already-suffixed URLs are not double-transformed
@@ -91,4 +91,4 @@ export default {
91
91
  - [SettingsMenu](../settings-menu/settings-menu.docs.md) -- parent container
92
92
  - [SettingsMenuItem](../settings-menu-item/settings-menu-item.docs.md) -- base menu item used internally
93
93
  - [LogoutButton](../logout-button/logout-button.docs.md) -- sibling account action
94
- - [ChangePasswordPage](../../pages/ChangePasswordPage/change-password-page.docs.md) -- full-page variant for expired passwords
94
+ - [ChangePasswordPage](../../auth/ChangePasswordPage/change-password-page.docs.md) -- full-page variant for expired passwords
@@ -1,102 +0,0 @@
1
- # ChangePasswordPage
2
-
3
- Change password page with current, new, and confirm password fields. Supports a `forced` mode for expired passwords that displays an info banner and is triggered by post-login redirect. This full-page variant is used when the user must change their password before accessing the application (e.g., expired password policy). For voluntary password changes from within the app, the `ChangePasswordButton` in the settings menu opens a popup instead.
4
-
5
- ## When to Use
6
-
7
- - When a signed-in user wants to change their password (full-page flow)
8
- - In `forced` mode after login when the user's password has expired
9
- - The standard vc-shell routing maps `/change-password` to this page
10
-
11
- ## Basic Usage
12
-
13
- ```vue
14
- <template>
15
- <ChangePassword />
16
- </template>
17
-
18
- <!-- Forced mode (expired password) -->
19
- <template>
20
- <ChangePassword forced />
21
- </template>
22
- ```
23
-
24
- With custom branding:
25
-
26
- ```vue
27
- <template>
28
- <ChangePassword
29
- forced
30
- logo="/assets/my-company-logo.svg"
31
- background="/assets/custom-background.jpg"
32
- />
33
- </template>
34
- ```
35
-
36
- ## Key Props
37
-
38
- | Prop | Type | Default | Description |
39
- |------|------|---------|-------------|
40
- | `forced` | `boolean` | `false` | Show expired-password info banner and adjusted title |
41
- | `logo` | `string` | - | Override logo image URL |
42
- | `background` | `string` | - | Custom background image URL |
43
-
44
- ## Recipe: Router Configuration with Forced Mode
45
-
46
- Set up the route so the login page can redirect here when the password is expired:
47
-
48
- ```ts
49
- import ChangePassword from "@vc-shell/framework/shared/pages/ChangePasswordPage";
50
-
51
- const routes = [
52
- {
53
- path: "/change-password",
54
- name: "ChangePassword",
55
- component: ChangePassword,
56
- props: (route) => ({
57
- forced: route.query.forced === "true",
58
- }),
59
- },
60
- ];
61
- ```
62
-
63
- In the login flow, redirect when the user's password has expired:
64
-
65
- ```ts
66
- async function handleLogin() {
67
- const result = await signIn(username.value, password.value);
68
- if (result.passwordExpired) {
69
- router.push({ name: "ChangePassword", query: { forced: "true" } });
70
- } else {
71
- router.push("/");
72
- }
73
- }
74
- ```
75
-
76
- ## Features
77
-
78
- - **Real-time password policy validation**: Uses `useUserManagement().validatePassword` to check the new password against the platform's policy as the user types (minimum length, required uppercase, lowercase, digits, special characters)
79
- - **Equal password detection**: Shows a specific "Equal-passwords" error when the new password matches the current password, without making an API call
80
- - **Confirm-password mismatch detection**: Validates that the new password and confirm password fields match
81
- - **Forced mode banner**: When `forced` is `true`, displays an info banner explaining that the password has expired and must be changed
82
- - **Cancel behavior**: Cancel button signs out the user and redirects to `/login`
83
- - **Success redirect**: On successful password change, redirects to `/` (main application)
84
-
85
- ## Details
86
-
87
- - **Auth layout**: Renders inside `VcAuthLayout`, providing the centered card design with logo and background.
88
- - **Password policy**: The validation rules are fetched from the platform API and include configurable requirements (minimum length, character classes, etc.). The component displays these rules as a checklist.
89
- - **Forced vs voluntary**: In forced mode (`forced=true`), the page title changes to reflect the expired password scenario, and an info banner explains why the change is required. The form fields and behavior are otherwise identical.
90
- - **Sign-out on cancel**: If the user cancels during a forced password change, they are signed out. This prevents access to the application with an expired password.
91
-
92
- ## Tips
93
-
94
- - The `forced` prop is typically set via a route query parameter, not hardcoded. The login page detects expired passwords and redirects with the appropriate flag.
95
- - Password policy validation runs on keyup, providing immediate feedback. The submit button is disabled until all policy requirements are met and the passwords match.
96
- - This page is distinct from the `ChangePasswordButton` popup. The page is for full-screen flow (forced changes), while the popup is for voluntary in-app password changes.
97
-
98
- ## Related Components
99
-
100
- - **VcAuthLayout** - The underlying centered card layout
101
- - **LoginPage** - Redirects here when `user.passwordExpired` is true
102
- - **ChangePasswordButton** - Settings menu popup variant for voluntary password changes