@surstromming/select 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 +50 -0
- package/src/Select.vue +147 -0
- package/src/css/select-list.scss +31 -0
- package/src/css/select-trigger.scss +46 -0
- package/src/index.ts +9 -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/select
|
|
2
|
+
|
|
3
|
+
Pick one option from a list. Data-driven: pass an array of options, get the
|
|
4
|
+
whole control — a styled trigger plus a listbox in a [`Popover`](../popover).
|
|
5
|
+
|
|
6
|
+
## Dependency graph
|
|
7
|
+
|
|
8
|
+
```mermaid
|
|
9
|
+
graph LR
|
|
10
|
+
select["@surstromming/select"]
|
|
11
|
+
design["@surstromming/design"]
|
|
12
|
+
icon["@surstromming/icon"]
|
|
13
|
+
popover["@surstromming/popover"]
|
|
14
|
+
scroll_area["@surstromming/scroll-area"]
|
|
15
|
+
util["@surstromming/util"]
|
|
16
|
+
select --> design
|
|
17
|
+
select --> icon
|
|
18
|
+
select --> popover
|
|
19
|
+
popover --> design
|
|
20
|
+
popover --> scroll_area
|
|
21
|
+
scroll_area --> design
|
|
22
|
+
scroll_area --> util
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```vue
|
|
28
|
+
<template>
|
|
29
|
+
<Label id="fruit-label">Fruit</Label>
|
|
30
|
+
<Select v-model="fruit" :options="fruits" aria-labelledby="fruit-label" />
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<script setup lang="ts">
|
|
34
|
+
import { ref } from 'vue'
|
|
35
|
+
import { Select, type SelectOption } from '@surstromming/select'
|
|
36
|
+
|
|
37
|
+
const fruit = ref('')
|
|
38
|
+
const fruits: SelectOption[] = [
|
|
39
|
+
{ label: 'Apple', value: 'apple' },
|
|
40
|
+
{ label: 'Banana', value: 'banana' },
|
|
41
|
+
{ label: 'Durian', value: 'durian', disabled: true },
|
|
42
|
+
]
|
|
43
|
+
</script>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Props
|
|
47
|
+
|
|
48
|
+
| Prop | Type | Default | Notes |
|
|
49
|
+
| ------------- | ----------------- | ----------- | -------------------------------------- |
|
|
50
|
+
| `v-model` | `string` | — | The selected `value` |
|
|
51
|
+
| `options` | `SelectOption[]` | — (required) | `{ label, value, disabled? }` |
|
|
52
|
+
| `placeholder` | `string` | `Select…` | Shown, dimmed, until something is picked |
|
|
53
|
+
| `size` | `sm \| md \| lg` | `md` | Heights match `Input` and `Button` |
|
|
54
|
+
| `disabled` | `boolean` | `false` | |
|
|
55
|
+
|
|
56
|
+
`id` · `aria-*` fall through **to the trigger button** (`inheritAttrs: false`)
|
|
57
|
+
— it's the focusable control.
|
|
58
|
+
|
|
59
|
+
## Anatomy
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Popover
|
|
63
|
+
├─ button.trigger // value + chevron; the focusable control
|
|
64
|
+
└─ ul.list[role=listbox]
|
|
65
|
+
└─ li.option[role=option] // .isActive highlight, check on the selected one
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Keyboard
|
|
69
|
+
|
|
70
|
+
| Key | Action |
|
|
71
|
+
| ------------- | --------------------------------------------------- |
|
|
72
|
+
| `↓` / `↑` | Open; then move the active option (skips disabled) |
|
|
73
|
+
| `Enter` / `Space` | Open; then pick the active option |
|
|
74
|
+
| `Escape` | Close (from `Popover`) |
|
|
75
|
+
| outside click | Close (from `Popover`) |
|
|
76
|
+
|
|
77
|
+
Opening highlights the **current** selection, so `↓` moves from where you are.
|
|
78
|
+
Focus stays on the trigger the whole time — nothing to restore on close.
|
|
79
|
+
|
|
80
|
+
## Tokens
|
|
81
|
+
|
|
82
|
+
Trigger mirrors [`Input`](../input): `input` border, `radius(md)`, `ring` focus
|
|
83
|
+
ring, `destructive` when `aria-invalid`, dark `input` @ 30% background. Panel
|
|
84
|
+
uses `popover` / `border`; the active option uses `accent`.
|
|
85
|
+
|
|
86
|
+
## Notes
|
|
87
|
+
|
|
88
|
+
- Not a native `<select>`: the native one can't be styled to match (its dropdown
|
|
89
|
+
is drawn by the OS), which is the entire reason this component exists. The
|
|
90
|
+
trade-off is that we own the keyboard behaviour — see the table above.
|
|
91
|
+
- Single-select only. Multi-select needs chips, a different value type, and a
|
|
92
|
+
different keyboard model.
|
|
93
|
+
- Need to filter a long list? That's [`Combobox`](../combobox).
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@surstromming/select",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pick one option from a list.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Evgeniy Mnatsakanov <evgeniy.mnatsakanov@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/hackteck/surstromming/tree/main/packages/select#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/hackteck/surstromming.git",
|
|
11
|
+
"directory": "packages/select"
|
|
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
|
+
"select"
|
|
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/popover": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"vue": "^3.4.0"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/Select.vue
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Popover v-model:open="open">
|
|
3
|
+
<template #trigger>
|
|
4
|
+
<button
|
|
5
|
+
:class="triggerClasses"
|
|
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="onKeydown"
|
|
14
|
+
>
|
|
15
|
+
<span :class="valueClasses">{{ valueLabel }}</span>
|
|
16
|
+
<Icon :icon="ChevronDown" :size="16" />
|
|
17
|
+
</button>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<ul ref="list" :class="$style.list" role="listbox">
|
|
21
|
+
<li
|
|
22
|
+
v-for="(option, index) in options"
|
|
23
|
+
:key="option.value"
|
|
24
|
+
:class="optionClasses(option, index)"
|
|
25
|
+
role="option"
|
|
26
|
+
:aria-selected="option.value === model"
|
|
27
|
+
@click="select(option)"
|
|
28
|
+
@mousemove="activeIndex = index"
|
|
29
|
+
>
|
|
30
|
+
{{ option.label }}
|
|
31
|
+
<Icon v-if="option.value === model" :icon="Check" :size="16" />
|
|
32
|
+
</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</Popover>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup lang="ts">
|
|
38
|
+
import { computed, nextTick, ref, useCssModule, useTemplateRef, watch } from 'vue'
|
|
39
|
+
import { Icon } from '@surstromming/icon'
|
|
40
|
+
import { Popover } from '@surstromming/popover'
|
|
41
|
+
import { Check, ChevronDown } from 'lucide'
|
|
42
|
+
import type { SelectOption, SelectSize } from './index'
|
|
43
|
+
|
|
44
|
+
// The trigger is the focusable control — id/aria belong on it, not the wrapper.
|
|
45
|
+
defineOptions({ inheritAttrs: false })
|
|
46
|
+
|
|
47
|
+
const props = withDefaults(
|
|
48
|
+
defineProps<{
|
|
49
|
+
options: SelectOption[]
|
|
50
|
+
placeholder?: string
|
|
51
|
+
size?: SelectSize
|
|
52
|
+
disabled?: boolean
|
|
53
|
+
}>(),
|
|
54
|
+
{ placeholder: 'Select…', size: 'md' },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const model = defineModel<string>()
|
|
58
|
+
|
|
59
|
+
const open = ref(false)
|
|
60
|
+
const activeIndex = ref(-1)
|
|
61
|
+
|
|
62
|
+
const selected = computed(() => props.options.find((option) => option.value === model.value))
|
|
63
|
+
const valueLabel = computed(() => selected.value?.label ?? props.placeholder)
|
|
64
|
+
|
|
65
|
+
const list = useTemplateRef<HTMLElement>('list')
|
|
66
|
+
|
|
67
|
+
// A highlight you can't see is worse than none — follow it in a long list.
|
|
68
|
+
const revealActive = async () => {
|
|
69
|
+
if (!open.value || activeIndex.value < 0) return
|
|
70
|
+
await nextTick()
|
|
71
|
+
list.value?.children[activeIndex.value]?.scrollIntoView({ block: 'nearest' })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Opening lands on the current choice, so ↓ moves from where you are. Revealing
|
|
75
|
+
// it is a separate call rather than a job for the watcher below: reopening on
|
|
76
|
+
// the same option doesn't change the index, so nothing would fire and a
|
|
77
|
+
// selection far down a long list would open off screen.
|
|
78
|
+
watch(open, (isOpen) => {
|
|
79
|
+
if (!isOpen) return
|
|
80
|
+
activeIndex.value = props.options.findIndex((o) => o.value === model.value)
|
|
81
|
+
revealActive()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
watch(activeIndex, revealActive)
|
|
85
|
+
|
|
86
|
+
const toggle = () => {
|
|
87
|
+
open.value = !open.value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const select = (option: SelectOption) => {
|
|
91
|
+
model.value = option.value
|
|
92
|
+
open.value = false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Skips disabled options; stops at the ends rather than wrapping.
|
|
96
|
+
const move = (step: number) => {
|
|
97
|
+
const last = props.options.length - 1
|
|
98
|
+
let next = activeIndex.value
|
|
99
|
+
for (let i = next + step; i >= 0 && i <= last; i += step) {
|
|
100
|
+
if (!props.options[i].disabled) {
|
|
101
|
+
next = i
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
activeIndex.value = next
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
109
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
110
|
+
event.preventDefault() // don't scroll the page
|
|
111
|
+
if (!open.value) open.value = true
|
|
112
|
+
else move(event.key === 'ArrowDown' ? 1 : -1)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
117
|
+
event.preventDefault() // Space would scroll, Enter would submit the form
|
|
118
|
+
const active = props.options[activeIndex.value]
|
|
119
|
+
if (open.value && active) select(active)
|
|
120
|
+
else open.value = true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const $style = useCssModule()
|
|
125
|
+
const triggerClasses = computed(() => [$style.trigger, $style[`size-${props.size}`]])
|
|
126
|
+
const valueClasses = computed(() => [$style.value, { [$style.isPlaceholder]: !selected.value }])
|
|
127
|
+
const optionClasses = (option: SelectOption, index: number) => [
|
|
128
|
+
$style.option,
|
|
129
|
+
{ [$style.isActive]: index === activeIndex.value, [$style.isDisabled]: option.disabled },
|
|
130
|
+
]
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<style module lang="scss">
|
|
134
|
+
@use '@surstromming/design' as design;
|
|
135
|
+
@use 'css/select-trigger';
|
|
136
|
+
@use 'css/select-list';
|
|
137
|
+
|
|
138
|
+
.value {
|
|
139
|
+
overflow: hidden;
|
|
140
|
+
text-overflow: ellipsis;
|
|
141
|
+
white-space: nowrap;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.isPlaceholder {
|
|
145
|
+
color: design.color(muted-foreground);
|
|
146
|
+
}
|
|
147
|
+
</style>
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
@use '@surstromming/design' as design;
|
|
2
|
+
|
|
3
|
+
// Metrics mirror Input's, so a Select and an Input line up in one row.
|
|
4
|
+
.trigger {
|
|
5
|
+
@include design.field;
|
|
6
|
+
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: space-between;
|
|
10
|
+
gap: design.spacing(2);
|
|
11
|
+
width: 100%;
|
|
12
|
+
font-size: 0.875rem;
|
|
13
|
+
white-space: nowrap;
|
|
14
|
+
text-align: start;
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
|
|
17
|
+
// The chevron is affordance, not content — it shouldn't compete with the value.
|
|
18
|
+
svg {
|
|
19
|
+
flex-shrink: 0;
|
|
20
|
+
color: design.color(muted-foreground);
|
|
21
|
+
pointer-events: none;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&:hover {
|
|
25
|
+
#{design.$darkThemeSelector} & {
|
|
26
|
+
background-color: design.with-alpha(input, 50%);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.size {
|
|
32
|
+
&-sm {
|
|
33
|
+
height: design.spacing(8);
|
|
34
|
+
padding-inline: design.spacing(3);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&-md {
|
|
38
|
+
height: design.spacing(9);
|
|
39
|
+
padding-inline: design.spacing(3);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&-lg {
|
|
43
|
+
height: design.spacing(10);
|
|
44
|
+
padding-inline: design.spacing(4);
|
|
45
|
+
}
|
|
46
|
+
}
|