11import { HXElement } from './HXElement' ;
22import { getPosition } from '../utils/position' ;
3+ import debounce from 'lodash/debounce' ;
4+
5+ const DEFAULT_POSITION = 'bottom-start' ;
36
47/**
58 * Fires when the element is concealed.
@@ -33,20 +36,22 @@ export class HXMenuElement extends HXElement {
3336
3437 $onCreate ( ) {
3538 this . _onDocumentClick = this . _onDocumentClick . bind ( this ) ;
39+ this . _onDocumentScroll = this . _onDocumentScroll . bind ( this ) ;
40+ this . _reposition = this . _reposition . bind ( this ) ;
41+
42+ this . _onWindowResize = debounce ( this . _reposition , 50 ) ;
3643 }
3744
3845 $onConnect ( ) {
3946 this . $upgradeProperty ( 'open' ) ;
4047 this . $upgradeProperty ( 'position' ) ;
4148 this . $upgradeProperty ( 'relativeTo' ) ;
42- this . $defaultAttribute ( 'position' , 'bottom-start' ) ;
49+
50+ this . $defaultAttribute ( 'position' , DEFAULT_POSITION ) ;
4351 this . $defaultAttribute ( 'role' , 'menu' ) ;
44- this . _initialPosition = this . position ;
45- document . addEventListener ( 'click' , this . _onDocumentClick ) ;
46- }
4752
48- $onDisconnect ( ) {
49- document . removeEventListener ( 'click ', this . _onDocumentClick ) ;
53+ this . setAttribute ( 'aria-hidden' , ! this . open ) ;
54+ this . setAttribute ( 'aria-expanded ', this . open ) ;
5055 }
5156
5257 static get $observedAttributes ( ) {
@@ -55,84 +60,134 @@ export class HXMenuElement extends HXElement {
5560
5661 $onAttributeChange ( attr , oldVal , newVal ) {
5762 if ( attr === 'open' ) {
58- let isOpen = ( newVal !== null ) ;
59- this . setAttribute ( 'aria-expanded' , isOpen ) ;
60- this . $emit ( isOpen ? 'open' : 'close' ) ;
63+ this . _attrOpenChange ( oldVal , newVal ) ;
6164 }
6265 }
6366
64- set position ( value ) {
67+ /**
68+ * External element that controls menu visibility.
69+ * This is commonly a `<hx-disclosure>`.
70+ *
71+ * @readonly
72+ * @type {HTMLElement }
73+ */
74+ get controlElement ( ) {
75+ return this . getRootNode ( ) . querySelector ( `[aria-controls="${ this . id } "]` ) ;
76+ }
77+
78+ /**
79+ * Determines if the menu is revealed.
80+ *
81+ * @default false
82+ * @type {Boolean }
83+ */
84+ get open ( ) {
85+ return this . hasAttribute ( 'open' ) ;
86+ }
87+ set open ( value ) {
6588 if ( value ) {
66- this . setAttribute ( 'position ' , value ) ;
89+ this . setAttribute ( 'open ' , '' ) ;
6790 } else {
68- this . removeAttribute ( 'position ' ) ;
91+ this . removeAttribute ( 'open ' ) ;
6992 }
7093 }
7194
95+ // TODO: Need to re-evaluate how we handle positioning when scrolling
96+ /**
97+ * Where to position the open menu in relation to its reference element.
98+ *
99+ * @default 'bottom-start'
100+ * @type {PositionString }
101+ */
72102 get position ( ) {
73- if ( this . hasAttribute ( 'position' ) ) {
74- return this . getAttribute ( 'position' ) ;
75- }
76- return undefined ;
77- }
78-
79- set relativeTo ( value ) {
80- this . setAttribute ( 'relative-to' , value ) ;
103+ return this . getAttribute ( 'position' ) || DEFAULT_POSITION ;
81104 }
82-
83- get relativeTo ( ) {
84- return this . getAttribute ( 'relative-to' ) ;
105+ set position ( value ) {
106+ this . setAttribute ( 'position' , value ) ;
85107 }
86108
109+ /**
110+ * Reference element used to calculate open menu position.
111+ *
112+ * @readonly
113+ * @type {HTMLElement }
114+ */
87115 get relativeElement ( ) {
88116 if ( this . relativeTo ) {
89117 return this . getRootNode ( ) . getElementById ( this . relativeTo ) ;
90118 } else {
91- return this . getRootNode ( ) . querySelector ( `[aria-controls=" ${ this . id } "]` ) ;
119+ return this . controlElement ;
92120 }
93121 }
94122
95- set open ( value ) {
96- if ( value ) {
97- this . setAttribute ( 'open' , '' ) ;
98- this . _setPosition ( ) ;
99- } else {
100- this . removeAttribute ( 'open' ) ;
101- }
123+ /**
124+ * ID of the element to position the menu.
125+ *
126+ * @type { String }
127+ */
128+ get relativeTo ( ) {
129+ return this . getAttribute ( 'relative-to' ) ;
102130 }
103-
104- get open ( ) {
105- return this . hasAttribute ( 'open' ) ;
131+ set relativeTo ( value ) {
132+ this . setAttribute ( 'relative-to' , value ) ;
106133 }
107134
108- _setPosition ( ) {
109- let offset = getPosition ( {
110- element : this ,
111- reference : this . relativeElement ,
112- position : this . position ,
113- margin : 2 ,
114- } ) ;
115- this . style . top = `${ offset . y } px` ;
116- this . style . left = `${ offset . x } px` ;
135+ /** @private */
136+ _addOpenListeners ( ) {
137+ document . addEventListener ( 'click' , this . _onDocumentClick ) ;
138+ document . addEventListener ( 'scroll' , this . _onDocumentScroll ) ;
139+ window . addEventListener ( 'resize' , this . _onWindowResize ) ;
117140 }
118141
119- _isDescendant ( el ) {
120- if ( el . closest ( `hx-menu[id="${ this . id } "]` ) ) {
121- return true ;
142+ /** @private */
143+ _attrOpenChange ( oldVal , newVal ) {
144+ let isOpen = ( newVal !== null ) ;
145+ this . setAttribute ( 'aria-hidden' , ! isOpen ) ;
146+ this . setAttribute ( 'aria-expanded' , isOpen ) ;
147+ this . $emit ( isOpen ? 'open' : 'close' ) ;
148+
149+ if ( isOpen ) {
150+ this . _addOpenListeners ( ) ;
151+ this . _reposition ( ) ;
152+ } else {
153+ this . _removeOpenListeners ( ) ;
122154 }
123- return false ;
124155 }
125156
126- _isDisclosure ( el ) {
127- if ( el . closest ( `hx-disclosure[aria-controls="${ this . id } "]` ) ) {
128- return true ;
157+ /** @private */
158+ _onDocumentClick ( evt ) {
159+ let isDescendant = this . contains ( evt . target ) ;
160+ let withinControl = this . controlElement . contains ( evt . target ) ;
161+ let isBackground = ( ! isDescendant && ! withinControl ) ;
162+
163+ if ( this . open && isBackground ) {
164+ this . open = false ;
129165 }
130- return false ;
131166 }
132167
133- _onDocumentClick ( event ) {
134- if ( ! this . _isDescendant ( event . target ) && ! this . _isDisclosure ( event . target ) ) {
135- this . open = false ;
168+ /** @private */
169+ _onDocumentScroll ( ) {
170+ this . _reposition ( ) ;
171+ }
172+
173+ /** @private */
174+ _removeOpenListeners ( ) {
175+ document . removeEventListener ( 'click' , this . _onDocumentClick ) ;
176+ document . removeEventListener ( 'scroll' , this . _onDocumentScroll ) ;
177+ window . removeEventListener ( 'resize' , this . _onWindowResize ) ;
178+ }
179+
180+ /** @private */
181+ _reposition ( ) {
182+ if ( this . relativeElement ) {
183+ let { x, y } = getPosition ( {
184+ element : this ,
185+ reference : this . relativeElement ,
186+ position : this . position ,
187+ } ) ;
188+
189+ this . style . top = `${ y } px` ;
190+ this . style . left = `${ x } px` ;
136191 }
137192 }
138193}
0 commit comments