@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,338 @@
1
+ <template>
2
+ <SField
3
+ :control-id="contract.inputId"
4
+ :label="contract.label"
5
+ :required="contract.required"
6
+ :help-text="contract.helpText"
7
+ :error-message="contract.errorMessage"
8
+ :invalid="contract.invalid"
9
+ :disabled="contract.disabled"
10
+ :readonly="contract.readonly"
11
+ >
12
+ <template #default="field">
13
+ <div ref="anchorRef" class="s-select-anchor">
14
+ <SComboboxTrigger
15
+ ref="triggerRef"
16
+ :id="field.controlId"
17
+ :title="contract.title"
18
+ :accessible-label="accessibleLabel"
19
+ :expanded="isOpen"
20
+ :controls="listboxId"
21
+ :display-value="displayValue"
22
+ :placeholder="contract.placeholder"
23
+ :marker="contract.triggerMarker ?? selectedOption?.marker"
24
+ :size="contract.size"
25
+ :disabled="field.disabled"
26
+ :invalid="field.invalid"
27
+ :described-by="mergeIdReferences(selectOwnedAttrs['aria-describedby'], field.describedBy)"
28
+ :required="field.required"
29
+ :readonly="field.readonly"
30
+ :test-id="selectOwnedAttrs['data-testid']"
31
+ :test="selectOwnedAttrs['data-test']"
32
+ :task-mapping-internal-value="selectOwnedAttrs['data-task-mapping-internal-value']"
33
+ :task-mapping-kind="selectOwnedAttrs['data-task-mapping-kind']"
34
+ :appearance="contract.appearance"
35
+ :width="contract.appearance === 'swatch' ? 'auto' : 'full'"
36
+ @toggle="toggle"
37
+ />
38
+ </div>
39
+ <teleport to="body">
40
+ <div v-if="isOpen" ref="panelRef" :style="floatingStyle" class="s-select-layer">
41
+ <SListbox
42
+ ref="listboxRef"
43
+ v-bind="listboxProps"
44
+ @update:model-value="onListboxUpdate"
45
+ @escape="close('escape')"
46
+ @tab="closeForTab"
47
+ />
48
+ </div>
49
+ </teleport>
50
+ </template>
51
+ </SField>
52
+ </template>
53
+
54
+ <script lang="ts">
55
+ import type { SelectionKey as PublicSelectionKey } from '../_internal/useSelectionRoving'
56
+ import type {
57
+ SelectionEntry as PublicSelectionEntry,
58
+ SelectionMode as PublicSelectionMode,
59
+ SelectionModelValue as PublicSelectionModelValue,
60
+ SelectionOption as PublicSelectionOption,
61
+ SelectionOptionGroup as PublicSelectionOptionGroup,
62
+ } from '../_internal/selectionContract'
63
+ import type { SMarker as PublicMarker } from '../_internal/markerContract'
64
+
65
+ export type SSelectOption<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOption<TKey>
66
+ export type SSelectOptionGroup<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOptionGroup<TKey>
67
+ export type SSelectEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
68
+ export type SSelectMode = PublicSelectionMode
69
+ export type SSelectModelValue<
70
+ TKey extends PublicSelectionKey,
71
+ TMode extends SSelectMode,
72
+ > = PublicSelectionModelValue<TKey, TMode>
73
+
74
+ interface SharedProps<TKey extends PublicSelectionKey> {
75
+ options: readonly SSelectEntry<TKey>[]
76
+ label?: string
77
+ placeholder?: string
78
+ disabled?: boolean
79
+ readonly?: boolean
80
+ required?: boolean
81
+ invalid?: boolean
82
+ errorMessage?: string
83
+ helpText?: string
84
+ emptyOptionLabel?: string
85
+ id?: string
86
+ size?: 'xs' | 'sm' | 'md'
87
+ appearance?: 'field' | 'swatch'
88
+ align?: 'left' | 'right' | 'center'
89
+ triggerMarker?: PublicMarker
90
+ title?: string
91
+ }
92
+
93
+ export type Props<
94
+ TKey extends PublicSelectionKey = PublicSelectionKey,
95
+ TMode extends SSelectMode = 'single',
96
+ > = SharedProps<TKey> & (TMode extends 'multiple'
97
+ ? { selectionMode: 'multiple'; modelValue: readonly TKey[]; emptyOptionLabel?: never }
98
+ : { selectionMode?: 'single'; modelValue: TKey | null; emptyOptionLabel?: string })
99
+
100
+ type ComponentProps<TKey extends PublicSelectionKey> = SharedProps<TKey> & (
101
+ | { selectionMode?: 'single'; modelValue: TKey | null; emptyOptionLabel?: string }
102
+ | { selectionMode: 'multiple'; modelValue: readonly TKey[]; emptyOptionLabel?: never }
103
+ )
104
+ </script>
105
+
106
+ <script setup lang="ts" generic="TKey extends SelectionKey = string">
107
+ import { computed, nextTick, onBeforeUnmount, ref, useId } from 'vue'
108
+ import { useFloatingPosition } from '../../../composables/useFloatingPosition'
109
+ import type { FloatingPlacement } from '../../../composables/useFloatingPosition'
110
+ import { findRelativeTabTarget } from '../../../internal/focusNavigation'
111
+ import { registerLayer, type LayerRegistration } from '../../../internal/layerStack'
112
+ import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
113
+ import {
114
+ validateBoolean,
115
+ validateDomId,
116
+ validateExactString,
117
+ validateNonEmptyString,
118
+ } from '../../../internal/runtimeContract'
119
+ import {
120
+ isSelectionModelList,
121
+ resolveSelectionInventory,
122
+ validateOptionalSelectionString,
123
+ validateSelectionMarker,
124
+ validateSelectionModel,
125
+ type SelectionMode,
126
+ } from '../_internal/selectionContract'
127
+ import { type SelectionKey } from '../_internal/useSelectionRoving'
128
+ import SComboboxTrigger from './SComboboxTrigger.vue'
129
+ import type { SComboboxTriggerApi } from './SComboboxTrigger.vue'
130
+ import SField from './SField.vue'
131
+ import SListbox from './SListbox.vue'
132
+ import type { SListboxProps, SListboxVariant } from './SListbox.vue'
133
+
134
+ defineOptions({ inheritAttrs: false })
135
+
136
+ const props = withDefaults(defineProps<ComponentProps<TKey>>(), {
137
+ label: undefined,
138
+ placeholder: 'Выберите значение',
139
+ disabled: false,
140
+ readonly: false,
141
+ required: false,
142
+ invalid: false,
143
+ errorMessage: undefined,
144
+ helpText: undefined,
145
+ emptyOptionLabel: undefined,
146
+ id: undefined,
147
+ size: 'md',
148
+ appearance: 'field',
149
+ align: 'left',
150
+ triggerMarker: undefined,
151
+ title: undefined,
152
+ selectionMode: undefined,
153
+ })
154
+ const emit = defineEmits<{
155
+ 'update:modelValue': [value: TKey | null] | [value: readonly TKey[]]
156
+ }>()
157
+ const ownedAttrs = useOwnedAttrs({ component: 'SSelect', owner: 'combobox trigger' })
158
+ const selectOwnedAttrs = computed(() => ownedAttrs.bindings())
159
+ const generatedInputId = useId()
160
+
161
+ const contract = computed(() => {
162
+ const selectionMode: SelectionMode = validateExactString(
163
+ 'SSelect', 'selectionMode', props.selectionMode ?? 'single', ['single', 'multiple'] as const,
164
+ )
165
+ let emptyOptionLabel: string | undefined
166
+ if (props.emptyOptionLabel !== undefined) {
167
+ if (selectionMode === 'multiple') {
168
+ throw new Error('SSelect: emptyOptionLabel is available only in single selection mode')
169
+ }
170
+ emptyOptionLabel = validateNonEmptyString('SSelect', 'emptyOptionLabel', props.emptyOptionLabel)
171
+ }
172
+ return {
173
+ inputId: props.id === undefined
174
+ ? `s-select-${generatedInputId}`
175
+ : validateDomId('SSelect', 'id', props.id),
176
+ label: props.label === undefined
177
+ ? undefined
178
+ : validateNonEmptyString('SSelect', 'label', props.label),
179
+ placeholder: validateNonEmptyString('SSelect', 'placeholder', props.placeholder),
180
+ disabled: validateBoolean('SSelect', 'disabled', props.disabled),
181
+ readonly: validateBoolean('SSelect', 'readonly', props.readonly),
182
+ required: validateBoolean('SSelect', 'required', props.required),
183
+ invalid: validateBoolean('SSelect', 'invalid', props.invalid),
184
+ errorMessage: validateOptionalSelectionString('SSelect', 'errorMessage', props.errorMessage),
185
+ helpText: validateOptionalSelectionString('SSelect', 'helpText', props.helpText),
186
+ size: validateExactString('SSelect', 'size', props.size, ['xs', 'sm', 'md'] as const),
187
+ appearance: validateExactString(
188
+ 'SSelect', 'appearance', props.appearance, ['field', 'swatch'] as const,
189
+ ),
190
+ align: validateExactString(
191
+ 'SSelect', 'align', props.align, ['left', 'right', 'center'] as const,
192
+ ),
193
+ triggerMarker: validateSelectionMarker('SSelect', 'triggerMarker', props.triggerMarker),
194
+ title: validateOptionalSelectionString('SSelect', 'title', props.title),
195
+ selectionMode,
196
+ emptyOptionLabel,
197
+ }
198
+ })
199
+ const listboxId = computed(() => `${contract.value.inputId}-listbox`)
200
+ const resolvedSelectionMode = computed(() => contract.value.selectionMode)
201
+ const inventory = computed(() => resolveSelectionInventory<TKey>('SSelect', props.options))
202
+ const selectedTokens = computed(() => validateSelectionModel(
203
+ 'SSelect', contract.value.selectionMode, props.modelValue, inventory.value,
204
+ ))
205
+ const accessibleLabel = computed(() => {
206
+ const ownedLabel = selectOwnedAttrs.value['aria-label']
207
+ if (ownedLabel !== undefined) return validateNonEmptyString('SSelect', 'aria-label', ownedLabel)
208
+ return contract.value.label ?? contract.value.placeholder
209
+ })
210
+ const selectedOption = computed(() => inventory.value.options.find((record) => (
211
+ selectedTokens.value.has(record.token)
212
+ ))?.option ?? null)
213
+ const displayValue = computed(() => inventory.value.options
214
+ .filter((record) => selectedTokens.value.has(record.token))
215
+ .map((record) => record.option.label)
216
+ .join(', '))
217
+ const resolvedEmptyOptionLabel = computed(() => contract.value.emptyOptionLabel)
218
+ const listboxProps = computed<
219
+ SListboxProps<TKey, 'single'> | SListboxProps<TKey, 'multiple'>
220
+ >(() => {
221
+ const variant: SListboxVariant = contract.value.appearance === 'swatch' ? 'color' : 'select'
222
+ const shared = {
223
+ id: listboxId.value,
224
+ label: accessibleLabel.value,
225
+ options: props.options,
226
+ variant,
227
+ invalid: contract.value.invalid,
228
+ }
229
+ if (contract.value.selectionMode === 'multiple') {
230
+ if (!isSelectionModelList(props.modelValue)) {
231
+ throw new TypeError('SSelect: multiple selection requires modelValue to be an array')
232
+ }
233
+ return {
234
+ ...shared,
235
+ selectionMode: 'multiple',
236
+ modelValue: props.modelValue,
237
+ }
238
+ }
239
+ if (isSelectionModelList(props.modelValue)) {
240
+ throw new TypeError('SSelect: single selection requires modelValue to be a selection key or null')
241
+ }
242
+ return {
243
+ ...shared,
244
+ selectionMode: 'single',
245
+ modelValue: props.modelValue,
246
+ clearOptionLabel: contract.value.emptyOptionLabel,
247
+ }
248
+ })
249
+
250
+ const isOpen = ref(false)
251
+ const layerZIndex = ref('var(--s-layer-floating)')
252
+ let layerRegistration: LayerRegistration | null = null
253
+ const triggerRef = ref<SComboboxTriggerApi | null>(null)
254
+ const listboxRef = ref<{ focusSelectedOrFirst: () => void } | null>(null)
255
+ const placement = computed<FloatingPlacement>(() => (
256
+ `bottom-${contract.value.align === 'left' ? 'start' : contract.value.align === 'right' ? 'end' : 'center'}`
257
+ ))
258
+ const { anchorRef, panelRef, floatingStyle } = useFloatingPosition({
259
+ active: isOpen,
260
+ placement: () => placement.value,
261
+ widthMatch: () => contract.value.appearance === 'field' ? 'exact' : 'none',
262
+ zIndex: () => layerZIndex.value,
263
+ })
264
+
265
+ type CloseReason = 'trigger' | 'selection' | 'escape' | 'tab' | 'outside'
266
+
267
+ function close(reason: CloseReason = 'trigger'): void {
268
+ if (!isOpen.value) return
269
+ isOpen.value = false
270
+ layerRegistration?.unregister({
271
+ restoreFocus: reason === 'trigger' || reason === 'selection' || reason === 'escape',
272
+ })
273
+ layerRegistration = null
274
+ layerZIndex.value = 'var(--s-layer-floating)'
275
+ }
276
+
277
+ function triggerElement(): HTMLElement {
278
+ const trigger = triggerRef.value
279
+ if (!trigger) throw new Error('SSelect: combobox trigger API is unavailable')
280
+ return trigger.getElement()
281
+ }
282
+
283
+ function closeForTab(event: KeyboardEvent): void {
284
+ const trigger = triggerElement()
285
+ const scope = layerRegistration?.focusScope() ?? document
286
+ const target = findRelativeTabTarget(trigger, scope, panelRef.value, event.shiftKey)
287
+ event.preventDefault()
288
+ close('tab')
289
+ if (target) nextTick(() => target.focus())
290
+ }
291
+
292
+ async function open(): Promise<void> {
293
+ void selectedTokens.value
294
+ void resolvedEmptyOptionLabel.value
295
+ if (contract.value.disabled || contract.value.readonly || isOpen.value) return
296
+ const trigger = triggerElement()
297
+ isOpen.value = true
298
+ layerRegistration = registerLayer({
299
+ kind: 'listbox',
300
+ root: () => panelRef.value,
301
+ anchor: () => anchorRef.value,
302
+ restoreTarget: trigger,
303
+ requestDismiss: (reason) => close(reason === 'escape' ? 'escape' : 'outside'),
304
+ })
305
+ layerZIndex.value = layerRegistration.zIndex
306
+ await nextTick()
307
+ if (isOpen.value) listboxRef.value?.focusSelectedOrFirst()
308
+ }
309
+
310
+ function toggle(): void {
311
+ if (isOpen.value) close('trigger')
312
+ else void open()
313
+ }
314
+
315
+ function onListboxUpdate(value: TKey | null | readonly TKey[]): void {
316
+ if (resolvedSelectionMode.value === 'multiple') {
317
+ if (!isSelectionModelList(value)) {
318
+ throw new TypeError('SSelect: multiple selection update must be an array')
319
+ }
320
+ validateSelectionModel('SSelect update', 'multiple', value, inventory.value)
321
+ emit('update:modelValue', value)
322
+ return
323
+ }
324
+ if (isSelectionModelList(value)) {
325
+ throw new TypeError('SSelect: single selection update must be a selection key or null')
326
+ }
327
+ validateSelectionModel('SSelect update', 'single', value, inventory.value)
328
+ emit('update:modelValue', value)
329
+ close('selection')
330
+ }
331
+
332
+ onBeforeUnmount(() => {
333
+ layerRegistration?.unregister({ restoreFocus: false })
334
+ layerRegistration = null
335
+ })
336
+ </script>
337
+
338
+ <style lang="postcss" src="./SSelect.css" scoped></style>
@@ -0,0 +1,31 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-textarea {
4
+ @apply block w-full resize-none text-sm;
5
+ }
6
+
7
+ .s-textarea[data-monospace='true'] {
8
+ @apply font-mono;
9
+ }
10
+
11
+ .s-textarea[data-appearance='default'][data-size='sm'] { @apply px-2.5 py-1.5 text-xs; }
12
+ .s-textarea[data-appearance='default'][data-size='md'] { @apply px-3 py-2 text-sm; }
13
+ .s-textarea[data-appearance='default'][data-size='lg'] { @apply px-4 py-2.5 text-lg; }
14
+
15
+ .s-textarea[data-appearance='ghost'] {
16
+ @apply border-0 bg-transparent p-0 text-surface-800 shadow-none ring-0 placeholder:text-surface-400 dark:text-surface-100 dark:placeholder:text-surface-500;
17
+ appearance: none;
18
+ line-height: 1.45;
19
+ }
20
+
21
+ .s-textarea[data-appearance='ghost']:focus {
22
+ @apply border-0 shadow-none ring-0;
23
+ }
24
+
25
+ .s-textarea[data-appearance='ghost']:disabled {
26
+ @apply cursor-not-allowed text-surface-400 dark:text-surface-500;
27
+ }
28
+
29
+ .s-textarea[data-appearance='ghost'][data-invalid='true'] {
30
+ @apply text-danger-700 placeholder:text-danger-400 dark:text-danger-300 dark:placeholder:text-danger-300/80;
31
+ }
@@ -0,0 +1,316 @@
1
+ <template>
2
+ <SField
3
+ :control-id="textareaId"
4
+ :label="label"
5
+ :required="required"
6
+ :help-text="helpText"
7
+ :error-message="errorMessage"
8
+ :invalid="invalid"
9
+ :disabled="disabled"
10
+ :readonly="readonly"
11
+ :fill="validatedAppearance === 'ghost' ? 'container' : 'content'"
12
+ >
13
+ <template #default="field">
14
+ <textarea
15
+ v-bind="{ ...ownedAttrs.bindings(), ...resolveFieldBindings(field) }"
16
+ :id="textareaId"
17
+ ref="textareaRef"
18
+ class="s-textarea"
19
+ :data-appearance="validatedAppearance"
20
+ :data-size="validatedSize"
21
+ :data-monospace="monospace || undefined"
22
+ :data-auto-resize="resizeContract.mode === 'content' || undefined"
23
+ :data-max-rows="validatedMaxRows"
24
+ :data-invalid="field.invalid || undefined"
25
+ :value="modelValue"
26
+ :placeholder="placeholder"
27
+ :disabled="field.disabled"
28
+ :readonly="field.readonly"
29
+ :required="field.required"
30
+ :aria-invalid="field.invalid || undefined"
31
+ :aria-required="field.required || undefined"
32
+ :aria-readonly="field.readonly || undefined"
33
+ :aria-describedby="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
34
+ :rows="validatedRows"
35
+ @input="handleInput"
36
+ @keydown="emit('keydown', $event)"
37
+ @keyup="handleKeyup"
38
+ @click="handleClick"
39
+ />
40
+ </template>
41
+ </SField>
42
+ </template>
43
+
44
+ <script setup lang="ts">
45
+ import { computed, nextTick, onMounted, ref, useId, watch } from 'vue'
46
+ import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
47
+ import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
48
+ import { resolveFieldSurfaceBindings, type FieldSurfaceBindings } from '../../../internal/fieldSurface'
49
+ import {
50
+ resolveOptionalDomId,
51
+ validateBoolean,
52
+ validateBoundedInteger,
53
+ validateExactString,
54
+ } from '../../../internal/runtimeContract'
55
+ import SField from './SField.vue'
56
+
57
+ defineOptions({
58
+ inheritAttrs: false,
59
+ })
60
+
61
+ /** Визуальная интеграция native textarea с SField. / Native textarea visual integration with SField. */
62
+ export type STextareaAppearance = 'default' | 'ghost'
63
+ export type STextareaSize = 'sm' | 'md' | 'lg'
64
+
65
+ /**
66
+ * Дискриминированный resize contract: fixed не имеет maxRows, content может быть bounded или unbounded.
67
+ * Discriminated resize contract: fixed has no maxRows; content can be bounded or unbounded.
68
+ */
69
+ export type STextareaResizeContract =
70
+ | { mode: 'fixed'; autoResize: false }
71
+ | { mode: 'content'; autoResize: true; maxRows?: number }
72
+
73
+ const TEXTAREA_APPEARANCES = ['default', 'ghost'] as const satisfies readonly STextareaAppearance[]
74
+ const TEXTAREA_SIZES = ['sm', 'md', 'lg'] as const satisfies readonly STextareaSize[]
75
+ const TEXTAREA_ROWS_DOMAIN = { minimum: 1, maximum: 100 } as const
76
+
77
+ export interface Props {
78
+ modelValue: string | null
79
+ label?: string
80
+ placeholder?: string
81
+ disabled?: boolean
82
+ readonly?: boolean
83
+ required?: boolean
84
+ invalid?: boolean
85
+ errorMessage?: string
86
+ helpText?: string
87
+ id?: string
88
+ rows?: number
89
+ size?: STextareaSize
90
+ autoResize?: boolean
91
+ maxRows?: number
92
+ appearance?: STextareaAppearance
93
+ monospace?: boolean
94
+ }
95
+
96
+ const props = withDefaults(defineProps<Props>(), {
97
+ modelValue: '',
98
+ label: undefined,
99
+ placeholder: undefined,
100
+ disabled: false,
101
+ readonly: false,
102
+ required: false,
103
+ invalid: false,
104
+ errorMessage: undefined,
105
+ helpText: undefined,
106
+ id: undefined,
107
+ rows: 3,
108
+ size: 'md',
109
+ autoResize: false,
110
+ maxRows: undefined,
111
+ appearance: 'default',
112
+ monospace: false,
113
+ })
114
+
115
+ const emit = defineEmits<{
116
+ (event: 'update:modelValue', value: string): void
117
+ (event: 'keydown', keyboardEvent: KeyboardEvent): void
118
+ (event: 'keyup', keyboardEvent: KeyboardEvent): void
119
+ (event: 'click', mouseEvent: MouseEvent): void
120
+ (event: 'state-change', state: { text: string; cursor: number }): void
121
+ }>()
122
+
123
+ const ownedAttrs = useOwnedAttrs({ component: 'STextarea', owner: 'native textarea' })
124
+ useInteractiveLeafRegistration({ owner: 'STextarea' })
125
+ const generatedTextareaId = useId()
126
+ const textareaId = computed(() => resolveOptionalDomId(
127
+ 'STextarea',
128
+ 'id',
129
+ props.id,
130
+ `s-textarea-${generatedTextareaId}`,
131
+ ))
132
+ const textareaRef = ref<HTMLTextAreaElement | null>(null)
133
+ const validatedAppearance = computed<STextareaAppearance>(() => validateExactString(
134
+ 'STextarea',
135
+ 'appearance',
136
+ props.appearance,
137
+ TEXTAREA_APPEARANCES,
138
+ ))
139
+ const validatedSize = computed<STextareaSize>(() => validateExactString(
140
+ 'STextarea',
141
+ 'size',
142
+ props.size,
143
+ TEXTAREA_SIZES,
144
+ ))
145
+ const validatedRows = computed(() => validateBoundedInteger(
146
+ 'STextarea',
147
+ 'rows',
148
+ props.rows,
149
+ TEXTAREA_ROWS_DOMAIN,
150
+ ))
151
+ const validatedMaxRows = computed(() => props.maxRows === undefined
152
+ ? undefined
153
+ : validateBoundedInteger('STextarea', 'maxRows', props.maxRows, TEXTAREA_ROWS_DOMAIN))
154
+ const resizeContract = computed<STextareaResizeContract>(() => {
155
+ const autoResize = validateBoolean('STextarea', 'autoResize', props.autoResize)
156
+ const maxRows = validatedMaxRows.value
157
+ if (!autoResize && maxRows !== undefined) {
158
+ throw new TypeError(
159
+ `STextarea: maxRows применяется только при autoResize=true; получено autoResize=false, maxRows=${maxRows}. `
160
+ + `/ STextarea: maxRows applies only when autoResize=true; received autoResize=false, maxRows=${maxRows}.`,
161
+ )
162
+ }
163
+ if (maxRows !== undefined && maxRows < validatedRows.value) {
164
+ throw new TypeError(
165
+ `STextarea: maxRows не может быть меньше rows; получено rows=${validatedRows.value}, maxRows=${maxRows}. `
166
+ + `/ STextarea: maxRows cannot be less than rows; received rows=${validatedRows.value}, maxRows=${maxRows}.`,
167
+ )
168
+ }
169
+ if (!autoResize) return { mode: 'fixed', autoResize: false }
170
+ if (maxRows === undefined) return { mode: 'content', autoResize: true }
171
+ return { mode: 'content', autoResize: true, maxRows }
172
+ })
173
+
174
+ function resolveFieldBindings(field: {
175
+ invalid: boolean
176
+ disabled: boolean
177
+ readonly: boolean
178
+ }): FieldSurfaceBindings {
179
+ return resolveFieldSurfaceBindings('STextarea', {
180
+ kind: 'textarea',
181
+ appearance: validatedAppearance.value === 'default' ? 'field' : 'ghost',
182
+ invalid: field.invalid,
183
+ disabled: field.disabled,
184
+ readonly: field.readonly,
185
+ })
186
+ }
187
+
188
+ function emitState(target: HTMLTextAreaElement): void {
189
+ emit('state-change', {
190
+ text: target.value,
191
+ cursor: validateSelectionOffset(target.selectionStart, target.value.length, 'state-change cursor'),
192
+ })
193
+ }
194
+
195
+ function handleInput(event: Event): void {
196
+ const target = event.target as HTMLTextAreaElement
197
+ emit('update:modelValue', target.value)
198
+ emitState(target)
199
+ nextTick(resize)
200
+ }
201
+
202
+ function handleKeyup(event: KeyboardEvent): void {
203
+ emit('keyup', event)
204
+ emitState(event.currentTarget as HTMLTextAreaElement)
205
+ }
206
+
207
+ function handleClick(event: MouseEvent): void {
208
+ emit('click', event)
209
+ emitState(event.currentTarget as HTMLTextAreaElement)
210
+ }
211
+
212
+ function resize(): void {
213
+ const textarea = textareaRef.value
214
+ const contract = resizeContract.value
215
+ if (contract.mode === 'fixed' || !textarea || textarea.scrollHeight === 0) return
216
+
217
+ const style = window.getComputedStyle(textarea)
218
+ const lineHeight = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.5
219
+ const paddingTop = Number.parseFloat(style.paddingTop) || 0
220
+ const paddingBottom = Number.parseFloat(style.paddingBottom) || 0
221
+ const minimumHeight = validatedRows.value * lineHeight + paddingTop + paddingBottom
222
+ const maximumHeight = contract.maxRows !== undefined
223
+ ? contract.maxRows * lineHeight + paddingTop + paddingBottom
224
+ : 0
225
+
226
+ if (props.disabled || props.modelValue === null || props.modelValue.length === 0) {
227
+ textarea.style.height = `${minimumHeight}px`
228
+ textarea.style.overflowY = 'hidden'
229
+ return
230
+ }
231
+
232
+ textarea.style.height = 'auto'
233
+ const contentHeight = textarea.scrollHeight
234
+ if (maximumHeight > 0 && contentHeight > maximumHeight) {
235
+ textarea.style.height = `${maximumHeight}px`
236
+ textarea.style.overflowY = 'auto'
237
+ return
238
+ }
239
+
240
+ textarea.style.height = `${Math.max(minimumHeight, contentHeight)}px`
241
+ textarea.style.overflowY = 'hidden'
242
+ }
243
+
244
+ function focus(): void {
245
+ textareaRef.value?.focus()
246
+ }
247
+
248
+ function requireTextarea(coordinate: string): HTMLTextAreaElement {
249
+ const textarea = textareaRef.value
250
+ if (!textarea) {
251
+ throw new Error(
252
+ `STextarea: textarea не готова для ${coordinate}; дождитесь mount. `
253
+ + `/ STextarea: textarea is not ready for ${coordinate}; wait for mount.`,
254
+ )
255
+ }
256
+ return textarea
257
+ }
258
+
259
+ function validateSelectionOffset(offset: unknown, textLength: number, coordinate: string): number {
260
+ return validateBoundedInteger('STextarea', coordinate, offset, { minimum: 0, maximum: textLength })
261
+ }
262
+
263
+ function getSelectionOffset(): number {
264
+ const textarea = requireTextarea('getSelectionOffset')
265
+ return validateSelectionOffset(textarea.selectionStart, textarea.value.length, 'getSelectionOffset')
266
+ }
267
+
268
+ function setSelectionOffset(offset: number): void {
269
+ const textarea = requireTextarea('setSelectionOffset')
270
+ const nextOffset = validateSelectionOffset(offset, textarea.value.length, 'setSelectionOffset')
271
+ textarea.focus()
272
+ textarea.setSelectionRange(nextOffset, nextOffset)
273
+ }
274
+
275
+ function insertSoftLineBreak(): void {
276
+ const textarea = requireTextarea('insertSoftLineBreak')
277
+ if (props.disabled || props.readonly) return
278
+
279
+ const start = validateSelectionOffset(textarea.selectionStart, textarea.value.length, 'insertSoftLineBreak selectionStart')
280
+ const end = validateSelectionOffset(textarea.selectionEnd, textarea.value.length, 'insertSoftLineBreak selectionEnd')
281
+ if (end < start) {
282
+ throw new RangeError(
283
+ `STextarea: insertSoftLineBreak selectionEnd=${end} меньше selectionStart=${start}. `
284
+ + `/ STextarea: insertSoftLineBreak selectionEnd=${end} is below selectionStart=${start}.`,
285
+ )
286
+ }
287
+ const nextValue = `${textarea.value.slice(0, start)}\n${textarea.value.slice(end)}`
288
+ const nextCursor = start + 1
289
+
290
+ emit('update:modelValue', nextValue)
291
+ nextTick(() => {
292
+ const currentTextarea = textareaRef.value
293
+ if (!currentTextarea) return
294
+ currentTextarea.focus()
295
+ currentTextarea.setSelectionRange(nextCursor, nextCursor)
296
+ emitState(currentTextarea)
297
+ resize()
298
+ })
299
+ }
300
+
301
+ defineExpose({
302
+ focus,
303
+ getSelectionOffset,
304
+ insertSoftLineBreak,
305
+ resize,
306
+ setSelectionOffset,
307
+ })
308
+
309
+ onMounted(resize)
310
+ watch(
311
+ () => [props.modelValue, props.disabled, props.rows, props.maxRows] as const,
312
+ () => nextTick(resize),
313
+ )
314
+ </script>
315
+
316
+ <style lang="postcss" src="./STextarea.css"></style>