@surstromming/combobox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Evgeniy Mnatsakanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @surstromming/combobox
2
+
3
+ A [`Select`](../select) with a search field — for lists too long to scan.
4
+ Data-driven: pass options, get the whole control.
5
+
6
+ ## Dependency graph
7
+
8
+ ```mermaid
9
+ graph LR
10
+ combobox["@surstromming/combobox"]
11
+ design["@surstromming/design"]
12
+ icon["@surstromming/icon"]
13
+ input["@surstromming/input"]
14
+ popover["@surstromming/popover"]
15
+ scroll_area["@surstromming/scroll-area"]
16
+ util["@surstromming/util"]
17
+ combobox --> design
18
+ combobox --> icon
19
+ combobox --> input
20
+ input --> design
21
+ combobox --> popover
22
+ popover --> design
23
+ popover --> scroll_area
24
+ scroll_area --> design
25
+ scroll_area --> util
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```vue
31
+ <template>
32
+ <Combobox v-model="framework" :options="frameworks" placeholder="Pick a framework" />
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { ref } from 'vue'
37
+ import { Combobox, type ComboboxOption } from '@surstromming/combobox'
38
+
39
+ const framework = ref('')
40
+ const frameworks: ComboboxOption[] = [
41
+ { label: 'Vue', value: 'vue' },
42
+ { label: 'Svelte', value: 'svelte' },
43
+ { label: 'Angular', value: 'angular', disabled: true },
44
+ ]
45
+ </script>
46
+ ```
47
+
48
+ ## Props
49
+
50
+ | Prop | Type | Default | Notes |
51
+ | ------------------- | ------------------ | ---------------- | --------------------------------- |
52
+ | `v-model` | `string` | — | The selected `value` |
53
+ | `options` | `ComboboxOption[]` | — (required) | `{ label, value, disabled? }` |
54
+ | `placeholder` | `string` | `Select…` | Trigger text before a choice |
55
+ | `searchPlaceholder` | `string` | `Search…` | |
56
+ | `emptyText` | `string` | `Nothing found.` | Shown when nothing matches |
57
+ | `disabled` | `boolean` | `false` | |
58
+
59
+ `id` · `aria-*` fall through **to the trigger button** (`inheritAttrs: false`).
60
+
61
+ ## Anatomy
62
+
63
+ ```
64
+ Popover
65
+ ├─ button.trigger // value + up/down chevrons
66
+ ├─ div.search > Input // focused on open; cleared on close
67
+ └─ ul.list[role=listbox]
68
+ ├─ li.option // filtered by the query
69
+ └─ li.empty // emptyText
70
+ ```
71
+
72
+ ## Behavior & keyboard
73
+
74
+ | Key | Action |
75
+ | ------------------ | -------------------------------------------------- |
76
+ | `↓` / `Enter` / `Space` (trigger) | Open |
77
+ | `↓` / `↑` (search) | Move the active option (skips disabled) |
78
+ | `Enter` (search) | Pick the active option |
79
+ | `Escape` | Close (from `Popover`) |
80
+ | outside click | Close (from `Popover`) |
81
+
82
+ - Opening **focuses the search field** — otherwise it's a select with extra
83
+ steps. Closing clears the query, so the next open starts fresh.
84
+ - Filtering is a case-insensitive substring match on `label`. Re-filtering
85
+ resets the highlight to the first match, so `Enter` always picks what you're
86
+ looking at.
87
+
88
+ ## Notes
89
+
90
+ - Filtering is client-side over the `options` you pass. For a remote search,
91
+ keep the query in your own state, refetch, and hand the new array back in —
92
+ the component re-renders from it.
93
+ - Single-select only, like `Select`.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@surstromming/combobox",
3
+ "version": "0.1.0",
4
+ "description": "Select with a search field.",
5
+ "license": "MIT",
6
+ "author": "Evgeniy Mnatsakanov <evgeniy.mnatsakanov@gmail.com>",
7
+ "homepage": "https://github.com/hackteck/surstromming/tree/main/packages/combobox#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hackteck/surstromming.git",
11
+ "directory": "packages/combobox"
12
+ },
13
+ "bugs": "https://github.com/hackteck/surstromming/issues",
14
+ "keywords": [
15
+ "vue",
16
+ "vue3",
17
+ "component-library",
18
+ "design-system",
19
+ "ui",
20
+ "scss",
21
+ "css-modules",
22
+ "shadcn",
23
+ "combobox"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": [
27
+ "**/*.scss",
28
+ "**/*.css"
29
+ ],
30
+ "files": [
31
+ "src"
32
+ ],
33
+ "main": "./src/index.ts",
34
+ "module": "./src/index.ts",
35
+ "types": "./src/index.ts",
36
+ "exports": {
37
+ ".": "./src/index.ts"
38
+ },
39
+ "dependencies": {
40
+ "@surstromming/design": "^0.1.0",
41
+ "@surstromming/icon": "^0.1.0",
42
+ "@surstromming/input": "^0.1.0",
43
+ "@surstromming/popover": "^0.1.0"
44
+ },
45
+ "peerDependencies": {
46
+ "vue": "^3.4.0"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,211 @@
1
+ <template>
2
+ <Popover v-model:open="open">
3
+ <template #trigger>
4
+ <button
5
+ :class="$style.trigger"
6
+ type="button"
7
+ role="combobox"
8
+ aria-haspopup="listbox"
9
+ :aria-expanded="open"
10
+ :disabled="disabled"
11
+ v-bind="$attrs"
12
+ @click="toggle"
13
+ @keydown="onTriggerKeydown"
14
+ >
15
+ <span :class="valueClasses">{{ valueLabel }}</span>
16
+ <Icon :icon="ChevronsUpDown" :size="16" />
17
+ </button>
18
+ </template>
19
+
20
+ <div :class="$style.search">
21
+ <Input
22
+ ref="search"
23
+ v-model="query"
24
+ type="search"
25
+ :placeholder="searchPlaceholder"
26
+ @keydown="onSearchKeydown"
27
+ />
28
+ </div>
29
+
30
+ <ul ref="list" :class="$style.list" role="listbox">
31
+ <li
32
+ v-for="(option, index) in matches"
33
+ :key="option.value"
34
+ :class="optionClasses(option, index)"
35
+ role="option"
36
+ :aria-selected="option.value === model"
37
+ @click="select(option)"
38
+ @mousemove="activeIndex = index"
39
+ >
40
+ {{ option.label }}
41
+ <Icon v-if="option.value === model" :icon="Check" :size="16" />
42
+ </li>
43
+ <li v-if="!matches.length" :class="$style.empty">{{ emptyText }}</li>
44
+ </ul>
45
+ </Popover>
46
+ </template>
47
+
48
+ <script setup lang="ts">
49
+ import { computed, nextTick, ref, useCssModule, useTemplateRef, watch } from 'vue'
50
+ import { Icon } from '@surstromming/icon'
51
+ import { Input } from '@surstromming/input'
52
+ import { Popover } from '@surstromming/popover'
53
+ import { Check, ChevronsUpDown } from 'lucide'
54
+ import type { ComboboxOption } from './index'
55
+
56
+ // The trigger is the focusable control — id/aria belong on it, not the wrapper.
57
+ defineOptions({ inheritAttrs: false })
58
+
59
+ const props = withDefaults(
60
+ defineProps<{
61
+ options: ComboboxOption[]
62
+ placeholder?: string
63
+ searchPlaceholder?: string
64
+ emptyText?: string
65
+ disabled?: boolean
66
+ }>(),
67
+ {
68
+ placeholder: 'Select…',
69
+ searchPlaceholder: 'Search…',
70
+ emptyText: 'Nothing found.',
71
+ },
72
+ )
73
+
74
+ const model = defineModel<string>()
75
+
76
+ const open = ref(false)
77
+ const query = ref('')
78
+ const activeIndex = ref(0)
79
+ const search = useTemplateRef<InstanceType<typeof Input>>('search')
80
+ const list = useTemplateRef<HTMLElement>('list')
81
+
82
+ const selected = computed(() => props.options.find((option) => option.value === model.value))
83
+ const valueLabel = computed(() => selected.value?.label ?? props.placeholder)
84
+
85
+ const matches = computed(() => {
86
+ const needle = query.value.trim().toLowerCase()
87
+ if (!needle) return props.options
88
+ return props.options.filter((option) => option.label.toLowerCase().includes(needle))
89
+ })
90
+
91
+ // Every keystroke re-filters, so the old index would point at the wrong option.
92
+ watch(matches, () => {
93
+ activeIndex.value = 0
94
+ })
95
+
96
+ // A highlight you can't see is worse than none — follow it in a long list.
97
+ watch(activeIndex, async (index) => {
98
+ if (!open.value || index < 0) return
99
+ await nextTick()
100
+ list.value?.children[index]?.scrollIntoView({ block: 'nearest' })
101
+ })
102
+
103
+ // A combobox that doesn't focus its search field is just a select with extra steps.
104
+ watch(open, async (isOpen) => {
105
+ if (!isOpen) {
106
+ query.value = ''
107
+ return
108
+ }
109
+ await nextTick()
110
+ search.value?.focus()
111
+ })
112
+
113
+ const toggle = () => {
114
+ open.value = !open.value
115
+ }
116
+
117
+ const select = (option: ComboboxOption) => {
118
+ model.value = option.value
119
+ open.value = false
120
+ }
121
+
122
+ // Skips disabled options; stops at the ends rather than wrapping.
123
+ const move = (step: number) => {
124
+ const last = matches.value.length - 1
125
+ let next = activeIndex.value
126
+ for (let i = next + step; i >= 0 && i <= last; i += step) {
127
+ if (!matches.value[i].disabled) {
128
+ next = i
129
+ break
130
+ }
131
+ }
132
+ activeIndex.value = next
133
+ }
134
+
135
+ const onTriggerKeydown = (event: KeyboardEvent) => {
136
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
137
+ event.preventDefault()
138
+ open.value = true
139
+ }
140
+ }
141
+
142
+ const onSearchKeydown = (event: KeyboardEvent) => {
143
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
144
+ event.preventDefault() // don't move the text caret
145
+ move(event.key === 'ArrowDown' ? 1 : -1)
146
+ return
147
+ }
148
+
149
+ if (event.key === 'Enter') {
150
+ event.preventDefault()
151
+ const active = matches.value[activeIndex.value]
152
+ if (active) select(active)
153
+ }
154
+ }
155
+
156
+ const $style = useCssModule()
157
+ const valueClasses = computed(() => [$style.value, { [$style.isPlaceholder]: !selected.value }])
158
+ const optionClasses = (option: ComboboxOption, index: number) => [
159
+ $style.option,
160
+ { [$style.isActive]: index === activeIndex.value, [$style.isDisabled]: option.disabled },
161
+ ]
162
+ </script>
163
+
164
+ <style module lang="scss">
165
+ @use '@surstromming/design' as design;
166
+ @use 'css/combobox-list';
167
+
168
+ .trigger {
169
+ @include design.field;
170
+
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: space-between;
174
+ gap: design.spacing(2);
175
+ width: 100%;
176
+ height: design.spacing(9);
177
+ padding-inline: design.spacing(3);
178
+ font-size: 0.875rem;
179
+ white-space: nowrap;
180
+ text-align: start;
181
+ cursor: pointer;
182
+
183
+ // The chevrons are affordance, not content.
184
+ svg {
185
+ flex-shrink: 0;
186
+ color: design.color(muted-foreground);
187
+ pointer-events: none;
188
+ }
189
+
190
+ &:hover {
191
+ #{design.$darkThemeSelector} & {
192
+ background-color: design.with-alpha(input, 50%);
193
+ }
194
+ }
195
+ }
196
+
197
+ .value {
198
+ overflow: hidden;
199
+ text-overflow: ellipsis;
200
+ white-space: nowrap;
201
+ }
202
+
203
+ .isPlaceholder {
204
+ color: design.color(muted-foreground);
205
+ }
206
+
207
+ .search {
208
+ border-bottom: 1px solid design.color(border);
209
+ padding: design.spacing(1);
210
+ }
211
+ </style>
@@ -0,0 +1,38 @@
1
+ @use '@surstromming/design' as design;
2
+
3
+ .list {
4
+ margin: 0;
5
+ padding: design.spacing(1);
6
+ list-style: none;
7
+ }
8
+
9
+ .option {
10
+ display: flex;
11
+ align-items: center;
12
+ justify-content: space-between;
13
+ gap: design.spacing(2);
14
+ border-radius: design.radius(sm);
15
+ padding: design.spacing(2);
16
+ font-size: 0.875rem;
17
+ cursor: pointer;
18
+ user-select: none;
19
+ }
20
+
21
+ // Pointer and keyboard share one highlight — the active option is the one
22
+ // Enter would pick, whether the mouse or the arrow keys put it there.
23
+ .isActive {
24
+ background-color: design.color(accent);
25
+ color: design.color(accent-foreground);
26
+ }
27
+
28
+ .isDisabled {
29
+ pointer-events: none;
30
+ opacity: 0.5;
31
+ }
32
+
33
+ .empty {
34
+ padding: design.spacing(4) design.spacing(2);
35
+ color: design.color(muted-foreground);
36
+ font-size: 0.875rem;
37
+ text-align: center;
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface ComboboxOption {
2
+ label: string
3
+ value: string
4
+ disabled?: boolean
5
+ }
6
+
7
+ export { default as Combobox } from './Combobox.vue'