@vc-shell/vc-app-skill 2.7.0 → 2.8.0-pr399.6ff7b99
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/package.json +1 -1
- package/runtime/VERSION +1 -1
- package/runtime/knowledge/docs/_BUILD_HASH.md +1 -1
- package/runtime/knowledge/docs/core/composables/useBladeWidgets/index.docs.md +93 -19
- package/runtime/knowledge/docs/core/composables/useToolbar/useToolbar.docs.md +26 -4
- package/runtime/knowledge/docs/core/composables/useWidgets/useWidgets.docs.md +7 -7
- package/runtime/knowledge/docs/core/plugins/extension-points/extension-points.docs.md +10 -4
- package/runtime/knowledge/docs/core/services/services.docs.md +10 -10
- package/runtime/knowledge/docs/shell/components/change-password/change-password.docs.md +2 -2
- package/runtime/knowledge/docs/ui/components/atoms/vc-widget/vc-widget.docs.md +8 -0
- package/runtime/knowledge/docs/ui/components/molecules/vc-dropdown/vc-dropdown.docs.md +18 -2
- package/runtime/knowledge/docs/ui/components/organisms/vc-blade/vc-blade.docs.md +18 -0
- package/runtime/knowledge/docs/ui/components/organisms/vc-sidebar/vc-sidebar.docs.md +5 -0
- package/runtime/knowledge/docs/core/composables/useKeyboardShortcuts/useKeyboardShortcuts.docs.md +0 -219
- package/runtime/knowledge/docs/core/composables/useLatestRequest/useLatestRequest.docs.md +0 -151
- package/runtime/knowledge/docs/core/utilities/focus.docs.md +0 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vc-shell/vc-app-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0-pr399.6ff7b99",
|
|
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.
|
|
1
|
+
2.8.0
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Synced from framework at commit
|
|
1
|
+
Synced from framework at commit b57bc2f71 on 2026-10-30T15:31:36.432Z
|
|
@@ -82,17 +82,89 @@ Inline `useBladeWidgets([...])` is fine for one-off widgets, but extracting beco
|
|
|
82
82
|
|
|
83
83
|
### HeadlessWidgetDeclaration
|
|
84
84
|
|
|
85
|
-
| Field
|
|
86
|
-
|
|
|
87
|
-
| `id`
|
|
88
|
-
| `icon`
|
|
89
|
-
| `title`
|
|
90
|
-
| `badge`
|
|
91
|
-
| `loading`
|
|
92
|
-
| `disabled`
|
|
93
|
-
| `isVisible`
|
|
94
|
-
| `onClick`
|
|
95
|
-
| `onRefresh`
|
|
85
|
+
| Field | Type | Required | Description |
|
|
86
|
+
| ----------------- | -------------------------------------------------------- | -------- | ----------------------------------------- |
|
|
87
|
+
| `id` | `string` | Yes | Unique widget identifier |
|
|
88
|
+
| `icon` | `string` | Yes | Icon name (e.g., `"lucide-tag"`) |
|
|
89
|
+
| `title` | `string` | Yes | i18n key or display title |
|
|
90
|
+
| `badge` | `Ref<number \| string> \| ComputedRef<number \| string>` | No | Badge counter value |
|
|
91
|
+
| `loading` | `Ref<boolean> \| ComputedRef<boolean>` | No | Show loading indicator |
|
|
92
|
+
| `disabled` | `Ref<boolean> \| boolean` | No | Disable the widget |
|
|
93
|
+
| `isVisible` | `ComputedRef<boolean> \| Ref<boolean> \| boolean` | No | Toggle visibility |
|
|
94
|
+
| `onClick` | `() => void` | No | Action when widget is clicked |
|
|
95
|
+
| `onRefresh` | `() => void \| Promise<void>` | No | Called by `refresh(id)` or `refreshAll()` |
|
|
96
|
+
| `alerts` | `Ref<IWidgetAlert[]> \| ComputedRef<IWidgetAlert[]>` | No | Live alerts shown in the widget popup |
|
|
97
|
+
| `alertsComponent` | `Component` | No | Popup content instead of the default rows |
|
|
98
|
+
|
|
99
|
+
### Alerts
|
|
100
|
+
|
|
101
|
+
A widget with a non-empty `alerts` list shows a dot on its icon, colored by the highest severity. Hovering the widget (desktop) opens a popup with the alerts; clicking the widget still opens the blade. On touch screens the first tap on a widget with alerts opens the popup, which then has an Open button; a second tap on the widget also opens the blade. Widgets without alerts show no popup.
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
const alerts = computed<IWidgetAlert[]>(() => (outOfStock.value ? [{ id: "stock", severity: "warning", text: t("OFFERS.OUT_OF_STOCK") }] : []));
|
|
105
|
+
|
|
106
|
+
useBladeWidgets([{ id: "OffersWidget", icon: "lucide-tag", title: "OFFERS.TITLE", alerts }]);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| `IWidgetAlert` field | Type | Required | Description |
|
|
110
|
+
| -------------------- | ---------------------------------------------- | -------- | --------------------------------- |
|
|
111
|
+
| `id` | `string` | Yes | Unique within the widget |
|
|
112
|
+
| `severity` | `"info" \| "warning" \| "error" \| "critical"` | Yes | Drives the dot color and row icon |
|
|
113
|
+
| `text` | `string` | Yes | Already translated |
|
|
114
|
+
| `meta` | `string` | No | Secondary line (time, reason) |
|
|
115
|
+
| `onOpen` | `() => void` | No | Called on row click |
|
|
116
|
+
| `action` | `{ label: string; run: () => void }` | No | Inline action button |
|
|
117
|
+
|
|
118
|
+
Alerts are data: compute them from what the widget has loaded, and they stay exactly as long as the problem does. When a push means the data changed, refetch it; the alerts follow, together with the badge. A component widget passes the same `alerts` through `useWidgetTrigger`.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
const unread = ref<Message[]>([]);
|
|
122
|
+
const load = async () => {
|
|
123
|
+
unread.value = await api.searchUnread({ entityId: entityId.value });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// A push for this entity: refetch, and the badge and alerts update together
|
|
127
|
+
useBladeNotifications<MessagePush>({
|
|
128
|
+
types: ["MessagePushNotification"],
|
|
129
|
+
filter: (m) => m.entityId === entityId.value,
|
|
130
|
+
onMessage: load,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const alerts = computed<IWidgetAlert[]>(() => unread.value.map((m) => ({ id: m.id!, severity: "info", text: m.content ?? "", onOpen: openMessenger })));
|
|
134
|
+
useWidgetTrigger({ onRefresh: load, alerts });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Widget alerts are independent from the notification bell: the bell lists notifications, the widget lists what is unresolved in its data.
|
|
138
|
+
|
|
139
|
+
### Custom popup content
|
|
140
|
+
|
|
141
|
+
The popup shows one row per alert by default. To draw it yourself (message previews, an offer card), pass `alertsComponent`. The alerts still decide the dot and whether the popup opens; the component decides what it shows. It receives `IWidgetAlertsComponentProps`: the `alerts` and a `close()` to call after it opens a blade.
|
|
142
|
+
|
|
143
|
+
```vue
|
|
144
|
+
<!-- MessagesPreview.vue -->
|
|
145
|
+
<script setup lang="ts">
|
|
146
|
+
import type { IWidgetAlertsComponentProps } from "@vc-shell/framework";
|
|
147
|
+
defineProps<IWidgetAlertsComponentProps>();
|
|
148
|
+
</script>
|
|
149
|
+
|
|
150
|
+
<template>
|
|
151
|
+
<div class="tw-p-3">
|
|
152
|
+
<p
|
|
153
|
+
v-for="a in alerts"
|
|
154
|
+
:key="a.id"
|
|
155
|
+
>
|
|
156
|
+
{{ a.meta }}: {{ a.text }}
|
|
157
|
+
</p>
|
|
158
|
+
<button @click="close()">Open conversation</button>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
useWidgetTrigger({ onRefresh: load, alerts, alertsComponent: MessagesPreview });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Keyboard opening moves focus to the first button, link or input in the component, so put the main action first or make it a real `<button>`.
|
|
96
168
|
|
|
97
169
|
### Returns
|
|
98
170
|
|
|
@@ -116,14 +188,16 @@ useWidgetTrigger({ onRefresh: loadData });
|
|
|
116
188
|
|
|
117
189
|
### IWidgetTrigger
|
|
118
190
|
|
|
119
|
-
| Field
|
|
120
|
-
|
|
|
121
|
-
| `icon`
|
|
122
|
-
| `title`
|
|
123
|
-
| `badge`
|
|
124
|
-
| `onClick`
|
|
125
|
-
| `onRefresh`
|
|
126
|
-
| `disabled`
|
|
191
|
+
| Field | Type | Required | Description |
|
|
192
|
+
| ----------------- | ----------------------------- | -------- | ----------------------------------------------------- |
|
|
193
|
+
| `icon` | `string` | No | Lucide icon name for dropdown rendering |
|
|
194
|
+
| `title` | `string` | No | Display title (fallback: widget's title) |
|
|
195
|
+
| `badge` | `Ref<number \| string>` | No | Reactive badge value |
|
|
196
|
+
| `onClick` | `() => void` | No | Handler called when widget is clicked in dropdown |
|
|
197
|
+
| `onRefresh` | `() => void \| Promise<void>` | No | Handler called to refresh widget data |
|
|
198
|
+
| `disabled` | `Ref<boolean> \| boolean` | No | Disabled state |
|
|
199
|
+
| `alerts` | `Ref<IWidgetAlert[]>` | No | Live alerts shown in the widget popup (see above) |
|
|
200
|
+
| `alertsComponent` | `Component` | No | Popup content instead of the default rows (see above) |
|
|
127
201
|
|
|
128
202
|
### How It Works
|
|
129
203
|
|
|
@@ -163,18 +163,40 @@ Avoid wrapping `registerToolbarItem` in an `if (hasAccess(...))` check unless yo
|
|
|
163
163
|
|
|
164
164
|
## Cross-blade toolbar management
|
|
165
165
|
|
|
166
|
-
All methods accept an optional `targetBladeId` second argument. Pass it to register, update, or clear toolbar items on a blade other than the current one:
|
|
166
|
+
All methods accept an optional `targetBladeId` second argument. Pass it to register, update, or clear toolbar items on a blade other than the current one. The id is the blade instance id (`useBlade().id`), generated when the blade opens:
|
|
167
167
|
|
|
168
168
|
```typescript
|
|
169
|
-
// Register a button on a specific
|
|
170
|
-
registerToolbarItem({ id: "child-action", title: "Action", clickHandler: () => {} },
|
|
169
|
+
// Register a button on a specific open blade instance
|
|
170
|
+
registerToolbarItem({ id: "child-action", title: "Action", clickHandler: () => {} }, childBladeId);
|
|
171
171
|
|
|
172
172
|
// Clear another blade's toolbar
|
|
173
|
-
clearBladeToolbarItems(
|
|
173
|
+
clearBladeToolbarItems(childBladeId);
|
|
174
174
|
```
|
|
175
175
|
|
|
176
176
|
Pass `"*"` as the bladeId to register a global item that appears on every blade.
|
|
177
177
|
|
|
178
|
+
## Adding a button to another module's blade
|
|
179
|
+
|
|
180
|
+
A module can add a button to a blade it does not own with `registerExternalToolbarItem()`, the toolbar counterpart of `registerExternalWidget()`. `targetBlades` lists blade names (the `name` from `defineBlade`). The button shows on every open instance of those blades and survives the blades closing:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { registerExternalToolbarItem } from "@vc-shell/framework";
|
|
184
|
+
|
|
185
|
+
export default defineAppModule({
|
|
186
|
+
install() {
|
|
187
|
+
registerExternalToolbarItem({
|
|
188
|
+
id: "quality-check",
|
|
189
|
+
title: "Quality check",
|
|
190
|
+
icon: "lucide-sparkles",
|
|
191
|
+
targetBlades: ["Products"],
|
|
192
|
+
clickHandler: () => runCheck(),
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The name match is case-insensitive. If the blade registers its own item with the same `id`, the blade's item wins. `registerToolbarItem(item, "Products")` does the same for a single blade.
|
|
199
|
+
|
|
178
200
|
## Auto-cleanup control
|
|
179
201
|
|
|
180
202
|
By default, items registered through `useToolbar()` inside a component setup are cleared when that component unmounts (`autoCleanup: true`). Disable this for items that should persist beyond the registering component:
|
|
@@ -51,11 +51,11 @@ None.
|
|
|
51
51
|
|
|
52
52
|
### Additional Exports
|
|
53
53
|
|
|
54
|
-
| Export | Description
|
|
55
|
-
| -------------------------------------- |
|
|
56
|
-
| `provideWidgetService()` | Creates and provides the widget service via Vue injection. Idempotent -- returns existing service if already provided. Cleans up via `onScopeDispose`.
|
|
57
|
-
| `registerWidget(widget, bladeId)` | Global pre-registration. Widgets registered this way are picked up when `provideWidgetService()` creates the service. Use in module `install()`.
|
|
58
|
-
| `registerExternalWidget(registration)` | Global pre-registration for cross-module widgets. These widgets are rendered in blades owned by other modules.
|
|
54
|
+
| Export | Description |
|
|
55
|
+
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
56
|
+
| `provideWidgetService()` | Creates and provides the widget service via Vue injection. Idempotent -- returns existing service if already provided. Cleans up via `onScopeDispose`. |
|
|
57
|
+
| `registerWidget(widget, bladeId)` | Global pre-registration. Widgets registered this way are picked up when `provideWidgetService()` creates the service. Use in module `install()`. Pass a blade name to show the widget on every instance of that blade. |
|
|
58
|
+
| `registerExternalWidget(registration)` | Global pre-registration for cross-module widgets. These widgets are rendered in blades owned by other modules. |
|
|
59
59
|
|
|
60
60
|
<!-- internal:start -->
|
|
61
61
|
|
|
@@ -90,7 +90,7 @@ export default {
|
|
|
90
90
|
component: markRaw(OrderStatusWidget),
|
|
91
91
|
icon: "fas fa-clipboard-check",
|
|
92
92
|
},
|
|
93
|
-
"
|
|
93
|
+
"OrderDetails", // blade name (from defineBlade); the widget appears on every open instance of it
|
|
94
94
|
);
|
|
95
95
|
},
|
|
96
96
|
};
|
|
@@ -110,7 +110,7 @@ export default {
|
|
|
110
110
|
install() {
|
|
111
111
|
registerExternalWidget({
|
|
112
112
|
id: "shipping-tracker",
|
|
113
|
-
targetBlades: ["
|
|
113
|
+
targetBlades: ["OrderDetails"], // blade names from other modules
|
|
114
114
|
component: markRaw(ShippingTracker),
|
|
115
115
|
title: "Shipping",
|
|
116
116
|
});
|
|
@@ -137,12 +137,18 @@ This means module load order does not matter. Remote modules loaded via Module F
|
|
|
137
137
|
Components are sorted by `priority` (ascending). Lower numbers render first:
|
|
138
138
|
|
|
139
139
|
```typescript
|
|
140
|
-
add({ id: "a", component: A, priority: 20 }); // Renders
|
|
141
|
-
add({ id: "b", component: B, priority: 10 }); // Renders
|
|
142
|
-
add({ id: "c", component: C }); // priority=0, renders first
|
|
140
|
+
add({ id: "a", component: A, priority: 20 }); // Renders third
|
|
141
|
+
add({ id: "b", component: B, priority: 10 }); // Renders second
|
|
142
|
+
add({ id: "c", component: C }); // priority=0, renders first
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
Default priority is `0`. Components with the same priority render in registration order.
|
|
145
|
+
Default priority is `0`. Components with the same priority render in registration order. Inside one module that is the order of the `add()` calls. Across modules it is the order the modules install in, which can change between page loads when remote modules load asynchronously. When several modules contribute to one extension point, give each component an explicit `priority`, spaced out (10, 20, 30) so a new component can go in between.
|
|
146
|
+
|
|
147
|
+
To move a component that another module registered, call `add()` with the same `id` and a new `priority`; the entry is replaced and re-sorted.
|
|
148
|
+
|
|
149
|
+
!!! note "Toolbar sorts the other way"
|
|
150
|
+
|
|
151
|
+
Toolbar items put a higher `priority` first. See [useToolbar](../../composables/useToolbar/).
|
|
146
152
|
|
|
147
153
|
### Typed Metadata
|
|
148
154
|
|
|
@@ -32,16 +32,16 @@ Every service follows the same architecture:
|
|
|
32
32
|
|
|
33
33
|
## Services
|
|
34
34
|
|
|
35
|
-
| Service | Factory | Module-level API | Key Type
|
|
36
|
-
| ------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
-
| **MenuService** | `createMenuService()` | `addMenuItem()`, `removeRegisteredMenuItem()`, `setMenuBadge()`, `removeMenuBadge()`, `getMenuBadge()`, `getMenuBadges()` | `MenuItem`
|
|
38
|
-
| **WidgetService** | `createWidgetService()` | `registerWidget()`, `registerExternalWidget()` | `IWidget` / `IExternalWidgetRegistration`
|
|
39
|
-
| **ToolbarService** | `createToolbarService()` | `registerToolbarItem()`
|
|
40
|
-
| **DashboardService** | `createDashboardService({ hasAccess? })` | `registerDashboardWidget()` | `DashboardWidget`
|
|
41
|
-
| **SettingsMenuService** | `createSettingsMenuService()` | `addSettingsMenuItem()` | `ISettingsMenuItem`
|
|
42
|
-
| **AppBarWidgetService** | `createAppBarWidgetService()` | `addAppBarWidget()` | `AppBarWidget`
|
|
43
|
-
| **AppBarMobileButtonsService** | `createAppBarMobileButtonsService()` | (direct API) | `AppBarButtonContent`
|
|
44
|
-
| **LanguageService** | `createLanguageService()` | (direct API) | locale strings
|
|
35
|
+
| Service | Factory | Module-level API | Key Type |
|
|
36
|
+
| ------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
|
37
|
+
| **MenuService** | `createMenuService()` | `addMenuItem()`, `removeRegisteredMenuItem()`, `setMenuBadge()`, `removeMenuBadge()`, `getMenuBadge()`, `getMenuBadges()` | `MenuItem` |
|
|
38
|
+
| **WidgetService** | `createWidgetService()` | `registerWidget()`, `registerExternalWidget()` | `IWidget` / `IExternalWidgetRegistration` |
|
|
39
|
+
| **ToolbarService** | `createToolbarService()` | `registerToolbarItem()`, `registerExternalToolbarItem()` | `IToolbarItem` / `IExternalToolbarItemRegistration` |
|
|
40
|
+
| **DashboardService** | `createDashboardService({ hasAccess? })` | `registerDashboardWidget()` | `DashboardWidget` |
|
|
41
|
+
| **SettingsMenuService** | `createSettingsMenuService()` | `addSettingsMenuItem()` | `ISettingsMenuItem` |
|
|
42
|
+
| **AppBarWidgetService** | `createAppBarWidgetService()` | `addAppBarWidget()` | `AppBarWidget` |
|
|
43
|
+
| **AppBarMobileButtonsService** | `createAppBarMobileButtonsService()` | (direct API) | `AppBarButtonContent` |
|
|
44
|
+
| **LanguageService** | `createLanguageService()` | (direct API) | locale strings |
|
|
45
45
|
|
|
46
46
|
## Usage
|
|
47
47
|
|
|
@@ -44,7 +44,7 @@ The `ChangePassword` component renders a `VcPopup` with three validated input fi
|
|
|
44
44
|
|
|
45
45
|
### Validation
|
|
46
46
|
|
|
47
|
-
- Field-level: `required`
|
|
47
|
+
- Field-level: `required` on every field; `min:6` on the new password and its confirmation only. The current password can be any password the account has (one set before the policy changed, or by an administrator), so it isn't checked against the policy for new passwords; the platform verifies it on save.
|
|
48
48
|
- API-level: `validatePassword()` checks the platform's password policy on each input change.
|
|
49
49
|
- Custom checks: confirms that new password matches confirmation and that the new password differs from the current one.
|
|
50
50
|
- Errors are displayed as `IdentityError` codes mapped to i18n keys: `COMPONENTS.CHANGE_PASSWORD.ERRORS.{code}`.
|
|
@@ -64,7 +64,7 @@ The `ChangePassword` component renders a `VcPopup` with three validated input fi
|
|
|
64
64
|
|
|
65
65
|
| Field | Validation | Description |
|
|
66
66
|
| ---------------- | ------------------------------------ | ---------------------------------------------- |
|
|
67
|
-
| Current Password | `required
|
|
67
|
+
| Current Password | `required` | Verifies the user knows their current password |
|
|
68
68
|
| New Password | `required`, `min:6`, platform policy | The desired new password |
|
|
69
69
|
| Confirm Password | `required`, `min:6`, must match new | Confirmation of the new password |
|
|
70
70
|
|
|
@@ -111,6 +111,14 @@ Badge values above 99 are automatically displayed as "99+".
|
|
|
111
111
|
| `--widget-border-radius` | `8px` | Corner radius |
|
|
112
112
|
| `--widget-focus-ring-color` | `var(--primary-300)` | Focus ring color |
|
|
113
113
|
|
|
114
|
+
## Alert dot and badge highlight
|
|
115
|
+
|
|
116
|
+
Inside a blade widget strip, `VcWidget` reads per-widget render state provided by the blade (it renders exactly as before elsewhere):
|
|
117
|
+
|
|
118
|
+
- **Alert dot**: a small dot on the icon (`.vc-widget__alert-dot--danger|--warning|--info`), coloured from the highest alert severity. The dot itself is `aria-hidden`; the widget gets `aria-haspopup="dialog"`, `aria-expanded` for the alert popup, and an `aria-describedby` text such as "2 need attention".
|
|
119
|
+
- **Badge change highlight**: when `value` changes, the badge pulses for about 1.5s (`.vc-widget__badge--changed`). A first change within 2s of mount counts as the initial load and does not pulse. Only inside a blade; a standalone `VcWidget` never pulses. The pulse is disabled under `prefers-reduced-motion`.
|
|
120
|
+
- **Edit mode**: while the widget layout is being edited, the badge and dot are hidden and clicks are blocked.
|
|
121
|
+
|
|
114
122
|
## Accessibility
|
|
115
123
|
|
|
116
124
|
- Renders with `role="button"` and `tabindex="0"` for keyboard interaction
|
|
@@ -188,6 +188,21 @@ By default, `padded` is `true`, which adds compact padding and rounded item back
|
|
|
188
188
|
</VcDropdown>
|
|
189
189
|
```
|
|
190
190
|
|
|
191
|
+
### Keeping Content Mounted
|
|
192
|
+
|
|
193
|
+
By default the panel is unmounted while closed. Set `forceMount` to keep it in the DOM, hidden with `display: none` and `inert`, so content inside it (data loads, subscriptions) stays alive. Outside-click and Escape handling only run while the dropdown is open, and a floating panel is repositioned each time it opens.
|
|
194
|
+
|
|
195
|
+
```vue
|
|
196
|
+
<VcDropdown v-model="isOpen" force-mount>
|
|
197
|
+
<template #trigger="{ toggle }">
|
|
198
|
+
<VcButton @click="toggle">Menu</VcButton>
|
|
199
|
+
</template>
|
|
200
|
+
<template #items-container>
|
|
201
|
+
<LiveStatusList />
|
|
202
|
+
</template>
|
|
203
|
+
</VcDropdown>
|
|
204
|
+
```
|
|
205
|
+
|
|
191
206
|
## Recipes
|
|
192
207
|
|
|
193
208
|
### Confirmation Dropdown
|
|
@@ -331,6 +346,7 @@ By default, `padded` is `true`, which adds compact padding and rounded item back
|
|
|
331
346
|
| `autoFocusPanel` | `boolean` | `true` | Focus the panel element when opened |
|
|
332
347
|
| `padded` | `boolean` | `true` | Apply compact padding and rounded item backgrounds |
|
|
333
348
|
| `zIndex` | `number` | `9300` | Z-index for the floating panel |
|
|
349
|
+
| `forceMount` | `boolean` | `false` | Keep the panel content mounted (hidden, inert) while closed |
|
|
334
350
|
|
|
335
351
|
## Events
|
|
336
352
|
|
|
@@ -393,8 +409,8 @@ By default, `padded` is `true`, which adds compact padding and rounded item back
|
|
|
393
409
|
- Floating positioning uses `useFloatingPosition` (a thin wrapper around `@floating-ui/vue`) with `autoUpdate: true` when `floating` is enabled — the panel recalculates its position on scroll/resize.
|
|
394
410
|
- Teleport target is determined by `useTeleportTarget`. When `floating` is `true`, the panel is teleported to avoid z-index stacking context issues.
|
|
395
411
|
- Keyboard navigation (`ArrowDown`, `ArrowUp`, `Home`, `End`, `Enter`/`Space`, `Escape`) is handled in `onPanelKeydown`. Escape also attaches a document-level listener when the dropdown is open to catch events that bubble past the panel.
|
|
396
|
-
- `syncFocusedIndex` is called on open to pre-focus the active item (via `isItemActive`) or the first item.
|
|
397
|
-
- The `VcDropdownItem` internal sub-component (in `_internal/VcDropdownItem.vue`) is used by `VcBreadcrumbs` for its overflow menu items.
|
|
412
|
+
- `syncFocusedIndex` is called on open to pre-focus the active item (via `isItemActive`) or the first item. That cursor is painted only while the panel is `:focus-visible`, so a mouse-opened menu shows no pre-highlighted item.
|
|
413
|
+
- The `VcDropdownItem` internal sub-component (in `_internal/VcDropdownItem.vue`) is used by `VcBreadcrumbs` for its overflow menu items. Its icon placeholder keeps titles aligned only when some item in the list has an icon or check; a list with none renders no icon gutter.
|
|
398
414
|
- Default `zIndex` is `9300` (matches the CSS custom property `--z-critical-floating-panel`).
|
|
399
415
|
|
|
400
416
|
<!-- internal:end -->
|
|
@@ -449,6 +449,24 @@ Four variants are available: `danger`, `warning`, `info`, `success`. System bann
|
|
|
449
449
|
|
|
450
450
|
On mobile, width is forced to `100%`. When `expanded` is `true`, the blade fills all available space.
|
|
451
451
|
|
|
452
|
+
## Widgets
|
|
453
|
+
|
|
454
|
+
Registered widgets (see [`useBladeWidgets`](../../../../core/composables/useBladeWidgets/index.docs.md)) are shown in a strip in the blade header on desktop and in a bottom bar on mobile.
|
|
455
|
+
|
|
456
|
+
- Clicking a widget always opens its blade.
|
|
457
|
+
- A widget with alerts shows a dot on its icon. Hovering it (desktop), tapping it (touch; the next tap or the popup's Open button opens the blade) or pressing ArrowDown on it shows the alerts popup. Widgets without alerts show no popup. Screen readers hear the alert count on the widget and on the chevron when hidden widgets have alerts.
|
|
458
|
+
- The strip holds up to 5 widgets. The rest go to the dropdown behind the chevron (a sheet on mobile). The chevron is visible whenever it has something to show: hidden widgets or Customize. On an unnamed blade whose widgets all fit the strip there is no chevron.
|
|
459
|
+
- On a narrow blade the strip shows only the widgets that fit, and the rest move to the top of the dropdown until there is room again. The saved layout does not change. The toolbar keeps room for at least its "More" button (`--blade-toolbar-buttons-min-width`, 80px by default). While customizing, the strip shows all its widgets and scrolls.
|
|
460
|
+
- Widgets hidden in the dropdown or sheet stay mounted, so their badges, alerts and refresh keep working.
|
|
461
|
+
|
|
462
|
+
### Customizing the layout
|
|
463
|
+
|
|
464
|
+
Choose Customize in the dropdown (sheet on mobile) to edit which widgets are on the blade and in what order: the minus and plus buttons move a widget off or onto the strip (plus is disabled while the strip is full), drag changes the order, and arrow keys reorder the focused widget. Reset restores the default layout, Done leaves edit mode. On desktop, closing the dropdown with Escape or the chevron discards the edit. On mobile, swiping the sheet down saves it like Done.
|
|
465
|
+
|
|
466
|
+
A widget you remove stays in the dropdown. A strip widget that is not shown right now (hidden on this record, or its module has not loaded yet) keeps its slot, and no other widget takes it. An external widget whose module loads after the blade opened appears as soon as the module registers it. New widgets fill free strip slots until the user customizes the blade; after that they appear in the dropdown.
|
|
467
|
+
|
|
468
|
+
The layout is stored per blade in `localStorage` under `VC_BLADE_WIDGETS_${bladeName.toUpperCase()}`. Blades without a `name` cannot be customized.
|
|
469
|
+
|
|
452
470
|
## Recipes
|
|
453
471
|
|
|
454
472
|
### Master-Detail (List + Details)
|
|
@@ -77,6 +77,7 @@ const open = ref(false);
|
|
|
77
77
|
| `closeThreshold` | `number` | `0.3` | Swipe fraction of panel height needed to dismiss (bottom). |
|
|
78
78
|
| `draggable` | `boolean` | `false` | Enable swipe-to-dismiss (bottom only). |
|
|
79
79
|
| `dragHandle` | `boolean` | `false` | Show iOS-style drag handle bar. |
|
|
80
|
+
| `forceMount` | `boolean` | `false` | Keep the panel content mounted (hidden, inert) while closed. |
|
|
80
81
|
|
|
81
82
|
## Events
|
|
82
83
|
|
|
@@ -117,6 +118,10 @@ When `trapFocus` is true (default), Tab key cycles through focusable elements in
|
|
|
117
118
|
|
|
118
119
|
When `position="bottom"` and `draggable` is true, users can swipe the panel down to dismiss it. The `dragHandle` prop adds a visible handle bar at the top of the panel for better affordance.
|
|
119
120
|
|
|
121
|
+
### Keeping Content Mounted
|
|
122
|
+
|
|
123
|
+
By default the panel is unmounted while closed. Set `forceMount` to keep it in the DOM, hidden with `display: none` and `inert`, so content inside it (data loads, subscriptions) stays alive. The overlay, focus trap, scroll lock and Escape handling are active only while `modelValue` is true.
|
|
124
|
+
|
|
120
125
|
### Size Presets
|
|
121
126
|
|
|
122
127
|
| Size | Width (left/right) | Height (bottom) |
|
package/runtime/knowledge/docs/core/composables/useKeyboardShortcuts/useKeyboardShortcuts.docs.md
DELETED
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: useKeyboardShortcuts
|
|
3
|
-
category: composables
|
|
4
|
-
group: utilities
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
!!! tip "Long page"
|
|
8
|
-
Use the section headings to jump directly to what you need: [Primary pattern: declare a shortcut on a toolbar item](#primary-pattern-declare-a-shortcut-on-a-toolbar-item), [Quick Start](#quick-start), [Built-in blade shortcuts](#built-in-blade-shortcuts), or [API Reference](#api-reference).
|
|
9
|
-
|
|
10
|
-
# useKeyboardShortcuts
|
|
11
|
-
|
|
12
|
-
`useKeyboardShortcuts` is the framework's keyboard-shortcut toolkit: the `hotkey` fluent builder for describing a combination, and OS-aware formatting for the tooltip and `aria-keyshortcuts` that a toolbar button shows automatically once it declares one. There is no manual `keydown` wiring in module code -- a shortcut is a plain field on a toolbar item, and the blade navigation layer dispatches it globally.
|
|
13
|
-
|
|
14
|
-
## Primary pattern: declare a shortcut on a toolbar item
|
|
15
|
-
|
|
16
|
-
Add `shortcut: hotkey.mod.s` to any `IBladeToolbar` entry. `hotkey.mod.s` resolves to `{ key: "s", mod: true }` -- `mod` is the platform's primary modifier (Cmd on macOS, Ctrl elsewhere), so one definition covers both operating systems.
|
|
17
|
-
|
|
18
|
-
```vue title="orders-details.vue"
|
|
19
|
-
<script setup lang="ts">
|
|
20
|
-
import { ref } from "vue";
|
|
21
|
-
import { hotkey, VcBlade, type IBladeToolbar } from "@vc-shell/framework";
|
|
22
|
-
|
|
23
|
-
const bladeToolbar = ref<IBladeToolbar[]>([
|
|
24
|
-
{
|
|
25
|
-
id: "save",
|
|
26
|
-
title: "Save",
|
|
27
|
-
icon: "lucide-save",
|
|
28
|
-
shortcut: hotkey.mod.s,
|
|
29
|
-
clickHandler: () => save(),
|
|
30
|
-
},
|
|
31
|
-
]);
|
|
32
|
-
</script>
|
|
33
|
-
|
|
34
|
-
<template>
|
|
35
|
-
<VcBlade :toolbar-items="bladeToolbar" />
|
|
36
|
-
</template>
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
That single field is the whole integration. The rendered "Save" button now shows a `⌘S` / `Ctrl+S` tooltip, sets `aria-keyshortcuts="Meta+S"`, and the blade navigation layer fires `clickHandler` when the user presses the combination while the blade is active -- no listener, no cleanup.
|
|
40
|
-
|
|
41
|
-
## When to reach for it
|
|
42
|
-
|
|
43
|
-
- **Declaring a shortcut on a toolbar item.** The common case: add `shortcut: hotkey.mod.s` next to `clickHandler` on an `IBladeToolbar` entry (array pattern or `useToolbar().registerToolbarItem`, see [useToolbar](../useToolbar/)).
|
|
44
|
-
- **Formatting a shortcut for custom UI.** Call `useKeyboardShortcuts()` and use `formatShortcut(def)` when you need the OS-aware label somewhere other than the built-in toolbar tooltip (a help panel, a command palette row).
|
|
45
|
-
- **Detecting the platform.** `isMac` is exposed for any OS-conditional copy or icon choice tied to a shortcut.
|
|
46
|
-
- **Not for building your own key listener.** There is no public "register a global shortcut" API outside toolbar items -- see [Common mistakes](#common-mistakes).
|
|
47
|
-
|
|
48
|
-
## Quick Start
|
|
49
|
-
|
|
50
|
-
### Import vs `useKeyboardShortcuts()` destructure
|
|
51
|
-
|
|
52
|
-
`hotkey` is a context-free singleton -- import it directly wherever you only need to build a definition:
|
|
53
|
-
|
|
54
|
-
```typescript
|
|
55
|
-
import { hotkey } from "@vc-shell/framework";
|
|
56
|
-
|
|
57
|
-
const saveShortcut = hotkey.mod.s; // { key: "s", mod: true }
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Reach for the composable form only when you also need `formatShortcut` or `isMac` (both are bound together so `formatShortcut` doesn't need an `isMac` argument at the call site):
|
|
61
|
-
|
|
62
|
-
```typescript
|
|
63
|
-
import { useKeyboardShortcuts } from "@vc-shell/framework";
|
|
64
|
-
|
|
65
|
-
const { hotkey, formatShortcut, isMac } = useKeyboardShortcuts();
|
|
66
|
-
|
|
67
|
-
const saveShortcut = hotkey.mod.s;
|
|
68
|
-
const { parts, aria } = formatShortcut(saveShortcut);
|
|
69
|
-
// isMac ? parts = ["⌘", "S"] : parts = ["Ctrl", "S"]
|
|
70
|
-
// aria = "Meta+S" on macOS, "Control+S" elsewhere
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Both forms return the same `hotkey` singleton -- the composable exists for convenience when a component already needs `isMac`/`formatShortcut`, not because `hotkey` itself carries state.
|
|
74
|
-
|
|
75
|
-
## Built-in blade shortcuts
|
|
76
|
-
|
|
77
|
-
Two shortcuts are wired up by the framework at the blade navigation root -- no client code required, and no toolbar item needed to get them:
|
|
78
|
-
|
|
79
|
-
| Shortcut | Effect | Gate |
|
|
80
|
-
| ------------ | ----------------------- | --------------------------------------------------------------------------- |
|
|
81
|
-
| `Esc` | Closes the active blade | Only if the blade is closable (has a `parentId`; workspace roots ignore it) |
|
|
82
|
-
| `Cmd/Ctrl+\` | Toggles expand/maximize | Only on desktop and only if the blade is closable |
|
|
83
|
-
|
|
84
|
-
Both are evaluated against the currently active blade, and both are skipped while the corresponding condition doesn't hold -- `Esc` on a non-closable workspace root does nothing, and `Cmd/Ctrl+\` does nothing on mobile viewports even on a closable blade.
|
|
85
|
-
|
|
86
|
-
**An explicit toolbar shortcut always wins.** The dispatcher checks the active blade's toolbar items before either built-in, so a toolbar item declared with `shortcut: hotkey.escape` (for example, a "Cancel" button) intercepts `Esc` and the built-in close never runs.
|
|
87
|
-
|
|
88
|
-
## OS adaptation
|
|
89
|
-
|
|
90
|
-
`formatShortcut(def)` returns two independent representations from one `ShortcutDefinition`, so you never branch on OS yourself:
|
|
91
|
-
|
|
92
|
-
- `parts` -- an array of OS-native keycap labels for display. macOS renders modifier glyphs in Apple's canonical order (`⌃ ⌥ ⇧ ⌘`) with no separator; Windows/Linux render words (`Ctrl`, `Alt`, `Shift`) with a `+` between chips.
|
|
93
|
-
- `aria` -- a single canonical string in W3C `aria-keyshortcuts` format (`"Control+Shift+S"`), OS-independent by design -- this is what assistive tech reads, not what's painted on screen.
|
|
94
|
-
|
|
95
|
-
```typescript
|
|
96
|
-
import { useKeyboardShortcuts } from "@vc-shell/framework";
|
|
97
|
-
|
|
98
|
-
const { hotkey, formatShortcut, isMac } = useKeyboardShortcuts();
|
|
99
|
-
const { parts } = formatShortcut(hotkey.mod.s);
|
|
100
|
-
|
|
101
|
-
// isMac === true → parts = ["⌘", "S"] → tooltip renders "⌘S" (no separator)
|
|
102
|
-
// isMac === false → parts = ["Ctrl", "S"] → tooltip renders "Ctrl+S" (with separator)
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
The toolbar button itself (`ToolbarBaseButton`) already does this for you: it calls `formatShortcut` internally and renders the result through the internal `ShortcutTooltip` (`ShortcutKbd` chips inside a `VcTooltip`). The blade and AI panel header controls use the same component. `isMac` also determines `matchesEvent`'s runtime resolution of `mod` -- the same flag drives both what the user sees and what actually fires.
|
|
106
|
-
|
|
107
|
-
## Accessibility
|
|
108
|
-
|
|
109
|
-
- **`aria-keyshortcuts`.** Set automatically on the button element whenever `shortcut` is present, using the canonical `aria` string from `formatShortcut` (for example `aria-keyshortcuts="Meta+S"`). No shortcut, no attribute -- it's never rendered empty.
|
|
110
|
-
- **Tooltip discovery.** A sighted user discovers the shortcut through the `<kbd>` chips shown on hover/focus of the toolbar button, after a 500ms delay (`ShortcutTooltip`). This is the only visual affordance; there is no separate "shortcuts list" surface in v1.
|
|
111
|
-
- **No button, no discovery.** Both `aria-keyshortcuts` and the tooltip are properties of the rendered toolbar button -- they exist only because `shortcut` lives on an `IBladeToolbar` item that renders as a button. There is currently no way to advertise a shortcut that isn't attached to a visible button (see [Common mistakes](#common-mistakes)).
|
|
112
|
-
- **Accessible name still comes from the title.** `shortcut` does not change how the button's accessible name is computed -- it still comes from the visible `title` text. Keep `title` non-empty; `shortcut` is additive, not a substitute for a labeled button.
|
|
113
|
-
- **Suppressed behind a modal.** Blade shortcuts (toolbar and built-in) do not fire while a modal (`aria-modal="true"`) is open, so a shortcut never acts on a blade hidden behind it.
|
|
114
|
-
|
|
115
|
-
## Common mistakes
|
|
116
|
-
|
|
117
|
-
### Expecting a bare key to fire while an input is focused
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
// Wrong -- assumes "e" fires globally
|
|
121
|
-
registerToolbarItem({ id: "edit", title: "Edit", shortcut: { key: "e" }, clickHandler: () => edit() });
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
A shortcut with no modifier is suppressed while focus is inside a text input, textarea, select, or a `contenteditable` element -- otherwise typing the letter "e" into a form field would trigger the button. This guard only applies to bare keys; add a modifier (`hotkey.mod.e`) if the shortcut must also fire while a field is focused.
|
|
125
|
-
|
|
126
|
-
```typescript
|
|
127
|
-
// Correct -- a modified combination is not suppressed by input focus
|
|
128
|
-
registerToolbarItem({ id: "edit", title: "Edit", shortcut: hotkey.mod.e, clickHandler: () => edit() });
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
### Two toolbar items sharing the same combination
|
|
132
|
-
|
|
133
|
-
```typescript
|
|
134
|
-
// Wrong -- both items declare mod+s on the same blade
|
|
135
|
-
const bladeToolbar = ref<IBladeToolbar[]>([
|
|
136
|
-
{ id: "save", title: "Save", shortcut: hotkey.mod.s, clickHandler: () => save() },
|
|
137
|
-
{ id: "save-as", title: "Save As", shortcut: hotkey.mod.s, clickHandler: () => saveAs() },
|
|
138
|
-
]);
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
The dispatcher walks a blade's toolbar items in declaration order and fires the _first_ item whose shortcut matches, then stops -- `save-as` never receives `Cmd/Ctrl+S` as long as `save` is registered ahead of it. In development, the dispatcher emits a deduplicated `console.warn` on keydown for this case (first item wins, second is named in the warning), for a toolbar item with an unrecognized `shortcut.key`, and for a toolbar item that overrides a built-in (`Esc`, `Cmd/Ctrl+\`). Production stays silent -- these are warnings, not thrown errors -- so still treat shortcut collisions within a blade's toolbar as a review-time concern: keep combinations unique per blade.
|
|
142
|
-
|
|
143
|
-
```typescript
|
|
144
|
-
// Correct -- distinct combinations
|
|
145
|
-
const bladeToolbar = ref<IBladeToolbar[]>([
|
|
146
|
-
{ id: "save", title: "Save", shortcut: hotkey.mod.s, clickHandler: () => save() },
|
|
147
|
-
{ id: "save-as", title: "Save As", shortcut: hotkey.mod.shift.s, clickHandler: () => saveAs() },
|
|
148
|
-
]);
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
### Expecting a button-less shortcut
|
|
152
|
-
|
|
153
|
-
```typescript
|
|
154
|
-
// Wrong -- there is no API to register a shortcut without a toolbar item
|
|
155
|
-
onMounted(() => {
|
|
156
|
-
window.addEventListener("keydown", (e) => {
|
|
157
|
-
if (e.metaKey && e.key === "s") save();
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Rolling your own `keydown` listener bypasses `isTextInputFocused` suppression, the active-blade scoping, and the tooltip/`aria-keyshortcuts` discovery that toolbar shortcuts get for free -- and it risks double-firing alongside the framework dispatcher. In v1, every shortcut must be attached to a visible `IBladeToolbar` item; there is no headless/button-less registration path. If the action genuinely has no natural button, add one (it can be low-priority/overflowed) rather than reaching for a manual listener.
|
|
163
|
-
|
|
164
|
-
## API Reference
|
|
165
|
-
|
|
166
|
-
### `hotkey`
|
|
167
|
-
|
|
168
|
-
A fluent builder, importable directly or via the composable. Modifier and key names are finite string unions, so a typo (`hotkey.mdo.s`) is a compile error, not a silent no-op.
|
|
169
|
-
|
|
170
|
-
```typescript
|
|
171
|
-
hotkey.mod.s; // { key: "s", mod: true }
|
|
172
|
-
hotkey.mod.shift.s; // { key: "s", mod: true, shift: true }
|
|
173
|
-
hotkey.ctrl.alt.delete; // { key: "delete", ctrl: true, alt: true }
|
|
174
|
-
hotkey.escape; // { key: "escape" }
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
Chainable modifiers: `mod`, `ctrl`, `meta`, `alt`, `shift` (any order, any combination). Terminal keys: letters `a`-`z`, digits `0`-`9`, `enter`, `escape`, `tab`, `space`, `delete`, `backspace`, `arrowup`/`arrowdown`/`arrowleft`/`arrowright`, `f1`-`f12`, `backslash`, `period`, `comma`, `slash`.
|
|
178
|
-
|
|
179
|
-
### `useKeyboardShortcuts()`
|
|
180
|
-
|
|
181
|
-
Context-free -- no inject/lifecycle, usable in or out of a component `setup()`.
|
|
182
|
-
|
|
183
|
-
#### Returns: `UseKeyboardShortcutsReturn`
|
|
184
|
-
|
|
185
|
-
| Property | Type | Description |
|
|
186
|
-
| ---------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
187
|
-
| `hotkey` | `HotkeyBuilder` | The same singleton as the named `hotkey` import |
|
|
188
|
-
| `formatShortcut` | `(def: ShortcutDefinition) => { parts: string[]; aria: string }` | `isMac`-bound: formats a definition into display chips and an aria string |
|
|
189
|
-
| `isMac` | `boolean` | `true` when the user agent reports macOS/iOS |
|
|
190
|
-
|
|
191
|
-
### `formatShortcut(def, isMac)`
|
|
192
|
-
|
|
193
|
-
The standalone function (also exported directly) if you need to format for an explicit `isMac` value instead of the detected one:
|
|
194
|
-
|
|
195
|
-
```typescript
|
|
196
|
-
import { formatShortcut } from "@vc-shell/framework";
|
|
197
|
-
|
|
198
|
-
formatShortcut({ key: "s", mod: true }, true); // { parts: ["⌘", "S"], aria: "Meta+S" }
|
|
199
|
-
formatShortcut({ key: "s", mod: true }, false); // { parts: ["Ctrl", "S"], aria: "Control+S" }
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### `ShortcutDefinition`
|
|
203
|
-
|
|
204
|
-
| Property | Type | Required | Description |
|
|
205
|
-
| -------- | --------- | -------- | ----------------------------------------------------------------------------------- |
|
|
206
|
-
| `key` | `string` | Yes | Single key: `"s"`, `"1"`, `"enter"`, `"escape"`, `"f2"`, `"arrowup"`, `"backslash"` |
|
|
207
|
-
| `mod` | `boolean` | No | Platform primary modifier -- Cmd on macOS, Ctrl elsewhere |
|
|
208
|
-
| `ctrl` | `boolean` | No | Literal Ctrl, regardless of platform |
|
|
209
|
-
| `meta` | `boolean` | No | Literal Cmd/Win key, regardless of platform |
|
|
210
|
-
| `alt` | `boolean` | No | Alt/Option |
|
|
211
|
-
| `shift` | `boolean` | No | Shift |
|
|
212
|
-
|
|
213
|
-
Building definitions by hand (`{ key: "s", mod: true }`) works identically to `hotkey.mod.s` -- the builder is sugar over this shape, useful when a definition needs to be constructed dynamically.
|
|
214
|
-
|
|
215
|
-
## Related
|
|
216
|
-
|
|
217
|
-
- [useToolbar](../useToolbar/) -- registers the toolbar items that carry `shortcut`; see its `IBladeToolbar`/`IToolbarItem` reference for the full item shape
|
|
218
|
-
- [Blade Navigation Composables](../../blade-navigation/) -- the blade stack that scopes shortcut dispatch to the active blade and hosts the two built-in shortcuts
|
|
219
|
-
- `IBladeToolbar` in [Core types](../../types/) -- the `shortcut?: ShortcutDefinition` field lives here
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: useLatestRequest
|
|
3
|
-
category: composables
|
|
4
|
-
group: utilities
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# useLatestRequest
|
|
8
|
-
|
|
9
|
-
Latest-wins sequencing for overlapping async work: lets a caller drop a response
|
|
10
|
-
that a newer request already superseded.
|
|
11
|
-
|
|
12
|
-
`useAsync` covers loading state, error state and error notifications, but it has
|
|
13
|
-
no notion of a superseded call — a slow earlier response can overwrite a newer
|
|
14
|
-
one. This composable fills that gap.
|
|
15
|
-
|
|
16
|
-
## When to Use
|
|
17
|
-
|
|
18
|
-
- A search field that fires a request per keystroke, where the slowest response must not win
|
|
19
|
-
- A master/detail pair, where clicking row A then row B must not leave A's details on screen
|
|
20
|
-
- Any load that can still be in flight when its blade closes
|
|
21
|
-
- When NOT to use: for a single request with no concurrent sibling — plain `useAsync` is enough
|
|
22
|
-
|
|
23
|
-
!!! note "Discards, does not cancel"
|
|
24
|
-
The superseded request still completes; its result is thrown away. The generated
|
|
25
|
-
API clients build their own `RequestInit` and accept no `AbortSignal`, so there
|
|
26
|
-
is nothing to cancel through. Aborting would additionally save the round trip and
|
|
27
|
-
belongs with a client that takes a signal.
|
|
28
|
-
|
|
29
|
-
## Quick Start
|
|
30
|
-
|
|
31
|
-
```typescript
|
|
32
|
-
import { useLatestRequest } from "@vc-shell/framework";
|
|
33
|
-
|
|
34
|
-
const search = useLatestRequest();
|
|
35
|
-
const items = ref([]);
|
|
36
|
-
|
|
37
|
-
async function load(criteria) {
|
|
38
|
-
const result = await search.latest(client.search(criteria));
|
|
39
|
-
if (!result) return; // a newer search already won
|
|
40
|
-
items.value = result;
|
|
41
|
-
}
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
`latest()` owns the bookkeeping: it marks the request as the newest, releases it
|
|
45
|
-
when it settles — including on a throw — and hands back `undefined` if a newer
|
|
46
|
-
request started meanwhile. There is no `finally` to forget.
|
|
47
|
-
|
|
48
|
-
!!! note "When `undefined` is ambiguous"
|
|
49
|
-
`undefined` means superseded. A request whose own successful result can be
|
|
50
|
-
`undefined` cannot distinguish the two, and should use `begin()` below.
|
|
51
|
-
|
|
52
|
-
## API Reference
|
|
53
|
-
|
|
54
|
-
### Returns
|
|
55
|
-
|
|
56
|
-
| Member | Type | Description |
|
|
57
|
-
| ------------ | ----------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
58
|
-
| `latest` | `<T>(request: Promise<T>) => Promise<T \| undefined>` | Runs a request, resolving to `undefined` if a newer one superseded it |
|
|
59
|
-
| `begin` | `() => LatestRequest` | Starts a request and supersedes any earlier one. The manual form behind `latest` |
|
|
60
|
-
| `invalidate` | `() => void` | Supersedes the in-flight request without starting a new one |
|
|
61
|
-
| `dispose` | `() => void` | Permanently supersedes everything. Runs automatically when the owning scope stops |
|
|
62
|
-
| `pending` | `Readonly<Ref<boolean>>` | `true` while the newest request is still running |
|
|
63
|
-
|
|
64
|
-
### `LatestRequest`
|
|
65
|
-
|
|
66
|
-
Returned by `begin()`, for the cases `latest()` cannot serve: the request and the
|
|
67
|
-
check living in different functions, or a result that is legitimately `undefined`.
|
|
68
|
-
|
|
69
|
-
| Member | Type | Description |
|
|
70
|
-
| ----------- | --------------- | ---------------------------------------------------------------------------------- |
|
|
71
|
-
| `isCurrent` | `() => boolean` | `false` once a newer request started, or after `invalidate()` / `dispose()` |
|
|
72
|
-
| `complete` | `() => void` | Marks this request finished. Idempotent; only the current request clears `pending` |
|
|
73
|
-
|
|
74
|
-
## Features
|
|
75
|
-
|
|
76
|
-
### `pending` tracks the newest request only
|
|
77
|
-
|
|
78
|
-
A superseded request finishing does **not** clear `pending` — the newer one is
|
|
79
|
-
still running, and clearing there would hide the spinner while the screen is
|
|
80
|
-
still waiting for data.
|
|
81
|
-
|
|
82
|
-
### Automatic disposal
|
|
83
|
-
|
|
84
|
-
When called inside a component or effect scope, the tracker disposes itself when
|
|
85
|
-
that scope stops, so a response landing after its blade closed can never write
|
|
86
|
-
into a dead scope. Call `dispose()` by hand only outside a scope.
|
|
87
|
-
|
|
88
|
-
## Recipes
|
|
89
|
-
|
|
90
|
-
### Driving a spinner
|
|
91
|
-
|
|
92
|
-
```typescript
|
|
93
|
-
const details = useLatestRequest();
|
|
94
|
-
// `pending` is a ref, so watch it, render it, or hand it to useLoading.
|
|
95
|
-
const loading = details.pending;
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
### Dropping a request on selection change
|
|
99
|
-
|
|
100
|
-
```typescript
|
|
101
|
-
watch(selectedId, () => {
|
|
102
|
-
// Nothing new starts yet; whatever is in flight stops being current.
|
|
103
|
-
details.invalidate();
|
|
104
|
-
});
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
## Common Mistakes
|
|
108
|
-
|
|
109
|
-
**Wrong: checking before the await**
|
|
110
|
-
|
|
111
|
-
```typescript
|
|
112
|
-
const request = search.begin();
|
|
113
|
-
if (!request.isCurrent()) return; // always true here — nothing has superseded it yet
|
|
114
|
-
const result = await client.search(criteria);
|
|
115
|
-
items.value = result; // still overwrites newer data
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
**Right: checking after**
|
|
119
|
-
|
|
120
|
-
```typescript
|
|
121
|
-
const request = search.begin();
|
|
122
|
-
const result = await client.search(criteria);
|
|
123
|
-
if (!request.isCurrent()) return;
|
|
124
|
-
items.value = result;
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
**Wrong: completing only on success**
|
|
128
|
-
|
|
129
|
-
```typescript
|
|
130
|
-
const request = search.begin();
|
|
131
|
-
const result = await client.search(criteria); // throws → pending stays true forever
|
|
132
|
-
request.complete();
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
**Right: completing in `finally`**
|
|
136
|
-
|
|
137
|
-
```typescript
|
|
138
|
-
const request = search.begin();
|
|
139
|
-
try {
|
|
140
|
-
const result = await client.search(criteria);
|
|
141
|
-
if (!request.isCurrent()) return;
|
|
142
|
-
items.value = result;
|
|
143
|
-
} finally {
|
|
144
|
-
request.complete();
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
## Related
|
|
149
|
-
|
|
150
|
-
- [useAsync](./useAsync.md) — loading state, error state and error notifications for a single call
|
|
151
|
-
- [useLoading](./useLoading.md) — aggregates several loading flags
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Focus targets
|
|
3
|
-
category: composables
|
|
4
|
-
group: utilities
|
|
5
|
-
internal: true
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Focus targets
|
|
9
|
-
|
|
10
|
-
Where focus goes after a context change, and which code puts it there. A transition that leaves focus on `<body>` makes the next Tab restart at the top of the document, which fails WCAG 2.4.3 Focus Order — so every transition below has a named target, and none of them may end on `<body>`.
|
|
11
|
-
|
|
12
|
-
Two kinds of move appear in the table, and the difference decides what you should expect to see:
|
|
13
|
-
|
|
14
|
-
- **Repair** — `focusIfLoose(getTarget)` moves focus only when nothing meaningful holds it. If the user clicked a row, or the view autofocused a field, focus stays where it is. A repair that declines is correct behaviour, not a miss.
|
|
15
|
-
- **Handoff** — a deliberate `focus()` call, used when the element the user was on stops existing. It moves focus even though something held it, because that something is about to be unmounted.
|
|
16
|
-
|
|
17
|
-
## Targets per transition
|
|
18
|
-
|
|
19
|
-
| Transition | Target | Kind | Implemented in |
|
|
20
|
-
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
-
| Sign-in, and any route change | the shell workspace (`.vc-app__workspace`, `tabindex="-1"`) | repair | `vc-app.vue` — `watch(route.fullPath)`, plus a `watch(workspaceRef)` because sign-in is not a route change this component can observe |
|
|
22
|
-
| Sign-out | the unauthenticated layout root | repair | `vc-auth-layout.vue` — `onMounted` |
|
|
23
|
-
| Blade open | the blade root | repair | `vc-blade.vue` — `onMounted` |
|
|
24
|
-
| Maximize / Restore — header button | the replacement control (`Restore` after maximize, `Maximize` after restore) | handoff | `shared/header-controls/header-controls.vue` (blade header and AI panel header) — `watch(maximized)`, `flush: "pre"` |
|
|
25
|
-
| Maximize / Restore — `mod+\` shortcut | same as the header button | handoff | same watch. The shortcut dispatches outside the click handlers, so the handoff hangs off the state change rather than either entry point (VCST-5812) |
|
|
26
|
-
| Maximize while focus is outside the blade (sidebar, app bar, global search) | the blade root | repair | `vc-blade.vue` — `watch(renderingState.maximized)`. Maximizing inerts those regions, and an inert node loses focus a tick later, so `focusIsLoose` treats an element inside `[inert]` as loose (VCST-5859) |
|
|
27
|
-
| Save, and any load that raises `loading` | the blade root | repair | `vc-blade.vue` — `watch(props.loading)`. The toolbar re-renders and the control the user activated can stop existing; before this, only a blade that happened to remount recovered, through the mount repair (VCST-5670) |
|
|
28
|
-
| First load, before content has ever rendered | the blade root | park | `vc-blade.vue` — `watch(showSkeleton)`, which parks focus that was inside the blade before the skeleton replaces its controls |
|
|
29
|
-
| Popup close | the opener, or the workspace when the opener is gone | repair | `usePopup` for imperative popups; `vc-popup.vue` on `@after-leave` for `v-model` ones, because Headless UI restores focus only when it owns the unmount (VCST-5676). Both call `focusIfLoose(() => opener ?? focusFallbackTarget())`, and the second to run declines |
|
|
30
|
-
|
|
31
|
-
A blade mounting inside the workspace requests focus after the workspace does, so the more specific context wins. That ordering is deliberate.
|
|
32
|
-
|
|
33
|
-
## Asserting it
|
|
34
|
-
|
|
35
|
-
Focus transitions are invisible to axe, so the story-level a11y gate proves nothing here. Perform the action, then read `document.activeElement`:
|
|
36
|
-
|
|
37
|
-
```js
|
|
38
|
-
document.activeElement.tagName; // must never be "BODY"
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Pressing Tab once and checking that focus continues is a weaker check and on some paths no check at all: when the nav precedes `main` in DOM order, a genuine continuation and a restart from the document's first stop land on the same control. The absent `[active]` node is the evidence, not the next stop.
|
|
42
|
-
|
|
43
|
-
Chrome keeps the sequential-focus navigation starting point at a removed node, so Tab can still reach the right control even after focus was lost. That is engine-specific and does nothing for a screen reader — `document.activeElement` is still `BODY` and the focus ring is gone.
|
|
44
|
-
|
|
45
|
-
## Related
|
|
46
|
-
|
|
47
|
-
- `focusIfLoose`, `focusFallbackTarget` — `framework/core/utilities/focus.ts`
|
|
48
|
-
- VCST-5670 — the ticket this table was written for
|