@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.
@@ -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')