Web Inspector: REGRESSION (r244157): Timelines: ruler size appears wrong on first...
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Views / NavigationBar.js
1 /*
2  * Copyright (C) 2013, 2015 Apple Inc. All rights reserved.
3  *
4  * Redistribution and use in source and binary forms, with or without
5  * modification, are permitted provided that the following conditions
6  * are met:
7  * 1. Redistributions of source code must retain the above copyright
8  *    notice, this list of conditions and the following disclaimer.
9  * 2. Redistributions in binary form must reproduce the above copyright
10  *    notice, this list of conditions and the following disclaimer in the
11  *    documentation and/or other materials provided with the distribution.
12  *
13  * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
14  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
15  * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
16  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
17  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
23  * THE POSSIBILITY OF SUCH DAMAGE.
24  */
25
26 WI.NavigationBar = class NavigationBar extends WI.View
27 {
28     constructor(element, navigationItems, role, label)
29     {
30         super(element);
31
32         this.element.classList.add(this.constructor.StyleClassName || "navigation-bar");
33         this.element.tabIndex = 0;
34
35         if (role)
36             this.element.setAttribute("role", role);
37         if (label)
38             this.element.setAttribute("aria-label", label);
39
40         this.element.addEventListener("focus", this._focus.bind(this), false);
41         this.element.addEventListener("blur", this._blur.bind(this), false);
42         this.element.addEventListener("keydown", this._keyDown.bind(this), false);
43         this.element.addEventListener("mousedown", this._mouseDown.bind(this), false);
44
45         this._mouseMovedEventListener = this._mouseMoved.bind(this);
46         this._mouseUpEventListener = this._mouseUp.bind(this);
47
48         this._forceLayout = false;
49         this._minimumWidth = NaN;
50         this._navigationItems = [];
51         this._selectedNavigationItem = null;
52
53         if (navigationItems) {
54             for (var i = 0; i < navigationItems.length; ++i)
55                 this.addNavigationItem(navigationItems[i]);
56         }
57     }
58
59     // Public
60
61     addNavigationItem(navigationItem, parentElement)
62     {
63         return this.insertNavigationItem(navigationItem, this._navigationItems.length, parentElement);
64     }
65
66     insertNavigationItem(navigationItem, index, parentElement)
67     {
68         console.assert(navigationItem instanceof WI.NavigationItem);
69         if (!(navigationItem instanceof WI.NavigationItem))
70             return null;
71
72         if (navigationItem.parentNavigationBar)
73             navigationItem.parentNavigationBar.removeNavigationItem(navigationItem);
74
75         navigationItem.didAttach(this);
76
77         console.assert(index >= 0 && index <= this._navigationItems.length);
78         index = Math.max(0, Math.min(index, this._navigationItems.length));
79
80         this._navigationItems.splice(index, 0, navigationItem);
81
82         if (!parentElement)
83             parentElement = this.element;
84
85         var nextSibling = this._navigationItems[index + 1];
86         var nextSiblingElement = nextSibling ? nextSibling.element : null;
87         if (nextSiblingElement && nextSiblingElement.parentNode !== parentElement)
88             nextSiblingElement = null;
89
90         parentElement.insertBefore(navigationItem.element, nextSiblingElement);
91
92         this._minimumWidth = NaN;
93
94         this.needsLayout();
95
96         return navigationItem;
97     }
98
99     removeNavigationItem(navigationItem)
100     {
101         console.assert(navigationItem instanceof WI.NavigationItem);
102         if (!(navigationItem instanceof WI.NavigationItem))
103             return null;
104
105         if (!navigationItem._parentNavigationBar)
106             return null;
107
108         console.assert(navigationItem._parentNavigationBar === this, "Cannot remove item with unexpected parent bar.", navigationItem);
109         if (navigationItem._parentNavigationBar !== this)
110             return null;
111
112         navigationItem.didDetach();
113
114         if (this._selectedNavigationItem === navigationItem)
115             this.selectedNavigationItem = null;
116
117         this._navigationItems.remove(navigationItem);
118         navigationItem.element.remove();
119
120         this._minimumWidth = NaN;
121
122         this.needsLayout();
123
124         return navigationItem;
125     }
126
127     get selectedNavigationItem()
128     {
129         return this._selectedNavigationItem;
130     }
131
132     set selectedNavigationItem(navigationItem)
133     {
134         let navigationItemHasOtherParent = navigationItem && navigationItem.parentNavigationBar !== this;
135         console.assert(!navigationItemHasOtherParent, "Cannot select item with unexpected parent bar.", navigationItem);
136         if (navigationItemHasOtherParent)
137             return;
138
139         // Only radio navigation items can be selected.
140         if (!(navigationItem instanceof WI.RadioButtonNavigationItem))
141             navigationItem = null;
142
143         if (this._selectedNavigationItem === navigationItem)
144             return;
145
146         if (this._selectedNavigationItem)
147             this._selectedNavigationItem.selected = false;
148
149         this._selectedNavigationItem = navigationItem || null;
150
151         if (this._selectedNavigationItem)
152             this._selectedNavigationItem.selected = true;
153
154         // When the mouse is down don't dispatch the selected event, it will be dispatched on mouse up.
155         // This prevents sending the event while the user is scrubbing the bar.
156         if (!this._mouseIsDown)
157             this.dispatchEventToListeners(WI.NavigationBar.Event.NavigationItemSelected);
158     }
159
160     get navigationItems()
161     {
162         return this._navigationItems;
163     }
164
165     get minimumWidth()
166     {
167         if (isNaN(this._minimumWidth))
168             this._minimumWidth = this._calculateMinimumWidth();
169         return this._minimumWidth;
170     }
171
172     get sizesToFit()
173     {
174         // Can be overridden by subclasses.
175         return false;
176     }
177
178     findNavigationItem(identifier)
179     {
180         function matchingSelfOrChild(item) {
181             if (item.identifier === identifier)
182                 return item;
183
184             if (item instanceof WI.GroupNavigationItem) {
185                 for (let childItem of item.navigationItems) {
186                     let result = matchingSelfOrChild(childItem);
187                     if (result)
188                         return result;
189                 }
190             }
191
192             return null;
193         }
194
195         for (let item of this._navigationItems) {
196             let result = matchingSelfOrChild(item);
197             if (result)
198                 return result;
199         }
200
201         return null;
202     }
203
204     needsLayout()
205     {
206         this._forceLayout = true;
207
208         super.needsLayout();
209     }
210
211     sizeDidChange()
212     {
213         super.sizeDidChange();
214
215         this._updateContent();
216     }
217
218     layout()
219     {
220         super.layout();
221
222         if (!this._forceLayout)
223             return;
224
225         this._updateContent();
226     }
227
228     // Private
229
230     _updateContent()
231     {
232         this._forceLayout = false;
233
234         // Remove the collapsed style class to test if the items can fit at full width.
235         this.element.classList.remove(WI.NavigationBar.CollapsedStyleClassName);
236
237         function forceItemHidden(item, hidden) {
238             item[WI.NavigationBar.ForceHiddenSymbol] = hidden;
239             item.element.classList.toggle("force-hidden", hidden);
240         }
241
242         function isDivider(item) {
243             return item instanceof WI.DividerNavigationItem;
244         }
245
246         // Tell each navigation item to update to full width if needed.
247         for (let item of this._navigationItems) {
248             forceItemHidden(item, false);
249             item.updateLayout(true);
250         }
251
252         if (this.sizesToFit)
253             return;
254
255         let visibleNavigationItems = this._visibleNavigationItems;
256
257         function calculateVisibleItemWidth() {
258             return visibleNavigationItems.reduce((total, item) => total + item.width, 0);
259         }
260
261         let totalItemWidth = calculateVisibleItemWidth();
262
263         const barWidth = this.element.realOffsetWidth;
264
265         // Add the collapsed class back if the items are wider than the bar.
266         if (totalItemWidth > barWidth)
267             this.element.classList.add(WI.NavigationBar.CollapsedStyleClassName);
268
269         // Give each navigation item the opportunity to collapse further.
270         for (let item of visibleNavigationItems)
271             item.updateLayout(false);
272
273         totalItemWidth = calculateVisibleItemWidth();
274
275         if (totalItemWidth > barWidth) {
276             // Hide visible items, starting with the lowest priority item, until
277             // the bar fits the available width.
278             visibleNavigationItems.sort((a, b) => a.visibilityPriority - b.visibilityPriority);
279
280             while (totalItemWidth > barWidth && visibleNavigationItems.length) {
281                 let navigationItem = visibleNavigationItems.shift();
282                 totalItemWidth -= navigationItem.width;
283                 forceItemHidden(navigationItem, true);
284             }
285
286             visibleNavigationItems = this._visibleNavigationItems;
287         }
288
289         // Hide leading, trailing, and consecutive dividers.
290         let previousItem = null;
291         for (let item of visibleNavigationItems) {
292             if (isDivider(item) && (!previousItem || isDivider(previousItem))) {
293                 forceItemHidden(item);
294                 continue;
295             }
296
297             previousItem = item;
298         }
299
300         if (isDivider(previousItem))
301             forceItemHidden(previousItem);
302     }
303
304     _mouseDown(event)
305     {
306         // Only handle left mouse clicks.
307         if (event.button !== 0)
308             return;
309
310         // Remove the tabIndex so clicking the navigation bar does not give it focus.
311         // Only keep the tabIndex if already focused from keyboard navigation. This matches Xcode.
312         if (!this._focused)
313             this.element.removeAttribute("tabindex");
314
315         var itemElement = event.target.closest("." + WI.RadioButtonNavigationItem.StyleClassName);
316         if (!itemElement || !itemElement.navigationItem)
317             return;
318
319         this._previousSelectedNavigationItem = this.selectedNavigationItem;
320         this.selectedNavigationItem = itemElement.navigationItem;
321
322         this._mouseIsDown = true;
323
324         if (typeof this.selectedNavigationItem.dontPreventDefaultOnNavigationBarMouseDown === "function"
325             && this.selectedNavigationItem.dontPreventDefaultOnNavigationBarMouseDown()
326             && this._previousSelectedNavigationItem === this.selectedNavigationItem)
327             return;
328
329         // Register these listeners on the document so we can track the mouse if it leaves the navigation bar.
330         document.addEventListener("mousemove", this._mouseMovedEventListener, false);
331         document.addEventListener("mouseup", this._mouseUpEventListener, false);
332
333         event.preventDefault();
334         event.stopPropagation();
335     }
336
337     _mouseMoved(event)
338     {
339         console.assert(event.button === 0);
340         console.assert(this._mouseIsDown);
341         if (!this._mouseIsDown)
342             return;
343
344         event.preventDefault();
345         event.stopPropagation();
346
347         var itemElement = event.target.closest("." + WI.RadioButtonNavigationItem.StyleClassName);
348         if (!itemElement || !itemElement.navigationItem || !this.element.contains(itemElement)) {
349             // Find the element that is at the X position of the mouse, even when the mouse is no longer
350             // vertically in the navigation bar.
351             var element = document.elementFromPoint(event.pageX, this.element.totalOffsetTop + (this.element.offsetHeight / 2));
352             if (!element)
353                 return;
354
355             itemElement = element.closest("." + WI.RadioButtonNavigationItem.StyleClassName);
356             if (!itemElement || !itemElement.navigationItem || !this.element.contains(itemElement))
357                 return;
358         }
359
360         if (this.selectedNavigationItem)
361             this.selectedNavigationItem.active = false;
362
363         this.selectedNavigationItem = itemElement.navigationItem;
364
365         this.selectedNavigationItem.active = true;
366     }
367
368     _mouseUp(event)
369     {
370         console.assert(event.button === 0);
371         console.assert(this._mouseIsDown);
372         if (!this._mouseIsDown)
373             return;
374
375         if (this.selectedNavigationItem)
376             this.selectedNavigationItem.active = false;
377
378         this._mouseIsDown = false;
379
380         document.removeEventListener("mousemove", this._mouseMovedEventListener, false);
381         document.removeEventListener("mouseup", this._mouseUpEventListener, false);
382
383         // Restore the tabIndex so the navigation bar can be in the keyboard tab loop.
384         this.element.tabIndex = 0;
385
386         // Dispatch the selected event here since the selectedNavigationItem setter surpresses it
387         // while the mouse is down to prevent sending it while scrubbing the bar.
388         if (this._previousSelectedNavigationItem !== this.selectedNavigationItem)
389             this.dispatchEventToListeners(WI.NavigationBar.Event.NavigationItemSelected);
390
391         delete this._previousSelectedNavigationItem;
392
393         event.preventDefault();
394         event.stopPropagation();
395     }
396
397     _keyDown(event)
398     {
399         if (!this._focused)
400             return;
401
402         if (event.keyIdentifier !== "Left" && event.keyIdentifier !== "Right")
403             return;
404
405         event.preventDefault();
406         event.stopPropagation();
407
408         var selectedNavigationItemIndex = this._navigationItems.indexOf(this._selectedNavigationItem);
409
410         if (event.keyIdentifier === "Left") {
411             if (selectedNavigationItemIndex === -1)
412                 selectedNavigationItemIndex = this._navigationItems.length;
413
414             do {
415                 selectedNavigationItemIndex = Math.max(0, selectedNavigationItemIndex - 1);
416             } while (selectedNavigationItemIndex && !(this._navigationItems[selectedNavigationItemIndex] instanceof WI.RadioButtonNavigationItem));
417         } else if (event.keyIdentifier === "Right") {
418             do {
419                 selectedNavigationItemIndex = Math.min(selectedNavigationItemIndex + 1, this._navigationItems.length - 1);
420             } while (selectedNavigationItemIndex < this._navigationItems.length - 1 && !(this._navigationItems[selectedNavigationItemIndex] instanceof WI.RadioButtonNavigationItem));
421         }
422
423         if (!(this._navigationItems[selectedNavigationItemIndex] instanceof WI.RadioButtonNavigationItem))
424             return;
425
426         this.selectedNavigationItem = this._navigationItems[selectedNavigationItemIndex];
427     }
428
429     _focus(event)
430     {
431         this._focused = true;
432     }
433
434     _blur(event)
435     {
436         this._focused = false;
437     }
438
439     _calculateMinimumWidth()
440     {
441         let visibleNavigationItems = this._visibleNavigationItems;
442         if (!visibleNavigationItems.length)
443             return 0;
444
445         const wasCollapsed = this.element.classList.contains(WI.NavigationBar.CollapsedStyleClassName);
446
447         // Add the collapsed style class to calculate the width of the items when they are collapsed.
448         if (!wasCollapsed)
449             this.element.classList.add(WI.NavigationBar.CollapsedStyleClassName);
450
451         let totalItemWidth = visibleNavigationItems.reduce((total, item) => total + item.minimumWidth, 0);
452
453         // Remove the collapsed style class if we were not collapsed before.
454         if (!wasCollapsed)
455             this.element.classList.remove(WI.NavigationBar.CollapsedStyleClassName);
456
457         return totalItemWidth;
458     }
459
460     get _visibleNavigationItems()
461     {
462         return this._navigationItems.filter((item) => {
463             if (item instanceof WI.FlexibleSpaceNavigationItem)
464                 return false;
465             if (item.hidden || item[WI.NavigationBar.ForceHiddenSymbol])
466                 return false;
467             return true;
468         });
469     }
470 };
471
472 WI.NavigationBar.ForceHiddenSymbol = Symbol("force-hidden");
473
474 WI.NavigationBar.CollapsedStyleClassName = "collapsed";
475
476 WI.NavigationBar.Event = {
477     NavigationItemSelected: "navigation-bar-navigation-item-selected"
478 };