@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,202 @@
1
+ import { isFocusableElement } from './focusNavigation'
2
+
3
+ export type LayerKind = 'dialog' | 'listbox' | 'menu' | 'popover' | 'tooltip'
4
+ export type LayerDismissReason = 'escape' | 'outside-contextmenu' | 'outside-pointer'
5
+
6
+ export interface LayerRegistrationOptions {
7
+ kind: LayerKind
8
+ root: () => HTMLElement | null
9
+ anchor?: () => HTMLElement | null
10
+ branches?: () => readonly HTMLElement[]
11
+ modal?: boolean
12
+ restoreTarget?: HTMLElement | null
13
+ dismissOnEscape?: () => boolean
14
+ dismissOnOutsidePointer?: () => boolean
15
+ blocksParentFocusTrap?: boolean
16
+ trapFocus?: (event: KeyboardEvent) => void
17
+ requestDismiss: (reason: LayerDismissReason, event: Event) => void
18
+ }
19
+
20
+ export interface LayerRegistration {
21
+ readonly id: symbol
22
+ readonly zIndex: string
23
+ readonly focusScope: () => ParentNode
24
+ unregister: (options?: { restoreFocus?: boolean }) => void
25
+ }
26
+
27
+ interface LayerEntry extends LayerRegistrationOptions {
28
+ id: symbol
29
+ parentId: symbol | null
30
+ restoreTarget: HTMLElement | null
31
+ tier: 'floating' | 'overlay'
32
+ zIndex: string
33
+ }
34
+
35
+ const layers: LayerEntry[] = []
36
+ let listenersAttached = false
37
+ let bodyLockCount = 0
38
+ let preservedBodyOverflow = ''
39
+
40
+ const entryContains = (entry: LayerEntry, target: Node): boolean => {
41
+ if (entry.root()?.contains(target) || entry.anchor?.()?.contains(target)) return true
42
+ return entry.branches?.().some((branch) => branch.contains(target)) ?? false
43
+ }
44
+
45
+ const findParentId = (options: LayerRegistrationOptions): symbol | null => {
46
+ const reference = options.anchor?.()
47
+ ?? options.restoreTarget
48
+ ?? (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
49
+ ? document.activeElement
50
+ : null)
51
+ if (!reference) return null
52
+ return [...layers].reverse().find((entry) => entryContains(entry, reference))?.id ?? null
53
+ }
54
+
55
+ const topmostLayer = (): LayerEntry | undefined => layers[layers.length - 1]
56
+
57
+ const resolveFocusScope = (entry: LayerEntry): ParentNode => {
58
+ let parentId = entry.parentId
59
+ while (parentId) {
60
+ const parent = layers.find((candidate) => candidate.id === parentId)
61
+ if (!parent) break
62
+ if (parent.modal || parent.trapFocus) {
63
+ return parent.root() ?? document
64
+ }
65
+ parentId = parent.parentId
66
+ }
67
+ return document
68
+ }
69
+
70
+ const handleGlobalKeydown = (event: KeyboardEvent): void => {
71
+ const topmost = topmostLayer()
72
+ if (!topmost) return
73
+
74
+ if (event.key === 'Escape') {
75
+ if (topmost.dismissOnEscape?.() ?? true) {
76
+ event.preventDefault()
77
+ event.stopPropagation()
78
+ topmost.requestDismiss('escape', event)
79
+ }
80
+ return
81
+ }
82
+
83
+ if (event.key !== 'Tab') return
84
+ let trapIndex = -1
85
+ for (let index = layers.length - 1; index >= 0; index -= 1) {
86
+ if (layers[index]?.trapFocus) {
87
+ trapIndex = index
88
+ break
89
+ }
90
+ }
91
+ if (trapIndex < 0) return
92
+ const blockedByChild = layers
93
+ .slice(trapIndex + 1)
94
+ .some((entry) => entry.blocksParentFocusTrap ?? true)
95
+ if (!blockedByChild) layers[trapIndex]?.trapFocus?.(event)
96
+ }
97
+
98
+ const handleGlobalOutsideEvent = (event: Event, reason: LayerDismissReason): void => {
99
+ const topmost = topmostLayer()
100
+ if (!topmost) return
101
+ const target = event.target
102
+ if (target instanceof Node && entryContains(topmost, target)) return
103
+ if (topmost.dismissOnOutsidePointer?.() ?? true) topmost.requestDismiss(reason, event)
104
+ }
105
+
106
+ const handleGlobalPointerDown = (event: PointerEvent): void => {
107
+ handleGlobalOutsideEvent(event, 'outside-pointer')
108
+ }
109
+
110
+ const handleGlobalContextMenu = (event: MouseEvent): void => {
111
+ handleGlobalOutsideEvent(event, 'outside-contextmenu')
112
+ }
113
+
114
+ const attachListeners = (): void => {
115
+ if (listenersAttached || typeof window === 'undefined' || typeof document === 'undefined') return
116
+ listenersAttached = true
117
+ window.addEventListener('keydown', handleGlobalKeydown, true)
118
+ document.addEventListener('pointerdown', handleGlobalPointerDown, true)
119
+ document.addEventListener('contextmenu', handleGlobalContextMenu, true)
120
+ }
121
+
122
+ const detachListeners = (): void => {
123
+ if (!listenersAttached || layers.length > 0 || typeof window === 'undefined' || typeof document === 'undefined') return
124
+ listenersAttached = false
125
+ window.removeEventListener('keydown', handleGlobalKeydown, true)
126
+ document.removeEventListener('pointerdown', handleGlobalPointerDown, true)
127
+ document.removeEventListener('contextmenu', handleGlobalContextMenu, true)
128
+ }
129
+
130
+ const lockBody = (): void => {
131
+ if (typeof document === 'undefined') return
132
+ if (bodyLockCount === 0) {
133
+ preservedBodyOverflow = document.body.style.overflow
134
+ document.body.style.overflow = 'hidden'
135
+ }
136
+ bodyLockCount += 1
137
+ }
138
+
139
+ const unlockBody = (): void => {
140
+ if (typeof document === 'undefined') return
141
+ bodyLockCount = Math.max(0, bodyLockCount - 1)
142
+ if (bodyLockCount === 0) document.body.style.overflow = preservedBodyOverflow
143
+ }
144
+
145
+ const scheduleFocusRestore = (entry: LayerEntry): void => {
146
+ queueMicrotask(() => {
147
+ if (entry.restoreTarget?.isConnected && isFocusableElement(entry.restoreTarget)) {
148
+ entry.restoreTarget.focus()
149
+ return
150
+ }
151
+ topmostLayer()?.root()?.focus()
152
+ })
153
+ }
154
+
155
+ /**
156
+ * Регистрирует одну открытую overlay-поверхность в общем dismiss/focus/body-lock стеке.
157
+ * Registers one open overlay surface in the shared dismiss/focus/body-lock stack.
158
+ */
159
+ export function registerLayer(options: LayerRegistrationOptions): LayerRegistration {
160
+ const previousTopmost = topmostLayer()
161
+ const tier = options.modal || previousTopmost?.tier === 'overlay' ? 'overlay' : 'floating'
162
+ const zIndex = previousTopmost && previousTopmost.tier === tier
163
+ ? `calc(${previousTopmost.zIndex} + var(--s-layer-overlay-step))`
164
+ : tier === 'overlay'
165
+ ? 'var(--s-layer-overlay-backdrop)'
166
+ : 'var(--s-layer-floating)'
167
+ const entry: LayerEntry = {
168
+ ...options,
169
+ id: Symbol(options.kind),
170
+ parentId: findParentId(options),
171
+ restoreTarget: options.restoreTarget ?? null,
172
+ tier,
173
+ zIndex,
174
+ }
175
+ let registered = true
176
+ layers.push(entry)
177
+ if (entry.modal) lockBody()
178
+ attachListeners()
179
+
180
+ return {
181
+ id: entry.id,
182
+ zIndex: entry.zIndex,
183
+ focusScope: () => resolveFocusScope(entry),
184
+ unregister: ({ restoreFocus = false } = {}) => {
185
+ if (!registered) return
186
+ registered = false
187
+ const index = layers.findIndex((candidate) => candidate.id === entry.id)
188
+ const wasTopmost = index === layers.length - 1
189
+ if (index >= 0) {
190
+ const removedRoot = entry.root()
191
+ for (const layer of layers.slice(index + 1)) {
192
+ if (layer.parentId === entry.id) layer.parentId = entry.parentId
193
+ if (removedRoot?.contains(layer.restoreTarget)) layer.restoreTarget = entry.restoreTarget
194
+ }
195
+ layers.splice(index, 1)
196
+ }
197
+ if (entry.modal) unlockBody()
198
+ detachListeners()
199
+ if (restoreFocus && wasTopmost) scheduleFocusRestore(entry)
200
+ },
201
+ }
202
+ }
@@ -0,0 +1,256 @@
1
+ import {
2
+ LINK_BROWSING_TARGETS,
3
+ type LinkBrowsingTarget,
4
+ type LinkHistoryStateValue,
5
+ type LinkRouteScalar,
6
+ type LinkRouterTarget,
7
+ } from '../components/shared/controls/SLink'
8
+
9
+ /** Runtime input accepts unknown fields so every public owner validates the actual payload. */
10
+ export interface LinkTargetInput {
11
+ readonly href?: unknown
12
+ readonly to?: unknown
13
+ readonly target?: unknown
14
+ readonly rel?: unknown
15
+ readonly owner: string
16
+ }
17
+
18
+ export type ResolvedLinkTarget =
19
+ | { readonly kind: 'anchor'; readonly bindings: { readonly href: string; readonly target: LinkBrowsingTarget; readonly rel?: string } }
20
+ | { readonly kind: 'router'; readonly bindings: { readonly to: LinkRouterTarget; readonly target: LinkBrowsingTarget; readonly rel?: string } }
21
+
22
+ const ROUTE_KEYS = new Set(['name', 'path', 'params', 'query', 'hash', 'replace', 'force', 'state'])
23
+
24
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
25
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
26
+ const prototype = Object.getPrototypeOf(value)
27
+ return prototype === Object.prototype || prototype === null
28
+ }
29
+
30
+ function hasOwn(value: object, key: string): boolean {
31
+ return Object.prototype.hasOwnProperty.call(value, key)
32
+ }
33
+
34
+ function isRouteScalar(value: unknown): value is LinkRouteScalar {
35
+ return value === null
36
+ || value === undefined
37
+ || typeof value === 'string'
38
+ || (typeof value === 'number' && Number.isFinite(value))
39
+ }
40
+
41
+ function enumerableDataEntries(
42
+ owner: string,
43
+ coordinate: string,
44
+ value: Record<string, unknown>,
45
+ ): Array<readonly [string, unknown]> {
46
+ const entries: Array<readonly [string, unknown]> = []
47
+ for (const key of Reflect.ownKeys(value)) {
48
+ if (typeof key !== 'string') {
49
+ throw new TypeError(`${owner}: ${coordinate} не допускает symbol keys / ${coordinate} does not allow symbol keys`)
50
+ }
51
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
52
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
53
+ throw new TypeError(
54
+ `${owner}: ${coordinate}.${key} должен быть enumerable data property `
55
+ + `/ ${coordinate}.${key} must be an enumerable data property`,
56
+ )
57
+ }
58
+ entries.push([key, descriptor.value])
59
+ }
60
+ return entries
61
+ }
62
+
63
+ function validateDenseScalarArray(
64
+ owner: string,
65
+ coordinate: string,
66
+ value: readonly unknown[],
67
+ allowNullish: boolean,
68
+ ): boolean {
69
+ for (let index = 0; index < value.length; index += 1) {
70
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
71
+ if (!descriptor?.enumerable || !('value' in descriptor)) return false
72
+ if (!isRouteScalar(descriptor.value)) return false
73
+ if (!allowNullish && (descriptor.value === null || descriptor.value === undefined)) return false
74
+ }
75
+ return Reflect.ownKeys(value).every((key) => key === 'length' || (
76
+ typeof key === 'string'
77
+ && /^(0|[1-9]\d*)$/u.test(key)
78
+ && Number(key) < value.length
79
+ ))
80
+ }
81
+
82
+ function validateRouteDictionary(
83
+ owner: string,
84
+ coordinate: string,
85
+ value: unknown,
86
+ allowNullishArrayValues: boolean,
87
+ ): void {
88
+ if (!isPlainObject(value)) {
89
+ throw new TypeError(`${owner}: ${coordinate} должен быть plain object / ${coordinate} must be a plain object`)
90
+ }
91
+ for (const [key, candidate] of enumerableDataEntries(owner, coordinate, value)) {
92
+ const validArray = Array.isArray(candidate)
93
+ && validateDenseScalarArray(owner, `${coordinate}.${key}`, candidate, allowNullishArrayValues)
94
+ if (!isRouteScalar(candidate) && !validArray) {
95
+ throw new TypeError(
96
+ `${owner}: ${coordinate}.${key} должен быть scalar или массивом scalar `
97
+ + `/ ${coordinate}.${key} must be a scalar or scalar array`,
98
+ )
99
+ }
100
+ }
101
+ }
102
+
103
+ function validateHistoryStateValue(
104
+ owner: string,
105
+ coordinate: string,
106
+ value: unknown,
107
+ ancestors: Set<object>,
108
+ ): asserts value is LinkHistoryStateValue {
109
+ if (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value)) return
110
+ if (typeof value !== 'object') {
111
+ throw new TypeError(`${owner}: ${coordinate} содержит неподдерживаемое state value / ${coordinate} contains an unsupported state value`)
112
+ }
113
+ if (ancestors.has(value)) {
114
+ throw new TypeError(`${owner}: ${coordinate} содержит cycle / ${coordinate} contains a cycle`)
115
+ }
116
+ if (!Array.isArray(value) && !isPlainObject(value)) {
117
+ throw new TypeError(`${owner}: ${coordinate} должен содержать только plain objects/arrays / ${coordinate} must contain only plain objects/arrays`)
118
+ }
119
+ ancestors.add(value)
120
+ if (Array.isArray(value)) {
121
+ if (!Reflect.ownKeys(value).every((key) => key === 'length' || (
122
+ typeof key === 'string'
123
+ && /^(0|[1-9]\d*)$/u.test(key)
124
+ && Number(key) < value.length
125
+ ))) {
126
+ throw new TypeError(`${owner}: ${coordinate} не должен быть sparse / ${coordinate} must not be sparse`)
127
+ }
128
+ for (let index = 0; index < value.length; index += 1) {
129
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
130
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
131
+ throw new TypeError(`${owner}: ${coordinate} не должен быть sparse/accessor / ${coordinate} must not be sparse/accessor`)
132
+ }
133
+ validateHistoryStateValue(owner, `${coordinate}[${index}]`, descriptor.value, ancestors)
134
+ }
135
+ } else {
136
+ for (const [key, item] of enumerableDataEntries(owner, coordinate, value)) {
137
+ validateHistoryStateValue(owner, `${coordinate}.${key}`, item, ancestors)
138
+ }
139
+ }
140
+ ancestors.delete(value)
141
+ }
142
+
143
+ function isRouterTarget(owner: string, value: unknown): value is LinkRouterTarget {
144
+ if (typeof value === 'string') {
145
+ if (value.trim().length > 0 && value === value.trim()) return true
146
+ throw new TypeError(
147
+ `${owner}: to должен быть непустой строкой или route object `
148
+ + '/ to must be a non-empty string or route object',
149
+ )
150
+ }
151
+ if (!isPlainObject(value)) {
152
+ throw new TypeError(`${owner}: to должен быть plain route object / to must be a plain route object`)
153
+ }
154
+ const routeEntries = enumerableDataEntries(owner, 'to', value)
155
+ for (const [key] of routeEntries) {
156
+ if (!ROUTE_KEYS.has(key)) {
157
+ throw new TypeError(
158
+ `${owner}: to route object содержит неподдерживаемое поле ${JSON.stringify(key)} `
159
+ + `/ to route object contains unsupported field ${JSON.stringify(key)}`,
160
+ )
161
+ }
162
+ }
163
+ const hasName = hasOwn(value, 'name')
164
+ const hasPath = hasOwn(value, 'path')
165
+ if (hasName === hasPath) {
166
+ throw new TypeError(
167
+ `${owner}: to route object должен содержать ровно один explicit name/path `
168
+ + '/ to route object must contain exactly one explicit name/path',
169
+ )
170
+ }
171
+ if (hasName && !(
172
+ typeof value.name === 'symbol'
173
+ || (typeof value.name === 'string' && value.name.trim().length > 0)
174
+ )) {
175
+ throw new TypeError(`${owner}: to.name должен быть непустой строкой или symbol / to.name must be a non-empty string or symbol`)
176
+ }
177
+ if (hasPath && (typeof value.path !== 'string' || value.path.trim().length === 0)) {
178
+ throw new TypeError(`${owner}: to.path должен быть непустой строкой / to.path must be a non-empty string`)
179
+ }
180
+ if (hasPath && value.params !== undefined) {
181
+ throw new TypeError(`${owner}: to.params допустим только для named route / to.params is only supported for a named route`)
182
+ }
183
+ if (value.params !== undefined) validateRouteDictionary(owner, 'to.params', value.params, false)
184
+ if (value.query !== undefined) validateRouteDictionary(owner, 'to.query', value.query, true)
185
+ if (value.hash !== undefined && typeof value.hash !== 'string') {
186
+ throw new TypeError(`${owner}: to.hash должен быть строкой / to.hash must be a string`)
187
+ }
188
+ for (const key of ['replace', 'force'] as const) {
189
+ if (value[key] !== undefined && typeof value[key] !== 'boolean') {
190
+ throw new TypeError(`${owner}: to.${key} должен быть boolean / to.${key} must be a boolean`)
191
+ }
192
+ }
193
+ if (value.state !== undefined) {
194
+ if (!isPlainObject(value.state)) {
195
+ throw new TypeError(`${owner}: to.state должен быть plain object / to.state must be a plain object`)
196
+ }
197
+ validateHistoryStateValue(owner, 'to.state', value.state, new Set<object>())
198
+ }
199
+ return true
200
+ }
201
+
202
+ function isBrowsingTarget(value: unknown): value is LinkBrowsingTarget {
203
+ for (const target of LINK_BROWSING_TARGETS) {
204
+ if (value === target) return true
205
+ }
206
+ return false
207
+ }
208
+
209
+ function resolveBrowsingTarget(owner: string, value: unknown): LinkBrowsingTarget {
210
+ if (value === undefined) return '_self'
211
+ if (isBrowsingTarget(value)) return value
212
+ throw new TypeError(
213
+ `${owner}: target должен быть одним из ${LINK_BROWSING_TARGETS.join(', ')} `
214
+ + `/ target must be one of ${LINK_BROWSING_TARGETS.join(', ')}`,
215
+ )
216
+ }
217
+
218
+ function resolveRel(owner: string, value: unknown, target: LinkBrowsingTarget): string | undefined {
219
+ if (value === undefined) return target === '_blank' ? 'noopener noreferrer' : undefined
220
+ if (typeof value !== 'string' || value.trim().length === 0) {
221
+ throw new TypeError(`${owner}: rel должен быть непустой строкой / rel must be a non-empty string`)
222
+ }
223
+ if (target !== '_blank') return value
224
+
225
+ const tokens = value.trim().split(/\s+/u)
226
+ const comparableTokens = new Set(tokens.map((token) => token.toLowerCase()))
227
+ if (!comparableTokens.has('noopener')) tokens.push('noopener')
228
+ if (!comparableTokens.has('noreferrer')) tokens.push('noreferrer')
229
+ return tokens.join(' ')
230
+ }
231
+
232
+ /** Validates the sole link-target boundary and secures new browsing contexts. */
233
+ export function resolveLinkTarget(input: LinkTargetInput): ResolvedLinkTarget {
234
+ const hasHref = input.href !== undefined
235
+ const hasTo = input.to !== undefined
236
+ if (hasHref === hasTo) {
237
+ throw new TypeError(`${input.owner}: передайте ровно один target — href или to`)
238
+ }
239
+
240
+ const target = resolveBrowsingTarget(input.owner, input.target)
241
+ const rel = resolveRel(input.owner, input.rel, target)
242
+ if (hasHref) {
243
+ if (
244
+ typeof input.href !== 'string'
245
+ || input.href.trim().length === 0
246
+ || input.href !== input.href.trim()
247
+ ) {
248
+ throw new TypeError(`${input.owner}: href должен быть непустой строкой / href must be a non-empty string`)
249
+ }
250
+ return { kind: 'anchor', bindings: { href: input.href, target, ...(rel === undefined ? {} : { rel }) } }
251
+ }
252
+ if (isRouterTarget(input.owner, input.to)) {
253
+ return { kind: 'router', bindings: { to: input.to, target, ...(rel === undefined ? {} : { rel }) } }
254
+ }
255
+ throw new TypeError(`${input.owner}: недостижимый router target / unreachable router target`)
256
+ }