2d85def25fe55aada1000cf8bbedb07527723ab9
[WebKit-https.git] / Source / WebInspectorUI / UserInterface / Views / NavigationBar.js
1 /*
2  * Copyright (C) 2013 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 WebInspector.NavigationBar = function(element, navigationItems, role, label) {
27     // FIXME: Convert this to a WebInspector.Object subclass, and call super().
28     // WebInspector.Object.call(this);
29
30     this._element = element || document.createElement("div");
31     this._element.classList.add(this.constructor.StyleClassName || WebInspector.NavigationBar.StyleClassName);
32     this._element.tabIndex = 0;
33
34     if (role)
35         this._element.setAttribute("role", role);
36     if (label)
37         this._element.setAttribute("aria-label", label);
38
39     this._element.addEventListener("focus", this._focus.bind(this), false);
40     this._element.addEventListener("blur", this._blur.bind(this), false);
41     this._element.addEventListener("keydown", this._keyDown.bind(this), false);
42     this._element.addEventListener("mousedown", this._mouseDown.bind(this), false);
43
44     document.addEventListener("load", this.updateLayout.bind(this), false);
45
46     this._styleElement = document.createElement("style");
47
48     this._navigationItems = [];
49
50     if (navigationItems) {
51         for (var i = 0; i < navigationItems.length; ++i)
52             this.addNavigationItem(navigationItems[i]);
53     }
54
55     document.head.appendChild(this._styleElement);
56 };
57
58 // FIXME: Move to a WebInspector.Object subclass and we can remove this.
59 WebInspector.Object.deprecatedAddConstructorFunctions(WebInspector.NavigationBar);
60
61 WebInspector.NavigationBar.StyleClassName = "navigation-bar";
62 WebInspector.NavigationBar.CollapsedStyleClassName = "collapsed";
63
64 WebInspector.NavigationBar.Event = {
65     NavigationItemSelected: "navigation-bar-navigation-item-selected"
66 };
67
68 WebInspector.NavigationBar.prototype = {
69     constructor: WebInspector.NavigationBar,
70
71     // Public
72
73     addNavigationItem: function(navigationItem, parentElement)
74     {
75         return this.insertNavigationItem(navigationItem, this._navigationItems.length, parentElement);
76     },
77
78     insertNavigationItem: function(navigationItem, index, parentElement)
79     {
80         console.assert(navigationItem instanceof WebInspector.NavigationItem);
81         if (!(navigationItem instanceof WebInspector.NavigationItem))
82             return null;
83
84         if (navigationItem.parentNavigationBar)
85             navigationItem.parentNavigationBar.removeNavigationItem(navigationItem);
86
87         navigationItem._parentNavigationBar = this;
88
89         console.assert(index >= 0 && index <= this._navigationItems.length);
90         index = Math.max(0, Math.min(index, this._navigationItems.length));
91
92         this._navigationItems.splice(index, 0, navigationItem);
93
94         if (!parentElement)
95             parentElement = this._element;
96
97         var nextSibling = this._navigationItems[index + 1];
98         var nextSiblingElement = nextSibling ? nextSibling.element : null;
99         if (nextSiblingElement && nextSiblingElement.parentNode !== parentElement)
100             nextSiblingElement = null;
101
102         parentElement.insertBefore(navigationItem.element, nextSiblingElement);
103
104         this._minimumWidthNeedsRecalculation = true;
105         this._needsStyleUpdated = true;
106
107         this.updateLayoutSoon();
108
109         return navigationItem;
110     },
111
112     removeNavigationItem: function(navigationItemOrIdentifierOrIndex, index)
113     {
114         var navigationItem = this._findNavigationItem(navigationItemOrIdentifierOrIndex);
115         if (!navigationItem)
116             return null;
117
118         navigationItem._parentNavigationBar = null;
119
120         if (this._selectedNavigationItem === navigationItem)
121             this.selectedNavigationItem = null;
122
123         this._navigationItems.remove(navigationItem);
124         navigationItem.element.remove();
125
126         this._minimumWidthNeedsRecalculation = true;
127         this._needsStyleUpdated = true;
128
129         this.updateLayoutSoon();
130
131         return navigationItem;
132     },
133
134     updateLayoutSoon: function()
135     {
136         if (this._updateLayoutTimeout)
137             return;
138
139         this._needsLayout = true;
140
141         function update()
142         {
143             delete this._updateLayoutTimeout;
144
145             if (this._needsLayout || this._needsStyleUpdated)
146                 this.updateLayout();
147         }
148
149         this._updateLayoutTimeout = setTimeout(update.bind(this), 0);
150     },
151
152     updateLayout: function()
153     {
154         if (this._updateLayoutTimeout) {
155             clearTimeout(this._updateLayoutTimeout);
156             delete this._updateLayoutTimeout;
157         }
158
159         if (this._needsStyleUpdated)
160             this._updateStyle();
161
162         this._needsLayout = false;
163
164         if (typeof this.customUpdateLayout === "function") {
165             this.customUpdateLayout();
166             return;
167         }
168
169         // Remove the collapsed style class to test if the items can fit at full width.
170         this._element.classList.remove(WebInspector.NavigationBar.CollapsedStyleClassName);
171
172         // Tell each navigation item to update to full width if needed.
173         for (var i = 0; i < this._navigationItems.length; ++i)
174             this._navigationItems[i].updateLayout(true);
175
176         var totalItemWidth = 0;
177         for (var i = 0; i < this._navigationItems.length; ++i) {
178             // Skip flexible space items since they can take up no space at the minimum width.
179             if (this._navigationItems[i] instanceof WebInspector.FlexibleSpaceNavigationItem)
180                 continue;
181
182             totalItemWidth += this._navigationItems[i].element.offsetWidth;
183         }
184
185         var barWidth = this._element.offsetWidth;
186
187         // Add the collapsed class back if the items are wider than the bar.
188         if (totalItemWidth > barWidth)
189             this._element.classList.add(WebInspector.NavigationBar.CollapsedStyleClassName);
190
191         // Give each navigation item the opportunity to collapse further.
192         for (var i = 0; i < this._navigationItems.length; ++i)
193             this._navigationItems[i].updateLayout();
194     },
195
196     get selectedNavigationItem()
197     {
198         return this._selectedNavigationItem || null;
199     },
200
201     set selectedNavigationItem(navigationItemOrIdentifierOrIndex)
202     {
203         var navigationItem = this._findNavigationItem(navigationItemOrIdentifierOrIndex);
204
205         // Only radio navigation items can be selected.
206         if (!(navigationItem instanceof WebInspector.RadioButtonNavigationItem))
207             navigationItem = null;
208
209         if (this._selectedNavigationItem === navigationItem)
210             return;
211
212         if (this._selectedNavigationItem)
213             this._selectedNavigationItem.selected = false;
214
215         this._selectedNavigationItem = navigationItem || null;
216
217         if (this._selectedNavigationItem)
218             this._selectedNavigationItem.selected = true;
219
220         // When the mouse is down don't dispatch the selected event, it will be dispatched on mouse up.
221         // This prevents sending the event while the user is scrubbing the bar.
222         if (!this._mouseIsDown)
223             this.dispatchEventToListeners(WebInspector.NavigationBar.Event.NavigationItemSelected);
224     },
225
226     get navigationItems()
227     {
228         return this._navigationItems;
229     },
230
231     get element()
232     {
233         return this._element;
234     },
235
236     get minimumWidth()
237     {
238         if (this._minimumWidth === undefined || this._minimumWidthNeedsRecalculation) {
239             this._minimumWidth = this._calculateMinimumWidth();
240             delete this._minimumWidthNeedsRecalculation;
241         }
242
243         return this._minimumWidth;
244     },
245
246     get sizesToFit()
247     {
248         // Can be overriden by subclasses.
249         return false;
250     },
251
252     // Private
253
254     _findNavigationItem: function(navigationItemOrIdentifierOrIndex)
255     {
256         var navigationItem = null;
257
258         if (navigationItemOrIdentifierOrIndex instanceof WebInspector.NavigationItem) {
259             if (this._navigationItems.includes(navigationItemOrIdentifierOrIndex))
260                 navigationItem = navigationItemOrIdentifierOrIndex;
261         } else if (typeof navigationItemOrIdentifierOrIndex === "number") {
262             navigationItem = this._navigationItems[navigationItemOrIdentifierOrIndex];
263         } else if (typeof navigationItemOrIdentifierOrIndex === "string") {
264             for (var i = 0; i < this._navigationItems.length; ++i) {
265                 if (this._navigationItems[i].identifier === navigationItemOrIdentifierOrIndex) {
266                     navigationItem = this._navigationItems[i];
267                     break;
268                 }
269             }
270         }
271
272         return navigationItem;
273     },
274
275     _mouseDown: function(event)
276     {
277         // Only handle left mouse clicks.
278         if (event.button !== 0)
279             return;
280
281         // Remove the tabIndex so clicking the navigation bar does not give it focus.
282         // Only keep the tabIndex if already focused from keyboard navigation. This matches Xcode.
283         if (!this._focused)
284             this._element.removeAttribute("tabindex");
285
286         var itemElement = event.target.enclosingNodeOrSelfWithClass(WebInspector.RadioButtonNavigationItem.StyleClassName);
287         if (!itemElement || !itemElement.navigationItem)
288             return;
289
290         this._previousSelectedNavigationItem = this.selectedNavigationItem;
291         this.selectedNavigationItem = itemElement.navigationItem;
292
293         this._mouseIsDown = true;
294
295         this._mouseMovedEventListener = this._mouseMoved.bind(this);
296         this._mouseUpEventListener = this._mouseUp.bind(this);
297
298         // Register these listeners on the document so we can track the mouse if it leaves the resizer.
299         document.addEventListener("mousemove", this._mouseMovedEventListener, false);
300         document.addEventListener("mouseup", this._mouseUpEventListener, false);
301
302         event.preventDefault();
303         event.stopPropagation();
304     },
305
306     _mouseMoved: function(event)
307     {
308         console.assert(event.button === 0);
309         console.assert(this._mouseIsDown);
310         if (!this._mouseIsDown)
311             return;
312
313         event.preventDefault();
314         event.stopPropagation();
315
316         var itemElement = event.target.enclosingNodeOrSelfWithClass(WebInspector.RadioButtonNavigationItem.StyleClassName);
317         if (!itemElement || !itemElement.navigationItem) {
318             // Find the element that is at the X position of the mouse, even when the mouse is no longer
319             // vertically in the navigation bar.
320             var element = document.elementFromPoint(event.pageX, this._element.totalOffsetTop);
321             if (!element)
322                 return;
323
324             itemElement = element.enclosingNodeOrSelfWithClass(WebInspector.RadioButtonNavigationItem.StyleClassName);
325             if (!itemElement || !itemElement.navigationItem)
326                 return;
327         }
328
329         if (this.selectedNavigationItem)
330             this.selectedNavigationItem.active = false;
331
332         this.selectedNavigationItem = itemElement.navigationItem;
333
334         this.selectedNavigationItem.active = true;
335     },
336
337     _mouseUp: function(event)
338     {
339         console.assert(event.button === 0);
340         console.assert(this._mouseIsDown);
341         if (!this._mouseIsDown)
342             return;
343
344         if (this.selectedNavigationItem)
345             this.selectedNavigationItem.active = false;
346
347         this._mouseIsDown = false;
348
349         document.removeEventListener("mousemove", this._mouseMovedEventListener, false);
350         document.removeEventListener("mouseup", this._mouseUpEventListener, false);
351
352         delete this._mouseMovedEventListener;
353         delete this._mouseUpEventListener;
354
355         // Restore the tabIndex so the navigation bar can be in the keyboard tab loop.
356         this._element.tabIndex = 0;
357
358         // Dispatch the selected event here since the selectedNavigationItem setter surpresses it
359         // while the mouse is down to prevent sending it while scrubbing the bar.
360         if (this._previousSelectedNavigationItem !== this.selectedNavigationItem)
361             this.dispatchEventToListeners(WebInspector.NavigationBar.Event.NavigationItemSelected);
362
363         delete this._previousSelectedNavigationItem;
364
365         event.preventDefault();
366         event.stopPropagation();
367     },
368
369     _keyDown: function(event)
370     {
371         if (!this._focused)
372             return;
373
374         if (event.keyIdentifier !== "Left" && event.keyIdentifier !== "Right")
375             return;
376
377         event.preventDefault();
378         event.stopPropagation();
379
380         var selectedNavigationItemIndex = this._navigationItems.indexOf(this._selectedNavigationItem);
381
382         if (event.keyIdentifier === "Left") {
383             if (selectedNavigationItemIndex === -1)
384                 selectedNavigationItemIndex = this._navigationItems.length;
385
386             do {
387                 selectedNavigationItemIndex = Math.max(0, selectedNavigationItemIndex - 1);
388             } while (selectedNavigationItemIndex && !(this._navigationItems[selectedNavigationItemIndex] instanceof WebInspector.RadioButtonNavigationItem));
389         } else if (event.keyIdentifier === "Right") {
390             do {
391                 selectedNavigationItemIndex = Math.min(selectedNavigationItemIndex + 1, this._navigationItems.length - 1);
392             } while (selectedNavigationItemIndex < this._navigationItems.length - 1 && !(this._navigationItems[selectedNavigationItemIndex] instanceof WebInspector.RadioButtonNavigationItem));
393         }
394
395         if (!(this._navigationItems[selectedNavigationItemIndex] instanceof WebInspector.RadioButtonNavigationItem))
396             return;
397
398         this.selectedNavigationItem = this._navigationItems[selectedNavigationItemIndex];
399     },
400
401     _focus: function(event)
402     {
403         this._focused = true;
404     },
405
406     _blur: function(event)
407     {
408         this._focused = false;
409     },
410
411     _updateStyle: function()
412     {
413         this._needsStyleUpdated = false;
414
415         var parentSelector = "." + (this.constructor.StyleClassName || WebInspector.NavigationBar.StyleClassName);
416
417         var styleText = "";
418         for (var i = 0; i < this._navigationItems.length; ++i) {
419             if (!this._navigationItems[i].generateStyleText)
420                 continue;
421             if (styleText)
422                 styleText += "\n";
423             styleText += this._navigationItems[i].generateStyleText(parentSelector);
424         }
425
426         this._styleElement.textContent = styleText;
427     },
428
429     _calculateMinimumWidth: function()
430     {
431         var wasCollapsed = this._element.classList.contains(WebInspector.NavigationBar.CollapsedStyleClassName);
432
433         // Add the collapsed style class to calculate the width of the items when they are collapsed.
434         if (!wasCollapsed)
435             this._element.classList.add(WebInspector.NavigationBar.CollapsedStyleClassName);
436
437         var totalItemWidth = 0;
438         for (var i = 0; i < this._navigationItems.length; ++i) {
439             // Skip flexible space items since they can take up no space at the minimum width.
440             if (this._navigationItems[i] instanceof WebInspector.FlexibleSpaceNavigationItem)
441                 continue;
442             totalItemWidth += this._navigationItems[i].element.offsetWidth;
443         }
444
445         // Remove the collapsed style class if we were not collapsed before.
446         if (!wasCollapsed)
447             this._element.classList.remove(WebInspector.NavigationBar.CollapsedStyleClassName);
448
449         return totalItemWidth;
450     }
451 };
452
453 WebInspector.NavigationBar.prototype.__proto__ = WebInspector.Object.prototype;