@pgcorp/ui-kit 0.2.0 → 0.3.1

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 (50) hide show
  1. package/README.md +5 -5
  2. package/docs/public-api.md +8 -0
  3. package/package.json +15 -3
  4. package/src/components/layout/SAppShell.vue +3 -2
  5. package/src/components/layout/SStack.css +9 -3
  6. package/src/components/layout/SStack.vue +10 -2
  7. package/src/components/shared/complex/STree.vue +29 -4
  8. package/src/components/shared/complex/treeAdapter.ts +2 -1
  9. package/src/components/shared/complex/types.ts +1 -12
  10. package/src/components/shared/containers/SLeftSidebar.vue +7 -2
  11. package/src/components/shared/containers/SPageHeader.css +11 -0
  12. package/src/components/shared/containers/SPageHeader.vue +7 -1
  13. package/src/components/shared/containers/SPopover.vue +2 -1
  14. package/src/components/shared/containers/SSidebarGroup.vue +10 -5
  15. package/src/components/shared/containers/sidebar.ts +2 -1
  16. package/src/components/shared/controls/SButton.css +13 -0
  17. package/src/components/shared/controls/SButton.vue +135 -29
  18. package/src/components/shared/controls/SCombobox.css +9 -0
  19. package/src/components/shared/controls/SCombobox.vue +322 -0
  20. package/src/components/shared/controls/SInputText.vue +15 -0
  21. package/src/components/shared/controls/SInteractiveSurface.vue +2 -1
  22. package/src/components/shared/controls/SLink.ts +1 -17
  23. package/src/components/shared/controls/SLink.vue +26 -2
  24. package/src/components/shared/controls/SListbox.vue +99 -1
  25. package/src/components/shared/controls/SListboxOption.vue +15 -2
  26. package/src/components/shared/controls/SSwitch.css +48 -0
  27. package/src/components/shared/controls/SSwitch.vue +109 -0
  28. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +6 -3
  29. package/src/components/shared/data-display/SChip.css +59 -0
  30. package/src/components/shared/data-display/SChip.vue +92 -0
  31. package/src/components/shared/data-display/SDocBlock.vue +10 -10
  32. package/src/components/shared/data-display/SLinkedSystemsList.vue +3 -3
  33. package/src/components/shared/data-display/STable.vue +150 -14
  34. package/src/components/shared/data-display/STooltip.vue +2 -1
  35. package/src/components/shared/data-display/SVirtualList.ts +1 -16
  36. package/src/components/shared/data-display/SVirtualList.vue +18 -2
  37. package/src/components/shared/data-display/json.ts +1 -1
  38. package/src/components/shared/data-display/table.ts +9 -28
  39. package/src/components/shared/database/SDataGrid.vue +29 -8
  40. package/src/components/shared/database/SSqlEditor.vue +3 -2
  41. package/src/components/shared/database/dataGrid.ts +1 -17
  42. package/src/components/shared/navigation/SBottomNav.vue +3 -0
  43. package/src/components/shared/navigation/STabs.vue +2 -1
  44. package/src/composables/uiPreferencesReset.ts +2 -1
  45. package/src/composables/useClipboard.ts +2 -1
  46. package/src/composables/useSidebarPanelState.ts +3 -2
  47. package/src/internal/es2020.ts +50 -0
  48. package/src/internal/inlineTokenEditorContract.ts +4 -3
  49. package/src/internal/ownedAttrs.ts +24 -2
  50. package/src/internal/pointerInteractionLease.ts +2 -1
@@ -1,7 +1,8 @@
1
1
  <template>
2
- <button
3
- ref="buttonRef"
4
- v-bind="buttonBindings"
2
+ <component
3
+ :is="rootComponent"
4
+ :ref="setRootRef"
5
+ v-bind="rootBindings"
5
6
  class="s-button"
6
7
  :data-severity="severity"
7
8
  :data-size="size"
@@ -17,8 +18,7 @@
17
18
  :data-loading="loading || undefined"
18
19
  :aria-pressed="pressed"
19
20
  :aria-busy="busy || loading || undefined"
20
- :disabled="disabled || loading"
21
- :type="type"
21
+ @click="handleClick"
22
22
  >
23
23
  <span v-if="loading" class="s-button__loader" aria-hidden="true">
24
24
  <SProgressIndicator
@@ -37,7 +37,7 @@
37
37
  <component :is="leadingIcon" v-else-if="leadingIcon" class="s-button__icon s-button__icon--leading" aria-hidden="true" />
38
38
  <span v-if="label" class="s-button__label">{{ label }}</span>
39
39
  <PassiveContentBoundary v-if="renderDefaultSlot" :contract="passiveContentContract">
40
- <slot />
40
+ <component :is="defaultContentComponent" />
41
41
  </PassiveContentBoundary>
42
42
  <span v-if="badge !== undefined" class="s-button__badge-slot">
43
43
  <SBadge :severity="badgeSeverity" size="xs">{{ badge }}</SBadge>
@@ -52,14 +52,16 @@
52
52
  ><ChevronRight /></span>
53
53
  <component :is="trailingIcon" v-else-if="trailingIcon" class="s-button__icon s-button__icon--trailing" aria-hidden="true" />
54
54
  </span>
55
- </button>
55
+ </component>
56
56
  </template>
57
57
 
58
58
  <script setup lang="ts">
59
- import { computed, ref, watchEffect } from 'vue'
60
- import type { Component, VNodeChild } from 'vue'
59
+ import { computed, getCurrentInstance, ref, watchEffect } from 'vue'
60
+ import type { Component, ComponentPublicInstance } from 'vue'
61
+ import { RouterLink } from 'vue-router'
61
62
  import { ChevronRight } from '../../icons/sputnigUiIcons'
62
63
  import type { InteractiveElementApi } from '../../../internal/interactiveElement'
64
+ import { resolveLinkTarget } from '../../../internal/linkTarget'
63
65
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
64
66
  import {
65
67
  PassiveContentBoundary,
@@ -69,6 +71,10 @@ import {
69
71
  import { useTreeActionRegistration } from '../_internal/treeRuntime'
70
72
  import SBadge, { type BadgeSeverity } from '../data-display/SBadge.vue'
71
73
  import SProgressIndicator, { type SProgressIndicatorSize } from '../data-display/SProgressIndicator.vue'
74
+ import type {
75
+ LinkBrowsingTarget,
76
+ LinkRouterTarget,
77
+ } from './SLink'
72
78
 
73
79
  defineOptions({ inheritAttrs: false })
74
80
 
@@ -79,7 +85,7 @@ export type SButtonDensity = 'default' | 'compact'
79
85
  export type SButtonSpacing = 'default' | 'tight'
80
86
  export type SButtonHoverSurface = 'self' | 'parent'
81
87
  export type SButtonWidth = 'auto' | 'fit' | 'full'
82
- export type SButtonContentAlign = 'center' | 'start' | 'between'
88
+ export type SButtonContentAlign = 'center' | 'start' | 'end' | 'between'
83
89
  export type SButtonAppearance = 'solid' | 'soft' | 'outline' | 'ghost' | 'text'
84
90
  export type SButtonShape = 'default' | 'pill' | 'square' | 'circle' | 'row'
85
91
  export type SButtonNativeType = 'button' | 'reset' | 'submit'
@@ -89,10 +95,10 @@ export interface SButtonDisclosure {
89
95
  iconPlacement?: 'leading' | 'trailing' | 'none'
90
96
  }
91
97
 
92
- /** Typed native-button ownership surface for composite UI-kit components. / Типизированная ownership-поверхность native button для составных UI-kit-компонентов. */
93
- export type SButtonApi = InteractiveElementApi<HTMLButtonElement>
98
+ /** Typed interactive owner API for action and navigation buttons. / Типизированная API-поверхность action/navigation button. */
99
+ export type SButtonApi = InteractiveElementApi<HTMLButtonElement | HTMLAnchorElement>
94
100
 
95
- export interface Props {
101
+ interface SButtonPresentationProps {
96
102
  label?: string
97
103
  severity?: SButtonSeverity
98
104
  size?: SButtonSize
@@ -116,9 +122,29 @@ export interface Props {
116
122
  pressed?: boolean | 'mixed'
117
123
  busy?: boolean
118
124
  disclosure?: SButtonDisclosure
125
+ }
126
+
127
+ export type SButtonActionProps = SButtonPresentationProps & {
128
+ href?: never
129
+ to?: never
130
+ target?: never
131
+ rel?: never
119
132
  type?: SButtonNativeType
120
133
  }
121
134
 
135
+ interface SButtonNavigationOptions {
136
+ target?: LinkBrowsingTarget
137
+ rel?: string
138
+ }
139
+
140
+ export type SButtonNavigationProps = SButtonPresentationProps & SButtonNavigationOptions & (
141
+ | { href: string; to?: never; type?: never }
142
+ | { href?: never; to: LinkRouterTarget; type?: never }
143
+ )
144
+
145
+ /** Strict action/navigation XOR with a shared button presentation. */
146
+ export type Props = SButtonActionProps | SButtonNavigationProps
147
+
122
148
  const props = withDefaults(defineProps<Props>(), {
123
149
  label: undefined,
124
150
  severity: 'primary',
@@ -143,23 +169,57 @@ const props = withDefaults(defineProps<Props>(), {
143
169
  pressed: undefined,
144
170
  busy: false,
145
171
  disclosure: undefined,
146
- type: 'button',
172
+ href: undefined,
173
+ to: undefined,
174
+ target: undefined,
175
+ rel: undefined,
176
+ type: undefined,
147
177
  })
148
- const slots = defineSlots<{
149
- /** Пассивная метка/графика действия; вложенные controls запрещены. / Passive action label/graphics; nested controls are forbidden. */
150
- default?: () => VNodeChild
178
+ const emit = defineEmits<{
179
+ click: [event: MouseEvent]
151
180
  }>()
152
- const buttonRef = ref<HTMLButtonElement | null>(null)
181
+ const componentInstance = getCurrentInstance()
182
+ if (!componentInstance) throw new Error('SButton: component instance is unavailable')
183
+ const buttonSlots = componentInstance.slots
184
+ const defaultContentComponent = (): ReturnType<NonNullable<typeof buttonSlots.default>> => (
185
+ buttonSlots.default?.() ?? []
186
+ )
187
+ const rootRef = ref<HTMLButtonElement | HTMLAnchorElement | null>(null)
153
188
  const ownedAttrs = useOwnedAttrs({ component: 'SButton', owner: 'native button' })
154
189
  const isRegisteredTreeAction = useTreeActionRegistration({
155
190
  owner: 'SButton',
156
- element: buttonRef,
191
+ element: rootRef,
157
192
  disabled: () => props.disabled || props.loading,
158
193
  })
159
194
 
160
195
  useInteractiveLeafRegistration({ owner: 'SButton' })
161
196
 
162
- const buttonBindings = computed(() => {
197
+ const navigationTarget = computed(() => {
198
+ const hasHref = props.href !== undefined
199
+ const hasTo = props.to !== undefined
200
+ if (hasHref && hasTo) {
201
+ throw new TypeError('SButton: передайте не более одного navigation target — href или to')
202
+ }
203
+ if (!hasHref && !hasTo) return null
204
+ if (props.type !== undefined) {
205
+ throw new TypeError('SButton: navigation target несовместим с native button type')
206
+ }
207
+ return resolveLinkTarget({
208
+ href: props.href,
209
+ to: props.to,
210
+ target: props.target,
211
+ rel: props.rel,
212
+ owner: 'SButton',
213
+ })
214
+ })
215
+ const navigationUnavailable = computed(() => props.disabled || props.loading)
216
+ const rootComponent = computed(() => {
217
+ const target = navigationTarget.value
218
+ if (!target || navigationUnavailable.value) return target ? 'a' : 'button'
219
+ return target.kind === 'router' ? RouterLink : 'a'
220
+ })
221
+
222
+ const rootBindings = computed(() => {
163
223
  const bindings = ownedAttrs.bindings()
164
224
  const managedBindings = isRegisteredTreeAction ? { ...bindings, tabindex: -1 } : bindings
165
225
  const semanticBindings = props.disclosure
@@ -169,17 +229,31 @@ const buttonBindings = computed(() => {
169
229
  'aria-controls': props.disclosure.controls,
170
230
  }
171
231
  : managedBindings
172
- if (!props.loading) return semanticBindings
232
+ const target = navigationTarget.value
233
+ const elementBindings = target === null
234
+ ? {
235
+ ...semanticBindings,
236
+ disabled: props.disabled || props.loading || undefined,
237
+ type: props.type ?? 'button',
238
+ }
239
+ : navigationUnavailable.value
240
+ ? {
241
+ ...semanticBindings,
242
+ 'aria-disabled': true,
243
+ tabindex: -1,
244
+ }
245
+ : { ...semanticBindings, ...target.bindings }
246
+ if (!props.loading) return elementBindings
173
247
 
174
- const externalAccessibleName = semanticBindings['aria-label']
248
+ const externalAccessibleName = elementBindings['aria-label']
175
249
  const accessibleName = [props.loadingLabel, props.label, externalAccessibleName]
176
250
  .find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0)
177
- if (!accessibleName && !slots.default) {
251
+ if (!accessibleName && !buttonSlots.default) {
178
252
  throw new Error('SButton: loading action requires label, loadingLabel, or aria-label')
179
253
  }
180
254
  return accessibleName
181
- ? { ...semanticBindings, 'aria-label': accessibleName }
182
- : semanticBindings
255
+ ? { ...elementBindings, 'aria-label': accessibleName }
256
+ : elementBindings
183
257
  })
184
258
 
185
259
  const passiveContentContract: PassiveContentContract = {
@@ -189,6 +263,13 @@ const passiveContentContract: PassiveContentContract = {
189
263
  }
190
264
 
191
265
  watchEffect(() => {
266
+ const target = navigationTarget.value
267
+ if (target && props.pressed !== undefined) {
268
+ throw new Error('SButton: navigation target несовместим с pressed toggle semantics')
269
+ }
270
+ if (target && props.disclosure !== undefined) {
271
+ throw new Error('SButton: navigation target несовместим с disclosure trigger semantics')
272
+ }
192
273
  if (props.disclosure && props.disclosure.controls.trim().length === 0) {
193
274
  throw new Error('SButton: disclosure.controls must be a non-empty id')
194
275
  }
@@ -202,17 +283,42 @@ watchEffect(() => {
202
283
  })
203
284
 
204
285
  const isIconOnly = computed(() => props.iconOnly || (
205
- props.label === '' && Boolean(props.leadingIcon || props.trailingIcon || slots.default)
286
+ props.label === '' && Boolean(props.leadingIcon || props.trailingIcon || buttonSlots.default)
206
287
  ))
207
288
  const renderDefaultSlot = computed(() => props.label === undefined || (
208
289
  props.label === '' && !props.leadingIcon && !props.trailingIcon
209
290
  ))
210
291
  const progressIndicatorSize = computed<SProgressIndicatorSize>(() => props.size === 'lg' ? 'sm' : 'xs')
211
292
 
212
- const getElement = (): HTMLButtonElement => {
213
- const element = buttonRef.value
293
+ function setRootRef(target: Element | ComponentPublicInstance | null): void {
294
+ if (target === null) {
295
+ rootRef.value = null
296
+ return
297
+ }
298
+ if (target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement) {
299
+ rootRef.value = target
300
+ return
301
+ }
302
+ const element: unknown = Reflect.get(target, '$el')
303
+ if (!(element instanceof HTMLAnchorElement)) {
304
+ throw new TypeError('SButton: RouterLink root must resolve to an HTMLAnchorElement')
305
+ }
306
+ rootRef.value = element
307
+ }
308
+
309
+ function handleClick(event: MouseEvent): void {
310
+ if (navigationTarget.value && navigationUnavailable.value) {
311
+ event.preventDefault()
312
+ event.stopImmediatePropagation()
313
+ return
314
+ }
315
+ emit('click', event)
316
+ }
317
+
318
+ const getElement = (): HTMLButtonElement | HTMLAnchorElement => {
319
+ const element = rootRef.value
214
320
  if (!element) {
215
- throw new Error('SButton: native button element is unavailable')
321
+ throw new Error('SButton: interactive element is unavailable')
216
322
  }
217
323
  return element
218
324
  }
@@ -0,0 +1,9 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-combobox-anchor {
4
+ @apply w-full min-w-0;
5
+ }
6
+
7
+ .s-combobox-layer {
8
+ @apply max-w-[calc(100vw-1rem)];
9
+ }
@@ -0,0 +1,322 @@
1
+ <template>
2
+ <div ref="anchorRef" class="s-combobox-anchor">
3
+ <SInputText
4
+ ref="inputRef"
5
+ v-bind="ownedAttrs.bindings()"
6
+ :id="inputId"
7
+ :model-value="validatedSearch"
8
+ :label="label"
9
+ :placeholder="placeholder"
10
+ :disabled="disabled"
11
+ :readonly="readonly"
12
+ :required="required"
13
+ :invalid="invalid"
14
+ :error-message="errorMessage"
15
+ :help-text="helpText"
16
+ :size="size"
17
+ type="search"
18
+ autocomplete="off"
19
+ role="combobox"
20
+ aria-haspopup="listbox"
21
+ aria-autocomplete="list"
22
+ :aria-expanded="isOpen"
23
+ :aria-controls="listboxId"
24
+ :aria-activedescendant="activeDescendantId"
25
+ @update:model-value="onSearchUpdate"
26
+ @focus="open"
27
+ @click="open"
28
+ @keydown="onInputKeydown"
29
+ />
30
+ </div>
31
+ <teleport to="body">
32
+ <div v-if="isOpen" ref="panelRef" :style="floatingStyle" class="s-combobox-layer">
33
+ <SListbox
34
+ ref="listboxRef"
35
+ :id="listboxId"
36
+ :model-value="popupModelValue"
37
+ :options="filteredOptions"
38
+ :label="accessibleLabel"
39
+ :disabled="disabled"
40
+ :readonly="readonly"
41
+ :invalid="invalid"
42
+ :empty-label="emptyLabel"
43
+ focus-mode="activedescendant"
44
+ :active-value="activeValue"
45
+ @update:model-value="onListboxUpdate"
46
+ @update:active-value="activeValue = $event"
47
+ />
48
+ </div>
49
+ </teleport>
50
+ </template>
51
+
52
+ <script lang="ts">
53
+ import type {
54
+ SelectionEntry as PublicSelectionEntry,
55
+ SelectionOption as PublicSelectionOption,
56
+ SelectionOptionGroup as PublicSelectionOptionGroup,
57
+ } from '../_internal/selectionContract'
58
+ import type { SelectionKey as PublicSelectionKey } from '../_internal/useSelectionRoving'
59
+
60
+ export type SComboboxOption<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOption<TKey>
61
+ export type SComboboxOptionGroup<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOptionGroup<TKey>
62
+ export type SComboboxEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
63
+ export type SComboboxFilterMode = 'local' | 'manual'
64
+
65
+ export interface Props<TKey extends PublicSelectionKey = PublicSelectionKey> {
66
+ modelValue: TKey | null
67
+ search: string
68
+ options: readonly SComboboxEntry<TKey>[]
69
+ filterMode?: SComboboxFilterMode
70
+ label?: string
71
+ placeholder?: string
72
+ disabled?: boolean
73
+ readonly?: boolean
74
+ required?: boolean
75
+ invalid?: boolean
76
+ errorMessage?: string
77
+ helpText?: string
78
+ emptyLabel?: string
79
+ id?: string
80
+ size?: 'sm' | 'md' | 'lg'
81
+ }
82
+ </script>
83
+
84
+ <script setup lang="ts" generic="TKey extends SelectionKey = string">
85
+ import { computed, nextTick, onBeforeUnmount, shallowRef, useId, watch } from 'vue'
86
+ import { useFloatingPosition } from '../../../composables/useFloatingPosition'
87
+ import { registerLayer, type LayerRegistration } from '../../../internal/layerStack'
88
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
89
+ import {
90
+ validateBoolean,
91
+ validateDomId,
92
+ validateExactString,
93
+ validateNonEmptyString,
94
+ } from '../../../internal/runtimeContract'
95
+ import {
96
+ isSelectionModelList,
97
+ resolveSelectionInventory,
98
+ validateOptionalSelectionString,
99
+ validateSelectionModel,
100
+ type ResolvedSelectionInventory,
101
+ type SelectionEntry,
102
+ } from '../_internal/selectionContract'
103
+ import { selectionKeyToken, type SelectionKey } from '../_internal/useSelectionRoving'
104
+ import SInputText, { type SInputTextApi } from './SInputText.vue'
105
+ import SListbox, { type SListboxMoveIntent } from './SListbox.vue'
106
+
107
+ defineOptions({ inheritAttrs: false })
108
+
109
+ const props = withDefaults(defineProps<Props<TKey>>(), {
110
+ filterMode: 'local',
111
+ label: undefined,
112
+ placeholder: 'Начните вводить для поиска',
113
+ disabled: false,
114
+ readonly: false,
115
+ required: false,
116
+ invalid: false,
117
+ errorMessage: undefined,
118
+ helpText: undefined,
119
+ emptyLabel: 'Нет доступных вариантов',
120
+ id: undefined,
121
+ size: 'md',
122
+ })
123
+ const emit = defineEmits<{
124
+ 'update:modelValue': [value: TKey | null]
125
+ 'update:search': [value: string]
126
+ }>()
127
+ const ownedAttrs = useOwnedAttrs({ component: 'SCombobox', owner: 'editable combobox input' })
128
+ const generatedId = useId()
129
+ const inputRef = shallowRef<SInputTextApi | null>(null)
130
+ const listboxRef = shallowRef<{
131
+ initializeActive: () => void
132
+ moveActive: (intent: SListboxMoveIntent) => void
133
+ selectActive: () => void
134
+ } | null>(null)
135
+ const isOpen = shallowRef(false)
136
+ const activeValue = shallowRef<TKey | null>(null)
137
+ const layerZIndex = shallowRef('var(--s-layer-floating)')
138
+ let layerRegistration: LayerRegistration | null = null
139
+
140
+ const inputId = computed(() => props.id === undefined
141
+ ? `s-combobox-${generatedId}`
142
+ : validateDomId('SCombobox', 'id', props.id))
143
+ const listboxId = computed(() => `${inputId.value}-listbox`)
144
+ const validatedSearch = computed(() => {
145
+ if (typeof props.search !== 'string') throw new TypeError('SCombobox: search must be a string')
146
+ return props.search
147
+ })
148
+ const contract = computed(() => ({
149
+ filterMode: validateExactString(
150
+ 'SCombobox', 'filterMode', props.filterMode, ['local', 'manual'] as const,
151
+ ),
152
+ placeholder: validateNonEmptyString('SCombobox', 'placeholder', props.placeholder),
153
+ disabled: validateBoolean('SCombobox', 'disabled', props.disabled),
154
+ readonly: validateBoolean('SCombobox', 'readonly', props.readonly),
155
+ required: validateBoolean('SCombobox', 'required', props.required),
156
+ invalid: validateBoolean('SCombobox', 'invalid', props.invalid),
157
+ emptyLabel: validateNonEmptyString('SCombobox', 'emptyLabel', props.emptyLabel),
158
+ size: validateExactString('SCombobox', 'size', props.size, ['sm', 'md', 'lg'] as const),
159
+ label: validateOptionalSelectionString('SCombobox', 'label', props.label),
160
+ helpText: validateOptionalSelectionString('SCombobox', 'helpText', props.helpText),
161
+ errorMessage: validateOptionalSelectionString('SCombobox', 'errorMessage', props.errorMessage),
162
+ }))
163
+ const inventory = computed(() => {
164
+ const resolved = resolveSelectionInventory<TKey>('SCombobox', props.options)
165
+ validateSelectionModel('SCombobox', 'single', props.modelValue, resolved)
166
+ return resolved
167
+ })
168
+
169
+ function inventoryEntries(resolved: ResolvedSelectionInventory<TKey>): readonly SelectionEntry<TKey>[] {
170
+ return resolved.entries.map((entry) => entry.kind === 'option'
171
+ ? entry.record.option
172
+ : {
173
+ groupId: entry.groupId,
174
+ label: entry.label,
175
+ options: entry.records.map((record) => record.option),
176
+ })
177
+ }
178
+
179
+ const filteredOptions = computed<readonly SelectionEntry<TKey>[]>(() => {
180
+ const resolved = inventory.value
181
+ if (contract.value.filterMode === 'manual') return inventoryEntries(resolved)
182
+ const query = validatedSearch.value.trim().toLocaleLowerCase()
183
+ if (query.length === 0) return inventoryEntries(resolved)
184
+ return resolved.entries.flatMap<SelectionEntry<TKey>>((entry) => {
185
+ if (entry.kind === 'option') {
186
+ const searchable = `${entry.record.option.label} ${entry.record.option.title ?? ''}`.toLocaleLowerCase()
187
+ return searchable.includes(query) ? [entry.record.option] : []
188
+ }
189
+ const records = entry.records.filter((record) => (
190
+ `${record.option.label} ${record.option.title ?? ''}`.toLocaleLowerCase().includes(query)
191
+ ))
192
+ return records.length === 0 ? [] : [{
193
+ groupId: entry.groupId,
194
+ label: entry.label,
195
+ options: records.map((record) => record.option),
196
+ }]
197
+ })
198
+ })
199
+ const filteredInventory = computed(() => resolveSelectionInventory<TKey>(
200
+ 'SCombobox filtered options', filteredOptions.value,
201
+ ))
202
+ const popupModelValue = computed<TKey | null>(() => {
203
+ if (props.modelValue === null) return null
204
+ const token = selectionKeyToken('SCombobox', props.modelValue, 'modelValue')
205
+ return filteredInventory.value.optionsByToken.has(token) ? props.modelValue : null
206
+ })
207
+ const accessibleLabel = computed(() => {
208
+ const bindings = ownedAttrs.bindings()
209
+ const ariaLabel = bindings['aria-label']
210
+ if (typeof ariaLabel === 'string') return validateNonEmptyString('SCombobox', 'aria-label', ariaLabel)
211
+ return contract.value.label ?? contract.value.placeholder
212
+ })
213
+ const activeDescendantId = computed(() => {
214
+ if (!isOpen.value || activeValue.value === null) return undefined
215
+ const token = selectionKeyToken('SCombobox', activeValue.value, 'activeValue')
216
+ if (!filteredInventory.value.optionsByToken.has(token)) return undefined
217
+ return `${listboxId.value}-option-${encodeURIComponent(token)}`
218
+ })
219
+
220
+ watch(validatedSearch, () => {
221
+ activeValue.value = null
222
+ if (isOpen.value) nextTick(() => listboxRef.value?.initializeActive())
223
+ }, { flush: 'sync' })
224
+
225
+ const { anchorRef, panelRef, floatingStyle } = useFloatingPosition({
226
+ active: isOpen,
227
+ placement: () => 'bottom-start',
228
+ widthMatch: () => 'exact',
229
+ zIndex: () => layerZIndex.value,
230
+ })
231
+
232
+ type CloseReason = 'selection' | 'escape' | 'tab' | 'outside'
233
+
234
+ function inputElement(): HTMLInputElement {
235
+ const api = inputRef.value
236
+ if (!api) throw new Error('SCombobox: SInputText API is unavailable')
237
+ return api.getElement()
238
+ }
239
+
240
+ function close(reason: CloseReason): void {
241
+ if (!isOpen.value) return
242
+ void reason
243
+ isOpen.value = false
244
+ activeValue.value = null
245
+ layerRegistration?.unregister({ restoreFocus: false })
246
+ layerRegistration = null
247
+ layerZIndex.value = 'var(--s-layer-floating)'
248
+ }
249
+
250
+ async function open(): Promise<void> {
251
+ void inventory.value
252
+ if (contract.value.disabled || contract.value.readonly || isOpen.value) return
253
+ const input = inputElement()
254
+ isOpen.value = true
255
+ layerRegistration = registerLayer({
256
+ kind: 'listbox',
257
+ root: () => panelRef.value,
258
+ anchor: () => anchorRef.value,
259
+ restoreTarget: input,
260
+ requestDismiss: (reason) => close(reason === 'escape' ? 'escape' : 'outside'),
261
+ })
262
+ layerZIndex.value = layerRegistration.zIndex
263
+ await nextTick()
264
+ listboxRef.value?.initializeActive()
265
+ }
266
+
267
+ function onSearchUpdate(value: string): void {
268
+ emit('update:search', value)
269
+ activeValue.value = null
270
+ if (!isOpen.value) void open()
271
+ else nextTick(() => listboxRef.value?.initializeActive())
272
+ }
273
+
274
+ function onListboxUpdate(value: TKey | null | readonly TKey[]): void {
275
+ if (isSelectionModelList(value)) {
276
+ throw new TypeError('SCombobox: selection update must be a single key or null')
277
+ }
278
+ validateSelectionModel('SCombobox update', 'single', value, inventory.value)
279
+ emit('update:modelValue', value)
280
+ if (value !== null) {
281
+ const token = selectionKeyToken('SCombobox', value, 'selected value')
282
+ const option = inventory.value.optionsByToken.get(token)
283
+ if (!option) throw new Error('SCombobox: selected option identity is stale')
284
+ emit('update:search', option.option.label)
285
+ }
286
+ close('selection')
287
+ }
288
+
289
+ async function onInputKeydown(event: KeyboardEvent): Promise<void> {
290
+ const intents: Partial<Record<string, SListboxMoveIntent>> = {
291
+ ArrowDown: 'next',
292
+ ArrowUp: 'previous',
293
+ Home: 'first',
294
+ End: 'last',
295
+ }
296
+ const intent = intents[event.key]
297
+ if (intent) {
298
+ event.preventDefault()
299
+ const wasOpen = isOpen.value
300
+ await open()
301
+ await nextTick()
302
+ if (!wasOpen && (intent === 'next' || intent === 'previous')) return
303
+ listboxRef.value?.moveActive(intent)
304
+ return
305
+ }
306
+ if (event.key === 'Enter' && isOpen.value) {
307
+ event.preventDefault()
308
+ listboxRef.value?.selectActive()
309
+ return
310
+ }
311
+ if (event.key === 'Escape' && isOpen.value) {
312
+ event.preventDefault()
313
+ close('escape')
314
+ return
315
+ }
316
+ if (event.key === 'Tab') close('tab')
317
+ }
318
+
319
+ onBeforeUnmount(() => layerRegistration?.unregister({ restoreFocus: false }))
320
+ </script>
321
+
322
+ <style lang="postcss" src="./SCombobox.css" scoped></style>
@@ -40,6 +40,9 @@
40
40
  :aria-describedby="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
41
41
  @input="onInput"
42
42
  @change="onChange"
43
+ @focus="emit('focus', $event)"
44
+ @click="emit('click', $event)"
45
+ @keydown="emit('keydown', $event)"
43
46
  />
44
47
  <div v-if="trailingKind" class="s-input-trailing" :data-kind="trailingKind">
45
48
  <span v-if="trailingIcon" class="s-input-trailing__passive" aria-hidden="true">
@@ -72,6 +75,7 @@
72
75
 
73
76
  <script setup lang="ts">
74
77
  import { computed, ref, useId, watchEffect, type Component } from 'vue'
78
+ import type { InteractiveElementApi } from '../../../internal/interactiveElement'
75
79
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
76
80
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
77
81
  import { resolveFieldSurfaceBindings, type FieldSurfaceBindings } from '../../../internal/fieldSurface'
@@ -177,6 +181,9 @@ export interface SInputTextTrailingAction {
177
181
  loading?: boolean
178
182
  }
179
183
 
184
+ /** Native input owner API for focus-managed compound controls. */
185
+ export type SInputTextApi = InteractiveElementApi<HTMLInputElement>
186
+
180
187
  const props = withDefaults(defineProps<Props>(), {
181
188
  modelValue: '',
182
189
  modelModifiers: () => ({}),
@@ -206,6 +213,9 @@ const props = withDefaults(defineProps<Props>(), {
206
213
  const emit = defineEmits<{
207
214
  'update:modelValue': [value: string]
208
215
  'trailing-action': [event: MouseEvent]
216
+ focus: [event: FocusEvent]
217
+ click: [event: MouseEvent]
218
+ keydown: [event: KeyboardEvent]
209
219
  }>()
210
220
  const ownedAttrs = useOwnedAttrs({ component: 'SInputText', owner: 'native input' })
211
221
  useInteractiveLeafRegistration({ owner: 'SInputText' })
@@ -213,6 +223,11 @@ useInteractiveLeafRegistration({ owner: 'SInputText' })
213
223
  const inputRef = ref<HTMLInputElement | null>(null)
214
224
  defineExpose({
215
225
  focus: () => inputRef.value?.focus(),
226
+ getElement: () => {
227
+ const element = inputRef.value
228
+ if (!element) throw new Error('SInputText: native input element is unavailable')
229
+ return element
230
+ },
216
231
  })
217
232
 
218
233
  const generatedInputId = useId()
@@ -2,6 +2,7 @@
2
2
  import { computed, watchEffect, type VNodeChild } from 'vue'
3
3
  import { RouterLink, type RouteLocationRaw } from 'vue-router'
4
4
  import type { STooltipTriggerBinding } from '../data-display/STooltip.vue'
5
+ import { hasOwn } from '../../../internal/es2020'
5
6
  import { resolveLinkTarget } from '../../../internal/linkTarget'
6
7
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
7
8
  import {
@@ -195,7 +196,7 @@ const validatedTooltipTrigger = computed<STooltipTriggerBinding | undefined>(()
195
196
  }
196
197
  const record = candidate as Record<string, unknown>
197
198
  const unexpected = Object.keys(record).filter((key) => !TOOLTIP_TRIGGER_KEYS.includes(key as typeof TOOLTIP_TRIGGER_KEYS[number]))
198
- const missing = TOOLTIP_TRIGGER_KEYS.filter((key) => !Object.hasOwn(record, key))
199
+ const missing = TOOLTIP_TRIGGER_KEYS.filter((key) => !hasOwn(record, key))
199
200
  if (unexpected.length > 0 || missing.length > 0) {
200
201
  throw new Error(`SInteractiveSurface: tooltipTrigger имеет недопустимый набор ключей; missing=${missing.join(',')}; unexpected=${unexpected.join(',')}. / SInteractiveSurface: tooltipTrigger has an invalid key set; missing=${missing.join(',')}; unexpected=${unexpected.join(',')}.`)
201
202
  }