Skip to content

Commit 7ade282

Browse files
brboiaab-odoo
authored andcommitted
[REF] web: simplify dropdown components
This commit adds jsdoc and typings to the dropdown components and also rearranges a bit the code so it is cleaner and simpler. Most part of the diff is about the keynav feature. closes #802 Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
1 parent 8c4df84 commit 7ade282

File tree

2 files changed

+175
-103
lines changed

2 files changed

+175
-103
lines changed

addons/web/static/src/core/dropdown/dropdown.js

Lines changed: 153 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -6,151 +6,194 @@ import { scrollTo } from "../utils/scrolling";
66
import { ParentClosingMode } from "./dropdown_item";
77

88
const { Component, core, hooks, useState, QWeb } = owl;
9+
const { EventBus } = core;
910
const { 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+
*/
1127
export 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();
231283
Dropdown.props = {
232284
startOpen: {
233285
type: Boolean,

0 commit comments

Comments
 (0)