@sabrenski/spire-ui 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/index.d.ts +4981 -0
  4. package/dist/spire-ui.css +1 -0
  5. package/dist/spire-ui.es.js +18403 -0
  6. package/dist/spire-ui.umd.js +45 -0
  7. package/package.json +83 -0
  8. package/src/components/Accordion/Accordion.test.ts +218 -0
  9. package/src/components/Accordion/AccordionContent.vue +112 -0
  10. package/src/components/Accordion/AccordionItem.vue +87 -0
  11. package/src/components/Accordion/AccordionRoot.vue +111 -0
  12. package/src/components/Accordion/AccordionTrigger.vue +125 -0
  13. package/src/components/Accordion/index.ts +11 -0
  14. package/src/components/Accordion/keys.ts +23 -0
  15. package/src/components/Avatar/Avatar.test.ts +181 -0
  16. package/src/components/Avatar/Avatar.vue +150 -0
  17. package/src/components/Avatar/index.ts +2 -0
  18. package/src/components/Badge/Badge.test.ts +141 -0
  19. package/src/components/Badge/Badge.vue +133 -0
  20. package/src/components/Badge/index.ts +2 -0
  21. package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
  22. package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
  23. package/src/components/BadgeContainer/index.ts +2 -0
  24. package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
  25. package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
  26. package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
  27. package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
  28. package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
  29. package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
  30. package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
  31. package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
  32. package/src/components/Breadcrumb/index.ts +13 -0
  33. package/src/components/Breadcrumb/keys.ts +7 -0
  34. package/src/components/Button/Button.test.ts +231 -0
  35. package/src/components/Button/Button.vue +349 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Callout/Callout.test.ts +260 -0
  38. package/src/components/Callout/Callout.vue +341 -0
  39. package/src/components/Callout/index.ts +2 -0
  40. package/src/components/Card/Card.test.ts +565 -0
  41. package/src/components/Card/Card.vue +209 -0
  42. package/src/components/Card/CardContent.vue +57 -0
  43. package/src/components/Card/CardFooter.vue +72 -0
  44. package/src/components/Card/CardHeader.vue +111 -0
  45. package/src/components/Card/CardImage.vue +124 -0
  46. package/src/components/Card/index.ts +14 -0
  47. package/src/components/Chart/BarChart.vue +208 -0
  48. package/src/components/Chart/BaseChart.vue +444 -0
  49. package/src/components/Chart/Chart.test.ts +359 -0
  50. package/src/components/Chart/DonutChart.vue +283 -0
  51. package/src/components/Chart/LineChart.vue +211 -0
  52. package/src/components/Chart/index.ts +20 -0
  53. package/src/components/Chart/useChartTheme.ts +192 -0
  54. package/src/components/Checkbox/Checkbox.test.ts +209 -0
  55. package/src/components/Checkbox/Checkbox.vue +285 -0
  56. package/src/components/Checkbox/index.ts +2 -0
  57. package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
  58. package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
  59. package/src/components/ChoiceChip/index.ts +2 -0
  60. package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
  61. package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
  62. package/src/components/ChoiceChipGroup/index.ts +2 -0
  63. package/src/components/ColorPicker/ColorArea.vue +159 -0
  64. package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
  65. package/src/components/ColorPicker/ColorPicker.vue +339 -0
  66. package/src/components/ColorPicker/ColorSlider.vue +191 -0
  67. package/src/components/ColorPicker/index.ts +7 -0
  68. package/src/components/Combobox/Combobox.test.ts +891 -0
  69. package/src/components/Combobox/Combobox.vue +934 -0
  70. package/src/components/Combobox/index.ts +2 -0
  71. package/src/components/DataTable/DataTable.test.ts +1221 -0
  72. package/src/components/DataTable/DataTable.vue +1415 -0
  73. package/src/components/DataTable/index.ts +10 -0
  74. package/src/components/DatePicker/DatePicker.test.ts +625 -0
  75. package/src/components/DatePicker/DatePicker.vue +1586 -0
  76. package/src/components/DatePicker/index.ts +2 -0
  77. package/src/components/Drawer/Drawer.test.ts +336 -0
  78. package/src/components/Drawer/Drawer.vue +466 -0
  79. package/src/components/Drawer/index.ts +2 -0
  80. package/src/components/Dropdown/Dropdown.test.ts +607 -0
  81. package/src/components/Dropdown/Dropdown.vue +807 -0
  82. package/src/components/Dropdown/DropdownItem.vue +227 -0
  83. package/src/components/Dropdown/DropdownSeparator.vue +14 -0
  84. package/src/components/Dropdown/DropdownSub.vue +104 -0
  85. package/src/components/Dropdown/DropdownSubContent.vue +187 -0
  86. package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
  87. package/src/components/Dropdown/index.ts +14 -0
  88. package/src/components/EmptyState/EmptyState.test.ts +180 -0
  89. package/src/components/EmptyState/EmptyState.vue +137 -0
  90. package/src/components/EmptyState/index.ts +2 -0
  91. package/src/components/FileUpload/FileUpload.test.ts +1151 -0
  92. package/src/components/FileUpload/FileUpload.vue +1042 -0
  93. package/src/components/FileUpload/index.ts +2 -0
  94. package/src/components/Heading/Heading.test.ts +107 -0
  95. package/src/components/Heading/Heading.vue +67 -0
  96. package/src/components/Heading/index.ts +2 -0
  97. package/src/components/Icon/Icon.test.ts +157 -0
  98. package/src/components/Icon/Icon.vue +86 -0
  99. package/src/components/Icon/index.ts +2 -0
  100. package/src/components/Input/Input.test.ts +273 -0
  101. package/src/components/Input/Input.vue +388 -0
  102. package/src/components/Input/index.ts +2 -0
  103. package/src/components/Layout/Container.vue +67 -0
  104. package/src/components/Layout/Grid.vue +159 -0
  105. package/src/components/Layout/GridItem.vue +154 -0
  106. package/src/components/Layout/Layout.test.ts +202 -0
  107. package/src/components/Layout/Stack.vue +128 -0
  108. package/src/components/Layout/index.ts +9 -0
  109. package/src/components/Layout/keys.ts +7 -0
  110. package/src/components/Modal/Modal.test.ts +311 -0
  111. package/src/components/Modal/Modal.vue +336 -0
  112. package/src/components/Modal/index.ts +2 -0
  113. package/src/components/Pagination/Pagination.test.ts +303 -0
  114. package/src/components/Pagination/Pagination.vue +212 -0
  115. package/src/components/Pagination/index.ts +3 -0
  116. package/src/components/Pagination/utils.ts +86 -0
  117. package/src/components/Popover/Popover.test.ts +285 -0
  118. package/src/components/Popover/Popover.vue +441 -0
  119. package/src/components/Popover/index.ts +2 -0
  120. package/src/components/Progress/Progress.test.ts +361 -0
  121. package/src/components/Progress/Progress.vue +363 -0
  122. package/src/components/Progress/index.ts +7 -0
  123. package/src/components/Radio/Radio.test.ts +216 -0
  124. package/src/components/Radio/Radio.vue +214 -0
  125. package/src/components/Radio/index.ts +2 -0
  126. package/src/components/Rating/Rating.test.ts +319 -0
  127. package/src/components/Rating/Rating.vue +247 -0
  128. package/src/components/Rating/index.ts +2 -0
  129. package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
  130. package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
  131. package/src/components/SegmentedControl/index.ts +2 -0
  132. package/src/components/Select/Select.test.ts +589 -0
  133. package/src/components/Select/Select.vue +666 -0
  134. package/src/components/Select/index.ts +2 -0
  135. package/src/components/Sidebar/Sidebar.test.ts +301 -0
  136. package/src/components/Sidebar/SidebarGroup.vue +103 -0
  137. package/src/components/Sidebar/SidebarItem.vue +196 -0
  138. package/src/components/Sidebar/SidebarLayout.vue +42 -0
  139. package/src/components/Sidebar/SidebarRoot.vue +122 -0
  140. package/src/components/Sidebar/index.ts +11 -0
  141. package/src/components/Sidebar/keys.ts +14 -0
  142. package/src/components/Skeleton/Skeleton.test.ts +130 -0
  143. package/src/components/Skeleton/Skeleton.vue +104 -0
  144. package/src/components/Skeleton/index.ts +2 -0
  145. package/src/components/Slider/Slider.test.ts +416 -0
  146. package/src/components/Slider/Slider.vue +435 -0
  147. package/src/components/Slider/index.ts +2 -0
  148. package/src/components/Slider/utils.ts +91 -0
  149. package/src/components/Spinner/Spinner.test.ts +79 -0
  150. package/src/components/Spinner/Spinner.vue +159 -0
  151. package/src/components/Spinner/index.ts +2 -0
  152. package/src/components/SpireProvider/SpireProvider.vue +71 -0
  153. package/src/components/SpireProvider/index.ts +11 -0
  154. package/src/components/Stepper/Stepper.test.ts +221 -0
  155. package/src/components/Stepper/StepperContent.vue +51 -0
  156. package/src/components/Stepper/StepperItem.vue +89 -0
  157. package/src/components/Stepper/StepperRoot.vue +101 -0
  158. package/src/components/Stepper/StepperSeparator.vue +52 -0
  159. package/src/components/Stepper/StepperTrigger.vue +144 -0
  160. package/src/components/Stepper/index.ts +11 -0
  161. package/src/components/Stepper/keys.ts +27 -0
  162. package/src/components/Switch/Switch.test.ts +214 -0
  163. package/src/components/Switch/Switch.vue +235 -0
  164. package/src/components/Switch/index.ts +2 -0
  165. package/src/components/Tabs/Tabs.test.ts +363 -0
  166. package/src/components/Tabs/Tabs.vue +318 -0
  167. package/src/components/Tabs/index.ts +2 -0
  168. package/src/components/Text/Text.test.ts +154 -0
  169. package/src/components/Text/Text.vue +100 -0
  170. package/src/components/Text/index.ts +2 -0
  171. package/src/components/Textarea/Textarea.test.ts +432 -0
  172. package/src/components/Textarea/Textarea.vue +411 -0
  173. package/src/components/Textarea/index.ts +2 -0
  174. package/src/components/TimePicker/TimePicker.test.ts +352 -0
  175. package/src/components/TimePicker/TimePicker.vue +569 -0
  176. package/src/components/TimePicker/index.ts +2 -0
  177. package/src/components/Timeline/Timeline.test.ts +193 -0
  178. package/src/components/Timeline/Timeline.vue +111 -0
  179. package/src/components/Timeline/TimelineItem.vue +167 -0
  180. package/src/components/Timeline/index.ts +13 -0
  181. package/src/components/Timeline/keys.ts +21 -0
  182. package/src/components/Toast/ToastItem.test.ts +289 -0
  183. package/src/components/Toast/ToastItem.vue +370 -0
  184. package/src/components/Toast/ToastProvider.test.ts +158 -0
  185. package/src/components/Toast/ToastProvider.vue +181 -0
  186. package/src/components/Toast/index.ts +83 -0
  187. package/src/components/Toast/toastState.test.ts +165 -0
  188. package/src/components/Toast/toastState.ts +161 -0
  189. package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
  190. package/src/components/ToggleButton/ToggleButton.vue +197 -0
  191. package/src/components/ToggleButton/index.ts +2 -0
  192. package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
  193. package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
  194. package/src/components/ToggleGroup/index.ts +2 -0
  195. package/src/components/Tooltip/Tooltip.test.ts +238 -0
  196. package/src/components/Tooltip/Tooltip.vue +217 -0
  197. package/src/components/Tooltip/index.ts +2 -0
  198. package/src/components/TreeView/TreeView.test.ts +357 -0
  199. package/src/components/TreeView/TreeView.vue +251 -0
  200. package/src/components/TreeView/TreeViewItem.vue +288 -0
  201. package/src/components/TreeView/index.ts +11 -0
  202. package/src/components/TreeView/keys.ts +35 -0
  203. package/src/composables/index.ts +12 -0
  204. package/src/composables/useClickOutside.ts +36 -0
  205. package/src/composables/useClipboard.ts +35 -0
  206. package/src/composables/useEventListener.ts +48 -0
  207. package/src/composables/useFocusTrap.ts +58 -0
  208. package/src/composables/useHoverReveal.ts +98 -0
  209. package/src/composables/useId.ts +10 -0
  210. package/src/composables/useMagnetic.ts +171 -0
  211. package/src/composables/useRelativePosition.ts +127 -0
  212. package/src/composables/useRipple.ts +146 -0
  213. package/src/composables/useScrollLock.ts +25 -0
  214. package/src/composables/useSpireConfig.ts +27 -0
  215. package/src/composables/useStagger.ts +224 -0
  216. package/src/config/icons.test.ts +115 -0
  217. package/src/config/icons.ts +170 -0
  218. package/src/index.ts +361 -0
  219. package/src/styles/depth.css +129 -0
  220. package/src/styles/effects.css +169 -0
  221. package/src/styles/fallback.css +152 -0
  222. package/src/styles/main.css +25 -0
  223. package/src/styles/mood.css +211 -0
  224. package/src/styles/motion.css +159 -0
  225. package/src/styles/reset.css +97 -0
  226. package/src/styles/theme.css +708 -0
  227. package/src/styles/tokens.css +183 -0
  228. package/src/utils/.gitkeep +0 -0
  229. package/src/utils/color.ts +277 -0
  230. package/src/utils/date.test.ts +522 -0
  231. package/src/utils/date.ts +380 -0
  232. package/src/utils/index.ts +23 -0
  233. package/src/utils/object.test.ts +80 -0
  234. package/src/utils/object.ts +25 -0
  235. package/src/utils/string.test.ts +64 -0
  236. package/src/utils/string.ts +32 -0
  237. package/src/utils/time.ts +156 -0
@@ -0,0 +1,934 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
3
+ import { useId } from '../../composables'
4
+
5
+ export interface ComboboxOption {
6
+ /** Display text shown to user */
7
+ label: string
8
+ /** Value stored in v-model */
9
+ value: string | number
10
+ /** Disable this option */
11
+ disabled?: boolean
12
+ }
13
+
14
+ export interface ComboboxProps {
15
+ /** Selected value(s) - single value or array for multiple */
16
+ modelValue?: string | number | (string | number)[] | null
17
+ /** Available options */
18
+ options: ComboboxOption[]
19
+ /** Enable multi-select mode */
20
+ multiple?: boolean
21
+ /** Label text above input */
22
+ label?: string
23
+ /** Placeholder when empty */
24
+ placeholder?: string
25
+ /** Helper text below input */
26
+ hint?: string
27
+ /** Error message (also sets error state) */
28
+ error?: string
29
+ /** Input size */
30
+ size?: 'sm' | 'md' | 'lg'
31
+ /** Disabled state */
32
+ disabled?: boolean
33
+ /** Required field */
34
+ required?: boolean
35
+ /** Full width mode */
36
+ block?: boolean
37
+ /** Allow creating new options (tagging) */
38
+ allowCreate?: boolean
39
+ /** Max chips to display before +N (multi-select only) */
40
+ maxDisplayedChips?: number
41
+ /** HTML id attribute */
42
+ id?: string
43
+ /** HTML name attribute (for forms) */
44
+ name?: string
45
+ }
46
+
47
+ const props = withDefaults(defineProps<ComboboxProps>(), {
48
+ modelValue: null,
49
+ multiple: false,
50
+ placeholder: 'Search...',
51
+ size: 'md',
52
+ disabled: false,
53
+ required: false,
54
+ block: false,
55
+ allowCreate: false,
56
+ maxDisplayedChips: 3
57
+ })
58
+
59
+ const emit = defineEmits<{
60
+ (e: 'update:modelValue', value: string | number | (string | number)[] | null): void
61
+ (e: 'create', value: string): void
62
+ }>()
63
+
64
+ const containerRef = ref<HTMLElement | null>(null)
65
+ const inputRef = ref<HTMLInputElement | null>(null)
66
+ const listboxRef = ref<HTMLUListElement | null>(null)
67
+
68
+ const isOpen = ref(false)
69
+ const inputValue = ref('')
70
+ const highlightedIndex = ref(-1)
71
+ const markedChipIndex = ref(-1)
72
+
73
+ const uid = useId('combobox')
74
+ const comboboxId = computed(() => props.id || uid)
75
+ const listboxId = computed(() => `${comboboxId.value}-listbox`)
76
+ const hintId = computed(() => `${comboboxId.value}-hint`)
77
+ const errorId = computed(() => `${comboboxId.value}-error`)
78
+
79
+ function getOptionId(index: number) {
80
+ return `${comboboxId.value}-option-${index}`
81
+ }
82
+
83
+ const describedBy = computed(() => {
84
+ if (props.error) return errorId.value
85
+ if (props.hint) return hintId.value
86
+ return undefined
87
+ })
88
+
89
+ const selectedValues = computed<(string | number)[]>(() => {
90
+ if (props.modelValue === null || props.modelValue === undefined) return []
91
+ if (Array.isArray(props.modelValue)) return props.modelValue
92
+ return [props.modelValue]
93
+ })
94
+
95
+ const selectedOptions = computed(() => {
96
+ return selectedValues.value
97
+ .map(v => props.options.find(opt => opt.value === v))
98
+ .filter((opt): opt is ComboboxOption => opt !== undefined)
99
+ })
100
+
101
+ const filteredOptions = computed(() => {
102
+ const query = inputValue.value.toLowerCase().trim()
103
+ if (!query) return props.options
104
+ return props.options.filter(opt =>
105
+ opt.label.toLowerCase().includes(query)
106
+ )
107
+ })
108
+
109
+ const showCreateOption = computed(() => {
110
+ if (!props.allowCreate) return false
111
+ const query = inputValue.value.trim()
112
+ if (!query) return false
113
+ const exactMatch = props.options.some(
114
+ opt => opt.label.toLowerCase() === query.toLowerCase()
115
+ )
116
+ return !exactMatch
117
+ })
118
+
119
+ const activeDescendant = computed(() => {
120
+ if (!isOpen.value || highlightedIndex.value < 0) return undefined
121
+ return getOptionId(highlightedIndex.value)
122
+ })
123
+
124
+ const menuStyle = ref<Record<string, string>>({})
125
+
126
+ /**
127
+ * Update menu position below trigger
128
+ */
129
+ function updateMenuPosition() {
130
+ if (!containerRef.value) return
131
+ const rect = containerRef.value.getBoundingClientRect()
132
+ menuStyle.value = {
133
+ position: 'fixed',
134
+ top: `${rect.bottom + 4}px`,
135
+ left: `${rect.left}px`,
136
+ minWidth: `${rect.width}px`,
137
+ maxWidth: `${Math.max(rect.width, 300)}px`
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Open the dropdown
143
+ */
144
+ function open() {
145
+ if (props.disabled || isOpen.value) return
146
+ isOpen.value = true
147
+ updateMenuPosition()
148
+ highlightedIndex.value = filteredOptions.value.findIndex(opt => !opt.disabled)
149
+ markedChipIndex.value = -1
150
+
151
+ document.addEventListener('mousedown', handleClickOutside)
152
+ window.addEventListener('resize', close)
153
+ window.addEventListener('scroll', updateMenuPosition, true)
154
+ }
155
+
156
+ /**
157
+ * Close the dropdown
158
+ */
159
+ function close() {
160
+ if (!isOpen.value) return
161
+ isOpen.value = false
162
+ highlightedIndex.value = -1
163
+ markedChipIndex.value = -1
164
+
165
+ if (!props.multiple && selectedOptions.value.length > 0) {
166
+ inputValue.value = selectedOptions.value[0].label
167
+ } else if (!props.multiple) {
168
+ inputValue.value = ''
169
+ }
170
+
171
+ document.removeEventListener('mousedown', handleClickOutside)
172
+ window.removeEventListener('resize', close)
173
+ window.removeEventListener('scroll', updateMenuPosition, true)
174
+ }
175
+
176
+ /**
177
+ * Handle click outside
178
+ */
179
+ function handleClickOutside(event: MouseEvent) {
180
+ const target = event.target as Node
181
+ if (containerRef.value?.contains(target) || listboxRef.value?.contains(target)) {
182
+ return
183
+ }
184
+ close()
185
+ }
186
+
187
+ /**
188
+ * Handle input changes
189
+ */
190
+ function handleInput(event: Event) {
191
+ const target = event.target as HTMLInputElement
192
+ inputValue.value = target.value
193
+ markedChipIndex.value = -1
194
+
195
+ if (!isOpen.value) {
196
+ open()
197
+ } else {
198
+ highlightedIndex.value = filteredOptions.value.findIndex(opt => !opt.disabled)
199
+ nextTick(scrollToHighlighted)
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Handle input focus
205
+ */
206
+ function handleFocus() {
207
+ inputRef.value?.select()
208
+ if (!isOpen.value && !props.disabled) {
209
+ open()
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Select an option
215
+ */
216
+ function selectOption(option: ComboboxOption) {
217
+ if (option.disabled) return
218
+
219
+ if (props.multiple) {
220
+ const currentValues = [...selectedValues.value]
221
+ const idx = currentValues.indexOf(option.value)
222
+ if (idx >= 0) {
223
+ currentValues.splice(idx, 1)
224
+ } else {
225
+ currentValues.push(option.value)
226
+ }
227
+ emit('update:modelValue', currentValues)
228
+ inputValue.value = ''
229
+ inputRef.value?.focus()
230
+ } else {
231
+ emit('update:modelValue', option.value)
232
+ inputValue.value = option.label
233
+ close()
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Remove a chip (multi-select)
239
+ */
240
+ function removeChip(value: string | number, event?: MouseEvent) {
241
+ event?.stopPropagation()
242
+ const currentValues = selectedValues.value.filter(v => v !== value)
243
+ emit('update:modelValue', currentValues.length > 0 ? currentValues : null)
244
+ inputRef.value?.focus()
245
+ }
246
+
247
+ /**
248
+ * Handle creating a new option
249
+ */
250
+ function handleCreate() {
251
+ const value = inputValue.value.trim()
252
+ if (!value) return
253
+ emit('create', value)
254
+ inputValue.value = ''
255
+ if (!props.multiple) {
256
+ close()
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Scroll highlighted option into view
262
+ */
263
+ function scrollToHighlighted() {
264
+ if (!listboxRef.value || highlightedIndex.value < 0) return
265
+ const option = listboxRef.value.children[highlightedIndex.value] as HTMLElement
266
+ option?.scrollIntoView({ block: 'nearest' })
267
+ }
268
+
269
+ /**
270
+ * Move highlight
271
+ */
272
+ function moveHighlight(direction: 1 | -1) {
273
+ const len = filteredOptions.value.length
274
+ if (len === 0) return
275
+
276
+ let next = highlightedIndex.value
277
+ do {
278
+ next = (next + direction + len) % len
279
+ } while (filteredOptions.value[next]?.disabled && next !== highlightedIndex.value)
280
+
281
+ if (!filteredOptions.value[next]?.disabled) {
282
+ highlightedIndex.value = next
283
+ nextTick(scrollToHighlighted)
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Handle keyboard navigation
289
+ */
290
+ function handleKeydown(event: KeyboardEvent) {
291
+ switch (event.key) {
292
+ case 'ArrowDown':
293
+ event.preventDefault()
294
+ if (!isOpen.value) {
295
+ open()
296
+ } else {
297
+ moveHighlight(1)
298
+ }
299
+ break
300
+
301
+ case 'ArrowUp':
302
+ event.preventDefault()
303
+ if (!isOpen.value) {
304
+ open()
305
+ } else {
306
+ moveHighlight(-1)
307
+ }
308
+ break
309
+
310
+ case 'Enter':
311
+ event.preventDefault()
312
+ if (isOpen.value && highlightedIndex.value >= 0 && filteredOptions.value[highlightedIndex.value]) {
313
+ selectOption(filteredOptions.value[highlightedIndex.value])
314
+ } else if (showCreateOption.value) {
315
+ handleCreate()
316
+ }
317
+ break
318
+
319
+ case 'Escape':
320
+ event.preventDefault()
321
+ close()
322
+ break
323
+
324
+ case 'Backspace':
325
+ if (props.multiple && inputValue.value === '' && selectedValues.value.length > 0) {
326
+ event.preventDefault()
327
+ const lastIdx = selectedValues.value.length - 1
328
+
329
+ if (markedChipIndex.value === lastIdx) {
330
+ removeChip(selectedValues.value[lastIdx])
331
+ markedChipIndex.value = -1
332
+ } else {
333
+ markedChipIndex.value = lastIdx
334
+ }
335
+ }
336
+ break
337
+
338
+ case 'Home':
339
+ if (isOpen.value) {
340
+ event.preventDefault()
341
+ highlightedIndex.value = filteredOptions.value.findIndex(opt => !opt.disabled)
342
+ nextTick(scrollToHighlighted)
343
+ }
344
+ break
345
+
346
+ case 'End':
347
+ if (isOpen.value) {
348
+ event.preventDefault()
349
+ for (let i = filteredOptions.value.length - 1; i >= 0; i--) {
350
+ if (!filteredOptions.value[i].disabled) {
351
+ highlightedIndex.value = i
352
+ break
353
+ }
354
+ }
355
+ nextTick(scrollToHighlighted)
356
+ }
357
+ break
358
+
359
+ case 'Tab':
360
+ close()
361
+ break
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Highlight matching text in option label
367
+ */
368
+ function highlightMatch(label: string): string {
369
+ const query = inputValue.value.trim()
370
+ if (!query) return label
371
+
372
+ const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
373
+ return label.replace(regex, '<mark>$1</mark>')
374
+ }
375
+
376
+ /**
377
+ * Check if option is selected
378
+ */
379
+ function isSelected(value: string | number): boolean {
380
+ return selectedValues.value.includes(value)
381
+ }
382
+
383
+ /**
384
+ * Chips to display (with overflow handling)
385
+ */
386
+ const displayedChips = computed(() => {
387
+ if (!props.multiple) return []
388
+ return selectedOptions.value.slice(0, props.maxDisplayedChips)
389
+ })
390
+
391
+ const overflowCount = computed(() => {
392
+ if (!props.multiple) return 0
393
+ return Math.max(0, selectedOptions.value.length - props.maxDisplayedChips)
394
+ })
395
+
396
+ watch(() => props.modelValue, (newVal) => {
397
+ if (!props.multiple && newVal !== null && newVal !== undefined) {
398
+ const opt = props.options.find(o => o.value === newVal)
399
+ if (opt) {
400
+ inputValue.value = opt.label
401
+ }
402
+ }
403
+ }, { immediate: true })
404
+
405
+ onBeforeUnmount(() => {
406
+ document.removeEventListener('mousedown', handleClickOutside)
407
+ window.removeEventListener('resize', close)
408
+ window.removeEventListener('scroll', updateMenuPosition, true)
409
+ })
410
+ </script>
411
+
412
+ <template>
413
+ <div
414
+ class="ui-combobox"
415
+ :class="[
416
+ `ui-combobox--${size}`,
417
+ {
418
+ 'ui-combobox--block': block,
419
+ 'ui-combobox--disabled': disabled,
420
+ 'ui-combobox--error': error,
421
+ 'ui-combobox--open': isOpen,
422
+ 'ui-combobox--multiple': multiple
423
+ }
424
+ ]"
425
+ >
426
+ <label
427
+ v-if="label"
428
+ :for="comboboxId"
429
+ class="ui-combobox__label"
430
+ >
431
+ {{ label }}
432
+ <span v-if="required" class="ui-combobox__required" aria-hidden="true">*</span>
433
+ </label>
434
+
435
+ <div
436
+ ref="containerRef"
437
+ class="ui-combobox__trigger"
438
+ :class="[
439
+ `ui-combobox__trigger--${size}`,
440
+ {
441
+ 'ui-combobox__trigger--error': error,
442
+ 'ui-combobox__trigger--multiple': multiple
443
+ }
444
+ ]"
445
+ @click="inputRef?.focus()"
446
+ >
447
+ <template v-if="multiple">
448
+ <span
449
+ v-for="(chip, idx) in displayedChips"
450
+ :key="chip.value"
451
+ class="ui-combobox__chip"
452
+ :class="{ 'ui-combobox__chip--marked': markedChipIndex === selectedValues.indexOf(chip.value) }"
453
+ >
454
+ {{ chip.label }}
455
+ <button
456
+ type="button"
457
+ class="ui-combobox__chip-remove"
458
+ tabindex="-1"
459
+ aria-label="Remove"
460
+ @click="removeChip(chip.value, $event)"
461
+ >
462
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
463
+ <path d="M18 6L6 18M6 6l12 12" />
464
+ </svg>
465
+ </button>
466
+ </span>
467
+
468
+ <span v-if="overflowCount > 0" class="ui-combobox__overflow">
469
+ +{{ overflowCount }}
470
+ </span>
471
+ </template>
472
+
473
+ <input
474
+ :id="comboboxId"
475
+ ref="inputRef"
476
+ type="text"
477
+ role="combobox"
478
+ class="ui-combobox__input"
479
+ :value="inputValue"
480
+ :placeholder="multiple && selectedValues.length > 0 ? '' : placeholder"
481
+ :disabled="disabled"
482
+ :aria-expanded="isOpen"
483
+ :aria-controls="listboxId"
484
+ :aria-activedescendant="activeDescendant"
485
+ :aria-invalid="error ? 'true' : undefined"
486
+ :aria-describedby="describedBy"
487
+ :aria-required="required"
488
+ aria-autocomplete="list"
489
+ autocomplete="off"
490
+ @input="handleInput"
491
+ @focus="handleFocus"
492
+ @keydown="handleKeydown"
493
+ />
494
+
495
+ <svg
496
+ class="ui-combobox__chevron"
497
+ :class="{ 'ui-combobox__chevron--open': isOpen }"
498
+ viewBox="0 0 24 24"
499
+ fill="none"
500
+ stroke="currentColor"
501
+ stroke-width="2"
502
+ aria-hidden="true"
503
+ >
504
+ <path d="M6 9l6 6 6-6" />
505
+ </svg>
506
+ </div>
507
+
508
+ <select
509
+ v-if="name"
510
+ :name="name"
511
+ :multiple="multiple"
512
+ :required="required"
513
+ :disabled="disabled"
514
+ class="ui-combobox__native"
515
+ tabindex="-1"
516
+ aria-hidden="true"
517
+ >
518
+ <option v-if="!multiple" value="" disabled>{{ placeholder }}</option>
519
+ <option
520
+ v-for="opt in options"
521
+ :key="opt.value"
522
+ :value="opt.value"
523
+ :selected="isSelected(opt.value)"
524
+ :disabled="opt.disabled"
525
+ >
526
+ {{ opt.label }}
527
+ </option>
528
+ </select>
529
+
530
+ <Teleport to="body">
531
+ <Transition name="ui-combobox-menu">
532
+ <ul
533
+ v-if="isOpen"
534
+ :id="listboxId"
535
+ ref="listboxRef"
536
+ role="listbox"
537
+ class="ui-combobox__listbox"
538
+ :class="{ 'ui-combobox__listbox--multi': multiple }"
539
+ :style="menuStyle"
540
+ :aria-labelledby="comboboxId"
541
+ :aria-multiselectable="multiple"
542
+ >
543
+ <li
544
+ v-if="filteredOptions.length === 0 && !showCreateOption"
545
+ class="ui-combobox__empty"
546
+ >
547
+ No results found
548
+ </li>
549
+
550
+ <li
551
+ v-for="(option, index) in filteredOptions"
552
+ :key="option.value"
553
+ :id="getOptionId(index)"
554
+ role="option"
555
+ class="ui-combobox__option"
556
+ :class="{
557
+ 'ui-combobox__option--selected': isSelected(option.value),
558
+ 'ui-combobox__option--highlighted': index === highlightedIndex,
559
+ 'ui-combobox__option--disabled': option.disabled
560
+ }"
561
+ :aria-selected="isSelected(option.value)"
562
+ :aria-disabled="option.disabled"
563
+ @mouseenter="!option.disabled && (highlightedIndex = index)"
564
+ @click="selectOption(option)"
565
+ >
566
+ <span v-if="multiple" class="ui-combobox__checkbox">
567
+ <svg v-if="isSelected(option.value)" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
568
+ <path d="M5 12l5 5L20 7" />
569
+ </svg>
570
+ </span>
571
+
572
+ <span class="ui-combobox__option-label" v-html="highlightMatch(option.label)" />
573
+
574
+ <svg
575
+ v-if="!multiple && isSelected(option.value)"
576
+ class="ui-combobox__check"
577
+ viewBox="0 0 24 24"
578
+ fill="none"
579
+ stroke="currentColor"
580
+ stroke-width="2"
581
+ >
582
+ <path d="M5 12l5 5L20 7" />
583
+ </svg>
584
+ </li>
585
+
586
+ <li
587
+ v-if="showCreateOption"
588
+ class="ui-combobox__option ui-combobox__option--create"
589
+ :class="{ 'ui-combobox__option--highlighted': filteredOptions.length === 0 }"
590
+ @click="handleCreate"
591
+ >
592
+ Create "<strong>{{ inputValue.trim() }}</strong>"
593
+ </li>
594
+ </ul>
595
+ </Transition>
596
+ </Teleport>
597
+
598
+ <p
599
+ v-if="error"
600
+ :id="errorId"
601
+ class="ui-combobox__message ui-combobox__message--error"
602
+ role="alert"
603
+ >
604
+ {{ error }}
605
+ </p>
606
+ <p
607
+ v-else-if="hint"
608
+ :id="hintId"
609
+ class="ui-combobox__message ui-combobox__message--hint"
610
+ >
611
+ {{ hint }}
612
+ </p>
613
+ </div>
614
+ </template>
615
+
616
+ <style scoped>
617
+ .ui-combobox {
618
+ display: flex;
619
+ flex-direction: column;
620
+ gap: var(--space-1);
621
+ font-family: var(--font-sans);
622
+ }
623
+
624
+ .ui-combobox--block {
625
+ width: 100%;
626
+ }
627
+
628
+ .ui-combobox__label {
629
+ font-size: var(--text-sm);
630
+ font-weight: var(--font-medium);
631
+ color: var(--input-label);
632
+ line-height: var(--leading-tight);
633
+ }
634
+
635
+ .ui-combobox__required {
636
+ color: var(--input-error);
637
+ margin-left: var(--space-1);
638
+ }
639
+
640
+ .ui-combobox__trigger {
641
+ display: flex;
642
+ align-items: center;
643
+ flex-wrap: wrap;
644
+ gap: var(--space-1);
645
+ width: 100%;
646
+ background-color: var(--input-bg);
647
+ border: 1px solid var(--input-border);
648
+ border-radius: var(--radius-md);
649
+ cursor: text;
650
+ transition:
651
+ border-color var(--duration-fast) var(--ease-default),
652
+ box-shadow var(--duration-fast) var(--ease-default);
653
+ }
654
+
655
+ .ui-combobox__trigger:not(.ui-combobox__trigger--multiple) {
656
+ flex-wrap: nowrap;
657
+ }
658
+
659
+ .ui-combobox__trigger--xs {
660
+ min-height: var(--input-height-xs);
661
+ padding: 0 var(--space-1);
662
+ font-size: var(--text-xs);
663
+ }
664
+
665
+ .ui-combobox__trigger--sm {
666
+ min-height: var(--input-height-sm);
667
+ padding: var(--space-1) var(--space-2);
668
+ font-size: var(--text-sm);
669
+ }
670
+
671
+ .ui-combobox__trigger--md {
672
+ min-height: var(--input-height-md);
673
+ padding: var(--space-1) var(--space-3);
674
+ font-size: var(--text-sm);
675
+ }
676
+
677
+ .ui-combobox__trigger--lg {
678
+ min-height: var(--input-height-lg);
679
+ padding: var(--space-1) var(--space-3);
680
+ font-size: var(--text-base);
681
+ }
682
+
683
+ .ui-combobox__trigger--xl {
684
+ min-height: var(--input-height-xl);
685
+ padding: var(--space-1) var(--space-4);
686
+ font-size: var(--text-base);
687
+ }
688
+
689
+ .ui-combobox__trigger:hover:not(.ui-combobox--disabled .ui-combobox__trigger) {
690
+ border-color: var(--input-border-hover);
691
+ }
692
+
693
+ .ui-combobox__trigger:focus-within {
694
+ border-color: var(--input-border-focus);
695
+ box-shadow: 0 0 0 3px var(--input-ring);
696
+ }
697
+
698
+ .ui-combobox__trigger--error {
699
+ border-color: var(--input-border-error);
700
+ }
701
+
702
+ .ui-combobox__trigger--error:focus-within {
703
+ box-shadow: 0 0 0 3px var(--input-ring-error);
704
+ }
705
+
706
+ .ui-combobox--disabled .ui-combobox__trigger {
707
+ opacity: 0.5;
708
+ cursor: not-allowed;
709
+ background-color: var(--input-bg-disabled);
710
+ }
711
+
712
+ .ui-combobox__input {
713
+ flex: 1;
714
+ min-width: 60px;
715
+ border: none;
716
+ outline: none;
717
+ background: transparent;
718
+ font-family: inherit;
719
+ font-size: inherit;
720
+ color: var(--input-text);
721
+ }
722
+
723
+ .ui-combobox__input::placeholder {
724
+ color: var(--input-placeholder);
725
+ }
726
+
727
+ .ui-combobox__input:disabled {
728
+ cursor: not-allowed;
729
+ }
730
+
731
+ .ui-combobox__chevron {
732
+ flex-shrink: 0;
733
+ width: 1rem;
734
+ height: 1rem;
735
+ color: var(--input-icon);
736
+ transition: transform var(--duration-fast) var(--ease-default);
737
+ margin-left: auto;
738
+ }
739
+
740
+ .ui-combobox__chevron--open {
741
+ transform: rotate(180deg);
742
+ }
743
+
744
+ .ui-combobox__chip {
745
+ display: inline-flex;
746
+ align-items: center;
747
+ gap: var(--space-1);
748
+ padding: 0 var(--space-2);
749
+ height: 1.5rem;
750
+ background-color: var(--chip-bg-selected);
751
+ border: 1px solid var(--chip-border-selected);
752
+ border-radius: var(--radius-sm);
753
+ font-size: var(--text-xs);
754
+ color: var(--chip-text-selected);
755
+ white-space: nowrap;
756
+ }
757
+
758
+ .ui-combobox__chip--marked {
759
+ background-color: var(--action-destructive);
760
+ border-color: var(--action-destructive);
761
+ color: var(--action-destructive-text);
762
+ }
763
+
764
+ .ui-combobox__chip-remove {
765
+ display: flex;
766
+ align-items: center;
767
+ justify-content: center;
768
+ width: 0.875rem;
769
+ height: 0.875rem;
770
+ padding: 0;
771
+ border: none;
772
+ background: none;
773
+ cursor: pointer;
774
+ color: inherit;
775
+ opacity: 0.7;
776
+ border-radius: var(--radius-sm);
777
+ }
778
+
779
+ .ui-combobox__chip-remove:hover {
780
+ opacity: 1;
781
+ background-color: rgba(0, 0, 0, 0.1);
782
+ }
783
+
784
+ .ui-combobox__chip-remove svg {
785
+ width: 0.75rem;
786
+ height: 0.75rem;
787
+ }
788
+
789
+ .ui-combobox__overflow {
790
+ display: inline-flex;
791
+ align-items: center;
792
+ justify-content: center;
793
+ padding: 0 var(--space-2);
794
+ height: 1.5rem;
795
+ background-color: var(--badge-default-bg);
796
+ border-radius: var(--radius-sm);
797
+ font-size: var(--text-xs);
798
+ font-weight: var(--font-medium);
799
+ color: var(--badge-default-text);
800
+ }
801
+
802
+ .ui-combobox__native {
803
+ position: absolute;
804
+ width: 1px;
805
+ height: 1px;
806
+ padding: 0;
807
+ margin: -1px;
808
+ overflow: hidden;
809
+ clip: rect(0, 0, 0, 0);
810
+ white-space: nowrap;
811
+ border: 0;
812
+ }
813
+
814
+ .ui-combobox__message {
815
+ font-size: var(--text-xs);
816
+ line-height: var(--leading-normal);
817
+ margin: 0;
818
+ }
819
+
820
+ .ui-combobox__message--hint {
821
+ color: var(--input-hint);
822
+ }
823
+
824
+ .ui-combobox__message--error {
825
+ color: var(--input-error);
826
+ }
827
+ </style>
828
+
829
+ <style>
830
+ .ui-combobox__listbox {
831
+ z-index: 9999;
832
+ margin: 0;
833
+ padding: var(--space-1);
834
+ list-style: none;
835
+ background-color: var(--select-menu-bg);
836
+ border: 1px solid var(--select-menu-border);
837
+ border-radius: var(--radius-lg);
838
+ box-shadow: var(--shadow-lg);
839
+ max-height: 256px;
840
+ overflow-y: auto;
841
+ overscroll-behavior: contain;
842
+ }
843
+
844
+ .ui-combobox__option {
845
+ display: flex;
846
+ align-items: center;
847
+ gap: var(--space-2);
848
+ padding: var(--space-2) var(--space-3);
849
+ border-radius: var(--radius-md);
850
+ font-family: var(--font-sans);
851
+ font-size: var(--text-sm);
852
+ color: var(--select-option-text);
853
+ cursor: pointer;
854
+ transition: background-color var(--duration-fast) var(--ease-default);
855
+ }
856
+
857
+ .ui-combobox__option--highlighted {
858
+ background-color: var(--select-option-hover);
859
+ }
860
+
861
+ .ui-combobox__option--selected {
862
+ color: var(--select-option-selected);
863
+ font-weight: var(--font-medium);
864
+ }
865
+
866
+ .ui-combobox__option--disabled {
867
+ opacity: 0.5;
868
+ cursor: not-allowed;
869
+ }
870
+
871
+ .ui-combobox__option--create {
872
+ color: var(--action-primary);
873
+ }
874
+
875
+ .ui-combobox__checkbox {
876
+ display: flex;
877
+ align-items: center;
878
+ justify-content: center;
879
+ width: 1rem;
880
+ height: 1rem;
881
+ border: 1.5px solid var(--checkbox-border);
882
+ border-radius: var(--radius-sm);
883
+ background-color: var(--checkbox-bg);
884
+ flex-shrink: 0;
885
+ }
886
+
887
+ .ui-combobox__option--selected .ui-combobox__checkbox {
888
+ background-color: var(--checkbox-checked-bg);
889
+ border-color: var(--checkbox-checked-bg);
890
+ color: var(--checkbox-check);
891
+ }
892
+
893
+ .ui-combobox__checkbox svg {
894
+ width: 0.75rem;
895
+ height: 0.75rem;
896
+ }
897
+
898
+ .ui-combobox__option-label {
899
+ flex: 1;
900
+ }
901
+
902
+ .ui-combobox__option-label mark {
903
+ background-color: transparent;
904
+ color: var(--action-primary);
905
+ font-weight: var(--font-semibold);
906
+ }
907
+
908
+ .ui-combobox__check {
909
+ flex-shrink: 0;
910
+ width: 1rem;
911
+ height: 1rem;
912
+ color: var(--select-option-selected);
913
+ }
914
+
915
+ .ui-combobox__empty {
916
+ padding: var(--space-3);
917
+ text-align: center;
918
+ font-size: var(--text-sm);
919
+ color: var(--input-placeholder);
920
+ }
921
+
922
+ .ui-combobox-menu-enter-active,
923
+ .ui-combobox-menu-leave-active {
924
+ transition:
925
+ opacity var(--duration-fast) var(--ease-default),
926
+ transform var(--duration-fast) var(--ease-default);
927
+ }
928
+
929
+ .ui-combobox-menu-enter-from,
930
+ .ui-combobox-menu-leave-to {
931
+ opacity: 0;
932
+ transform: translateY(-4px);
933
+ }
934
+ </style>