@pgcorp/ui-kit 0.1.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.
Files changed (251) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/docs/accessibility.md +27 -0
  4. package/docs/getting-started.md +80 -0
  5. package/docs/licensing.md +30 -0
  6. package/docs/public-api.md +39 -0
  7. package/docs/security.md +21 -0
  8. package/docs/theming.md +35 -0
  9. package/docs/versioning.md +23 -0
  10. package/index.ts +3 -0
  11. package/package.json +592 -0
  12. package/src/components/icons/_internal/generatedSputnigUiIconMarkup.ts +971 -0
  13. package/src/components/icons/sputnigUiIcons.ts +530 -0
  14. package/src/components/layout/SAppShell.vue +232 -0
  15. package/src/components/layout/SDockRegion.vue +200 -0
  16. package/src/components/layout/SResizeHandle.vue +251 -0
  17. package/src/components/layout/SSideMenuButton.vue +104 -0
  18. package/src/components/layout/SSideMenuRail.vue +47 -0
  19. package/src/components/layout/SStack.css +25 -0
  20. package/src/components/layout/SStack.vue +57 -0
  21. package/src/components/layout/SStatusBarShell.css +15 -0
  22. package/src/components/layout/SStatusBarShell.vue +26 -0
  23. package/src/components/layout/SWorkbenchLayout.vue +53 -0
  24. package/src/components/layout/SWorkbenchPanelShell.css +161 -0
  25. package/src/components/layout/SWorkbenchPanelShell.vue +286 -0
  26. package/src/components/layout/_internal/SAppBrand.css +45 -0
  27. package/src/components/layout/_internal/SAppBrand.vue +37 -0
  28. package/src/components/layout/_internal/SAppBrandContent.vue +30 -0
  29. package/src/components/layout/types.ts +28 -0
  30. package/src/components/shared/_internal/SDialogSurface.css +127 -0
  31. package/src/components/shared/_internal/SDialogSurface.vue +117 -0
  32. package/src/components/shared/_internal/SDisclosureSummary.css +92 -0
  33. package/src/components/shared/_internal/SDisclosureSummary.vue +121 -0
  34. package/src/components/shared/_internal/SListboxGroup.css +18 -0
  35. package/src/components/shared/_internal/SListboxGroup.vue +24 -0
  36. package/src/components/shared/_internal/SMenuItem.css +112 -0
  37. package/src/components/shared/_internal/SMenuItem.vue +213 -0
  38. package/src/components/shared/_internal/SMenuLayer.css +21 -0
  39. package/src/components/shared/_internal/SMenuLayer.vue +48 -0
  40. package/src/components/shared/_internal/SMenuSeparator.css +5 -0
  41. package/src/components/shared/_internal/SMenuSeparator.vue +13 -0
  42. package/src/components/shared/_internal/SToastItem.css +65 -0
  43. package/src/components/shared/_internal/SToastItem.vue +58 -0
  44. package/src/components/shared/_internal/STreeNode.vue +761 -0
  45. package/src/components/shared/_internal/dialogSurface.ts +105 -0
  46. package/src/components/shared/_internal/focusTrap.ts +40 -0
  47. package/src/components/shared/_internal/listboxContext.ts +16 -0
  48. package/src/components/shared/_internal/markerContract.ts +10 -0
  49. package/src/components/shared/_internal/menuContext.ts +23 -0
  50. package/src/components/shared/_internal/selectionContract.ts +239 -0
  51. package/src/components/shared/_internal/sidebarGroupContext.ts +31 -0
  52. package/src/components/shared/_internal/tabsContext.ts +57 -0
  53. package/src/components/shared/_internal/treeRuntime.ts +203 -0
  54. package/src/components/shared/_internal/useSelectionRoving.ts +162 -0
  55. package/src/components/shared/codeLanguages.ts +168 -0
  56. package/src/components/shared/complex/SKanbanCard.css +8 -0
  57. package/src/components/shared/complex/SKanbanCard.vue +76 -0
  58. package/src/components/shared/complex/SKanbanLane.vue +82 -0
  59. package/src/components/shared/complex/STree.css +24 -0
  60. package/src/components/shared/complex/STree.vue +288 -0
  61. package/src/components/shared/complex/treeAdapter.ts +210 -0
  62. package/src/components/shared/complex/types.ts +102 -0
  63. package/src/components/shared/containers/SCloudChrome.css +103 -0
  64. package/src/components/shared/containers/SCloudChrome.vue +87 -0
  65. package/src/components/shared/containers/SCloudChromeEmpty.css +10 -0
  66. package/src/components/shared/containers/SCloudChromeEmpty.vue +18 -0
  67. package/src/components/shared/containers/SCloudChromeRow.css +78 -0
  68. package/src/components/shared/containers/SCloudChromeRow.vue +108 -0
  69. package/src/components/shared/containers/SCollapsiblePanel.css +57 -0
  70. package/src/components/shared/containers/SCollapsiblePanel.vue +360 -0
  71. package/src/components/shared/containers/SDrawer.css +60 -0
  72. package/src/components/shared/containers/SDrawer.vue +86 -0
  73. package/src/components/shared/containers/SFormGrid.css +26 -0
  74. package/src/components/shared/containers/SFormGrid.vue +46 -0
  75. package/src/components/shared/containers/SFormGridItem.css +18 -0
  76. package/src/components/shared/containers/SFormGridItem.vue +29 -0
  77. package/src/components/shared/containers/SLeftSidebar.vue +74 -0
  78. package/src/components/shared/containers/SModal.css +27 -0
  79. package/src/components/shared/containers/SModal.vue +105 -0
  80. package/src/components/shared/containers/SPageHeader.css +66 -0
  81. package/src/components/shared/containers/SPageHeader.vue +45 -0
  82. package/src/components/shared/containers/SPanel.css +134 -0
  83. package/src/components/shared/containers/SPanel.vue +169 -0
  84. package/src/components/shared/containers/SPopover.css +35 -0
  85. package/src/components/shared/containers/SPopover.vue +551 -0
  86. package/src/components/shared/containers/SSidebarGroup.css +12 -0
  87. package/src/components/shared/containers/SSidebarGroup.vue +443 -0
  88. package/src/components/shared/containers/SSidebarSection.css +41 -0
  89. package/src/components/shared/containers/SSidebarSection.vue +268 -0
  90. package/src/components/shared/containers/disclosureSummary.ts +48 -0
  91. package/src/components/shared/containers/popover.ts +72 -0
  92. package/src/components/shared/containers/sidebar.ts +206 -0
  93. package/src/components/shared/controls/SButton.css +274 -0
  94. package/src/components/shared/controls/SButton.vue +211 -0
  95. package/src/components/shared/controls/SCheckbox.css +65 -0
  96. package/src/components/shared/controls/SCheckbox.vue +130 -0
  97. package/src/components/shared/controls/SCheckboxGroup.vue +134 -0
  98. package/src/components/shared/controls/SChoiceCards.css +29 -0
  99. package/src/components/shared/controls/SChoiceCards.vue +145 -0
  100. package/src/components/shared/controls/SColorSelect.vue +146 -0
  101. package/src/components/shared/controls/SComboboxTrigger.css +35 -0
  102. package/src/components/shared/controls/SComboboxTrigger.vue +122 -0
  103. package/src/components/shared/controls/SContextMenu.css +11 -0
  104. package/src/components/shared/controls/SContextMenu.vue +186 -0
  105. package/src/components/shared/controls/SContextToggleButton.vue +64 -0
  106. package/src/components/shared/controls/SDragHandle.vue +100 -0
  107. package/src/components/shared/controls/SDropdownMenu.css +5 -0
  108. package/src/components/shared/controls/SDropdownMenu.vue +188 -0
  109. package/src/components/shared/controls/SField.css +79 -0
  110. package/src/components/shared/controls/SField.vue +89 -0
  111. package/src/components/shared/controls/SFieldGroup.css +58 -0
  112. package/src/components/shared/controls/SFieldGroup.vue +117 -0
  113. package/src/components/shared/controls/SFieldLabel.css +12 -0
  114. package/src/components/shared/controls/SFieldLabel.vue +51 -0
  115. package/src/components/shared/controls/SInlineTokenEditor.css +61 -0
  116. package/src/components/shared/controls/SInlineTokenEditor.vue +1081 -0
  117. package/src/components/shared/controls/SInputText.css +66 -0
  118. package/src/components/shared/controls/SInputText.vue +321 -0
  119. package/src/components/shared/controls/SInteractiveSurface.css +72 -0
  120. package/src/components/shared/controls/SInteractiveSurface.vue +326 -0
  121. package/src/components/shared/controls/SLink.css +114 -0
  122. package/src/components/shared/controls/SLink.ts +85 -0
  123. package/src/components/shared/controls/SLink.vue +114 -0
  124. package/src/components/shared/controls/SListbox.css +31 -0
  125. package/src/components/shared/controls/SListbox.vue +388 -0
  126. package/src/components/shared/controls/SListboxOption.css +63 -0
  127. package/src/components/shared/controls/SListboxOption.vue +85 -0
  128. package/src/components/shared/controls/SMarker.css +19 -0
  129. package/src/components/shared/controls/SMarker.vue +33 -0
  130. package/src/components/shared/controls/SMenuSurface.css +20 -0
  131. package/src/components/shared/controls/SMenuSurface.vue +333 -0
  132. package/src/components/shared/controls/SSegmentedControl.css +38 -0
  133. package/src/components/shared/controls/SSegmentedControl.vue +134 -0
  134. package/src/components/shared/controls/SSelect.css +12 -0
  135. package/src/components/shared/controls/SSelect.vue +338 -0
  136. package/src/components/shared/controls/STextarea.css +31 -0
  137. package/src/components/shared/controls/STextarea.vue +316 -0
  138. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +224 -0
  139. package/src/components/shared/controls/menu.ts +178 -0
  140. package/src/components/shared/data-display/SActionCard.css +6 -0
  141. package/src/components/shared/data-display/SActionCard.vue +49 -0
  142. package/src/components/shared/data-display/SActionList.css +15 -0
  143. package/src/components/shared/data-display/SActionList.vue +34 -0
  144. package/src/components/shared/data-display/SActionListItem.css +114 -0
  145. package/src/components/shared/data-display/SActionListItem.vue +230 -0
  146. package/src/components/shared/data-display/SBadge.css +73 -0
  147. package/src/components/shared/data-display/SBadge.vue +52 -0
  148. package/src/components/shared/data-display/SCodeBlock.css +56 -0
  149. package/src/components/shared/data-display/SCodeBlock.vue +188 -0
  150. package/src/components/shared/data-display/SCodeEditor.css +178 -0
  151. package/src/components/shared/data-display/SCodeEditor.vue +274 -0
  152. package/src/components/shared/data-display/SCodeSearchPanel.vue +327 -0
  153. package/src/components/shared/data-display/SCopyField.vue +77 -0
  154. package/src/components/shared/data-display/SDocBlock.css +136 -0
  155. package/src/components/shared/data-display/SDocBlock.vue +358 -0
  156. package/src/components/shared/data-display/SEmptyState.css +42 -0
  157. package/src/components/shared/data-display/SEmptyState.vue +79 -0
  158. package/src/components/shared/data-display/SExpandableText.vue +314 -0
  159. package/src/components/shared/data-display/SJsonTree.css +6 -0
  160. package/src/components/shared/data-display/SJsonTree.vue +111 -0
  161. package/src/components/shared/data-display/SKeyValueGrid.css +37 -0
  162. package/src/components/shared/data-display/SKeyValueGrid.vue +277 -0
  163. package/src/components/shared/data-display/SLinkedSystemsList.vue +270 -0
  164. package/src/components/shared/data-display/SMessage.css +88 -0
  165. package/src/components/shared/data-display/SMessage.vue +152 -0
  166. package/src/components/shared/data-display/SMetricCard.css +32 -0
  167. package/src/components/shared/data-display/SMetricCard.vue +117 -0
  168. package/src/components/shared/data-display/SProgressBar.css +84 -0
  169. package/src/components/shared/data-display/SProgressBar.vue +114 -0
  170. package/src/components/shared/data-display/SProgressIndicator.css +59 -0
  171. package/src/components/shared/data-display/SProgressIndicator.vue +104 -0
  172. package/src/components/shared/data-display/SSectionHeader.css +84 -0
  173. package/src/components/shared/data-display/SSectionHeader.vue +58 -0
  174. package/src/components/shared/data-display/SStatus.css +90 -0
  175. package/src/components/shared/data-display/SStatus.vue +79 -0
  176. package/src/components/shared/data-display/STable.css +83 -0
  177. package/src/components/shared/data-display/STable.vue +339 -0
  178. package/src/components/shared/data-display/SToastContainer.css +53 -0
  179. package/src/components/shared/data-display/SToastContainer.vue +31 -0
  180. package/src/components/shared/data-display/STooltip.css +114 -0
  181. package/src/components/shared/data-display/STooltip.vue +550 -0
  182. package/src/components/shared/data-display/STooltipAnchor.css +21 -0
  183. package/src/components/shared/data-display/STooltipAnchor.vue +119 -0
  184. package/src/components/shared/data-display/SVirtualList.css +24 -0
  185. package/src/components/shared/data-display/SVirtualList.ts +19 -0
  186. package/src/components/shared/data-display/SVirtualList.vue +361 -0
  187. package/src/components/shared/data-display/json.ts +136 -0
  188. package/src/components/shared/data-display/table.ts +76 -0
  189. package/src/components/shared/database/SDataGrid.css +3 -0
  190. package/src/components/shared/database/SDataGrid.vue +464 -0
  191. package/src/components/shared/database/SSqlEditor.vue +363 -0
  192. package/src/components/shared/database/dataGrid.ts +61 -0
  193. package/src/components/shared/feedback/SAsyncState.css +26 -0
  194. package/src/components/shared/feedback/SAsyncState.vue +190 -0
  195. package/src/components/shared/graph/SGraphViewport.ts +1 -0
  196. package/src/components/shared/graph/SGraphViewport.vue +858 -0
  197. package/src/components/shared/graph/_internal/graphViewportContract.ts +362 -0
  198. package/src/components/shared/markdown.ts +169 -0
  199. package/src/components/shared/navigation/SBottomNav.vue +692 -0
  200. package/src/components/shared/navigation/SBreadcrumbs.css +26 -0
  201. package/src/components/shared/navigation/SBreadcrumbs.vue +155 -0
  202. package/src/components/shared/navigation/SCatalogNavigator.css +100 -0
  203. package/src/components/shared/navigation/SCatalogNavigator.vue +308 -0
  204. package/src/components/shared/navigation/STab.vue +363 -0
  205. package/src/components/shared/navigation/STabList.vue +442 -0
  206. package/src/components/shared/navigation/STabPanel.vue +165 -0
  207. package/src/components/shared/navigation/STabPanels.vue +33 -0
  208. package/src/components/shared/navigation/STabs.vue +617 -0
  209. package/src/components/shared/navigation/SWizardSteps.css +69 -0
  210. package/src/components/shared/navigation/SWizardSteps.vue +171 -0
  211. package/src/components/shared/navigation/_internal/SCatalogNavigatorGroup.vue +63 -0
  212. package/src/components/shared/persona/SPersonaProjectPicker.css +36 -0
  213. package/src/components/shared/persona/SPersonaProjectPicker.vue +361 -0
  214. package/src/components/shared/persona/SPersonaRunWorkbench.css +109 -0
  215. package/src/components/shared/persona/SPersonaRunWorkbench.vue +549 -0
  216. package/src/components/shared/persona/_internal/SPersonaRunConsole.css +40 -0
  217. package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +298 -0
  218. package/src/components/shared/persona/_internal/inventory.ts +110 -0
  219. package/src/components/shared/persona/_internal/labels.ts +79 -0
  220. package/src/components/shared/persona/presentation.ts +371 -0
  221. package/src/components/shared/persona/types.ts +256 -0
  222. package/src/composables/uiPreferencesReset.ts +52 -0
  223. package/src/composables/useClipboard.ts +37 -0
  224. package/src/composables/useDialogOverlay.ts +85 -0
  225. package/src/composables/useDisclosure.ts +40 -0
  226. package/src/composables/useFloatingPosition.ts +245 -0
  227. package/src/composables/useNotifier.ts +26 -0
  228. package/src/composables/useSidebarPanelState.ts +610 -0
  229. package/src/internal/codeEditorContract.ts +110 -0
  230. package/src/internal/codeEditorExtensions.ts +27 -0
  231. package/src/internal/feedbackPresentation.ts +136 -0
  232. package/src/internal/fieldSurface.ts +59 -0
  233. package/src/internal/focusNavigation.ts +69 -0
  234. package/src/internal/inlineTokenEditorContract.ts +136 -0
  235. package/src/internal/interactiveElement.ts +33 -0
  236. package/src/internal/layerStack.ts +202 -0
  237. package/src/internal/linkTarget.ts +256 -0
  238. package/src/internal/ownedAttrs.ts +464 -0
  239. package/src/internal/passiveContentContract.ts +129 -0
  240. package/src/internal/pointerInteractionLease.ts +81 -0
  241. package/src/internal/runtimeContract.ts +194 -0
  242. package/src/internal/runtimeTheme.ts +159 -0
  243. package/src/internal/semanticSizing.ts +60 -0
  244. package/src/internal/useMenuLayer.ts +129 -0
  245. package/src/stores/useNotifierStore.ts +69 -0
  246. package/src/styles/reference.css +4 -0
  247. package/src/styles/style.css +151 -0
  248. package/src/styles/tokens.css +836 -0
  249. package/src/theme.ts +449 -0
  250. package/src/types/highlight-js-lib.d.ts +76 -0
  251. package/src/variants.ts +15 -0
@@ -0,0 +1,610 @@
1
+ import { computed, getCurrentScope, onScopeDispose, reactive, readonly, watch } from 'vue';
2
+
3
+ import {
4
+ assertSidebarSectionsState,
5
+ type SectionState,
6
+ } from '../components/shared/containers/sidebar';
7
+ import type { UiPreferencesResetController } from './uiPreferencesReset';
8
+
9
+ const DEFAULT_MIN_SIZE = 40;
10
+ const MEMORY_STATE_RECOVERY = 'continue-with-memory-state' as const;
11
+ export const SIDEBAR_PANEL_STATE_SCHEMA_VERSION = 2 as const;
12
+
13
+ /**
14
+ * Дефолтный layout-contract секции sidebar.
15
+ * Default layout contract for a sidebar section.
16
+ */
17
+ export interface SectionDefaultConfig {
18
+ defaultIsOpen: boolean;
19
+ defaultSize: number | null;
20
+ minSize?: number;
21
+ }
22
+
23
+ /**
24
+ * Точный storage-contract, нужный owner состояния sidebar.
25
+ * Exact storage contract required by the sidebar state owner.
26
+ */
27
+ export interface SidebarPanelStateStorage {
28
+ getItem(key: string): string | null;
29
+ setItem(key: string, value: string): void;
30
+ removeItem(key: string): void;
31
+ }
32
+
33
+ export type SidebarPanelStatePersistenceErrorCode =
34
+ | 'storage-resolution-failed'
35
+ | 'read-failed'
36
+ | 'invalid-json'
37
+ | 'invalid-schema'
38
+ | 'write-failed'
39
+ | 'remove-failed';
40
+
41
+ /**
42
+ * Диагностируемая ошибка persistence с точными operation, storage key и path.
43
+ * Diagnosable persistence error with exact operation, storage key, and path.
44
+ */
45
+ export class SidebarPanelStatePersistenceError extends Error {
46
+ readonly code: SidebarPanelStatePersistenceErrorCode;
47
+ readonly storageKey: string;
48
+ readonly path: string;
49
+ readonly cause: unknown;
50
+
51
+ constructor(options: {
52
+ code: SidebarPanelStatePersistenceErrorCode;
53
+ storageKey: string;
54
+ path?: string;
55
+ message: string;
56
+ cause: unknown;
57
+ }) {
58
+ super(options.message);
59
+ this.name = 'SidebarPanelStatePersistenceError';
60
+ this.code = options.code;
61
+ this.storageKey = options.storageKey;
62
+ this.path = options.path ?? '$';
63
+ this.cause = options.cause;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Ошибка публичного runtime/config contract sidebar state.
69
+ * Error in the public sidebar state runtime or configuration contract.
70
+ */
71
+ export class SidebarPanelStateContractError extends Error {
72
+ readonly path: string;
73
+
74
+ constructor(path: string, message: string) {
75
+ super(`Sidebar panel state contract violation at ${path}: ${message}`);
76
+ this.name = 'SidebarPanelStateContractError';
77
+ this.path = path;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Явное решение consumer продолжить с in-memory state после диагностики persistence-ошибки.
83
+ * Explicit consumer decision to continue with in-memory state after diagnosing a persistence error.
84
+ */
85
+ export type SidebarPanelStatePersistenceRecovery = typeof MEMORY_STATE_RECOVERY;
86
+
87
+ /**
88
+ * Явные runtime dependencies для persistence и scoped reset.
89
+ * Explicit runtime dependencies for persistence and scoped reset.
90
+ */
91
+ export interface SidebarPanelStateRuntime {
92
+ resolveStorage(): SidebarPanelStateStorage | null;
93
+ resetController: UiPreferencesResetController;
94
+ recoverFromPersistenceError(
95
+ error: SidebarPanelStatePersistenceError,
96
+ ): SidebarPanelStatePersistenceRecovery;
97
+ }
98
+
99
+ type PersistedSectionState = Pick<SectionState, 'isOpen' | 'size'>;
100
+ type PersistedSidebarPanelState = Record<string, PersistedSectionState>;
101
+ type MutableSectionState = {
102
+ isOpen: boolean;
103
+ size: number | null;
104
+ minSize: number;
105
+ };
106
+ type PersistedSidebarPanelDocument = {
107
+ version: typeof SIDEBAR_PANEL_STATE_SCHEMA_VERSION;
108
+ sections: PersistedSidebarPanelState;
109
+ };
110
+
111
+ type DecodedPersistedState = {
112
+ sections: PersistedSidebarPanelState;
113
+ migratedFromVersion: 1 | null;
114
+ };
115
+
116
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
117
+ typeof value === 'object' && value !== null && !Array.isArray(value);
118
+
119
+ const isValidSize = (value: unknown): value is number | null =>
120
+ value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
121
+
122
+ function assertExactKeys(
123
+ value: Record<string, unknown>,
124
+ expectedKeys: readonly string[],
125
+ path: string,
126
+ ): void {
127
+ const actualKeys = Object.keys(value).sort();
128
+ const sortedExpectedKeys = [...expectedKeys].sort();
129
+ if (
130
+ actualKeys.length !== sortedExpectedKeys.length
131
+ || actualKeys.some((key, index) => key !== sortedExpectedKeys[index])
132
+ ) {
133
+ throw new SidebarPanelStateContractError(
134
+ path,
135
+ `expected keys [${sortedExpectedKeys.join(', ')}], received [${actualKeys.join(', ')}]`,
136
+ );
137
+ }
138
+ }
139
+
140
+ function assertSidebarLayoutState(value: unknown, path: string): asserts value is Record<string, SectionState> {
141
+ try {
142
+ assertSidebarSectionsState(value, 'useSidebarPanelState');
143
+ } catch (cause) {
144
+ throw new SidebarPanelStateContractError(
145
+ path,
146
+ cause instanceof Error ? cause.message : 'invalid sidebar layout state',
147
+ );
148
+ }
149
+ }
150
+
151
+ function validateDefaultConfig(
152
+ defaultConfig: Record<string, SectionDefaultConfig>,
153
+ ): readonly string[] {
154
+ if (!isPlainRecord(defaultConfig)) {
155
+ throw new SidebarPanelStateContractError('defaultConfig', 'expected an object');
156
+ }
157
+
158
+ const sectionKeys = Object.keys(defaultConfig);
159
+ if (sectionKeys.length === 0) {
160
+ throw new SidebarPanelStateContractError('defaultConfig', 'at least one section is required');
161
+ }
162
+
163
+ for (const sectionKey of sectionKeys) {
164
+ if (sectionKey.trim().length === 0) {
165
+ throw new SidebarPanelStateContractError('defaultConfig', 'section keys must not be empty');
166
+ }
167
+ const config = defaultConfig[sectionKey];
168
+ const path = `defaultConfig.${sectionKey}`;
169
+ if (!isPlainRecord(config)) {
170
+ throw new SidebarPanelStateContractError(path, 'expected an object');
171
+ }
172
+ const expectedKeys = config.minSize === undefined
173
+ ? ['defaultIsOpen', 'defaultSize']
174
+ : ['defaultIsOpen', 'defaultSize', 'minSize'];
175
+ assertExactKeys(config, expectedKeys, path);
176
+ if (typeof config.defaultIsOpen !== 'boolean') {
177
+ throw new SidebarPanelStateContractError(`${path}.defaultIsOpen`, 'expected a boolean');
178
+ }
179
+ if (!isValidSize(config.defaultSize)) {
180
+ throw new SidebarPanelStateContractError(`${path}.defaultSize`, 'expected null or a finite non-negative number');
181
+ }
182
+ if (
183
+ config.minSize !== undefined
184
+ && (typeof config.minSize !== 'number' || !Number.isFinite(config.minSize) || config.minSize <= 0)
185
+ ) {
186
+ throw new SidebarPanelStateContractError(`${path}.minSize`, 'expected a finite positive number');
187
+ }
188
+ }
189
+
190
+ assertSidebarLayoutState(
191
+ buildDefaultState(defaultConfig, sectionKeys, {}),
192
+ 'defaultConfig',
193
+ );
194
+
195
+ return sectionKeys;
196
+ }
197
+
198
+ function validateRuntime(runtime: SidebarPanelStateRuntime): void {
199
+ if (!isPlainRecord(runtime)) {
200
+ throw new SidebarPanelStateContractError('runtime', 'expected an object');
201
+ }
202
+ assertExactKeys(
203
+ runtime,
204
+ ['recoverFromPersistenceError', 'resetController', 'resolveStorage'],
205
+ 'runtime',
206
+ );
207
+ if (typeof runtime.resolveStorage !== 'function') {
208
+ throw new SidebarPanelStateContractError('runtime.resolveStorage', 'expected a function');
209
+ }
210
+ if (typeof runtime.recoverFromPersistenceError !== 'function') {
211
+ throw new SidebarPanelStateContractError(
212
+ 'runtime.recoverFromPersistenceError',
213
+ 'expected a function',
214
+ );
215
+ }
216
+ if (!isPlainRecord(runtime.resetController)) {
217
+ throw new SidebarPanelStateContractError('runtime.resetController', 'expected an object');
218
+ }
219
+ if (typeof runtime.resetController.subscribe !== 'function') {
220
+ throw new SidebarPanelStateContractError(
221
+ 'runtime.resetController.subscribe',
222
+ 'expected a function',
223
+ );
224
+ }
225
+ if (typeof runtime.resetController.requestReset !== 'function') {
226
+ throw new SidebarPanelStateContractError(
227
+ 'runtime.resetController.requestReset',
228
+ 'expected a function',
229
+ );
230
+ }
231
+ }
232
+
233
+ function decodePersistedState(
234
+ value: unknown,
235
+ sectionKeys: readonly string[],
236
+ basePath = '$',
237
+ ): PersistedSidebarPanelState {
238
+ if (!isPlainRecord(value)) {
239
+ throw new SidebarPanelStateContractError(basePath, 'expected an object');
240
+ }
241
+
242
+ const knownSections = new Set(sectionKeys);
243
+ const decoded: PersistedSidebarPanelState = {};
244
+ for (const [sectionKey, sectionValue] of Object.entries(value)) {
245
+ const path = `${basePath}.${sectionKey}`;
246
+ if (!knownSections.has(sectionKey)) {
247
+ throw new SidebarPanelStateContractError(path, 'unknown section');
248
+ }
249
+ if (!isPlainRecord(sectionValue)) {
250
+ throw new SidebarPanelStateContractError(path, 'expected an object');
251
+ }
252
+ assertExactKeys(sectionValue, ['isOpen', 'size'], path);
253
+ if (typeof sectionValue.isOpen !== 'boolean') {
254
+ throw new SidebarPanelStateContractError(`${path}.isOpen`, 'expected a boolean');
255
+ }
256
+ if (!isValidSize(sectionValue.size)) {
257
+ throw new SidebarPanelStateContractError(`${path}.size`, 'expected null or a finite non-negative number');
258
+ }
259
+ decoded[sectionKey] = {
260
+ isOpen: sectionValue.isOpen,
261
+ size: sectionValue.size,
262
+ };
263
+ }
264
+
265
+ return decoded;
266
+ }
267
+
268
+ function persistedDocument(sections: PersistedSidebarPanelState): PersistedSidebarPanelDocument {
269
+ return {
270
+ version: SIDEBAR_PANEL_STATE_SCHEMA_VERSION,
271
+ sections,
272
+ };
273
+ }
274
+
275
+ function preferredFlexibleSectionKey(
276
+ state: Record<string, SectionState>,
277
+ defaultConfig: Record<string, SectionDefaultConfig>,
278
+ sectionKeys: readonly string[],
279
+ ): string | null {
280
+ const openKeys = sectionKeys.filter((sectionKey) => state[sectionKey]?.isOpen);
281
+ if (openKeys.length === 0) return null;
282
+ const defaultFlexibleKey = sectionKeys.find((sectionKey) => {
283
+ const config = defaultConfig[sectionKey];
284
+ return config?.defaultIsOpen === true && config.defaultSize === null;
285
+ });
286
+ if (defaultFlexibleKey && state[defaultFlexibleKey]?.isOpen) return defaultFlexibleKey;
287
+ return openKeys.at(-1) ?? null;
288
+ }
289
+
290
+ /**
291
+ * Мигрирует документ v1 по объявленному layout-контракту: default flexible owner сохраняет
292
+ * гибкость, а остальные прежние flexible sections получают declared default/min size.
293
+ * Migrates a v1 document using the declared layout contract: the default flexible owner keeps
294
+ * flexibility while every other legacy flexible section receives its declared default/min size.
295
+ */
296
+ function migrateVersionOneState(
297
+ legacy: PersistedSidebarPanelState,
298
+ defaultConfig: Record<string, SectionDefaultConfig>,
299
+ sectionKeys: readonly string[],
300
+ ): PersistedSidebarPanelState {
301
+ const fullState = buildDefaultState(defaultConfig, sectionKeys, legacy);
302
+ const flexibleKey = preferredFlexibleSectionKey(fullState, defaultConfig, sectionKeys);
303
+ const migratedState: Record<string, SectionState> = Object.fromEntries(
304
+ sectionKeys.map((sectionKey) => {
305
+ const section = fullState[sectionKey]!;
306
+ const config = defaultConfig[sectionKey]!;
307
+ const size = section.isOpen && sectionKey === flexibleKey
308
+ ? null
309
+ : section.isOpen && section.size === null
310
+ ? config.defaultSize ?? config.minSize ?? DEFAULT_MIN_SIZE
311
+ : section.size;
312
+ return [sectionKey, { ...section, size }];
313
+ }),
314
+ );
315
+ assertSidebarLayoutState(migratedState, '$.sections');
316
+ return Object.fromEntries(sectionKeys.map((sectionKey) => {
317
+ const section = migratedState[sectionKey]!;
318
+ return [sectionKey, { isOpen: section.isOpen, size: section.size }];
319
+ }));
320
+ }
321
+
322
+ function decodePersistedDocument(
323
+ value: unknown,
324
+ defaultConfig: Record<string, SectionDefaultConfig>,
325
+ sectionKeys: readonly string[],
326
+ ): DecodedPersistedState {
327
+ if (!isPlainRecord(value)) {
328
+ throw new SidebarPanelStateContractError('$', 'expected an object');
329
+ }
330
+ if (Object.hasOwn(value, 'version') || Object.hasOwn(value, 'sections')) {
331
+ assertExactKeys(value, ['sections', 'version'], '$');
332
+ if (value.version !== SIDEBAR_PANEL_STATE_SCHEMA_VERSION) {
333
+ throw new SidebarPanelStateContractError(
334
+ '$.version',
335
+ `expected ${SIDEBAR_PANEL_STATE_SCHEMA_VERSION}, received ${String(value.version)}`,
336
+ );
337
+ }
338
+ const sections = decodePersistedState(value.sections, sectionKeys, '$.sections');
339
+ assertSidebarLayoutState(
340
+ buildDefaultState(defaultConfig, sectionKeys, sections),
341
+ '$.sections',
342
+ );
343
+ return { sections, migratedFromVersion: null };
344
+ }
345
+ const legacy = decodePersistedState(value, sectionKeys);
346
+ return {
347
+ sections: migrateVersionOneState(legacy, defaultConfig, sectionKeys),
348
+ migratedFromVersion: 1,
349
+ };
350
+ }
351
+
352
+ function validateNextState(
353
+ value: Record<string, SectionState>,
354
+ defaultConfig: Record<string, SectionDefaultConfig>,
355
+ sectionKeys: readonly string[],
356
+ ): void {
357
+ if (!isPlainRecord(value)) {
358
+ throw new SidebarPanelStateContractError('newState', 'expected an object');
359
+ }
360
+ assertExactKeys(value, sectionKeys, 'newState');
361
+
362
+ for (const sectionKey of sectionKeys) {
363
+ const section = value[sectionKey];
364
+ const path = `newState.${sectionKey}`;
365
+ if (!isPlainRecord(section)) {
366
+ throw new SidebarPanelStateContractError(path, 'expected an object');
367
+ }
368
+ assertExactKeys(section, ['isOpen', 'size', 'minSize'], path);
369
+ if (typeof section.isOpen !== 'boolean') {
370
+ throw new SidebarPanelStateContractError(`${path}.isOpen`, 'expected a boolean');
371
+ }
372
+ if (!isValidSize(section.size)) {
373
+ throw new SidebarPanelStateContractError(`${path}.size`, 'expected null or a finite non-negative number');
374
+ }
375
+ const expectedMinSize = defaultConfig[sectionKey]?.minSize ?? DEFAULT_MIN_SIZE;
376
+ if (section.minSize !== expectedMinSize) {
377
+ throw new SidebarPanelStateContractError(
378
+ `${path}.minSize`,
379
+ `expected configured value ${expectedMinSize}, received ${String(section.minSize)}`,
380
+ );
381
+ }
382
+ }
383
+ assertSidebarLayoutState(value, 'newState');
384
+ }
385
+
386
+ function buildDefaultState(
387
+ defaultConfig: Record<string, SectionDefaultConfig>,
388
+ sectionKeys: readonly string[],
389
+ persistedState: PersistedSidebarPanelState,
390
+ ): Record<string, SectionState> {
391
+ return Object.fromEntries(sectionKeys.map((sectionKey) => {
392
+ const config = defaultConfig[sectionKey]!;
393
+ const persisted = persistedState[sectionKey];
394
+ return [sectionKey, {
395
+ isOpen: persisted?.isOpen ?? config.defaultIsOpen,
396
+ size: persisted === undefined ? config.defaultSize : persisted.size,
397
+ minSize: config.minSize ?? DEFAULT_MIN_SIZE,
398
+ }];
399
+ }));
400
+ }
401
+
402
+ /**
403
+ * Управляет disclosure/size state всех секций sidebar через один validated owner.
404
+ * Owns disclosure/size state for every sidebar section through one validated owner.
405
+ *
406
+ * Persisted v2 document содержит exact `{ version, sections }`. Валидный unversioned v1 record
407
+ * мигрирует один раз по объявленным default/min layout values; невалидный v2 целиком удаляется
408
+ * только после явного recovery-решения runtime.
409
+ * The persisted v2 document is exact `{ version, sections }`. A valid unversioned v1 record is
410
+ * migrated once from declared default/min layout values; invalid v2 data is removed as a whole
411
+ * only after the runtime explicitly accepts memory-state recovery.
412
+ */
413
+ export function useSidebarPanelState(
414
+ storageKey: string,
415
+ defaultConfig: Record<string, SectionDefaultConfig>,
416
+ runtime: SidebarPanelStateRuntime,
417
+ ) {
418
+ if (typeof storageKey !== 'string' || storageKey.trim().length === 0) {
419
+ throw new SidebarPanelStateContractError('storageKey', 'expected a non-empty string');
420
+ }
421
+ validateRuntime(runtime);
422
+ if (!getCurrentScope()) {
423
+ throw new SidebarPanelStateContractError(
424
+ 'runtime.scope',
425
+ 'useSidebarPanelState must run inside an active Vue effect scope',
426
+ );
427
+ }
428
+
429
+ const sectionKeys = validateDefaultConfig(defaultConfig);
430
+
431
+ const recover = (error: SidebarPanelStatePersistenceError): void => {
432
+ const resolution = runtime.recoverFromPersistenceError(error);
433
+ if (resolution !== MEMORY_STATE_RECOVERY) {
434
+ throw new SidebarPanelStateContractError(
435
+ 'runtime.recoverFromPersistenceError',
436
+ `expected "${MEMORY_STATE_RECOVERY}", received ${String(resolution)}`,
437
+ );
438
+ }
439
+ };
440
+
441
+ let storage: SidebarPanelStateStorage | null = null;
442
+ try {
443
+ storage = runtime.resolveStorage();
444
+ } catch (cause) {
445
+ recover(new SidebarPanelStatePersistenceError({
446
+ code: 'storage-resolution-failed',
447
+ storageKey,
448
+ message: `Unable to resolve sidebar state storage for "${storageKey}".`,
449
+ cause,
450
+ }));
451
+ }
452
+
453
+ const removePersistedState = (): void => {
454
+ if (!storage) {
455
+ return;
456
+ }
457
+ try {
458
+ storage.removeItem(storageKey);
459
+ } catch (cause) {
460
+ recover(new SidebarPanelStatePersistenceError({
461
+ code: 'remove-failed',
462
+ storageKey,
463
+ message: `Unable to remove sidebar state for "${storageKey}".`,
464
+ cause,
465
+ }));
466
+ }
467
+ };
468
+
469
+ const writePersistedState = (sections: PersistedSidebarPanelState): void => {
470
+ if (!storage) return;
471
+ try {
472
+ storage.setItem(storageKey, JSON.stringify(persistedDocument(sections)));
473
+ } catch (cause) {
474
+ recover(new SidebarPanelStatePersistenceError({
475
+ code: 'write-failed',
476
+ storageKey,
477
+ message: `Unable to write sidebar state for "${storageKey}".`,
478
+ cause,
479
+ }));
480
+ }
481
+ };
482
+
483
+ const readPersistedState = (): PersistedSidebarPanelState => {
484
+ if (!storage) {
485
+ return {};
486
+ }
487
+
488
+ let rawValue: string | null;
489
+ try {
490
+ rawValue = storage.getItem(storageKey);
491
+ } catch (cause) {
492
+ recover(new SidebarPanelStatePersistenceError({
493
+ code: 'read-failed',
494
+ storageKey,
495
+ message: `Unable to read sidebar state for "${storageKey}".`,
496
+ cause,
497
+ }));
498
+ return {};
499
+ }
500
+ if (rawValue === null) {
501
+ return {};
502
+ }
503
+
504
+ let parsedValue: unknown;
505
+ try {
506
+ parsedValue = JSON.parse(rawValue) as unknown;
507
+ } catch (cause) {
508
+ recover(new SidebarPanelStatePersistenceError({
509
+ code: 'invalid-json',
510
+ storageKey,
511
+ message: `Persisted sidebar state for "${storageKey}" is not valid JSON.`,
512
+ cause,
513
+ }));
514
+ removePersistedState();
515
+ return {};
516
+ }
517
+
518
+ try {
519
+ const decoded = decodePersistedDocument(parsedValue, defaultConfig, sectionKeys);
520
+ if (decoded.migratedFromVersion === 1) writePersistedState(decoded.sections);
521
+ return decoded.sections;
522
+ } catch (cause) {
523
+ recover(new SidebarPanelStatePersistenceError({
524
+ code: 'invalid-schema',
525
+ storageKey,
526
+ path: cause instanceof SidebarPanelStateContractError ? cause.path : '$',
527
+ message: `Persisted sidebar state for "${storageKey}" does not match its schema.`,
528
+ cause,
529
+ }));
530
+ removePersistedState();
531
+ return {};
532
+ }
533
+ };
534
+
535
+ const sectionsState = reactive<Record<string, SectionState>>(
536
+ buildDefaultState(defaultConfig, sectionKeys, readPersistedState()),
537
+ );
538
+
539
+ const stateToStore = computed<PersistedSidebarPanelDocument>(() =>
540
+ persistedDocument(Object.fromEntries(sectionKeys.map((sectionKey) => {
541
+ const section = sectionsState[sectionKey]!;
542
+ return [sectionKey, { isOpen: section.isOpen, size: section.size }];
543
+ }))),
544
+ );
545
+
546
+ watch(stateToStore, (nextState) => {
547
+ writePersistedState(nextState.sections);
548
+ }, { deep: true });
549
+
550
+ const updateSections = (newState: Record<string, SectionState>): void => {
551
+ validateNextState(newState, defaultConfig, sectionKeys);
552
+ for (const sectionKey of sectionKeys) {
553
+ sectionsState[sectionKey] = { ...newState[sectionKey]! };
554
+ }
555
+ };
556
+
557
+ const toggleSection = (sectionKey: string): void => {
558
+ const section = sectionsState[sectionKey];
559
+ if (!section) {
560
+ throw new SidebarPanelStateContractError(
561
+ `sectionsState.${sectionKey}`,
562
+ 'cannot toggle an unknown section',
563
+ );
564
+ }
565
+ const nextState: Record<string, MutableSectionState> = Object.fromEntries(
566
+ sectionKeys.map((key) => [key, { ...sectionsState[key]! }]),
567
+ );
568
+ const nextSection = nextState[sectionKey]!;
569
+ const otherOpenKeys = sectionKeys.filter((key) => key !== sectionKey && nextState[key]?.isOpen);
570
+ if (nextSection.isOpen) {
571
+ nextSection.isOpen = false;
572
+ if (nextSection.size === null && otherOpenKeys.length > 0) {
573
+ const sectionIndex = sectionKeys.indexOf(sectionKey);
574
+ const nextFlexibleKey = [...otherOpenKeys].sort((left, right) => (
575
+ Math.abs(sectionKeys.indexOf(left) - sectionIndex)
576
+ - Math.abs(sectionKeys.indexOf(right) - sectionIndex)
577
+ ))[0]!;
578
+ nextState[nextFlexibleKey] = { ...nextState[nextFlexibleKey]!, size: null };
579
+ }
580
+ } else {
581
+ nextSection.isOpen = true;
582
+ if (otherOpenKeys.length === 0) {
583
+ nextSection.size = null;
584
+ } else if (nextSection.size === null) {
585
+ const config = defaultConfig[sectionKey]!;
586
+ nextSection.size = config.defaultSize ?? config.minSize ?? DEFAULT_MIN_SIZE;
587
+ }
588
+ }
589
+ validateNextState(nextState, defaultConfig, sectionKeys);
590
+ for (const key of sectionKeys) sectionsState[key] = nextState[key]!;
591
+ };
592
+
593
+ const resetSections = (): void => {
594
+ removePersistedState();
595
+ const defaultState = buildDefaultState(defaultConfig, sectionKeys, {});
596
+ for (const sectionKey of sectionKeys) {
597
+ sectionsState[sectionKey] = defaultState[sectionKey]!;
598
+ }
599
+ };
600
+
601
+ const unsubscribeFromReset = runtime.resetController.subscribe(resetSections);
602
+ onScopeDispose(unsubscribeFromReset);
603
+
604
+ return {
605
+ sectionsState: readonly(sectionsState),
606
+ updateSections,
607
+ toggleSection,
608
+ resetSections,
609
+ };
610
+ }