@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,26 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-breadcrumbs {
4
+ @apply min-w-0 overflow-x-auto border-y border-surface-200 py-2 dark:border-surface-700;
5
+ scrollbar-width: thin;
6
+ }
7
+
8
+ .s-breadcrumbs__list {
9
+ @apply flex min-w-max list-none items-center gap-1 p-0 m-0;
10
+ }
11
+
12
+ .s-breadcrumbs__item {
13
+ @apply flex items-center gap-1;
14
+ }
15
+
16
+ .s-breadcrumbs__current {
17
+ @apply block shrink-0 rounded px-2 py-1 text-xs font-medium;
18
+ }
19
+
20
+ .s-breadcrumbs__current {
21
+ @apply text-surface-900 dark:text-surface-50;
22
+ }
23
+
24
+ .s-breadcrumbs__divider {
25
+ @apply h-3 w-3 shrink-0 text-surface-400;
26
+ }
@@ -0,0 +1,155 @@
1
+ <script setup lang="ts">
2
+ import { watch } from 'vue'
3
+
4
+ import { resolveLinkTarget } from '../../../internal/linkTarget'
5
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
6
+ import { ChevronRight } from '../../icons/sputnigUiIcons'
7
+ import SButton from '../controls/SButton.vue'
8
+ import SLink from '../controls/SLink.vue'
9
+ import type { LinkDestination } from '../controls/SLink'
10
+
11
+ defineOptions({ inheritAttrs: false })
12
+
13
+ interface BreadcrumbItemBase {
14
+ readonly id: string
15
+ readonly label: string
16
+ }
17
+
18
+ /** Обычное navigation destination с точным href/to XOR. / Ordinary navigation destination with exact href/to XOR. */
19
+ export type BreadcrumbDestinationItem = BreadcrumbItemBase & LinkDestination & {
20
+ readonly kind: 'destination'
21
+ readonly disabled?: boolean
22
+ }
23
+
24
+ /** Команда в цепочке, не имитирующая ссылку. / A breadcrumb command that does not impersonate a link. */
25
+ export interface BreadcrumbActionItem extends BreadcrumbItemBase {
26
+ readonly kind: 'action'
27
+ readonly disabled?: boolean
28
+ }
29
+
30
+ /** Текущая пассивная страница. / The current passive page. */
31
+ export interface BreadcrumbCurrentItem extends BreadcrumbItemBase {
32
+ readonly kind: 'current'
33
+ }
34
+
35
+ /** Точный semantic contract сегмента. / Exact semantic breadcrumb-item contract. */
36
+ export type BreadcrumbItem = BreadcrumbDestinationItem | BreadcrumbActionItem | BreadcrumbCurrentItem
37
+
38
+ const props = withDefaults(defineProps<{
39
+ items: readonly BreadcrumbItem[]
40
+ ariaLabel?: string
41
+ }>(), {
42
+ ariaLabel: 'Навигационная цепочка',
43
+ })
44
+
45
+ const emit = defineEmits<{
46
+ action: [id: string, event: MouseEvent]
47
+ navigate: [item: BreadcrumbDestinationItem, event: MouseEvent]
48
+ }>()
49
+
50
+ const ownedAttrs = useOwnedAttrs({ component: 'SBreadcrumbs', owner: 'breadcrumb navigation root' })
51
+ const EXACT_KEYS = {
52
+ action: new Set(['kind', 'id', 'label', 'disabled']),
53
+ current: new Set(['kind', 'id', 'label']),
54
+ destination: new Set(['kind', 'id', 'label', 'disabled', 'href', 'to', 'target', 'rel']),
55
+ } as const
56
+
57
+ function assertRecord(value: unknown, coordinate: string): asserts value is Record<string, unknown> {
58
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) return
59
+ throw new TypeError(`SBreadcrumbs: ${coordinate} must be an object`)
60
+ }
61
+
62
+ function validateContract(): void {
63
+ if (typeof props.ariaLabel !== 'string' || !props.ariaLabel.trim()) {
64
+ throw new TypeError('SBreadcrumbs: ariaLabel must be a non-empty string')
65
+ }
66
+ if (!Array.isArray(props.items) || props.items.length === 0) {
67
+ throw new TypeError('SBreadcrumbs: items must be a non-empty array')
68
+ }
69
+ const ids = new Set<string>()
70
+ let currentIndex = -1
71
+ for (const [index, candidate] of props.items.entries()) {
72
+ const coordinate = `items[${index}]`
73
+ assertRecord(candidate, coordinate)
74
+ const kind = candidate.kind
75
+ if (kind !== 'action' && kind !== 'current' && kind !== 'destination') {
76
+ throw new TypeError(`SBreadcrumbs: ${coordinate}.kind must be action, current, or destination`)
77
+ }
78
+ for (const key of Reflect.ownKeys(candidate)) {
79
+ if (typeof key !== 'string' || !EXACT_KEYS[kind].has(key)) {
80
+ throw new TypeError(`SBreadcrumbs: ${coordinate} has unsupported field '${String(key)}'`)
81
+ }
82
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key)
83
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
84
+ throw new TypeError(`SBreadcrumbs: ${coordinate}.${key} must be an enumerable data property`)
85
+ }
86
+ }
87
+ if (typeof candidate.id !== 'string' || !candidate.id.trim()) {
88
+ throw new TypeError(`SBreadcrumbs: ${coordinate}.id must be a non-empty string`)
89
+ }
90
+ if (ids.has(candidate.id)) throw new TypeError(`SBreadcrumbs: duplicate item id '${candidate.id}'`)
91
+ ids.add(candidate.id)
92
+ if (typeof candidate.label !== 'string' || !candidate.label.trim()) {
93
+ throw new TypeError(`SBreadcrumbs: ${coordinate}.label must be a non-empty string`)
94
+ }
95
+ if (candidate.disabled !== undefined && typeof candidate.disabled !== 'boolean') {
96
+ throw new TypeError(`SBreadcrumbs: ${coordinate}.disabled must be a boolean`)
97
+ }
98
+ if (kind === 'destination') {
99
+ resolveLinkTarget({
100
+ owner: `SBreadcrumbs: ${coordinate}`,
101
+ href: candidate.href,
102
+ to: candidate.to,
103
+ target: candidate.target,
104
+ rel: candidate.rel,
105
+ })
106
+ }
107
+ if (kind === 'current') {
108
+ if (currentIndex >= 0) throw new TypeError('SBreadcrumbs: items must contain exactly one current item')
109
+ currentIndex = index
110
+ }
111
+ }
112
+ if (currentIndex !== props.items.length - 1) {
113
+ throw new TypeError('SBreadcrumbs: the sole current item must be last')
114
+ }
115
+ }
116
+
117
+ watch(() => [props.items, props.ariaLabel] as const, validateContract, { immediate: true, deep: true })
118
+ </script>
119
+
120
+ <template>
121
+ <nav v-bind="ownedAttrs.bindings()" class="s-breadcrumbs" :aria-label="ariaLabel">
122
+ <ol class="s-breadcrumbs__list">
123
+ <li v-for="(item, index) in items" :key="item.id" class="s-breadcrumbs__item">
124
+ <ChevronRight v-if="index > 0" class="s-breadcrumbs__divider" aria-hidden="true" />
125
+ <span v-if="item.kind === 'current'" class="s-breadcrumbs__current" aria-current="page">
126
+ {{ item.label }}
127
+ </span>
128
+ <SLink
129
+ v-else-if="item.kind === 'destination'"
130
+ :href="item.href"
131
+ :to="item.to"
132
+ :target="item.target"
133
+ :rel="item.rel"
134
+ variant="subtle"
135
+ size="sm"
136
+ :disabled="item.disabled"
137
+ @click="emit('navigate', item, $event)"
138
+ >
139
+ {{ item.label }}
140
+ </SLink>
141
+ <SButton
142
+ v-else
143
+ :label="item.label"
144
+ :disabled="item.disabled"
145
+ size="xs"
146
+ severity="secondary"
147
+ appearance="ghost"
148
+ @click="emit('action', item.id, $event)"
149
+ />
150
+ </li>
151
+ </ol>
152
+ </nav>
153
+ </template>
154
+
155
+ <style lang="postcss" src="./SBreadcrumbs.css" scoped></style>
@@ -0,0 +1,100 @@
1
+ .s-catalog-navigator {
2
+ container-type: inline-size;
3
+ display: flex;
4
+ min-height: 0;
5
+ flex: 1 1 auto;
6
+ flex-direction: column;
7
+ gap: 0.625rem;
8
+ }
9
+
10
+ .s-catalog-navigator__toolbar {
11
+ display: grid;
12
+ grid-template-columns: repeat(2, minmax(0, 1fr));
13
+ gap: 0.5rem;
14
+ align-items: end;
15
+ }
16
+
17
+ .s-catalog-navigator__toolbar > :first-child {
18
+ grid-column: 1 / -1;
19
+ }
20
+
21
+ .s-catalog-navigator__summary {
22
+ color: var(--color-text-muted);
23
+ font-size: 0.75rem;
24
+ line-height: 1rem;
25
+ }
26
+
27
+ .s-catalog-navigator__viewport {
28
+ min-height: 10rem;
29
+ flex: 1 1 auto;
30
+ overflow-y: auto;
31
+ overscroll-behavior: contain;
32
+ border-block: 1px solid var(--color-border-subtle);
33
+ }
34
+
35
+ .s-catalog-navigator__row {
36
+ display: flex;
37
+ width: 100%;
38
+ min-width: 0;
39
+ align-items: center;
40
+ gap: 0.5rem;
41
+ border-bottom: 1px solid var(--color-border-subtle);
42
+ }
43
+
44
+ .s-catalog-navigator__row:hover {
45
+ background: var(--color-surface-muted);
46
+ }
47
+
48
+ .s-catalog-navigator__row--selected {
49
+ background: color-mix(in srgb, var(--color-primary) 12%, transparent);
50
+ box-shadow: inset 3px 0 0 var(--color-primary);
51
+ }
52
+
53
+ .s-catalog-navigator__row--selected .s-catalog-navigator__subtitle {
54
+ color: var(--color-text);
55
+ }
56
+
57
+ .s-catalog-navigator__row-main {
58
+ display: grid;
59
+ min-width: 0;
60
+ flex: 1 1 auto;
61
+ gap: 0.125rem;
62
+ }
63
+
64
+ .s-catalog-navigator__subtitle {
65
+ overflow: hidden;
66
+ text-overflow: ellipsis;
67
+ white-space: nowrap;
68
+ padding-inline: 0.75rem;
69
+ color: var(--color-text-muted);
70
+ font-size: 0.75rem;
71
+ }
72
+
73
+ .s-catalog-navigator__badges {
74
+ display: flex;
75
+ flex: 0 0 auto;
76
+ flex-wrap: wrap;
77
+ justify-content: flex-end;
78
+ gap: 0.25rem;
79
+ }
80
+
81
+ .s-catalog-navigator__empty {
82
+ padding: 1.5rem 0.75rem;
83
+ color: var(--color-text-muted);
84
+ font-size: 0.875rem;
85
+ text-align: center;
86
+ }
87
+
88
+ @container (max-width: 26rem) {
89
+ .s-catalog-navigator__toolbar {
90
+ grid-template-columns: 1fr;
91
+ }
92
+
93
+ .s-catalog-navigator__toolbar > :first-child {
94
+ grid-column: auto;
95
+ }
96
+
97
+ .s-catalog-navigator__badges {
98
+ justify-content: flex-start;
99
+ }
100
+ }
@@ -0,0 +1,308 @@
1
+ <template>
2
+ <section v-bind="ownedAttrs.bindings()" class="s-catalog-navigator" :aria-label="ariaLabel">
3
+ <div class="s-catalog-navigator__toolbar">
4
+ <SInputText
5
+ v-model="query"
6
+ size="sm"
7
+ :placeholder="searchPlaceholder"
8
+ :aria-label="searchPlaceholder"
9
+ />
10
+ <SSelect
11
+ v-model="selectedGroup"
12
+ size="sm"
13
+ :options="groupOptions"
14
+ :label="groupFilterLabel"
15
+ />
16
+ <SSelect
17
+ v-model="sortMode"
18
+ size="sm"
19
+ :options="sortOptions"
20
+ label="Сортировка"
21
+ />
22
+ </div>
23
+
24
+ <div class="s-catalog-navigator__summary" aria-live="polite">
25
+ Найдено: {{ filteredItemCount }} из {{ items.length }}
26
+ </div>
27
+
28
+ <div class="s-catalog-navigator__viewport" role="region" :aria-label="`${ariaLabel}: элементы`">
29
+ <div v-if="groupedItems.length === 0" class="s-catalog-navigator__empty">
30
+ {{ emptyText }}
31
+ </div>
32
+ <template v-else>
33
+ <SCatalogNavigatorGroup
34
+ v-for="group in groupedItems"
35
+ :key="group.key"
36
+ :id="groupDisclosureId(group.key)"
37
+ :label="group.label"
38
+ :count="group.items.length"
39
+ >
40
+ <div
41
+ v-for="item in group.items"
42
+ :key="item.id"
43
+ class="s-catalog-navigator__row"
44
+ role="listitem"
45
+ :class="{ 's-catalog-navigator__row--selected': item.id === validatedSelectedId }"
46
+ :aria-current="item.id === validatedSelectedId ? 'true' : undefined"
47
+ >
48
+ <div class="s-catalog-navigator__row-main">
49
+ <SButton
50
+ :label="item.title"
51
+ size="sm"
52
+ severity="secondary"
53
+ appearance="ghost"
54
+ shape="row"
55
+ width="full"
56
+ content-align="start"
57
+ :disabled="item.disabled"
58
+ :aria-current="item.id === validatedSelectedId || undefined"
59
+ @click="selectItem(item)"
60
+ />
61
+ <span v-if="item.subtitle" class="s-catalog-navigator__subtitle">{{ item.subtitle }}</span>
62
+ </div>
63
+ <span v-if="item.badges?.length" class="s-catalog-navigator__badges">
64
+ <SBadge v-for="badge in item.badges" :key="badge" severity="secondary" size="xs">
65
+ {{ badge }}
66
+ </SBadge>
67
+ </span>
68
+ <slot name="item-actions" :item="item" />
69
+ </div>
70
+ </SCatalogNavigatorGroup>
71
+ </template>
72
+ </div>
73
+ </section>
74
+ </template>
75
+
76
+ <script setup lang="ts">
77
+ import { computed, ref, useId, watch } from 'vue';
78
+ import SInputText from '../controls/SInputText.vue';
79
+ import SSelect from '../controls/SSelect.vue';
80
+ import SButton from '../controls/SButton.vue';
81
+ import SBadge from '../data-display/SBadge.vue';
82
+ import SCatalogNavigatorGroup from './_internal/SCatalogNavigatorGroup.vue';
83
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs';
84
+
85
+ defineOptions({ inheritAttrs: false });
86
+
87
+ export interface CatalogNavigatorItem {
88
+ id: string;
89
+ title: string;
90
+ subtitle?: string;
91
+ group: string;
92
+ groupLabel: string;
93
+ keywords?: string[];
94
+ badges?: string[];
95
+ disabled?: boolean;
96
+ }
97
+
98
+ const props = withDefaults(defineProps<{
99
+ items: CatalogNavigatorItem[];
100
+ selectedId?: string | null;
101
+ ariaLabel?: string;
102
+ searchPlaceholder?: string;
103
+ groupFilterLabel?: string;
104
+ emptyText?: string;
105
+ }>(), {
106
+ selectedId: null,
107
+ ariaLabel: 'Каталог',
108
+ searchPlaceholder: 'Поиск',
109
+ groupFilterLabel: 'Группа',
110
+ emptyText: 'Ничего не найдено.',
111
+ });
112
+
113
+ const emit = defineEmits<{
114
+ (event: 'select', id: string): void;
115
+ }>();
116
+ const ownedAttrs = useOwnedAttrs({ component: 'SCatalogNavigator', owner: 'catalog navigation section' });
117
+
118
+ const query = ref('');
119
+ const ALL_GROUP_FILTER = 'all';
120
+ const selectedGroup = ref(ALL_GROUP_FILTER);
121
+ const sortMode = ref('title-asc');
122
+ const componentId = useId();
123
+ const groupIdentityTokens = new Map<string, number>();
124
+ let nextGroupIdentityToken = 0;
125
+
126
+ interface CatalogGroupInventory {
127
+ key: string;
128
+ label: string;
129
+ filterValue: string;
130
+ disclosureId: string;
131
+ }
132
+
133
+ const ITEM_KEYS = new Set([
134
+ 'id', 'title', 'subtitle', 'group', 'groupLabel', 'keywords', 'badges', 'disabled',
135
+ ]);
136
+
137
+ function validateNonEmptyString(value: unknown, coordinate: string): string {
138
+ if (typeof value !== 'string' || value.trim().length === 0) {
139
+ throw new TypeError(`SCatalogNavigator: ${coordinate} must be a non-empty string`);
140
+ }
141
+ return value;
142
+ }
143
+
144
+ function validateOptionalStringList(value: unknown, coordinate: string): string[] | undefined {
145
+ if (value === undefined) return undefined;
146
+ if (!Array.isArray(value)) {
147
+ throw new TypeError(`SCatalogNavigator: ${coordinate} must be an array of non-empty strings`);
148
+ }
149
+ const result = value.map((item, index) => validateNonEmptyString(item, `${coordinate}[${index}]`));
150
+ if (new Set(result).size !== result.length) {
151
+ throw new TypeError(`SCatalogNavigator: ${coordinate} must not contain duplicate values`);
152
+ }
153
+ return result;
154
+ }
155
+
156
+ function groupIdentityToken(key: string): number {
157
+ const current = groupIdentityTokens.get(key);
158
+ if (current !== undefined) return current;
159
+ const token = nextGroupIdentityToken;
160
+ nextGroupIdentityToken += 1;
161
+ groupIdentityTokens.set(key, token);
162
+ return token;
163
+ }
164
+
165
+ const catalogInventory = computed(() => {
166
+ if (!Array.isArray(props.items)) {
167
+ throw new TypeError('SCatalogNavigator: items must be an array');
168
+ }
169
+ const ids = new Set<string>();
170
+ const groupsByKey = new Map<string, CatalogGroupInventory>();
171
+ const validatedItems = props.items.map((candidate, index): CatalogNavigatorItem => {
172
+ if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
173
+ throw new TypeError(`SCatalogNavigator: items[${index}] must be an object`);
174
+ }
175
+ for (const key of Reflect.ownKeys(candidate)) {
176
+ if (typeof key !== 'string' || !ITEM_KEYS.has(key)) {
177
+ throw new TypeError(`SCatalogNavigator: items[${index}] has unsupported field ${String(key)}`);
178
+ }
179
+ }
180
+ const id = validateNonEmptyString(candidate.id, `items[${index}].id`);
181
+ if (ids.has(id)) {
182
+ throw new TypeError(`SCatalogNavigator: duplicate item id ${JSON.stringify(id)}`);
183
+ }
184
+ ids.add(id);
185
+ validateNonEmptyString(candidate.title, `items[${index}].title`);
186
+ const group = validateNonEmptyString(candidate.group, `items[${index}].group`);
187
+ const groupLabel = validateNonEmptyString(candidate.groupLabel, `items[${index}].groupLabel`);
188
+ const existingGroup = groupsByKey.get(group);
189
+ if (existingGroup && existingGroup.label !== groupLabel) {
190
+ throw new TypeError(
191
+ `SCatalogNavigator: group ${JSON.stringify(group)} has conflicting labels ${JSON.stringify(existingGroup.label)} and ${JSON.stringify(groupLabel)}`,
192
+ );
193
+ }
194
+ if (!existingGroup) {
195
+ const token = groupIdentityToken(group);
196
+ groupsByKey.set(group, {
197
+ key: group,
198
+ label: groupLabel,
199
+ filterValue: `group-${token}`,
200
+ disclosureId: `${componentId}-catalog-group-${token}`,
201
+ });
202
+ }
203
+ if (candidate.disabled !== undefined && typeof candidate.disabled !== 'boolean') {
204
+ throw new TypeError(`SCatalogNavigator: items[${index}].disabled must be a boolean`);
205
+ }
206
+ if (candidate.subtitle !== undefined) {
207
+ validateNonEmptyString(candidate.subtitle, `items[${index}].subtitle`);
208
+ }
209
+ validateOptionalStringList(candidate.keywords, `items[${index}].keywords`);
210
+ validateOptionalStringList(candidate.badges, `items[${index}].badges`);
211
+ return candidate;
212
+ });
213
+ return {
214
+ items: validatedItems,
215
+ ids,
216
+ groups: [...groupsByKey.values()],
217
+ groupsByKey,
218
+ };
219
+ });
220
+
221
+ const validatedSelectedId = computed(() => {
222
+ if (props.selectedId === null) return null;
223
+ const id = validateNonEmptyString(props.selectedId, 'selectedId');
224
+ if (!catalogInventory.value.ids.has(id)) {
225
+ throw new RangeError(`SCatalogNavigator: selectedId ${JSON.stringify(id)} is outside items inventory`);
226
+ }
227
+ return id;
228
+ });
229
+
230
+ function selectItem(item: CatalogNavigatorItem): void {
231
+ if (!item.disabled) emit('select', item.id);
232
+ }
233
+
234
+ function groupDisclosureId(groupKey: string): string {
235
+ const group = catalogInventory.value.groupsByKey.get(groupKey);
236
+ if (!group) {
237
+ throw new RangeError(`SCatalogNavigator: group ${JSON.stringify(groupKey)} is outside inventory`);
238
+ }
239
+ return group.disclosureId;
240
+ }
241
+
242
+ const groupOptions = computed(() => [
243
+ { value: ALL_GROUP_FILTER, label: 'Все группы' },
244
+ ...[...catalogInventory.value.groups]
245
+ .sort((left, right) => left.label.localeCompare(right.label, 'ru'))
246
+ .map((group) => ({ value: group.filterValue, label: group.label })),
247
+ ]);
248
+
249
+ const sortOptions = [
250
+ { value: 'title-asc', label: 'По названию' },
251
+ { value: 'title-desc', label: 'По названию, обратно' },
252
+ { value: 'id-asc', label: 'По ID' },
253
+ ];
254
+
255
+ const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase('ru'));
256
+
257
+ const selectedGroupKey = computed(() => {
258
+ if (selectedGroup.value === ALL_GROUP_FILTER) return null;
259
+ const group = catalogInventory.value.groups.find((candidate) => candidate.filterValue === selectedGroup.value);
260
+ if (!group) {
261
+ throw new RangeError(`SCatalogNavigator: selected group filter ${JSON.stringify(selectedGroup.value)} is outside inventory`);
262
+ }
263
+ return group.key;
264
+ });
265
+
266
+ watch(
267
+ () => catalogInventory.value.groups.map((group) => group.filterValue).join('\n'),
268
+ () => {
269
+ if (
270
+ selectedGroup.value !== ALL_GROUP_FILTER
271
+ && !catalogInventory.value.groups.some((group) => group.filterValue === selectedGroup.value)
272
+ ) {
273
+ selectedGroup.value = ALL_GROUP_FILTER;
274
+ }
275
+ },
276
+ { flush: 'sync' },
277
+ );
278
+
279
+ const visibleItems = computed(() => catalogInventory.value.items
280
+ .filter((item) => selectedGroupKey.value === null || item.group === selectedGroupKey.value)
281
+ .filter((item) => {
282
+ if (!normalizedQuery.value) return true;
283
+ const haystack = [item.title, item.subtitle, item.id, ...(item.keywords ?? [])]
284
+ .filter((value): value is string => typeof value === 'string')
285
+ .join(' ')
286
+ .toLocaleLowerCase('ru');
287
+ return haystack.includes(normalizedQuery.value);
288
+ })
289
+ .sort((left, right) => {
290
+ if (sortMode.value === 'id-asc') return left.id.localeCompare(right.id, 'ru');
291
+ const direction = sortMode.value === 'title-desc' ? -1 : 1;
292
+ return direction * left.title.localeCompare(right.title, 'ru');
293
+ }));
294
+
295
+ const groupedItems = computed(() => {
296
+ const groups = new Map<string, { key: string; label: string; items: CatalogNavigatorItem[] }>();
297
+ visibleItems.value.forEach((item) => {
298
+ const group = groups.get(item.group) ?? { key: item.group, label: item.groupLabel, items: [] };
299
+ group.items.push(item);
300
+ groups.set(item.group, group);
301
+ });
302
+ return Array.from(groups.values()).sort((left, right) => left.label.localeCompare(right.label, 'ru'));
303
+ });
304
+
305
+ const filteredItemCount = computed(() => visibleItems.value.length);
306
+ </script>
307
+
308
+ <style lang="postcss" src="./SCatalogNavigator.css" scoped></style>