@vc-shell/vc-app-skill 2.6.0-rc.1-pr368.e4295d2 → 2.6.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.6.0-rc.1-pr368.e4295d2",
3
+ "version": "2.6.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.6.0-rc.1
1
+ 2.6.0
@@ -1 +1 @@
1
- Synced from framework at commit 371aa8ec5 on 2026-09-08T10:09:34.584Z
1
+ Synced from framework at commit 1919c06af on 2026-09-22T14:57:56.467Z
@@ -71,13 +71,13 @@ function openMenu() {
71
71
 
72
72
  ### Parameters (Options Object)
73
73
 
74
- | Parameter | Type | Default | Description |
75
- | ------------------- | --------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------- |
76
- | `containerSelector` | `string` | `'[role="menu"]'` | CSS selector to auto-find the container on mount. Set to empty string to disable auto-attach. |
77
- | `itemSelector` | `string` | `'[tabindex="0"]'` | CSS selector for focusable items inside the container. |
78
- | `onEnter` | `(el: HTMLElement) => void` | `undefined` | Called when Enter or Space is pressed on a focused item. Falls back to `el.click()` if not provided. |
79
- | `onEscape` | `() => void` | `undefined` | Called when Escape is pressed. Typically used to close the menu or popover. |
80
- | `loop` | `boolean` | `true` | When `true`, pressing ArrowDown on the last item wraps to the first, and ArrowUp on the first wraps to the last. |
74
+ | Parameter | Type | Default | Description |
75
+ | ------------------- | --------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- |
76
+ | `containerSelector` | `string` | `'[role="menu"]'` | CSS selector to auto-find the container on mount. Set to empty string to disable auto-attach. |
77
+ | `itemSelector` | `string` | `'[tabindex="0"]'` | CSS selector for focusable items inside the container. |
78
+ | `onEnter` | `(el: HTMLElement) => void` | `undefined` | Called when Enter or Space is pressed on a focused item. Falls back to `el.click()` if not provided. |
79
+ | `onEscape` | `() => void` | `undefined` | Called when Escape is pressed, and makes the composable consume the key. Typically used to close the menu or popover. |
80
+ | `loop` | `boolean` | `true` | When `true`, pressing ArrowDown on the last item wraps to the first, and ArrowUp on the first wraps to the last. |
81
81
 
82
82
  ### Returns
83
83
 
@@ -101,7 +101,7 @@ The composable listens for `keydown` events on the container element and handles
101
101
  - **ArrowDown / Tab**: Move focus to the next item (`focusNextElement`)
102
102
  - **ArrowUp / Shift+Tab**: Move focus to the previous item (`focusPreviousElement`)
103
103
  - **Enter / Space**: Activate the focused item (calls `onEnter` callback, or `el.click()`)
104
- - **Escape**: Call `onEscape` callback
104
+ - **Escape**: Call `onEscape` callback. When a callback is given the event is consumed (`preventDefault` + `stopPropagation`), so a sidebar or dialog listening on `document` does not also close behind the overlay. With no `onEscape`, the key passes through untouched.
105
105
 
106
106
  On each key event, the composable re-queries the container for matching items (`itemSelector`), so it naturally handles dynamic lists where items are added or removed. The focused index is validated against the current item count before every operation to prevent stale-index bugs.
107
107
 
@@ -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
@@ -7,15 +7,17 @@ internal: true
7
7
 
8
8
  # useSlowNetworkDetection
9
9
 
10
- Detects slow network conditions and shows a persistent warning notification so users know why the UI is unresponsive. Two detection channels work together: a **proactive** channel reads `navigator.connection.effectiveType` to catch weak connections before any request is made, and a **reactive** channel flags API requests that have been pending for more than 10 seconds. The notification auto-dismisses with a 3-second delay after conditions clear, preventing flicker. When the browser goes fully offline, the slow-network notification is suppressed in favor of the existing offline notification from `useConnectionStatus`.
10
+ Detects slow network conditions and publishes them as the `vc-slow-network` class on `<html>`, which the framework renders as a 2px sweep along the top edge of the viewport. Two detection channels work together: a **proactive** channel reads `navigator.connection.effectiveType` to catch weak connections before any request is made, and a **reactive** channel flags idempotent API requests that have been pending for more than 10 seconds. The class is removed with a 3-second delay after conditions clear, preventing flicker. When the browser goes fully offline, the class is dropped in favor of the offline notification from `useConnectionStatus`.
11
+
12
+ A slow network is a state, not an event, so it is never announced as a toast: a dismissable one reappears on every action that outruns the threshold, and a sticky one sits on the screen for the whole session on a 2g connection (VCST-6045).
11
13
 
12
14
  Like `useConnectionStatus`, this is a module-level singleton — calling it from multiple components shares the same state and listeners.
13
15
 
14
16
  ## When to Use
15
17
 
16
18
  - Reactively check `isSlowNetwork` to show skeleton loaders, disable auto-refresh, or display inline warnings
17
- - The notification is automatic — no extra code needed beyond calling the composable once at app startup (already done in `framework/index.ts`)
18
- - Use `trackRequest` / `untrackRequest` only if you have custom fetch logic outside the standard interceptor (the built-in fetch interceptor already tracks all `/api/*` requests)
19
+ - The `vc-slow-network` class is automatic — no extra code needed beyond calling the composable once at app startup (already done in `framework/index.ts`)
20
+ - Use `trackRequest` / `untrackRequest` only if you have custom fetch logic outside the standard interceptor (the built-in fetch interceptor already tracks idempotent `/api/*` requests)
19
21
  - When NOT to use: for offline detection (use `useConnectionStatus` instead) or for measuring exact latency/bandwidth
20
22
 
21
23
  ## Quick Start
@@ -61,11 +63,12 @@ const { isSlowNetwork } = useSlowNetworkDetection();
61
63
 
62
64
  These are module-private `const` declarations, not exported symbols — do not `import` them. They document the thresholds baked into the composable.
63
65
 
64
- | Name | Value | Purpose |
65
- | --------------------------- | ------------------- | --------------------------------------------------- |
66
- | `SLOW_REQUEST_THRESHOLD_MS` | `10000` | Time before a pending request is considered slow |
67
- | `DISMISS_DELAY_MS` | `3000` | Delay before hiding the notification after recovery |
68
- | `SLOW_EFFECTIVE_TYPES` | `["slow-2g", "2g"]` | Connection types flagged as slow |
66
+ | Name | Value | Purpose |
67
+ | --------------------------- | ------------------- | ------------------------------------------------ |
68
+ | `SLOW_REQUEST_THRESHOLD_MS` | `10000` | Time before a pending request is considered slow |
69
+ | `DISMISS_DELAY_MS` | `3000` | Delay before dropping the class after recovery |
70
+ | `SLOW_EFFECTIVE_TYPES` | `["slow-2g", "2g"]` | Connection types flagged as slow |
71
+ | `SLOW_NETWORK_CLASS` | `"vc-slow-network"` | Class the state is published as, on `<html>` |
69
72
 
70
73
  ## How It Works
71
74
 
@@ -75,15 +78,15 @@ On first call, the composable checks `navigator.connection.effectiveType` (Netwo
75
78
 
76
79
  ### Channel 2: Request timers (reactive)
77
80
 
78
- The fetch interceptor in `framework/core/interceptors/index.ts` calls `trackRequest(id)` before every `/api/*` request and `untrackRequest(id)` in the `finally` block. Each tracked request gets a 10-second timer. If the response arrives in time, the timer is cancelled. If not, `isSlowNetwork` becomes `true` and stays `true` until all slow requests complete.
81
+ The fetch interceptor in `framework/core/interceptors/index.ts` calls `trackRequest(id)` before every idempotent `/api/*` request (GET and HEAD) and `untrackRequest(id)` in the `finally` block. An upload or any other mutation is left out: its duration is the operator's uplink, not a stalled server, and a large file would otherwise claim the network is slow every time. Each tracked request gets a 10-second timer. If the response arrives in time, the timer is cancelled. If not, `isSlowNetwork` becomes `true` and stays `true` until all slow requests complete.
79
82
 
80
- ### Notification lifecycle
83
+ ### Class lifecycle
81
84
 
82
- 1. `isSlowNetwork` becomes `true` → show `notification.warning` (deduplicated by fixed ID, persists until removed)
83
- 2. `isSlowNetwork` becomes `false` → start a 3-second dismiss timer
84
- 3. If `isSlowNetwork` goes back to `true` within those 3 seconds → cancel dismiss, notification stays
85
- 4. If the browser goes offline → immediately hide the slow notification (the offline notification takes over)
86
- 5. If the browser comes back online and `isSlowNetwork` is still `true` → re-show
85
+ 1. `isSlowNetwork` becomes `true` → add `vc-slow-network` to `<html>`, and the bar appears
86
+ 2. `isSlowNetwork` becomes `false` → start a 3-second removal timer
87
+ 3. If `isSlowNetwork` goes back to `true` within those 3 seconds → cancel removal, the class stays
88
+ 4. If the browser goes offline → remove the class at once (the offline notification takes over)
89
+ 5. If the browser comes back online and `isSlowNetwork` is still `true` → add it again
87
90
  <!-- internal:end -->
88
91
 
89
92
  ## Recipe: Custom Slow-Network Behavior in a Blade
@@ -131,8 +134,9 @@ async function fetchFromExternalApi(url: string) {
131
134
  ## Tips
132
135
 
133
136
  - **Singleton by design.** Multiple calls to `useSlowNetworkDetection()` share the same state. No overhead from calling it in many components.
134
- - **The notification cannot stack.** It uses a fixed `notificationId`, so even if multiple requests become slow simultaneously, only one notification is shown.
135
- - **The 3-second dismiss delay prevents flicker.** Without it, a burst of requests completing one-by-one would cause the notification to flash on and off rapidly.
137
+ - **The state cannot stack.** A class is present or it is not, so concurrent slow requests read as one state.
138
+ - **The 3-second removal delay prevents flicker.** Without it, a burst of requests completing one-by-one would toggle the class on and off rapidly.
139
+ - **The bar lives in one CSS rule.** `framework/assets/styles/index.scss` draws it next to the `html.vc-offline` rule, so an app can restyle or suppress it without touching the composable. Under `prefers-reduced-motion` it becomes a static line.
136
140
  - **`navigator.connection` has limited support.** Only Chromium-based browsers support it. Firefox and Safari users will only get the request-timer channel, which is still effective.
137
141
  - **The composable does not block requests.** Unlike the offline guard in the interceptor, slow-network detection is purely informational — it never prevents or delays a fetch.
138
142
 
@@ -171,4 +175,4 @@ trackRequest("my-request-2");
171
175
 
172
176
  - [`useConnectionStatus`](../useConnectionStatus/useConnectionStatus.docs.md) — offline detection (binary online/offline)
173
177
  - [`registerInterceptors`](https://github.com/VirtoCommerce/vc-shell/blob/main/framework/core/interceptors/index.ts) — the fetch wrapper that calls `trackRequest`/`untrackRequest`
174
- - `notification` from `@core/notifications/notification` — the notification system used to display the warning
178
+ - `framework/assets/styles/index.scss` — the top-edge sweep this class renders as, alongside the `html.vc-offline` rule
@@ -234,20 +234,23 @@ Use the `"star-and-text"` or `"text"` variant in table cell slots for a compact
234
234
 
235
235
  ## Props
236
236
 
237
- | Prop | Type | Default | Description |
238
- | ------------- | -------------------------------------- | --------- | ---------------------------------------- |
239
- | `modelValue` | `number` | -- | Current rating value to display |
240
- | `max` | `number` | `5` | Maximum rating value (defines the scale) |
241
- | `variant` | `"stars" \| "star-and-text" \| "text"` | `"stars"` | Display variant |
242
- | `label` | `string` | -- | Field label text above the rating |
243
- | `tooltip` | `string` | -- | Tooltip shown on the label |
244
- | `placeholder` | `string` | -- | Text shown when `modelValue` is falsy |
237
+ | Prop | Type | Default | Description |
238
+ | -------------- | -------------------------------------- | --------- | ----------------------------------------------------------------------- |
239
+ | `modelValue` | `number` | -- | Current rating value to display |
240
+ | `max` | `number` | `5` | Maximum rating value (defines the scale) |
241
+ | `variant` | `"stars" \| "star-and-text" \| "text"` | `"stars"` | Display variant |
242
+ | `label` | `string` | -- | Field label text above the rating |
243
+ | `tooltip` | `string` | -- | Tooltip shown on the label |
244
+ | `placeholder` | `string` | -- | Text shown when `modelValue` is falsy |
245
+ | `error` | `boolean` | -- | Marks the field invalid without a message |
246
+ | `errorMessage` | `string` | -- | Validation message shown below the rating; also marks the field invalid |
245
247
 
246
248
  ## Slots
247
249
 
248
- | Slot | Scope | Description |
249
- | --------- | ----- | -------------------------------------------------------------------------------------- |
250
- | `details` | -- | Additional content below the rating (available in `star-and-text` and `text` variants) |
250
+ | Slot | Scope | Description |
251
+ | --------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------- |
252
+ | `details` | -- | Additional content below the rating (available in `star-and-text` and `text` variants) |
253
+ | `error` | -- | Replaces the default validation message element. Keep the slotted element's `id`, or the control's `aria-describedby` points at nothing |
251
254
 
252
255
  ## CSS Variables
253
256
 
@@ -266,6 +269,8 @@ Use the `"star-and-text"` or `"text"` variant in table cell slots for a compact
266
269
  - When no value is present, the `aria-label` falls back to the placeholder text or "No rating".
267
270
  - The label is rendered via `VcLabel` with optional tooltip, maintaining consistent label semantics.
268
271
  - Star icons are decorative and do not receive focus since the component is display-only.
272
+ - A validation message is rendered below the rating and referenced from the control through `aria-describedby`, so a screen reader is told what is wrong rather than only that something is.
273
+ - Invalid state comes from `useFormField`, so it reflects a surrounding `VcInputGroup` as well as the component's own `error` / `errorMessage`.
269
274
 
270
275
  ## Related Components
271
276