fune/toolkit/content/widgets/button.js
Gijs Kruitbosch 7e7c04544c Bug 1329643 - implement generic richlistbox improvements for keyboard focus, r=Jamie,settings-reviewers,mossop
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
2022-11-14 21:07:08 +00:00

312 lines
8.8 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.
{
class MozButtonBase extends MozElements.BaseText {
constructor() {
super();
/**
* While it would seem we could do this by handling oncommand, we can't
* because any external oncommand handlers might get called before ours,
* and then they would see the incorrect value of checked. Additionally
* a command attribute would redirect the command events anyway.
*/
this.addEventListener("click", event => {
if (event.button != 0) {
return;
}
this._handleClick();
});
this.addEventListener("keypress", event => {
if (event.key != " ") {
return;
}
this._handleClick();
// Prevent page from scrolling on the space key.
event.preventDefault();
});
this.addEventListener("keypress", event => {
if (this.hasMenu()) {
if (this.open) {
return;
}
} else if (!this.inRichListItem) {
if (
event.keyCode == KeyEvent.DOM_VK_UP ||
(event.keyCode == KeyEvent.DOM_VK_LEFT &&
document.defaultView.getComputedStyle(this.parentNode)
.direction == "ltr") ||
(event.keyCode == KeyEvent.DOM_VK_RIGHT &&
document.defaultView.getComputedStyle(this.parentNode)
.direction == "rtl")
) {
event.preventDefault();
window.document.commandDispatcher.rewindFocus();
return;
}
if (
event.keyCode == KeyEvent.DOM_VK_DOWN ||
(event.keyCode == KeyEvent.DOM_VK_RIGHT &&
document.defaultView.getComputedStyle(this.parentNode)
.direction == "ltr") ||
(event.keyCode == KeyEvent.DOM_VK_LEFT &&
document.defaultView.getComputedStyle(this.parentNode)
.direction == "rtl")
) {
event.preventDefault();
window.document.commandDispatcher.advanceFocus();
return;
}
}
if (
event.keyCode ||
event.charCode <= 32 ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return;
} // No printable char pressed, not a potential accesskey
// Possible accesskey pressed
var charPressedLower = String.fromCharCode(
event.charCode
).toLowerCase();
// If the accesskey of the current button is pressed, just activate it
if (this.accessKey.toLowerCase() == charPressedLower) {
this.click();
return;
}
// Search for accesskey in the list of buttons for this doc and each subdoc
// Get the buttons for the main document and all sub-frames
for (
var frameCount = -1;
frameCount < window.top.frames.length;
frameCount++
) {
var doc =
frameCount == -1
? window.top.document
: window.top.frames[frameCount].document;
if (this.fireAccessKeyButton(doc.documentElement, charPressedLower)) {
return;
}
}
// Test dialog buttons
let buttonBox = window.top.document.querySelector("dialog")?.buttonBox;
if (buttonBox) {
this.fireAccessKeyButton(buttonBox, charPressedLower);
}
});
}
set type(val) {
this.setAttribute("type", val);
}
get type() {
return this.getAttribute("type");
}
set disabled(val) {
if (val) {
this.setAttribute("disabled", "true");
} else {
this.removeAttribute("disabled");
}
}
get disabled() {
return this.getAttribute("disabled") == "true";
}
set group(val) {
this.setAttribute("group", val);
}
get group() {
return this.getAttribute("group");
}
set open(val) {
if (this.hasMenu()) {
this.openMenu(val);
} else if (val) {
// Fall back to just setting the attribute
this.setAttribute("open", "true");
} else {
this.removeAttribute("open");
}
}
get open() {
return this.hasAttribute("open");
}
set checked(val) {
if (this.type == "radio" && val) {
var sibs = this.parentNode.getElementsByAttribute("group", this.group);
for (var i = 0; i < sibs.length; ++i) {
sibs[i].removeAttribute("checked");
}
}
if (val) {
this.setAttribute("checked", "true");
} else {
this.removeAttribute("checked");
}
}
get checked() {
return this.hasAttribute("checked");
}
filterButtons(node) {
// if the node isn't visible, don't descend into it.
var cs = node.ownerGlobal.getComputedStyle(node);
if (cs.visibility != "visible" || cs.display == "none") {
return NodeFilter.FILTER_REJECT;
}
// but it may be a popup element, in which case we look at "state"...
if (XULPopupElement.isInstance(node) && node.state != "open") {
return NodeFilter.FILTER_REJECT;
}
// OK - the node seems visible, so it is a candidate.
if (node.localName == "button" && node.accessKey && !node.disabled) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
}
fireAccessKeyButton(aSubtree, aAccessKeyLower) {
var iterator = aSubtree.ownerDocument.createTreeWalker(
aSubtree,
NodeFilter.SHOW_ELEMENT,
this.filterButtons
);
while (iterator.nextNode()) {
var test = iterator.currentNode;
if (
test.accessKey.toLowerCase() == aAccessKeyLower &&
!test.disabled &&
!test.collapsed &&
!test.hidden
) {
test.focus();
test.click();
return true;
}
}
return false;
}
_handleClick() {
if (!this.disabled) {
if (this.type == "checkbox") {
this.checked = !this.checked;
} else if (this.type == "radio") {
this.checked = true;
}
}
}
}
MozXULElement.implementCustomInterface(MozButtonBase, [
Ci.nsIDOMXULButtonElement,
]);
MozElements.ButtonBase = MozButtonBase;
class MozButton extends MozButtonBase {
static get inheritedAttributes() {
return {
".box-inherit": "align,dir,pack,orient",
".button-icon": "src=image",
".button-text": "value=label,accesskey,crop",
".button-menu-dropmarker": "open,disabled,label",
};
}
get icon() {
return this.querySelector(".button-icon");
}
static get buttonFragment() {
let frag = document.importNode(
MozXULElement.parseXULToFragment(`
<hbox class="box-inherit button-box" align="center" pack="center" flex="1" anonid="button-box">
<image class="button-icon"/>
<label class="button-text"/>
</hbox>`),
true
);
Object.defineProperty(this, "buttonFragment", { value: frag });
return frag;
}
static get menuFragment() {
let frag = document.importNode(
MozXULElement.parseXULToFragment(`
<hbox class="box-inherit button-box" align="center" pack="center" flex="1">
<hbox class="box-inherit" align="center" pack="center" flex="1">
<image class="button-icon"/>
<label class="button-text"/>
</hbox>
<dropmarker class="button-menu-dropmarker"/>
</hbox>`),
true
);
Object.defineProperty(this, "menuFragment", { value: frag });
return frag;
}
get _hasConnected() {
return this.querySelector(":scope > .button-box") != null;
}
connectedCallback() {
if (this.delayConnectedCallback() || this._hasConnected) {
return;
}
let fragment;
if (this.type === "menu") {
fragment = MozButton.menuFragment;
this.addEventListener("keypress", event => {
if (event.keyCode != KeyEvent.DOM_VK_RETURN && event.key != " ") {
return;
}
this.open = true;
// Prevent page from scrolling on the space key.
if (event.key == " ") {
event.preventDefault();
}
});
} else {
fragment = this.constructor.buttonFragment;
}
this.appendChild(fragment.cloneNode(true));
this.initializeAttributeInheritance();
this.inRichListItem = !!this.closest("richlistitem");
}
}
customElements.define("button", MozButton);
}