forked from mirrors/gecko-dev
Rather than having each richlistbox consumer having to reinvent focus patterns for buttons and menulists in its 'rich' items, let's just teach richlistbox and richlistitem to not suck at keyboard navigation. That way we won't keep forgetting to deal with this whenever we add new lists anywhere. This allows us to remove the custom handling in sitePermissions.js, and the same handling should be covered by the existing test, ie browser/components/preferences/tests/browser_permissions_dialog.js To summarize the desired keyboard behaviour: - tab/shift-tab move focus to controls inside selected items only (not other rows) - arrow keys move the list selection up/down - when arrowing to move the list selection, focus moves with the selection if it was previously on a control in the previously selected item. Differential Revision: https://phabricator.services.mozilla.com/D161528
1042 lines
29 KiB
JavaScript
1042 lines
29 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
"use strict";
|
|
|
|
// This is loaded into all XUL windows. Wrap in a block to prevent
|
|
// leaking to window scope.
|
|
{
|
|
const { AppConstants } = ChromeUtils.importESModule(
|
|
"resource://gre/modules/AppConstants.sys.mjs"
|
|
);
|
|
|
|
/**
|
|
* XUL:richlistbox element.
|
|
*/
|
|
MozElements.RichListBox = class RichListBox extends MozElements.BaseControl {
|
|
constructor() {
|
|
super();
|
|
|
|
this.selectedItems = new ChromeNodeList();
|
|
this._currentIndex = null;
|
|
this._lastKeyTime = 0;
|
|
this._incrementalString = "";
|
|
this._suppressOnSelect = false;
|
|
this._userSelecting = false;
|
|
this._selectTimeout = null;
|
|
this._currentItem = null;
|
|
this._selectionStart = null;
|
|
|
|
this.addEventListener(
|
|
"keypress",
|
|
event => {
|
|
if (event.altKey || event.metaKey) {
|
|
return;
|
|
}
|
|
|
|
switch (event.keyCode) {
|
|
case KeyEvent.DOM_VK_UP:
|
|
this._moveByOffsetFromUserEvent(-1, event);
|
|
break;
|
|
case KeyEvent.DOM_VK_DOWN:
|
|
this._moveByOffsetFromUserEvent(1, event);
|
|
break;
|
|
case KeyEvent.DOM_VK_HOME:
|
|
this._moveByOffsetFromUserEvent(-this.currentIndex, event);
|
|
break;
|
|
case KeyEvent.DOM_VK_END:
|
|
this._moveByOffsetFromUserEvent(
|
|
this.getRowCount() - this.currentIndex - 1,
|
|
event
|
|
);
|
|
break;
|
|
case KeyEvent.DOM_VK_PAGE_UP:
|
|
this._moveByOffsetFromUserEvent(this.scrollOnePage(-1), event);
|
|
break;
|
|
case KeyEvent.DOM_VK_PAGE_DOWN:
|
|
this._moveByOffsetFromUserEvent(this.scrollOnePage(1), event);
|
|
break;
|
|
}
|
|
},
|
|
{ mozSystemGroup: true }
|
|
);
|
|
|
|
this.addEventListener("keypress", event => {
|
|
if (event.target != this) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
event.key == " " &&
|
|
event.ctrlKey &&
|
|
!event.shiftKey &&
|
|
!event.altKey &&
|
|
!event.metaKey &&
|
|
this.currentItem &&
|
|
this.selType == "multiple"
|
|
) {
|
|
this.toggleItemSelection(this.currentItem);
|
|
}
|
|
|
|
if (!event.charCode || event.altKey || event.ctrlKey || event.metaKey) {
|
|
return;
|
|
}
|
|
|
|
if (event.timeStamp - this._lastKeyTime > 1000) {
|
|
this._incrementalString = "";
|
|
}
|
|
|
|
var key = String.fromCharCode(event.charCode).toLowerCase();
|
|
this._incrementalString += key;
|
|
this._lastKeyTime = event.timeStamp;
|
|
|
|
// If all letters in the incremental string are the same, just
|
|
// try to match the first one
|
|
var incrementalString = /^(.)\1+$/.test(this._incrementalString)
|
|
? RegExp.$1
|
|
: this._incrementalString;
|
|
var length = incrementalString.length;
|
|
|
|
var rowCount = this.getRowCount();
|
|
var l = this.selectedItems.length;
|
|
var start = l > 0 ? this.getIndexOfItem(this.selectedItems[l - 1]) : -1;
|
|
// start from the first element if none was selected or from the one
|
|
// following the selected one if it's a new or a repeated-letter search
|
|
if (start == -1 || length == 1) {
|
|
start++;
|
|
}
|
|
|
|
for (var i = 0; i < rowCount; i++) {
|
|
var k = (start + i) % rowCount;
|
|
var listitem = this.getItemAtIndex(k);
|
|
if (!this._canUserSelect(listitem)) {
|
|
continue;
|
|
}
|
|
// allow richlistitems to specify the string being searched for
|
|
var searchText =
|
|
"searchLabel" in listitem
|
|
? listitem.searchLabel
|
|
: listitem.getAttribute("label"); // (see also bug 250123)
|
|
searchText = searchText.substring(0, length).toLowerCase();
|
|
if (searchText == incrementalString) {
|
|
this.ensureIndexIsVisible(k);
|
|
this.timedSelect(listitem, this._selectDelay);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
this.addEventListener("focus", event => {
|
|
if (this.getRowCount() > 0) {
|
|
if (this.currentIndex == -1) {
|
|
this.currentIndex = this.getIndexOfFirstVisibleRow();
|
|
let currentItem = this.getItemAtIndex(this.currentIndex);
|
|
if (currentItem) {
|
|
this.selectItem(currentItem);
|
|
}
|
|
} else {
|
|
this._fireEvent(this.currentItem, "DOMMenuItemActive");
|
|
}
|
|
}
|
|
this._lastKeyTime = 0;
|
|
});
|
|
|
|
this.addEventListener("click", event => {
|
|
// clicking into nothing should unselect multiple selections
|
|
if (event.originalTarget == this && this.selType == "multiple") {
|
|
this.clearSelection();
|
|
this.currentItem = null;
|
|
}
|
|
});
|
|
|
|
this.addEventListener("MozSwipeGesture", event => {
|
|
// Only handle swipe gestures up and down
|
|
switch (event.direction) {
|
|
case event.DIRECTION_DOWN:
|
|
this.scrollTop = this.scrollHeight;
|
|
break;
|
|
case event.DIRECTION_UP:
|
|
this.scrollTop = 0;
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
connectedCallback() {
|
|
if (this.delayConnectedCallback()) {
|
|
return;
|
|
}
|
|
|
|
this.setAttribute("allowevents", "true");
|
|
this._refreshSelection();
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
set selectedItem(val) {
|
|
this.selectItem(val);
|
|
}
|
|
get selectedItem() {
|
|
return this.selectedItems.length ? this.selectedItems[0] : null;
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
set selectedIndex(val) {
|
|
if (val >= 0) {
|
|
// This is a micro-optimization so that a call to getIndexOfItem or
|
|
// getItemAtIndex caused by _fireOnSelect (especially for derived
|
|
// widgets) won't loop the children.
|
|
this._selecting = {
|
|
item: this.getItemAtIndex(val),
|
|
index: val,
|
|
};
|
|
this.selectItem(this._selecting.item);
|
|
delete this._selecting;
|
|
} else {
|
|
this.clearSelection();
|
|
this.currentItem = null;
|
|
}
|
|
}
|
|
get selectedIndex() {
|
|
if (this.selectedItems.length) {
|
|
return this.getIndexOfItem(this.selectedItems[0]);
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
set value(val) {
|
|
var kids = this.getElementsByAttribute("value", val);
|
|
if (kids && kids.item(0)) {
|
|
this.selectItem(kids[0]);
|
|
}
|
|
}
|
|
get value() {
|
|
if (this.selectedItems.length) {
|
|
return this.selectedItem.value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
get itemCount() {
|
|
return this.itemChildren.length;
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
set selType(val) {
|
|
this.setAttribute("seltype", val);
|
|
}
|
|
get selType() {
|
|
return this.getAttribute("seltype");
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
set currentItem(val) {
|
|
if (this._currentItem == val) {
|
|
return;
|
|
}
|
|
|
|
if (this._currentItem) {
|
|
this._currentItem.current = false;
|
|
if (!val && !this.suppressMenuItemEvent) {
|
|
// An item is losing focus and there is no new item to focus.
|
|
// Notify a11y that there is no focused item.
|
|
this._fireEvent(this._currentItem, "DOMMenuItemInactive");
|
|
}
|
|
}
|
|
this._currentItem = val;
|
|
|
|
if (val) {
|
|
val.current = true;
|
|
if (!this.suppressMenuItemEvent) {
|
|
// Notify a11y that this item got focus.
|
|
this._fireEvent(val, "DOMMenuItemActive");
|
|
}
|
|
}
|
|
}
|
|
get currentItem() {
|
|
return this._currentItem;
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
set currentIndex(val) {
|
|
if (val >= 0) {
|
|
this.currentItem = this.getItemAtIndex(val);
|
|
} else {
|
|
this.currentItem = null;
|
|
}
|
|
}
|
|
get currentIndex() {
|
|
return this.currentItem ? this.getIndexOfItem(this.currentItem) : -1;
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
get selectedCount() {
|
|
return this.selectedItems.length;
|
|
}
|
|
|
|
get itemChildren() {
|
|
let children = Array.from(this.children).filter(
|
|
node => node.localName == "richlistitem"
|
|
);
|
|
return children;
|
|
}
|
|
|
|
set suppressOnSelect(val) {
|
|
this.setAttribute("suppressonselect", val);
|
|
}
|
|
get suppressOnSelect() {
|
|
return this.getAttribute("suppressonselect") == "true";
|
|
}
|
|
|
|
set _selectDelay(val) {
|
|
this.setAttribute("_selectDelay", val);
|
|
}
|
|
get _selectDelay() {
|
|
return this.getAttribute("_selectDelay") || 50;
|
|
}
|
|
|
|
_fireOnSelect() {
|
|
// make sure not to modify last-selected when suppressing select events
|
|
// (otherwise we'll lose the selection when a template gets rebuilt)
|
|
if (this._suppressOnSelect || this.suppressOnSelect) {
|
|
return;
|
|
}
|
|
|
|
// remember the current item and all selected items with IDs
|
|
var state = this.currentItem ? this.currentItem.id : "";
|
|
if (this.selType == "multiple" && this.selectedCount) {
|
|
let getId = function getId(aItem) {
|
|
return aItem.id;
|
|
};
|
|
state +=
|
|
" " +
|
|
[...this.selectedItems]
|
|
.filter(getId)
|
|
.map(getId)
|
|
.join(" ");
|
|
}
|
|
if (state) {
|
|
this.setAttribute("last-selected", state);
|
|
} else {
|
|
this.removeAttribute("last-selected");
|
|
}
|
|
|
|
// preserve the index just in case no IDs are available
|
|
if (this.currentIndex > -1) {
|
|
this._currentIndex = this.currentIndex + 1;
|
|
}
|
|
|
|
var event = document.createEvent("Events");
|
|
event.initEvent("select", true, true);
|
|
this.dispatchEvent(event);
|
|
|
|
// always call this (allows a commandupdater without controller)
|
|
document.commandDispatcher.updateCommands("richlistbox-select");
|
|
}
|
|
|
|
getNextItem(aStartItem, aDelta) {
|
|
while (aStartItem) {
|
|
aStartItem = aStartItem.nextSibling;
|
|
if (
|
|
aStartItem &&
|
|
aStartItem.localName == "richlistitem" &&
|
|
(!this._userSelecting || this._canUserSelect(aStartItem))
|
|
) {
|
|
--aDelta;
|
|
if (aDelta == 0) {
|
|
return aStartItem;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getPreviousItem(aStartItem, aDelta) {
|
|
while (aStartItem) {
|
|
aStartItem = aStartItem.previousSibling;
|
|
if (
|
|
aStartItem &&
|
|
aStartItem.localName == "richlistitem" &&
|
|
(!this._userSelecting || this._canUserSelect(aStartItem))
|
|
) {
|
|
--aDelta;
|
|
if (aDelta == 0) {
|
|
return aStartItem;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
appendItem(aLabel, aValue) {
|
|
var item = this.ownerDocument.createXULElement("richlistitem");
|
|
item.setAttribute("value", aValue);
|
|
|
|
var label = this.ownerDocument.createXULElement("label");
|
|
label.setAttribute("value", aLabel);
|
|
label.setAttribute("flex", "1");
|
|
label.setAttribute("crop", "end");
|
|
item.appendChild(label);
|
|
|
|
this.appendChild(item);
|
|
|
|
return item;
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
getIndexOfItem(aItem) {
|
|
// don't search the children, if we're looking for none of them
|
|
if (aItem == null) {
|
|
return -1;
|
|
}
|
|
if (this._selecting && this._selecting.item == aItem) {
|
|
return this._selecting.index;
|
|
}
|
|
return this.itemChildren.indexOf(aItem);
|
|
}
|
|
|
|
// nsIDOMXULSelectControlElement
|
|
getItemAtIndex(aIndex) {
|
|
if (this._selecting && this._selecting.index == aIndex) {
|
|
return this._selecting.item;
|
|
}
|
|
return this.itemChildren[aIndex] || null;
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
addItemToSelection(aItem) {
|
|
if (this.selType != "multiple" && this.selectedCount) {
|
|
return;
|
|
}
|
|
|
|
if (aItem.selected) {
|
|
return;
|
|
}
|
|
|
|
this.selectedItems.append(aItem);
|
|
aItem.selected = true;
|
|
|
|
this._fireOnSelect();
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
removeItemFromSelection(aItem) {
|
|
if (!aItem.selected) {
|
|
return;
|
|
}
|
|
|
|
this.selectedItems.remove(aItem);
|
|
aItem.selected = false;
|
|
this._fireOnSelect();
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
toggleItemSelection(aItem) {
|
|
if (aItem.selected) {
|
|
this.removeItemFromSelection(aItem);
|
|
} else {
|
|
this.addItemToSelection(aItem);
|
|
}
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
selectItem(aItem) {
|
|
if (!aItem || aItem.disabled) {
|
|
return;
|
|
}
|
|
|
|
if (this.selectedItems.length == 1 && this.selectedItems[0] == aItem) {
|
|
return;
|
|
}
|
|
|
|
this._selectionStart = null;
|
|
|
|
var suppress = this._suppressOnSelect;
|
|
this._suppressOnSelect = true;
|
|
|
|
this.clearSelection();
|
|
this.addItemToSelection(aItem);
|
|
this.currentItem = aItem;
|
|
|
|
this._suppressOnSelect = suppress;
|
|
this._fireOnSelect();
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
selectItemRange(aStartItem, aEndItem) {
|
|
if (this.selType != "multiple") {
|
|
return;
|
|
}
|
|
|
|
if (!aStartItem) {
|
|
aStartItem = this._selectionStart
|
|
? this._selectionStart
|
|
: this.currentItem;
|
|
}
|
|
|
|
if (!aStartItem) {
|
|
aStartItem = aEndItem;
|
|
}
|
|
|
|
var suppressSelect = this._suppressOnSelect;
|
|
this._suppressOnSelect = true;
|
|
|
|
this._selectionStart = aStartItem;
|
|
|
|
var currentItem;
|
|
var startIndex = this.getIndexOfItem(aStartItem);
|
|
var endIndex = this.getIndexOfItem(aEndItem);
|
|
if (endIndex < startIndex) {
|
|
currentItem = aEndItem;
|
|
aEndItem = aStartItem;
|
|
aStartItem = currentItem;
|
|
} else {
|
|
currentItem = aStartItem;
|
|
}
|
|
|
|
while (currentItem) {
|
|
this.addItemToSelection(currentItem);
|
|
if (currentItem == aEndItem) {
|
|
currentItem = this.getNextItem(currentItem, 1);
|
|
break;
|
|
}
|
|
currentItem = this.getNextItem(currentItem, 1);
|
|
}
|
|
|
|
// Clear around new selection
|
|
// Don't use clearSelection() because it causes a lot of noise
|
|
// with respect to selection removed notifications used by the
|
|
// accessibility API support.
|
|
var userSelecting = this._userSelecting;
|
|
this._userSelecting = false; // that's US automatically unselecting
|
|
for (; currentItem; currentItem = this.getNextItem(currentItem, 1)) {
|
|
this.removeItemFromSelection(currentItem);
|
|
}
|
|
|
|
for (
|
|
currentItem = this.getItemAtIndex(0);
|
|
currentItem != aStartItem;
|
|
currentItem = this.getNextItem(currentItem, 1)
|
|
) {
|
|
this.removeItemFromSelection(currentItem);
|
|
}
|
|
this._userSelecting = userSelecting;
|
|
|
|
this._suppressOnSelect = suppressSelect;
|
|
|
|
this._fireOnSelect();
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
selectAll() {
|
|
this._selectionStart = null;
|
|
|
|
var suppress = this._suppressOnSelect;
|
|
this._suppressOnSelect = true;
|
|
|
|
var item = this.getItemAtIndex(0);
|
|
while (item) {
|
|
this.addItemToSelection(item);
|
|
item = this.getNextItem(item, 1);
|
|
}
|
|
|
|
this._suppressOnSelect = suppress;
|
|
this._fireOnSelect();
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
clearSelection() {
|
|
if (this.selectedItems) {
|
|
while (this.selectedItems.length) {
|
|
let item = this.selectedItems[0];
|
|
item.selected = false;
|
|
this.selectedItems.remove(item);
|
|
}
|
|
}
|
|
|
|
this._selectionStart = null;
|
|
this._fireOnSelect();
|
|
}
|
|
|
|
// nsIDOMXULMultiSelectControlElement
|
|
getSelectedItem(aIndex) {
|
|
return aIndex < this.selectedItems.length
|
|
? this.selectedItems[aIndex]
|
|
: null;
|
|
}
|
|
|
|
ensureIndexIsVisible(aIndex) {
|
|
return this.ensureElementIsVisible(this.getItemAtIndex(aIndex));
|
|
}
|
|
|
|
ensureElementIsVisible(aElement, aAlignToTop) {
|
|
if (!aElement) {
|
|
return;
|
|
}
|
|
|
|
// These calculations assume that there is no padding on the
|
|
// "richlistbox" element, although there might be a margin.
|
|
var targetRect = aElement.getBoundingClientRect();
|
|
var scrollRect = this.getBoundingClientRect();
|
|
var offset = targetRect.top - scrollRect.top;
|
|
if (!aAlignToTop && offset >= 0) {
|
|
// scrollRect.bottom wouldn't take a horizontal scroll bar into account
|
|
let scrollRectBottom = scrollRect.top + this.clientHeight;
|
|
offset = targetRect.bottom - scrollRectBottom;
|
|
if (offset <= 0) {
|
|
return;
|
|
}
|
|
}
|
|
this.scrollTop += offset;
|
|
}
|
|
|
|
getIndexOfFirstVisibleRow() {
|
|
var children = this.itemChildren;
|
|
|
|
for (var ix = 0; ix < children.length; ix++) {
|
|
if (this._isItemVisible(children[ix])) {
|
|
return ix;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
getRowCount() {
|
|
return this.itemChildren.length;
|
|
}
|
|
|
|
scrollOnePage(aDirection) {
|
|
var children = this.itemChildren;
|
|
|
|
if (!children.length) {
|
|
return 0;
|
|
}
|
|
|
|
// If nothing is selected, we just select the first element
|
|
// at the extreme we're moving away from
|
|
if (!this.currentItem) {
|
|
return aDirection == -1 ? children.length : 0;
|
|
}
|
|
|
|
// If the current item is visible, scroll by one page so that
|
|
// the new current item is at approximately the same position as
|
|
// the existing current item.
|
|
let height = this.getBoundingClientRect().height;
|
|
if (this._isItemVisible(this.currentItem)) {
|
|
this.scrollBy(0, height * aDirection);
|
|
}
|
|
|
|
// Figure out, how many items fully fit into the view port
|
|
// (including the currently selected one), and determine
|
|
// the index of the first one lying (partially) outside
|
|
let currentItemRect = this.currentItem.getBoundingClientRect();
|
|
var startBorder = currentItemRect.y;
|
|
if (aDirection == -1) {
|
|
startBorder += currentItemRect.height;
|
|
}
|
|
|
|
var index = this.currentIndex;
|
|
for (var ix = index; 0 <= ix && ix < children.length; ix += aDirection) {
|
|
let childRect = children[ix].getBoundingClientRect();
|
|
if (childRect.height == 0) {
|
|
continue; // hidden children have a y of 0
|
|
}
|
|
var endBorder = childRect.y + (aDirection == -1 ? childRect.height : 0);
|
|
if ((endBorder - startBorder) * aDirection > height) {
|
|
break; // we've reached the desired distance
|
|
}
|
|
index = ix;
|
|
}
|
|
|
|
return index != this.currentIndex
|
|
? index - this.currentIndex
|
|
: aDirection;
|
|
}
|
|
|
|
_refreshSelection() {
|
|
// when this method is called, we know that either the currentItem
|
|
// and selectedItems we have are null (ctor) or a reference to an
|
|
// element no longer in the DOM (template).
|
|
|
|
// first look for the last-selected attribute
|
|
var state = this.getAttribute("last-selected");
|
|
if (state) {
|
|
var ids = state.split(" ");
|
|
|
|
var suppressSelect = this._suppressOnSelect;
|
|
this._suppressOnSelect = true;
|
|
this.clearSelection();
|
|
for (let i = 1; i < ids.length; i++) {
|
|
var selectedItem = document.getElementById(ids[i]);
|
|
if (selectedItem) {
|
|
this.addItemToSelection(selectedItem);
|
|
}
|
|
}
|
|
|
|
var currentItem = document.getElementById(ids[0]);
|
|
if (!currentItem && this._currentIndex) {
|
|
currentItem = this.getItemAtIndex(
|
|
Math.min(this._currentIndex - 1, this.getRowCount())
|
|
);
|
|
}
|
|
if (currentItem) {
|
|
this.currentItem = currentItem;
|
|
if (this.selType != "multiple" && this.selectedCount == 0) {
|
|
this.selectedItem = currentItem;
|
|
}
|
|
|
|
if (this.getBoundingClientRect().height) {
|
|
this.ensureElementIsVisible(currentItem);
|
|
} else {
|
|
// XXX hack around a bug in ensureElementIsVisible as it will
|
|
// scroll beyond the last element, bug 493645.
|
|
this.ensureElementIsVisible(currentItem.previousElementSibling);
|
|
}
|
|
}
|
|
this._suppressOnSelect = suppressSelect;
|
|
// XXX actually it's just a refresh, but at least
|
|
// the Extensions manager expects this:
|
|
this._fireOnSelect();
|
|
return;
|
|
}
|
|
|
|
// try to restore the selected items according to their IDs
|
|
// (applies after a template rebuild, if last-selected was not set)
|
|
if (this.selectedItems) {
|
|
let itemIds = [];
|
|
for (let i = this.selectedCount - 1; i >= 0; i--) {
|
|
let selectedItem = this.selectedItems[i];
|
|
itemIds.push(selectedItem.id);
|
|
this.selectedItems.remove(selectedItem);
|
|
}
|
|
for (let i = 0; i < itemIds.length; i++) {
|
|
let selectedItem = document.getElementById(itemIds[i]);
|
|
if (selectedItem) {
|
|
this.selectedItems.append(selectedItem);
|
|
}
|
|
}
|
|
}
|
|
if (this.currentItem && this.currentItem.id) {
|
|
this.currentItem = document.getElementById(this.currentItem.id);
|
|
} else {
|
|
this.currentItem = null;
|
|
}
|
|
|
|
// if we have no previously current item or if the above check fails to
|
|
// find the previous nodes (which causes it to clear selection)
|
|
if (!this.currentItem && this.selectedCount == 0) {
|
|
this.currentIndex = this._currentIndex ? this._currentIndex - 1 : 0;
|
|
|
|
// cf. listbox constructor:
|
|
// select items according to their attributes
|
|
var children = this.itemChildren;
|
|
for (let i = 0; i < children.length; ++i) {
|
|
if (children[i].getAttribute("selected") == "true") {
|
|
this.selectedItems.append(children[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.selType != "multiple" && this.selectedCount == 0) {
|
|
this.selectedItem = this.currentItem;
|
|
}
|
|
}
|
|
|
|
_isItemVisible(aItem) {
|
|
if (!aItem) {
|
|
return false;
|
|
}
|
|
|
|
var y = this.getBoundingClientRect().y;
|
|
|
|
// Partially visible items are also considered visible
|
|
let itemRect = aItem.getBoundingClientRect();
|
|
return (
|
|
itemRect.y + itemRect.height > y && itemRect.y < y + this.clientHeight
|
|
);
|
|
}
|
|
|
|
moveByOffset(aOffset, aIsSelecting, aIsSelectingRange, aEvent) {
|
|
if ((aIsSelectingRange || !aIsSelecting) && this.selType != "multiple") {
|
|
return;
|
|
}
|
|
|
|
var newIndex = this.currentIndex + aOffset;
|
|
if (newIndex < 0) {
|
|
newIndex = 0;
|
|
}
|
|
|
|
var numItems = this.getRowCount();
|
|
if (newIndex > numItems - 1) {
|
|
newIndex = numItems - 1;
|
|
}
|
|
|
|
var newItem = this.getItemAtIndex(newIndex);
|
|
// make sure that the item is actually visible/selectable
|
|
if (this._userSelecting && newItem && !this._canUserSelect(newItem)) {
|
|
newItem =
|
|
aOffset > 0
|
|
? this.getNextItem(newItem, 1) || this.getPreviousItem(newItem, 1)
|
|
: this.getPreviousItem(newItem, 1) || this.getNextItem(newItem, 1);
|
|
}
|
|
if (newItem) {
|
|
let hadFocus = this.currentItem.contains(document.activeElement);
|
|
this.ensureIndexIsVisible(this.getIndexOfItem(newItem));
|
|
if (aIsSelectingRange) {
|
|
this.selectItemRange(null, newItem);
|
|
} else if (aIsSelecting) {
|
|
this.selectItem(newItem);
|
|
}
|
|
if (hadFocus) {
|
|
let flags =
|
|
Services.focus[
|
|
aEvent.type.startsWith("key") ? "FLAG_BYKEY" : "FLAG_BYJS"
|
|
];
|
|
Services.focus.moveFocus(
|
|
window,
|
|
newItem,
|
|
Services.focus.MOVEFOCUS_FIRST,
|
|
flags
|
|
);
|
|
}
|
|
|
|
this.currentItem = newItem;
|
|
}
|
|
}
|
|
|
|
_moveByOffsetFromUserEvent(aOffset, aEvent) {
|
|
if (!aEvent.defaultPrevented) {
|
|
this._userSelecting = true;
|
|
this.moveByOffset(aOffset, !aEvent.ctrlKey, aEvent.shiftKey, aEvent);
|
|
this._userSelecting = false;
|
|
aEvent.preventDefault();
|
|
}
|
|
}
|
|
|
|
_canUserSelect(aItem) {
|
|
var style = document.defaultView.getComputedStyle(aItem);
|
|
return (
|
|
style.display != "none" &&
|
|
style.visibility == "visible" &&
|
|
style.MozUserInput != "none"
|
|
);
|
|
}
|
|
|
|
_selectTimeoutHandler(aMe) {
|
|
aMe._fireOnSelect();
|
|
aMe._selectTimeout = null;
|
|
}
|
|
|
|
timedSelect(aItem, aTimeout) {
|
|
var suppress = this._suppressOnSelect;
|
|
if (aTimeout != -1) {
|
|
this._suppressOnSelect = true;
|
|
}
|
|
|
|
this.selectItem(aItem);
|
|
|
|
this._suppressOnSelect = suppress;
|
|
|
|
if (aTimeout != -1) {
|
|
if (this._selectTimeout) {
|
|
window.clearTimeout(this._selectTimeout);
|
|
}
|
|
this._selectTimeout = window.setTimeout(
|
|
this._selectTimeoutHandler,
|
|
aTimeout,
|
|
this
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For backwards-compatibility and for convenience.
|
|
* Use ensureElementIsVisible instead
|
|
*/
|
|
ensureSelectedElementIsVisible() {
|
|
return this.ensureElementIsVisible(this.selectedItem);
|
|
}
|
|
|
|
_fireEvent(aTarget, aName) {
|
|
let event = document.createEvent("Events");
|
|
event.initEvent(aName, true, true);
|
|
aTarget.dispatchEvent(event);
|
|
}
|
|
};
|
|
|
|
MozXULElement.implementCustomInterface(MozElements.RichListBox, [
|
|
Ci.nsIDOMXULSelectControlElement,
|
|
Ci.nsIDOMXULMultiSelectControlElement,
|
|
]);
|
|
|
|
customElements.define("richlistbox", MozElements.RichListBox);
|
|
|
|
/**
|
|
* XUL:richlistitem element.
|
|
*/
|
|
MozElements.MozRichlistitem = class MozRichlistitem extends MozElements.BaseText {
|
|
constructor() {
|
|
super();
|
|
|
|
this.selectedByMouseOver = false;
|
|
|
|
/**
|
|
* If there is no modifier key, we select on mousedown, not
|
|
* click, so that drags work correctly.
|
|
*/
|
|
this.addEventListener("mousedown", event => {
|
|
var control = this.control;
|
|
if (!control || control.disabled) {
|
|
return;
|
|
}
|
|
if (
|
|
(!event.ctrlKey ||
|
|
(AppConstants.platform == "macosx" && event.button == 2)) &&
|
|
!event.shiftKey &&
|
|
!event.metaKey
|
|
) {
|
|
if (!this.selected) {
|
|
control.selectItem(this);
|
|
}
|
|
control.currentItem = this;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* On a click (up+down on the same item), deselect everything
|
|
* except this item.
|
|
*/
|
|
this.addEventListener("click", event => {
|
|
if (event.button != 0) {
|
|
return;
|
|
}
|
|
|
|
var control = this.control;
|
|
if (!control || control.disabled) {
|
|
return;
|
|
}
|
|
control._userSelecting = true;
|
|
if (control.selType != "multiple") {
|
|
control.selectItem(this);
|
|
} else if (event.ctrlKey || event.metaKey) {
|
|
control.toggleItemSelection(this);
|
|
control.currentItem = this;
|
|
} else if (event.shiftKey) {
|
|
control.selectItemRange(null, this);
|
|
control.currentItem = this;
|
|
} else {
|
|
/* We want to deselect all the selected items except what was
|
|
clicked, UNLESS it was a right-click. We have to do this
|
|
in click rather than mousedown so that you can drag a
|
|
selected group of items */
|
|
|
|
// use selectItemRange instead of selectItem, because this
|
|
// doesn't de- and reselect this item if it is selected
|
|
control.selectItemRange(this, this);
|
|
}
|
|
control._userSelecting = false;
|
|
});
|
|
}
|
|
|
|
connectedCallback() {
|
|
this._updateInnerControlsForSelection(this.selected);
|
|
}
|
|
|
|
/**
|
|
* nsIDOMXULSelectControlItemElement
|
|
*/
|
|
get label() {
|
|
const XUL_NS =
|
|
"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
|
|
return Array.from(
|
|
this.getElementsByTagNameNS(XUL_NS, "label"),
|
|
label => label.value
|
|
).join(" ");
|
|
}
|
|
|
|
set searchLabel(val) {
|
|
if (val !== null) {
|
|
this.setAttribute("searchlabel", val);
|
|
}
|
|
// fall back to the label property (default value)
|
|
else {
|
|
this.removeAttribute("searchlabel");
|
|
}
|
|
}
|
|
|
|
get searchLabel() {
|
|
return this.hasAttribute("searchlabel")
|
|
? this.getAttribute("searchlabel")
|
|
: this.label;
|
|
}
|
|
/**
|
|
* nsIDOMXULSelectControlItemElement
|
|
*/
|
|
set value(val) {
|
|
this.setAttribute("value", val);
|
|
}
|
|
|
|
get value() {
|
|
return this.getAttribute("value");
|
|
}
|
|
|
|
/**
|
|
* nsIDOMXULSelectControlItemElement
|
|
*/
|
|
set selected(val) {
|
|
if (val) {
|
|
this.setAttribute("selected", "true");
|
|
} else {
|
|
this.removeAttribute("selected");
|
|
}
|
|
this._updateInnerControlsForSelection(val);
|
|
}
|
|
|
|
get selected() {
|
|
return this.getAttribute("selected") == "true";
|
|
}
|
|
/**
|
|
* nsIDOMXULSelectControlItemElement
|
|
*/
|
|
get control() {
|
|
var parent = this.parentNode;
|
|
while (parent) {
|
|
if (parent.localName == "richlistbox") {
|
|
return parent;
|
|
}
|
|
parent = parent.parentNode;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
set current(val) {
|
|
if (val) {
|
|
this.setAttribute("current", "true");
|
|
} else {
|
|
this.removeAttribute("current");
|
|
}
|
|
}
|
|
|
|
get current() {
|
|
return this.getAttribute("current") == "true";
|
|
}
|
|
|
|
_updateInnerControlsForSelection(selected) {
|
|
for (let control of this.querySelectorAll("button,menulist")) {
|
|
if (!selected && control.tabIndex == 0) {
|
|
control.tabIndex = -1;
|
|
} else if (selected && control.tabIndex == -1) {
|
|
control.tabIndex = 0;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
MozXULElement.implementCustomInterface(MozElements.MozRichlistitem, [
|
|
Ci.nsIDOMXULSelectControlItemElement,
|
|
]);
|
|
|
|
customElements.define("richlistitem", MozElements.MozRichlistitem);
|
|
}
|