@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,692 @@
1
+ <template>
2
+ <nav
3
+ ref="navRef"
4
+ v-bind="ownedAttrs.bindings()"
5
+ class="s-bottom-nav"
6
+ :class="{ 's-bottom-nav--hidden': isHidden }"
7
+ data-test="bottom-nav"
8
+ :aria-label="label"
9
+ @focusout="handleFocusout"
10
+ >
11
+ <div class="s-bottom-nav__items">
12
+ <div
13
+ v-for="item in items"
14
+ :key="item.id"
15
+ class="s-bottom-nav__item"
16
+ >
17
+ <SLink
18
+ v-if="item.kind === 'destination'"
19
+ :href="item.href"
20
+ :to="item.to"
21
+ :target="item.target"
22
+ :rel="item.rel"
23
+ :leading-icon="item.icon"
24
+ :disabled="item.disabled"
25
+ :active="isItemActive(item)"
26
+ variant="bottom-navigation"
27
+ width="full"
28
+ :aria-label="iconOnly ? item.label : undefined"
29
+ :data-testid="`bottom-nav-${item.id}`"
30
+ @click="handleDestinationClick($event, item)"
31
+ >
32
+ <span v-if="!iconOnly">{{ item.label }}</span>
33
+ </SLink>
34
+ <SButton
35
+ v-else
36
+ :ref="(api) => bindItemButton(item, api)"
37
+ :label="iconOnly ? undefined : item.label"
38
+ :leading-icon="item.icon"
39
+ :icon-only="iconOnly"
40
+ :disabled="isItemDisabled(item)"
41
+ size="sm"
42
+ :severity="isItemActive(item) ? 'primary' : 'secondary'"
43
+ :appearance="isItemActive(item) ? 'soft' : 'ghost'"
44
+ shape="row"
45
+ width="full"
46
+ content-align="center"
47
+ :id="item.kind === 'overflow' ? overflowTriggerId(item) : undefined"
48
+ :aria-current="item.kind === 'view' && isItemActive(item) ? 'page' : undefined"
49
+ :aria-label="iconOnly ? item.label : undefined"
50
+ :aria-haspopup="item.kind === 'overflow' && item.items.length > 0 ? 'menu' : undefined"
51
+ :disclosure="item.kind === 'overflow' && item.items.length > 0 ? {
52
+ expanded: activeOverflowId === item.id,
53
+ controls: overflowMenuId(item),
54
+ } : undefined"
55
+ :data-test="`bottom-nav-${item.id}`"
56
+ :data-testid="`bottom-nav-${item.id}`"
57
+ @click="handleItemClick(item, $event)"
58
+ @keydown="handleItemKeydown($event, item)"
59
+ />
60
+ <span v-if="item.badge !== undefined" class="s-bottom-nav__badge">
61
+ <SBadge severity="danger" size="xs">
62
+ {{ item.badge }}
63
+ </SBadge>
64
+ </span>
65
+ </div>
66
+ <div
67
+ v-if="activeOverflowItem"
68
+ ref="overflowLayerRef"
69
+ class="s-bottom-nav__overflow-layer"
70
+ data-test="bottom-nav-overflow"
71
+ >
72
+ <SMenuSurface
73
+ :id="overflowMenuId(activeOverflowItem)"
74
+ :label="activeOverflowItem.menuLabel"
75
+ density="compact"
76
+ orientation="horizontal"
77
+ autofocus
78
+ :items="activeOverflowMenuEntries"
79
+ @escape="closeOverflow(true)"
80
+ @focus-exit="closeOverflow(false)"
81
+ @select="handleOverflowSelection"
82
+ @tab="handleOverflowTab"
83
+ />
84
+ </div>
85
+ </div>
86
+ <div v-if="$slots.status" class="s-bottom-nav__status">
87
+ <slot name="status" />
88
+ </div>
89
+ </nav>
90
+ </template>
91
+
92
+ <script setup lang="ts">
93
+ import { ref, onMounted, onBeforeUnmount, computed, nextTick, useId, watch } from 'vue';
94
+ import type { Component } from 'vue';
95
+ import { findRelativeTabTarget } from '../../../internal/focusNavigation';
96
+ import { registerLayer, type LayerRegistration } from '../../../internal/layerStack';
97
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs';
98
+ import { resolveLinkTarget } from '../../../internal/linkTarget';
99
+ import SButton from '../controls/SButton.vue';
100
+ import type { SButtonApi } from '../controls/SButton.vue';
101
+ import SLink from '../controls/SLink.vue';
102
+ import SBadge from '../data-display/SBadge.vue';
103
+ import SMenuSurface from '../controls/SMenuSurface.vue';
104
+ import type { LinkDestination } from '../controls/SLink';
105
+ import type {
106
+ SMenuAction,
107
+ SMenuActivationEvent,
108
+ SMenuDestination,
109
+ SMenuEntry,
110
+ SMenuSelectableEntry,
111
+ } from '../controls/menu';
112
+
113
+ defineOptions({ inheritAttrs: false });
114
+
115
+ /**
116
+ * Общие данные пункта с уникальным непустым id. / Shared item data with a unique non-empty id.
117
+ */
118
+ export interface BottomNavItemBase {
119
+ id: string;
120
+ icon: Component;
121
+ label: string;
122
+ badge?: number;
123
+ disabled?: boolean;
124
+ }
125
+
126
+ /**
127
+ * Назначение обновляет modelValue и владеет aria-current. / A destination updates modelValue and owns aria-current.
128
+ */
129
+ export type BottomNavDestinationItem = BottomNavItemBase & LinkDestination & {
130
+ kind: 'destination';
131
+ }
132
+
133
+ /**
134
+ * Локальное представление переключает controlled model без выдуманного URL.
135
+ * A local view updates the controlled model without inventing a URL.
136
+ */
137
+ export interface BottomNavViewItem extends BottomNavItemBase {
138
+ kind: 'view';
139
+ }
140
+
141
+ /**
142
+ * Команда эмитит action и не имитирует текущую страницу. / A command emits action and never impersonates a page.
143
+ */
144
+ export interface BottomNavActionItem extends BottomNavItemBase {
145
+ kind: 'action';
146
+ }
147
+
148
+ /** Пункт, который можно выбрать напрямую. / A directly selectable item. */
149
+ export type BottomNavLeafItem = BottomNavDestinationItem | BottomNavViewItem | BottomNavActionItem;
150
+
151
+ /**
152
+ * Кнопка меню с доступным названием владеет типизированными пунктами. / A labelled menu button owns typed items.
153
+ */
154
+ export interface BottomNavOverflowItem extends BottomNavItemBase {
155
+ kind: 'overflow';
156
+ menuLabel: string;
157
+ items: readonly BottomNavLeafItem[];
158
+ }
159
+
160
+ /** Публичный discriminated contract элемента нижней навигации. / Public discriminated bottom-navigation item contract. */
161
+ export type BottomNavItem = BottomNavLeafItem | BottomNavOverflowItem;
162
+
163
+ /**
164
+ * Измеренное состояние экземпляра navigation для scoped layout-consumer.
165
+ * Measured navigation instance state for a scoped layout consumer.
166
+ */
167
+ export interface BottomNavLayoutState {
168
+ readonly hidden: boolean;
169
+ readonly height: number;
170
+ }
171
+
172
+ const props = withDefaults(defineProps<{
173
+ /** Имя navigation landmark для assistive technologies. / Accessible navigation landmark name. */
174
+ label: string;
175
+ items: readonly BottomNavItem[];
176
+ /** id текущего destination/view; другие id не получают current state. / Current destination/view id; other ids never receive current state. */
177
+ modelValue: string;
178
+ scrollTargetId?: string;
179
+ hidden?: boolean;
180
+ iconOnly?: boolean;
181
+ }>(), {
182
+ hidden: false,
183
+ iconOnly: true,
184
+ });
185
+
186
+ const emit = defineEmits<{
187
+ (e: 'update:modelValue', value: string): void;
188
+ (e: 'action', value: string, event: SMenuActivationEvent): void;
189
+ (e: 'navigate', item: BottomNavDestinationItem, event: SMenuActivationEvent): void;
190
+ (e: 'layout-change', value: BottomNavLayoutState): void;
191
+ }>();
192
+ const ownedAttrs = useOwnedAttrs({ component: 'SBottomNav', owner: 'navigation root' });
193
+
194
+ const iconOnly = computed(() => props.iconOnly);
195
+ const componentId = useId();
196
+ const navRef = ref<HTMLElement | null>(null);
197
+ const overflowLayerRef = ref<HTMLElement | null>(null);
198
+ const isHidden = ref(false);
199
+ const activeOverflowId = ref<string | null>(null);
200
+ const overflowTriggerApis = new Map<string, SButtonApi>();
201
+ const overflowIdentityTokens = new Map<string, number>();
202
+ let nextOverflowIdentityToken = 0;
203
+ const overflowItems = computed(() => props.items.filter(
204
+ (item): item is BottomNavOverflowItem => item.kind === 'overflow',
205
+ ));
206
+ const activeOverflowItem = computed(() => overflowItems.value.find(
207
+ (item) => item.id === activeOverflowId.value,
208
+ ) ?? null);
209
+ const activeOverflowMenuEntries = computed<readonly SMenuEntry[]>(() => {
210
+ const overflow = activeOverflowItem.value;
211
+ if (!overflow) return [];
212
+ return overflow.items.map((item): SMenuAction | SMenuDestination => item.kind === 'destination'
213
+ ? {
214
+ ...item,
215
+ badgeSeverity: 'danger',
216
+ current: props.modelValue === item.id,
217
+ layout: 'tile',
218
+ }
219
+ : {
220
+ ...item,
221
+ kind: 'action',
222
+ badgeSeverity: 'danger',
223
+ current: item.kind === 'view' && props.modelValue === item.id,
224
+ layout: 'tile',
225
+ });
226
+ });
227
+ let lastScrollTop = 0;
228
+ let overflowLayerRegistration: LayerRegistration | null = null;
229
+ let resizeObserver: ResizeObserver | null = null;
230
+ let usesWindowResizeFallback = false;
231
+ let lastPublishedLayout: BottomNavLayoutState | null = null;
232
+
233
+ const overflowIdentityToken = (item: BottomNavOverflowItem): number => {
234
+ const current = overflowIdentityTokens.get(item.id);
235
+ if (current !== undefined) return current;
236
+ const token = nextOverflowIdentityToken;
237
+ nextOverflowIdentityToken += 1;
238
+ overflowIdentityTokens.set(item.id, token);
239
+ return token;
240
+ };
241
+
242
+ const overflowTriggerId = (item: BottomNavOverflowItem): string =>
243
+ `s-bottom-nav-${componentId}-overflow-${overflowIdentityToken(item)}-trigger`;
244
+
245
+ const overflowMenuId = (item: BottomNavOverflowItem): string =>
246
+ `s-bottom-nav-${componentId}-overflow-${overflowIdentityToken(item)}-menu`;
247
+
248
+ const bindItemButton = (
249
+ item: BottomNavViewItem | BottomNavActionItem | BottomNavOverflowItem,
250
+ value: unknown,
251
+ ): void => {
252
+ if (item.kind !== 'overflow') return;
253
+ if (value === null) {
254
+ overflowTriggerApis.delete(item.id);
255
+ return;
256
+ }
257
+ if (
258
+ typeof value !== 'object'
259
+ || typeof Reflect.get(value, 'getElement') !== 'function'
260
+ || typeof Reflect.get(value, 'focus') !== 'function'
261
+ ) {
262
+ throw new TypeError(`SBottomNav: overflow trigger '${item.id}' must expose SButtonApi`);
263
+ }
264
+ overflowTriggerApis.set(item.id, value as SButtonApi);
265
+ };
266
+
267
+ const resolveOverflowTrigger = (item: BottomNavOverflowItem): HTMLButtonElement => {
268
+ const api = overflowTriggerApis.get(item.id);
269
+ if (!api) {
270
+ throw new Error(`SBottomNav: overflow trigger '${item.id}' is unavailable`);
271
+ }
272
+ const trigger = api.getElement();
273
+ const expectedId = overflowTriggerId(item);
274
+ if (trigger.id !== expectedId) {
275
+ throw new Error(
276
+ `SBottomNav: overflow trigger '${item.id}' owns id '${trigger.id}', expected '${expectedId}'`,
277
+ );
278
+ }
279
+ return trigger;
280
+ };
281
+
282
+ const isItemDisabled = (item: BottomNavItem): boolean =>
283
+ Boolean(item.disabled || (item.kind === 'overflow' && item.items.length === 0));
284
+
285
+ const isItemActive = (item: BottomNavItem): boolean => {
286
+ if (isItemDisabled(item)) return false;
287
+ if (item.kind === 'action') return false;
288
+ if (item.kind === 'destination' || item.kind === 'view') return props.modelValue === item.id;
289
+ return activeOverflowId.value === item.id
290
+ || item.items.some((child) => (
291
+ child.kind === 'destination' || child.kind === 'view'
292
+ ) && !child.disabled && child.id === props.modelValue);
293
+ };
294
+
295
+ const adoptsCurrentDestination = (
296
+ item: BottomNavDestinationItem,
297
+ event: SMenuActivationEvent,
298
+ ): boolean => {
299
+ if ((item.target ?? '_self') !== '_self') return false;
300
+ if (!(event instanceof MouseEvent)) return true;
301
+ return event.button === 0 && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey;
302
+ };
303
+
304
+ const emitLeafSelection = (item: BottomNavLeafItem, event: SMenuActivationEvent): void => {
305
+ if (item.disabled) return;
306
+ if (item.kind === 'destination') {
307
+ emit('navigate', item, event);
308
+ if (adoptsCurrentDestination(item, event)) emit('update:modelValue', item.id);
309
+ return;
310
+ }
311
+ if (item.kind === 'view') {
312
+ emit('update:modelValue', item.id);
313
+ return;
314
+ }
315
+ emit('action', item.id, event);
316
+ };
317
+
318
+ const closeOverflow = (restoreFocus = false): void => {
319
+ if (!activeOverflowItem.value && !overflowLayerRegistration) return;
320
+ activeOverflowId.value = null;
321
+ overflowLayerRegistration?.unregister({ restoreFocus });
322
+ overflowLayerRegistration = null;
323
+ };
324
+
325
+ const openOverflow = (item: BottomNavOverflowItem): void => {
326
+ if (isItemDisabled(item)) return;
327
+ if (activeOverflowItem.value) closeOverflow(false);
328
+ const trigger = resolveOverflowTrigger(item);
329
+ activeOverflowId.value = item.id;
330
+ overflowLayerRegistration = registerLayer({
331
+ kind: 'menu',
332
+ root: () => overflowLayerRef.value,
333
+ anchor: () => trigger,
334
+ restoreTarget: trigger,
335
+ requestDismiss: (reason) => closeOverflow(reason === 'escape'),
336
+ });
337
+ };
338
+
339
+ const handleDestinationClick = (event: MouseEvent, item: BottomNavDestinationItem): void => {
340
+ closeOverflow(false);
341
+ emitLeafSelection(item, event);
342
+ };
343
+
344
+ const handleItemClick = (
345
+ item: BottomNavViewItem | BottomNavActionItem | BottomNavOverflowItem,
346
+ event: MouseEvent,
347
+ ): void => {
348
+ if (isItemDisabled(item)) return;
349
+ if (item.kind === 'overflow') {
350
+ if (activeOverflowId.value === item.id) closeOverflow(true);
351
+ else openOverflow(item);
352
+ return;
353
+ }
354
+ closeOverflow(false);
355
+ emitLeafSelection(item, event);
356
+ };
357
+
358
+ const handleItemKeydown = (event: KeyboardEvent, item: BottomNavItem): void => {
359
+ if (event.key !== 'ArrowDown' || item.kind !== 'overflow' || isItemDisabled(item)) return;
360
+ event.preventDefault();
361
+ openOverflow(item);
362
+ };
363
+
364
+ const handleOverflowSelection = (entry: SMenuSelectableEntry, event: SMenuActivationEvent): void => {
365
+ if (entry.kind !== 'action' && entry.kind !== 'destination') {
366
+ throw new TypeError(`SBottomNav: overflow menu accepts action/destination entries, received '${entry.kind}'`);
367
+ }
368
+ const overflow = activeOverflowItem.value;
369
+ if (!overflow) throw new Error(`SBottomNav: selected menu entry '${entry.id}' without an active overflow`);
370
+ const item = overflow.items.find((candidate) => candidate.id === entry.id);
371
+ if (!item) throw new Error(`SBottomNav: selected unknown overflow entry '${entry.id}'`);
372
+ if (item.disabled) return;
373
+ emitLeafSelection(item, event);
374
+ closeOverflow(true);
375
+ };
376
+
377
+ const handleFocusout = (event: FocusEvent): void => {
378
+ if (!activeOverflowItem.value) return;
379
+ const nextTarget = event.relatedTarget;
380
+ if (nextTarget instanceof Node && navRef.value?.contains(nextTarget)) return;
381
+ closeOverflow(false);
382
+ };
383
+
384
+ const handleOverflowTab = (event: KeyboardEvent): void => {
385
+ const item = activeOverflowItem.value;
386
+ if (!item) return;
387
+ const trigger = resolveOverflowTrigger(item);
388
+ const scope = overflowLayerRegistration?.focusScope() ?? document;
389
+ const target = findRelativeTabTarget(trigger, scope, overflowLayerRef.value, event.shiftKey);
390
+ event.preventDefault();
391
+ closeOverflow(false);
392
+ if (target) void nextTick(() => target.focus());
393
+ };
394
+
395
+ const handleScroll = (event: Event) => {
396
+ if (!props.scrollTargetId) return;
397
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
398
+
399
+ const target = event.target;
400
+
401
+ let current = 0;
402
+
403
+ if (target instanceof HTMLElement && target.id === props.scrollTargetId) {
404
+ current = target.scrollTop || 0;
405
+ } else if (target === document && document.documentElement.id === props.scrollTargetId) {
406
+ current = document.documentElement.scrollTop || document.body.scrollTop || 0;
407
+ } else {
408
+ return;
409
+ }
410
+
411
+ if (current > lastScrollTop + 10) {
412
+ isHidden.value = true;
413
+ } else if (current < lastScrollTop - 5) {
414
+ isHidden.value = false;
415
+ }
416
+ lastScrollTop = current;
417
+ };
418
+
419
+ const BASE_ITEM_KEYS = ['kind', 'id', 'label', 'icon', 'badge', 'disabled'] as const;
420
+ const ITEM_KEYS = {
421
+ action: new Set<string>(BASE_ITEM_KEYS),
422
+ destination: new Set<string>([...BASE_ITEM_KEYS, 'href', 'to', 'target', 'rel']),
423
+ view: new Set<string>(BASE_ITEM_KEYS),
424
+ overflow: new Set<string>([...BASE_ITEM_KEYS, 'menuLabel', 'items']),
425
+ } as const;
426
+
427
+ function assertItemRecord(value: unknown, coordinate: string): asserts value is Record<string, unknown> {
428
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) return;
429
+ throw new TypeError(`SBottomNav: ${coordinate} must be an object`);
430
+ }
431
+
432
+ function validateLeafRecord(
433
+ candidate: Record<string, unknown>,
434
+ coordinate: string,
435
+ ): 'action' | 'destination' | 'view' {
436
+ const kind = candidate.kind;
437
+ if (kind !== 'action' && kind !== 'destination' && kind !== 'view') {
438
+ throw new TypeError(`SBottomNav: ${coordinate}.kind must be action, destination, or view`);
439
+ }
440
+ validateItemRecord(candidate, coordinate, kind);
441
+ return kind;
442
+ }
443
+
444
+ function validateItemRecord(
445
+ candidate: Record<string, unknown>,
446
+ coordinate: string,
447
+ kind: keyof typeof ITEM_KEYS,
448
+ ): void {
449
+ for (const key of Reflect.ownKeys(candidate)) {
450
+ if (typeof key !== 'string' || !ITEM_KEYS[kind].has(key)) {
451
+ throw new TypeError(`SBottomNav: ${coordinate} has unsupported field ${String(key)}`);
452
+ }
453
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
454
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
455
+ throw new TypeError(`SBottomNav: ${coordinate}.${key} must be an enumerable data property`);
456
+ }
457
+ }
458
+ if (typeof candidate.id !== 'string' || !candidate.id.trim()) {
459
+ throw new TypeError(`SBottomNav: ${coordinate}.id must be a non-empty string`);
460
+ }
461
+ if (typeof candidate.label !== 'string' || !candidate.label.trim()) {
462
+ throw new TypeError(`SBottomNav: ${coordinate}.label must be a non-empty string`);
463
+ }
464
+ if (typeof candidate.icon !== 'object' && typeof candidate.icon !== 'function') {
465
+ throw new TypeError(`SBottomNav: ${coordinate}.icon must be a Vue component`);
466
+ }
467
+ if (candidate.disabled !== undefined && typeof candidate.disabled !== 'boolean') {
468
+ throw new TypeError(`SBottomNav: ${coordinate}.disabled must be a boolean`);
469
+ }
470
+ if (candidate.badge !== undefined && (
471
+ typeof candidate.badge !== 'number'
472
+ || !Number.isSafeInteger(candidate.badge)
473
+ || candidate.badge < 0
474
+ )) {
475
+ throw new TypeError(`SBottomNav: ${coordinate}.badge must be a non-negative safe integer`);
476
+ }
477
+ if (kind === 'destination') {
478
+ resolveLinkTarget({
479
+ owner: `SBottomNav: ${coordinate}`,
480
+ href: candidate.href,
481
+ to: candidate.to,
482
+ target: candidate.target,
483
+ rel: candidate.rel,
484
+ });
485
+ }
486
+ }
487
+
488
+ function validateItemsContract(value: unknown): void {
489
+ if (!Array.isArray(value) || value.length === 0) {
490
+ throw new TypeError('SBottomNav: items must be a non-empty array');
491
+ }
492
+ const ids = new Set<string>();
493
+ for (const [index, candidate] of value.entries()) {
494
+ const coordinate = `items[${index}]`;
495
+ assertItemRecord(candidate, coordinate);
496
+ const kind = candidate.kind;
497
+ if (kind !== 'action' && kind !== 'destination' && kind !== 'view' && kind !== 'overflow') {
498
+ throw new TypeError(`SBottomNav: ${coordinate}.kind must be action, destination, view, or overflow`);
499
+ }
500
+ validateItemRecord(candidate, coordinate, kind);
501
+ const id = candidate.id;
502
+ if (typeof id !== 'string') throw new TypeError(`SBottomNav: ${coordinate}.id must be a string`);
503
+ if (ids.has(id)) throw new TypeError(`SBottomNav: duplicate item id '${id}'`);
504
+ ids.add(id);
505
+ if (kind !== 'overflow') continue;
506
+ if (typeof candidate.menuLabel !== 'string' || !candidate.menuLabel.trim()) {
507
+ throw new TypeError(`SBottomNav: ${coordinate}.menuLabel must be a non-empty string`);
508
+ }
509
+ if (!Array.isArray(candidate.items)) {
510
+ throw new TypeError(`SBottomNav: ${coordinate}.items must be an array`);
511
+ }
512
+ for (const [childIndex, child] of candidate.items.entries()) {
513
+ const childCoordinate = `${coordinate}.items[${childIndex}]`;
514
+ assertItemRecord(child, childCoordinate);
515
+ validateLeafRecord(child, childCoordinate);
516
+ const childId = child.id;
517
+ if (typeof childId !== 'string') throw new TypeError(`SBottomNav: ${childCoordinate}.id must be a string`);
518
+ if (ids.has(childId)) throw new TypeError(`SBottomNav: duplicate item id '${childId}'`);
519
+ ids.add(childId);
520
+ }
521
+ }
522
+ }
523
+
524
+ const publishLayout = () => {
525
+ const el = navRef.value;
526
+ if (!el) return;
527
+ const nextLayout: BottomNavLayoutState = {
528
+ hidden: isHidden.value,
529
+ height: Math.max(0, el.offsetHeight),
530
+ };
531
+ if (
532
+ lastPublishedLayout
533
+ && lastPublishedLayout.hidden === nextLayout.hidden
534
+ && lastPublishedLayout.height === nextLayout.height
535
+ ) return;
536
+ lastPublishedLayout = nextLayout;
537
+ emit('layout-change', nextLayout);
538
+ };
539
+
540
+ onMounted(() => {
541
+ isHidden.value = props.hidden ?? false;
542
+ if (typeof document !== 'undefined') {
543
+ document.addEventListener('scroll', handleScroll as EventListener, { passive: true, capture: true });
544
+ }
545
+ if (typeof ResizeObserver !== 'undefined' && navRef.value) {
546
+ resizeObserver = new ResizeObserver(publishLayout);
547
+ resizeObserver.observe(navRef.value);
548
+ } else if (typeof window !== 'undefined') {
549
+ usesWindowResizeFallback = true;
550
+ window.addEventListener('resize', publishLayout);
551
+ }
552
+ publishLayout();
553
+ });
554
+
555
+ watch(isHidden, (val) => {
556
+ if (val) closeOverflow(false);
557
+ publishLayout();
558
+ });
559
+
560
+ watch(
561
+ () => props.hidden,
562
+ (hidden) => {
563
+ isHidden.value = hidden;
564
+ },
565
+ );
566
+
567
+ watch(
568
+ () => props.modelValue,
569
+ () => {
570
+ closeOverflow(false);
571
+ // При смене главного экрана всегда показываем панель снова
572
+ isHidden.value = props.hidden ?? false;
573
+ // Сбрасываем запомненную позицию скролла для нового экрана
574
+ lastScrollTop = 0;
575
+ publishLayout();
576
+ },
577
+ );
578
+
579
+ watch(
580
+ () => props.items,
581
+ () => {
582
+ validateItemsContract(props.items);
583
+ if (activeOverflowId.value) {
584
+ const activeItem = overflowItems.value.find((item) => item.id === activeOverflowId.value);
585
+ if (!activeItem || isItemDisabled(activeItem)) {
586
+ closeOverflow(false);
587
+ }
588
+ }
589
+ },
590
+ { immediate: true },
591
+ );
592
+
593
+ watch(
594
+ () => [props.label, props.modelValue, props.scrollTargetId] as const,
595
+ ([label, modelValue, scrollTargetId]) => {
596
+ if (typeof label !== 'string' || !label.trim()) throw new TypeError('SBottomNav: label must be non-empty');
597
+ if (typeof modelValue !== 'string' || !modelValue.trim()) {
598
+ throw new TypeError('SBottomNav: modelValue must be a non-empty destination id');
599
+ }
600
+ if (scrollTargetId !== undefined && (typeof scrollTargetId !== 'string' || !scrollTargetId.trim())) {
601
+ throw new TypeError('SBottomNav: scrollTargetId must be a non-empty string when provided');
602
+ }
603
+ },
604
+ { immediate: true },
605
+ );
606
+
607
+ onBeforeUnmount(() => {
608
+ if (typeof document !== 'undefined') {
609
+ document.removeEventListener('scroll', handleScroll as EventListener, { capture: true } as EventListenerOptions);
610
+ }
611
+ resizeObserver?.disconnect();
612
+ resizeObserver = null;
613
+ if (usesWindowResizeFallback && typeof window !== 'undefined') {
614
+ window.removeEventListener('resize', publishLayout);
615
+ }
616
+ usesWindowResizeFallback = false;
617
+ overflowLayerRegistration?.unregister({ restoreFocus: false });
618
+ overflowLayerRegistration = null;
619
+ overflowTriggerApis.clear();
620
+ overflowIdentityTokens.clear();
621
+ });
622
+ </script>
623
+
624
+ <style scoped>
625
+ .s-bottom-nav {
626
+ position: fixed;
627
+ bottom: 0;
628
+ left: 0;
629
+ right: 0;
630
+ display: flex;
631
+ flex-direction: column;
632
+ background: var(--color-surface);
633
+ border-top: 1px solid var(--color-border-subtle);
634
+ padding: 6px 6px calc(8px + var(--safe-area-bottom, 0px)) 6px;
635
+ gap: 6px;
636
+ transition: transform var(--s-motion-standard) ease-in-out;
637
+ z-index: var(--s-layer-navigation);
638
+ }
639
+ .s-bottom-nav--hidden {
640
+ transform: translateY(100%);
641
+ }
642
+ .s-bottom-nav__items {
643
+ display: flex;
644
+ align-items: center;
645
+ justify-content: space-between;
646
+ gap: 6px;
647
+ }
648
+ .s-bottom-nav__status {
649
+ display: flex;
650
+ flex-wrap: wrap;
651
+ align-items: center;
652
+ justify-content: space-between;
653
+ row-gap: 4px;
654
+ column-gap: 8px;
655
+ padding: 6px 4px 0;
656
+ border-top: 1px solid var(--color-border-subtle);
657
+ font-size: 11px;
658
+ line-height: 1.2;
659
+ color: var(--color-text-muted);
660
+ }
661
+ .s-bottom-nav__item {
662
+ position: relative;
663
+ min-width: 0;
664
+ flex: 1 1 0;
665
+ }
666
+ .s-bottom-nav__badge {
667
+ position: absolute;
668
+ top: -2px;
669
+ right: 2px;
670
+ }
671
+ .s-bottom-nav__overflow-layer {
672
+ position: absolute;
673
+ bottom: 100%;
674
+ left: 0;
675
+ right: 0;
676
+ padding: 8px 10px;
677
+ overflow-x: auto;
678
+ z-index: var(--s-layer-navigation-submenu);
679
+ }
680
+ .s-bottom-nav__overflow-layer::-webkit-scrollbar {
681
+ height: 4px;
682
+ }
683
+ .s-bottom-nav__overflow-layer::-webkit-scrollbar-thumb {
684
+ background: var(--color-border);
685
+ border-radius: var(--s-radius-pill);
686
+ }
687
+ @supports (padding: max(0px)) {
688
+ .s-bottom-nav {
689
+ padding-bottom: calc(8px + max(env(safe-area-inset-bottom, 0px), var(--safe-area-bottom, 0px)));
690
+ }
691
+ }
692
+ </style>