@surstromming/dropdown-menu 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 +113 -0
- package/package.json +50 -0
- package/src/DropdownMenu.vue +199 -0
- package/src/index.ts +22 -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,113 @@
|
|
|
1
|
+
# @surstromming/dropdown-menu
|
|
2
|
+
|
|
3
|
+
A menu of actions anchored to a trigger — a row's "⋯", a user menu.
|
|
4
|
+
Data-driven: pass an array, wire a trigger, listen for `select`. Built on the
|
|
5
|
+
[`Popover`](../popover) shell (outside-click + Escape dismissal).
|
|
6
|
+
|
|
7
|
+
## Dependency graph
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
graph LR
|
|
11
|
+
dropdown_menu["@surstromming/dropdown-menu"]
|
|
12
|
+
design["@surstromming/design"]
|
|
13
|
+
icon["@surstromming/icon"]
|
|
14
|
+
popover["@surstromming/popover"]
|
|
15
|
+
scroll_area["@surstromming/scroll-area"]
|
|
16
|
+
util["@surstromming/util"]
|
|
17
|
+
dropdown_menu --> design
|
|
18
|
+
dropdown_menu --> icon
|
|
19
|
+
dropdown_menu --> popover
|
|
20
|
+
popover --> design
|
|
21
|
+
popover --> scroll_area
|
|
22
|
+
scroll_area --> design
|
|
23
|
+
scroll_area --> util
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```vue
|
|
29
|
+
<template>
|
|
30
|
+
<DropdownMenu :items="items" @select="run">
|
|
31
|
+
<template #trigger="{ toggle }">
|
|
32
|
+
<Button variant="outline" @click="toggle">Options</Button>
|
|
33
|
+
</template>
|
|
34
|
+
</DropdownMenu>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup lang="ts">
|
|
38
|
+
import { DropdownMenu, type DropdownMenuItem } from '@surstromming/dropdown-menu'
|
|
39
|
+
import { Button } from '@surstromming/button'
|
|
40
|
+
import { Pencil, Trash } from 'lucide'
|
|
41
|
+
|
|
42
|
+
const items: DropdownMenuItem[] = [
|
|
43
|
+
{ label: 'Edit', value: 'edit', icon: Pencil },
|
|
44
|
+
{ separator: true },
|
|
45
|
+
{ label: 'Delete', value: 'delete', icon: Trash, destructive: true },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
const run = (value: string) => { /* value is 'edit' | 'delete' */ }
|
|
49
|
+
</script>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Props
|
|
53
|
+
|
|
54
|
+
| Prop | Type | Default | Notes |
|
|
55
|
+
| -------------- | -------------------- | ------------ | ---------------------------------------- |
|
|
56
|
+
| `items` | `DropdownMenuItem[]` | — (required) | Options and separators, in order |
|
|
57
|
+
| `align` | `start \| end` | `start` | Which trigger edge the menu lines up with |
|
|
58
|
+
| `layer` | `popover \| menu \| modal` | `popover` | From `Popover` — `menu` clears a teleported sidebar drawer |
|
|
59
|
+
| `v-model:open` | `boolean` | `false` | Optional — the trigger toggles it |
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
type DropdownMenuItem =
|
|
63
|
+
| { label: string; value: string; icon?: IconNode; disabled?: boolean; destructive?: boolean }
|
|
64
|
+
| { separator: true }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Emit | Payload | When |
|
|
68
|
+
| -------- | ------- | ----------------------------- |
|
|
69
|
+
| `select` | `value` | An item is chosen (menu closes) |
|
|
70
|
+
|
|
71
|
+
## Slots
|
|
72
|
+
|
|
73
|
+
| Slot | Props | Description |
|
|
74
|
+
| --------- | ---------------- | --------------------------------------------- |
|
|
75
|
+
| `trigger` | `{ open, toggle }` | The anchor. Call `toggle` to open/close. |
|
|
76
|
+
|
|
77
|
+
The trigger is yours so it can be any control; add `aria-haspopup="menu"` and
|
|
78
|
+
`:aria-expanded="open"` to it for the full a11y contract.
|
|
79
|
+
|
|
80
|
+
## Anatomy
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
Popover
|
|
84
|
+
├─ <slot name="trigger" />
|
|
85
|
+
└─ div.menu[role=menu]
|
|
86
|
+
├─ button.item[role=menuitem] // icon + label; .isDestructive
|
|
87
|
+
└─ div.separator[role=separator]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Keyboard
|
|
91
|
+
|
|
92
|
+
| Key | Action |
|
|
93
|
+
| ------------- | --------------------------------------------------- |
|
|
94
|
+
| `↓` / `↑` | Move between items (wraps, skips separators/disabled) |
|
|
95
|
+
| `Home` / `End`| First / last item |
|
|
96
|
+
| `Enter` / `Space` | Fire the focused item (native button) |
|
|
97
|
+
| `Escape` | Close (from `Popover`) |
|
|
98
|
+
| `Tab` | Close and move on — a menu isn't a tab stop |
|
|
99
|
+
| outside click | Close (from `Popover`) |
|
|
100
|
+
|
|
101
|
+
Opening moves focus to the first item; closing returns focus to the trigger,
|
|
102
|
+
so a keyboard user never loses their place.
|
|
103
|
+
|
|
104
|
+
## Tokens
|
|
105
|
+
|
|
106
|
+
Panel from `Popover` (`popover`, `shadow(md)`). Items: `accent` highlight
|
|
107
|
+
shared by hover and focus; `destructive` items recolour text/icon and use a
|
|
108
|
+
`destructive` @ 10% highlight; separators are a `border` hairline.
|
|
109
|
+
|
|
110
|
+
## Notes
|
|
111
|
+
|
|
112
|
+
Actions only — no checkbox/radio items or submenus yet. For a value you keep
|
|
113
|
+
(a chosen option), that's [`Select`](../select), not a menu.
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@surstromming/dropdown-menu",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A menu of actions anchored to a trigger.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Evgeniy Mnatsakanov <evgeniy.mnatsakanov@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/hackteck/surstromming/tree/main/packages/dropdown-menu#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/hackteck/surstromming.git",
|
|
11
|
+
"directory": "packages/dropdown-menu"
|
|
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
|
+
"dropdown-menu"
|
|
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
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Popover v-model:open="open" :align="align" :side="side" :layer="layer">
|
|
3
|
+
<template #trigger>
|
|
4
|
+
<slot name="trigger" :open="open" :toggle="toggle" />
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<div ref="menu" :class="$style.menu" role="menu" @keydown="onKeydown">
|
|
8
|
+
<template v-for="(item, index) in items" :key="index">
|
|
9
|
+
<div v-if="'separator' in item" :class="$style.separator" role="separator" />
|
|
10
|
+
<button
|
|
11
|
+
v-else
|
|
12
|
+
:ref="(el) => setItemRef(el, index)"
|
|
13
|
+
:class="itemClasses(item)"
|
|
14
|
+
type="button"
|
|
15
|
+
role="menuitem"
|
|
16
|
+
tabindex="-1"
|
|
17
|
+
:disabled="item.disabled"
|
|
18
|
+
@click="select(item)"
|
|
19
|
+
>
|
|
20
|
+
<Icon v-if="item.icon" :icon="item.icon" :size="16" />
|
|
21
|
+
{{ item.label }}
|
|
22
|
+
</button>
|
|
23
|
+
</template>
|
|
24
|
+
</div>
|
|
25
|
+
</Popover>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { nextTick, useCssModule, useTemplateRef, watch } from 'vue'
|
|
30
|
+
import { Icon } from '@surstromming/icon'
|
|
31
|
+
import { Popover, type PopoverLayer } from '@surstromming/popover'
|
|
32
|
+
import type {
|
|
33
|
+
DropdownMenuAlign,
|
|
34
|
+
DropdownMenuItem,
|
|
35
|
+
DropdownMenuOption,
|
|
36
|
+
DropdownMenuSide,
|
|
37
|
+
} from './index'
|
|
38
|
+
|
|
39
|
+
const props = withDefaults(
|
|
40
|
+
defineProps<{
|
|
41
|
+
items: DropdownMenuItem[]
|
|
42
|
+
align?: DropdownMenuAlign
|
|
43
|
+
side?: DropdownMenuSide
|
|
44
|
+
layer?: PopoverLayer
|
|
45
|
+
}>(),
|
|
46
|
+
{ align: 'start', side: 'bottom', layer: 'popover' },
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const open = defineModel<boolean>('open', { default: false })
|
|
50
|
+
const emit = defineEmits<{ select: [value: string] }>()
|
|
51
|
+
|
|
52
|
+
const toggle = () => {
|
|
53
|
+
open.value = !open.value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const menu = useTemplateRef<HTMLElement>('menu')
|
|
57
|
+
|
|
58
|
+
// Buttons keyed by their index in `items` (separators leave gaps — that's fine,
|
|
59
|
+
// the move helper skips anything that isn't a selectable item).
|
|
60
|
+
const itemRefs: (HTMLButtonElement | null)[] = []
|
|
61
|
+
const setItemRef = (el: unknown, index: number) => {
|
|
62
|
+
itemRefs[index] = el as HTMLButtonElement | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isSelectable = (item: DropdownMenuItem) => !('separator' in item) && !item.disabled
|
|
66
|
+
|
|
67
|
+
const focusAt = (index: number) => itemRefs[index]?.focus()
|
|
68
|
+
|
|
69
|
+
const focusEdge = (from: 'start' | 'end') => {
|
|
70
|
+
const order = from === 'start' ? [...props.items.keys()] : [...props.items.keys()].reverse()
|
|
71
|
+
const index = order.find((i) => isSelectable(props.items[i]))
|
|
72
|
+
if (index !== undefined) focusAt(index)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Steps from the focused item, wrapping, skipping separators and disabled ones.
|
|
76
|
+
const move = (direction: 1 | -1) => {
|
|
77
|
+
const count = props.items.length
|
|
78
|
+
const active = itemRefs.findIndex((el) => el === document.activeElement)
|
|
79
|
+
const from = active === -1 ? (direction === 1 ? -1 : count) : active
|
|
80
|
+
for (let step = 1; step <= count; step++) {
|
|
81
|
+
const index = (from + direction * step + count * count) % count
|
|
82
|
+
if (isSelectable(props.items[index])) {
|
|
83
|
+
focusAt(index)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const select = (item: DropdownMenuOption) => {
|
|
90
|
+
emit('select', item.value)
|
|
91
|
+
open.value = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
95
|
+
switch (event.key) {
|
|
96
|
+
case 'ArrowDown':
|
|
97
|
+
move(1)
|
|
98
|
+
break
|
|
99
|
+
case 'ArrowUp':
|
|
100
|
+
move(-1)
|
|
101
|
+
break
|
|
102
|
+
case 'Home':
|
|
103
|
+
focusEdge('start')
|
|
104
|
+
break
|
|
105
|
+
case 'End':
|
|
106
|
+
focusEdge('end')
|
|
107
|
+
break
|
|
108
|
+
case 'Tab':
|
|
109
|
+
open.value = false // Tab leaves the menu rather than cycling inside it
|
|
110
|
+
return
|
|
111
|
+
default:
|
|
112
|
+
return // Enter/Space land on the focused <button> natively; Escape → Popover
|
|
113
|
+
}
|
|
114
|
+
event.preventDefault() // arrows/Home/End must not scroll the page
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Opening moves focus into the menu; the trigger it came from gets focus back
|
|
118
|
+
// on close, so a keyboard user never loses their place.
|
|
119
|
+
let trigger: HTMLElement | null = null
|
|
120
|
+
watch(open, async (isOpen) => {
|
|
121
|
+
if (isOpen) {
|
|
122
|
+
trigger = document.activeElement as HTMLElement
|
|
123
|
+
await nextTick()
|
|
124
|
+
focusEdge('start')
|
|
125
|
+
} else if (menu.value?.contains(document.activeElement)) {
|
|
126
|
+
trigger?.focus()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const $style = useCssModule()
|
|
131
|
+
const itemClasses = (item: DropdownMenuOption) => [
|
|
132
|
+
$style.item,
|
|
133
|
+
{ [$style.isDestructive]: item.destructive },
|
|
134
|
+
]
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<style module lang="scss">
|
|
138
|
+
@use '@surstromming/design' as design;
|
|
139
|
+
|
|
140
|
+
.menu {
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
min-width: design.spacing(48);
|
|
144
|
+
padding: design.spacing(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.item {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: design.spacing(2);
|
|
151
|
+
border-radius: design.radius(sm);
|
|
152
|
+
background-color: transparent;
|
|
153
|
+
padding: design.spacing(1.5) design.spacing(2);
|
|
154
|
+
color: design.color(popover-foreground);
|
|
155
|
+
font-size: 0.875rem;
|
|
156
|
+
text-align: start;
|
|
157
|
+
white-space: nowrap;
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
outline: none;
|
|
160
|
+
|
|
161
|
+
// Hover and keyboard focus share one highlight — the item the pointer or the
|
|
162
|
+
// arrows landed on is the one Enter would fire.
|
|
163
|
+
&:hover,
|
|
164
|
+
&:focus-visible {
|
|
165
|
+
background-color: design.color(accent);
|
|
166
|
+
color: design.color(accent-foreground);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
&:disabled {
|
|
170
|
+
color: design.color(muted-foreground);
|
|
171
|
+
pointer-events: none;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
svg {
|
|
175
|
+
flex-shrink: 0;
|
|
176
|
+
color: design.color(muted-foreground);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.isDestructive {
|
|
181
|
+
color: design.color(destructive);
|
|
182
|
+
|
|
183
|
+
&:hover,
|
|
184
|
+
&:focus-visible {
|
|
185
|
+
background-color: design.with-alpha(destructive, 10%);
|
|
186
|
+
color: design.color(destructive);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
svg {
|
|
190
|
+
color: design.color(destructive);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.separator {
|
|
195
|
+
height: 1px;
|
|
196
|
+
margin: design.spacing(1) 0;
|
|
197
|
+
background-color: design.color(border);
|
|
198
|
+
}
|
|
199
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IconNode } from '@surstromming/icon'
|
|
2
|
+
import type { PopoverAlign, PopoverSide } from '@surstromming/popover'
|
|
3
|
+
|
|
4
|
+
export type DropdownMenuAlign = PopoverAlign
|
|
5
|
+
export type DropdownMenuSide = PopoverSide
|
|
6
|
+
|
|
7
|
+
export interface DropdownMenuOption {
|
|
8
|
+
label: string
|
|
9
|
+
value: string
|
|
10
|
+
icon?: IconNode
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
/** A dangerous action — delete, revoke — painted in the destructive colour. */
|
|
13
|
+
destructive?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DropdownMenuSeparator {
|
|
17
|
+
separator: true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DropdownMenuItem = DropdownMenuOption | DropdownMenuSeparator
|
|
21
|
+
|
|
22
|
+
export { default as DropdownMenu } from './DropdownMenu.vue'
|