@@ -6,151 +6,194 @@ import { scrollTo } from "../utils/scrolling";
66import { ParentClosingMode } from "./dropdown_item" ;
77
88const { Component, core, hooks, useState, QWeb } = owl ;
9+ const { EventBus } = core ;
910const { useExternalListener, onMounted, onPatched, onWillStart } = hooks ;
1011
12+ /**
13+ * @typedef DropdownState
14+ * @property {boolean } open
15+ * @property {boolean } groupIsOpen
16+ */
17+
18+ /**
19+ * @typedef DropdownStateChangedPayload
20+ * @property {Dropdown } emitter
21+ * @property {DropdownState } newState
22+ */
23+
24+ /**
25+ * @extends Component
26+ */
1127export class Dropdown extends Component {
1228 setup ( ) {
13- this . hotkeyService = useService ( "hotkey" ) ;
14- this . hotkeyTokens = [ ] ;
15- this . state = useState ( { open : this . props . startOpen , groupIsOpen : this . props . startOpen } ) ;
29+ this . state = useState ( {
30+ open : this . props . startOpen ,
31+ groupIsOpen : this . props . startOpen ,
32+ } ) ;
33+
34+ onWillStart ( ( ) => {
35+ if ( ( this . state . open || this . state . groupIsOpen ) && this . props . beforeOpen ) {
36+ return this . props . beforeOpen ( ) ;
37+ }
38+ } ) ;
1639
17- this . ui = useService ( "ui" ) ;
1840 if ( ! this . props . manualOnly ) {
1941 // Close on outside click listener
2042 useExternalListener ( window , "click" , this . onWindowClicked ) ;
2143 // Listen to all dropdowns state changes
2244 useBus ( Dropdown . bus , "state-changed" , this . onDropdownStateChanged ) ;
2345 }
46+
47+ // Set up UI active element related behavior ---------------------------
48+ this . ui = useService ( "ui" ) ;
2449 useBus ( this . ui . bus , "active-element-changed" , ( activeElement ) => {
25- if ( activeElement !== this . myActiveEl ) {
50+ if ( activeElement !== this . myActiveEl && ! this . state . open ) {
51+ // Close when UI active element changes to something different
2652 this . close ( ) ;
2753 }
2854 } ) ;
29-
3055 onMounted ( ( ) => {
3156 Promise . resolve ( ) . then ( ( ) => {
3257 this . myActiveEl = this . ui . activeElement ;
3358 } ) ;
3459 } ) ;
3560
61+ // Set up key navigation -----------------------------------------------
62+ this . hotkeyService = useService ( "hotkey" ) ;
63+ this . hotkeyTokens = [ ] ;
64+
65+ const nextActiveIndexFns = {
66+ "FIRST" : ( ) => 0 ,
67+ "LAST" : ( items ) => items . length - 1 ,
68+ "NEXT" : ( items , prevActiveIndex ) => Math . min ( prevActiveIndex + 1 , items . length - 1 ) ,
69+ "PREV" : ( _ , prevActiveIndex ) => Math . max ( 0 , prevActiveIndex - 1 ) ,
70+ } ;
71+
72+ /** @type {(direction: "FIRST"|"LAST"|"NEXT"|"PREV") => Function } */
73+ function activeItemSetter ( direction ) {
74+ return function ( ) {
75+ const items = [ ...this . el . querySelectorAll ( ":scope > ul.o_dropdown_menu > .o_dropdown_item" ) ] ;
76+ const prevActiveIndex = items . findIndex ( ( item ) =>
77+ [ ...item . classList ] . includes ( "o_dropdown_active" )
78+ ) ;
79+ const nextActiveIndex = nextActiveIndexFns [ direction ] ( items , prevActiveIndex ) ;
80+ items . forEach ( ( item ) => item . classList . remove ( "o_dropdown_active" ) ) ;
81+ items [ nextActiveIndex ] . classList . add ( "o_dropdown_active" ) ;
82+ scrollTo ( items [ nextActiveIndex ] , this . el . querySelector ( ".o_dropdown_menu" ) ) ;
83+ } ;
84+ }
85+
86+ const hotkeyCallbacks = {
87+ "arrowdown" : activeItemSetter ( "NEXT" ) . bind ( this ) ,
88+ "arrowup" : activeItemSetter ( "PREV" ) . bind ( this ) ,
89+ "shift+arrowdown" : activeItemSetter ( "LAST" ) . bind ( this ) ,
90+ "shift+arrowup" : activeItemSetter ( "FIRST" ) . bind ( this ) ,
91+ "enter" : ( ) => {
92+ const activeItem = this . el . querySelector (
93+ ":scope > ul.o_dropdown_menu > .o_dropdown_item.o_dropdown_active"
94+ ) ;
95+ if ( activeItem ) {
96+ activeItem . click ( ) ;
97+ }
98+ } ,
99+ "escape" : this . close . bind ( this ) ,
100+ } ;
101+
102+ /** @this {Dropdown} */
36103 function autoSubscribeKeynav ( ) {
37104 if ( this . state . open ) {
38- this . subscribeKeynav ( ) ;
105+ // Subscribe keynav
106+ if ( this . hotkeyTokens . length ) {
107+ // Keynav already subscribed
108+ return ;
109+ }
110+ for ( const [ hotkey , callback ] of Object . entries ( hotkeyCallbacks ) ) {
111+ this . hotkeyTokens . push (
112+ this . hotkeyService . registerHotkey ( hotkey , callback , {
113+ altIsOptional : true ,
114+ allowRepeat : true ,
115+ } )
116+ ) ;
117+ }
39118 } else {
40- this . unsubscribeKeynav ( ) ;
119+ // Unsubscribe keynav
120+ for ( const token of this . hotkeyTokens ) {
121+ this . hotkeyService . unregisterHotkey ( token ) ;
122+ }
123+ this . hotkeyTokens = [ ] ;
41124 }
42125 }
43126
44127 onMounted ( autoSubscribeKeynav . bind ( this ) ) ;
45128 onPatched ( autoSubscribeKeynav . bind ( this ) ) ;
46- onWillStart ( ( ) => {
47- if ( ( this . state . open || this . state . groupIsOpen ) && this . props . beforeOpen ) {
48- return this . props . beforeOpen ( ) ;
49- }
50- } ) ;
51129 }
52130
53- // ---------------------------------------------------------------------------
131+ // -------------------------------------------------------------------------
54132 // Private
55- // ---------------------------------------------------------------------------
133+ // -------------------------------------------------------------------------
56134
135+ /**
136+ * Changes the dropdown state and notifies over the Dropdown bus.
137+ *
138+ * All state changes must trigger on the bus, except when reacting to
139+ * another dropdown state change.
140+ *
141+ * @see onDropdownStateChanged()
142+ *
143+ * @param {Partial<DropdownState> } stateSlice
144+ */
57145 async changeStateAndNotify ( stateSlice ) {
58146 if ( ( stateSlice . open || stateSlice . groupIsOpen ) && this . props . beforeOpen ) {
59147 await this . props . beforeOpen ( ) ;
60148 }
61149 // Update the state
62150 Object . assign ( this . state , stateSlice ) ;
63151 // Notify over the bus
64- Dropdown . bus . trigger ( "state-changed" , {
152+ /** @type DropdownStateChangedPayload */
153+ const stateChangedPayload = {
65154 emitter : this ,
66155 newState : { ...this . state } ,
67- } ) ;
156+ } ;
157+ Dropdown . bus . trigger ( "state-changed" , stateChangedPayload ) ;
68158 }
69159
70160 /**
71- * @param {"PREV"|"NEXT"|"FIRST"|"LAST" } direction
161+ * Closes the dropdown.
162+ *
163+ * @returns {Promise<void> }
72164 */
73- setActiveItem ( direction ) {
74- const items = [
75- ...this . el . querySelectorAll ( ":scope > ul.o_dropdown_menu > .o_dropdown_item" ) ,
76- ] ;
77- const prevActiveIndex = items . findIndex ( ( item ) =>
78- [ ...item . classList ] . includes ( "o_dropdown_active" )
79- ) ;
80- const nextActiveIndex =
81- direction === "NEXT"
82- ? Math . min ( prevActiveIndex + 1 , items . length - 1 )
83- : direction === "PREV"
84- ? Math . max ( 0 , prevActiveIndex - 1 )
85- : direction === "LAST"
86- ? items . length - 1
87- : direction === "FIRST"
88- ? 0
89- : undefined ;
90- if ( nextActiveIndex !== undefined ) {
91- items . forEach ( ( item ) => item . classList . remove ( "o_dropdown_active" ) ) ;
92- items [ nextActiveIndex ] . classList . add ( "o_dropdown_active" ) ;
93- scrollTo ( items [ nextActiveIndex ] , this . el . querySelector ( ".o_dropdown_menu" ) ) ;
94- }
95- }
96-
97- subscribeKeynav ( ) {
98- if ( this . hotkeyTokens . length ) {
99- return ;
100- }
101-
102- const subs = {
103- arrowup : ( ) => this . setActiveItem ( "PREV" ) ,
104- arrowdown : ( ) => this . setActiveItem ( "NEXT" ) ,
105- "shift+arrowup" : ( ) => this . setActiveItem ( "FIRST" ) ,
106- "shift+arrowdown" : ( ) => this . setActiveItem ( "LAST" ) ,
107- enter : ( ) => {
108- const activeItem = this . el . querySelector (
109- ":scope > ul.o_dropdown_menu > .o_dropdown_item.o_dropdown_active"
110- ) ;
111- if ( activeItem ) {
112- activeItem . click ( ) ;
113- }
114- } ,
115- escape : this . close . bind ( this ) ,
116- } ;
117-
118- this . hotkeyTokens = [ ] ;
119- for ( const [ hotkey , callback ] of Object . entries ( subs ) ) {
120- this . hotkeyTokens . push (
121- this . hotkeyService . registerHotkey ( hotkey , callback , {
122- altIsOptional : true ,
123- allowRepeat : true ,
124- } )
125- ) ;
126- }
127- }
128-
129- unsubscribeKeynav ( ) {
130- this . hotkeyTokens . forEach ( ( tokenId ) => this . hotkeyService . unregisterHotkey ( tokenId ) ) ;
131- this . hotkeyTokens = [ ] ;
132- }
133-
134165 close ( ) {
135166 return this . changeStateAndNotify ( { open : false , groupIsOpen : false } ) ;
136167 }
137168
169+ /**
170+ * Opens the dropdown.
171+ *
172+ * @returns {Promise<void> }
173+ */
138174 open ( ) {
139175 return this . changeStateAndNotify ( { open : true , groupIsOpen : true } ) ;
140176 }
141177
178+ /**
179+ * Toggles the dropdown open state.
180+ *
181+ * @returns {Promise<void> }
182+ */
142183 toggle ( ) {
143184 const toggled = ! this . state . open ;
144- return this . changeStateAndNotify ( {
145- open : toggled ,
146- groupIsOpen : toggled ,
147- } ) ;
185+ return this . changeStateAndNotify ( { open : toggled , groupIsOpen : toggled } ) ;
148186 }
149187
150- // ---------------------------------------------------------------------------
188+ // -------------------------------------------------------------------------
151189 // Handlers
152- // ---------------------------------------------------------------------------
190+ // -------------------------------------------------------------------------
153191
192+ /**
193+ * Checks if should close on dropdown item selection.
194+ *
195+ * @param {CustomEvent<import("./dropdown_item").DropdownItemSelectedEventDetail> } ev
196+ */
154197 onItemSelected ( ev ) {
155198 // Handle parent closing request
156199 const { dropdownClosingRequest } = ev . detail ;
@@ -167,15 +210,17 @@ export class Dropdown extends Component {
167210
168211 /**
169212 * Dropdowns react to each other state changes through this method.
213+ *
214+ * All state changes must trigger on the bus, except when reacting to
215+ * another dropdown state change.
216+ *
217+ * @see changeStateAndNotify()
218+ *
219+ * @param {DropdownStateChangedPayload } args
170220 */
171221 onDropdownStateChanged ( args ) {
172- if ( args . emitter . el === this . el ) {
173- // Do not listen to my own events
174- return ;
175- }
176-
177222 if ( this . el . contains ( args . emitter . el ) ) {
178- // Do not listen to events emitted by children
223+ // Do not listen to events emitted by self or children
179224 return ;
180225 }
181226
@@ -196,10 +241,17 @@ export class Dropdown extends Component {
196241 }
197242 }
198243
244+ /**
245+ * Toggles the dropdown on its toggler click.
246+ */
199247 onTogglerClick ( ) {
200248 this . toggle ( ) ;
201249 }
202250
251+ /**
252+ * Opens the dropdown the mous enters its toggler.
253+ * NB: only if its siblings dropdown group is opened.
254+ */
203255 onTogglerMouseEnter ( ) {
204256 if ( this . state . groupIsOpen && ! this . state . open ) {
205257 this . open ( ) ;
@@ -208,26 +260,26 @@ export class Dropdown extends Component {
208260
209261 /**
210262 * Used to close ourself on outside click.
263+ *
264+ * @param {MouseEvent } ev
211265 */
212266 onWindowClicked ( ev ) {
213267 // Return if already closed
214- if ( ! this . state . open ) return ;
268+ if ( ! this . state . open ) {
269+ return ;
270+ }
215271 // Return if it's a different ui active element
216- if ( this . ui . activeElement !== this . myActiveEl ) return ;
217-
218- let element = ev . target ;
219- let gotClickedInside = false ;
220- do {
221- element = element . parentElement && element . parentElement . closest ( ".o_dropdown" ) ;
222- gotClickedInside = element === this . el ;
223- } while ( element && element . parentElement && ! gotClickedInside ) ;
272+ if ( this . ui . activeElement !== this . myActiveEl ) {
273+ return ;
274+ }
224275
276+ const gotClickedInside = this . el . contains ( ev . target ) ;
225277 if ( ! gotClickedInside ) {
226278 this . close ( ) ;
227279 }
228280 }
229281}
230- Dropdown . bus = new core . EventBus ( ) ;
282+ Dropdown . bus = new EventBus ( ) ;
231283Dropdown . props = {
232284 startOpen : {
233285 type : Boolean ,
0 commit comments