@vc-shell/vc-app-skill 2.9.0 → 2.10.0-pr401.35bf698

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vc-shell/vc-app-skill",
3
- "version": "2.9.0",
3
+ "version": "2.10.0-pr401.35bf698",
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.9.0
1
+ 2.10.0
@@ -1 +1 @@
1
- Synced from framework at commit fd1e671ba on 2026-11-02T09:58:34.572Z
1
+ Synced from framework at commit 413c13a36 on 2026-11-02T10:54:29.692Z
@@ -101,13 +101,13 @@ defineBladeContext(
101
101
 
102
102
  ## Recipe: Consumers Registered from Another Module
103
103
 
104
- Toolbar buttons and headless widgets registered by blade name from another module are plain objects, so `injectBladeContext()` has no component to run in. The framework hands them the context instead:
104
+ Toolbar buttons and headless widgets registered by blade name from another module are plain objects, so `injectBladeContext()` has no component to run in. The framework calls their function forms with two arguments instead: `(blade, ctx)`, the descriptor of the blade the button or widget sits on and its context. The blade comes first because `isVisible: (blade) => …` has worked that way since 1.1.37.
105
105
 
106
- | Registration | Receives the context |
107
- | ----------------------------------------------------- | --------------------------------------------------------------------------------- |
108
- | `registerExternalToolbarItem` / `registerToolbarItem` | `clickHandler(ctx)`, `disabled: (ctx) => …`, `isVisible: (blade, ctx) => …` |
109
- | `registerExternalWidget` / `registerWidget` | `isVisible: (blade, ctx) => …`; the component itself calls `injectBladeContext()` |
110
- | `registerWidget` with `headless` | `headless.onClick(ctx)`, `headless.disabled: (ctx) => …` |
106
+ | Registration | Called with `(blade, ctx)` |
107
+ | ----------------------------------------------------- | -------------------------------------------------------------- |
108
+ | `registerExternalToolbarItem` / `registerToolbarItem` | `clickHandler`, `disabled`, `isVisible` |
109
+ | `registerExternalWidget` / `registerWidget` | `isVisible`; the component itself calls `injectBladeContext()` |
110
+ | `registerWidget` with `headless` | `headless.onClick`, `headless.disabled` |
111
111
 
112
112
  ```typescript
113
113
  // In the blade
@@ -118,13 +118,24 @@ registerExternalToolbarItem({
118
118
  id: "bulk-export",
119
119
  title: "Export selected",
120
120
  targetBlades: ["Products"],
121
- disabled: (ctx) => !(ctx.selectedItems as Product[] | undefined)?.length,
122
- clickHandler: (ctx) => exportProducts(ctx?.selectedItems as Product[]),
121
+ disabled: (_blade, ctx) => !(ctx.selectedItems as Product[] | undefined)?.length,
122
+ clickHandler: (_blade, ctx) => exportProducts(ctx.selectedItems as Product[]),
123
123
  });
124
124
  ```
125
125
 
126
126
  The values are the same unwrapped snapshot `injectBladeContext()` returns, and the state is reactive: `disabled` and `isVisible` follow the blade's refs. A blade without `defineBladeContext` gives these handlers `{}`, not an error.
127
127
 
128
+ `ctx` does not say which blade it came from. When one registration targets several blades, tell them apart by `blade.name`:
129
+
130
+ ```typescript
131
+ registerExternalToolbarItem({
132
+ id: "bulk-export",
133
+ targetBlades: ["Products", "Offers"],
134
+ disabled: (_blade, ctx) => !(ctx.selectedItems as unknown[] | undefined)?.length,
135
+ clickHandler: (blade, ctx) => (blade?.name === "Offers" ? exportOffers(ctx.selectedItems as Offer[]) : exportProducts(ctx.selectedItems as Product[])),
136
+ });
137
+ ```
138
+
128
139
  ## Details
129
140
 
130
141
  - **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.
@@ -199,7 +199,7 @@ The name match is case-insensitive. If the blade registers its own item with the
199
199
 
200
200
  ### Reading the blade's data
201
201
 
202
- A button registered from outside cannot call `injectBladeContext()`: its handlers run outside the blade's component tree. The framework passes them the data the blade exposed with [`defineBladeContext`](../bladeContext/) instead. `clickHandler` receives it, and so do the function forms of `disabled` and `isVisible`:
202
+ A button registered from outside cannot call `injectBladeContext()`: its handlers run outside the blade's component tree. The framework calls `clickHandler` and the function forms of `disabled` and `isVisible` with `(blade, ctx)`: the descriptor of the blade the button sits on, and the data it exposed with [`defineBladeContext`](../bladeContext/).
203
203
 
204
204
  ```typescript
205
205
  // In the target blade
@@ -211,12 +211,22 @@ registerExternalToolbarItem({
211
211
  title: "Export selected",
212
212
  icon: "lucide-download",
213
213
  targetBlades: ["Products"],
214
- disabled: (ctx) => !(ctx.selectedItems as Product[] | undefined)?.length,
215
- clickHandler: (ctx) => exportProducts(ctx?.selectedItems as Product[]),
214
+ disabled: (_blade, ctx) => !(ctx.selectedItems as Product[] | undefined)?.length,
215
+ clickHandler: (_blade, ctx) => exportProducts(ctx.selectedItems as Product[]),
216
216
  });
217
217
  ```
218
218
 
219
- Refs are already unwrapped, and the state follows the blade: the button enables as soon as the user selects a row. A keyboard `shortcut` on the item gets the same context. When the blade defines no context, the handlers get `{}`.
219
+ Refs are already unwrapped, and the state follows the blade: the button enables as soon as the user selects a row. A keyboard `shortcut` on the item gets the same arguments. When the blade defines no context, `ctx` is `{}`.
220
+
221
+ `ctx` does not say which blade it came from. With several `targetBlades`, branch on `blade.name`:
222
+
223
+ ```typescript
224
+ registerExternalToolbarItem({
225
+ id: "bulk-export",
226
+ targetBlades: ["Products", "Offers"],
227
+ clickHandler: (blade, ctx) => (blade?.name === "Offers" ? exportOffers(ctx.selectedItems as Offer[]) : exportProducts(ctx.selectedItems as Product[])),
228
+ });
229
+ ```
220
230
 
221
231
  ## Auto-cleanup control
222
232
 
@@ -404,9 +414,9 @@ function helperFunction() {
404
414
  | `id` | `string` | Yes | Unique identifier for the button |
405
415
  | `title` | `string \| Ref<string> \| ComputedRef<string>` | No | Button label (supports reactive values) |
406
416
  | `icon` | `string \| (() => string)` | No | Icon class (e.g., `"fas fa-save"`) or a function returning one |
407
- | `clickHandler` | `(context?: BladeContext) => void` | No | Click callback; receives the blade context |
408
- | `disabled` | `boolean \| ComputedRef<boolean \| undefined> \| ((context: BladeContext) => boolean \| undefined)` | No | Whether the button is disabled; a function receives the blade context |
409
- | `isVisible` | `boolean \| Ref<boolean \| undefined> \| ComputedRef<boolean \| undefined> \| ((blade, context) => boolean \| undefined)` | No | Controls button visibility; a function receives the blade descriptor and the blade context |
417
+ | `clickHandler` | `(blade, context) => void` | No | Click callback; receives the blade and its context |
418
+ | `disabled` | `boolean \| ComputedRef<boolean \| undefined> \| ((blade, context) => boolean \| undefined)` | No | Whether the button is disabled; a function receives the blade and its context |
419
+ | `isVisible` | `boolean \| Ref<boolean \| undefined> \| ComputedRef<boolean \| undefined> \| ((blade, context) => boolean \| undefined)` | No | Controls button visibility; a function receives the blade and its context |
410
420
  | `priority` | `number` | No | Sort order (higher = displayed first, default `0`) |
411
421
  | `separator` | `"left" \| "right" \| "both"` | No | Adds a visual divider next to the button |
412
422
  | `permissions` | `string \| string[]` | No | Required permission(s) to display the button |
@@ -118,7 +118,7 @@ export default {
118
118
  };
119
119
  ```
120
120
 
121
- The widget component reads the blade's data with [`injectBladeContext()`](../bladeContext/), since it renders inside the blade. `isVisible` runs before the component exists, so a function form receives the blade context as its second argument:
121
+ The widget component reads the blade's data with [`injectBladeContext()`](../bladeContext/), since it renders inside the blade. `isVisible` runs before the component exists, so a function form receives `(blade, ctx)`: the blade descriptor and the blade context.
122
122
 
123
123
  ```typescript
124
124
  registerExternalWidget({
@@ -126,11 +126,11 @@ registerExternalWidget({
126
126
  targetBlades: ["OrderDetails"],
127
127
  component: markRaw(ShippingTracker),
128
128
  // the blade calls defineBladeContext({ item })
129
- isVisible: (blade, ctx) => (ctx.item as Order | undefined)?.status === "Shipped",
129
+ isVisible: (_blade, ctx) => (ctx.item as Order | undefined)?.status === "Shipped",
130
130
  });
131
131
  ```
132
132
 
133
- A headless widget registered by blade name has no component to inject from, so its `onClick` and a function `disabled` receive the context too:
133
+ A headless widget registered by blade name has no component to inject from, so its `onClick` and a function `disabled` receive `(blade, ctx)` too:
134
134
 
135
135
  ```typescript
136
136
  registerWidget(
@@ -139,8 +139,8 @@ registerWidget(
139
139
  title: "Invoice",
140
140
  headless: {
141
141
  icon: "lucide-receipt",
142
- disabled: (ctx) => !ctx.item,
143
- onClick: (ctx) => openInvoice((ctx?.item as Order).id),
142
+ disabled: (_blade, ctx) => !ctx.item,
143
+ onClick: (_blade, ctx) => openInvoice((ctx.item as Order).id),
144
144
  },
145
145
  },
146
146
  "OrderDetails",
@@ -359,9 +359,10 @@ interface IBladeToolbar {
359
359
  id?: string;
360
360
  title?: string | Ref<string> | ComputedRef<string>;
361
361
  icon?: string | (() => string);
362
- clickHandler?(app?: Record<string, any> | null): void;
363
- disabled?: boolean | ComputedRef<boolean | undefined>;
364
- isVisible?: boolean | Ref<boolean | undefined> | ComputedRef<boolean | undefined> | ((blade?: BladeDescriptor) => boolean | undefined);
362
+ // function forms get (blade, context): the blade descriptor and the blade context
363
+ clickHandler?(blade: BladeDescriptor | undefined, context: BladeContext): void;
364
+ disabled?: boolean | ComputedRef<boolean | undefined> | ((blade: BladeDescriptor | undefined, context: BladeContext) => boolean | undefined);
365
+ isVisible?: boolean | Ref<boolean | undefined> | ComputedRef<boolean | undefined> | ((blade: BladeDescriptor | undefined, context: BladeContext) => boolean | undefined);
365
366
  separator?: "left" | "right" | "both";
366
367
  permissions?: string | string[];
367
368
  }
@@ -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