@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.
Files changed (45) hide show
  1. package/README.md +5 -5
  2. package/package.json +19 -3
  3. package/src/components/layout/SSideMenuButton.vue +5 -4
  4. package/src/components/layout/SSideMenuRail.vue +3 -6
  5. package/src/components/layout/SStack.css +9 -3
  6. package/src/components/layout/SStack.vue +10 -2
  7. package/src/components/shared/_internal/STreeNode.vue +10 -4
  8. package/src/components/shared/_internal/sidebarGroupContext.ts +3 -1
  9. package/src/components/shared/_internal/tabsContext.ts +1 -0
  10. package/src/components/shared/containers/SLeftSidebar.vue +2 -2
  11. package/src/components/shared/containers/SPageHeader.css +11 -0
  12. package/src/components/shared/containers/SPageHeader.vue +7 -1
  13. package/src/components/shared/containers/SPanel.css +7 -1
  14. package/src/components/shared/containers/SPanel.vue +5 -1
  15. package/src/components/shared/containers/SSidebarGroup.css +1 -1
  16. package/src/components/shared/containers/SSidebarGroup.vue +113 -94
  17. package/src/components/shared/containers/SSidebarSection.css +16 -4
  18. package/src/components/shared/containers/SSidebarSection.vue +15 -16
  19. package/src/components/shared/controls/SButton.css +39 -0
  20. package/src/components/shared/controls/SButton.vue +146 -29
  21. package/src/components/shared/controls/SCombobox.css +9 -0
  22. package/src/components/shared/controls/SCombobox.vue +322 -0
  23. package/src/components/shared/controls/SContextToggleButton.vue +1 -1
  24. package/src/components/shared/controls/SInputText.vue +15 -0
  25. package/src/components/shared/controls/SInteractiveSurface.css +4 -0
  26. package/src/components/shared/controls/SInteractiveSurface.vue +4 -0
  27. package/src/components/shared/controls/SListbox.vue +99 -1
  28. package/src/components/shared/controls/SListboxOption.vue +15 -2
  29. package/src/components/shared/controls/SRange.css +35 -0
  30. package/src/components/shared/controls/SRange.vue +129 -0
  31. package/src/components/shared/controls/SSwitch.css +48 -0
  32. package/src/components/shared/controls/SSwitch.vue +109 -0
  33. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +6 -3
  34. package/src/components/shared/data-display/SChip.css +59 -0
  35. package/src/components/shared/data-display/SChip.vue +92 -0
  36. package/src/components/shared/data-display/SCodeBlock.vue +1 -1
  37. package/src/components/shared/data-display/SCodeEditor.css +15 -0
  38. package/src/components/shared/data-display/SCodeEditor.vue +14 -0
  39. package/src/components/shared/data-display/STable.vue +107 -3
  40. package/src/components/shared/data-display/table.ts +9 -0
  41. package/src/components/shared/navigation/SBottomNav.vue +3 -0
  42. package/src/components/shared/navigation/STab.vue +31 -6
  43. package/src/components/shared/navigation/STabs.vue +8 -2
  44. package/src/internal/codeEditorContract.ts +1 -1
  45. package/src/internal/ownedAttrs.ts +31 -2
@@ -1,20 +1,24 @@
1
1
  <template>
2
- <button
3
- ref="buttonRef"
4
- v-bind="buttonBindings"
2
+ <component
3
+ :is="rootComponent"
4
+ :ref="setRootRef"
5
+ v-bind="rootBindings"
5
6
  class="s-button"
6
7
  :data-severity="severity"
7
8
  :data-size="size"
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
- :disabled="disabled || loading"
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
- <slot />
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
- </button>
55
+ </component>
52
56
  </template>
53
57
 
54
58
  <script setup lang="ts">
55
- import { computed, ref, watchEffect } from 'vue'
56
- import type { Component, VNodeChild } from 'vue'
59
+ import { computed, getCurrentInstance, ref, watchEffect } from 'vue'
60
+ import type { Component, ComponentPublicInstance } from 'vue'
61
+ import { RouterLink } from 'vue-router'
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 native-button ownership surface for composite UI-kit components. / Типизированная ownership-поверхность native button для составных UI-kit-компонентов. */
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
- export interface Props {
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
- type: 'button',
167
+ href: undefined,
168
+ to: undefined,
169
+ target: undefined,
170
+ rel: undefined,
171
+ type: undefined,
131
172
  })
132
- const slots = defineSlots<{
133
- /** Пассивная метка/графика действия; вложенные controls запрещены. / Passive action label/graphics; nested controls are forbidden. */
134
- default?: () => VNodeChild
173
+ const emit = defineEmits<{
174
+ click: [event: MouseEvent]
135
175
  }>()
136
- const buttonRef = ref<HTMLButtonElement | null>(null)
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: buttonRef,
186
+ element: rootRef,
141
187
  disabled: () => props.disabled || props.loading,
142
188
  })
143
189
 
144
190
  useInteractiveLeafRegistration({ owner: 'SButton' })
145
191
 
146
- const buttonBindings = computed(() => {
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
- if (!props.loading) return semanticBindings
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 = semanticBindings['aria-label']
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 && !slots.default) {
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
- ? { ...semanticBindings, 'aria-label': accessibleName }
166
- : semanticBindings
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 || slots.default)
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
- const getElement = (): HTMLButtonElement => {
197
- const element = buttonRef.value
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: native button element is unavailable')
316
+ throw new Error('SButton: interactive element is unavailable')
200
317
  }
201
318
  return element
202
319
  }
@@ -0,0 +1,9 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-combobox-anchor {
4
+ @apply w-full min-w-0;
5
+ }
6
+
7
+ .s-combobox-layer {
8
+ @apply max-w-[calc(100vw-1rem)];
9
+ }
@@ -0,0 +1,322 @@
1
+ <template>
2
+ <div ref="anchorRef" class="s-combobox-anchor">
3
+ <SInputText
4
+ ref="inputRef"
5
+ v-bind="ownedAttrs.bindings()"
6
+ :id="inputId"
7
+ :model-value="validatedSearch"
8
+ :label="label"
9
+ :placeholder="placeholder"
10
+ :disabled="disabled"
11
+ :readonly="readonly"
12
+ :required="required"
13
+ :invalid="invalid"
14
+ :error-message="errorMessage"
15
+ :help-text="helpText"
16
+ :size="size"
17
+ type="search"
18
+ autocomplete="off"
19
+ role="combobox"
20
+ aria-haspopup="listbox"
21
+ aria-autocomplete="list"
22
+ :aria-expanded="isOpen"
23
+ :aria-controls="listboxId"
24
+ :aria-activedescendant="activeDescendantId"
25
+ @update:model-value="onSearchUpdate"
26
+ @focus="open"
27
+ @click="open"
28
+ @keydown="onInputKeydown"
29
+ />
30
+ </div>
31
+ <teleport to="body">
32
+ <div v-if="isOpen" ref="panelRef" :style="floatingStyle" class="s-combobox-layer">
33
+ <SListbox
34
+ ref="listboxRef"
35
+ :id="listboxId"
36
+ :model-value="popupModelValue"
37
+ :options="filteredOptions"
38
+ :label="accessibleLabel"
39
+ :disabled="disabled"
40
+ :readonly="readonly"
41
+ :invalid="invalid"
42
+ :empty-label="emptyLabel"
43
+ focus-mode="activedescendant"
44
+ :active-value="activeValue"
45
+ @update:model-value="onListboxUpdate"
46
+ @update:active-value="activeValue = $event"
47
+ />
48
+ </div>
49
+ </teleport>
50
+ </template>
51
+
52
+ <script lang="ts">
53
+ import type {
54
+ SelectionEntry as PublicSelectionEntry,
55
+ SelectionOption as PublicSelectionOption,
56
+ SelectionOptionGroup as PublicSelectionOptionGroup,
57
+ } from '../_internal/selectionContract'
58
+ import type { SelectionKey as PublicSelectionKey } from '../_internal/useSelectionRoving'
59
+
60
+ export type SComboboxOption<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOption<TKey>
61
+ export type SComboboxOptionGroup<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOptionGroup<TKey>
62
+ export type SComboboxEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
63
+ export type SComboboxFilterMode = 'local' | 'manual'
64
+
65
+ export interface Props<TKey extends PublicSelectionKey = PublicSelectionKey> {
66
+ modelValue: TKey | null
67
+ search: string
68
+ options: readonly SComboboxEntry<TKey>[]
69
+ filterMode?: SComboboxFilterMode
70
+ label?: string
71
+ placeholder?: string
72
+ disabled?: boolean
73
+ readonly?: boolean
74
+ required?: boolean
75
+ invalid?: boolean
76
+ errorMessage?: string
77
+ helpText?: string
78
+ emptyLabel?: string
79
+ id?: string
80
+ size?: 'sm' | 'md' | 'lg'
81
+ }
82
+ </script>
83
+
84
+ <script setup lang="ts" generic="TKey extends SelectionKey = string">
85
+ import { computed, nextTick, onBeforeUnmount, shallowRef, useId, watch } from 'vue'
86
+ import { useFloatingPosition } from '../../../composables/useFloatingPosition'
87
+ import { registerLayer, type LayerRegistration } from '../../../internal/layerStack'
88
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
89
+ import {
90
+ validateBoolean,
91
+ validateDomId,
92
+ validateExactString,
93
+ validateNonEmptyString,
94
+ } from '../../../internal/runtimeContract'
95
+ import {
96
+ isSelectionModelList,
97
+ resolveSelectionInventory,
98
+ validateOptionalSelectionString,
99
+ validateSelectionModel,
100
+ type ResolvedSelectionInventory,
101
+ type SelectionEntry,
102
+ } from '../_internal/selectionContract'
103
+ import { selectionKeyToken, type SelectionKey } from '../_internal/useSelectionRoving'
104
+ import SInputText, { type SInputTextApi } from './SInputText.vue'
105
+ import SListbox, { type SListboxMoveIntent } from './SListbox.vue'
106
+
107
+ defineOptions({ inheritAttrs: false })
108
+
109
+ const props = withDefaults(defineProps<Props<TKey>>(), {
110
+ filterMode: 'local',
111
+ label: undefined,
112
+ placeholder: 'Начните вводить для поиска',
113
+ disabled: false,
114
+ readonly: false,
115
+ required: false,
116
+ invalid: false,
117
+ errorMessage: undefined,
118
+ helpText: undefined,
119
+ emptyLabel: 'Нет доступных вариантов',
120
+ id: undefined,
121
+ size: 'md',
122
+ })
123
+ const emit = defineEmits<{
124
+ 'update:modelValue': [value: TKey | null]
125
+ 'update:search': [value: string]
126
+ }>()
127
+ const ownedAttrs = useOwnedAttrs({ component: 'SCombobox', owner: 'editable combobox input' })
128
+ const generatedId = useId()
129
+ const inputRef = shallowRef<SInputTextApi | null>(null)
130
+ const listboxRef = shallowRef<{
131
+ initializeActive: () => void
132
+ moveActive: (intent: SListboxMoveIntent) => void
133
+ selectActive: () => void
134
+ } | null>(null)
135
+ const isOpen = shallowRef(false)
136
+ const activeValue = shallowRef<TKey | null>(null)
137
+ const layerZIndex = shallowRef('var(--s-layer-floating)')
138
+ let layerRegistration: LayerRegistration | null = null
139
+
140
+ const inputId = computed(() => props.id === undefined
141
+ ? `s-combobox-${generatedId}`
142
+ : validateDomId('SCombobox', 'id', props.id))
143
+ const listboxId = computed(() => `${inputId.value}-listbox`)
144
+ const validatedSearch = computed(() => {
145
+ if (typeof props.search !== 'string') throw new TypeError('SCombobox: search must be a string')
146
+ return props.search
147
+ })
148
+ const contract = computed(() => ({
149
+ filterMode: validateExactString(
150
+ 'SCombobox', 'filterMode', props.filterMode, ['local', 'manual'] as const,
151
+ ),
152
+ placeholder: validateNonEmptyString('SCombobox', 'placeholder', props.placeholder),
153
+ disabled: validateBoolean('SCombobox', 'disabled', props.disabled),
154
+ readonly: validateBoolean('SCombobox', 'readonly', props.readonly),
155
+ required: validateBoolean('SCombobox', 'required', props.required),
156
+ invalid: validateBoolean('SCombobox', 'invalid', props.invalid),
157
+ emptyLabel: validateNonEmptyString('SCombobox', 'emptyLabel', props.emptyLabel),
158
+ size: validateExactString('SCombobox', 'size', props.size, ['sm', 'md', 'lg'] as const),
159
+ label: validateOptionalSelectionString('SCombobox', 'label', props.label),
160
+ helpText: validateOptionalSelectionString('SCombobox', 'helpText', props.helpText),
161
+ errorMessage: validateOptionalSelectionString('SCombobox', 'errorMessage', props.errorMessage),
162
+ }))
163
+ const inventory = computed(() => {
164
+ const resolved = resolveSelectionInventory<TKey>('SCombobox', props.options)
165
+ validateSelectionModel('SCombobox', 'single', props.modelValue, resolved)
166
+ return resolved
167
+ })
168
+
169
+ function inventoryEntries(resolved: ResolvedSelectionInventory<TKey>): readonly SelectionEntry<TKey>[] {
170
+ return resolved.entries.map((entry) => entry.kind === 'option'
171
+ ? entry.record.option
172
+ : {
173
+ groupId: entry.groupId,
174
+ label: entry.label,
175
+ options: entry.records.map((record) => record.option),
176
+ })
177
+ }
178
+
179
+ const filteredOptions = computed<readonly SelectionEntry<TKey>[]>(() => {
180
+ const resolved = inventory.value
181
+ if (contract.value.filterMode === 'manual') return inventoryEntries(resolved)
182
+ const query = validatedSearch.value.trim().toLocaleLowerCase()
183
+ if (query.length === 0) return inventoryEntries(resolved)
184
+ return resolved.entries.flatMap<SelectionEntry<TKey>>((entry) => {
185
+ if (entry.kind === 'option') {
186
+ const searchable = `${entry.record.option.label} ${entry.record.option.title ?? ''}`.toLocaleLowerCase()
187
+ return searchable.includes(query) ? [entry.record.option] : []
188
+ }
189
+ const records = entry.records.filter((record) => (
190
+ `${record.option.label} ${record.option.title ?? ''}`.toLocaleLowerCase().includes(query)
191
+ ))
192
+ return records.length === 0 ? [] : [{
193
+ groupId: entry.groupId,
194
+ label: entry.label,
195
+ options: records.map((record) => record.option),
196
+ }]
197
+ })
198
+ })
199
+ const filteredInventory = computed(() => resolveSelectionInventory<TKey>(
200
+ 'SCombobox filtered options', filteredOptions.value,
201
+ ))
202
+ const popupModelValue = computed<TKey | null>(() => {
203
+ if (props.modelValue === null) return null
204
+ const token = selectionKeyToken('SCombobox', props.modelValue, 'modelValue')
205
+ return filteredInventory.value.optionsByToken.has(token) ? props.modelValue : null
206
+ })
207
+ const accessibleLabel = computed(() => {
208
+ const bindings = ownedAttrs.bindings()
209
+ const ariaLabel = bindings['aria-label']
210
+ if (typeof ariaLabel === 'string') return validateNonEmptyString('SCombobox', 'aria-label', ariaLabel)
211
+ return contract.value.label ?? contract.value.placeholder
212
+ })
213
+ const activeDescendantId = computed(() => {
214
+ if (!isOpen.value || activeValue.value === null) return undefined
215
+ const token = selectionKeyToken('SCombobox', activeValue.value, 'activeValue')
216
+ if (!filteredInventory.value.optionsByToken.has(token)) return undefined
217
+ return `${listboxId.value}-option-${encodeURIComponent(token)}`
218
+ })
219
+
220
+ watch(validatedSearch, () => {
221
+ activeValue.value = null
222
+ if (isOpen.value) nextTick(() => listboxRef.value?.initializeActive())
223
+ }, { flush: 'sync' })
224
+
225
+ const { anchorRef, panelRef, floatingStyle } = useFloatingPosition({
226
+ active: isOpen,
227
+ placement: () => 'bottom-start',
228
+ widthMatch: () => 'exact',
229
+ zIndex: () => layerZIndex.value,
230
+ })
231
+
232
+ type CloseReason = 'selection' | 'escape' | 'tab' | 'outside'
233
+
234
+ function inputElement(): HTMLInputElement {
235
+ const api = inputRef.value
236
+ if (!api) throw new Error('SCombobox: SInputText API is unavailable')
237
+ return api.getElement()
238
+ }
239
+
240
+ function close(reason: CloseReason): void {
241
+ if (!isOpen.value) return
242
+ void reason
243
+ isOpen.value = false
244
+ activeValue.value = null
245
+ layerRegistration?.unregister({ restoreFocus: false })
246
+ layerRegistration = null
247
+ layerZIndex.value = 'var(--s-layer-floating)'
248
+ }
249
+
250
+ async function open(): Promise<void> {
251
+ void inventory.value
252
+ if (contract.value.disabled || contract.value.readonly || isOpen.value) return
253
+ const input = inputElement()
254
+ isOpen.value = true
255
+ layerRegistration = registerLayer({
256
+ kind: 'listbox',
257
+ root: () => panelRef.value,
258
+ anchor: () => anchorRef.value,
259
+ restoreTarget: input,
260
+ requestDismiss: (reason) => close(reason === 'escape' ? 'escape' : 'outside'),
261
+ })
262
+ layerZIndex.value = layerRegistration.zIndex
263
+ await nextTick()
264
+ listboxRef.value?.initializeActive()
265
+ }
266
+
267
+ function onSearchUpdate(value: string): void {
268
+ emit('update:search', value)
269
+ activeValue.value = null
270
+ if (!isOpen.value) void open()
271
+ else nextTick(() => listboxRef.value?.initializeActive())
272
+ }
273
+
274
+ function onListboxUpdate(value: TKey | null | readonly TKey[]): void {
275
+ if (isSelectionModelList(value)) {
276
+ throw new TypeError('SCombobox: selection update must be a single key or null')
277
+ }
278
+ validateSelectionModel('SCombobox update', 'single', value, inventory.value)
279
+ emit('update:modelValue', value)
280
+ if (value !== null) {
281
+ const token = selectionKeyToken('SCombobox', value, 'selected value')
282
+ const option = inventory.value.optionsByToken.get(token)
283
+ if (!option) throw new Error('SCombobox: selected option identity is stale')
284
+ emit('update:search', option.option.label)
285
+ }
286
+ close('selection')
287
+ }
288
+
289
+ async function onInputKeydown(event: KeyboardEvent): Promise<void> {
290
+ const intents: Partial<Record<string, SListboxMoveIntent>> = {
291
+ ArrowDown: 'next',
292
+ ArrowUp: 'previous',
293
+ Home: 'first',
294
+ End: 'last',
295
+ }
296
+ const intent = intents[event.key]
297
+ if (intent) {
298
+ event.preventDefault()
299
+ const wasOpen = isOpen.value
300
+ await open()
301
+ await nextTick()
302
+ if (!wasOpen && (intent === 'next' || intent === 'previous')) return
303
+ listboxRef.value?.moveActive(intent)
304
+ return
305
+ }
306
+ if (event.key === 'Enter' && isOpen.value) {
307
+ event.preventDefault()
308
+ listboxRef.value?.selectActive()
309
+ return
310
+ }
311
+ if (event.key === 'Escape' && isOpen.value) {
312
+ event.preventDefault()
313
+ close('escape')
314
+ return
315
+ }
316
+ if (event.key === 'Tab') close('tab')
317
+ }
318
+
319
+ onBeforeUnmount(() => layerRegistration?.unregister({ restoreFocus: false }))
320
+ </script>
321
+
322
+ <style lang="postcss" src="./SCombobox.css" scoped></style>
@@ -30,7 +30,7 @@ export interface Props {
30
30
  partial?: boolean
31
31
  addTitle?: string
32
32
  removeTitle?: string
33
- size?: 'sm' | 'md' | 'lg'
33
+ size?: 'xs' | 'sm' | 'md' | 'lg'
34
34
  loading?: boolean
35
35
  }
36
36