@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 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'