1- // VscodeContextMenu is data-driven with { label, value, separator }[] and lacks
2- // support for icons, per-item danger styling, loading spinners, and disabled states.
31import {
42 VscodeIcon ,
53 VscodeProgressRing ,
64} from "@vscode-elements/react-elements" ;
7- import { useState , useRef , useEffect , useCallback } from "react" ;
5+ import { useState , useRef , useEffect } from "react" ;
6+
7+ import { isEscape } from "../utils/keys" ;
88
99interface ActionMenuAction {
10- separator ?: false ;
1110 label : string ;
1211 icon : string ;
1312 onClick : ( ) => void ;
@@ -16,56 +15,27 @@ interface ActionMenuAction {
1615 loading ?: boolean ;
1716}
1817
19- interface ActionMenuSeparator {
20- separator : true ;
21- }
22-
23- export type ActionMenuItem = ActionMenuAction | ActionMenuSeparator ;
18+ export type ActionMenuItem =
19+ | { separator : true }
20+ | ( { separator ?: false } & ActionMenuAction ) ;
2421
2522interface ActionMenuProps {
2623 items : ActionMenuItem [ ] ;
2724}
2825
26+ /*
27+ * VscodeContextMenu is data-driven with { label, value, separator }[] and lacks
28+ * support for icons, per-item danger styling, loading spinners, and disabled states.
29+ */
2930export function ActionMenu ( { items } : ActionMenuProps ) {
30- const [ position , setPosition ] = useState < {
31- top : number ;
32- right : number ;
33- } | null > ( null ) ;
31+ const [ isOpen , setIsOpen ] = useState ( false ) ;
3432 const menuRef = useRef < HTMLDivElement > ( null ) ;
35- const buttonRef = useRef < HTMLDivElement > ( null ) ;
36- const dropdownRef = useRef < HTMLDivElement > ( null ) ;
37-
38- function toggle ( ) {
39- setPosition ( ( prev ) => {
40- if ( prev ) {
41- return null ;
42- }
43- const rect = buttonRef . current ?. getBoundingClientRect ( ) ;
44- if ( ! rect ) {
45- return null ;
46- }
47- return { top : rect . bottom , right : window . innerWidth - rect . right } ;
48- } ) ;
49- }
50-
51- const isOpen = position !== null ;
5233
53- const dropdownRefCallback = useCallback ( ( node : HTMLDivElement | null ) => {
54- dropdownRef . current = node ;
55- node ?. focus ( ) ;
56- } , [ ] ) ;
57-
58- function onKeyDown ( event : React . KeyboardEvent ) {
59- if ( event . key === "Escape" ) {
60- setPosition ( null ) ;
61- }
62- }
34+ const close = ( ) => setIsOpen ( false ) ;
6335
6436 useEffect ( ( ) => {
6537 if ( ! isOpen ) return ;
6638
67- const close = ( ) => setPosition ( null ) ;
68-
6939 function onMouseDown ( event : MouseEvent ) {
7040 if ( menuRef . current && ! menuRef . current . contains ( event . target as Node ) ) {
7141 close ( ) ;
@@ -83,21 +53,26 @@ export function ActionMenu({ items }: ActionMenuProps) {
8353
8454 return (
8555 < div className = "action-menu" ref = { menuRef } >
86- < div ref = { buttonRef } >
87- < VscodeIcon
88- actionIcon
89- name = "ellipsis"
90- label = "More actions"
91- onClick = { toggle }
92- />
93- </ div >
94- { position && (
56+ < VscodeIcon
57+ actionIcon
58+ name = "ellipsis"
59+ label = "More actions"
60+ onClick = { ( ) => setIsOpen ( ( prev ) => ! prev ) }
61+ />
62+ { isOpen && (
9563 < div
96- ref = { dropdownRefCallback }
64+ ref = { ( node ) => {
65+ if ( ! node || ! menuRef . current ) {
66+ return ;
67+ }
68+ const rect = menuRef . current . getBoundingClientRect ( ) ;
69+ node . style . top = `${ rect . bottom + 4 } px` ;
70+ node . style . right = `${ window . innerWidth - rect . right } px` ;
71+ node . focus ( { preventScroll : true } ) ;
72+ } }
9773 className = "action-menu-dropdown"
98- style = { position }
9974 tabIndex = { - 1 }
100- onKeyDown = { onKeyDown }
75+ onKeyDown = { ( e ) => isEscape ( e ) && close ( ) }
10176 >
10277 { items . map ( ( item , index ) =>
10378 item . separator ? (
@@ -119,7 +94,7 @@ export function ActionMenu({ items }: ActionMenuProps) {
11994 . join ( " " ) }
12095 onClick = { ( ) => {
12196 item . onClick ( ) ;
122- setPosition ( null ) ;
97+ close ( ) ;
12398 } }
12499 disabled = { item . disabled === true || item . loading === true }
125100 >
0 commit comments