@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 +21 -0
- package/README.md +93 -0
- package/package.json +51 -0
- package/src/Combobox.vue +211 -0
- package/src/css/combobox-list.scss +38 -0
- package/src/index.ts +7 -0
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
|
+
}
|
package/src/Combobox.vue
ADDED
|
@@ -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
|
+
}
|