@pgcorp/ui-kit 0.2.0 → 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 +15 -3
- package/src/components/layout/SStack.css +9 -3
- package/src/components/layout/SStack.vue +10 -2
- package/src/components/shared/containers/SPageHeader.css +11 -0
- package/src/components/shared/containers/SPageHeader.vue +7 -1
- package/src/components/shared/controls/SButton.css +13 -0
- package/src/components/shared/controls/SButton.vue +130 -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/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/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/internal/ownedAttrs.ts +24 -2
|
@@ -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()
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
:data-variant="resolvedVariant"
|
|
7
7
|
:data-invalid="resolvedInvalid || undefined"
|
|
8
8
|
:data-readonly="resolvedReadonly || undefined"
|
|
9
|
+
:data-focus-mode="resolvedFocusMode"
|
|
9
10
|
role="listbox"
|
|
10
11
|
:aria-label="resolvedLabel"
|
|
11
12
|
:aria-multiselectable="resolvedSelectionMode === 'multiple' || undefined"
|
|
@@ -17,23 +18,31 @@
|
|
|
17
18
|
<SListboxOption
|
|
18
19
|
v-if="resolvedClearOptionLabel"
|
|
19
20
|
:registration-token="CLEAR_OPTION_TOKEN"
|
|
21
|
+
:id="optionId(CLEAR_OPTION_TOKEN)"
|
|
20
22
|
:label="resolvedClearOptionLabel"
|
|
21
23
|
:title="resolvedClearOptionLabel"
|
|
22
24
|
:selected="modelValue === null"
|
|
23
25
|
:disabled="resolvedDisabled"
|
|
24
26
|
:appearance="optionAppearance"
|
|
27
|
+
:active="activeToken === CLEAR_OPTION_TOKEN"
|
|
28
|
+
:focus-on-select="resolvedFocusMode === 'roving'"
|
|
29
|
+
@hover="setActiveToken(CLEAR_OPTION_TOKEN)"
|
|
25
30
|
@select="clearSelection"
|
|
26
31
|
/>
|
|
27
32
|
<template v-for="entry in inventory.entries" :key="entry.token">
|
|
28
33
|
<SListboxOption
|
|
29
34
|
v-if="entry.kind === 'option'"
|
|
30
35
|
:registration-token="entry.record.token"
|
|
36
|
+
:id="optionId(entry.record.token)"
|
|
31
37
|
:label="entry.record.option.label"
|
|
32
38
|
:title="entry.record.option.title ?? entry.record.option.label"
|
|
33
39
|
:selected="selectedTokens.has(entry.record.token)"
|
|
34
40
|
:disabled="resolvedDisabled || entry.record.option.disabled"
|
|
35
41
|
:marker="entry.record.option.marker"
|
|
36
42
|
:appearance="optionAppearance"
|
|
43
|
+
:active="activeToken === entry.record.token"
|
|
44
|
+
:focus-on-select="resolvedFocusMode === 'roving'"
|
|
45
|
+
@hover="setActiveToken(entry.record.token)"
|
|
37
46
|
@select="selectOption(entry.record.token)"
|
|
38
47
|
/>
|
|
39
48
|
<SListboxGroup
|
|
@@ -46,12 +55,16 @@
|
|
|
46
55
|
v-for="record in entry.records"
|
|
47
56
|
:key="record.token"
|
|
48
57
|
:registration-token="record.token"
|
|
58
|
+
:id="optionId(record.token)"
|
|
49
59
|
:label="record.option.label"
|
|
50
60
|
:title="record.option.title ?? record.option.label"
|
|
51
61
|
:selected="selectedTokens.has(record.token)"
|
|
52
62
|
:disabled="resolvedDisabled || record.option.disabled"
|
|
53
63
|
:marker="record.option.marker"
|
|
54
64
|
:appearance="optionAppearance"
|
|
65
|
+
:active="activeToken === record.token"
|
|
66
|
+
:focus-on-select="resolvedFocusMode === 'roving'"
|
|
67
|
+
@hover="setActiveToken(record.token)"
|
|
55
68
|
@select="selectOption(record.token)"
|
|
56
69
|
/>
|
|
57
70
|
</SListboxGroup>
|
|
@@ -73,6 +86,8 @@ import type {
|
|
|
73
86
|
} from '../_internal/selectionContract'
|
|
74
87
|
|
|
75
88
|
export type SListboxVariant = 'select' | 'color'
|
|
89
|
+
export type SListboxFocusMode = 'roving' | 'activedescendant'
|
|
90
|
+
export type SListboxMoveIntent = 'next' | 'previous' | 'first' | 'last'
|
|
76
91
|
export type SListboxOption<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOption<TKey>
|
|
77
92
|
export type SListboxOptionGroup<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOptionGroup<TKey>
|
|
78
93
|
export type SListboxEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
|
|
@@ -106,6 +121,8 @@ interface SharedProps<TKey extends PublicSelectionKey> {
|
|
|
106
121
|
emptyLabel?: string
|
|
107
122
|
clearOptionLabel?: string
|
|
108
123
|
id?: string
|
|
124
|
+
focusMode?: SListboxFocusMode
|
|
125
|
+
activeValue?: TKey | null
|
|
109
126
|
}
|
|
110
127
|
|
|
111
128
|
export type SListboxProps<
|
|
@@ -154,9 +171,12 @@ const props = withDefaults(defineProps<ComponentProps<TKey>>(), {
|
|
|
154
171
|
emptyLabel: 'Нет доступных вариантов',
|
|
155
172
|
clearOptionLabel: undefined,
|
|
156
173
|
id: undefined,
|
|
174
|
+
focusMode: 'roving',
|
|
175
|
+
activeValue: undefined,
|
|
157
176
|
})
|
|
158
177
|
const emit = defineEmits<{
|
|
159
178
|
'update:modelValue': [value: TKey | null] | [value: readonly TKey[]]
|
|
179
|
+
'update:activeValue': [value: TKey | null]
|
|
160
180
|
'option-keydown': [payload: SListboxOptionKeydownPayload<TKey>]
|
|
161
181
|
escape: []
|
|
162
182
|
tab: [event: KeyboardEvent]
|
|
@@ -187,6 +207,9 @@ const contract = computed(() => {
|
|
|
187
207
|
disabled: validateBoolean('SListbox', 'disabled', props.disabled),
|
|
188
208
|
readonly: validateBoolean('SListbox', 'readonly', props.readonly),
|
|
189
209
|
invalid: validateBoolean('SListbox', 'invalid', props.invalid),
|
|
210
|
+
focusMode: validateExactString(
|
|
211
|
+
'SListbox', 'focusMode', props.focusMode, ['roving', 'activedescendant'] as const,
|
|
212
|
+
),
|
|
190
213
|
selectionMode,
|
|
191
214
|
clearOptionLabel,
|
|
192
215
|
}
|
|
@@ -199,6 +222,7 @@ const resolvedDisabled = computed(() => contract.value.disabled)
|
|
|
199
222
|
const resolvedReadonly = computed(() => contract.value.readonly)
|
|
200
223
|
const resolvedInvalid = computed(() => contract.value.invalid)
|
|
201
224
|
const resolvedSelectionMode = computed(() => contract.value.selectionMode)
|
|
225
|
+
const resolvedFocusMode = computed(() => contract.value.focusMode)
|
|
202
226
|
const resolvedClearOptionLabel = computed(() => contract.value.clearOptionLabel)
|
|
203
227
|
const selectionState = computed(() => {
|
|
204
228
|
const resolvedInventory = resolveSelectionInventory<TKey>('SListbox', props.options)
|
|
@@ -213,6 +237,25 @@ const inventory = computed(() => selectionState.value.inventory)
|
|
|
213
237
|
const selectedTokens = computed(() => selectionState.value.selectedTokens)
|
|
214
238
|
const optionAppearance = computed(() => resolvedVariant.value === 'color' ? 'color' : 'select')
|
|
215
239
|
const CLEAR_OPTION_TOKEN = 'clear-option'
|
|
240
|
+
const activeToken = computed(() => {
|
|
241
|
+
if (resolvedFocusMode.value === 'roving') return undefined
|
|
242
|
+
if (resolvedSelectionMode.value !== 'single') {
|
|
243
|
+
throw new Error('SListbox: activedescendant focus mode supports single selection only')
|
|
244
|
+
}
|
|
245
|
+
if (props.activeValue === undefined) {
|
|
246
|
+
throw new TypeError('SListbox: activeValue is required in activedescendant focus mode')
|
|
247
|
+
}
|
|
248
|
+
if (props.activeValue === null) return null
|
|
249
|
+
const token = selectionKeyToken('SListbox', props.activeValue, 'activeValue')
|
|
250
|
+
if (!inventory.value.optionsByToken.has(token)) {
|
|
251
|
+
throw new Error('SListbox: activeValue does not match any option')
|
|
252
|
+
}
|
|
253
|
+
return token
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
function optionId(token: string): string {
|
|
257
|
+
return `${resolvedId.value}-option-${encodeURIComponent(token)}`
|
|
258
|
+
}
|
|
216
259
|
|
|
217
260
|
function emitModelValue(value: TKey | null | readonly TKey[]): void {
|
|
218
261
|
if (contract.value.selectionMode === 'multiple') {
|
|
@@ -281,6 +324,7 @@ function enabledRegistrations(): ListboxOptionRegistration[] {
|
|
|
281
324
|
function initializeTabstop(): void {
|
|
282
325
|
const options = enabledRegistrations()
|
|
283
326
|
for (const option of registrations) option.element.tabIndex = -1
|
|
327
|
+
if (resolvedFocusMode.value === 'activedescendant') return
|
|
284
328
|
const selected = options.find((option) => option.selected())
|
|
285
329
|
const target = selected ?? options[0]
|
|
286
330
|
if (target) target.element.tabIndex = 0
|
|
@@ -300,6 +344,56 @@ function focusOption(target: ListboxOptionRegistration, options = enabledRegistr
|
|
|
300
344
|
target.element.focus()
|
|
301
345
|
}
|
|
302
346
|
|
|
347
|
+
function setActiveToken(token: string): void {
|
|
348
|
+
if (resolvedFocusMode.value !== 'activedescendant') return
|
|
349
|
+
const target = enabledRegistrations().find((option) => option.token === token)
|
|
350
|
+
if (!target) return
|
|
351
|
+
emit('update:activeValue', optionValue(target))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function activeRegistration(options: ListboxOptionRegistration[]): ListboxOptionRegistration | undefined {
|
|
355
|
+
const token = activeToken.value
|
|
356
|
+
return token === undefined || token === null
|
|
357
|
+
? undefined
|
|
358
|
+
: options.find((option) => option.token === token)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function initializeActive(): void {
|
|
362
|
+
if (resolvedFocusMode.value !== 'activedescendant') {
|
|
363
|
+
throw new Error('SListbox: initializeActive is available only in activedescendant focus mode')
|
|
364
|
+
}
|
|
365
|
+
const options = enabledRegistrations()
|
|
366
|
+
const target = activeRegistration(options) ?? options.find((option) => option.selected()) ?? options[0]
|
|
367
|
+
if (target) emit('update:activeValue', optionValue(target))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function moveActive(intent: SListboxMoveIntent): void {
|
|
371
|
+
if (resolvedFocusMode.value !== 'activedescendant') {
|
|
372
|
+
throw new Error('SListbox: moveActive is available only in activedescendant focus mode')
|
|
373
|
+
}
|
|
374
|
+
const options = enabledRegistrations()
|
|
375
|
+
if (options.length === 0) return
|
|
376
|
+
const current = activeRegistration(options)
|
|
377
|
+
const currentIndex = current === undefined ? -1 : options.indexOf(current)
|
|
378
|
+
const nextIndex = intent === 'first'
|
|
379
|
+
? 0
|
|
380
|
+
: intent === 'last'
|
|
381
|
+
? options.length - 1
|
|
382
|
+
: intent === 'next'
|
|
383
|
+
? (Math.max(currentIndex, -1) + 1) % options.length
|
|
384
|
+
: (currentIndex <= 0 ? options.length : currentIndex) - 1
|
|
385
|
+
const target = options[nextIndex]
|
|
386
|
+
if (target) emit('update:activeValue', optionValue(target))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function selectActive(): void {
|
|
390
|
+
if (resolvedFocusMode.value !== 'activedescendant') {
|
|
391
|
+
throw new Error('SListbox: selectActive is available only in activedescendant focus mode')
|
|
392
|
+
}
|
|
393
|
+
if (resolvedReadonly.value || resolvedDisabled.value) return
|
|
394
|
+
activeRegistration(enabledRegistrations())?.activate()
|
|
395
|
+
}
|
|
396
|
+
|
|
303
397
|
function currentOption(
|
|
304
398
|
options: ListboxOptionRegistration[],
|
|
305
399
|
event: KeyboardEvent,
|
|
@@ -319,6 +413,7 @@ function optionValue(registration: ListboxOptionRegistration): TKey | null {
|
|
|
319
413
|
}
|
|
320
414
|
|
|
321
415
|
function handleKeydown(event: KeyboardEvent): void {
|
|
416
|
+
if (resolvedFocusMode.value === 'activedescendant') return
|
|
322
417
|
if (event.key === 'Tab') {
|
|
323
418
|
emit('tab', event)
|
|
324
419
|
return
|
|
@@ -376,13 +471,16 @@ function handleKeydown(event: KeyboardEvent): void {
|
|
|
376
471
|
}
|
|
377
472
|
|
|
378
473
|
function focusSelectedOrFirst(): void {
|
|
474
|
+
if (resolvedFocusMode.value !== 'roving') {
|
|
475
|
+
throw new Error('SListbox: focusSelectedOrFirst is available only in roving focus mode')
|
|
476
|
+
}
|
|
379
477
|
const options = enabledRegistrations()
|
|
380
478
|
const target = options.find((option) => option.selected()) ?? options[0]
|
|
381
479
|
if (target) focusOption(target, options)
|
|
382
480
|
}
|
|
383
481
|
|
|
384
482
|
onBeforeUnmount(() => { if (typeaheadTimer) clearTimeout(typeaheadTimer) })
|
|
385
|
-
defineExpose({ focusSelectedOrFirst })
|
|
483
|
+
defineExpose({ focusSelectedOrFirst, initializeActive, moveActive, selectActive })
|
|
386
484
|
</script>
|
|
387
485
|
|
|
388
486
|
<style lang="postcss" src="./SListbox.css" scoped></style>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<li
|
|
3
3
|
ref="optionRef"
|
|
4
|
+
:id="id"
|
|
4
5
|
class="s-listbox-option__control"
|
|
5
6
|
:data-appearance="appearance"
|
|
6
|
-
:data-focused="focused || undefined"
|
|
7
|
+
:data-focused="focused || active || undefined"
|
|
7
8
|
:title="title ?? label"
|
|
8
9
|
:aria-label="appearance === 'color' ? label : undefined"
|
|
9
10
|
role="option"
|
|
@@ -12,6 +13,8 @@
|
|
|
12
13
|
:tabindex="-1"
|
|
13
14
|
@focus="focused = true"
|
|
14
15
|
@blur="focused = false"
|
|
16
|
+
@mousedown="onMousedown"
|
|
17
|
+
@mouseenter="emit('hover')"
|
|
15
18
|
@click.stop="select"
|
|
16
19
|
>
|
|
17
20
|
<span class="s-listbox-option__main" :data-appearance="appearance">
|
|
@@ -42,6 +45,9 @@ export interface Props {
|
|
|
42
45
|
appearance?: 'select' | 'color'
|
|
43
46
|
marker?: MarkerContract
|
|
44
47
|
title?: string
|
|
48
|
+
id?: string
|
|
49
|
+
active?: boolean
|
|
50
|
+
focusOnSelect?: boolean
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
defineOptions({ inheritAttrs: false })
|
|
@@ -51,10 +57,14 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
51
57
|
appearance: 'select',
|
|
52
58
|
marker: undefined,
|
|
53
59
|
title: undefined,
|
|
60
|
+
id: undefined,
|
|
61
|
+
active: false,
|
|
62
|
+
focusOnSelect: true,
|
|
54
63
|
})
|
|
55
64
|
|
|
56
65
|
const emit = defineEmits<{
|
|
57
66
|
select: []
|
|
67
|
+
hover: []
|
|
58
68
|
}>()
|
|
59
69
|
useInteractiveLeafRegistration({ owner: 'SListboxOption' })
|
|
60
70
|
const context = inject(listboxContextKey)
|
|
@@ -64,9 +74,12 @@ const optionRef = ref<HTMLLIElement | null>(null)
|
|
|
64
74
|
let unregister: (() => void) | undefined
|
|
65
75
|
const select = (): void => {
|
|
66
76
|
if (props.disabled) return
|
|
67
|
-
optionRef.value?.focus()
|
|
77
|
+
if (props.focusOnSelect) optionRef.value?.focus()
|
|
68
78
|
emit('select')
|
|
69
79
|
}
|
|
80
|
+
const onMousedown = (event: MouseEvent): void => {
|
|
81
|
+
if (!props.focusOnSelect) event.preventDefault()
|
|
82
|
+
}
|
|
70
83
|
onMounted(() => {
|
|
71
84
|
const element = optionRef.value
|
|
72
85
|
if (!(element instanceof HTMLLIElement)) throw new Error('SListboxOption: option element is unavailable')
|