@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,333 @@
1
+ <template>
2
+ <div
3
+ ref="surfaceRef"
4
+ v-bind="ownedAttrs.bindings()"
5
+ class="s-menu-surface"
6
+ :data-density="density"
7
+ :data-orientation="orientation"
8
+ :data-menu-depth="depth"
9
+ role="menu"
10
+ :aria-label="label"
11
+ :aria-orientation="orientation"
12
+ @keydown="handleKeydown"
13
+ @focusout="handleFocusout"
14
+ >
15
+ <template v-for="item in items" :key="item.id">
16
+ <SMenuSeparator v-if="item.kind === 'separator'" :entry-id="item.id" />
17
+ <SMenuItem
18
+ v-else
19
+ :entry="item"
20
+ :submenu-open="activeSubmenu?.id === item.id"
21
+ @select="selectItem(item, $event)"
22
+ @open-submenu="openSubmenu(item.id, true)"
23
+ @pointer-intent="handlePointerIntent(item)"
24
+ />
25
+ </template>
26
+ <teleport to="body">
27
+ <SMenuLayer
28
+ v-if="activeSubmenu"
29
+ ref="submenuLayerRef"
30
+ kind="submenu"
31
+ :landmark-label="`${label}: ${activeSubmenu.label}`"
32
+ :position="submenuPosition"
33
+ :z-index="submenuZIndex"
34
+ >
35
+ <SMenuSurface
36
+ ref="submenuSurfaceRef"
37
+ :label="`${label}: ${activeSubmenu.label}`"
38
+ :items="activeSubmenu.children"
39
+ :density="density"
40
+ orientation="vertical"
41
+ :autofocus="submenuAutofocus"
42
+ @escape="closeSubmenu('escape')"
43
+ @submenu-back="closeSubmenu('escape')"
44
+ @dismiss-tree="emit('dismiss-tree')"
45
+ @select="forwardSelection"
46
+ @tab="emit('tab', $event)"
47
+ />
48
+ </SMenuLayer>
49
+ </teleport>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import { computed, inject, nextTick, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue'
55
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
56
+ import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
57
+ import { useMenuLayer } from '../../../internal/useMenuLayer'
58
+ import { menuContextKey, menuDepthKey } from '../_internal/menuContext'
59
+ import type { MenuDensity, MenuItemRegistration } from '../_internal/menuContext'
60
+ import SMenuItem from '../_internal/SMenuItem.vue'
61
+ import SMenuLayer from '../_internal/SMenuLayer.vue'
62
+ import type { SMenuLayerApi } from '../_internal/SMenuLayer.vue'
63
+ import SMenuSeparator from '../_internal/SMenuSeparator.vue'
64
+ import {
65
+ validateSMenuEntries,
66
+ type SMenuActivationEvent,
67
+ type SMenuEntry,
68
+ type SMenuInteractiveEntry,
69
+ type SMenuSelectableEntry,
70
+ type SMenuSubmenu,
71
+ } from './menu'
72
+
73
+ export type SMenuDensity = MenuDensity
74
+
75
+ defineOptions({ inheritAttrs: false })
76
+ export interface Props {
77
+ label: string
78
+ items: readonly SMenuEntry[]
79
+ density?: SMenuDensity
80
+ orientation?: 'vertical' | 'horizontal'
81
+ autofocus?: boolean
82
+ initialFocus?: 'first' | 'last'
83
+ }
84
+
85
+ /** Типизированная focus-поверхность для menu owners без DOM queries. / Typed focus surface for menu owners without DOM queries. */
86
+ export interface SMenuSurfaceApi {
87
+ focusFirst: () => void
88
+ focusLast: () => void
89
+ }
90
+
91
+ const props = withDefaults(defineProps<Props>(), {
92
+ density: 'comfortable',
93
+ orientation: 'vertical',
94
+ autofocus: false,
95
+ initialFocus: 'first',
96
+ })
97
+ const emit = defineEmits<{
98
+ escape: []
99
+ 'focus-exit': [event: FocusEvent]
100
+ select: [item: SMenuSelectableEntry, event: SMenuActivationEvent]
101
+ tab: [event: KeyboardEvent]
102
+ 'submenu-back': []
103
+ 'dismiss-tree': []
104
+ }>()
105
+ const ownedAttrs = useOwnedAttrs({ component: 'SMenuSurface', owner: 'menu root' })
106
+ useInteractiveLeafRegistration({ owner: 'SMenuSurface' })
107
+
108
+ const parentDepth = inject(menuDepthKey, -1)
109
+ const depth = parentDepth + 1
110
+ provide(menuDepthKey, depth)
111
+ const surfaceRef = ref<HTMLElement | null>(null)
112
+ const registrations = new Map<string, MenuItemRegistration>()
113
+ provide(menuContextKey, {
114
+ density: toRef(props, 'density'),
115
+ register: (registration) => {
116
+ if (registrations.has(registration.entryId)) {
117
+ throw new Error(`SMenuSurface: duplicate mounted entry id '${registration.entryId}'`)
118
+ }
119
+ registrations.set(registration.entryId, registration)
120
+ return () => registrations.delete(registration.entryId)
121
+ },
122
+ })
123
+
124
+ const activeSubmenuId = ref<string | null>(null)
125
+ const submenuAutofocus = ref(true)
126
+ const submenuLayerRef = ref<SMenuLayerApi | null>(null)
127
+ const submenuSurfaceRef = ref<SMenuSurfaceApi | null>(null)
128
+ const submenuLayerElement = computed(() => submenuLayerRef.value?.getElement() ?? null)
129
+ const {
130
+ position: submenuPosition,
131
+ zIndex: submenuZIndex,
132
+ open: openSubmenuLayer,
133
+ close: closeSubmenuLayer,
134
+ } = useMenuLayer(submenuLayerElement, {
135
+ onDismiss: (reason) => {
136
+ activeSubmenuId.value = null
137
+ if (reason === 'outside') emit('dismiss-tree')
138
+ },
139
+ })
140
+ const activeSubmenu = computed<SMenuSubmenu | null>(() => {
141
+ const item = props.items.find((entry) => entry.id === activeSubmenuId.value)
142
+ return item?.kind === 'submenu' ? item : null
143
+ })
144
+
145
+ let typeaheadBuffer = ''
146
+ let typeaheadTimer: ReturnType<typeof setTimeout> | null = null
147
+
148
+ const orderedRegistrations = (): MenuItemRegistration[] => [...registrations.values()]
149
+ .filter((item) => item.element().isConnected)
150
+ .sort((left, right) => {
151
+ const relation = left.element().compareDocumentPosition(right.element())
152
+ if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
153
+ if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
154
+ return 0
155
+ })
156
+
157
+ const focusItem = (items: MenuItemRegistration[], index: number): void => {
158
+ for (const item of items) item.setTabStop(false)
159
+ const item = items[index]
160
+ if (!item) return
161
+ item.setTabStop(true)
162
+ item.focus()
163
+ }
164
+
165
+ const focusFirst = (): void => {
166
+ const items = orderedRegistrations()
167
+ if (items.length > 0) focusItem(items, 0)
168
+ }
169
+ const focusLast = (): void => {
170
+ const items = orderedRegistrations()
171
+ if (items.length > 0) focusItem(items, items.length - 1)
172
+ }
173
+
174
+ const validateContract = (): void => {
175
+ if (typeof props.label !== 'string' || !props.label.trim()) {
176
+ throw new TypeError('SMenuSurface: label must be a non-empty string')
177
+ }
178
+ validateSMenuEntries(props.items)
179
+ }
180
+
181
+ const moveFocus = (direction: 1 | -1): void => {
182
+ const items = orderedRegistrations()
183
+ if (items.length === 0) return
184
+ const currentIndex = items.findIndex((item) => item.isFocused())
185
+ const origin = currentIndex < 0 ? (direction === 1 ? -1 : 0) : currentIndex
186
+ focusItem(items, (origin + direction + items.length) % items.length)
187
+ }
188
+
189
+ const focusByTypeahead = (key: string): void => {
190
+ const normalizedKey = key.toLocaleLowerCase()
191
+ typeaheadBuffer += normalizedKey
192
+ if (typeaheadTimer) clearTimeout(typeaheadTimer)
193
+ typeaheadTimer = setTimeout(() => {
194
+ typeaheadBuffer = ''
195
+ typeaheadTimer = null
196
+ }, 500)
197
+
198
+ const search = [...typeaheadBuffer].every((character) => character === normalizedKey)
199
+ ? normalizedKey
200
+ : typeaheadBuffer
201
+ const items = orderedRegistrations()
202
+ if (items.length === 0) return
203
+ const currentIndex = items.findIndex((item) => item.isFocused())
204
+ const ordered = [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex + 1)]
205
+ const match = ordered.find((item) => item.label().trim().toLocaleLowerCase().startsWith(search))
206
+ if (match) focusItem(items, items.indexOf(match))
207
+ }
208
+
209
+ async function openSubmenu(entryId: string, autofocus: boolean): Promise<void> {
210
+ const entry = props.items.find((candidate) => candidate.id === entryId)
211
+ const registration = registrations.get(entryId)
212
+ if (entry?.kind !== 'submenu' || entry.disabled || !registration) return
213
+ if (activeSubmenuId.value === entryId) {
214
+ if (autofocus) await nextTick(() => submenuSurfaceRef.value?.focusFirst())
215
+ return
216
+ }
217
+ if (activeSubmenuId.value) closeSubmenuLayer('outside')
218
+ submenuAutofocus.value = autofocus
219
+ activeSubmenuId.value = entryId
220
+ await openSubmenuLayer(
221
+ { type: 'element', element: registration.element(), align: 'left', placement: 'inline' },
222
+ registration.element(),
223
+ )
224
+ if (autofocus) await nextTick(() => submenuSurfaceRef.value?.focusFirst())
225
+ }
226
+
227
+ function closeSubmenu(reason: 'escape' | 'outside'): void {
228
+ if (!activeSubmenuId.value) return
229
+ closeSubmenuLayer(reason)
230
+ activeSubmenuId.value = null
231
+ }
232
+
233
+ function handlePointerIntent(item: SMenuInteractiveEntry): void {
234
+ if (item.kind === 'submenu' && !item.disabled) {
235
+ void openSubmenu(item.id, false)
236
+ } else if (activeSubmenuId.value) closeSubmenu('outside')
237
+ }
238
+
239
+ function selectItem(item: SMenuInteractiveEntry, event: SMenuActivationEvent): void {
240
+ if (item.kind === 'submenu' || item.disabled) return
241
+ emit('select', item, event)
242
+ }
243
+
244
+ function forwardSelection(item: SMenuSelectableEntry, event: SMenuActivationEvent): void {
245
+ emit('select', item, event)
246
+ }
247
+
248
+ function focusedRegistration(): MenuItemRegistration | undefined {
249
+ return orderedRegistrations().find((item) => item.isFocused())
250
+ }
251
+
252
+ const handleKeydown = (event: KeyboardEvent): void => {
253
+ const eventTarget = event.target
254
+ if (
255
+ event.currentTarget !== eventTarget
256
+ && !orderedRegistrations().some((registration) => registration.ownsEventTarget(eventTarget))
257
+ ) return
258
+ if (event.key === 'Tab') {
259
+ emit('tab', event)
260
+ return
261
+ }
262
+ if (event.key === 'Escape') {
263
+ event.preventDefault()
264
+ emit('escape')
265
+ return
266
+ }
267
+ const nextKey = props.orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
268
+ const previousKey = props.orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
269
+ if (props.orientation === 'vertical' && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
270
+ const current = focusedRegistration()
271
+ event.preventDefault()
272
+ if (event.key === 'ArrowRight' && current?.hasSubmenu()) void openSubmenu(current.entryId, true)
273
+ else if (event.key === 'ArrowLeft' && depth > 0) emit('submenu-back')
274
+ return
275
+ }
276
+ if (event.key === nextKey || event.key === previousKey) {
277
+ event.preventDefault()
278
+ moveFocus(event.key === nextKey ? 1 : -1)
279
+ return
280
+ }
281
+ if (props.orientation === 'horizontal' && event.key === 'ArrowDown') {
282
+ const current = focusedRegistration()
283
+ if (current?.hasSubmenu()) {
284
+ event.preventDefault()
285
+ void openSubmenu(current.entryId, true)
286
+ }
287
+ return
288
+ }
289
+ if (event.key === 'Home' || event.key === 'End') {
290
+ event.preventDefault()
291
+ if (event.key === 'Home') focusFirst()
292
+ else focusLast()
293
+ return
294
+ }
295
+ if (event.key === 'Enter' || event.key === ' ') {
296
+ const current = focusedRegistration()
297
+ if (current) {
298
+ current.activate(event)
299
+ }
300
+ return
301
+ }
302
+ if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
303
+ focusByTypeahead(event.key)
304
+ }
305
+ }
306
+
307
+ const handleFocusout = (event: FocusEvent): void => {
308
+ const nextTarget = event.relatedTarget
309
+ if (nextTarget instanceof Node && (
310
+ surfaceRef.value?.contains(nextTarget) || submenuLayerElement.value?.contains(nextTarget)
311
+ )) return
312
+ emit('focus-exit', event)
313
+ }
314
+
315
+ onMounted(() => {
316
+ if (props.autofocus) nextTick(props.initialFocus === 'last' ? focusLast : focusFirst)
317
+ })
318
+ watch(
319
+ () => [props.label, props.items] as const,
320
+ () => {
321
+ validateContract()
322
+ if (activeSubmenuId.value && !activeSubmenu.value) closeSubmenu('outside')
323
+ },
324
+ { immediate: true, deep: true },
325
+ )
326
+ onBeforeUnmount(() => {
327
+ if (typeaheadTimer) clearTimeout(typeaheadTimer)
328
+ })
329
+
330
+ defineExpose<SMenuSurfaceApi>({ focusFirst, focusLast })
331
+ </script>
332
+
333
+ <style lang="postcss" src="./SMenuSurface.css" scoped></style>
@@ -0,0 +1,38 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-segmented-control__group {
4
+ @apply inline-grid min-w-0 grid-flow-col auto-cols-fr rounded-md border border-surface-300 bg-surface-100 p-0.5 shadow-sm dark:border-surface-700 dark:bg-surface-900;
5
+ }
6
+
7
+ .s-segmented-control__item {
8
+ @apply min-w-0 rounded-md border border-transparent bg-transparent px-5 py-2.5 text-sm font-semibold text-surface-600 hover:bg-surface-200 disabled:cursor-not-allowed disabled:opacity-50 dark:text-surface-300 dark:hover:bg-surface-800;
9
+ }
10
+
11
+ .s-segmented-control__item:focus-visible {
12
+ outline: var(--s-focus-ring-width) solid var(--s-focus-ring-color);
13
+ outline-offset: var(--s-focus-ring-offset);
14
+ }
15
+
16
+ .s-segmented-control__item[data-size='sm'] {
17
+ @apply px-2.5 py-1.5 text-xs;
18
+ }
19
+
20
+ .s-segmented-control__item[aria-checked='true'] {
21
+ @apply bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300;
22
+ }
23
+
24
+ .s-segmented-control__group[data-invalid='true'] {
25
+ @apply border-danger-500 dark:border-danger-400;
26
+ }
27
+
28
+ .s-segmented-control__group[data-readonly='true'] .s-segmented-control__item {
29
+ @apply cursor-default hover:bg-transparent dark:hover:bg-transparent;
30
+ }
31
+
32
+ .s-segmented-control__group[data-readonly='true'] .s-segmented-control__item[aria-checked='true'] {
33
+ @apply hover:bg-primary-50 dark:hover:bg-primary-950/40;
34
+ }
35
+
36
+ .s-segmented-control__group[data-option-wrap='nowrap'] .s-segmented-control__item {
37
+ white-space: nowrap;
38
+ }
@@ -0,0 +1,134 @@
1
+ <template>
2
+ <SFieldGroup
3
+ v-bind="ownedAttrs.bindings()"
4
+ :id="id"
5
+ :label="label"
6
+ :help-text="helpText"
7
+ :error-message="errorMessage"
8
+ :required="required"
9
+ :invalid="invalid"
10
+ :disabled="disabled"
11
+ :readonly="readonly"
12
+ >
13
+ <template #default="group">
14
+ <div
15
+ class="s-segmented-control__group"
16
+ :data-size="size"
17
+ :data-option-wrap="optionWrap"
18
+ :data-invalid="group.invalid || undefined"
19
+ :data-readonly="group.readonly || undefined"
20
+ role="radiogroup"
21
+ :aria-labelledby="group.labelId"
22
+ :aria-describedby="group.describedBy"
23
+ :aria-invalid="group.invalid || undefined"
24
+ :aria-required="group.required || undefined"
25
+ :aria-readonly="group.readonly || undefined"
26
+ >
27
+ <button
28
+ v-for="option in options"
29
+ :key="optionToken(option)"
30
+ :ref="(element) => roving.register(option.value, resolveNativeElement(element))"
31
+ class="s-segmented-control__item"
32
+ :data-size="size"
33
+ type="button"
34
+ :aria-checked="option.value === modelValue"
35
+ :disabled="group.disabled || option.disabled"
36
+ role="radio"
37
+ :tabindex="option.value === focusableValue ? 0 : -1"
38
+ :title="option.title"
39
+ @click="selectOption(option)"
40
+ @keydown="roving.handleKeydown($event, option.value)"
41
+ >{{ option.label }}</button>
42
+ </div>
43
+ </template>
44
+ </SFieldGroup>
45
+ </template>
46
+
47
+ <script lang="ts">
48
+ import type { SelectionKey as PublicSelectionKey } from '../_internal/useSelectionRoving'
49
+
50
+ /** Одна option сегментированного выбора. / One segmented selection option. */
51
+ export type SegmentedControlOption<T extends PublicSelectionKey = string> = {
52
+ value: T
53
+ label: string
54
+ title?: string
55
+ disabled?: boolean
56
+ }
57
+
58
+ /** Публичный field/radiogroup-контракт сегментированного выбора. / Public segmented field/radiogroup contract. */
59
+ export interface Props<T extends PublicSelectionKey = string> {
60
+ modelValue: T
61
+ options: readonly SegmentedControlOption<T>[]
62
+ label: string
63
+ id?: string
64
+ helpText?: string
65
+ errorMessage?: string
66
+ required?: boolean
67
+ invalid?: boolean
68
+ disabled?: boolean
69
+ readonly?: boolean
70
+ size?: 'sm' | 'md'
71
+ optionWrap?: 'wrap' | 'nowrap'
72
+ }
73
+ </script>
74
+
75
+ <script setup lang="ts" generic="T extends SelectionKey = string">
76
+ import { computed } from 'vue'
77
+ import type { ComponentPublicInstance } from 'vue'
78
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
79
+ import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
80
+ import { selectionKeyToken, useSelectionRoving } from '../_internal/useSelectionRoving'
81
+ import type { SelectionKey } from '../_internal/useSelectionRoving'
82
+ import SFieldGroup from './SFieldGroup.vue'
83
+
84
+ defineOptions({ inheritAttrs: false })
85
+
86
+ const props = withDefaults(defineProps<Props<T>>(), {
87
+ id: undefined,
88
+ helpText: undefined,
89
+ errorMessage: undefined,
90
+ required: false,
91
+ invalid: false,
92
+ disabled: false,
93
+ readonly: false,
94
+ size: 'md',
95
+ optionWrap: 'wrap',
96
+ })
97
+
98
+ const emit = defineEmits<{
99
+ 'update:modelValue': [value: T]
100
+ }>()
101
+ const ownedAttrs = useOwnedAttrs({ component: 'SSegmentedControl', owner: 'field group root' })
102
+ useInteractiveLeafRegistration({ owner: 'SSegmentedControl' })
103
+
104
+ function selectOption(option: SegmentedControlOption<T>): void {
105
+ if (props.disabled || props.readonly || option.disabled) {
106
+ return
107
+ }
108
+ emit('update:modelValue', option.value)
109
+ }
110
+
111
+ const rovingOptions = computed(() => props.options.map((option) => ({
112
+ value: option.value,
113
+ disabled: props.disabled || option.disabled,
114
+ })))
115
+ const roving = useSelectionRoving({
116
+ options: rovingOptions,
117
+ selectedValue: computed(() => props.modelValue),
118
+ select: (value) => {
119
+ if (!props.disabled && !props.readonly) emit('update:modelValue', value)
120
+ },
121
+ component: 'SSegmentedControl',
122
+ })
123
+ const focusableValue = roving.focusableValue
124
+ const optionToken = (option: SegmentedControlOption<T>): string => selectionKeyToken(
125
+ 'SSegmentedControl',
126
+ option.value,
127
+ 'option.value',
128
+ )
129
+ const resolveNativeElement = (element: Element | ComponentPublicInstance | null): HTMLElement | null => (
130
+ element instanceof HTMLElement ? element : null
131
+ )
132
+ </script>
133
+
134
+ <style lang="postcss" src="./SSegmentedControl.css" scoped></style>
@@ -0,0 +1,12 @@
1
+ /*
2
+ Стили компонента SSelect на базе Headless UI.
3
+ */
4
+ @reference "../../../styles/reference.css";
5
+
6
+ .s-select-anchor {
7
+ @apply w-full min-w-0;
8
+ }
9
+
10
+ .s-select-layer {
11
+ @apply max-w-[calc(100vw-1rem)];
12
+ }