@pgcorp/ui-kit 0.1.2 → 0.3.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.
- package/README.md +5 -5
- package/package.json +19 -3
- package/src/components/layout/SSideMenuButton.vue +5 -4
- package/src/components/layout/SSideMenuRail.vue +3 -6
- package/src/components/layout/SStack.css +9 -3
- package/src/components/layout/SStack.vue +10 -2
- package/src/components/shared/_internal/STreeNode.vue +10 -4
- package/src/components/shared/_internal/sidebarGroupContext.ts +3 -1
- package/src/components/shared/_internal/tabsContext.ts +1 -0
- package/src/components/shared/containers/SLeftSidebar.vue +2 -2
- package/src/components/shared/containers/SPageHeader.css +11 -0
- package/src/components/shared/containers/SPageHeader.vue +7 -1
- package/src/components/shared/containers/SPanel.css +7 -1
- package/src/components/shared/containers/SPanel.vue +5 -1
- package/src/components/shared/containers/SSidebarGroup.css +1 -1
- package/src/components/shared/containers/SSidebarGroup.vue +113 -94
- package/src/components/shared/containers/SSidebarSection.css +16 -4
- package/src/components/shared/containers/SSidebarSection.vue +15 -16
- package/src/components/shared/controls/SButton.css +39 -0
- package/src/components/shared/controls/SButton.vue +146 -29
- package/src/components/shared/controls/SCombobox.css +9 -0
- package/src/components/shared/controls/SCombobox.vue +322 -0
- package/src/components/shared/controls/SContextToggleButton.vue +1 -1
- package/src/components/shared/controls/SInputText.vue +15 -0
- package/src/components/shared/controls/SInteractiveSurface.css +4 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +4 -0
- package/src/components/shared/controls/SListbox.vue +99 -1
- package/src/components/shared/controls/SListboxOption.vue +15 -2
- package/src/components/shared/controls/SRange.css +35 -0
- package/src/components/shared/controls/SRange.vue +129 -0
- 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/SCodeBlock.vue +1 -1
- package/src/components/shared/data-display/SCodeEditor.css +15 -0
- package/src/components/shared/data-display/SCodeEditor.vue +14 -0
- package/src/components/shared/data-display/STable.vue +107 -3
- package/src/components/shared/data-display/table.ts +9 -0
- package/src/components/shared/navigation/SBottomNav.vue +3 -0
- package/src/components/shared/navigation/STab.vue +31 -6
- package/src/components/shared/navigation/STabs.vue +8 -2
- package/src/internal/codeEditorContract.ts +1 -1
- package/src/internal/ownedAttrs.ts +31 -2
|
@@ -1,20 +1,24 @@
|
|
|
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"
|
|
8
9
|
:data-width="width"
|
|
9
10
|
:data-content-align="contentAlign"
|
|
11
|
+
:data-density="density"
|
|
12
|
+
:data-spacing="spacing"
|
|
13
|
+
:data-hover-surface="hoverSurface"
|
|
10
14
|
:data-icon-only="isIconOnly"
|
|
15
|
+
:data-icon-size="iconSize"
|
|
11
16
|
:data-appearance="appearance"
|
|
12
17
|
:data-shape="shape"
|
|
13
18
|
:data-loading="loading || undefined"
|
|
14
19
|
:aria-pressed="pressed"
|
|
15
20
|
:aria-busy="busy || loading || undefined"
|
|
16
|
-
|
|
17
|
-
:type="type"
|
|
21
|
+
@click="handleClick"
|
|
18
22
|
>
|
|
19
23
|
<span v-if="loading" class="s-button__loader" aria-hidden="true">
|
|
20
24
|
<SProgressIndicator
|
|
@@ -33,7 +37,7 @@
|
|
|
33
37
|
<component :is="leadingIcon" v-else-if="leadingIcon" class="s-button__icon s-button__icon--leading" aria-hidden="true" />
|
|
34
38
|
<span v-if="label" class="s-button__label">{{ label }}</span>
|
|
35
39
|
<PassiveContentBoundary v-if="renderDefaultSlot" :contract="passiveContentContract">
|
|
36
|
-
<
|
|
40
|
+
<component :is="defaultContentComponent" />
|
|
37
41
|
</PassiveContentBoundary>
|
|
38
42
|
<span v-if="badge !== undefined" class="s-button__badge-slot">
|
|
39
43
|
<SBadge :severity="badgeSeverity" size="xs">{{ badge }}</SBadge>
|
|
@@ -48,14 +52,16 @@
|
|
|
48
52
|
><ChevronRight /></span>
|
|
49
53
|
<component :is="trailingIcon" v-else-if="trailingIcon" class="s-button__icon s-button__icon--trailing" aria-hidden="true" />
|
|
50
54
|
</span>
|
|
51
|
-
</
|
|
55
|
+
</component>
|
|
52
56
|
</template>
|
|
53
57
|
|
|
54
58
|
<script setup lang="ts">
|
|
55
|
-
import { computed, ref, watchEffect } from 'vue'
|
|
56
|
-
import type { Component,
|
|
59
|
+
import { computed, getCurrentInstance, ref, watchEffect } from 'vue'
|
|
60
|
+
import type { Component, ComponentPublicInstance } from 'vue'
|
|
61
|
+
import { RouterLink } from 'vue-router'
|
|
57
62
|
import { ChevronRight } from '../../icons/sputnigUiIcons'
|
|
58
63
|
import type { InteractiveElementApi } from '../../../internal/interactiveElement'
|
|
64
|
+
import { resolveLinkTarget } from '../../../internal/linkTarget'
|
|
59
65
|
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
60
66
|
import {
|
|
61
67
|
PassiveContentBoundary,
|
|
@@ -65,13 +71,21 @@ import {
|
|
|
65
71
|
import { useTreeActionRegistration } from '../_internal/treeRuntime'
|
|
66
72
|
import SBadge, { type BadgeSeverity } from '../data-display/SBadge.vue'
|
|
67
73
|
import SProgressIndicator, { type SProgressIndicatorSize } from '../data-display/SProgressIndicator.vue'
|
|
74
|
+
import type {
|
|
75
|
+
HrefLinkDestination,
|
|
76
|
+
RouterLinkDestination,
|
|
77
|
+
} from './SLink'
|
|
68
78
|
|
|
69
79
|
defineOptions({ inheritAttrs: false })
|
|
70
80
|
|
|
71
81
|
export type SButtonSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warn' | 'danger' | 'contrast'
|
|
72
82
|
export type SButtonSize = 'xs' | 'sm' | 'md' | 'lg'
|
|
83
|
+
export type SButtonIconSize = 'default' | 'large'
|
|
84
|
+
export type SButtonDensity = 'default' | 'compact'
|
|
85
|
+
export type SButtonSpacing = 'default' | 'tight'
|
|
86
|
+
export type SButtonHoverSurface = 'self' | 'parent'
|
|
73
87
|
export type SButtonWidth = 'auto' | 'fit' | 'full'
|
|
74
|
-
export type SButtonContentAlign = 'center' | 'start' | 'between'
|
|
88
|
+
export type SButtonContentAlign = 'center' | 'start' | 'end' | 'between'
|
|
75
89
|
export type SButtonAppearance = 'solid' | 'soft' | 'outline' | 'ghost' | 'text'
|
|
76
90
|
export type SButtonShape = 'default' | 'pill' | 'square' | 'circle' | 'row'
|
|
77
91
|
export type SButtonNativeType = 'button' | 'reset' | 'submit'
|
|
@@ -81,10 +95,10 @@ export interface SButtonDisclosure {
|
|
|
81
95
|
iconPlacement?: 'leading' | 'trailing' | 'none'
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
/** Typed
|
|
85
|
-
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>
|
|
86
100
|
|
|
87
|
-
|
|
101
|
+
interface SButtonPresentationProps {
|
|
88
102
|
label?: string
|
|
89
103
|
severity?: SButtonSeverity
|
|
90
104
|
size?: SButtonSize
|
|
@@ -93,6 +107,10 @@ export interface Props {
|
|
|
93
107
|
loadingLabel?: string
|
|
94
108
|
leadingIcon?: Component
|
|
95
109
|
trailingIcon?: Component
|
|
110
|
+
iconSize?: SButtonIconSize
|
|
111
|
+
density?: SButtonDensity
|
|
112
|
+
spacing?: SButtonSpacing
|
|
113
|
+
hoverSurface?: SButtonHoverSurface
|
|
96
114
|
badge?: string | number
|
|
97
115
|
badgeSeverity?: BadgeSeverity
|
|
98
116
|
shortcut?: string
|
|
@@ -104,9 +122,24 @@ export interface Props {
|
|
|
104
122
|
pressed?: boolean | 'mixed'
|
|
105
123
|
busy?: boolean
|
|
106
124
|
disclosure?: SButtonDisclosure
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type SButtonActionProps = SButtonPresentationProps & {
|
|
128
|
+
href?: never
|
|
129
|
+
to?: never
|
|
130
|
+
target?: never
|
|
131
|
+
rel?: never
|
|
107
132
|
type?: SButtonNativeType
|
|
108
133
|
}
|
|
109
134
|
|
|
135
|
+
export type SButtonNavigationProps = SButtonPresentationProps & (
|
|
136
|
+
| (HrefLinkDestination & { type?: never })
|
|
137
|
+
| (RouterLinkDestination & { type?: never })
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
/** Strict action/navigation XOR with a shared button presentation. */
|
|
141
|
+
export type Props = SButtonActionProps | SButtonNavigationProps
|
|
142
|
+
|
|
110
143
|
const props = withDefaults(defineProps<Props>(), {
|
|
111
144
|
label: undefined,
|
|
112
145
|
severity: 'primary',
|
|
@@ -116,6 +149,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
116
149
|
loadingLabel: undefined,
|
|
117
150
|
leadingIcon: undefined,
|
|
118
151
|
trailingIcon: undefined,
|
|
152
|
+
iconSize: 'default',
|
|
153
|
+
density: 'default',
|
|
154
|
+
spacing: 'default',
|
|
155
|
+
hoverSurface: 'self',
|
|
119
156
|
badge: undefined,
|
|
120
157
|
badgeSeverity: 'secondary',
|
|
121
158
|
shortcut: undefined,
|
|
@@ -127,23 +164,57 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
127
164
|
pressed: undefined,
|
|
128
165
|
busy: false,
|
|
129
166
|
disclosure: undefined,
|
|
130
|
-
|
|
167
|
+
href: undefined,
|
|
168
|
+
to: undefined,
|
|
169
|
+
target: undefined,
|
|
170
|
+
rel: undefined,
|
|
171
|
+
type: undefined,
|
|
131
172
|
})
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
default?: () => VNodeChild
|
|
173
|
+
const emit = defineEmits<{
|
|
174
|
+
click: [event: MouseEvent]
|
|
135
175
|
}>()
|
|
136
|
-
const
|
|
176
|
+
const componentInstance = getCurrentInstance()
|
|
177
|
+
if (!componentInstance) throw new Error('SButton: component instance is unavailable')
|
|
178
|
+
const buttonSlots = componentInstance.slots
|
|
179
|
+
const defaultContentComponent = (): ReturnType<NonNullable<typeof buttonSlots.default>> => (
|
|
180
|
+
buttonSlots.default?.() ?? []
|
|
181
|
+
)
|
|
182
|
+
const rootRef = ref<HTMLButtonElement | HTMLAnchorElement | null>(null)
|
|
137
183
|
const ownedAttrs = useOwnedAttrs({ component: 'SButton', owner: 'native button' })
|
|
138
184
|
const isRegisteredTreeAction = useTreeActionRegistration({
|
|
139
185
|
owner: 'SButton',
|
|
140
|
-
element:
|
|
186
|
+
element: rootRef,
|
|
141
187
|
disabled: () => props.disabled || props.loading,
|
|
142
188
|
})
|
|
143
189
|
|
|
144
190
|
useInteractiveLeafRegistration({ owner: 'SButton' })
|
|
145
191
|
|
|
146
|
-
const
|
|
192
|
+
const navigationTarget = computed(() => {
|
|
193
|
+
const hasHref = props.href !== undefined
|
|
194
|
+
const hasTo = props.to !== undefined
|
|
195
|
+
if (hasHref && hasTo) {
|
|
196
|
+
throw new TypeError('SButton: передайте не более одного navigation target — href или to')
|
|
197
|
+
}
|
|
198
|
+
if (!hasHref && !hasTo) return null
|
|
199
|
+
if (props.type !== undefined) {
|
|
200
|
+
throw new TypeError('SButton: navigation target несовместим с native button type')
|
|
201
|
+
}
|
|
202
|
+
return resolveLinkTarget({
|
|
203
|
+
href: props.href,
|
|
204
|
+
to: props.to,
|
|
205
|
+
target: props.target,
|
|
206
|
+
rel: props.rel,
|
|
207
|
+
owner: 'SButton',
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
const navigationUnavailable = computed(() => props.disabled || props.loading)
|
|
211
|
+
const rootComponent = computed(() => {
|
|
212
|
+
const target = navigationTarget.value
|
|
213
|
+
if (!target || navigationUnavailable.value) return target ? 'a' : 'button'
|
|
214
|
+
return target.kind === 'router' ? RouterLink : 'a'
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const rootBindings = computed(() => {
|
|
147
218
|
const bindings = ownedAttrs.bindings()
|
|
148
219
|
const managedBindings = isRegisteredTreeAction ? { ...bindings, tabindex: -1 } : bindings
|
|
149
220
|
const semanticBindings = props.disclosure
|
|
@@ -153,17 +224,31 @@ const buttonBindings = computed(() => {
|
|
|
153
224
|
'aria-controls': props.disclosure.controls,
|
|
154
225
|
}
|
|
155
226
|
: managedBindings
|
|
156
|
-
|
|
227
|
+
const target = navigationTarget.value
|
|
228
|
+
const elementBindings = target === null
|
|
229
|
+
? {
|
|
230
|
+
...semanticBindings,
|
|
231
|
+
disabled: props.disabled || props.loading || undefined,
|
|
232
|
+
type: props.type ?? 'button',
|
|
233
|
+
}
|
|
234
|
+
: navigationUnavailable.value
|
|
235
|
+
? {
|
|
236
|
+
...semanticBindings,
|
|
237
|
+
'aria-disabled': true,
|
|
238
|
+
tabindex: -1,
|
|
239
|
+
}
|
|
240
|
+
: { ...semanticBindings, ...target.bindings }
|
|
241
|
+
if (!props.loading) return elementBindings
|
|
157
242
|
|
|
158
|
-
const externalAccessibleName =
|
|
243
|
+
const externalAccessibleName = elementBindings['aria-label']
|
|
159
244
|
const accessibleName = [props.loadingLabel, props.label, externalAccessibleName]
|
|
160
245
|
.find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0)
|
|
161
|
-
if (!accessibleName && !
|
|
246
|
+
if (!accessibleName && !buttonSlots.default) {
|
|
162
247
|
throw new Error('SButton: loading action requires label, loadingLabel, or aria-label')
|
|
163
248
|
}
|
|
164
249
|
return accessibleName
|
|
165
|
-
? { ...
|
|
166
|
-
:
|
|
250
|
+
? { ...elementBindings, 'aria-label': accessibleName }
|
|
251
|
+
: elementBindings
|
|
167
252
|
})
|
|
168
253
|
|
|
169
254
|
const passiveContentContract: PassiveContentContract = {
|
|
@@ -173,6 +258,13 @@ const passiveContentContract: PassiveContentContract = {
|
|
|
173
258
|
}
|
|
174
259
|
|
|
175
260
|
watchEffect(() => {
|
|
261
|
+
const target = navigationTarget.value
|
|
262
|
+
if (target && props.pressed !== undefined) {
|
|
263
|
+
throw new Error('SButton: navigation target несовместим с pressed toggle semantics')
|
|
264
|
+
}
|
|
265
|
+
if (target && props.disclosure !== undefined) {
|
|
266
|
+
throw new Error('SButton: navigation target несовместим с disclosure trigger semantics')
|
|
267
|
+
}
|
|
176
268
|
if (props.disclosure && props.disclosure.controls.trim().length === 0) {
|
|
177
269
|
throw new Error('SButton: disclosure.controls must be a non-empty id')
|
|
178
270
|
}
|
|
@@ -186,17 +278,42 @@ watchEffect(() => {
|
|
|
186
278
|
})
|
|
187
279
|
|
|
188
280
|
const isIconOnly = computed(() => props.iconOnly || (
|
|
189
|
-
props.label === '' && Boolean(props.leadingIcon || props.trailingIcon ||
|
|
281
|
+
props.label === '' && Boolean(props.leadingIcon || props.trailingIcon || buttonSlots.default)
|
|
190
282
|
))
|
|
191
283
|
const renderDefaultSlot = computed(() => props.label === undefined || (
|
|
192
284
|
props.label === '' && !props.leadingIcon && !props.trailingIcon
|
|
193
285
|
))
|
|
194
286
|
const progressIndicatorSize = computed<SProgressIndicatorSize>(() => props.size === 'lg' ? 'sm' : 'xs')
|
|
195
287
|
|
|
196
|
-
|
|
197
|
-
|
|
288
|
+
function setRootRef(target: Element | ComponentPublicInstance | null): void {
|
|
289
|
+
if (target === null) {
|
|
290
|
+
rootRef.value = null
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement) {
|
|
294
|
+
rootRef.value = target
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const element: unknown = Reflect.get(target, '$el')
|
|
298
|
+
if (!(element instanceof HTMLAnchorElement)) {
|
|
299
|
+
throw new TypeError('SButton: RouterLink root must resolve to an HTMLAnchorElement')
|
|
300
|
+
}
|
|
301
|
+
rootRef.value = element
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function handleClick(event: MouseEvent): void {
|
|
305
|
+
if (navigationTarget.value && navigationUnavailable.value) {
|
|
306
|
+
event.preventDefault()
|
|
307
|
+
event.stopImmediatePropagation()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
emit('click', event)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const getElement = (): HTMLButtonElement | HTMLAnchorElement => {
|
|
314
|
+
const element = rootRef.value
|
|
198
315
|
if (!element) {
|
|
199
|
-
throw new Error('SButton:
|
|
316
|
+
throw new Error('SButton: interactive element is unavailable')
|
|
200
317
|
}
|
|
201
318
|
return element
|
|
202
319
|
}
|
|
@@ -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>
|