diff options
Diffstat (limited to 'toolkit/content/widgets/radio.js')
-rw-r--r-- | toolkit/content/widgets/radio.js | 567 |
1 files changed, 567 insertions, 0 deletions
diff --git a/toolkit/content/widgets/radio.js b/toolkit/content/widgets/radio.js new file mode 100644 index 0000000000..482323acb9 --- /dev/null +++ b/toolkit/content/widgets/radio.js @@ -0,0 +1,567 @@ +/* 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 MozRadiogroup extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("mousedown", event => { + if (this.disabled) { + event.preventDefault(); + } + }); + + /** + * keyboard navigation Here's how keyboard navigation works in radio groups on Windows: + * The group takes 'focus' + * The user is then free to navigate around inside the group + * using the arrow keys. Accessing previous or following radio buttons + * is done solely through the arrow keys and not the tab button. Tab + * takes you to the next widget in the tab order + */ + this.addEventListener("keypress", event => { + if (event.key != " " || event.originalTarget != this) { + return; + } + this.selectedItem = this.focusedItem; + this.selectedItem.doCommand(); + // Prevent page from scrolling on the space key. + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_UP || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(false); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_LEFT || + event.originalTarget != this + ) { + return; + } + // left arrow goes back when we are ltr, forward when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "rtl" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_DOWN || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(true); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_RIGHT || + event.originalTarget != this + ) { + return; + } + // right arrow goes forward when we are ltr, back when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "ltr" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + /** + * set a focused attribute on the selected item when the group + * receives focus so that we can style it as if it were focused even though + * it is not (Windows platform behaviour is for the group to receive focus, + * not the item + */ + this.addEventListener("focus", event => { + if (event.originalTarget != this) { + return; + } + this.setAttribute("focused", "true"); + if (this.focusedItem) { + return; + } + + var val = this.selectedItem; + if (!val || val.disabled || val.hidden || val.collapsed) { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + val = children[i]; + break; + } + } + } + this.focusedItem = val; + }); + + this.addEventListener("blur", event => { + if (event.originalTarget != this) { + return; + } + this.removeAttribute("focused"); + this.focusedItem = null; + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + // When this is called via `connectedCallback` there are two main variations: + // 1) The radiogroup and radio children are defined in markup. + // 2) We are appending a DocumentFragment + // In both cases, the <radiogroup> connectedCallback fires first. But in (2), + // the children <radio>s won't be upgraded yet, so r.control will be undefined. + // To avoid churn in this case where we would have to reinitialize the list as each + // child radio gets upgraded as a result of init(), ignore the resulting calls + // to radioAttached. + this.ignoreRadioChildConstruction = true; + this.init(); + this.ignoreRadioChildConstruction = false; + if (!this.value) { + this.selectedIndex = 0; + } + } + + init() { + this._radioChildren = null; + + if (this.getAttribute("disabled") == "true") { + this.disabled = true; + } + + var children = this._getRadioChildren(); + var length = children.length; + for (var i = 0; i < length; i++) { + if (children[i].getAttribute("selected") == "true") { + this.selectedIndex = i; + return; + } + } + + var value = this.value; + if (value) { + this.value = value; + } + } + + /** + * Called when a new <radio> gets added to an already connected radiogroup. + * This can happen due to DOM getting appended after the <radiogroup> is created. + * When this happens, reinitialize the UI if necessary to make sure the state is + * consistent. + * + * @param {DOMNode} child + * The <radio> element that got added + */ + radioAttached(child) { + if (this.ignoreRadioChildConstruction) { + return; + } + if (!this._radioChildren || !this._radioChildren.includes(child)) { + this.init(); + } + } + + /** + * Called when a new <radio> gets removed from a radio group. + * + * @param {DOMNode} child + * The <radio> element that got removed + */ + radioUnattached(child) { + // Just invalidate the cache, next time it's fetched it'll get rebuilt. + this._radioChildren = null; + } + + set value(val) { + this.setAttribute("value", val); + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; i++) { + if (String(children[i].value) == String(val)) { + this.selectedItem = children[i]; + break; + } + } + } + + get value() { + return this.getAttribute("value"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + children[i].disabled = val; + } + } + + get disabled() { + if (this.getAttribute("disabled") == "true") { + return true; + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + return false; + } + } + return true; + } + + get itemCount() { + return this._getRadioChildren().length; + } + + set selectedIndex(val) { + this.selectedItem = this._getRadioChildren()[val]; + } + + get selectedIndex() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return i; + } + } + return -1; + } + + set selectedItem(val) { + var focused = this.getAttribute("focused") == "true"; + var alreadySelected = false; + + if (val) { + alreadySelected = val.getAttribute("selected") == "true"; + val.setAttribute("focused", focused); + val.setAttribute("selected", "true"); + this.setAttribute("value", val.value); + } else { + this.removeAttribute("value"); + } + + // uncheck all other group nodes + var children = this._getRadioChildren(); + var previousItem = null; + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + if (children[i].getAttribute("selected") == "true") { + previousItem = children[i]; + } + + children[i].removeAttribute("selected"); + children[i].removeAttribute("focused"); + } + } + + var event = document.createEvent("Events"); + event.initEvent("select", false, true); + this.dispatchEvent(event); + + if (focused) { + if (alreadySelected) { + // Notify accessibility that this item got focus. + event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } else { + // Only report if actual change + if (val) { + // Accessibility will fire focus for this. + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + val.dispatchEvent(event); + } + + if (previousItem) { + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + previousItem.dispatchEvent(event); + } + } + } + } + + get selectedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return children[i]; + } + } + return null; + } + + set focusedItem(val) { + if (val) { + val.setAttribute("focused", "true"); + // Notify accessibility that this item got focus. + let event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } + + // unfocus all other group nodes + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + children[i].removeAttribute("focused"); + } + } + } + + get focusedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].getAttribute("focused") == "true") { + return children[i]; + } + } + return null; + } + + checkAdjacentElement(aNextFlag) { + var currentElement = this.focusedItem || this.selectedItem; + var i; + var children = this._getRadioChildren(); + for (i = 0; i < children.length; ++i) { + if (children[i] == currentElement) { + break; + } + } + var index = i; + + if (aNextFlag) { + do { + if (++i == children.length) { + i = 0; + } + if (i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } else { + do { + if (i == 0) { + i = children.length; + } + if (--i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } + } + + _getRadioChildren() { + if (this._radioChildren) { + return this._radioChildren; + } + + let radioChildren = []; + if (this.hasChildNodes()) { + for (let radio of this.querySelectorAll("radio")) { + customElements.upgrade(radio); + if (radio.control == this) { + radioChildren.push(radio); + } + } + } else { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + for (let radio of this.ownerDocument.getElementsByAttribute( + "group", + this.id + )) { + if (radio.namespaceURI == XUL_NS && radio.localName == "radio") { + customElements.upgrade(radio); + radioChildren.push(radio); + } + } + } + + return (this._radioChildren = radioChildren); + } + + getIndexOfItem(item) { + return this._getRadioChildren().indexOf(item); + } + + getItemAtIndex(index) { + var children = this._getRadioChildren(); + return index >= 0 && index < children.length ? children[index] : null; + } + + appendItem(label, value) { + var radio = document.createXULElement("radio"); + radio.setAttribute("label", label); + radio.setAttribute("value", value); + this.appendChild(radio); + return radio; + } + } + + MozXULElement.implementCustomInterface(MozRadiogroup, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULRadioGroupElement, + ]); + + customElements.define("radiogroup", MozRadiogroup); + + class MozRadio extends MozElements.BaseText { + static get markup() { + return ` + <image class="radio-check"></image> + <hbox class="radio-label-box" align="center" flex="1"> + <image class="radio-icon"></image> + <label class="radio-label" flex="1"></label> + </hbox> + `; + } + + static get inheritedAttributes() { + return { + ".radio-check": "disabled,selected", + ".radio-label": "text=label,accesskey,crop", + ".radio-icon": "src", + }; + } + + constructor() { + super(); + this.addEventListener("click", event => { + if (!this.disabled) { + this.control.selectedItem = this; + } + }); + + this.addEventListener("mousedown", event => { + if (!this.disabled) { + this.control.focusedItem = this; + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + if (!this.connectedOnce) { + this.connectedOnce = true; + // If the caller didn't provide custom content then append the default: + if (!this.firstElementChild) { + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + } + } + + var control = (this._control = this.control); + if (control) { + control.radioAttached(this); + } + } + + disconnectedCallback() { + if (this.control) { + this.control.radioUnattached(this); + } + this._control = null; + } + + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + get radioGroup() { + return this.control; + } + + get control() { + if (this._control) { + return this._control; + } + + var radiogroup = this.closest("radiogroup"); + if (radiogroup) { + return radiogroup; + } + + var group = this.getAttribute("group"); + if (!group) { + return null; + } + + var parent = this.ownerDocument.getElementById(group); + if (!parent || parent.localName != "radiogroup") { + parent = null; + } + return parent; + } + } + + MozXULElement.implementCustomInterface(MozRadio, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("radio", MozRadio); +})(); |