Source code

Revision control

Other Tools

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this
3
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
/**
6
* Allows a popup panel to host multiple subviews. The main view shown when the
7
* panel is opened may slide out to display a subview, which in turn may lead to
8
* other subviews in a cascade menu pattern.
9
*
10
* The <panel> element should contain a <panelmultiview> element. Views are
11
* declared using <panelview> elements that are usually children of the main
12
* <panelmultiview> element, although they don't need to be, as views can also
13
* be imported into the panel from other panels or popup sets.
14
*
15
* The panel should be opened asynchronously using the openPopup static method
16
* on the PanelMultiView object. This will display the view specified using the
17
* mainViewId attribute on the contained <panelmultiview> element.
18
*
19
* Specific subviews can slide in using the showSubView method, and backwards
20
* navigation can be done using the goBack method or through a button in the
21
* subview headers.
22
*
23
* The process of displaying the main view or a new subview requires multiple
24
* steps to be completed, hence at any given time the <panelview> element may
25
* be in different states:
26
*
27
* -- Open or closed
28
*
29
* All the <panelview> elements start "closed", meaning that they are not
30
* associated to a <panelmultiview> element and can be located anywhere in
31
* the document. When the openPopup or showSubView methods are called, the
32
* relevant view becomes "open" and the <panelview> element may be moved to
33
* ensure it is a descendant of the <panelmultiview> element.
34
*
35
* The "ViewShowing" event is fired at this point, when the view is not
36
* visible yet. The event is allowed to cancel the operation, in which case
37
* the view is closed immediately.
38
*
39
* Closing the view does not move the node back to its original position.
40
*
41
* -- Visible or invisible
42
*
43
* This indicates whether the view is visible in the document from a layout
44
* perspective, regardless of whether it is currently scrolled into view. In
45
* fact, all subviews are already visible before they start sliding in.
46
*
47
* Before scrolling into view, a view may become visible but be placed in a
48
* special off-screen area of the document where layout and measurements can
49
* take place asyncronously.
50
*
51
* When navigating forward, an open view may become invisible but stay open
52
* after sliding out of view. The last known size of these views is still
53
* taken into account for determining the overall panel size.
54
*
55
* When navigating backwards, an open subview will first become invisible and
56
* then will be closed.
57
*
58
* -- Active or inactive
59
*
60
* This indicates whether the view is fully scrolled into the visible area
61
* and ready to receive mouse and keyboard events. An active view is always
62
* visible, but a visible view may be inactive. For example, during a scroll
63
* transition, both views will be inactive.
64
*
65
* When a view becomes active, the ViewShown event is fired synchronously,
66
* and the showSubView and goBack methods can be called for navigation.
67
*
68
* For the main view of the panel, the ViewShown event is dispatched during
69
* the "popupshown" event, which means that other "popupshown" handlers may
70
* be called before the view is active. Thus, code that needs to perform
71
* further navigation automatically should either use the ViewShown event or
72
* wait for an event loop tick, like BrowserTestUtils.waitForEvent does.
73
*
74
* -- Navigating with the keyboard
75
*
76
* An open view may keep state related to keyboard navigation, even if it is
77
* invisible. When a view is closed, keyboard navigation state is cleared.
78
*
79
* This diagram shows how <panelview> nodes move during navigation:
80
*
81
* In this <panelmultiview> In other panels Action
82
* ┌───┬───┬───┐ ┌───┬───┐
83
* │(A)│ B │ C │ │ D │ E │ Open panel
84
* └───┴───┴───┘ └───┴───┘
85
* ┌───┬───┬───┐ ┌───┬───┐
86
* │{A}│(C)│ B │ │ D │ E │ Show subview C
87
* └───┴───┴───┘ └───┴───┘
88
* ┌───┬───┬───┬───┐ ┌───┐
89
* │{A}│{C}│(D)│ B │ │ E │ Show subview D
90
* └───┴───┴───┴───┘ └───┘
91
* │ ┌───┬───┬───┬───┐ ┌───┐
92
* │ │{A}│(C)│ D │ B │ │ E │ Go back
93
* │ └───┴───┴───┴───┘ └───┘
94
* │ │ │
95
* │ │ └── Currently visible view
96
* │ │ │
97
* └───┴───┴── Open views
98
*/
99
100
"use strict";
101
102
var EXPORTED_SYMBOLS = ["PanelMultiView", "PanelView"];
103
104
const { XPCOMUtils } = ChromeUtils.import(
106
);
107
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
108
ChromeUtils.defineModuleGetter(
109
this,
110
"CustomizableUI",
112
);
113
114
XPCOMUtils.defineLazyGetter(this, "gBundle", function() {
115
return Services.strings.createBundle(
117
);
118
});
119
120
/**
121
* Safety timeout after which asynchronous events will be canceled if any of the
122
* registered blockers does not return.
123
*/
124
const BLOCKERS_TIMEOUT_MS = 10000;
125
126
const TRANSITION_PHASES = Object.freeze({
127
START: 1,
128
PREPARE: 2,
129
TRANSITION: 3,
130
});
131
132
let gNodeToObjectMap = new WeakMap();
133
let gWindowsWithUnloadHandler = new WeakSet();
134
let gMultiLineElementsMap = new WeakMap();
135
136
/**
137
* Allows associating an object to a node lazily using a weak map.
138
*
139
* Classes deriving from this one may be easily converted to Custom Elements,
140
* although they would lose the ability of being associated lazily.
141
*/
142
var AssociatedToNode = class {
143
constructor(node) {
144
/**
145
* Node associated to this object.
146
*/
147
this.node = node;
148
149
/**
150
* This promise is resolved when the current set of blockers set by event
151
* handlers have all been processed.
152
*/
153
this._blockersPromise = Promise.resolve();
154
}
155
156
/**
157
* Retrieves the instance associated with the given node, constructing a new
158
* one if necessary. When the last reference to the node is released, the
159
* object instance will be garbage collected as well.
160
*/
161
static forNode(node) {
162
let associatedToNode = gNodeToObjectMap.get(node);
163
if (!associatedToNode) {
164
associatedToNode = new this(node);
165
gNodeToObjectMap.set(node, associatedToNode);
166
}
167
return associatedToNode;
168
}
169
170
get document() {
171
return this.node.ownerDocument;
172
}
173
174
get window() {
175
return this.node.ownerGlobal;
176
}
177
178
_getBoundsWithoutFlushing(element) {
179
return this.window.windowUtils.getBoundsWithoutFlushing(element);
180
}
181
182
/**
183
* Dispatches a custom event on this element.
184
*
185
* @param {String} eventName Name of the event to dispatch.
186
* @param {Object} [detail] Event detail object. Optional.
187
* @param {Boolean} cancelable If the event can be canceled.
188
* @return {Boolean} `true` if the event was canceled by an event handler, `false`
189
* otherwise.
190
*/
191
dispatchCustomEvent(eventName, detail, cancelable = false) {
192
let event = new this.window.CustomEvent(eventName, {
193
detail,
194
bubbles: true,
195
cancelable,
196
});
197
this.node.dispatchEvent(event);
198
return event.defaultPrevented;
199
}
200
201
/**
202
* Dispatches a custom event on this element and waits for any blocking
203
* promises registered using the "addBlocker" function on the details object.
204
* If this function is called again, the event is only dispatched after all
205
* the previously registered blockers have returned.
206
*
207
* The event can be canceled either by resolving any blocking promise to the
208
* boolean value "false" or by calling preventDefault on the event. Rejections
209
* and exceptions will be reported and will cancel the event.
210
*
211
* Blocking should be used sporadically because it slows down the interface.
212
* Also, non-reentrancy is not strictly guaranteed because a safety timeout of
213
* BLOCKERS_TIMEOUT_MS is implemented, after which the event will be canceled.
214
* This helps to prevent deadlocks if any of the event handlers does not
215
* resolve a blocker promise.
216
*
217
* @note Since there is no use case for dispatching different asynchronous
218
* events in parallel for the same element, this function will also wait
219
* for previous blockers when the event name is different.
220
*
221
* @param eventName
222
* Name of the custom event to dispatch.
223
*
224
* @resolves True if the event was canceled by a handler, false otherwise.
225
*/
226
async dispatchAsyncEvent(eventName) {
227
// Wait for all the previous blockers before dispatching the event.
228
let blockersPromise = this._blockersPromise.catch(() => {});
229
return (this._blockersPromise = blockersPromise.then(async () => {
230
let blockers = new Set();
231
let cancel = this.dispatchCustomEvent(
232
eventName,
233
{
234
addBlocker(promise) {
235
// Any exception in the blocker will cancel the operation.
236
blockers.add(
237
promise.catch(ex => {
238
Cu.reportError(ex);
239
return true;
240
})
241
);
242
},
243
},
244
true
245
);
246
if (blockers.size) {
247
let timeoutPromise = new Promise((resolve, reject) => {
248
this.window.setTimeout(reject, BLOCKERS_TIMEOUT_MS);
249
});
250
try {
251
let results = await Promise.race([
252
Promise.all(blockers),
253
timeoutPromise,
254
]);
255
cancel = cancel || results.some(result => result === false);
256
} catch (ex) {
257
Cu.reportError(
258
new Error(`One of the blockers for ${eventName} timed out.`)
259
);
260
return true;
261
}
262
}
263
return cancel;
264
}));
265
}
266
};
267
268
/**
269
* This is associated to <panelmultiview> elements.
270
*/
271
var PanelMultiView = class extends AssociatedToNode {
272
/**
273
* Tries to open the specified <panel> and displays the main view specified
274
* with the "mainViewId" attribute on the <panelmultiview> node it contains.
275
*
276
* If the panel does not contain a <panelmultiview>, it is opened directly.
277
* This allows consumers like page actions to accept different panel types.
278
*
279
* @see The non-static openPopup method for details.
280
*/
281
static async openPopup(panelNode, ...args) {
282
let panelMultiViewNode = panelNode.querySelector("panelmultiview");
283
if (panelMultiViewNode) {
284
return this.forNode(panelMultiViewNode).openPopup(...args);
285
}
286
panelNode.openPopup(...args);
287
return true;
288
}
289
290
/**
291
* Closes the specified <panel> which contains a <panelmultiview> node.
292
*
293
* If the panel does not contain a <panelmultiview>, it is closed directly.
294
* This allows consumers like page actions to accept different panel types.
295
*
296
* @see The non-static hidePopup method for details.
297
*/
298
static hidePopup(panelNode) {
299
let panelMultiViewNode = panelNode.querySelector("panelmultiview");
300
if (panelMultiViewNode) {
301
this.forNode(panelMultiViewNode).hidePopup();
302
} else {
303
panelNode.hidePopup();
304
}
305
}
306
307
/**
308
* Removes the specified <panel> from the document, ensuring that any
309
* <panelmultiview> node it contains is destroyed properly.
310
*
311
* If the viewCacheId attribute is present on the <panelmultiview> element,
312
* imported subviews will be moved out again to the element it specifies, so
313
* that the panel element can be removed safely.
314
*
315
* If the panel does not contain a <panelmultiview>, it is removed directly.
316
* This allows consumers like page actions to accept different panel types.
317
*/
318
static removePopup(panelNode) {
319
try {
320
let panelMultiViewNode = panelNode.querySelector("panelmultiview");
321
if (panelMultiViewNode) {
322
let panelMultiView = this.forNode(panelMultiViewNode);
323
panelMultiView._moveOutKids();
324
panelMultiView.disconnect();
325
}
326
} finally {
327
// Make sure to remove the panel element even if disconnecting fails.
328
panelNode.remove();
329
}
330
}
331
332
/**
333
* Ensures that when the specified window is closed all the <panelmultiview>
334
* node it contains are destroyed properly.
335
*/
336
static ensureUnloadHandlerRegistered(window) {
337
if (gWindowsWithUnloadHandler.has(window)) {
338
return;
339
}
340
341
window.addEventListener(
342
"unload",
343
() => {
344
for (let panelMultiViewNode of window.document.querySelectorAll(
345
"panelmultiview"
346
)) {
347
this.forNode(panelMultiViewNode).disconnect();
348
}
349
},
350
{ once: true }
351
);
352
353
gWindowsWithUnloadHandler.add(window);
354
}
355
356
get _panel() {
357
return this.node.parentNode;
358
}
359
360
set _transitioning(val) {
361
if (val) {
362
this.node.setAttribute("transitioning", "true");
363
} else {
364
this.node.removeAttribute("transitioning");
365
}
366
}
367
368
get _screenManager() {
369
if (this.__screenManager) {
370
return this.__screenManager;
371
}
372
return (this.__screenManager = Cc[
373
"@mozilla.org/gfx/screenmanager;1"
374
].getService(Ci.nsIScreenManager));
375
}
376
377
constructor(node) {
378
super(node);
379
this._openPopupPromise = Promise.resolve(false);
380
this._openPopupCancelCallback = () => {};
381
}
382
383
connect() {
384
this.connected = true;
385
386
PanelMultiView.ensureUnloadHandlerRegistered(this.window);
387
388
let viewContainer = (this._viewContainer = this.document.createXULElement(
389
"box"
390
));
391
viewContainer.classList.add("panel-viewcontainer");
392
393
let viewStack = (this._viewStack = this.document.createXULElement("box"));
394
viewStack.classList.add("panel-viewstack");
395
viewContainer.append(viewStack);
396
397
let offscreenViewContainer = this.document.createXULElement("box");
398
offscreenViewContainer.classList.add("panel-viewcontainer", "offscreen");
399
400
let offscreenViewStack = (this._offscreenViewStack = this.document.createXULElement(
401
"box"
402
));
403
offscreenViewStack.classList.add("panel-viewstack");
404
offscreenViewContainer.append(offscreenViewStack);
405
406
this.node.prepend(offscreenViewContainer);
407
this.node.prepend(viewContainer);
408
409
this.openViews = [];
410
411
this._panel.addEventListener("popupshowing", this);
412
this._panel.addEventListener("popuppositioned", this);
413
this._panel.addEventListener("popuphidden", this);
414
this._panel.addEventListener("popupshown", this);
415
416
// Proxy these public properties and methods, as used elsewhere by various
417
// parts of the browser, to this instance.
418
["goBack", "showSubView"].forEach(method => {
419
Object.defineProperty(this.node, method, {
420
enumerable: true,
421
value: (...args) => this[method](...args),
422
});
423
});
424
}
425
426
disconnect() {
427
// Guard against re-entrancy.
428
if (!this.node || !this.connected) {
429
return;
430
}
431
432
this._panel.removeEventListener("mousemove", this);
433
this._panel.removeEventListener("popupshowing", this);
434
this._panel.removeEventListener("popuppositioned", this);
435
this._panel.removeEventListener("popupshown", this);
436
this._panel.removeEventListener("popuphidden", this);
437
this.document.documentElement.removeEventListener("keydown", this, true);
438
this.node = this._openPopupPromise = this._openPopupCancelCallback = this._viewContainer = this._viewStack = this._transitionDetails = null;
439
}
440
441
/**
442
* Tries to open the panel associated with this PanelMultiView, and displays
443
* the main view specified with the "mainViewId" attribute.
444
*
445
* The hidePopup method can be called while the operation is in progress to
446
* prevent the panel from being displayed. View events may also cancel the
447
* operation, so there is no guarantee that the panel will become visible.
448
*
449
* The "popuphidden" event will be fired either when the operation is canceled
450
* or when the popup is closed later. This event can be used for example to
451
* reset the "open" state of the anchor or tear down temporary panels.
452
*
453
* If this method is called again before the panel is shown, the result
454
* depends on the operation currently in progress. If the operation was not
455
* canceled, the panel is opened using the arguments from the previous call,
456
* and this call is ignored. If the operation was canceled, it will be
457
* retried again using the arguments from this call.
458
*
459
* It's not necessary for the <panelmultiview> binding to be connected when
460
* this method is called, but the containing panel must have its display
461
* turned on, for example it shouldn't have the "hidden" attribute.
462
*
463
* @param anchor
464
* The node to anchor the popup to.
465
* @param options
466
* Either options to use or a string position. This is forwarded to
467
* the openPopup method of the panel.
468
* @param args
469
* Additional arguments to be forwarded to the openPopup method of the
470
* panel.
471
*
472
* @resolves With true as soon as the request to display the panel has been
473
* sent, or with false if the operation was canceled. The state of
474
* the panel at this point is not guaranteed. It may be still
475
* showing, completely shown, or completely hidden.
476
* @rejects If an exception is thrown at any point in the process before the
477
* request to display the panel is sent.
478
*/
479
async openPopup(anchor, options, ...args) {
480
// Set up the function that allows hidePopup or a second call to showPopup
481
// to cancel the specific panel opening operation that we're starting below.
482
// This function must be synchronous, meaning we can't use Promise.race,
483
// because hidePopup wants to dispatch the "popuphidden" event synchronously
484
// even if the panel has not been opened yet.
485
let canCancel = true;
486
let cancelCallback = (this._openPopupCancelCallback = () => {
487
// If the cancel callback is called and the panel hasn't been prepared
488
// yet, cancel showing it. Setting canCancel to false will prevent the
489
// popup from opening. If the panel has opened by the time the cancel
490
// callback is called, canCancel will be false already, and we will not
491
// fire the "popuphidden" event.
492
if (canCancel && this.node) {
493
canCancel = false;
494
this.dispatchCustomEvent("popuphidden");
495
}
496
});
497
498
// Create a promise that is resolved with the result of the last call to
499
// this method, where errors indicate that the panel was not opened.
500
let openPopupPromise = this._openPopupPromise.catch(() => {
501
return false;
502
});
503
504
// Make the preparation done before showing the panel non-reentrant. The
505
// promise created here will be resolved only after the panel preparation is
506
// completed, even if a cancellation request is received in the meantime.
507
return (this._openPopupPromise = openPopupPromise.then(async wasShown => {
508
// The panel may have been destroyed in the meantime.
509
if (!this.node) {
510
return false;
511
}
512
// If the panel has been already opened there is nothing more to do. We
513
// check the actual state of the panel rather than setting some state in
514
// our handler of the "popuphidden" event because this has a lower chance
515
// of locking indefinitely if events aren't raised in the expected order.
516
if (wasShown && ["open", "showing"].includes(this._panel.state)) {
517
return true;
518
}
519
try {
520
if (!this.connected) {
521
this.connect();
522
}
523
// Allow any of the ViewShowing handlers to prevent showing the main view.
524
if (!(await this._showMainView())) {
525
cancelCallback();
526
}
527
} catch (ex) {
528
cancelCallback();
529
throw ex;
530
}
531
// If a cancellation request was received there is nothing more to do.
532
if (!canCancel || !this.node) {
533
return false;
534
}
535
// We have to set canCancel to false before opening the popup because the
536
// hidePopup method of PanelMultiView can be re-entered by event handlers.
537
// If the openPopup call fails, however, we still have to dispatch the
538
// "popuphidden" event even if canCancel was set to false.
539
try {
540
canCancel = false;
541
this._panel.openPopup(anchor, options, ...args);
542
// Set an attribute on the popup to let consumers style popup elements -
543
// for example, the anchor arrow is styled to match the color of the header
544
// in the Protections Panel main view.
545
this._panel.setAttribute("mainviewshowing", true);
546
547
// On Windows, if another popup is hiding while we call openPopup, the
548
// call won't fail but the popup won't open. In this case, we have to
549
// dispatch an artificial "popuphidden" event to reset our state.
550
if (this._panel.state == "closed" && this.openViews.length) {
551
this.dispatchCustomEvent("popuphidden");
552
return false;
553
}
554
555
if (
556
options &&
557
typeof options == "object" &&
558
options.triggerEvent &&
559
options.triggerEvent.type == "keypress" &&
560
this.openViews.length
561
) {
562
// This was opened via the keyboard, so focus the first item.
563
this.openViews[0].focusWhenActive = true;
564
}
565
566
return true;
567
} catch (ex) {
568
this.dispatchCustomEvent("popuphidden");
569
throw ex;
570
}
571
}));
572
}
573
574
/**
575
* Closes the panel associated with this PanelMultiView.
576
*
577
* If the openPopup method was called but the panel has not been displayed
578
* yet, the operation is canceled and the panel will not be displayed, but the
579
* "popuphidden" event is fired synchronously anyways.
580
*
581
* This means that by the time this method returns all the operations handled
582
* by the "popuphidden" event are completed, for example resetting the "open"
583
* state of the anchor, and the panel is already invisible.
584
*/
585
hidePopup() {
586
if (!this.node || !this.connected) {
587
return;
588
}
589
590
// If we have already reached the _panel.openPopup call in the openPopup
591
// method, we can call hidePopup. Otherwise, we have to cancel the latest
592
// request to open the panel, which will have no effect if the request has
593
// been canceled already.
594
if (["open", "showing"].includes(this._panel.state)) {
595
this._panel.hidePopup();
596
} else {
597
this._openPopupCancelCallback();
598
}
599
600
// We close all the views synchronously, so that they are ready to be opened
601
// in other PanelMultiView instances. The "popuphidden" handler may also
602
// call this function, but the second time openViews will be empty.
603
this.closeAllViews();
604
}
605
606
/**
607
* Move any child subviews into the element defined by "viewCacheId" to make
608
* sure they will not be removed together with the <panelmultiview> element.
609
*/
610
_moveOutKids() {
611
let viewCacheId = this.node.getAttribute("viewCacheId");
612
if (!viewCacheId) {
613
return;
614
}
615
616
// Node.children and Node.children is live to DOM changes like the
617
// ones we're about to do, so iterate over a static copy:
618
let subviews = Array.from(this._viewStack.children);
619
let viewCache = this.document.getElementById(viewCacheId);
620
for (let subview of subviews) {
621
viewCache.appendChild(subview);
622
}
623
}
624
625
/**
626
* Slides in the specified view as a subview.
627
*
628
* @param viewIdOrNode
629
* DOM element or string ID of the <panelview> to display.
630
* @param anchor
631
* DOM element that triggered the subview, which will be highlighted
632
* and whose "label" attribute will be used for the title of the
633
* subview when a "title" attribute is not specified.
634
*/
635
showSubView(viewIdOrNode, anchor) {
636
// When autoPosition is true, the popup window manager would attempt to re-position
637
// the panel as subviews are opened and it changes size. The resulting popoppositioned
638
// events triggers the binding's arrow position adjustment - and its reflow.
639
// This is not needed here, as we calculated and set maxHeight so it is known
640
// to fit the screen while open.
641
// We do need autoposition for cases where the panel's anchor moves, which can happen
642
// especially with the "page actions" button in the URL bar (see bug 1520607), so
643
// we only set this to false when showing a subview, and set it back to true after we
644
// activate the subview.
645
this._panel.autoPosition = false;
646
647
this._showSubView(viewIdOrNode, anchor).catch(Cu.reportError);
648
}
649
async _showSubView(viewIdOrNode, anchor) {
650
let viewNode =
651
typeof viewIdOrNode == "string"
652
? this.document.getElementById(viewIdOrNode)
653
: viewIdOrNode;
654
if (!viewNode) {
655
Cu.reportError(new Error(`Subview ${viewIdOrNode} doesn't exist.`));
656
return;
657
}
658
659
if (!this.openViews.length) {
660
Cu.reportError(new Error(`Cannot show a subview in a closed panel.`));
661
return;
662
}
663
664
let prevPanelView = this.openViews[this.openViews.length - 1];
665
let nextPanelView = PanelView.forNode(viewNode);
666
if (this.openViews.includes(nextPanelView)) {
667
Cu.reportError(new Error(`Subview ${viewNode.id} is already open.`));
668
return;
669
}
670
671
// Do not re-enter the process if navigation is already in progress. Since
672
// there is only one active view at any given time, we can do this check
673
// safely, even considering that during the navigation process the actual
674
// view to which prevPanelView refers will change.
675
if (!prevPanelView.active) {
676
return;
677
}
678
// If prevPanelView._doingKeyboardActivation is true, it will be reset to
679
// false synchronously. Therefore, we must capture it before we use any
680
// "await" statements.
681
let doingKeyboardActivation = prevPanelView._doingKeyboardActivation;
682
// Marking the view that is about to scrolled out of the visible area as
683
// inactive will prevent re-entrancy and also disable keyboard navigation.
684
// From this point onwards, "await" statements can be used safely.
685
prevPanelView.active = false;
686
687
// Provide visual feedback while navigation is in progress, starting before
688
// the transition starts and ending when the previous view is invisible.
689
if (anchor) {
690
anchor.setAttribute("open", "true");
691
}
692
try {
693
// If the ViewShowing event cancels the operation we have to re-enable
694
// keyboard navigation, but this must be avoided if the panel was closed.
695
if (!(await this._openView(nextPanelView))) {
696
if (prevPanelView.isOpenIn(this)) {
697
// We don't raise a ViewShown event because nothing actually changed.
698
// Technically we should use a different state flag just because there
699
// is code that could check the "active" property to determine whether
700
// to wait for a ViewShown event later, but this only happens in
701
// regression tests and is less likely to be a technique used in
702
// production code, where use of ViewShown is less common.
703
prevPanelView.active = true;
704
}
705
return;
706
}
707
708
prevPanelView.captureKnownSize();
709
710
// The main view of a panel can be a subview in another one. Make sure to
711
// reset all the properties that may be set on a subview.
712
nextPanelView.mainview = false;
713
// The header may change based on how the subview was opened.
714
nextPanelView.headerText =
715
viewNode.getAttribute("title") ||
716
(anchor && anchor.getAttribute("label"));
717
// The constrained width of subviews may also vary between panels.
718
nextPanelView.minMaxWidth = prevPanelView.knownWidth;
719
720
if (anchor) {
721
viewNode.classList.add("PanelUI-subView");
722
}
723
724
await this._transitionViews(prevPanelView.node, viewNode, false, anchor);
725
} finally {
726
if (anchor) {
727
anchor.removeAttribute("open");
728
}
729
}
730
731
nextPanelView.focusWhenActive = doingKeyboardActivation;
732
this._activateView(nextPanelView);
733
}
734
735
/**
736
* Navigates backwards by sliding out the most recent subview.
737
*/
738
goBack() {
739
this._goBack().catch(Cu.reportError);
740
}
741
async _goBack() {
742
if (this.openViews.length < 2) {
743
// This may be called by keyboard navigation or external code when only
744
// the main view is open.
745
return;
746
}
747
748
let prevPanelView = this.openViews[this.openViews.length - 1];
749
let nextPanelView = this.openViews[this.openViews.length - 2];
750
751
// Like in the showSubView method, do not re-enter navigation while it is
752
// in progress, and make the view inactive immediately. From this point
753
// onwards, "await" statements can be used safely.
754
if (!prevPanelView.active) {
755
return;
756
}
757
prevPanelView.active = false;
758
759
prevPanelView.captureKnownSize();
760
await this._transitionViews(prevPanelView.node, nextPanelView.node, true);
761
762
this._closeLatestView();
763
764
this._activateView(nextPanelView);
765
}
766
767
/**
768
* Prepares the main view before showing the panel.
769
*/
770
async _showMainView() {
771
let nextPanelView = PanelView.forNode(
772
this.document.getElementById(this.node.getAttribute("mainViewId"))
773
);
774
775
// If the view is already open in another panel, close the panel first.
776
let oldPanelMultiViewNode = nextPanelView.node.panelMultiView;
777
if (oldPanelMultiViewNode) {
778
PanelMultiView.forNode(oldPanelMultiViewNode).hidePopup();
779
// Wait for a layout flush after hiding the popup, otherwise the view may
780
// not be displayed correctly for some time after the new panel is opened.
781
// This is filed as bug 1441015.
782
await this.window.promiseDocumentFlushed(() => {});
783
}
784
785
if (!(await this._openView(nextPanelView))) {
786
return false;
787
}
788
789
// The main view of a panel can be a subview in another one. Make sure to
790
// reset all the properties that may be set on a subview.
791
nextPanelView.mainview = true;
792
nextPanelView.headerText = "";
793
nextPanelView.minMaxWidth = 0;
794
795
// Ensure the view will be visible once the panel is opened.
796
nextPanelView.visible = true;
797
nextPanelView.descriptionHeightWorkaround();
798
799
return true;
800
}
801
802
/**
803
* Opens the specified PanelView and dispatches the ViewShowing event, which
804
* can be used to populate the subview or cancel the operation.
805
*
806
* This also clears all the attributes and styles that may be left by a
807
* transition that was interrupted.
808
*
809
* @resolves With true if the view was opened, false otherwise.
810
*/
811
async _openView(panelView) {
812
if (panelView.node.parentNode != this._viewStack) {
813
this._viewStack.appendChild(panelView.node);
814
}
815
816
panelView.node.panelMultiView = this.node;
817
this.openViews.push(panelView);
818
819
let canceled = await panelView.dispatchAsyncEvent("ViewShowing");
820
821
// The panel can be hidden while we are processing the ViewShowing event.
822
// This results in all the views being closed synchronously, and at this
823
// point the ViewHiding event has already been dispatched for all of them.
824
if (!this.openViews.length) {
825
return false;
826
}
827
828
// Check if the event requested cancellation but the panel is still open.
829
if (canceled) {
830
// Handlers for ViewShowing can't know if a different handler requested
831
// cancellation, so this will dispatch a ViewHiding event to give a chance
832
// to clean up.
833
this._closeLatestView();
834
return false;
835
}
836
837
// Clean up all the attributes and styles related to transitions. We do this
838
// here rather than when the view is closed because we are likely to make
839
// other DOM modifications soon, which isn't the case when closing.
840
let { style } = panelView.node;
841
style.removeProperty("outline");
842
style.removeProperty("width");
843
844
return true;
845
}
846
847
/**
848
* Activates the specified view and raises the ViewShown event, unless the
849
* view was closed in the meantime.
850
*/
851
_activateView(panelView) {
852
if (panelView.isOpenIn(this)) {
853
panelView.active = true;
854
if (panelView.focusWhenActive) {
855
panelView.focusFirstNavigableElement(false, true);
856
panelView.focusWhenActive = false;
857
}
858
panelView.dispatchCustomEvent("ViewShown");
859
860
// Re-enable panel autopositioning.
861
this._panel.autoPosition = true;
862
}
863
}
864
865
/**
866
* Closes the most recent PanelView and raises the ViewHiding event.
867
*
868
* @note The ViewHiding event is not cancelable and should probably be renamed
869
* to ViewHidden or ViewClosed instead, see bug 1438507.
870
*/
871
_closeLatestView() {
872
let panelView = this.openViews.pop();
873
panelView.clearNavigation();
874
panelView.dispatchCustomEvent("ViewHiding");
875
panelView.node.panelMultiView = null;
876
// Views become invisible synchronously when they are closed, and they won't
877
// become visible again until they are opened. When this is called at the
878
// end of backwards navigation, the view is already invisible.
879
panelView.visible = false;
880
}
881
882
/**
883
* Closes all the views that are currently open.
884
*/
885
closeAllViews() {
886
// Raise ViewHiding events for open views in reverse order.
887
while (this.openViews.length) {
888
this._closeLatestView();
889
}
890
}
891
892
/**
893
* Apply a transition to 'slide' from the currently active view to the next
894
* one.
895
* Sliding the next subview in means that the previous panelview stays where it
896
* is and the active panelview slides in from the left in LTR mode, right in
897
* RTL mode.
898
*
899
* @param {panelview} previousViewNode Node that is currently displayed, but
900
* is about to be transitioned away. This
901
* must be already inactive at this point.
902
* @param {panelview} viewNode Node that will becode the active view,
903
* after the transition has finished.
904
* @param {Boolean} reverse Whether we're navigation back to a
905
* previous view or forward to a next view.
906
*/
907
async _transitionViews(previousViewNode, viewNode, reverse) {
908
const { window } = this;
909
910
let nextPanelView = PanelView.forNode(viewNode);
911
let prevPanelView = PanelView.forNode(previousViewNode);
912
913
let details = (this._transitionDetails = {
914
phase: TRANSITION_PHASES.START,
915
});
916
917
// Set the viewContainer dimensions to make sure only the current view is
918
// visible.
919
let olderView = reverse ? nextPanelView : prevPanelView;
920
this._viewContainer.style.minHeight = olderView.knownHeight + "px";
921
this._viewContainer.style.height = prevPanelView.knownHeight + "px";
922
this._viewContainer.style.width = prevPanelView.knownWidth + "px";
923
// Lock the dimensions of the window that hosts the popup panel.
924
let rect = this._panel.getOuterScreenRect();
925
this._panel.setAttribute("width", rect.width);
926
this._panel.setAttribute("height", rect.height);
927
928
let viewRect;
929
if (reverse) {
930
// Use the cached size when going back to a previous view, but not when
931
// reopening a subview, because its contents may have changed.
932
viewRect = {
933
width: nextPanelView.knownWidth,
934
height: nextPanelView.knownHeight,
935
};
936
nextPanelView.visible = true;
937
} else if (viewNode.customRectGetter) {
938
// We use a customRectGetter for WebExtensions panels, because they need
939
// to query the size from an embedded browser. The presence of this
940
// getter also provides an indication that the view node shouldn't be
941
// moved around, otherwise the state of the browser would get disrupted.
942
let width = prevPanelView.knownWidth;
943
let height = prevPanelView.knownHeight;
944
viewRect = Object.assign({ height, width }, viewNode.customRectGetter());
945
nextPanelView.visible = true;
946
// Until the header is visible, it has 0 height.
947
// Wait for layout before measuring it
948
let header = viewNode.firstElementChild;
949
if (header && header.classList.contains("panel-header")) {
950
viewRect.height += await window.promiseDocumentFlushed(() => {
951
return this._getBoundsWithoutFlushing(header).height;
952
});
953
}
954
await nextPanelView.descriptionHeightWorkaround();
955
// Bail out if the panel was closed in the meantime.
956
if (!nextPanelView.isOpenIn(this)) {
957
return;
958
}
959
} else {
960
this._offscreenViewStack.style.minHeight = olderView.knownHeight + "px";
961
this._offscreenViewStack.appendChild(viewNode);
962
nextPanelView.visible = true;
963
964
// Now that the subview is visible, we can check the height of the
965
// description elements it contains.
966
await nextPanelView.descriptionHeightWorkaround();
967
968
viewRect = await window.promiseDocumentFlushed(() => {
969
return this._getBoundsWithoutFlushing(viewNode);
970
});
971
// Bail out if the panel was closed in the meantime.
972
if (!nextPanelView.isOpenIn(this)) {
973
return;
974
}
975
976
// Place back the view after all the other views that are already open in
977
// order for the transition to work as expected.
978
this._viewStack.appendChild(viewNode);
979
980
this._offscreenViewStack.style.removeProperty("min-height");
981
}
982
983
this._transitioning = true;
984
details.phase = TRANSITION_PHASES.PREPARE;
985
986
// The 'magic' part: build up the amount of pixels to move right or left.
987
let moveToLeft =
988
(this.window.RTL_UI && !reverse) || (!this.window.RTL_UI && reverse);
989
let deltaX = prevPanelView.knownWidth;
990
let deepestNode = reverse ? previousViewNode : viewNode;
991
992
// With a transition when navigating backwards - user hits the 'back'
993
// button - we need to make sure that the views are positioned in a way
994
// that a translateX() unveils the previous view from the right direction.
995
if (reverse) {
996
this._viewStack.style.marginInlineStart = "-" + deltaX + "px";
997
}
998
999
// Set the transition style and listen for its end to clean up and make sure
1000
// the box sizing becomes dynamic again.
1001
// Somehow, putting these properties in PanelUI.css doesn't work for newly
1002
// shown nodes in a XUL parent node.
1003
this._viewStack.style.transition =
1004
"transform var(--animation-easing-function)" +
1005
" var(--panelui-subview-transition-duration)";
1006
this._viewStack.style.willChange = "transform";
1007
// Use an outline instead of a border so that the size is not affected.
1008
deepestNode.style.outline = "1px solid var(--panel-separator-color)";
1009
1010
// Now that all the elements are in place for the start of the transition,
1011
// give the layout code a chance to set the initial values.
1012
await window.promiseDocumentFlushed(() => {});
1013
// Bail out if the panel was closed in the meantime.
1014
if (!nextPanelView.isOpenIn(this)) {
1015
return;
1016
}
1017
1018
// Now set the viewContainer dimensions to that of the new view, which
1019
// kicks of the height animation.
1020
this._viewContainer.style.height = viewRect.height + "px";
1021
this._viewContainer.style.width = viewRect.width + "px";
1022
this._panel.removeAttribute("width");
1023
this._panel.removeAttribute("height");
1024
// We're setting the width property to prevent flickering during the
1025
// sliding animation with smaller views.
1026
viewNode.style.width = viewRect.width + "px";
1027
1028
// Kick off the transition!
1029
details.phase = TRANSITION_PHASES.TRANSITION;
1030
1031
// If we're going to show the main view, we can remove the
1032
// min-height property on the view container. It's also time
1033
// to set the mainviewshowing attribute on the popup.
1034
if (viewNode.getAttribute("mainview")) {
1035
this._viewContainer.style.removeProperty("min-height");
1036
this._panel.setAttribute("mainviewshowing", true);
1037
} else {
1038
this._panel.removeAttribute("mainviewshowing");
1039
}
1040
1041
this._viewStack.style.transform =
1042
"translateX(" + (moveToLeft ? "" : "-") + deltaX + "px)";
1043
1044
await new Promise(resolve => {
1045
details.resolve = resolve;
1046
this._viewContainer.addEventListener(
1047
"transitionend",
1048
(details.listener = ev => {
1049
// It's quite common that `height` on the view container doesn't need
1050
// to transition, so we make sure to do all the work on the transform
1051
// transition-end, because that is guaranteed to happen.
1052
if (ev.target != this._viewStack || ev.propertyName != "transform") {
1053
return;
1054
}
1055
this._viewContainer.removeEventListener(
1056
"transitionend",
1057
details.listener
1058
);
1059
delete details.listener;
1060
resolve();
1061
})
1062
);
1063
this._viewContainer.addEventListener(
1064
"transitioncancel",
1065
(details.cancelListener = ev => {
1066
if (ev.target != this._viewStack) {
1067
return;
1068
}
1069
this._viewContainer.removeEventListener(
1070
"transitioncancel",
1071
details.cancelListener
1072
);
1073
delete details.cancelListener;
1074
resolve();
1075
})
1076
);
1077
});
1078
1079
// Bail out if the panel was closed during the transition.
1080
if (!nextPanelView.isOpenIn(this)) {
1081
return;
1082
}
1083
prevPanelView.visible = false;
1084
1085
// This will complete the operation by removing any transition properties.
1086
nextPanelView.node.style.removeProperty("width");
1087
deepestNode.style.removeProperty("outline");
1088
this._cleanupTransitionPhase();
1089
1090
nextPanelView.focusSelectedElement();
1091
}
1092
1093
/**
1094
* Attempt to clean up the attributes and properties set by `_transitionViews`
1095
* above. Which attributes and properties depends on the phase the transition
1096
* was left from.
1097
*/
1098
_cleanupTransitionPhase() {
1099
if (!this._transitionDetails) {
1100
return;
1101
}
1102
1103
let { phase, resolve, listener, cancelListener } = this._transitionDetails;
1104
this._transitionDetails = null;
1105
1106
if (phase >= TRANSITION_PHASES.START) {
1107
this._panel.removeAttribute("width");
1108
this._panel.removeAttribute("height");
1109
this._viewContainer.style.removeProperty("height");
1110
this._viewContainer.style.removeProperty("width");
1111
}
1112
if (phase >= TRANSITION_PHASES.PREPARE) {
1113
this._transitioning = false;
1114
this._viewStack.style.removeProperty("margin-inline-start");
1115
this._viewStack.style.removeProperty("transition");
1116
}
1117
if (phase >= TRANSITION_PHASES.TRANSITION) {
1118
this._viewStack.style.removeProperty("transform");
1119
if (listener) {
1120
this._viewContainer.removeEventListener("transitionend", listener);
1121
}
1122
if (cancelListener) {
1123
this._viewContainer.removeEventListener(
1124
"transitioncancel",
1125
cancelListener
1126
);
1127
}
1128
if (resolve) {
1129
resolve();
1130
}
1131
}
1132
}
1133
1134
_calculateMaxHeight() {
1135
// While opening the panel, we have to limit the maximum height of any
1136
// view based on the space that will be available. We cannot just use
1137
// window.screen.availTop and availHeight because these may return an
1138
// incorrect value when the window spans multiple screens.
1139
let anchor = this._panel.anchorNode;
1140
let anchorRect = anchor.getBoundingClientRect();
1141
1142
let screen = this._screenManager.screenForRect(
1143
anchor.screenX,
1144
anchor.screenY,
1145
anchorRect.width,
1146
anchorRect.height
1147
);
1148
let availTop = {},
1149
availHeight = {};
1150
screen.GetAvailRect({}, availTop, {}, availHeight);
1151
let cssAvailTop = availTop.value / screen.defaultCSSScaleFactor;
1152
1153
// The distance from the anchor to the available margin of the screen is
1154
// based on whether the panel will open towards the top or the bottom.
1155
let maxHeight;
1156
if (this._panel.alignmentPosition.startsWith("before_")) {
1157
maxHeight = anchor.screenY - cssAvailTop;
1158
} else {
1159
let anchorScreenBottom = anchor.screenY + anchorRect.height;
1160
let cssAvailHeight = availHeight.value / screen.defaultCSSScaleFactor;
1161
maxHeight = cssAvailTop + cssAvailHeight - anchorScreenBottom;
1162
}
1163
1164
// To go from the maximum height of the panel to the maximum height of
1165
// the view stack, we need to subtract the height of the arrow and the
1166
// height of the opposite margin, but we cannot get their actual values
1167
// because the panel is not visible yet. However, we know that this is
1168
// currently 11px on Mac, 13px on Windows, and 13px on Linux. We also
1169
// want an extra margin, both for visual reasons and to prevent glitches
1170
// due to small rounding errors. So, we just use a value that makes
1171
// sense for all platforms. If the arrow visuals change significantly,
1172
// this value will be easy to adjust.
1173
const EXTRA_MARGIN_PX = 20;
1174
maxHeight -= EXTRA_MARGIN_PX;
1175
return maxHeight;
1176
}
1177
1178
handleEvent(aEvent) {
1179
// Only process actual popup events from the panel or events we generate
1180
// ourselves, but not from menus being shown from within the panel.
1181
if (
1182
aEvent.type.startsWith("popup") &&
1183
aEvent.target != this._panel &&
1184
aEvent.target != this.node
1185
) {
1186
return;
1187
}
1188
switch (aEvent.type) {
1189
case "keydown":
1190
// Since we start listening for the "keydown" event when the popup is
1191
// already showing and stop listening when the panel is hidden, we
1192
// always have at least one view open.
1193
let currentView = this.openViews[this.openViews.length - 1];
1194
currentView.keyNavigation(aEvent);
1195
break;
1196
case "mousemove":
1197
this.openViews.forEach(panelView => panelView.clearNavigation());
1198
break;
1199
case "popupshowing": {
1200
this._viewContainer.setAttribute("panelopen", "true");
1201
if (!this.node.hasAttribute("disablekeynav")) {
1202
// We add the keydown handler on the root so that it handles key
1203
// presses when a panel appears but doesn't get focus, as happens
1204
// when a button to open a panel is clicked with the mouse.
1205
// However, this means the listener is on an ancestor of the panel,
1206
// which means that handlers such as ToolbarKeyboardNavigator are
1207
// deeper in the tree. Therefore, this must be a capturing listener
1208
// so we get the event first.
1209
this.document.documentElement.addEventListener("keydown", this, true);
1210
this._panel.addEventListener("mousemove", this);
1211
}
1212
break;
1213
}
1214
case "popuppositioned": {
1215
if (this._panel.state == "showing") {
1216
let maxHeight = this._calculateMaxHeight();
1217
this._viewStack.style.maxHeight = maxHeight + "px";
1218
this._offscreenViewStack.style.maxHeight = maxHeight + "px";
1219
}
1220
break;
1221
}
1222
case "popupshown":
1223
// The main view is always open and visible when the panel is first
1224
// shown, so we can check the height of the description elements it
1225
// contains and notify consumers using the ViewShown event. In order to
1226
// minimize flicker we need to allow synchronous reflows, and we still
1227
// make sure the ViewShown event is dispatched synchronously.
1228
let mainPanelView = this.openViews[0];
1229
mainPanelView.descriptionHeightWorkaround(true).catch(Cu.reportError);
1230
this._activateView(mainPanelView);
1231
break;
1232
case "popuphidden": {
1233
// WebExtensions consumers can hide the popup from viewshowing, or
1234
// mid-transition, which disrupts our state:
1235
this._transitioning = false;
1236
this._viewContainer.removeAttribute("panelopen");
1237
this._cleanupTransitionPhase();
1238
this.document.documentElement.removeEventListener(
1239
"keydown",
1240
this,
1241
true
1242
);
1243
this._panel.removeEventListener("mousemove", this);
1244
this.closeAllViews();
1245
1246
// Clear the main view size caches. The dimensions could be different
1247
// when the popup is opened again, e.g. through touch mode sizing.
1248
this._viewContainer.style.removeProperty("min-height");
1249
this._viewStack.style.removeProperty("max-height");
1250
this._viewContainer.style.removeProperty("width");
1251
this._viewContainer.style.removeProperty("height");
1252
1253
this.dispatchCustomEvent("PanelMultiViewHidden");
1254
break;
1255
}
1256
}
1257
}
1258
};
1259
1260
/**
1261
* This is associated to <panelview> elements.
1262
*/
1263
var PanelView = class extends AssociatedToNode {
1264
constructor(node) {
1265
super(node);
1266
1267
/**
1268
* Indicates whether the view is active. When this is false, consumers can
1269
* wait for the ViewShown event to know when the view becomes active.
1270
*/
1271
this.active = false;
1272
1273
/**
1274
* Specifies whether the view should be focused when active. When this
1275
* is true, the first navigable element in the view will be focused
1276
* when the view becomes active. This should be set to true when the view
1277
* is activated from the keyboard. It will be set to false once the view
1278
* is active.
1279
*/
1280
this.focusWhenActive = false;
1281
}
1282
1283
/**
1284
* Indicates whether the view is open in the specified PanelMultiView object.
1285
*/
1286
isOpenIn(panelMultiView) {
1287
return this.node.panelMultiView == panelMultiView.node;
1288
}
1289
1290
/**
1291
* The "mainview" attribute is set before the panel is opened when this view
1292
* is displayed as the main view, and is removed before the <panelview> is
1293
* displayed as a subview. The same view element can be displayed as a main
1294
* view and as a subview at different times.
1295
*/
1296
set mainview(value) {
1297
if (value) {
1298
this.node.setAttribute("mainview", true);
1299
} else {
1300
this.node.removeAttribute("mainview");
1301
}
1302
}
1303
1304
/**
1305
* Determines whether the view is visible. Setting this to false also resets
1306
* the "active" property.
1307
*/
1308
set visible(value) {
1309
if (value) {
1310
this.node.setAttribute("visible", true);
1311
} else {
1312
this.node.removeAttribute("visible");
1313
this.active = false;
1314
this.focusWhenActive = false;
1315
}
1316
}
1317
1318
/**
1319
* Constrains the width of this view using the "min-width" and "max-width"
1320
* styles. Setting this to zero removes the constraints.
1321
*/
1322
set minMaxWidth(value) {
1323
let style = this.node.style;
1324
if (value) {
1325
style.minWidth = style.maxWidth = value + "px";
1326
} else {
1327
style.removeProperty("min-width");
1328
style.removeProperty("max-width");
1329
}
1330
}
1331
1332
/**
1333
* Adds a header with the given title, or removes it if the title is empty.
1334
*/
1335
set headerText(value) {
1336
// If the header already exists, update or remove it as requested.
1337
let header = this.node.firstElementChild;
1338
if (header && header.classList.contains("panel-header")) {
1339
if (value) {
1340
// The back button has a label in it - we want to select
1341
// the label that's a direct child of the header.
1342
header.querySelector(
1343
".panel-header > label > span"
1344
).textContent = value;
1345
} else {
1346
header.remove();
1347
}
1348
return;
1349
}
1350
1351
// The header doesn't exist, only create it if needed.
1352
if (!value) {
1353
return;
1354
}
1355
1356
header = this.document.createXULElement("box");
1357
header.classList.add("panel-header");
1358
1359
let backButton = this.document.createXULElement("toolbarbutton");
1360
backButton.className =
1361
"subviewbutton subviewbutton-iconic subviewbutton-back";
1362
backButton.setAttribute("closemenu", "none");
1363
backButton.setAttribute("tabindex", "0");
1364
backButton.setAttribute(
1365
"aria-label",
1366
gBundle.GetStringFromName("panel.back")
1367
);
1368
backButton.addEventListener("command", () => {
1369
// The panelmultiview element may change if the view is reused.
1370
this.node.panelMultiView.goBack();
1371
backButton.blur();
1372
});
1373
1374
let label = this.document.createXULElement("label");
1375
let span = this.document.createElement("span");
1376
span.textContent = value;
1377
label.appendChild(span);
1378
1379
header.append(backButton, label);
1380
this.node.prepend(header);
1381
}
1382
1383
/**
1384
* Also make sure that the correct method is called on CustomizableWidget.
1385
*/
1386
dispatchCustomEvent(...args) {
1387
CustomizableUI.ensureSubviewListeners(this.node);
1388
return super.dispatchCustomEvent(...args);
1389
}
1390
1391
/**
1392
* Populates the "knownWidth" and "knownHeight" properties with the current
1393
* dimensions of the view. These may be zero if the view is invisible.
1394
*
1395
* These values are relevant during transitions and are retained for backwards
1396
* navigation if the view is still open but is invisible.
1397
*/
1398
captureKnownSize() {
1399
let rect = this._getBoundsWithoutFlushing(this.node);
1400
this.knownWidth = rect.width;
1401
this.knownHeight = rect.height;
1402
}
1403
1404
/**
1405
* If the main view or a subview contains wrapping elements, the attribute
1406
* "descriptionheightworkaround" should be set on the view to force all the
1407
* wrapping "description", "label" or "toolbarbutton" elements to a fixed
1408
* height. If the attribute is set and the visibility, contents, or width
1409
* of any of these elements changes, this function should be called to
1410
* refresh the calculated heights.
1411
*
1412
* @param allowSyncReflows
1413
* If set to true, the function takes a path that allows synchronous
1414
* reflows, but minimizes flickering. This is used for the main view
1415
* because we cannot use the workaround off-screen.
1416
*/
1417
async descriptionHeightWorkaround(allowSyncReflows = false) {
1418
if (!this.node.hasAttribute("descriptionheightworkaround")) {
1419
// This view does not require the workaround.
1420
return;
1421
}
1422
1423
// We batch DOM changes together in order to reduce synchronous layouts.
1424
// First we reset any change we may have made previously. The first time
1425
// this is called, and in the best case scenario, this has no effect.
1426
let items = [];
1427
let collectItems = () => {
1428
// Non-hidden <label> or <description> elements that also aren't empty
1429
// and also don't have a value attribute can be multiline (if their
1430
// text content is long enough).
1431
let isMultiline = ":not(:-moz-any([hidden],[value],:empty))";
1432
let selector = [
1433
"description" + isMultiline,
1434
"label" + isMultiline,
1435
"toolbarbutton[wrap]:not([hidden])",
1436
].join(",");
1437
for (let element of this.node.querySelectorAll(selector)) {
1438
// Ignore items in hidden containers.
1439
if (element.closest("[hidden]")) {
1440
continue;
1441
}
1442
1443
// Ignore content inside a <toolbarbutton>
1444
if (
1445
element.tagName != "toolbarbutton" &&
1446
element.closest("toolbarbutton")
1447
) {
1448
continue;
1449
}
1450
1451
// Take the label for toolbarbuttons; it only exists on those elements.
1452
element = element.multilineLabel || element;
1453
1454
let bounds = element.getBoundingClientRect();
1455
let previous = gMultiLineElementsMap.get(element);
1456
// We don't need to (re-)apply the workaround for invisible elements or
1457
// on elements we've seen before and haven't changed in the meantime.
1458
if (
1459
!bounds.width ||
1460
!bounds.height ||
1461
(previous &&
1462
element.textContent == previous.textContent &&
1463
bounds.width == previous.bounds.width)
1464
) {
1465
continue;
1466
}
1467
1468
items.push({ element });
1469
}
1470
};
1471
if (allowSyncReflows) {
1472
collectItems();
1473
} else {
1474
await this.window.promiseDocumentFlushed(collectItems);
1475
// Bail out if the panel was closed in the meantime.
1476
if (!this.node.panelMultiView) {
1477
return;
1478
}
1479
}
1480
1481
// Removing the 'height' property will only cause a layout flush in the next
1482
// loop below if it was set.
1483
for (let item of items) {
1484
item.element.style.removeProperty("height");
1485
}
1486
1487
// We now read the computed style to store the height of any element that
1488
// may contain wrapping text.
1489
let measureItems = () => {
1490
for (let item of items) {
1491
item.bounds = item.element.getBoundingClientRect();
1492
}
1493
};
1494
if (allowSyncReflows) {
1495
measureItems();
1496
} else {
1497
await this.window.promiseDocumentFlushed(measureItems);
1498
// Bail out if the panel was closed in the meantime.
1499
if (!this.node.panelMultiView) {
1500
return;
1501
}
1502
}
1503
1504
// Now we can make all the necessary DOM changes at once.
1505
for (let { element, bounds } of items) {
1506
gMultiLineElementsMap.set(element, {
1507
bounds,
1508
textContent: element.textContent,
1509
});
1510
element.style.height = bounds.height + "px";
1511
}
1512
}
1513
1514
/**
1515
* Determine whether an element can only be navigated to with tab/shift+tab,
1516
* not the arrow keys.
1517
*/
1518
_isNavigableWithTabOnly(element) {
1519
let tag = element.localName;
1520
return (
1521
tag == "menulist" ||
1522
tag == "input" ||
1523
tag == "textarea" ||
1524
// Allow tab to reach embedded documents.
1525
tag == "browser" ||
1526
tag == "iframe"
1527
);
1528
}
1529
1530
/**
1531
* Make a TreeWalker for keyboard navigation.
1532
*
1533
* @param {Boolean} arrowKey If `true`, elements only navigable with tab are
1534
* excluded.
1535
*/
1536
_makeNavigableTreeWalker(arrowKey) {
1537
let filter = node => {
1538
if (node.disabled) {
1539
return NodeFilter.FILTER_REJECT;
1540
}
1541
let bounds = this._getBoundsWithoutFlushing(node);
1542
if (bounds.width == 0 || bounds.height == 0) {
1543
return NodeFilter.FILTER_REJECT;
1544
}
1545
if (
1546
node.tagName == "button" ||
1547
node.tagName == "toolbarbutton" ||
1548
node.classList.contains("text-link") ||
1549
node.classList.contains("navigable") ||
1550
(!arrowKey && this._isNavigableWithTabOnly(node))
1551
) {
1552
// Set the tabindex attribute to make sure the node is focusable.
1553
// Don't do this for browser and iframe elements because this breaks
1554
// tabbing behavior. They're already focusable anyway.
1555
if (
1556
node.tagName != "browser" &&
1557
node.tagName != "iframe" &&
1558
!node.hasAttribute("tabindex")
1559
) {
1560
node.setAttribute("tabindex", "-1");
1561
}
1562
return NodeFilter.FILTER_ACCEPT;
1563
}
1564
return NodeFilter.FILTER_SKIP;
1565
};
1566
return this.document.createTreeWalker(
1567
this.node,
1568
NodeFilter.SHOW_ELEMENT,
1569
filter
1570
);
1571
}
1572
1573
/**
1574
* Get a TreeWalker which finds elements navigable with tab/shift+tab.
1575
*/
1576
get _tabNavigableWalker() {
1577
if (!this.__tabNavigableWalker) {
1578
this.__tabNavigableWalker = this._makeNavigableTreeWalker(false);
1579
}
1580
return this.__tabNavigableWalker;
1581
}
1582
1583
/**
1584
* Get a TreeWalker which finds elements navigable with up/down arrow keys.
1585
*/
1586
get _arrowNavigableWalker() {
1587
if (!this.__arrowNavigableWalker) {
1588
this.__arrowNavigableWalker = this._makeNavigableTreeWalker(true);
1589
}
1590
return this.__arrowNavigableWalker;
1591
}
1592
1593
/**
1594
* Element that is currently selected with the keyboard, or null if no element
1595
* is selected. Since the reference is held weakly, it can become null or
1596
* undefined at any time.
1597
*/
1598
get selectedElement() {
1599
return this._selectedElement && this._selectedElement.get();
1600
}
1601
set selectedElement(value) {
1602
if (!value) {
1603
delete this._selectedElement;
1604
} else {
1605
this._selectedElement = Cu.getWeakReference(value);
1606
}
1607
}
1608
1609
/**
1610
* Focuses and moves keyboard selection to the first navigable element.
1611
* This is a no-op if there are no navigable elements.
1612
*
1613
* @param {Boolean} homeKey `true` if this is for the home key.
1614
* @param {Boolean} skipBack `true` if the Back button should be skipped.
1615
*/
1616
focusFirstNavigableElement(homeKey = false, skipBack = false) {
1617
// The home key is conceptually similar to the up/down arrow keys.
1618
let walker = homeKey
1619
? this._arrowNavigableWalker
1620
: this._tabNavigableWalker;
1621
walker.currentNode = walker.root;
1622
this.selectedElement = walker.firstChild();
1623
if (
1624
skipBack &&
1625
walker.currentNode &&
1626
walker.currentNode.classList.contains("subviewbutton-back") &&
1627
walker.nextNode()
1628
) {
1629
this.selectedElement = walker.currentNode;
1630
}
1631
this.focusSelectedElement(/* byKey */ true);
1632
}
1633
1634
/**
1635
* Focuses and moves keyboard selection to the last navigable element.
1636
* This is a no-op if there are no navigable elements.
1637
*
1638
* @param {Boolean} endKey `true` if this is for the end key.
1639
*/
1640
focusLastNavigableElement(endKey = false) {
1641
// The end key is conceptually similar to the up/down arrow keys.
1642
let walker = endKey ? this._arrowNavigableWalker : this._tabNavigableWalker;
1643
walker.currentNode = walker.root;
1644
this.selectedElement = walker.lastChild();
1645
this.focusSelectedElement(/* byKey */ true);
1646
}
1647
1648
/**
1649
* Based on going up or down, select the previous or next focusable element.
1650
*
1651
* @param {Boolean} isDown whether we're going down (true) or up (false).
1652
* @param {Boolean} arrowKey `true` if this is for the up/down arrow keys.
1653
*
1654
* @return {DOMNode} the element we selected.
1655
*/
1656
moveSelection(isDown, arrowKey = false) {
1657
let walker = arrowKey
1658
? this._arrowNavigableWalker
1659
: this._tabNavigableWalker;
1660
let oldSel = this.selectedElement;
1661
let newSel;
1662
if (oldSel) {
1663
walker.currentNode = oldSel;
1664
newSel = isDown ? walker.nextNode() : walker.previousNode();
1665
}
1666
// If we couldn't find something, select the first or last item:
1667
if (!newSel) {
1668
walker.currentNode = walker.root;
1669
newSel = isDown ? walker.firstChild() : walker.lastChild();
1670
}
1671
this.selectedElement = newSel;
1672
return newSel;
1673
}
1674
1675
/**
1676
* Allow for navigating subview buttons using the arrow keys and the Enter key.
1677
* The Up and Down keys can be used to navigate the list up and down and the
1678
* Enter, Right or Left - depending on the text direction - key can be used to
1679
* simulate a click on the currently selected button.
1680
* The Right or Left key - depending on the text direction - can be used to
1681
* navigate to the previous view, functioning as a shortcut for the view's
1682
* back button.
1683
* Thus, in LTR mode:
1684
* - The Right key functions the same as the Enter key, simulating a click
1685
* - The Left key triggers a navigation back to the previous view.
1686
*
1687
* Key navigation is only enabled while the view is active, meaning that this
1688
* method will return early if it is invoked during a sliding transition.
1689
*
1690
* @param {KeyEvent} event
1691
*/
1692
keyNavigation(event) {
1693
if (!this.active) {
1694
return;
1695
}
1696
1697
let focus = this.document.activeElement;
1698
// Make sure the focus is actually inside the panel. (It might not be if
1699
// the panel was opened with the mouse.) If it isn't, we don't care
1700
// about it for our purposes.
1701
// We use Node.compareDocumentPosition because Node.contains doesn't
1702
// behave as expected for anonymous content; e.g. the input inside a
1703
// textbox.
1704
if (
1705
focus &&
1706
!(
1707
this.node.compareDocumentPosition(focus) &
1708
Node.DOCUMENT_POSITION_CONTAINED_BY
1709
)
1710
) {
1711
focus = null;
1712
}
1713
1714
// Some panels contain embedded documents. We can't manage
1715
// keyboard navigation within those.
1716
if (focus && (focus.tagName == "browser" || focus.tagName == "iframe")) {
1717
return;
1718
}
1719
1720
let stop = () => {
1721
event.stopPropagation();
1722
event.preventDefault();
1723
};
1724
1725
// If the focused element is only navigable with tab, it wants the arrow
1726
// keys, etc. We shouldn't handle any keys except tab and shift+tab.
1727
// We make a function for this for performance reasons: we only want to
1728
// check this for keys we potentially care about, not *all* keys.
1729
let tabOnly = () => {
1730
// We use the real focus rather than this.selectedElement because focus
1731
// might have been moved without keyboard navigation (e.g. mouse click)
1732
// and this.selectedElement is only updated for keyboard navigation.
1733
return focus && this._isNavigableWithTabOnly(focus);
1734
};
1735
1736
// If a context menu is open, we must let it handle all keys.
1737
// Normally, this just happens, but because we have a capturing root
1738
// element keydown listener, our listener takes precedence.
1739
// Again, we only want to do this check on demand for performance.
1740
let isContextMenuOpen = () => {
1741
if (!focus) {
1742
return false;
1743
}
1744
let contextNode = focus.closest("[context]");
1745
if (!contextNode) {
1746
return false;
1747
}
1748
let context = contextNode.getAttribute("context");
1749
let popup = this.document.getElementById(context);
1750
return popup && popup.state == "open";
1751
};
1752
1753
let keyCode = event.code;
1754
switch (keyCode) {
1755
case "ArrowDown":
1756
case "ArrowUp":
1757
if (tabOnly()) {
1758
break;
1759
}
1760
// Fall-through...
1761
case "Tab": {
1762
if (
1763
isContextMenuOpen() ||
1764
// Tab in an open menulist should close it.
1765
(focus && focus.localName == "menulist" && focus.open)
1766
) {
1767
break;
1768
}
1769
stop();
1770
let isDown =
1771
keyCode == "ArrowDown" || (keyCode == "Tab" && !event.shiftKey);
1772
let button = this.moveSelection(isDown, keyCode != "Tab");
1773
Services.focus.setFocus(button, Services.focus.FLAG_BYKEY);
1774
break;
1775
}
1776
case "Home":
1777
if (tabOnly() || isContextMenuOpen()) {
1778
break;
1779
}
1780
stop();
1781
this.focusFirstNavigableElement(true);
1782
break;
1783
case "End":
1784
if (tabOnly() || isContextMenuOpen()) {
1785
break;
1786
}
1787
stop();
1788
this.focusLastNavigableElement(true);
1789
break;
1790
case "ArrowLeft":
1791
case "ArrowRight": {
1792
if (tabOnly() || isContextMenuOpen()) {
1793
break;
1794
}
1795
stop();
1796
if (
1797
(!this.window.RTL_UI && keyCode == "ArrowLeft") ||
1798
(this.window.RTL_UI && keyCode == "ArrowRight")
1799
) {
1800
this.node.panelMultiView.goBack();
1801
break;
1802
}
1803
// If the current button is _not_ one that points to a subview, pressing
1804
// the arrow key shouldn't do anything.
1805
let button = this.selectedElement;
1806
if (!button || !button.classList.contains("subviewbutton-nav")) {
1807
break;
1808
}
1809
}
1810
// Fall-through...
1811
case "Space":
1812
case "NumpadEnter":
1813
case "Enter": {
1814
if (tabOnly() || isContextMenuOpen()) {
1815
break;
1816
}
1817
let button = this.selectedElement;
1818
if (!button) {
1819
break;
1820
}
1821
stop();
1822
1823
this._doingKeyboardActivation = true;
1824
// Unfortunately, 'tabindex' doesn't execute the default action, so
1825
// we explicitly do this here.
1826
// We are sending a command event, a mousedown event and then a click
1827
// event. This is done in order to mimic a "real" mouse click event.
1828
// Normally, the command event executes the action, then the click event
1829
// closes the menu. However, in some cases (e.g. the Library button),
1830
// there is no command event handler and the mousedown event executes the
1831
// action instead.
1832
let commandEvent = event.target.ownerDocument.createEvent(
1833
"xulcommandevent"
1834
);
1835
commandEvent.initCommandEvent(
1836
"command",
1837
true,
1838
true,
1839
event.target.ownerGlobal,
1840
0,
1841
event.ctrlKey,
1842
event.altKey,
1843
event.shiftKey,
1844
event.metaKey,
1845
null,
1846
0
1847
);
1848
button.dispatchEvent(commandEvent);
1849
1850
let dispEvent = new event.target.ownerGlobal.MouseEvent("mousedown", {
1851
bubbles: true,
1852
});
1853
button.dispatchEvent(dispEvent);
1854
dispEvent = new event.target.ownerGlobal.MouseEvent("click", {
1855
bubbles: true,
1856
});
1857
button.dispatchEvent(dispEvent);
1858
this._doingKeyboardActivation = false;
1859
break;
1860
}
1861
}
1862
}
1863
1864
/**
1865
* Focus the last selected element in the view, if any.
1866
*
1867
* @param byKey {Boolean} whether focus was moved by the user pressing a key.
1868
* Needed to ensure we show focus styles in the right cases.
1869
*/
1870
focusSelectedElement(byKey = false) {
1871
let selected = this.selectedElement;
1872
if (selected) {
1873
let flag = byKey ? "FLAG_BYKEY" : "FLAG_BYELEMENTFOCUS";
1874
Services.focus.setFocus(selected, Services.focus[flag]);
1875
}
1876
}
1877
1878
/**
1879
* Clear all traces of keyboard navigation happening right now.
1880
*/
1881
clearNavigation() {
1882
let selected = this.selectedElement;
1883
if (selected) {
1884
selected.blur();
1885
this.selectedElement = null;
1886
}
1887
}
1888
};