@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.
- package/README.md +5 -5
- package/docs/public-api.md +8 -0
- package/package.json +15 -3
- package/src/components/layout/SAppShell.vue +3 -2
- package/src/components/layout/SStack.css +9 -3
- package/src/components/layout/SStack.vue +10 -2
- package/src/components/shared/complex/STree.vue +29 -4
- package/src/components/shared/complex/treeAdapter.ts +2 -1
- package/src/components/shared/complex/types.ts +1 -12
- package/src/components/shared/containers/SLeftSidebar.vue +7 -2
- package/src/components/shared/containers/SPageHeader.css +11 -0
- package/src/components/shared/containers/SPageHeader.vue +7 -1
- package/src/components/shared/containers/SPopover.vue +2 -1
- package/src/components/shared/containers/SSidebarGroup.vue +10 -5
- package/src/components/shared/containers/sidebar.ts +2 -1
- package/src/components/shared/controls/SButton.css +13 -0
- package/src/components/shared/controls/SButton.vue +135 -29
- package/src/components/shared/controls/SCombobox.css +9 -0
- package/src/components/shared/controls/SCombobox.vue +322 -0
- package/src/components/shared/controls/SInputText.vue +15 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +2 -1
- package/src/components/shared/controls/SLink.ts +1 -17
- package/src/components/shared/controls/SLink.vue +26 -2
- package/src/components/shared/controls/SListbox.vue +99 -1
- package/src/components/shared/controls/SListboxOption.vue +15 -2
- package/src/components/shared/controls/SSwitch.css +48 -0
- package/src/components/shared/controls/SSwitch.vue +109 -0
- package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +6 -3
- package/src/components/shared/data-display/SChip.css +59 -0
- package/src/components/shared/data-display/SChip.vue +92 -0
- package/src/components/shared/data-display/SDocBlock.vue +10 -10
- package/src/components/shared/data-display/SLinkedSystemsList.vue +3 -3
- package/src/components/shared/data-display/STable.vue +150 -14
- package/src/components/shared/data-display/STooltip.vue +2 -1
- package/src/components/shared/data-display/SVirtualList.ts +1 -16
- package/src/components/shared/data-display/SVirtualList.vue +18 -2
- package/src/components/shared/data-display/json.ts +1 -1
- package/src/components/shared/data-display/table.ts +9 -28
- package/src/components/shared/database/SDataGrid.vue +29 -8
- package/src/components/shared/database/SSqlEditor.vue +3 -2
- package/src/components/shared/database/dataGrid.ts +1 -17
- package/src/components/shared/navigation/SBottomNav.vue +3 -0
- package/src/components/shared/navigation/STabs.vue +2 -1
- package/src/composables/uiPreferencesReset.ts +2 -1
- package/src/composables/useClipboard.ts +2 -1
- package/src/composables/useSidebarPanelState.ts +3 -2
- package/src/internal/es2020.ts +50 -0
- package/src/internal/inlineTokenEditorContract.ts +4 -3
- package/src/internal/ownedAttrs.ts +24 -2
- package/src/internal/pointerInteractionLease.ts +2 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
55
|
+
</component>
|
|
56
56
|
</template>
|
|
57
57
|
|
|
58
58
|
<script setup lang="ts">
|
|
59
|
-
import { computed, ref, watchEffect } from 'vue'
|
|
60
|
-
import type { Component,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
href: undefined,
|
|
173
|
+
to: undefined,
|
|
174
|
+
target: undefined,
|
|
175
|
+
rel: undefined,
|
|
176
|
+
type: undefined,
|
|
147
177
|
})
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
default?: () => VNodeChild
|
|
178
|
+
const emit = defineEmits<{
|
|
179
|
+
click: [event: MouseEvent]
|
|
151
180
|
}>()
|
|
152
|
-
const
|
|
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:
|
|
191
|
+
element: rootRef,
|
|
157
192
|
disabled: () => props.disabled || props.loading,
|
|
158
193
|
})
|
|
159
194
|
|
|
160
195
|
useInteractiveLeafRegistration({ owner: 'SButton' })
|
|
161
196
|
|
|
162
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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 && !
|
|
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
|
-
? { ...
|
|
182
|
-
:
|
|
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 ||
|
|
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
|
-
|
|
213
|
-
|
|
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:
|
|
321
|
+
throw new Error('SButton: interactive element is unavailable')
|
|
216
322
|
}
|
|
217
323
|
return element
|
|
218
324
|
}
|
|
@@ -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) => !
|
|
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
|
}
|