1- import { createComputed , createEffect , createMemo , createSignal , For , on , type JSX } from 'solid-js' ;
1+ import { createEffect , createSelector , createSignal , For , type JSX } from 'solid-js' ;
2+ import { Portal } from 'solid-js/web' ;
23import { computePosition , flip , shift , size } from '@floating-ui/dom' ;
34
45import { modulo } from '../helpers/modulo' ;
56import { cls } from '../helpers/class-names' ;
7+ import { equals } from '../helpers/comparison' ;
68
7- export type TwoDim = [ number , number ] ;
9+ export type Point2D = [ number , number ] ;
810
911interface FocusTrapProps {
1012 returnFocus : ( ) => void ;
1113 children : JSX . Element ;
1214}
1315
1416const FocusTrap = ( props : FocusTrapProps ) => {
15- const trap = ( ) => < div tabIndex = { 0 } style = { { position : 'absolute' } } onFocus = { props . returnFocus } /> ;
17+ const trap = ( ) => (
18+ < div tabIndex = { 0 } style = { { position : 'absolute' } } onFocus = { props . returnFocus } />
19+ ) ;
1620
1721 return (
1822 < >
@@ -25,92 +29,82 @@ const FocusTrap = (props: FocusTrapProps) => {
2529
2630interface ContextMenuItem {
2731 name : string ;
28- action : ( event : Event ) => void ;
32+ action : ( ) => void ;
2933}
3034
3135interface ContextMenuProps {
32- position : TwoDim ;
36+ anchor : Point2D ;
3337 items : ContextMenuItem [ ] ;
3438 onCancel : ( ) => void ;
3539}
3640
3741export const ContextMenu = ( props : ContextMenuProps ) => {
3842 let elementRef : HTMLDivElement ;
43+ let listElement : HTMLUListElement ;
3944
40- const [ constrainedPosition , setConstrainedPosition ] = createSignal < TwoDim > ( [ 0 , 0 ] ) ;
41- const [ maxSize , setMaxSize ] = createSignal < TwoDim > ( [ 0 , 0 ] ) ;
42- const needToRecalculatePosition = createMemo ( on ( ( ) => props . position , ( ) => ( { value : true } ) ) ) ;
43- const [ currentIndex , setCurrentIndex ] = createSignal < number | null > ( null ) ;
44- const itemElements : HTMLElement [ ] = [ ] ;
45+ const [ constrainedPosition , setConstrainedPosition ] = createSignal < Point2D > ( [ 0 , 0 ] ) ;
46+ const [ maxSize , setMaxSize ] = createSignal < Point2D > ( [ 0 , 0 ] ) ;
47+ const [ lastAnchorPoint , setLastAnchorPoint ] = createSignal < Point2D | null > ( null ) ;
4548
46- createComputed ( ( ) => {
47- props . items ;
48- setCurrentIndex ( null ) ;
49- } ) ;
49+ const [ selectedIndex , setSelectedIndex ] = createSignal < number | null > ( null ) ;
50+ const isItemSelected = createSelector ( selectedIndex ) ;
5051
5152 const handleBackdropClick = ( event : MouseEvent ) => {
5253 event . preventDefault ( ) ;
5354 props . onCancel ( ) ;
5455 } ;
5556
5657 const handleMouseOut = ( event : MouseEvent ) => {
57- setCurrentIndex ( null ) ;
58+ setSelectedIndex ( null ) ;
5859 } ;
5960
6061 const handleKeyDown = ( event : KeyboardEvent ) => {
6162 if ( event . key === 'Escape' ) {
6263 props . onCancel ( ) ;
6364 event . stopPropagation ( ) ;
6465 } else if ( event . key === 'ArrowUp' || event . key === 'ArrowDown' ) {
65- let direction = event . key === 'ArrowDown' ? 1 : - 1 ;
66- let newIndex ;
67- if ( currentIndex ( ) !== null ) {
68- newIndex = modulo ( currentIndex ( ) ! + direction , props . items . length ) ;
69- } else {
70- newIndex = direction === 1 ? 0 : props . items . length - 1 ;
71- }
72- setCurrentIndex ( newIndex ) ;
66+ let delta = event . key === 'ArrowDown' ? 1 : - 1 ;
67+ let oldIndex = selectedIndex ( ) ?? ( delta > 0 ? - 1 : props . items . length ) ;
68+ let newIndex = modulo ( oldIndex + delta , props . items . length ) ;
69+ setSelectedIndex ( newIndex ) ;
7370 event . stopPropagation ( ) ;
7471 }
7572 } ;
7673
7774 const handleItemClick = ( item : ContextMenuItem ) => ( event : MouseEvent ) => {
78- item . action ( event ) ;
75+ item . action ( ) ;
7976 props . onCancel ( ) ;
8077 } ;
8178
8279 const handleItemKeyDown = ( item : ContextMenuItem ) => ( event : KeyboardEvent ) => {
8380 if ( event . key === 'Enter' || event . key === ' ' ) {
84- item . action ( event ) ;
81+ item . action ( ) ;
8582 props . onCancel ( ) ;
8683 }
8784 } ;
8885
8986 const handleItemHover = ( idx : number ) => ( event : MouseEvent ) => {
90- setCurrentIndex ( idx ) ;
87+ setSelectedIndex ( idx ) ;
9188 } ;
9289
9390 createEffect ( ( ) => {
94- if ( currentIndex ( ) !== null ) {
95- itemElements [ currentIndex ( ) ! ] ?. focus ( ) ;
96- } else {
97- elementRef . focus ( ) ;
98- }
91+ ( ( listElement . children [ selectedIndex ( ) ?? - 1 ] ?? elementRef ) as HTMLElement ) . focus ( ) ;
9992 } ) ;
10093
10194 createEffect ( ( ) => {
102- if ( needToRecalculatePosition ( ) . value && elementRef ) {
95+ let anchorPoint = props . anchor ;
96+ if ( ! equals ( lastAnchorPoint ( ) , anchorPoint ) ) {
10397 const virtualReferenceElement = {
10498 getBoundingClientRect ( ) {
10599 return {
106100 width : 0 ,
107101 height : 0 ,
108- x : props . position [ 0 ] ,
109- y : props . position [ 1 ] ,
110- top : props . position [ 1 ] ,
111- left : props . position [ 0 ] ,
112- right : props . position [ 0 ] ,
113- bottom : props . position [ 1 ] ,
102+ x : anchorPoint [ 0 ] ,
103+ y : anchorPoint [ 1 ] ,
104+ top : anchorPoint [ 1 ] ,
105+ left : anchorPoint [ 0 ] ,
106+ right : anchorPoint [ 0 ] ,
107+ bottom : anchorPoint [ 1 ] ,
114108 } ;
115109 } ,
116110 } ;
@@ -120,9 +114,7 @@ export const ContextMenu = (props: ContextMenuProps) => {
120114 availableHeight : number ;
121115 } > ( ) ;
122116
123- // Will be overridden on the next render
124- elementRef . style . maxWidth = '' ;
125- elementRef . style . maxHeight = '' ;
117+ setMaxSize ( [ 0 , 0 ] ) ;
126118
127119 computePosition ( virtualReferenceElement , elementRef , {
128120 placement : 'bottom-start' ,
@@ -142,54 +134,53 @@ export const ContextMenu = (props: ContextMenuProps) => {
142134 } ) . then ( async ( result ) => {
143135 const size = await sizeCalculation . promise ;
144136
145- setConstrainedPosition ( [ result . x , result . y ] ) ;
146- setMaxSize ( [ size . availableWidth , size . availableHeight ] ) ;
147-
148- needToRecalculatePosition ( ) . value = false ;
137+ if ( equals ( props . anchor , anchorPoint ) ) {
138+ setConstrainedPosition ( [ result . x , result . y ] ) ;
139+ setMaxSize ( [ size . availableWidth , size . availableHeight ] ) ;
140+ setLastAnchorPoint ( anchorPoint ) ;
141+ }
149142 } ) ;
150143 }
151144 } ) ;
152145
153146 return (
154- < FocusTrap returnFocus = { ( ) => elementRef . focus ( ) } >
155- < div
156- class = "popover-menu-backdrop"
157- onClick = { handleBackdropClick }
158- onMouseDown = { handleBackdropClick }
159- />
160- < div
161- ref = { ( el ) => elementRef = el }
162- class = "popover-menu"
163- style = { {
164- 'left' : `${ constrainedPosition ( ) [ 0 ] } px` ,
165- 'top' : `${ constrainedPosition ( ) [ 1 ] } px` ,
166- 'max-width' : maxSize ( ) [ 0 ] > 0 ? `${ maxSize ( ) [ 0 ] } px` : '' ,
167- 'max-height' : maxSize ( ) [ 1 ] > 0 ? `${ maxSize ( ) [ 1 ] } px` : '' ,
168- } }
169- tabIndex = { 0 }
170- onMouseOut = { handleMouseOut }
171- onKeyDown = { handleKeyDown }
172- >
173- < ul class = "menu-list" >
174- < For each = { props . items } >
175- { ( item , idx ) => (
176- < li
177- ref = { ( element ) => {
178- if ( element ) itemElements [ idx ( ) ] = element ;
179- else delete itemElements [ idx ( ) ] ;
180- } }
181- class = { cls ( 'menu-list-item' , currentIndex ( ) === idx ( ) && 'focused' ) }
182- tabIndex = { currentIndex ( ) === idx ( ) ? 0 : - 1 }
183- onClick = { handleItemClick ( item ) }
184- onKeyDown = { handleItemKeyDown ( item ) }
185- onMouseEnter = { handleItemHover ( idx ( ) ) }
186- >
187- { item . name }
188- </ li >
189- ) }
190- </ For >
191- </ ul >
192- </ div >
193- </ FocusTrap >
147+ < Portal >
148+ < FocusTrap returnFocus = { ( ) => elementRef . focus ( ) } >
149+ < div
150+ class = "popover-backdrop"
151+ onClick = { handleBackdropClick }
152+ onMouseDown = { handleBackdropClick }
153+ />
154+ < div
155+ ref = { ( el ) => elementRef = el }
156+ class = "menu-popover popover fixed-positioning animate-enter"
157+ style = { {
158+ 'left' : `${ constrainedPosition ( ) [ 0 ] } px` ,
159+ 'top' : `${ constrainedPosition ( ) [ 1 ] } px` ,
160+ 'max-width' : maxSize ( ) [ 0 ] > 0 ? `${ maxSize ( ) [ 0 ] } px` : '' ,
161+ 'max-height' : maxSize ( ) [ 1 ] > 0 ? `${ maxSize ( ) [ 1 ] } px` : '' ,
162+ } }
163+ tabIndex = { 0 }
164+ onMouseOut = { handleMouseOut }
165+ onKeyDown = { handleKeyDown }
166+ >
167+ < ul ref = { ( el ) => listElement = el } class = "menu-list" >
168+ < For each = { props . items } >
169+ { ( item , idx ) => (
170+ < li
171+ class = { cls ( 'menu-list-item' , isItemSelected ( idx ( ) ) && 'focused' ) }
172+ tabIndex = { isItemSelected ( idx ( ) ) ? 0 : - 1 }
173+ onClick = { handleItemClick ( item ) }
174+ onKeyDown = { handleItemKeyDown ( item ) }
175+ onMouseEnter = { handleItemHover ( idx ( ) ) }
176+ >
177+ { item . name }
178+ </ li >
179+ ) }
180+ </ For >
181+ </ ul >
182+ </ div >
183+ </ FocusTrap >
184+ </ Portal >
194185 ) ;
195186} ;
0 commit comments