@vc-shell/vc-app-skill 2.5.0-pr351.8e074ad → 2.6.0-rc.0

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.5.0-pr351.8e074ad",
3
+ "version": "2.6.0-rc.0",
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.5.0
1
+ 2.6.0-rc.0
@@ -1 +1 @@
1
- Synced from framework at commit 2964ccbe3 on 2026-08-19T10:23:33.758Z
1
+ Synced from framework at commit 9cf874d09 on 2026-09-02T10:19:47.488Z
@@ -44,10 +44,36 @@ The `createBladeStack` / `createBladeMessaging` factories are internal (not re-e
44
44
 
45
45
  The blade stack state machine. Manages an ordered array of `BladeDescriptor` objects.
46
46
 
47
- ### Factory: `createBladeStack(bladeRegistry, hasAccess?)`
47
+ ### Factory: `createBladeStack(bladeRegistry, hasAccess?, urlSink?)`
48
48
 
49
49
  Creates a new stack instance. Called once by the navigation plugin.
50
50
 
51
+ `urlSink` is where the stack writes the address bar. It defaults to a no-op, so a
52
+ stack created without one (Storybook, standalone mounts, unit tests) simply never
53
+ touches the URL. The plugin passes the router-backed sink.
54
+
55
+ ### The stack owns the URL
56
+
57
+ Every navigation action syncs the URL itself, with the verb the action implies.
58
+ Callers mutate the stack and nothing else — there is no "and now sync the URL"
59
+ step to forget.
60
+
61
+ | Action | Verb | When |
62
+ | --------------------- | --------- | -------------------------------------- |
63
+ | `openWorkspace` | `push` | the resulting active blade has a `url` |
64
+ | `openBlade` | `push` | the resulting active blade has a `url` |
65
+ | `coverCurrentBlade` | `push` | the resulting active blade has a `url` |
66
+ | `replaceCurrentBlade` | `replace` | the resulting active blade has a `url` |
67
+ | `closeBlade` | `replace` | the close was not prevented by a guard |
68
+ | `closeChildren` | `replace` | always |
69
+
70
+ Everything else — `updateBladeQuery`, `setBladeTitle`, `setBladeError`,
71
+ `_restoreStack` — never writes the URL.
72
+
73
+ The router guard restores the stack **from** the URL, so it suppresses the sink
74
+ for the duration of the navigation (`RouterUrlSink.suppressWhile`): during
75
+ resolution the URL is the source, and writing back would re-enter the guard.
76
+
51
77
  ### API
52
78
 
53
79
  | Method | Description |
@@ -162,6 +188,7 @@ The plain data object stored in the stack for each blade:
162
188
  - Close guards return `true` to PREVENT closing (opposite of the legacy convention where `false` prevented closing). The adapter handles the inversion.
163
189
  - `replaceCurrentBlade` destroys the current blade and creates a new one at the same index with the same `parentId`. Use `coverCurrentBlade` to hide instead of destroy — the covering blade's `callParent` reaches the hidden blade's exposed methods.
164
190
  - URL sync only updates the address bar for blades that have a `url` segment. Third-level detail panels without URLs leave the previous URL intact.
191
+ - The stack writes the URL, not the caller. `createUrlSync` remains for the two writes that are not stack mutations: the post-restore reconcile in `VcBladeNavigation` and the debounced table-query-state writer.
165
192
  - `useBladeNavigation()` requires the navigation plugin to be installed. It throws if called before plugin initialization.
166
193
 
167
194
  ## Related
@@ -790,7 +790,7 @@ if (param.value) {
790
790
  1. **Plugin setup:** `BladeNavigationPlugin` (plugin-v2) installs a `BladeStack` and `BladeMessaging` singleton and provides them via Vue's dependency injection.
791
791
  2. **Inside a blade:** `VcBladeSlot` provides a `BladeDescriptor` ref for each blade instance. `useBlade()` injects this descriptor to access identity, register guards, and set errors.
792
792
  3. **Outside a blade:** `useBlade()` falls back to the singleton instances. Only `openBlade` works because it does not need a blade descriptor.
793
- 4. **URL sync:** When blades with a `url` property are opened or closed, `useBlade()` synchronizes the browser URL via `createUrlSync`, enabling deep linking and back/forward navigation.
793
+ 4. **URL sync:** When blades with a `url` property are opened or closed, the blade stack synchronizes the browser URL itself (push on open, replace on close), enabling deep linking and back/forward navigation. `useBlade()` only mutates the stack.
794
794
 
795
795
  <!-- internal:end -->
796
796
 
@@ -0,0 +1,219 @@
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 `ShortcutKbd` chip component inside a tooltip. `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 (`ShortcutKbd`, wrapped in `VcTooltip`). 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
@@ -0,0 +1,151 @@
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
@@ -6,6 +6,17 @@ group: forms
6
6
 
7
7
  # useModificationTracker
8
8
 
9
+ !!! warning "Deprecated — use `useBladeForm`"
10
+ `useBladeForm` owns validation, the dirty flag, the unsaved-changes guard and the
11
+ close prompt together, where this tracks only the dirty flag. It has no callers
12
+ left in the framework or the reference app, and `cli/migrate`'s `use-blade-form`
13
+ transform already flags the `useForm` + `useBeforeUnload` +
14
+ `useModificationTracker` combination for the same move.
15
+
16
+ Its comparator is deliberately not being aligned with `useBladeForm`'s: an
17
+ empty array counts as empty there and not here, and changing that would move
18
+ the dirty flag under anyone still on this composable.
19
+
9
20
  Tracks deep changes to a value and reports whether it has been modified compared to its original (pristine) state. This is the core composable behind the "unsaved changes" detection pattern in vc-shell blades. It handles complex nested objects, arrays, and the common API quirk where `null`, `undefined`, and `""` should be treated as equivalent empty values.
10
21
 
11
22
  The composable maintains two copies of the data: the `pristineValue` (the clean baseline) and the `currentValue` (the working copy bound to form fields). Any deep difference between the two sets `isModified` to `true`.
@@ -110,6 +110,8 @@ The plugin instance is resolved via `inject(PopupPluginKey)`. If called outside
110
110
 
111
111
  Multiple popups can be open simultaneously — they stack with the most recent on top.
112
112
 
113
+ On close, focus returns to the control that opened the popup (WCAG 2.4.3 Focus Order). When that control was never focused, or has since been removed from the page, focus goes to the app workspace instead — never left on `<body>`, which would make the next Tab restart at the top of the document. A popup that deliberately moved focus somewhere else keeps it; this repairs lost focus rather than dictating it.
114
+
113
115
  ## Recipe: Delete Confirmation with Notification
114
116
 
115
117
  ```vue
@@ -8,8 +8,6 @@ group: ui-state
8
8
 
9
9
  Controls the sidebar (left navigation panel) expansion, pinning, hover, and mobile menu state. This composable exposes a unified API for reading and toggling all sidebar states from any component within the VcApp tree. The sidebar has three independent dimensions: **pinned** (user explicitly locked it open, persisted to localStorage), **hover-expanded** (mouse is hovering over the collapsed sidebar on desktop), and **mobile menu** (overlay drawer on small screens). The derived `isExpanded` computed combines pinned and hover states for convenience.
10
10
 
11
- Internally, it delegates to `useMenuExpanded()` to share reactive state with other components like `UserDropdownButton` that also need to know the sidebar state.
12
-
13
11
  ## When to Use
14
12
 
15
13
  - Toggle or read sidebar expanded/collapsed state from any component inside VcApp
@@ -82,6 +80,20 @@ provideSidebarState();
82
80
 
83
81
  <!-- internal:end -->
84
82
 
83
+ ## Storage key
84
+
85
+ The pinned state lives in `localStorage` under an app-scoped key -- the first segment of the URL path, so several vc-shell apps on one domain keep independent sidebars. `sidebarStorageKey()` returns that key, so tests and stories can seed the state before `VcApp` mounts instead of hard-coding the string:
86
+
87
+ ```typescript
88
+ import { sidebarStorageKey } from "@vc-shell/framework";
89
+
90
+ // Served from "/operations/catalog" -> "VC_APP_MENU_EXPANDED_operations"
91
+ localStorage.setItem(sidebarStorageKey(), "true");
92
+
93
+ // Or name the app explicitly
94
+ localStorage.setItem(sidebarStorageKey("operations"), "false");
95
+ ```
96
+
85
97
  ## Recipe: Auto-Close Mobile Menu After Navigation
86
98
 
87
99
  ```vue
@@ -138,10 +150,9 @@ const contentClass = computed(() => (isExpanded.value ? "tw-ml-64" : "tw-ml-16")
138
150
  ## Tips
139
151
 
140
152
  - **Hover delay prevents flicker.** The 200ms delay on `setHoverExpanded(true)` means that quickly passing the mouse over the sidebar does not cause it to flash open. Closing is instant so the sidebar feels responsive.
141
- - **`isPinned` is persisted to localStorage.** If a user pins the sidebar, it stays pinned across page reloads and browser sessions. The key is managed internally by `useMenuExpanded`.
153
+ - **`isPinned` is persisted to localStorage.** If a user pins the sidebar, it stays pinned across page reloads and browser sessions. The key is app-scoped -- `sidebarStorageKey()` returns it.
142
154
  - **Do not call outside VcApp.** If you call `useSidebarState()` in a component that is not a descendant of VcApp (e.g., in a standalone dialog or a separate Vue app instance), it will throw an error.
143
155
 
144
156
  ## Related
145
157
 
146
- - `useMenuExpanded` -- lower-level composable that manages pinned/hover state and localStorage persistence
147
158
  - `VcApp` -- the root component that calls `provideSidebarState()`
@@ -350,6 +350,10 @@ function helperFunction() {
350
350
  | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
351
351
  | `provideToolbarService()` | Creates and provides the toolbar service via Vue injection. Idempotent -- returns existing service if already provided. Cleans up via `onScopeDispose`. |
352
352
 
353
+ !!! note "When you need it"
354
+
355
+ Inside a shell app the framework install already provides the toolbar service, so `provideToolbarService()` returns the existing instance and you do not need to call it. Use it when bootstrapping an isolated tree (stories, tests) that has not installed the framework, the same way as `provideWidgetService()` and `provideDashboardService()`.
356
+
353
357
  ### IToolbarItem
354
358
 
355
359
  | Property | Type | Required | Description |
@@ -41,7 +41,6 @@ Every service follows the same architecture:
41
41
  | **SettingsMenuService** | `createSettingsMenuService()` | `addSettingsMenuItem()` | `ISettingsMenuItem` |
42
42
  | **AppBarWidgetService** | `createAppBarWidgetService()` | `addAppBarWidget()` | `AppBarWidget` |
43
43
  | **AppBarMobileButtonsService** | `createAppBarMobileButtonsService()` | (direct API) | `AppBarButtonContent` |
44
- | **GlobalSearchService** | `createGlobalSearchService()` | (direct API) | per-blade search state |
45
44
  | **LanguageService** | `createLanguageService()` | (direct API) | locale strings |
46
45
 
47
46
  ## Usage
@@ -315,6 +315,7 @@ All visual properties are customizable through CSS custom properties. Each varia
315
315
  - `aria-pressed` reflects the `selected` prop for toggle buttons
316
316
  - Icon-only buttons **must** include `ariaLabel` for screen reader users
317
317
  - Disabled buttons use `pointer-events: none` and `opacity: 0.5` to prevent interaction
318
+ - A button that becomes disabled **while it holds focus** keeps the focus: it switches to `aria-disabled` instead of the native attribute, since a natively disabled element cannot be focused and focus would fall to `<body>` (WCAG 2.4.3). It stays inert, and the user is still on it when the work finishes. Every other disabled button keeps the native attribute and stays out of the tab order.
318
319
  - The `click` event calls `e.preventDefault()` -- use `type="submit"` for form submissions
319
320
 
320
321
  ## Related Components
@@ -59,6 +59,7 @@ const open = ref(false);
59
59
  | `contentScrollable` | `boolean` | `true` | Enable scrolling in the content area |
60
60
  | `closeOnClickOutside` | `boolean` | `true` | Close when clicking outside |
61
61
  | `closeOnEscape` | `boolean` | `true` | Close on Escape key |
62
+ | `inert` | `boolean` | `false` | Hide the panel from assistive technology and keyboard focus while something covers it. The panel is teleported, so it does not inherit `inert` from an ancestor — pass it explicitly |
62
63
 
63
64
  ## Events
64
65
 
@@ -143,7 +144,7 @@ The panel's z-index comes from the global `--z-critical-floating-panel` token an
143
144
  - Escape key dismisses the panel (configurable via `closeOnEscape`)
144
145
  - Click-outside detection uses `pointerdown` for reliable z-index handling
145
146
  - Supports nested panels via an internal anchor registry -- child panel clicks do not close parent panels
146
- - Exposes `close()` method via template ref
147
+ - Exposes `close()` and `panelEl` (the teleported panel element) via template ref
147
148
 
148
149
  ## Related Components
149
150
 
@@ -157,7 +158,7 @@ The panel's z-index comes from the global `--z-critical-floating-panel` token an
157
158
  - Click-outside detection uses a `pointerdown` listener on `document` rather than a backdrop overlay. This ensures clicks on high-z-index siblings (sidebar, other teleported panels) are still caught.
158
159
  - `panelAnchorRegistry` (`panel-anchor-registry.ts`) is a `WeakMap<Element, Element | null>` that maps each open panel's DOM element to its anchor element. When `anchorRef` is a floating-ui `VirtualElement`, the registry stores its `contextElement` (the live DOM element backing the virtual reference). This is used to distinguish child panels (whose anchor lives inside this panel) from sibling/parent panels when deciding whether to close on outside click.
159
160
  - Nested VcSelect dropdowns teleported to `<body>` are exempt from click-outside via an ARIA `aria-controls` check: if the clicked `.vc-select__dropdown` has an `id` that matches an `aria-controls` attribute inside this panel, the close is suppressed.
160
- - The panel exposes a `close()` method via `defineExpose` for use with template refs.
161
+ - The panel exposes `close()` and `panelEl` via `defineExpose` for use with template refs. `panelEl` is the teleported panel itself — a call site managing focus needs it to answer "is focus still inside?", and cannot reach it any other way.
161
162
  - `useTeleportTarget` resolves the teleport destination (typically `body`) respecting any custom portal container.
162
163
 
163
164
  <!-- internal:end -->
@@ -79,7 +79,7 @@ VcApp orchestrates several internal systems:
79
79
  1. **Layout** -- switches between `DesktopLayout` (sidebar) and `MobileLayout` (top bar) based on viewport width. The breakpoint is managed by the framework's responsive detection system.
80
80
  2. **Blade Navigation** -- renders `VcBladeNavigation` for the stacked panel workspace. Blades open to the right and scroll horizontally.
81
81
  3. **Module Loading** -- consumes `DynamicModulesKey` for runtime module registration; shows error notifications on failure. Modules declare routes, menu items, and services.
82
- 4. **Services** -- bootstraps shell services (Menu, Toolbar, Settings, Dashboard, GlobalSearch, etc.) via `useShellBootstrap`. These services are available to all child components through provide/inject.
82
+ 4. **Services** -- provides the shell-scoped services via `useShellBootstrap`: dashboard, app bar mobile buttons, and the AI agent when configured. Menu, toolbar, widget and settings services are provided earlier, by the framework install. All are reachable from child components through provide/inject.
83
83
 
84
84
  <!-- internal:end -->
85
85
 
@@ -211,6 +211,83 @@ For any rendering not covered by built-in types, use the `#body` slot:
211
211
  </VcColumn>
212
212
  ```
213
213
 
214
+ ### Custom Cell Types
215
+
216
+ A `#body` slot covers one-off formatting. When the same rendering should apply
217
+ to a column `type` across every table in the app, register a cell component
218
+ instead. Registration is global and reactive — already-mounted tables pick the
219
+ new component up.
220
+
221
+ ```ts
222
+ // src/cells/register.ts — imported once from your app entry point
223
+ import { useCellRegistry } from "@vc-shell/framework";
224
+ import CellRating from "./CellRating.vue";
225
+
226
+ const { register } = useCellRegistry();
227
+
228
+ register({
229
+ type: "rating",
230
+ component: CellRating,
231
+ config: { editable: false },
232
+ });
233
+ ```
234
+
235
+ ```vue
236
+ <VcColumn id="rating" field="rating" title="Rating" type="rating" />
237
+ ```
238
+
239
+ The cell component receives a `value` prop holding the resolved field value.
240
+ When `config.editable` is `true`, it also receives `editable`, `label`,
241
+ `fieldName`, `fieldId`, `rules` and `rowIndex` while the row is being edited —
242
+ mirror `CellDefault.vue` for that contract.
243
+
244
+ Registering an existing type replaces the built-in for the whole app:
245
+
246
+ ```ts
247
+ register({ type: "money", component: MyMoneyCell, config: { editable: true } });
248
+ ```
249
+
250
+ The 12 built-ins are registered by `registerBuiltinCells()`, which runs once
251
+ when the table's cell renderer module is loaded — that is, as soon as anything
252
+ imports `VcDataTable`. Because ES modules finish evaluating before the module
253
+ that imported them runs, that always happens before your own registration code,
254
+ and `register()` is last-write-wins — so a type you register replaces the
255
+ built-in. `registerBuiltinCells()` itself skips types that are already present,
256
+ which keeps it idempotent and stops it clobbering a registration that did manage
257
+ to land first (for instance when the table is loaded through a dynamic
258
+ `import()`). `BUILTIN_CELL_TYPES` lists the names that are taken.
259
+
260
+ A `type` that resolves to nothing falls back to `CellDefault`, which renders the
261
+ raw value. Custom names are allowed, so a misspelt built-in is not a type error;
262
+ in development builds an unresolved type logs a warning naming the type.
263
+
264
+ #### Cell registry API
265
+
266
+ | Member | Signature | Description |
267
+ | -------------------- | ------------------------------------------------- | -------------------------------------------------------- |
268
+ | `register` | `(registration: CellRegistration) => void` | Register or replace a cell type. |
269
+ | `get` | `(type: string) => CellRegistration \| undefined` | Look up a registration. |
270
+ | `has` | `(type: string) => boolean` | Whether a type is registered. |
271
+ | `getRegisteredTypes` | `() => string[]` | All registered type names. |
272
+ | `unregister` | `(type: string) => boolean` | Remove a type. Returns `false` if it was not registered. |
273
+ | `clear` | `() => void` | Remove every registration, including the built-ins. |
274
+
275
+ Two module-level exports come with it:
276
+
277
+ | Export | Signature | Description |
278
+ | ---------------------- | --------------------- | --------------------------------------------------------------------------------------------------------- |
279
+ | `registerBuiltinCells` | `() => void` | Register the 12 built-in cell types. Idempotent and non-destructive; called for you when the table loads. |
280
+ | `BUILTIN_CELL_TYPES` | `readonly CellType[]` | The names of the 12 built-in types. |
281
+
282
+ `CellRegistration` is `{ type: CellType | string; component: Component; config?: { editable?: boolean; additionalProps?: string[] } }`.
283
+
284
+ `clear()` empties the registry permanently — `registerBuiltinCells()` runs once
285
+ per module load, so it will not repopulate on its own. Call it again yourself if
286
+ you need the built-ins back.
287
+
288
+ `CellType` accepts the 12 built-in names with autocomplete plus any custom
289
+ string. `BuiltinCellType` is the narrow union of just the built-ins.
290
+
214
291
  ---
215
292
 
216
293
  ## Sorting
@@ -162,6 +162,8 @@ With `editable`, `VcScheduler` ships a complete built-in create/edit flow -- no
162
162
 
163
163
  The editor modal additionally exposes all-day toggle, start/end date-time, and color, plus a Delete action in edit mode (commits via `event-delete`). Saving from either surface emits the public `event-create`/`event-update` event -- see [Events](#events) for the exact payloads.
164
164
 
165
+ For an **all-day** event the editor's End field is the last day the event covers, matching what the chip and the quick-info popover announce -- so a one-day event has End equal to Start. The `end` in the emitted payload is still the exclusive midnight boundary the layout works in; the editor converts on save. An End before Start is refused rather than saved.
166
+
165
167
  This entire flow is internal state: no `v-model` or extra event wiring is needed to make it work, beyond `editable` and the `event-create`/`event-update`/`event-delete` handlers you already have for persisting the result.
166
168
 
167
169
  ### `editorMode`: opting out of the built-in UI
@@ -498,8 +500,8 @@ const events = [{ id: "a", title: "Promo", start, end, allDay: true, color: "#a2
498
500
  - The Month grid uses `role="grid"` / `role="row"` / `role="gridcell"` / `role="columnheader"` for the weekday header and day cells, each `gridcell` carrying a full formatted-date `aria-label`.
499
501
  - Each event bar and timed chip is a focusable `role="button"` element (`tabindex="0"`) with an `aria-label` built from its title and formatted start/end dates -- activate with click or `Enter`. For an all-day event the announced end is the **last inclusive day**, not the exclusive `end` you pass: an event with `end` at midnight runs through the previous day, so a one-day event is announced as a single date rather than a two-day range. Timed events are announced with their real times.
500
502
  - The "+N more" overflow control is a `<button>` whose visible text is its accessible name; the date it belongs to comes from the enclosing `gridcell`. Activating it moves focus to the first event in the popover, and closing the popover (Escape, or the close button) returns focus to the "+N more" button. Each event inside the popover is a `<button>` too, so a day's 4th and later events are reachable and operable by keyboard.
501
- - The overflow popover's panel is a `VcPopover`, which supplies a titled header (the formatted date) and Escape-to-close. It does **not** apply `role="dialog"` or trap focus -- `VcPopover` has no focus management of its own, so any popover-based surface that needs focus moved into it does that itself, as the overflow popover above does.
502
- - The editor modal and the This event/All events scope dialog are `VcPopup`, which is a Headless UI `Dialog` -- those do get accessible naming, a focus trap, focus restore and `inert` on the background from the library. The built-in quick-create popover is a `VcPopover` and focuses its title field on open.
503
+ - All three anchored popovers -- "+N more" overflow, quick-info and quick-create -- are `VcPopover` panels carrying `role="dialog"` and an accessible name (the formatted date, the event title, and "New event" respectively). They do **not** trap focus: `VcPopover` has no focus management of its own, so each one moves focus into itself on open and hands it back to whatever opened it on close. Focus is repaired, not dictated -- a user who moves focus out of the panel keeps it there when the panel closes.
504
+ - The editor modal and the This event/All events scope dialog are `VcPopup`, which is a Headless UI `Dialog` -- those do get accessible naming, a focus trap, focus restore and `inert` on the background from the library.
503
505
  - The recurrence editor's weekday toggle buttons expose `aria-pressed` (selected state) and an `aria-label` with the full weekday name, since their visible narrow labels ("S", "M", "T", ...) collide (Sun/Sat, Tue/Thu). The "↻" recurring-occurrence marker is `aria-hidden` -- purely visual, redundant with the event's own accessible name.
504
506
  - The toolbar's prev/next buttons carry an explicit `aria-label` ("Previous" / "Next") since they are icon-only; the today/view-switch buttons show text labels.
505
507
  - Event move/resize (Month view's `editable`) is a pointer-drag interaction with no keyboard equivalent yet; use a form outside VcScheduler for keyboard-only date edits. Timeline view is read-only (click still opens the quick-info popover).
@@ -1,93 +0,0 @@
1
- ---
2
- title: useMenuExpanded
3
- category: composables
4
- group: ui-state
5
- slug: useMenuExpanded
6
- ---
7
-
8
- # useMenuExpanded
9
-
10
- Manages the sidebar menu expanded/collapsed and hover-expanded state with localStorage persistence. This is the low-level composable that powers the sidebar pin/unpin behavior in the vc-shell admin UI. It tracks two independent states: the permanent "pinned" state (persisted across sessions) and the transient "hover-expanded" state (active only while the user hovers over a collapsed sidebar).
11
-
12
- ## When to Use
13
-
14
- - Low-level control over sidebar pin and hover state
15
- - Building a custom sidebar component that needs expand/collapse persistence
16
- - When NOT to use: prefer `useSidebarState` which wraps this composable and adds mobile menu support, derived `isExpanded` computed, and responsive breakpoint handling
17
-
18
- ## Basic Usage
19
-
20
- ```typescript
21
- import { useMenuExpanded } from "@vc-shell/framework";
22
-
23
- const { isExpanded, toggleExpanded, isHoverExpanded, toggleHoverExpanded } = useMenuExpanded();
24
-
25
- // Toggle pin state (persisted to localStorage)
26
- toggleExpanded();
27
-
28
- // Hover expand with 200ms delay
29
- toggleHoverExpanded(true); // opens after delay
30
- toggleHoverExpanded(false); // closes immediately
31
- ```
32
-
33
- ## API
34
-
35
- ### Returns
36
-
37
- | Property | Type | Description |
38
- | --------------------- | ---------------------------------- | ---------------------------------------------------------------- |
39
- | `isExpanded` | `Ref<boolean>` | Pinned state, persisted via `useLocalStorage` |
40
- | `toggleExpanded` | `() => void` | Toggle the pinned state |
41
- | `isHoverExpanded` | `Ref<boolean>` | Temporary hover expansion (not persisted) |
42
- | `toggleHoverExpanded` | `(shouldExpand?: boolean) => void` | Set hover state; opening has a 200ms delay, closing is immediate |
43
-
44
- ## Recipe: Custom Sidebar with Hover Expand
45
-
46
- A common pattern is binding mouse events on the sidebar rail so the menu previews its full width on hover, without permanently pinning it open:
47
-
48
- ```vue
49
- <script setup lang="ts">
50
- import { computed } from "vue";
51
- import { useMenuExpanded } from "@vc-shell/framework";
52
-
53
- const { isExpanded, toggleExpanded, isHoverExpanded, toggleHoverExpanded } = useMenuExpanded();
54
-
55
- // The sidebar is visually expanded if pinned OR hover-expanded
56
- const isVisuallyExpanded = computed(() => isExpanded.value || isHoverExpanded.value);
57
- </script>
58
-
59
- <template>
60
- <aside
61
- :class="{ 'sidebar--expanded': isVisuallyExpanded }"
62
- @mouseenter="toggleHoverExpanded(true)"
63
- @mouseleave="toggleHoverExpanded(false)"
64
- >
65
- <nav>
66
- <!-- menu items -->
67
- </nav>
68
- <button @click="toggleExpanded">
69
- {{ isExpanded ? "Unpin" : "Pin" }}
70
- </button>
71
- </aside>
72
- </template>
73
- ```
74
-
75
- <!-- internal:start -->
76
-
77
- ## Details
78
-
79
- - **Storage key scoping**: The key is scoped per application using the first URL path segment: `VC_APP_MENU_EXPANDED_{appName}`. For example, if the app is hosted at `/operations/`, the key is `VC_APP_MENU_EXPANDED_operations`. This allows multiple vc-shell apps on the same domain to maintain independent sidebar states.
80
- - **Hover delay**: Opening uses a 200ms debounce to prevent accidental expansion when the cursor briefly passes over the sidebar. Closing is immediate to feel responsive.
81
- - **Cleanup**: Pending hover timeouts are cleaned up via `onScopeDispose` to prevent memory leaks when the composable's effect scope is destroyed.
82
- - **Default state**: The sidebar starts pinned open (`true`) on first visit, which is the most user-friendly default for new users.
83
- <!-- internal:end -->
84
-
85
- ## Tips
86
-
87
- - If you call `toggleHoverExpanded(true)` and then `toggleHoverExpanded(false)` within the 200ms window, the expansion is canceled -- the timeout is cleared before it fires.
88
- - The composable does not handle mobile breakpoints. For responsive behavior, use `useSidebarState` instead, which combines `useMenuExpanded` with mobile detection.
89
- - Each call to `useMenuExpanded()` creates a new instance with its own timeout tracking, but they share the same localStorage-backed `isExpanded` ref (via `useLocalStorage`). Multiple instances will stay in sync for the pinned state.
90
-
91
- ## Related
92
-
93
- - `useSidebarState` -- higher-level composable that adds mobile menu and derived `isExpanded` computed