From 2aa4a82499d4becd2284cdb482213d541b8804dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 16:29:10 +0200 Subject: Adding upstream version 86.0.1. Signed-off-by: Daniel Baumann --- toolkit/content/widgets/arrowscrollbox.js | 864 +++++ toolkit/content/widgets/autocomplete-input.js | 674 ++++ toolkit/content/widgets/autocomplete-popup.js | 621 ++++ .../content/widgets/autocomplete-richlistitem.js | 884 ++++++ toolkit/content/widgets/browser-custom-element.js | 1898 +++++++++++ toolkit/content/widgets/button.js | 317 ++ toolkit/content/widgets/calendar.js | 170 + toolkit/content/widgets/checkbox.js | 84 + toolkit/content/widgets/datekeeper.js | 381 +++ toolkit/content/widgets/datepicker.js | 450 +++ toolkit/content/widgets/datetimebox.css | 62 + toolkit/content/widgets/datetimebox.js | 1760 +++++++++++ toolkit/content/widgets/dialog.js | 517 +++ toolkit/content/widgets/docs/index.rst | 10 + toolkit/content/widgets/docs/ua_widget.rst | 58 + toolkit/content/widgets/editor.js | 215 ++ toolkit/content/widgets/findbar.js | 1398 ++++++++ toolkit/content/widgets/general.js | 79 + toolkit/content/widgets/marquee.css | 26 + toolkit/content/widgets/marquee.js | 417 +++ toolkit/content/widgets/menu.js | 492 +++ toolkit/content/widgets/menulist.js | 416 +++ toolkit/content/widgets/menupopup.js | 255 ++ toolkit/content/widgets/moz-input-box.js | 233 ++ toolkit/content/widgets/notificationbox.js | 474 +++ toolkit/content/widgets/panel.js | 305 ++ toolkit/content/widgets/pluginProblem.js | 49 + toolkit/content/widgets/popupnotification.js | 159 + toolkit/content/widgets/radio.js | 573 ++++ toolkit/content/widgets/richlistbox.js | 1023 ++++++ toolkit/content/widgets/search-textbox.js | 264 ++ toolkit/content/widgets/spinner.js | 538 ++++ toolkit/content/widgets/stringbundle.js | 78 + toolkit/content/widgets/tabbox.js | 831 +++++ toolkit/content/widgets/text.js | 394 +++ toolkit/content/widgets/timekeeper.js | 445 +++ toolkit/content/widgets/timepicker.js | 288 ++ toolkit/content/widgets/toolbarbutton.js | 227 ++ toolkit/content/widgets/tree.js | 1644 ++++++++++ toolkit/content/widgets/videocontrols.js | 3339 ++++++++++++++++++++ toolkit/content/widgets/wizard.js | 668 ++++ 41 files changed, 23580 insertions(+) create mode 100644 toolkit/content/widgets/arrowscrollbox.js create mode 100644 toolkit/content/widgets/autocomplete-input.js create mode 100644 toolkit/content/widgets/autocomplete-popup.js create mode 100644 toolkit/content/widgets/autocomplete-richlistitem.js create mode 100644 toolkit/content/widgets/browser-custom-element.js create mode 100644 toolkit/content/widgets/button.js create mode 100644 toolkit/content/widgets/calendar.js create mode 100644 toolkit/content/widgets/checkbox.js create mode 100644 toolkit/content/widgets/datekeeper.js create mode 100644 toolkit/content/widgets/datepicker.js create mode 100644 toolkit/content/widgets/datetimebox.css create mode 100644 toolkit/content/widgets/datetimebox.js create mode 100644 toolkit/content/widgets/dialog.js create mode 100644 toolkit/content/widgets/docs/index.rst create mode 100644 toolkit/content/widgets/docs/ua_widget.rst create mode 100644 toolkit/content/widgets/editor.js create mode 100644 toolkit/content/widgets/findbar.js create mode 100644 toolkit/content/widgets/general.js create mode 100644 toolkit/content/widgets/marquee.css create mode 100644 toolkit/content/widgets/marquee.js create mode 100644 toolkit/content/widgets/menu.js create mode 100644 toolkit/content/widgets/menulist.js create mode 100644 toolkit/content/widgets/menupopup.js create mode 100644 toolkit/content/widgets/moz-input-box.js create mode 100644 toolkit/content/widgets/notificationbox.js create mode 100644 toolkit/content/widgets/panel.js create mode 100644 toolkit/content/widgets/pluginProblem.js create mode 100644 toolkit/content/widgets/popupnotification.js create mode 100644 toolkit/content/widgets/radio.js create mode 100644 toolkit/content/widgets/richlistbox.js create mode 100644 toolkit/content/widgets/search-textbox.js create mode 100644 toolkit/content/widgets/spinner.js create mode 100644 toolkit/content/widgets/stringbundle.js create mode 100644 toolkit/content/widgets/tabbox.js create mode 100644 toolkit/content/widgets/text.js create mode 100644 toolkit/content/widgets/timekeeper.js create mode 100644 toolkit/content/widgets/timepicker.js create mode 100644 toolkit/content/widgets/toolbarbutton.js create mode 100644 toolkit/content/widgets/tree.js create mode 100644 toolkit/content/widgets/videocontrols.js create mode 100644 toolkit/content/widgets/wizard.js (limited to 'toolkit/content/widgets') diff --git a/toolkit/content/widgets/arrowscrollbox.js b/toolkit/content/widgets/arrowscrollbox.js new file mode 100644 index 0000000000..eda0c37d0f --- /dev/null +++ b/toolkit/content/widgets/arrowscrollbox.js @@ -0,0 +1,864 @@ +/* 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 { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + class MozArrowScrollbox extends MozElements.BaseControl { + static get inheritedAttributes() { + return { + "#scrollbutton-up": "disabled=scrolledtostart", + ".scrollbox-clip": "orient", + scrollbox: "orient,align,pack,dir,smoothscroll", + "#scrollbutton-down": "disabled=scrolledtoend", + }; + } + + get markup() { + return ` + + + + + + + + + + + + `; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.fragment); + + this.scrollbox = this.shadowRoot.querySelector("scrollbox"); + this._scrollButtonUp = this.shadowRoot.getElementById("scrollbutton-up"); + this._scrollButtonDown = this.shadowRoot.getElementById( + "scrollbutton-down" + ); + + this._arrowScrollAnim = { + scrollbox: this, + requestHandle: 0, + /* 0 indicates there is no pending request */ + start: function arrowSmoothScroll_start() { + this.lastFrameTime = window.performance.now(); + if (!this.requestHandle) { + this.requestHandle = window.requestAnimationFrame( + this.sample.bind(this) + ); + } + }, + stop: function arrowSmoothScroll_stop() { + window.cancelAnimationFrame(this.requestHandle); + this.requestHandle = 0; + }, + sample: function arrowSmoothScroll_handleEvent(timeStamp) { + const scrollIndex = this.scrollbox._scrollIndex; + const timePassed = timeStamp - this.lastFrameTime; + this.lastFrameTime = timeStamp; + + const scrollDelta = 0.5 * timePassed * scrollIndex; + this.scrollbox.scrollByPixels(scrollDelta, true); + this.requestHandle = window.requestAnimationFrame( + this.sample.bind(this) + ); + }, + }; + + this._scrollIndex = 0; + this._scrollIncrement = null; + this._ensureElementIsVisibleAnimationFrame = 0; + this._prevMouseScrolls = [null, null]; + this._touchStart = -1; + this._scrollButtonUpdatePending = false; + this._isScrolling = false; + this._destination = 0; + this._direction = 0; + + this.addEventListener("wheel", this.on_wheel); + this.addEventListener("touchstart", this.on_touchstart); + this.addEventListener("touchmove", this.on_touchmove); + this.addEventListener("touchend", this.on_touchend); + this.shadowRoot.addEventListener("click", this.on_click.bind(this)); + this.shadowRoot.addEventListener( + "mousedown", + this.on_mousedown.bind(this) + ); + this.shadowRoot.addEventListener( + "mouseover", + this.on_mouseover.bind(this) + ); + this.shadowRoot.addEventListener("mouseup", this.on_mouseup.bind(this)); + this.shadowRoot.addEventListener("mouseout", this.on_mouseout.bind(this)); + + // These events don't get retargeted outside of the shadow root, but + // some callers like tests wait for these events. So run handlers + // and then retarget events from the scrollbox to the host. + this.scrollbox.addEventListener( + "underflow", + event => { + this.on_underflow(event); + this.dispatchEvent(new Event("underflow")); + }, + true + ); + this.scrollbox.addEventListener( + "overflow", + event => { + this.on_overflow(event); + this.dispatchEvent(new Event("overflow")); + }, + true + ); + this.scrollbox.addEventListener("scroll", event => { + this.on_scroll(event); + this.dispatchEvent(new Event("scroll")); + }); + + this.scrollbox.addEventListener("scrollend", event => { + this.on_scrollend(event); + this.dispatchEvent(new Event("scrollend")); + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + if (!this.hasAttribute("smoothscroll")) { + this.smoothScroll = Services.prefs.getBoolPref( + "toolkit.scrollbox.smoothScroll", + true + ); + } + + this.removeAttribute("overflowing"); + this.initializeAttributeInheritance(); + this._updateScrollButtonsDisabledState(); + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment( + this.markup + ); + } + return document.importNode(this.constructor._fragment, true); + } + + get _clickToScroll() { + return this.hasAttribute("clicktoscroll"); + } + + get _scrollDelay() { + if (this._clickToScroll) { + return Services.prefs.getIntPref( + "toolkit.scrollbox.clickToScroll.scrollDelay", + 150 + ); + } + + // Use the same REPEAT_DELAY as "nsRepeatService.h". + return /Mac/.test(navigator.platform) ? 25 : 50; + } + + get scrollIncrement() { + if (this._scrollIncrement === null) { + this._scrollIncrement = Services.prefs.getIntPref( + "toolkit.scrollbox.scrollIncrement", + 20 + ); + } + return this._scrollIncrement; + } + + set smoothScroll(val) { + this.setAttribute("smoothscroll", !!val); + return val; + } + + get smoothScroll() { + return this.getAttribute("smoothscroll") == "true"; + } + + get scrollClientRect() { + return this.scrollbox.getBoundingClientRect(); + } + + get scrollClientSize() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.clientHeight + : this.scrollbox.clientWidth; + } + + get scrollSize() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.scrollHeight + : this.scrollbox.scrollWidth; + } + + get lineScrollAmount() { + // line scroll amout should be the width (at horizontal scrollbox) or + // the height (at vertical scrollbox) of the scrolled elements. + // However, the elements may have different width or height. So, + // for consistent speed, let's use avalage with of the elements. + var elements = this._getScrollableElements(); + return elements.length && this.scrollSize / elements.length; + } + + get scrollPosition() { + return this.getAttribute("orient") == "vertical" + ? this.scrollbox.scrollTop + : this.scrollbox.scrollLeft; + } + + get startEndProps() { + if (!this._startEndProps) { + this._startEndProps = + this.getAttribute("orient") == "vertical" + ? ["top", "bottom"] + : ["left", "right"]; + } + return this._startEndProps; + } + + get isRTLScrollbox() { + if (!this._isRTLScrollbox) { + this._isRTLScrollbox = + this.getAttribute("orient") != "vertical" && + document.defaultView.getComputedStyle(this.scrollbox).direction == + "rtl"; + } + return this._isRTLScrollbox; + } + + _onButtonClick(event) { + if (this._clickToScroll) { + this._distanceScroll(event); + } + } + + _onButtonMouseDown(event, index) { + if (this._clickToScroll && event.button == 0) { + this._startScroll(index); + } + } + + _onButtonMouseUp(event) { + if (this._clickToScroll && event.button == 0) { + this._stopScroll(); + } + } + + _onButtonMouseOver(index) { + if (this._clickToScroll) { + this._continueScroll(index); + } else { + this._startScroll(index); + } + } + + _onButtonMouseOut() { + if (this._clickToScroll) { + this._pauseScroll(); + } else { + this._stopScroll(); + } + } + + _boundsWithoutFlushing(element) { + if (!("_DOMWindowUtils" in this)) { + this._DOMWindowUtils = window.windowUtils; + } + + return this._DOMWindowUtils + ? this._DOMWindowUtils.getBoundsWithoutFlushing(element) + : element.getBoundingClientRect(); + } + + _canScrollToElement(element) { + if (element.hidden) { + return false; + } + + // See if the element is hidden via CSS without the hidden attribute. + // If we get only zeros for the client rect, this means the element + // is hidden. As a performance optimization, we don't flush layout + // here which means that on the fly changes aren't fully supported. + let rect = this._boundsWithoutFlushing(element); + return !!(rect.top || rect.left || rect.width || rect.height); + } + + ensureElementIsVisible(element, aInstant) { + if (!this._canScrollToElement(element)) { + return; + } + + if (this._ensureElementIsVisibleAnimationFrame) { + window.cancelAnimationFrame(this._ensureElementIsVisibleAnimationFrame); + } + this._ensureElementIsVisibleAnimationFrame = window.requestAnimationFrame( + () => { + element.scrollIntoView({ + block: "nearest", + behavior: aInstant ? "instant" : "auto", + }); + this._ensureElementIsVisibleAnimationFrame = 0; + } + ); + } + + scrollByIndex(index, aInstant) { + if (index == 0) { + return; + } + + var rect = this.scrollClientRect; + var [start, end] = this.startEndProps; + var x = index > 0 ? rect[end] + 1 : rect[start] - 1; + var nextElement = this._elementFromPoint(x, index); + if (!nextElement) { + return; + } + + var targetElement; + if (this.isRTLScrollbox) { + index *= -1; + } + while (index < 0 && nextElement) { + if (this._canScrollToElement(nextElement)) { + targetElement = nextElement; + } + nextElement = nextElement.previousElementSibling; + index++; + } + while (index > 0 && nextElement) { + if (this._canScrollToElement(nextElement)) { + targetElement = nextElement; + } + nextElement = nextElement.nextElementSibling; + index--; + } + if (!targetElement) { + return; + } + + this.ensureElementIsVisible(targetElement, aInstant); + } + + _getScrollableElements() { + let nodes = this.children; + if (nodes.length == 1) { + let node = nodes[0]; + if ( + node.localName == "slot" && + node.namespaceURI == "http://www.w3.org/1999/xhtml" + ) { + nodes = node.getRootNode().host.children; + } + } + return Array.prototype.filter.call(nodes, this._canScrollToElement, this); + } + + _elementFromPoint(aX, aPhysicalScrollDir) { + var elements = this._getScrollableElements(); + if (!elements.length) { + return null; + } + + if (this.isRTLScrollbox) { + elements.reverse(); + } + + var [start, end] = this.startEndProps; + var low = 0; + var high = elements.length - 1; + + if ( + aX < elements[low].getBoundingClientRect()[start] || + aX > elements[high].getBoundingClientRect()[end] + ) { + return null; + } + + var mid, rect; + while (low <= high) { + mid = Math.floor((low + high) / 2); + rect = elements[mid].getBoundingClientRect(); + if (rect[start] > aX) { + high = mid - 1; + } else if (rect[end] < aX) { + low = mid + 1; + } else { + return elements[mid]; + } + } + + // There's no element at the requested coordinate, but the algorithm + // from above yields an element next to it, in a random direction. + // The desired scrolling direction leads to the correct element. + + if (!aPhysicalScrollDir) { + return null; + } + + if (aPhysicalScrollDir < 0 && rect[start] > aX) { + mid = Math.max(mid - 1, 0); + } else if (aPhysicalScrollDir > 0 && rect[end] < aX) { + mid = Math.min(mid + 1, elements.length - 1); + } + + return elements[mid]; + } + + _startScroll(index) { + if (this.isRTLScrollbox) { + index *= -1; + } + + if (this._clickToScroll) { + this._scrollIndex = index; + this._mousedown = true; + + if (this.smoothScroll) { + this._arrowScrollAnim.start(); + return; + } + } + + if (!this._scrollTimer) { + this._scrollTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + } else { + this._scrollTimer.cancel(); + } + + let callback; + if (this._clickToScroll) { + callback = () => { + if (!document && this._scrollTimer) { + this._scrollTimer.cancel(); + } + this.scrollByIndex(this._scrollIndex); + }; + } else { + callback = () => this.scrollByPixels(this.scrollIncrement * index); + } + + this._scrollTimer.initWithCallback( + callback, + this._scrollDelay, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + + callback(); + } + + _stopScroll() { + if (this._scrollTimer) { + this._scrollTimer.cancel(); + } + + if (this._clickToScroll) { + this._mousedown = false; + if (!this._scrollIndex || !this.smoothScroll) { + return; + } + + this.scrollByIndex(this._scrollIndex); + this._scrollIndex = 0; + + this._arrowScrollAnim.stop(); + } + } + + _pauseScroll() { + if (this._mousedown) { + this._stopScroll(); + this._mousedown = true; + document.addEventListener("mouseup", this); + document.addEventListener("blur", this, true); + } + } + + _continueScroll(index) { + if (this._mousedown) { + this._startScroll(index); + } + } + + _distanceScroll(aEvent) { + if (aEvent.detail < 2 || aEvent.detail > 3) { + return; + } + + var scrollBack = aEvent.originalTarget == this._scrollButtonUp; + var scrollLeftOrUp = this.isRTLScrollbox ? !scrollBack : scrollBack; + var targetElement; + + if (aEvent.detail == 2) { + // scroll by the size of the scrollbox + let [start, end] = this.startEndProps; + let x; + if (scrollLeftOrUp) { + x = this.scrollClientRect[start] - this.scrollClientSize; + } else { + x = this.scrollClientRect[end] + this.scrollClientSize; + } + targetElement = this._elementFromPoint(x, scrollLeftOrUp ? -1 : 1); + + // the next partly-hidden element will become fully visible, + // so don't scroll too far + if (targetElement) { + targetElement = scrollBack + ? targetElement.nextElementSibling + : targetElement.previousElementSibling; + } + } + + if (!targetElement) { + // scroll to the first resp. last element + let elements = this._getScrollableElements(); + targetElement = scrollBack + ? elements[0] + : elements[elements.length - 1]; + } + + this.ensureElementIsVisible(targetElement); + } + + handleEvent(aEvent) { + if ( + aEvent.type == "mouseup" || + (aEvent.type == "blur" && aEvent.target == document) + ) { + this._mousedown = false; + document.removeEventListener("mouseup", this); + document.removeEventListener("blur", this, true); + } + } + + scrollByPixels(aPixels, aInstant) { + let scrollOptions = { behavior: aInstant ? "instant" : "auto" }; + scrollOptions[this.startEndProps[0]] = aPixels; + this.scrollbox.scrollBy(scrollOptions); + } + + _updateScrollButtonsDisabledState() { + if (!this.hasAttribute("overflowing")) { + this.setAttribute("scrolledtoend", "true"); + this.setAttribute("scrolledtostart", "true"); + return; + } + + if (this._scrollButtonUpdatePending) { + return; + } + this._scrollButtonUpdatePending = true; + + // Wait until after the next paint to get current layout data from + // getBoundsWithoutFlushing. + window.requestAnimationFrame(() => { + setTimeout(() => { + if (!this.isConnected) { + // We've been destroyed in the meantime. + return; + } + + this._scrollButtonUpdatePending = false; + + let scrolledToStart = false; + let scrolledToEnd = false; + + if (!this.hasAttribute("overflowing")) { + scrolledToStart = true; + scrolledToEnd = true; + } else { + let [leftOrTop, rightOrBottom] = this.startEndProps; + let leftOrTopEdge = ele => + Math.round(this._boundsWithoutFlushing(ele)[leftOrTop]); + let rightOrBottomEdge = ele => + Math.round(this._boundsWithoutFlushing(ele)[rightOrBottom]); + + let elements = this._getScrollableElements(); + let [leftOrTopElement, rightOrBottomElement] = [ + elements[0], + elements[elements.length - 1], + ]; + if (this.isRTLScrollbox) { + [leftOrTopElement, rightOrBottomElement] = [ + rightOrBottomElement, + leftOrTopElement, + ]; + } + + if ( + leftOrTopElement && + leftOrTopEdge(leftOrTopElement) >= leftOrTopEdge(this.scrollbox) + ) { + scrolledToStart = !this.isRTLScrollbox; + scrolledToEnd = this.isRTLScrollbox; + } else if ( + rightOrBottomElement && + rightOrBottomEdge(rightOrBottomElement) <= + rightOrBottomEdge(this.scrollbox) + ) { + scrolledToStart = this.isRTLScrollbox; + scrolledToEnd = !this.isRTLScrollbox; + } + } + + if (scrolledToEnd) { + this.setAttribute("scrolledtoend", "true"); + } else { + this.removeAttribute("scrolledtoend"); + } + + if (scrolledToStart) { + this.setAttribute("scrolledtostart", "true"); + } else { + this.removeAttribute("scrolledtostart"); + } + }, 0); + }); + } + + disconnectedCallback() { + // Release timer to avoid reference cycles. + if (this._scrollTimer) { + this._scrollTimer.cancel(); + this._scrollTimer = null; + } + } + + on_wheel(event) { + // Don't consume the event if we can't scroll. + if (!this.hasAttribute("overflowing")) { + return; + } + + let doScroll = false; + let instant; + let scrollAmount = 0; + if (this.getAttribute("orient") == "vertical") { + doScroll = true; + if (event.deltaMode == event.DOM_DELTA_PIXEL) { + scrollAmount = event.deltaY; + } else if (event.deltaMode == event.DOM_DELTA_PAGE) { + scrollAmount = event.deltaY * this.scrollClientSize; + } else { + scrollAmount = event.deltaY * this.lineScrollAmount; + } + } else { + // We allow vertical scrolling to scroll a horizontal scrollbox + // because many users have a vertical scroll wheel but no + // horizontal support. + // Because of this, we need to avoid scrolling chaos on trackpads + // and mouse wheels that support simultaneous scrolling in both axes. + // We do this by scrolling only when the last two scroll events were + // on the same axis as the current scroll event. + // For diagonal scroll events we only respect the dominant axis. + let isVertical = Math.abs(event.deltaY) > Math.abs(event.deltaX); + let delta = isVertical ? event.deltaY : event.deltaX; + let scrollByDelta = isVertical && this.isRTLScrollbox ? -delta : delta; + + if (this._prevMouseScrolls.every(prev => prev == isVertical)) { + doScroll = true; + if (event.deltaMode == event.DOM_DELTA_PIXEL) { + scrollAmount = scrollByDelta; + instant = true; + } else if (event.deltaMode == event.DOM_DELTA_PAGE) { + scrollAmount = scrollByDelta * this.scrollClientSize; + } else { + scrollAmount = scrollByDelta * this.lineScrollAmount; + } + } + + if (this._prevMouseScrolls.length > 1) { + this._prevMouseScrolls.shift(); + } + this._prevMouseScrolls.push(isVertical); + } + + if (doScroll) { + let direction = scrollAmount < 0 ? -1 : 1; + let startPos = this.scrollPosition; + + if (!this._isScrolling || this._direction != direction) { + this._destination = startPos + scrollAmount; + this._direction = direction; + } else { + // We were already in the process of scrolling in this direction + this._destination = this._destination + scrollAmount; + scrollAmount = this._destination - startPos; + } + this.scrollByPixels(scrollAmount, instant); + } + + event.stopPropagation(); + event.preventDefault(); + } + + on_touchstart(event) { + if (event.touches.length > 1) { + // Multiple touch points detected, abort. In particular this aborts + // the panning gesture when the user puts a second finger down after + // already panning with one finger. Aborting at this point prevents + // the pan gesture from being resumed until all fingers are lifted + // (as opposed to when the user is back down to one finger). + this._touchStart = -1; + } else { + this._touchStart = + this.getAttribute("orient") == "vertical" + ? event.touches[0].screenY + : event.touches[0].screenX; + } + } + + on_touchmove(event) { + if (event.touches.length == 1 && this._touchStart >= 0) { + var touchPoint = + this.getAttribute("orient") == "vertical" + ? event.touches[0].screenY + : event.touches[0].screenX; + var delta = this._touchStart - touchPoint; + if (Math.abs(delta) > 0) { + this.scrollByPixels(delta, true); + this._touchStart = touchPoint; + } + event.preventDefault(); + } + } + + on_touchend(event) { + this._touchStart = -1; + } + + on_underflow(event) { + // Ignore underflow events: + // - from nested scrollable elements + // - corresponding to an overflow event that we ignored + if (event.target != this.scrollbox || !this.hasAttribute("overflowing")) { + return; + } + + // Ignore events that doesn't match our orientation. + // Scrollport event orientation: + // 0: vertical + // 1: horizontal + // 2: both + if (this.getAttribute("orient") == "vertical") { + if (event.detail == 1) { + return; + } + } else if (event.detail == 0) { + // horizontal scrollbox + return; + } + + this.removeAttribute("overflowing"); + this._updateScrollButtonsDisabledState(); + } + + on_overflow(event) { + // Ignore overflow events: + // - from nested scrollable elements + if (event.target != this.scrollbox) { + return; + } + + // Ignore events that doesn't match our orientation. + // Scrollport event orientation: + // 0: vertical + // 1: horizontal + // 2: both + if (this.getAttribute("orient") == "vertical") { + if (event.detail == 1) { + return; + } + } else if (event.detail == 0) { + // horizontal scrollbox + return; + } + + this.setAttribute("overflowing", "true"); + this._updateScrollButtonsDisabledState(); + } + + on_scroll(event) { + this._isScrolling = true; + this._updateScrollButtonsDisabledState(); + } + + on_scrollend(event) { + this._isScrolling = false; + this._destination = 0; + this._direction = 0; + } + + on_click(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonClick(event); + } + + on_mousedown(event) { + if (event.originalTarget == this._scrollButtonUp) { + this._onButtonMouseDown(event, -1); + } + if (event.originalTarget == this._scrollButtonDown) { + this._onButtonMouseDown(event, 1); + } + } + + on_mouseup(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonMouseUp(event); + } + + on_mouseover(event) { + if (event.originalTarget == this._scrollButtonUp) { + this._onButtonMouseOver(-1); + } + if (event.originalTarget == this._scrollButtonDown) { + this._onButtonMouseOver(1); + } + } + + on_mouseout(event) { + if ( + event.originalTarget != this._scrollButtonUp && + event.originalTarget != this._scrollButtonDown + ) { + return; + } + this._onButtonMouseOut(); + } + } + + customElements.define("arrowscrollbox", MozArrowScrollbox); +} diff --git a/toolkit/content/widgets/autocomplete-input.js b/toolkit/content/widgets/autocomplete-input.js new file mode 100644 index 0000000000..935590cd14 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-input.js @@ -0,0 +1,674 @@ +/* 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.import( + "resource://gre/modules/AppConstants.jsm" + ); + const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" + ); + + class AutocompleteInput extends HTMLInputElement { + constructor() { + super(); + + this.popupSelectedIndex = -1; + + ChromeUtils.defineModuleGetter( + this, + "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm" + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "disablePopupAutohide", + "ui.popup.disable_autohide", + false + ); + + this.addEventListener("input", event => { + this.onInput(event); + }); + + this.addEventListener("keydown", event => this.handleKeyDown(event)); + + this.addEventListener( + "compositionstart", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleStartComposition(); + } + }, + true + ); + + this.addEventListener( + "compositionend", + event => { + if ( + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.handleEndComposition(); + } + }, + true + ); + + this.addEventListener( + "focus", + event => { + this.attachController(); + if ( + window.gBrowser && + window.gBrowser.selectedBrowser.hasAttribute("usercontextid") + ) { + this.userContextId = parseInt( + window.gBrowser.selectedBrowser.getAttribute("usercontextid") + ); + } else { + this.userContextId = 0; + } + }, + true + ); + + this.addEventListener( + "blur", + event => { + if (!this._dontBlur) { + if (this.forceComplete && this.mController.matchCount >= 1) { + // If forceComplete is requested, we need to call the enter processing + // on blur so the input will be forced to the closest match. + // Thunderbird is the only consumer of forceComplete and this is used + // to force an recipient's email to the exact address book entry. + this.mController.handleEnter(true); + } + if (!this.ignoreBlurWhileSearching) { + this._dontClosePopup = this.disablePopupAutohide; + this.detachController(); + } + } + }, + true + ); + } + + connectedCallback() { + this.setAttribute("is", "autocomplete-input"); + this.setAttribute("autocomplete", "off"); + + this.mController = Cc[ + "@mozilla.org/autocomplete/controller;1" + ].getService(Ci.nsIAutoCompleteController); + this.mSearchNames = null; + this.mIgnoreInput = false; + this.noRollupOnEmptySearch = false; + + this._popup = null; + + this.nsIAutocompleteInput = this.getCustomInterfaceCallback( + Ci.nsIAutoCompleteInput + ); + + this.valueIsTyped = false; + } + + get popup() { + // Memoize the result in a field rather than replacing this property, + // so that it can be reset along with the binding. + if (this._popup) { + return this._popup; + } + + let popup = null; + let popupId = this.getAttribute("autocompletepopup"); + if (popupId) { + popup = document.getElementById(popupId); + } + + /* This path is only used in tests, we have the and + in document for other usages */ + if (!popup) { + popup = document.createXULElement("panel", { + is: "autocomplete-richlistbox-popup", + }); + popup.setAttribute("type", "autocomplete-richlistbox"); + popup.setAttribute("noautofocus", "true"); + + if (!this._popupset) { + this._popupset = document.createXULElement("popupset"); + document.documentElement.appendChild(this._popupset); + } + + this._popupset.appendChild(popup); + } + popup.mInput = this; + + return (this._popup = popup); + } + + get popupElement() { + return this.popup; + } + + get controller() { + return this.mController; + } + + set popupOpen(val) { + if (val) { + this.openPopup(); + } else { + this.closePopup(); + } + } + + get popupOpen() { + return this.popup.popupOpen; + } + + set disableAutoComplete(val) { + this.setAttribute("disableautocomplete", val); + return val; + } + + get disableAutoComplete() { + return this.getAttribute("disableautocomplete") == "true"; + } + + set completeDefaultIndex(val) { + this.setAttribute("completedefaultindex", val); + return val; + } + + get completeDefaultIndex() { + return this.getAttribute("completedefaultindex") == "true"; + } + + set completeSelectedIndex(val) { + this.setAttribute("completeselectedindex", val); + return val; + } + + get completeSelectedIndex() { + return this.getAttribute("completeselectedindex") == "true"; + } + + set forceComplete(val) { + this.setAttribute("forcecomplete", val); + return val; + } + + get forceComplete() { + return this.getAttribute("forcecomplete") == "true"; + } + + set minResultsForPopup(val) { + this.setAttribute("minresultsforpopup", val); + return val; + } + + get minResultsForPopup() { + var m = parseInt(this.getAttribute("minresultsforpopup")); + return isNaN(m) ? 1 : m; + } + + set timeout(val) { + this.setAttribute("timeout", val); + return val; + } + + get timeout() { + var t = parseInt(this.getAttribute("timeout")); + return isNaN(t) ? 50 : t; + } + + set searchParam(val) { + this.setAttribute("autocompletesearchparam", val); + return val; + } + + get searchParam() { + return this.getAttribute("autocompletesearchparam") || ""; + } + + get searchCount() { + this.initSearchNames(); + return this.mSearchNames.length; + } + + get inPrivateContext() { + return this.PrivateBrowsingUtils.isWindowPrivate(window); + } + + get noRollupOnCaretMove() { + return this.popup.getAttribute("norolluponanchor") == "true"; + } + + set textValue(val) { + // "input" event is automatically dispatched by the editor if + // necessary. + this._setValueInternal(val, true); + + return this.value; + } + + get textValue() { + return this.value; + } + /** + * =================== nsIDOMXULMenuListElement =================== + */ + get editable() { + return true; + } + + set crop(val) { + return false; + } + + get crop() { + return false; + } + + set open(val) { + if (val) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + get open() { + return this.getAttribute("open") == "true"; + } + + set value(val) { + return this._setValueInternal(val, false); + } + + get value() { + return super.value; + } + + get focused() { + return this === document.activeElement; + } + /** + * maximum number of rows to display at a time when opening the popup normally + * (e.g., focus element and press the down arrow) + */ + set maxRows(val) { + this.setAttribute("maxrows", val); + return val; + } + + get maxRows() { + return parseInt(this.getAttribute("maxrows")) || 0; + } + /** + * maximum number of rows to display at a time when opening the popup by + * clicking the dropmarker (for inputs that have one) + */ + set maxdropmarkerrows(val) { + this.setAttribute("maxdropmarkerrows", val); + return val; + } + + get maxdropmarkerrows() { + return parseInt(this.getAttribute("maxdropmarkerrows"), 10) || 14; + } + /** + * option to allow scrolling through the list via the tab key, rather than + * tab moving focus out of the textbox + */ + set tabScrolling(val) { + this.setAttribute("tabscrolling", val); + return val; + } + + get tabScrolling() { + return this.getAttribute("tabscrolling") == "true"; + } + /** + * option to completely ignore any blur events while searches are + * still going on. + */ + set ignoreBlurWhileSearching(val) { + this.setAttribute("ignoreblurwhilesearching", val); + return val; + } + + get ignoreBlurWhileSearching() { + return this.getAttribute("ignoreblurwhilesearching") == "true"; + } + /** + * option to highlight entries that don't have any matches + */ + set highlightNonMatches(val) { + this.setAttribute("highlightnonmatches", val); + return val; + } + + get highlightNonMatches() { + return this.getAttribute("highlightnonmatches") == "true"; + } + + getSearchAt(aIndex) { + this.initSearchNames(); + return this.mSearchNames[aIndex]; + } + + setTextValueWithReason(aValue, aReason) { + this.textValue = aValue; + } + + selectTextRange(aStartIndex, aEndIndex) { + super.setSelectionRange(aStartIndex, aEndIndex); + } + + onSearchBegin() { + if (this.popup && typeof this.popup.onSearchBegin == "function") { + this.popup.onSearchBegin(); + } + } + + onSearchComplete() { + if (this.mController.matchCount == 0) { + this.setAttribute("nomatch", "true"); + } else { + this.removeAttribute("nomatch"); + } + + if (this.ignoreBlurWhileSearching && !this.focused) { + this.handleEnter(); + this.detachController(); + } + } + + onTextEntered(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textEntered", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + onTextReverted(event) { + if (this.getAttribute("notifylegacyevents") === "true") { + let e = new CustomEvent("textReverted", { + bubbles: false, + cancelable: true, + detail: { rootEvent: event }, + }); + return !this.dispatchEvent(e); + } + return false; + } + + /** + * =================== PRIVATE MEMBERS =================== + */ + + /* + * ::::::::::::: autocomplete controller ::::::::::::: + */ + + attachController() { + this.mController.input = this.nsIAutocompleteInput; + } + + detachController() { + if ( + this.mController.input && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.mController.input = null; + } + } + + /** + * ::::::::::::: popup opening ::::::::::::: + */ + openPopup() { + if (this.focused) { + this.popup.openAutocompletePopup(this.nsIAutocompleteInput, this); + } + } + + closePopup() { + if (this._dontClosePopup) { + delete this._dontClosePopup; + return; + } + this.popup.closePopup(); + } + + showHistoryPopup() { + // Store our "normal" maxRows on the popup, so that it can reset the + // value when the popup is hidden. + this.popup._normalMaxRows = this.maxRows; + + // Temporarily change our maxRows, since we want the dropdown to be a + // different size in this case. The popup's popupshowing/popuphiding + // handlers will take care of resetting this. + this.maxRows = this.maxdropmarkerrows; + + // Ensure that we have focus. + if (!this.focused) { + this.focus(); + } + this.attachController(); + this.mController.startSearch(""); + } + + toggleHistoryPopup() { + if (!this.popup.popupOpen) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + handleKeyDown(aEvent) { + // Re: urlbarDeferred, see the comment in urlbarBindings.xml. + if (aEvent.defaultPrevented && !aEvent.urlbarDeferred) { + return false; + } + + if ( + typeof this.onBeforeHandleKeyDown == "function" && + this.onBeforeHandleKeyDown(aEvent) + ) { + return true; + } + + const isMac = AppConstants.platform == "macosx"; + var cancel = false; + + // Catch any keys that could potentially move the caret. Ctrl can be + // used in combination with these keys on Windows and Linux; and Alt + // can be used on OS X, so make sure the unused one isn't used. + let metaKey = isMac ? aEvent.ctrlKey : aEvent.altKey; + if (!metaKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_LEFT: + case KeyEvent.DOM_VK_RIGHT: + case KeyEvent.DOM_VK_HOME: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle keys that are not part of a keyboard shortcut (no Ctrl or Alt) + if (!aEvent.ctrlKey && !aEvent.altKey) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_TAB: + if (this.tabScrolling && this.popup.popupOpen) { + cancel = this.mController.handleKeyNavigation( + aEvent.shiftKey ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN + ); + } else if (this.forceComplete && this.mController.matchCount >= 1) { + this.mController.handleTab(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + cancel = this.mController.handleKeyNavigation(aEvent.keyCode); + break; + } + } + + // Handle readline/emacs-style navigation bindings on Mac. + if ( + isMac && + this.popup.popupOpen && + aEvent.ctrlKey && + (aEvent.key === "n" || aEvent.key === "p") + ) { + const effectiveKey = + aEvent.key === "p" ? KeyEvent.DOM_VK_UP : KeyEvent.DOM_VK_DOWN; + cancel = this.mController.handleKeyNavigation(effectiveKey); + } + + // Handle keys we know aren't part of a shortcut, even with Alt or + // Ctrl. + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + cancel = this.mController.handleEscape(); + break; + case KeyEvent.DOM_VK_RETURN: + if (isMac) { + // Prevent the default action, since it will beep on Mac + if (aEvent.metaKey) { + aEvent.preventDefault(); + } + } + if (this.popup.selectedIndex >= 0) { + this.popupSelectedIndex = this.popup.selectedIndex; + } + cancel = this.handleEnter(aEvent); + break; + case KeyEvent.DOM_VK_DELETE: + if (isMac && !aEvent.shiftKey) { + break; + } + cancel = this.handleDelete(); + break; + case KeyEvent.DOM_VK_BACK_SPACE: + if (isMac && aEvent.shiftKey) { + cancel = this.handleDelete(); + } + break; + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_UP: + if (aEvent.altKey) { + this.toggleHistoryPopup(); + } + break; + case KeyEvent.DOM_VK_F4: + if (!isMac) { + this.toggleHistoryPopup(); + } + break; + } + + if (cancel) { + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + + return true; + } + + handleEnter(event) { + return this.mController.handleEnter(false, event || null); + } + + handleDelete() { + return this.mController.handleDelete(); + } + + /** + * ::::::::::::: miscellaneous ::::::::::::: + */ + initSearchNames() { + if (!this.mSearchNames) { + var names = this.getAttribute("autocompletesearch"); + if (!names) { + this.mSearchNames = []; + } else { + this.mSearchNames = names.split(" "); + } + } + } + + _focus() { + this._dontBlur = true; + this.focus(); + this._dontBlur = false; + } + + resetActionType() { + if (this.mIgnoreInput) { + return; + } + this.removeAttribute("actiontype"); + } + + _setValueInternal(value, isUserInput) { + this.mIgnoreInput = true; + + if (typeof this.onBeforeValueSet == "function") { + value = this.onBeforeValueSet(value); + } + + this.valueIsTyped = false; + if (isUserInput) { + super.setUserInput(value); + } else { + super.value = value; + } + + this.mIgnoreInput = false; + var event = document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + super.dispatchEvent(event); + return value; + } + + onInput(aEvent) { + if ( + !this.mIgnoreInput && + this.mController.input.wrappedJSObject == this.nsIAutocompleteInput + ) { + this.valueIsTyped = true; + this.mController.handleText(); + } + this.resetActionType(); + } + } + + MozHTMLElement.implementCustomInterface(AutocompleteInput, [ + Ci.nsIAutoCompleteInput, + Ci.nsIDOMXULMenuListElement, + ]); + customElements.define("autocomplete-input", AutocompleteInput, { + extends: "input", + }); +} diff --git a/toolkit/content/widgets/autocomplete-popup.js b/toolkit/content/widgets/autocomplete-popup.js new file mode 100644 index 0000000000..ebd549aaa7 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-popup.js @@ -0,0 +1,621 @@ +/* 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 MozPopupElement = MozElements.MozElementMixin(XULPopupElement); + MozElements.MozAutocompleteRichlistboxPopup = class MozAutocompleteRichlistboxPopup extends MozPopupElement { + constructor() { + super(); + + this.mInput = null; + this.mPopupOpen = false; + this._currentIndex = 0; + this._disabledItemClicked = false; + + this.setListeners(); + } + + initialize() { + this.setAttribute("ignorekeys", "true"); + this.setAttribute("level", "top"); + this.setAttribute("consumeoutsideclicks", "never"); + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + /** + * This is the default number of rows that we give the autocomplete + * popup when the textbox doesn't have a "maxrows" attribute + * for us to use. + */ + this.defaultMaxRows = 6; + + /** + * In some cases (e.g. when the input's dropmarker button is clicked), + * the input wants to display a popup with more rows. In that case, it + * should increase its maxRows property and store the "normal" maxRows + * in this field. When the popup is hidden, we restore the input's + * maxRows to the value stored in this field. + * + * This field is set to -1 between uses so that we can tell when it's + * been set by the input and when we need to set it in the popupshowing + * handler. + */ + this._normalMaxRows = -1; + this._previousSelectedIndex = -1; + this.mLastMoveTime = Date.now(); + this.mousedOverIndex = -1; + this._richlistbox = this.querySelector(".autocomplete-richlistbox"); + + if (!this.listEvents) { + this.listEvents = { + handleEvent: event => { + if (!this.parentNode) { + return; + } + + switch (event.type) { + case "mousedown": + this._disabledItemClicked = !!event.target.closest( + "richlistitem" + )?.disabled; + break; + case "mouseup": + // Don't call onPopupClick for the scrollbar buttons, thumb, + // slider, etc. If we hit the richlistbox and not a + // richlistitem, we ignore the event. + if ( + event.target.closest("richlistbox,richlistitem").localName == + "richlistitem" && + !this._disabledItemClicked + ) { + this.onPopupClick(event); + } + this._disabledItemClicked = false; + break; + case "mousemove": + if (Date.now() - this.mLastMoveTime <= 30) { + return; + } + + let item = event.target.closest("richlistbox,richlistitem"); + + // If we hit the richlistbox and not a richlistitem, we ignore + // the event. + if (item.localName == "richlistbox") { + return; + } + + let index = this.richlistbox.getIndexOfItem(item); + + this.mousedOverIndex = index; + + if (item.selectedByMouseOver) { + this.richlistbox.selectedIndex = index; + } + + this.mLastMoveTime = Date.now(); + break; + } + }, + }; + } + this.richlistbox.addEventListener("mousedown", this.listEvents); + this.richlistbox.addEventListener("mouseup", this.listEvents); + this.richlistbox.addEventListener("mousemove", this.listEvents); + } + + get richlistbox() { + if (!this._richlistbox) { + this.initialize(); + } + return this._richlistbox; + } + + static get markup() { + return ` + + `; + } + + /** + * nsIAutoCompletePopup + */ + get input() { + return this.mInput; + } + + get overrideValue() { + return null; + } + + get popupOpen() { + return this.mPopupOpen; + } + + get maxRows() { + return (this.mInput && this.mInput.maxRows) || this.defaultMaxRows; + } + + set selectedIndex(val) { + if (val != this.richlistbox.selectedIndex) { + this._previousSelectedIndex = this.richlistbox.selectedIndex; + } + this.richlistbox.selectedIndex = val; + // Since ensureElementIsVisible may cause an expensive Layout flush, + // invoke it only if there may be a scrollbar, so if we could fetch + // more results than we can show at once. + // maxResults is the maximum number of fetched results, maxRows is the + // maximum number of rows we show at once, without a scrollbar. + if (this.mPopupOpen && this.maxResults > this.maxRows) { + // when clearing the selection (val == -1, so selectedItem will be + // null), we want to scroll back to the top. see bug #406194 + this.richlistbox.ensureElementIsVisible( + this.richlistbox.selectedItem || this.richlistbox.firstElementChild + ); + } + return val; + } + + get selectedIndex() { + return this.richlistbox.selectedIndex; + } + + get maxResults() { + // This is how many richlistitems will be kept around. + // Note, this getter may be overridden, or instances + // can have the nomaxresults attribute set to have no + // limit. + if (this.getAttribute("nomaxresults") == "true") { + return Infinity; + } + return 20; + } + + get matchCount() { + return Math.min(this.mInput.controller.matchCount, this.maxResults); + } + + get overflowPadding() { + return Number(this.getAttribute("overflowpadding")); + } + + set view(val) { + return val; + } + + get view() { + return this.mInput.controller; + } + + closePopup() { + if (this.mPopupOpen) { + this.hidePopup(); + this.removeAttribute("width"); + } + } + + getNextIndex(aReverse, aAmount, aIndex, aMaxRow) { + if (aMaxRow < 0) { + return -1; + } + + var newIdx = aIndex + (aReverse ? -1 : 1) * aAmount; + if ( + (aReverse && aIndex == -1) || + (newIdx > aMaxRow && aIndex != aMaxRow) + ) { + newIdx = aMaxRow; + } else if ((!aReverse && aIndex == -1) || (newIdx < 0 && aIndex != 0)) { + newIdx = 0; + } + + if ( + (newIdx < 0 && aIndex == 0) || + (newIdx > aMaxRow && aIndex == aMaxRow) + ) { + aIndex = -1; + } else { + aIndex = newIdx; + } + + return aIndex; + } + + onPopupClick(aEvent) { + this.input.controller.handleEnter(true, aEvent); + } + + onSearchBegin() { + this.mousedOverIndex = -1; + + if (typeof this._onSearchBegin == "function") { + this._onSearchBegin(); + } + } + + openAutocompletePopup(aInput, aElement) { + // until we have "baseBinding", (see bug #373652) this allows + // us to override openAutocompletePopup(), but still call + // the method on the base class + this._openAutocompletePopup(aInput, aElement); + } + + _openAutocompletePopup(aInput, aElement) { + if (!this._richlistbox) { + this.initialize(); + } + + if (!this.mPopupOpen) { + // It's possible that the panel is hidden initially + // to avoid impacting startup / new window performance + aInput.popup.hidden = false; + + this.mInput = aInput; + // clear any previous selection, see bugs 400671 and 488357 + this.selectedIndex = -1; + + var width = aElement.getBoundingClientRect().width; + this.setAttribute("width", width > 100 ? width : 100); + // invalidate() depends on the width attribute + this._invalidate(); + + this.openPopup(aElement, "after_start", 0, 0, false, false); + } + } + + invalidate(reason) { + // Don't bother doing work if we're not even showing + if (!this.mPopupOpen) { + return; + } + + this._invalidate(reason); + } + + _invalidate(reason) { + // collapsed if no matches + this.richlistbox.collapsed = this.matchCount == 0; + + // Update the richlistbox height. + if (this._adjustHeightRAFToken) { + cancelAnimationFrame(this._adjustHeightRAFToken); + this._adjustHeightRAFToken = null; + } + + if (this.mPopupOpen) { + delete this._adjustHeightOnPopupShown; + this._adjustHeightRAFToken = requestAnimationFrame(() => + this.adjustHeight() + ); + } else { + this._adjustHeightOnPopupShown = true; + } + + this._currentIndex = 0; + if (this._appendResultTimeout) { + clearTimeout(this._appendResultTimeout); + } + this._appendCurrentResult(reason); + } + + _collapseUnusedItems() { + let existingItemsCount = this.richlistbox.children.length; + for (let i = this.matchCount; i < existingItemsCount; ++i) { + let item = this.richlistbox.children[i]; + + item.collapsed = true; + if (typeof item._onCollapse == "function") { + item._onCollapse(); + } + } + } + + adjustHeight() { + // Figure out how many rows to show + let rows = this.richlistbox.children; + let numRows = Math.min(this.matchCount, this.maxRows, rows.length); + + // Default the height to 0 if we have no rows to show + let height = 0; + if (numRows) { + let firstRowRect = rows[0].getBoundingClientRect(); + if (this._rlbPadding == undefined) { + let style = window.getComputedStyle(this.richlistbox); + let paddingTop = parseInt(style.paddingTop) || 0; + let paddingBottom = parseInt(style.paddingBottom) || 0; + this._rlbPadding = paddingTop + paddingBottom; + } + + // The class `forceHandleUnderflow` is for the item might need to + // handle OverUnderflow or Overflow when the height of an item will + // be changed dynamically. + for (let i = 0; i < numRows; i++) { + if (rows[i].classList.contains("forceHandleUnderflow")) { + rows[i].handleOverUnderflow(); + } + } + + let lastRowRect = rows[numRows - 1].getBoundingClientRect(); + // Calculate the height to have the first row to last row shown + height = lastRowRect.bottom - firstRowRect.top + this._rlbPadding; + } + + this._collapseUnusedItems(); + + this.richlistbox.style.removeProperty("height"); + // We need to get the ceiling of the calculated value to ensure that the box fully contains + // all of its contents and doesn't cause a scrollbar since nsIBoxObject only expects a + // `long`. e.g. if `height` is 99.5 the richlistbox would render at height 99px with a + // scrollbar for the extra 0.5px. + this.richlistbox.height = Math.ceil(height); + } + + _appendCurrentResult(invalidateReason) { + var controller = this.mInput.controller; + var matchCount = this.matchCount; + var existingItemsCount = this.richlistbox.children.length; + + // Process maxRows per chunk to improve performance and user experience + for (let i = 0; i < this.maxRows; i++) { + if (this._currentIndex >= matchCount) { + break; + } + let item; + let itemExists = this._currentIndex < existingItemsCount; + + let originalValue, originalText, originalType; + let style = controller.getStyleAt(this._currentIndex); + let value = + style && style.includes("autofill") + ? controller.getFinalCompleteValueAt(this._currentIndex) + : controller.getValueAt(this._currentIndex); + let label = controller.getLabelAt(this._currentIndex); + let comment = controller.getCommentAt(this._currentIndex); + let image = controller.getImageAt(this._currentIndex); + // trim the leading/trailing whitespace + let trimmedSearchString = controller.searchString + .replace(/^\s+/, "") + .replace(/\s+$/, ""); + + let reusable = false; + if (itemExists) { + item = this.richlistbox.children[this._currentIndex]; + + // Url may be a modified version of value, see _adjustAcItem(). + originalValue = + item.getAttribute("url") || item.getAttribute("ac-value"); + originalText = item.getAttribute("ac-text"); + originalType = item.getAttribute("originaltype"); + + // The styles on the list which have different structure and overrided + // _adjustAcItem() are unreusable. + const UNREUSEABLE_STYLES = [ + "autofill-profile", + "autofill-footer", + "autofill-clear-button", + "autofill-insecureWarning", + "generatedPassword", + "importableLearnMore", + "importableLogins", + "insecureWarning", + "loginsFooter", + "loginWithOrigin", + ]; + // Reuse the item when its style is exactly equal to the previous style or + // neither of their style are in the UNREUSEABLE_STYLES. + reusable = + originalType === style || + !( + UNREUSEABLE_STYLES.includes(style) || + UNREUSEABLE_STYLES.includes(originalType) + ); + } + + // If no reusable item available, then create a new item. + if (!reusable) { + let options = null; + switch (style) { + case "autofill-profile": + options = { is: "autocomplete-profile-listitem" }; + break; + case "autofill-footer": + options = { is: "autocomplete-profile-listitem-footer" }; + break; + case "autofill-clear-button": + options = { is: "autocomplete-profile-listitem-clear-button" }; + break; + case "autofill-insecureWarning": + options = { is: "autocomplete-creditcard-insecure-field" }; + break; + case "importableLearnMore": + options = { + is: "autocomplete-importable-learn-more-richlistitem", + }; + break; + case "importableLogins": + options = { is: "autocomplete-importable-logins-richlistitem" }; + break; + case "generatedPassword": + options = { is: "autocomplete-generated-password-richlistitem" }; + break; + case "insecureWarning": + options = { is: "autocomplete-richlistitem-insecure-warning" }; + break; + case "loginsFooter": + options = { is: "autocomplete-richlistitem-logins-footer" }; + break; + case "loginWithOrigin": + options = { is: "autocomplete-login-richlistitem" }; + break; + default: + options = { is: "autocomplete-richlistitem" }; + } + item = document.createXULElement("richlistitem", options); + item.className = "autocomplete-richlistitem"; + } + + item.setAttribute("dir", this.style.direction); + item.setAttribute("ac-image", image); + item.setAttribute("ac-value", value); + item.setAttribute("ac-label", label); + item.setAttribute("ac-comment", comment); + item.setAttribute("ac-text", trimmedSearchString); + + // Completely reuse the existing richlistitem for invalidation + // due to new results, but only when: the item is the same, *OR* + // we are about to replace the currently moused-over item, to + // avoid surprising the user. + let iface = Ci.nsIAutoCompletePopup; + if ( + reusable && + originalText == trimmedSearchString && + invalidateReason == iface.INVALIDATE_REASON_NEW_RESULT && + (originalValue == value || + this.mousedOverIndex === this._currentIndex) + ) { + // try to re-use the existing item + item._reuseAcItem(); + this._currentIndex++; + continue; + } else { + if (typeof item._cleanup == "function") { + item._cleanup(); + } + item.setAttribute("originaltype", style); + } + + if (reusable) { + // Adjust only when the result's type is reusable for existing + // item's. Otherwise, we might insensibly call old _adjustAcItem() + // as new binding has not been attached yet. + // We don't need to worry about switching to new binding, since + // _adjustAcItem() will fired by its own constructor accordingly. + item._adjustAcItem(); + item.collapsed = false; + } else if (itemExists) { + let oldItem = this.richlistbox.children[this._currentIndex]; + this.richlistbox.replaceChild(item, oldItem); + } else { + this.richlistbox.appendChild(item); + } + + this._currentIndex++; + } + + if (typeof this.onResultsAdded == "function") { + // The items bindings may not be attached yet, so we must delay this + // before we can properly handle items properly without breaking + // the richlistbox. + Services.tm.dispatchToMainThread(() => this.onResultsAdded()); + } + + if (this._currentIndex < matchCount) { + // yield after each batch of items so that typing the url bar is + // responsive + this._appendResultTimeout = setTimeout( + () => this._appendCurrentResult(), + 0 + ); + } + } + + selectBy(aReverse, aPage) { + try { + var amount = aPage ? 5 : 1; + + // because we collapsed unused items, we can't use this.richlistbox.getRowCount(), we need to use the matchCount + this.selectedIndex = this.getNextIndex( + aReverse, + amount, + this.selectedIndex, + this.matchCount - 1 + ); + if (this.selectedIndex == -1) { + this.input._focus(); + } + } catch (ex) { + // do nothing - occasionally timer-related js errors happen here + // e.g. "this.selectedIndex has no properties", when you type fast and hit a + // navigation key before this popup has opened + } + } + + disconnectedCallback() { + if (this.listEvents) { + this.richlistbox.removeEventListener("mousedown", this.listEvents); + this.richlistbox.removeEventListener("mouseup", this.listEvents); + this.richlistbox.removeEventListener("mousemove", this.listEvents); + delete this.listEvents; + } + } + + setListeners() { + this.addEventListener("popupshowing", event => { + // If normalMaxRows wasn't already set by the input, then set it here + // so that we restore the correct number when the popup is hidden. + + // Null-check this.mInput; see bug 1017914 + if (this._normalMaxRows < 0 && this.mInput) { + this._normalMaxRows = this.mInput.maxRows; + } + + this.mPopupOpen = true; + }); + + this.addEventListener("popupshown", event => { + if (this._adjustHeightOnPopupShown) { + delete this._adjustHeightOnPopupShown; + this.adjustHeight(); + } + }); + + this.addEventListener("popuphiding", event => { + var isListActive = true; + if (this.selectedIndex == -1) { + isListActive = false; + } + this.input.controller.stopSearch(); + + this.mPopupOpen = false; + + // Reset the maxRows property to the cached "normal" value (if there's + // any), and reset normalMaxRows so that we can detect whether it was set + // by the input when the popupshowing handler runs. + + // Null-check this.mInput; see bug 1017914 + if (this.mInput && this._normalMaxRows > 0) { + this.mInput.maxRows = this._normalMaxRows; + } + this._normalMaxRows = -1; + // If the list was being navigated and then closed, make sure + // we fire accessible focus event back to textbox + + // Null-check this.mInput; see bug 1017914 + if (isListActive && this.mInput) { + this.mInput.mIgnoreFocus = true; + this.mInput._focus(); + this.mInput.mIgnoreFocus = false; + } + }); + } + }; + + MozPopupElement.implementCustomInterface( + MozElements.MozAutocompleteRichlistboxPopup, + [Ci.nsIAutoCompletePopup] + ); + + customElements.define( + "autocomplete-richlistbox-popup", + MozElements.MozAutocompleteRichlistboxPopup, + { + extends: "panel", + } + ); +} diff --git a/toolkit/content/widgets/autocomplete-richlistitem.js b/toolkit/content/widgets/autocomplete-richlistitem.js new file mode 100644 index 0000000000..8fc0c12363 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-richlistitem.js @@ -0,0 +1,884 @@ +/* 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 { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + MozElements.MozAutocompleteRichlistitem = class MozAutocompleteRichlistitem extends MozElements.MozRichlistitem { + constructor() { + super(); + + /** + * This overrides listitem's mousedown handler because we want to set the + * selected item even when the shift or accel keys are pressed. + */ + this.addEventListener("mousedown", event => { + // Call this.control only once since it's not a simple getter. + let control = this.control; + if (!control || control.disabled) { + return; + } + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + }); + + this.addEventListener("mouseover", event => { + // The point of implementing this handler is to allow drags to change + // the selected item. If the user mouses down on an item, it becomes + // selected. If they then drag the mouse to another item, select it. + // Handle all three primary mouse buttons: right, left, and wheel, since + // all three change the selection on mousedown. + let mouseDown = event.buttons & 0b111; + if (!mouseDown) { + return; + } + // Call this.control only once since it's not a simple getter. + let control = this.control; + if (!control || control.disabled) { + return; + } + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + }); + + this.addEventListener("overflow", () => this._onOverflow()); + this.addEventListener("underflow", () => this._onUnderflow()); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + + this._boundaryCutoff = null; + this._inOverflow = false; + + this._adjustAcItem(); + } + + static get inheritedAttributes() { + return { + ".ac-type-icon": "selected,current,type", + ".ac-site-icon": "src=image,selected,type", + ".ac-title": "selected", + ".ac-title-text": "selected", + ".ac-separator": "selected,type", + ".ac-url": "selected", + ".ac-url-text": "selected", + }; + } + + static get markup() { + return ` + + + + + + + + + + + + `; + } + + get _typeIcon() { + return this.querySelector(".ac-type-icon"); + } + + get _titleText() { + return this.querySelector(".ac-title-text"); + } + + get _separator() { + return this.querySelector(".ac-separator"); + } + + get _urlText() { + return this.querySelector(".ac-url-text"); + } + + get _stringBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://global/locale/autocomplete.properties" + ); + } + return this.__stringBundle; + } + + get boundaryCutoff() { + if (!this._boundaryCutoff) { + this._boundaryCutoff = Services.prefs.getIntPref( + "toolkit.autocomplete.richBoundaryCutoff" + ); + } + return this._boundaryCutoff; + } + + _cleanup() { + this.removeAttribute("url"); + this.removeAttribute("image"); + this.removeAttribute("title"); + this.removeAttribute("text"); + } + + _onOverflow() { + this._inOverflow = true; + this._handleOverflow(); + } + + _onUnderflow() { + this._inOverflow = false; + this._handleOverflow(); + } + + _getBoundaryIndices(aText, aSearchTokens) { + // Short circuit for empty search ([""] == "") + if (aSearchTokens == "") { + return [0, aText.length]; + } + + // Find which regions of text match the search terms + let regions = []; + for (let search of Array.prototype.slice.call(aSearchTokens)) { + let matchIndex = -1; + let searchLen = search.length; + + // Find all matches of the search terms, but stop early for perf + let lowerText = aText.substr(0, this.boundaryCutoff).toLowerCase(); + while ((matchIndex = lowerText.indexOf(search, matchIndex + 1)) >= 0) { + regions.push([matchIndex, matchIndex + searchLen]); + } + } + + // Sort the regions by start position then end position + regions = regions.sort((a, b) => { + let start = a[0] - b[0]; + return start == 0 ? a[1] - b[1] : start; + }); + + // Generate the boundary indices from each region + let start = 0; + let end = 0; + let boundaries = []; + let len = regions.length; + for (let i = 0; i < len; i++) { + // We have a new boundary if the start of the next is past the end + let region = regions[i]; + if (region[0] > end) { + // First index is the beginning of match + boundaries.push(start); + // Second index is the beginning of non-match + boundaries.push(end); + + // Track the new region now that we've stored the previous one + start = region[0]; + } + + // Push back the end index for the current or new region + end = Math.max(end, region[1]); + } + + // Add the last region + boundaries.push(start); + boundaries.push(end); + + // Put on the end boundary if necessary + if (end < aText.length) { + boundaries.push(aText.length); + } + + // Skip the first item because it's always 0 + return boundaries.slice(1); + } + + _getSearchTokens(aSearch) { + let search = aSearch.toLowerCase(); + return search.split(/\s+/); + } + + _setUpDescription(aDescriptionElement, aText) { + // Get rid of all previous text + if (!aDescriptionElement) { + return; + } + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + // Get the indices that separate match and non-match text + let search = this.getAttribute("text"); + let tokens = this._getSearchTokens(search); + let indices = this._getBoundaryIndices(aText, tokens); + + this._appendDescriptionSpans( + indices, + aText, + aDescriptionElement, + aDescriptionElement + ); + } + + _appendDescriptionSpans( + indices, + text, + spansParentElement, + descriptionElement + ) { + let next; + let start = 0; + let len = indices.length; + // Even indexed boundaries are matches, so skip the 0th if it's empty + for (let i = indices[0] == 0 ? 1 : 0; i < len; i++) { + next = indices[i]; + let spanText = text.substr(start, next - start); + start = next; + + if (i % 2 == 0) { + // Emphasize the text for even indices + let span = spansParentElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + this._setUpEmphasisSpan(span, descriptionElement); + span.textContent = spanText; + } else { + // Otherwise, it's plain text + spansParentElement.appendChild(document.createTextNode(spanText)); + } + } + } + + _setUpEmphasisSpan(aSpan, aDescriptionElement) { + aSpan.classList.add("ac-emphasize-text"); + switch (aDescriptionElement) { + case this._titleText: + aSpan.classList.add("ac-emphasize-text-title"); + break; + case this._urlText: + aSpan.classList.add("ac-emphasize-text-url"); + break; + } + } + + /** + * This will generate an array of emphasis pairs for use with + * _setUpEmphasisedSections(). Each pair is a tuple (array) that + * represents a block of text - containing the text of that block, and a + * boolean for whether that block should have an emphasis styling applied + * to it. + * + * These pairs are generated by parsing a localised string (aSourceString) + * with parameters, in the format that is used by + * nsIStringBundle.formatStringFromName(): + * + * "textA %1$S textB textC %2$S" + * + * Or: + * + * "textA %S" + * + * Where "%1$S", "%2$S", and "%S" are intended to be replaced by provided + * replacement strings. These are specified an array of tuples + * (aReplacements), each containing the replacement text and a boolean for + * whether that text should have an emphasis styling applied. This is used + * as a 1-based array - ie, "%1$S" is replaced by the item in the first + * index of aReplacements, "%2$S" by the second, etc. "%S" will always + * match the first index. + */ + _generateEmphasisPairs(aSourceString, aReplacements) { + let pairs = []; + + // Split on %S, %1$S, %2$S, etc. ie: + // "textA %S" + // becomes ["textA ", "%S"] + // "textA %1$S textB textC %2$S" + // becomes ["textA ", "%1$S", " textB textC ", "%2$S"] + let parts = aSourceString.split(/(%(?:[0-9]+\$)?S)/); + + for (let part of parts) { + // The above regex will actually give us an empty string at the + // end - we don't want that, as we don't want to later generate an + // empty text node for it. + if (part.length === 0) { + continue; + } + + // Determine if this token is a replacement token or a normal text + // token. If it is a replacement token, we want to extract the + // numerical number. However, we still want to match on "$S". + let match = part.match(/^%(?:([0-9]+)\$)?S$/); + + if (match) { + // "%S" doesn't have a numerical number in it, but will always + // be assumed to be 1. Furthermore, the input string specifies + // these with a 1-based index, but we want a 0-based index. + let index = (match[1] || 1) - 1; + + if (index >= 0 && index < aReplacements.length) { + pairs.push([...aReplacements[index]]); + } + } else { + pairs.push([part]); + } + } + + return pairs; + } + + /** + * _setUpEmphasisedSections() has the same use as _setUpDescription, + * except instead of taking a string and highlighting given tokens, it takes + * an array of pairs generated by _generateEmphasisPairs(). This allows + * control over emphasising based on specific blocks of text, rather than + * search for substrings. + */ + _setUpEmphasisedSections(aDescriptionElement, aTextPairs) { + // Get rid of all previous text + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + for (let [text, emphasise] of aTextPairs) { + if (emphasise) { + let span = aDescriptionElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + span.textContent = text; + switch (emphasise) { + case "match": + this._setUpEmphasisSpan(span, aDescriptionElement); + break; + } + } else { + aDescriptionElement.appendChild(document.createTextNode(text)); + } + } + } + + _unescapeUrl(url) { + return Services.textToSubURI.unEscapeURIForUI(url); + } + + _reuseAcItem() { + this.collapsed = false; + + // The popup may have changed size between now and the last + // time the item was shown, so always handle over/underflow. + let dwu = window.windowUtils; + let popupWidth = dwu.getBoundsWithoutFlushing(this.parentNode).width; + if (!this._previousPopupWidth || this._previousPopupWidth != popupWidth) { + this._previousPopupWidth = popupWidth; + this.handleOverUnderflow(); + } + } + + _adjustAcItem() { + let originalUrl = this.getAttribute("ac-value"); + let title = this.getAttribute("ac-comment"); + this.setAttribute("url", originalUrl); + this.setAttribute("image", this.getAttribute("ac-image")); + this.setAttribute("title", title); + this.setAttribute("text", this.getAttribute("ac-text")); + + let type = this.getAttribute("originaltype"); + let types = new Set(type.split(/\s+/)); + // Remove types that should ultimately not be in the `type` string. + types.delete("autofill"); + type = [...types][0] || ""; + this.setAttribute("type", type); + + let displayUrl = this._unescapeUrl(originalUrl); + + // Show the domain as the title if we don't have a title. + if (!title) { + try { + let uri = Services.io.newURI(originalUrl); + // Not all valid URLs have a domain. + if (uri.host) { + title = uri.host; + } + } catch (e) {} + if (!title) { + title = displayUrl; + } + } + + if (Array.isArray(title)) { + this._setUpEmphasisedSections(this._titleText, title); + } else { + this._setUpDescription(this._titleText, title); + } + this._setUpDescription(this._urlText, displayUrl); + + // Removing the max-width may be jarring when the item is visible, but + // we have no other choice to properly crop the text. + // Removing max-widths may cause overflow or underflow events, that + // will set the _inOverflow property. In case both the old and the new + // text are overflowing, the overflow event won't happen, and we must + // enforce an _handleOverflow() call to update the max-widths. + let wasInOverflow = this._inOverflow; + this._removeMaxWidths(); + if (wasInOverflow && this._inOverflow) { + this._handleOverflow(); + } + } + + _removeMaxWidths() { + if (this._hasMaxWidths) { + this._titleText.style.removeProperty("max-width"); + this._urlText.style.removeProperty("max-width"); + this._hasMaxWidths = false; + } + } + + /** + * This method truncates the displayed strings as necessary. + */ + _handleOverflow() { + let itemRect = this.parentNode.getBoundingClientRect(); + let titleRect = this._titleText.getBoundingClientRect(); + let separatorRect = this._separator.getBoundingClientRect(); + let urlRect = this._urlText.getBoundingClientRect(); + let separatorURLWidth = separatorRect.width + urlRect.width; + + // Total width for the title and URL is the width of the item + // minus the start of the title text minus a little optional extra padding. + // This extra padding amount is basically arbitrary but keeps the text + // from getting too close to the popup's edge. + let dir = this.getAttribute("dir"); + let titleStart = + dir == "rtl" + ? itemRect.right - titleRect.right + : titleRect.left - itemRect.left; + + let popup = this.parentNode.parentNode; + let itemWidth = + itemRect.width - + titleStart - + popup.overflowPadding - + (popup.margins ? popup.margins.end : 0); + + let titleWidth = titleRect.width; + if (titleWidth + separatorURLWidth > itemWidth) { + // The percentage of the item width allocated to the title. + let titlePct = 0.66; + + let titleAvailable = itemWidth - separatorURLWidth; + let titleMaxWidth = Math.max(titleAvailable, itemWidth * titlePct); + if (titleWidth > titleMaxWidth) { + this._titleText.style.maxWidth = titleMaxWidth + "px"; + } + let urlMaxWidth = Math.max( + itemWidth - titleWidth, + itemWidth * (1 - titlePct) + ); + urlMaxWidth -= separatorRect.width; + this._urlText.style.maxWidth = urlMaxWidth + "px"; + this._hasMaxWidths = true; + } + } + + handleOverUnderflow() { + this._removeMaxWidths(); + this._handleOverflow(); + } + }; + + MozXULElement.implementCustomInterface( + MozElements.MozAutocompleteRichlistitem, + [Ci.nsIDOMXULSelectControlItemElement] + ); + + class MozAutocompleteRichlistitemInsecureWarning extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + let baseURL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" + ); + window.openTrustedLinkIn(baseURL + "insecure-password", "tab", { + relatedToCurrent: true, + }); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + super.connectedCallback(); + + // Unlike other autocomplete items, the height of the insecure warning + // increases by wrapping. So "forceHandleUnderflow" is for container to + // recalculate an item's height and width. + this.classList.add("forceHandleUnderflow"); + } + + static get inheritedAttributes() { + return { + ".ac-type-icon": "selected,current,type", + ".ac-site-icon": "src=image,selected,type", + ".ac-title-text": "selected", + ".ac-separator": "selected,type", + ".ac-url": "selected", + ".ac-url-text": "selected", + }; + } + + static get markup() { + return ` + + + + + + + + + + + + + + + + `; + } + + get _learnMoreString() { + if (!this.__learnMoreString) { + this.__learnMoreString = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .GetStringFromName("insecureFieldWarningLearnMore"); + } + return this.__learnMoreString; + } + + /** + * Override _getSearchTokens to have the Learn More text emphasized + */ + _getSearchTokens(aSearch) { + return [this._learnMoreString.toLowerCase()]; + } + } + + class MozAutocompleteRichlistitemLoginsFooter extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + + function handleEvent(event) { + if (event.button != 0) { + return; + } + + const { LoginHelper } = ChromeUtils.import( + "resource://gre/modules/LoginHelper.jsm" + ); + + LoginHelper.openPasswordManager(this.ownerGlobal, { + entryPoint: "autocomplete", + }); + } + + this.addEventListener("click", handleEvent); + } + } + + class MozAutocompleteImportableLearnMoreRichlistitem extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + + this.addEventListener("click", event => { + window.openTrustedLinkIn( + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "password-import", + "tab", + { relatedToCurrent: true } + ); + }); + } + + static get markup() { + return ` + + + + + + + + + + + + + + + + `; + } + + // Override to avoid clearing out fluent description. + _setUpDescription() {} + } + + class MozAutocompleteTwoLineRichlistitem extends MozElements.MozRichlistitem { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + this._adjustAcItem(); + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // getCommentAt: + ".line2-label": "text=ac-label", + }; + } + + static get markup() { + return ` +
+ +
+
+
+
+
+ `; + } + + _adjustAcItem() { + const popup = this.parentNode.parentNode; + const minWidth = getComputedStyle(popup).minWidth.replace("px", ""); + // Make item fit in popup as XUL box could not constrain + // item's width + // popup.width is equal to the input field's width from the content process + this.firstElementChild.style.width = + Math.max(minWidth, popup.width) + "px"; + } + + _onOverflow() {} + + _onUnderflow() {} + + handleOverUnderflow() {} + } + + class MozAutocompleteLoginRichlistitem extends MozAutocompleteTwoLineRichlistitem { + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // Don't inherit ac-label with getCommentAt since the label is JSON. + }; + } + + _adjustAcItem() { + super._adjustAcItem(); + + let details = JSON.parse(this.getAttribute("ac-label")); + this.querySelector(".line2-label").textContent = details.comment; + } + } + + class MozAutocompleteGeneratedPasswordRichlistitem extends MozAutocompleteTwoLineRichlistitem { + constructor() { + super(); + + // Line 2 and line 3 both display text with a different line-height than + // line 1 but we want the line-height to be the same so we wrap the text + // in and only adjust the line-height via font CSS properties on them. + this.generatedPasswordText = document.createElement("span"); + + this.line3Text = document.createElement("span"); + this.line3 = document.createElement("div"); + this.line3.className = "label-row generated-password-autosave"; + this.line3.append(this.line3Text); + } + + get _autoSaveString() { + if (!this.__autoSaveString) { + let brandShorterName = Services.strings + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShorterName"); + this.__autoSaveString = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .formatStringFromName("generatedPasswordWillBeSaved", [ + brandShorterName, + ]); + } + return this.__autoSaveString; + } + + _adjustAcItem() { + let { generatedPassword, willAutoSaveGeneratedPassword } = JSON.parse( + this.getAttribute("ac-label") + ); + let line2Label = this.querySelector(".line2-label"); + line2Label.textContent = ""; + this.generatedPasswordText.textContent = generatedPassword; + line2Label.append(this.generatedPasswordText); + + if (willAutoSaveGeneratedPassword) { + this.line3Text.textContent = this._autoSaveString; + this.querySelector(".labels-wrapper").append(this.line3); + } else { + this.line3.remove(); + } + + super._adjustAcItem(); + } + } + + class MozAutocompleteImportableLoginsRichlistitem extends MozAutocompleteTwoLineRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + // Let the login manager parent handle this importable browser click. + gBrowser.selectedBrowser.browsingContext.currentWindowGlobal + .getActor("LoginManager") + .receiveMessage({ + name: "PasswordManager:HandleImportable", + data: { + browserId: this.getAttribute("ac-value"), + }, + }); + }); + } + + static get markup() { + return ` +
+ +
+
+
+
+
+ `; + } + + _adjustAcItem() { + document.l10n.setAttributes( + this.querySelector(".labels-wrapper"), + `autocomplete-import-logins-${this.getAttribute("ac-value")}`, + { + host: this.getAttribute("ac-label").replace(/^www\./, ""), + } + ); + super._adjustAcItem(); + } + } + + customElements.define( + "autocomplete-richlistitem", + MozElements.MozAutocompleteRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-richlistitem-insecure-warning", + MozAutocompleteRichlistitemInsecureWarning, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-richlistitem-logins-footer", + MozAutocompleteRichlistitemLoginsFooter, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-two-line-richlistitem", + MozAutocompleteTwoLineRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-login-richlistitem", + MozAutocompleteLoginRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-generated-password-richlistitem", + MozAutocompleteGeneratedPasswordRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-importable-learn-more-richlistitem", + MozAutocompleteImportableLearnMoreRichlistitem, + { + extends: "richlistitem", + } + ); + + customElements.define( + "autocomplete-importable-logins-richlistitem", + MozAutocompleteImportableLoginsRichlistitem, + { + extends: "richlistitem", + } + ); +} diff --git a/toolkit/content/widgets/browser-custom-element.js b/toolkit/content/widgets/browser-custom-element.js new file mode 100644 index 0000000000..e7a2c7e642 --- /dev/null +++ b/toolkit/content/widgets/browser-custom-element.js @@ -0,0 +1,1898 @@ +/* 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 { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + const { BrowserUtils } = ChromeUtils.import( + "resource://gre/modules/BrowserUtils.jsm" + ); + + const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" + ); + + let LazyModules = {}; + + ChromeUtils.defineModuleGetter( + LazyModules, + "E10SUtils", + "resource://gre/modules/E10SUtils.jsm" + ); + + ChromeUtils.defineModuleGetter( + LazyModules, + "RemoteWebNavigation", + "resource://gre/modules/RemoteWebNavigation.jsm" + ); + + ChromeUtils.defineModuleGetter( + LazyModules, + "PopupBlocker", + "resource://gre/actors/PopupBlockingParent.jsm" + ); + + let lazyPrefs = {}; + XPCOMUtils.defineLazyPreferenceGetter( + lazyPrefs, + "unloadTimeoutMs", + "dom.beforeunload_timeout_ms" + ); + + const elementsToDestroyOnUnload = new Set(); + + window.addEventListener( + "unload", + () => { + for (let element of elementsToDestroyOnUnload.values()) { + element.destroy(); + } + elementsToDestroyOnUnload.clear(); + }, + { mozSystemGroup: true, once: true } + ); + + class MozBrowser extends MozElements.MozElementMixin(XULFrameElement) { + static get observedAttributes() { + return ["remote"]; + } + + constructor() { + super(); + + this.onPageHide = this.onPageHide.bind(this); + + this.isNavigating = false; + + this._documentURI = null; + this._characterSet = null; + this._documentContentType = null; + + this._inPermitUnload = new WeakSet(); + + /** + * These are managed by the tabbrowser: + */ + this.droppedLinkHandler = null; + this.mIconURL = null; + this.lastURI = null; + + XPCOMUtils.defineLazyGetter(this, "popupBlocker", () => { + return new LazyModules.PopupBlocker(this); + }); + + this.addEventListener( + "dragover", + event => { + if (!this.droppedLinkHandler || event.defaultPrevented) { + return; + } + + // For drags that appear to be internal text (for example, tab drags), + // set the dropEffect to 'none'. This prevents the drop even if some + // other listener cancelled the event. + var types = event.dataTransfer.types; + if ( + types.includes("text/x-moz-text-internal") && + !types.includes("text/plain") + ) { + event.dataTransfer.dropEffect = "none"; + event.stopPropagation(); + event.preventDefault(); + } + + // No need to handle "dragover" in e10s, since nsDocShellTreeOwner.cpp in the child process + // handles that case using "@mozilla.org/content/dropped-link-handler;1" service. + if (this.isRemoteBrowser) { + return; + } + + let linkHandler = Services.droppedLinkHandler; + if (linkHandler.canDropLink(event, false)) { + event.preventDefault(); + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener( + "drop", + event => { + // No need to handle "drop" in e10s, since nsDocShellTreeOwner.cpp in the child process + // handles that case using "@mozilla.org/content/dropped-link-handler;1" service. + if ( + !this.droppedLinkHandler || + event.defaultPrevented || + this.isRemoteBrowser + ) { + return; + } + + let linkHandler = Services.droppedLinkHandler; + try { + // Pass true to prevent the dropping of javascript:/data: URIs + var links = linkHandler.dropLinks(event, true); + } catch (ex) { + return; + } + + if (links.length) { + let triggeringPrincipal = linkHandler.getTriggeringPrincipal(event); + this.droppedLinkHandler(event, links, triggeringPrincipal); + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener("dragstart", event => { + // If we're a remote browser dealing with a dragstart, stop it + // from propagating up, since our content process should be dealing + // with the mouse movement. + if (this.isRemoteBrowser) { + event.stopPropagation(); + } + }); + } + + resetFields() { + if (this.observer) { + try { + Services.obs.removeObserver( + this.observer, + "browser:purge-session-history" + ); + } catch (ex) { + // It's not clear why this sometimes throws an exception. + } + this.observer = null; + } + + let browser = this; + this.observer = { + observe(aSubject, aTopic, aState) { + if (aTopic == "browser:purge-session-history") { + browser.purgeSessionHistory(); + } else if (aTopic == "apz:cancel-autoscroll") { + if (aState == browser._autoScrollScrollId) { + // Set this._autoScrollScrollId to null, so in stopScroll() we + // don't call stopApzAutoscroll() (since it's APZ that + // initiated the stopping). + browser._autoScrollScrollId = null; + browser._autoScrollPresShellId = null; + + browser._autoScrollPopup.hidePopup(); + } + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }; + + this._documentURI = null; + + this._documentContentType = null; + + this._loadContext = null; + + this._webBrowserFind = null; + + this._finder = null; + + this._remoteFinder = null; + + this._fastFind = null; + + this._lastSearchString = null; + + this._characterSet = ""; + + this._mayEnableCharacterEncodingMenu = null; + + this._charsetAutodetected = false; + + this._contentPrincipal = null; + + this._contentPartitionedPrincipal = null; + + this._csp = null; + + this._referrerInfo = null; + + this._contentRequestContextID = null; + + this._rdmFullZoom = 1.0; + + this._isSyntheticDocument = false; + + this.mPrefs = Services.prefs; + + this._mStrBundle = null; + + this._audioMuted = false; + + this._hasAnyPlayingMediaBeenBlocked = false; + + this._unselectedTabHoverMessageListenerCount = 0; + + this.urlbarChangeTracker = { + _startedLoadSinceLastUserTyping: false, + + startedLoad() { + this._startedLoadSinceLastUserTyping = true; + }, + finishedLoad() { + this._startedLoadSinceLastUserTyping = false; + }, + userTyped() { + this._startedLoadSinceLastUserTyping = false; + }, + }; + + this._userTypedValue = null; + + this._AUTOSCROLL_SNAP = 10; + + this._autoScrollBrowsingContext = null; + + this._startX = null; + + this._startY = null; + + this._autoScrollPopup = null; + + /** + * These IDs identify the scroll frame being autoscrolled. + */ + this._autoScrollScrollId = null; + + this._autoScrollPresShellId = null; + } + + connectedCallback() { + // We typically use this to avoid running JS that triggers a layout during parse + // (see comment on the delayConnectedCallback implementation). In this case, we + // are using it to avoid a leak - see https://bugzilla.mozilla.org/show_bug.cgi?id=1441935#c20. + if (this.delayConnectedCallback()) { + return; + } + + this.construct(); + } + + disconnectedCallback() { + this.destroy(); + } + + get autoscrollEnabled() { + if (this.getAttribute("autoscroll") == "false") { + return false; + } + + return this.mPrefs.getBoolPref("general.autoScroll", true); + } + + get canGoBack() { + return this.webNavigation.canGoBack; + } + + get canGoForward() { + return this.webNavigation.canGoForward; + } + + get currentURI() { + if (this.webNavigation) { + return this.webNavigation.currentURI; + } + return null; + } + + get documentURI() { + return this.isRemoteBrowser + ? this._documentURI + : this.contentDocument.documentURIObject; + } + + get documentContentType() { + if (this.isRemoteBrowser) { + return this._documentContentType; + } + return this.contentDocument ? this.contentDocument.contentType : null; + } + + set documentContentType(aContentType) { + if (aContentType != null) { + if (this.isRemoteBrowser) { + this._documentContentType = aContentType; + } else { + this.contentDocument.documentContentType = aContentType; + } + } + } + + get loadContext() { + if (this._loadContext) { + return this._loadContext; + } + + let { frameLoader } = this; + if (!frameLoader) { + return null; + } + this._loadContext = frameLoader.loadContext; + return this._loadContext; + } + + get autoCompletePopup() { + return document.getElementById(this.getAttribute("autocompletepopup")); + } + + get dateTimePicker() { + return document.getElementById(this.getAttribute("datetimepicker")); + } + + /** + * Provides a node to hang popups (such as the datetimepicker) from. + * If this isn't the descendant of a , null is returned + * instead and popup code must handle this case. + */ + get popupAnchor() { + let stack = this.closest("stack"); + if (!stack) { + return null; + } + + let popupAnchor = stack.querySelector(".popup-anchor"); + if (popupAnchor) { + return popupAnchor; + } + + // Create an anchor for the popup + popupAnchor = document.createElement("div"); + popupAnchor.className = "popup-anchor"; + popupAnchor.hidden = true; + stack.appendChild(popupAnchor); + return popupAnchor; + } + + set suspendMediaWhenInactive(val) { + this.browsingContext.suspendMediaWhenInactive = val; + } + + get suspendMediaWhenInactive() { + return !!this.browsingContext?.suspendMediaWhenInactive; + } + + set docShellIsActive(val) { + this.browsingContext.isActive = val; + if (this.isRemoteBrowser) { + let remoteTab = this.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.renderLayers = val; + } + } + } + + get docShellIsActive() { + return !!this.browsingContext?.isActive; + } + + set renderLayers(val) { + if (this.isRemoteBrowser) { + let remoteTab = this.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.renderLayers = val; + } + } else { + this.docShellIsActive = val; + } + } + + get renderLayers() { + if (this.isRemoteBrowser) { + return !!this.frameLoader?.remoteTab?.renderLayers; + } + return this.docShellIsActive; + } + + get hasLayers() { + if (this.isRemoteBrowser) { + return !!this.frameLoader?.remoteTab?.hasLayers; + } + return this.docShellIsActive; + } + + get isRemoteBrowser() { + return this.getAttribute("remote") == "true"; + } + + get remoteType() { + return this.browsingContext?.currentRemoteType; + } + + get isCrashed() { + if (!this.isRemoteBrowser || !this.frameLoader) { + return false; + } + + return !this.frameLoader.remoteTab; + } + + get messageManager() { + // Bug 1524084 - Trying to get at the message manager while in the crashed state will + // create a new message manager that won't shut down properly when the crashed browser + // is removed from the DOM. We work around that right now by returning null if we're + // in the crashed state. + if (this.frameLoader && !this.isCrashed) { + return this.frameLoader.messageManager; + } + return null; + } + + get webBrowserFind() { + if (!this._webBrowserFind) { + this._webBrowserFind = this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + return this._webBrowserFind; + } + + get finder() { + if (this.isRemoteBrowser) { + if (!this._remoteFinder) { + let jsm = "resource://gre/modules/FinderParent.jsm"; + let { FinderParent } = ChromeUtils.import(jsm, {}); + this._remoteFinder = new FinderParent(this); + } + return this._remoteFinder; + } + if (!this._finder) { + if (!this.docShell) { + return null; + } + + let Finder = ChromeUtils.import("resource://gre/modules/Finder.jsm", {}) + .Finder; + this._finder = new Finder(this.docShell); + } + return this._finder; + } + + get fastFind() { + if (!this._fastFind) { + if (!("@mozilla.org/typeaheadfind;1" in Cc)) { + return null; + } + + var tabBrowser = this.getTabBrowser(); + if (tabBrowser && "fastFind" in tabBrowser) { + return (this._fastFind = tabBrowser.fastFind); + } + + if (!this.docShell) { + return null; + } + + this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance( + Ci.nsITypeAheadFind + ); + this._fastFind.init(this.docShell); + } + return this._fastFind; + } + + get outerWindowID() { + return this.browsingContext?.currentWindowGlobal?.outerWindowId; + } + + get innerWindowID() { + return this.browsingContext?.currentWindowGlobal?.innerWindowId || null; + } + + get browsingContext() { + if (this.frameLoader) { + return this.frameLoader.browsingContext; + } + return null; + } + /** + * Note that this overrides webNavigation on XULFrameElement, and duplicates the return value for the non-remote case + */ + get webNavigation() { + return this.isRemoteBrowser + ? this._remoteWebNavigation + : this.docShell && this.docShell.QueryInterface(Ci.nsIWebNavigation); + } + + get webProgress() { + return this.browsingContext?.webProgress; + } + + get sessionHistory() { + return this.webNavigation.sessionHistory; + } + + get contentTitle() { + return this.isRemoteBrowser + ? this.browsingContext?.currentWindowGlobal?.documentTitle + : this.contentDocument.title; + } + + set characterSet(val) { + if (this.isRemoteBrowser) { + this.sendMessageToActor( + "UpdateCharacterSet", + { value: val }, + "BrowserTab" + ); + this._characterSet = val; + } else { + this.docShell.charset = val; + this.docShell.gatherCharsetMenuTelemetry(); + } + } + + get characterSet() { + return this.isRemoteBrowser ? this._characterSet : this.docShell.charset; + } + + get mayEnableCharacterEncodingMenu() { + return this.isRemoteBrowser + ? this._mayEnableCharacterEncodingMenu + : this.docShell.mayEnableCharacterEncodingMenu; + } + + set mayEnableCharacterEncodingMenu(aMayEnable) { + if (this.isRemoteBrowser) { + this._mayEnableCharacterEncodingMenu = aMayEnable; + } + } + + get charsetAutodetected() { + return this.isRemoteBrowser + ? this._charsetAutodetected + : this.docShell.charsetAutodetected; + } + + set charsetAutodetected(aAutodetected) { + if (this.isRemoteBrowser) { + this._charsetAutodetected = aAutodetected; + } + } + + get contentPrincipal() { + return this.isRemoteBrowser + ? this._contentPrincipal + : this.contentDocument.nodePrincipal; + } + + get contentPartitionedPrincipal() { + return this.isRemoteBrowser + ? this._contentPartitionedPrincipal + : this.contentDocument.partitionedPrincipal; + } + + get cookieJarSettings() { + return this.isRemoteBrowser + ? this.browsingContext?.currentWindowGlobal?.cookieJarSettings + : this.contentDocument.cookieJarSettings; + } + + get csp() { + return this.isRemoteBrowser ? this._csp : this.contentDocument.csp; + } + + get contentRequestContextID() { + if (this.isRemoteBrowser) { + return this._contentRequestContextID; + } + try { + return this.contentDocument.documentLoadGroup.requestContextID; + } catch (e) { + return null; + } + } + + get referrerInfo() { + return this.isRemoteBrowser + ? this._referrerInfo + : this.contentDocument.referrerInfo; + } + + set fullZoom(val) { + if (val.toFixed(2) == this.fullZoom.toFixed(2)) { + return; + } + if (this.browsingContext.inRDMPane) { + this._rdmFullZoom = val; + let event = document.createEvent("Events"); + event.initEvent("FullZoomChange", true, false); + this.dispatchEvent(event); + } else { + this.browsingContext.fullZoom = val; + } + } + + get fullZoom() { + if (this.browsingContext.inRDMPane) { + return this._rdmFullZoom; + } + return this.browsingContext.fullZoom; + } + + set textZoom(val) { + if (val.toFixed(2) == this.textZoom.toFixed(2)) { + return; + } + this.browsingContext.textZoom = val; + } + + get textZoom() { + return this.browsingContext.textZoom; + } + + enterResponsiveMode() { + if (this.browsingContext.inRDMPane) { + return; + } + this.browsingContext.inRDMPane = true; + this._rdmFullZoom = this.browsingContext.fullZoom; + this.browsingContext.fullZoom = 1.0; + } + + leaveResponsiveMode() { + if (!this.browsingContext.inRDMPane) { + return; + } + this.browsingContext.inRDMPane = false; + this.browsingContext.fullZoom = this._rdmFullZoom; + } + + get isSyntheticDocument() { + if (this.isRemoteBrowser) { + return this._isSyntheticDocument; + } + return this.contentDocument.mozSyntheticDocument; + } + + get hasContentOpener() { + return !!this.browsingContext.opener; + } + + get mStrBundle() { + if (!this._mStrBundle) { + // need to create string bundle manually instead of using + // see bug 63370 for details + this._mStrBundle = Services.strings.createBundle( + "chrome://global/locale/browser.properties" + ); + } + return this._mStrBundle; + } + + get audioMuted() { + return this._audioMuted; + } + + get shouldHandleUnselectedTabHover() { + return this._unselectedTabHoverMessageListenerCount > 0; + } + + set shouldHandleUnselectedTabHover(value) { + this._unselectedTabHoverMessageListenerCount += value ? 1 : -1; + } + + get securityUI() { + return this.browsingContext.secureBrowserUI; + } + + set userTypedValue(val) { + this.urlbarChangeTracker.userTyped(); + this._userTypedValue = val; + } + + get userTypedValue() { + return this._userTypedValue; + } + + get dontPromptAndDontUnload() { + return 1; + } + + get dontPromptAndUnload() { + return 2; + } + + _wrapURIChangeCall(fn) { + if (!this.isRemoteBrowser) { + this.isNavigating = true; + try { + fn(); + } finally { + this.isNavigating = false; + } + } else { + fn(); + } + } + + goBack( + requireUserInteraction = BrowserUtils.navigationRequireUserInteraction + ) { + var webNavigation = this.webNavigation; + if (webNavigation.canGoBack) { + this._wrapURIChangeCall(() => + webNavigation.goBack(requireUserInteraction) + ); + } + } + + goForward( + requireUserInteraction = BrowserUtils.navigationRequireUserInteraction + ) { + var webNavigation = this.webNavigation; + if (webNavigation.canGoForward) { + this._wrapURIChangeCall(() => + webNavigation.goForward(requireUserInteraction) + ); + } + } + + reload() { + const nsIWebNavigation = Ci.nsIWebNavigation; + const flags = nsIWebNavigation.LOAD_FLAGS_NONE; + this.reloadWithFlags(flags); + } + + reloadWithFlags(aFlags) { + this.webNavigation.reload(aFlags); + } + + stop() { + const nsIWebNavigation = Ci.nsIWebNavigation; + const flags = nsIWebNavigation.STOP_ALL; + this.webNavigation.stop(flags); + } + + /** + * throws exception for unknown schemes + */ + loadURI(aURI, aParams = {}) { + if (!aURI) { + aURI = "about:blank"; + } + let { + referrerInfo, + triggeringPrincipal, + postData, + headers, + csp, + } = aParams; + let loadFlags = + aParams.loadFlags || + aParams.flags || + Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let loadURIOptions = { + triggeringPrincipal, + csp, + referrerInfo, + loadFlags, + postData, + headers, + }; + this._wrapURIChangeCall(() => + this.webNavigation.loadURI(aURI, loadURIOptions) + ); + } + + gotoIndex(aIndex) { + this._wrapURIChangeCall(() => this.webNavigation.gotoIndex(aIndex)); + } + + preserveLayers(preserve) { + if (!this.isRemoteBrowser) { + return; + } + let { frameLoader } = this; + if (frameLoader.remoteTab) { + frameLoader.remoteTab.preserveLayers(preserve); + } + } + + deprioritize() { + if (!this.isRemoteBrowser) { + return; + } + let { frameLoader } = this; + if (frameLoader.remoteTab) { + frameLoader.remoteTab.deprioritize(); + } + } + + getTabBrowser() { + if ( + this.ownerGlobal.gBrowser && + this.ownerGlobal.gBrowser.getTabForBrowser && + this.ownerGlobal.gBrowser.getTabForBrowser(this) + ) { + return this.ownerGlobal.gBrowser; + } + return null; + } + + addProgressListener(aListener, aNotifyMask) { + if (!aNotifyMask) { + aNotifyMask = Ci.nsIWebProgress.NOTIFY_ALL; + } + + this.webProgress.addProgressListener(aListener, aNotifyMask); + } + + removeProgressListener(aListener) { + this.webProgress.removeProgressListener(aListener); + } + + onPageHide(aEvent) { + if (!this.docShell || !this.fastFind) { + return; + } + var tabBrowser = this.getTabBrowser(); + if ( + !tabBrowser || + !("fastFind" in tabBrowser) || + tabBrowser.selectedBrowser == this + ) { + this.fastFind.setDocShell(this.docShell); + } + } + + audioPlaybackStarted() { + if (this._audioMuted) { + return; + } + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackStarted", true, false); + this.dispatchEvent(event); + } + + audioPlaybackStopped() { + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackStopped", true, false); + this.dispatchEvent(event); + } + + /** + * When the pref "media.block-autoplay-until-in-foreground" is on, + * Gecko delays starting playback of media resources in tabs until the + * tab has been in the foreground or resumed by tab's play tab icon. + * - When Gecko delays starting playback of a media resource in a window, + * it sends a message to call activeMediaBlockStarted(). This causes the + * tab audio indicator to show. + * - When a tab is foregrounded, Gecko starts playing all delayed media + * resources in that tab, and sends a message to call + * activeMediaBlockStopped(). This causes the tab audio indicator to hide. + */ + activeMediaBlockStarted() { + this._hasAnyPlayingMediaBeenBlocked = true; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStarted", true, false); + this.dispatchEvent(event); + } + + activeMediaBlockStopped() { + if (!this._hasAnyPlayingMediaBeenBlocked) { + return; + } + this._hasAnyPlayingMediaBeenBlocked = false; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStopped", true, false); + this.dispatchEvent(event); + } + + mute(transientState) { + if (!transientState) { + this._audioMuted = true; + } + let context = this.frameLoader.browsingContext; + context.notifyMediaMutedChanged(true); + } + + unmute() { + this._audioMuted = false; + let context = this.frameLoader.browsingContext; + context.notifyMediaMutedChanged(false); + } + + resumeMedia() { + this.frameLoader.browsingContext.notifyStartDelayedAutoplayMedia(); + if (this._hasAnyPlayingMediaBeenBlocked) { + this._hasAnyPlayingMediaBeenBlocked = false; + let event = document.createEvent("Events"); + event.initEvent("DOMAudioPlaybackBlockStopped", true, false); + this.dispatchEvent(event); + } + } + + unselectedTabHover(hovered) { + if (!this.shouldHandleUnselectedTabHover) { + return; + } + this.sendMessageToActor( + "Browser:UnselectedTabHover", + { + hovered, + }, + "UnselectedTabHover", + "roots" + ); + } + + didStartLoadSinceLastUserTyping() { + return ( + !this.isNavigating && + this.urlbarChangeTracker._startedLoadSinceLastUserTyping + ); + } + + construct() { + elementsToDestroyOnUnload.add(this); + this.resetFields(); + this.mInitialized = true; + if (this.isRemoteBrowser) { + /* + * Don't try to send messages from this function. The message manager for + * the element may not be initialized yet. + */ + + this._remoteWebNavigation = new LazyModules.RemoteWebNavigation(this); + + // Initialize contentPrincipal to the about:blank principal for this loadcontext + let aboutBlank = Services.io.newURI("about:blank"); + let ssm = Services.scriptSecurityManager; + this._contentPrincipal = ssm.getLoadContextContentPrincipal( + aboutBlank, + this.loadContext + ); + // CSP for about:blank is null; if we ever change _contentPrincipal above, + // we should re-evaluate the CSP here. + this._csp = null; + + this.messageManager.loadFrameScript( + "chrome://global/content/browser-child.js", + true + ); + + if (!this.hasAttribute("disablehistory")) { + Services.obs.addObserver( + this.observer, + "browser:purge-session-history", + true + ); + } + } + + try { + // |webNavigation.sessionHistory| will have been set by the frame + // loader when creating the docShell as long as this xul:browser + // doesn't have the 'disablehistory' attribute set. + if (this.docShell && this.webNavigation.sessionHistory) { + Services.obs.addObserver( + this.observer, + "browser:purge-session-history", + true + ); + + // enable global history if we weren't told otherwise + if ( + !this.hasAttribute("disableglobalhistory") && + !this.isRemoteBrowser + ) { + try { + this.docShell.browsingContext.useGlobalHistory = true; + } catch (ex) { + // This can occur if the Places database is locked + Cu.reportError("Error enabling browser global history: " + ex); + } + } + } + } catch (e) { + Cu.reportError(e); + } + try { + // Ensures the securityUI is initialized. + var securityUI = this.securityUI; // eslint-disable-line no-unused-vars + } catch (e) {} + + if (!this.isRemoteBrowser) { + this._remoteWebNavigation = null; + this.addEventListener("pagehide", this.onPageHide, true); + } + } + + /** + * This is necessary because the destructor doesn't always get called when + * we are removed from a tabbrowser. This will be explicitly called by tabbrowser. + */ + destroy() { + elementsToDestroyOnUnload.delete(this); + + // Make sure that any open select is closed. + if (this.hasAttribute("selectmenulist")) { + let menulist = document.getElementById( + this.getAttribute("selectmenulist") + ); + if (menulist && menulist.open) { + let resourcePath = "resource://gre/actors/SelectParent.jsm"; + let { SelectParentHelper } = ChromeUtils.import(resourcePath); + SelectParentHelper.hide(menulist, this); + } + } + + this.resetFields(); + + if (!this.mInitialized) { + return; + } + + this.mInitialized = false; + this.lastURI = null; + + if (!this.isRemoteBrowser) { + this.removeEventListener("pagehide", this.onPageHide, true); + } + } + + updateForStateChange(aCharset, aDocumentURI, aContentType) { + if (this.isRemoteBrowser && this.messageManager) { + if (aCharset != null) { + this._characterSet = aCharset; + } + + if (aDocumentURI != null) { + this._documentURI = aDocumentURI; + } + + if (aContentType != null) { + this._documentContentType = aContentType; + } + } + } + + updateWebNavigationForLocationChange(aCanGoBack, aCanGoForward) { + if ( + this.isRemoteBrowser && + this.messageManager && + !Services.appinfo.sessionHistoryInParent + ) { + this._remoteWebNavigation._canGoBack = aCanGoBack; + this._remoteWebNavigation._canGoForward = aCanGoForward; + } + } + + updateForLocationChange( + aLocation, + aCharset, + aMayEnableCharacterEncodingMenu, + aCharsetAutodetected, + aDocumentURI, + aTitle, + aContentPrincipal, + aContentPartitionedPrincipal, + aCSP, + aReferrerInfo, + aIsSynthetic, + aHaveRequestContextID, + aRequestContextID, + aContentType + ) { + if (this.isRemoteBrowser && this.messageManager) { + if (aCharset != null) { + this._characterSet = aCharset; + this._mayEnableCharacterEncodingMenu = aMayEnableCharacterEncodingMenu; + this._charsetAutodetected = aCharsetAutodetected; + } + + if (aContentType != null) { + this._documentContentType = aContentType; + } + + this._remoteWebNavigation._currentURI = aLocation; + this._documentURI = aDocumentURI; + this._contentPrincipal = aContentPrincipal; + this._contentPartitionedPrincipal = aContentPartitionedPrincipal; + this._csp = aCSP; + this._referrerInfo = aReferrerInfo; + this._isSyntheticDocument = aIsSynthetic; + this._contentRequestContextID = aHaveRequestContextID + ? aRequestContextID + : null; + } + } + + purgeSessionHistory() { + if (this.isRemoteBrowser && !Services.appinfo.sessionHistoryInParent) { + this._remoteWebNavigation._canGoBack = false; + this._remoteWebNavigation._canGoForward = false; + } + + try { + if (Services.appinfo.sessionHistoryInParent) { + let sessionHistory = this.browsingContext?.sessionHistory; + if (!sessionHistory) { + return; + } + + // place the entry at current index at the end of the history list, so it won't get removed + if (sessionHistory.index < sessionHistory.count - 1) { + let indexEntry = sessionHistory.getEntryAtIndex( + sessionHistory.index + ); + sessionHistory.addEntry(indexEntry, true); + } + + let purge = sessionHistory.count; + if ( + this.browsingContext.currentWindowGlobal.documentURI != + "about:blank" + ) { + --purge; // Don't remove the page the user's staring at from shistory + } + + if (purge > 0) { + sessionHistory.purgeHistory(purge); + } + + return; + } + + this.sendMessageToActor( + "Browser:PurgeSessionHistory", + {}, + "PurgeSessionHistory", + "roots" + ); + } catch (ex) { + // This can throw if the browser has started to go away. + if (ex.result != Cr.NS_ERROR_NOT_INITIALIZED) { + throw ex; + } + } + } + + createAboutBlankContentViewer(aPrincipal, aPartitionedPrincipal) { + if (this.isRemoteBrowser) { + // Ensure that the content process has the permissions which are + // needed to create a document with the given principal. + let permissionPrincipal = BrowserUtils.principalWithMatchingOA( + aPrincipal, + this.contentPrincipal + ); + this.frameLoader.remoteTab.transmitPermissionsForPrincipal( + permissionPrincipal + ); + + // This still uses the message manager, for the following reasons: + // + // 1. Due to bug 1523638, it's virtually certain that, if we've just created + // this , that the WindowGlobalParent for the top-level frame + // of this browser doesn't exist yet, so it's not possible to get at a + // JS Window Actor for it. + // + // 2. JS Window Actors are tied to the principals for the frames they're running + // in - switching principals is therefore self-destructive and unexpected. + // + // So we'll continue to use the message manager until we come up with a better + // solution. + this.messageManager.sendAsyncMessage( + "BrowserElement:CreateAboutBlank", + { principal: aPrincipal, partitionedPrincipal: aPartitionedPrincipal } + ); + return; + } + let principal = BrowserUtils.principalWithMatchingOA( + aPrincipal, + this.contentPrincipal + ); + let partitionedPrincipal = BrowserUtils.principalWithMatchingOA( + aPartitionedPrincipal, + this.contentPartitionedPrincipal + ); + this.docShell.createAboutBlankContentViewer( + principal, + partitionedPrincipal + ); + } + + stopScroll() { + if (this._autoScrollBrowsingContext) { + window.removeEventListener("mousemove", this, true); + window.removeEventListener("mousedown", this, true); + window.removeEventListener("mouseup", this, true); + window.removeEventListener("DOMMouseScroll", this, true); + window.removeEventListener("contextmenu", this, true); + window.removeEventListener("keydown", this, true); + window.removeEventListener("keypress", this, true); + window.removeEventListener("keyup", this, true); + + let autoScrollWnd = this._autoScrollBrowsingContext.currentWindowGlobal; + if (autoScrollWnd) { + autoScrollWnd + .getActor("AutoScroll") + .sendAsyncMessage("Autoscroll:Stop", {}); + } + this._autoScrollBrowsingContext = null; + + try { + Services.obs.removeObserver(this.observer, "apz:cancel-autoscroll"); + } catch (ex) { + // It's not clear why this sometimes throws an exception + } + + if (this.isRemoteBrowser && this._autoScrollScrollId != null) { + let { remoteTab } = this.frameLoader; + if (remoteTab) { + remoteTab.stopApzAutoscroll( + this._autoScrollScrollId, + this._autoScrollPresShellId + ); + } + this._autoScrollScrollId = null; + this._autoScrollPresShellId = null; + } + } + } + + _getAndMaybeCreateAutoScrollPopup() { + let autoscrollPopup = document.getElementById("autoscroller"); + if (!autoscrollPopup) { + autoscrollPopup = document.createXULElement("panel"); + autoscrollPopup.className = "autoscroller"; + autoscrollPopup.setAttribute("consumeoutsideclicks", "true"); + autoscrollPopup.setAttribute("rolluponmousewheel", "true"); + autoscrollPopup.id = "autoscroller"; + } + + return autoscrollPopup; + } + + startScroll({ + scrolldir, + screenX, + screenY, + scrollId, + presShellId, + browsingContext, + }) { + if (!this.autoscrollEnabled) { + return { autoscrollEnabled: false, usingApz: false }; + } + + const POPUP_SIZE = 32; + if (!this._autoScrollPopup) { + this._autoScrollPopup = this._getAndMaybeCreateAutoScrollPopup(); + document.documentElement.appendChild(this._autoScrollPopup); + this._autoScrollPopup.removeAttribute("hidden"); + this._autoScrollPopup.setAttribute("noautofocus", "true"); + this._autoScrollPopup.style.height = POPUP_SIZE + "px"; + this._autoScrollPopup.style.width = POPUP_SIZE + "px"; + this._autoScrollPopup.style.margin = -POPUP_SIZE / 2 + "px"; + } + + let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + let screen = screenManager.screenForRect(screenX, screenY, 1, 1); + + // we need these attributes so themers don't need to create per-platform packages + if (screen.colorDepth > 8) { + // need high color for transparency + // Exclude second-rate platforms + this._autoScrollPopup.setAttribute( + "transparent", + !/BeOS|OS\/2/.test(navigator.appVersion) + ); + // Enable translucency on Windows and Mac + this._autoScrollPopup.setAttribute( + "translucent", + AppConstants.platform == "win" || AppConstants.platform == "macosx" + ); + } + + this._autoScrollPopup.setAttribute("scrolldir", scrolldir); + this._autoScrollPopup.addEventListener("popuphidden", this, true); + + // Sanitize screenX/screenY for available screen size with half the size + // of the popup removed. The popup uses negative margins to center on the + // coordinates we pass. Unfortunately `window.screen.availLeft` can be negative + // on Windows in multi-monitor setups, so we use nsIScreenManager instead: + let left = {}, + top = {}, + width = {}, + height = {}; + screen.GetAvailRect(left, top, width, height); + + // We need to get screen CSS-pixel (rather than display-pixel) coordinates. + // With 175% DPI, the actual ratio of display pixels to CSS pixels is + // 1.7647 because of rounding inside gecko. Unfortunately defaultCSSScaleFactor + // returns the original 1.75 dpi factor. While window.devicePixelRatio would + // get us the correct ratio, if the window is split between 2 screens, + // window.devicePixelRatio isn't guaranteed to match the screen we're + // autoscrolling on. So instead we do the same math as Gecko. + const scaleFactor = 60 / Math.round(60 / screen.defaultCSSScaleFactor); + let minX = left.value / scaleFactor + 0.5 * POPUP_SIZE; + let maxX = (left.value + width.value) / scaleFactor - 0.5 * POPUP_SIZE; + let minY = top.value / scaleFactor + 0.5 * POPUP_SIZE; + let maxY = (top.value + height.value) / scaleFactor - 0.5 * POPUP_SIZE; + let popupX = Math.max(minX, Math.min(maxX, screenX)); + let popupY = Math.max(minY, Math.min(maxY, screenY)); + this._autoScrollPopup.openPopupAtScreen(popupX, popupY); + this._ignoreMouseEvents = true; + this._startX = screenX; + this._startY = screenY; + this._autoScrollBrowsingContext = browsingContext; + + window.addEventListener("mousemove", this, true); + window.addEventListener("mousedown", this, true); + window.addEventListener("mouseup", this, true); + window.addEventListener("DOMMouseScroll", this, true); + window.addEventListener("contextmenu", this, true); + window.addEventListener("keydown", this, true); + window.addEventListener("keypress", this, true); + window.addEventListener("keyup", this, true); + + let usingApz = false; + if ( + this.isRemoteBrowser && + scrollId != null && + this.mPrefs.getBoolPref("apz.autoscroll.enabled", false) + ) { + let { remoteTab } = this.frameLoader; + if (remoteTab) { + // If APZ is handling the autoscroll, it may decide to cancel + // it of its own accord, so register an observer to allow it + // to notify us of that. + Services.obs.addObserver( + this.observer, + "apz:cancel-autoscroll", + true + ); + + usingApz = remoteTab.startApzAutoscroll( + screenX, + screenY, + scrollId, + presShellId + ); + } + // Save the IDs for later + this._autoScrollScrollId = scrollId; + this._autoScrollPresShellId = presShellId; + } + + return { autoscrollEnabled: true, usingApz }; + } + + cancelScroll() { + this._autoScrollPopup.hidePopup(); + } + + handleEvent(aEvent) { + if (this._autoScrollBrowsingContext) { + switch (aEvent.type) { + case "mousemove": { + var x = aEvent.screenX - this._startX; + var y = aEvent.screenY - this._startY; + + if ( + x > this._AUTOSCROLL_SNAP || + x < -this._AUTOSCROLL_SNAP || + y > this._AUTOSCROLL_SNAP || + y < -this._AUTOSCROLL_SNAP + ) { + this._ignoreMouseEvents = false; + } + break; + } + case "mouseup": + case "mousedown": + case "contextmenu": { + if (!this._ignoreMouseEvents) { + // Use a timeout to prevent the mousedown from opening the popup again. + // Ideally, we could use preventDefault here, but contenteditable + // and middlemouse paste don't interact well. See bug 1188536. + setTimeout(() => this._autoScrollPopup.hidePopup(), 0); + } + this._ignoreMouseEvents = false; + break; + } + case "DOMMouseScroll": { + this._autoScrollPopup.hidePopup(); + aEvent.preventDefault(); + break; + } + case "popuphidden": { + this._autoScrollPopup.removeEventListener( + "popuphidden", + this, + true + ); + this.stopScroll(); + break; + } + case "keydown": { + if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) { + // the escape key will be processed by + // nsXULPopupManager::KeyDown and the panel will be closed. + // So, don't consume the key event here. + break; + } + // don't break here. we need to eat keydown events. + } + // fall through + case "keypress": + case "keyup": { + // All keyevents should be eaten here during autoscrolling. + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + } + } + } + } + + closeBrowser() { + // The request comes from a XPCOM component, we'd want to redirect + // the request to tabbrowser. + let tabbrowser = this.getTabBrowser(); + if (tabbrowser) { + let tab = tabbrowser.getTabForBrowser(this); + if (tab) { + tabbrowser.removeTab(tab); + return; + } + } + + throw new Error( + "Closing a browser which was not attached to a tabbrowser is unsupported." + ); + } + + swapBrowsers(aOtherBrowser) { + // The request comes from a XPCOM component, we'd want to redirect + // the request to tabbrowser so tabbrowser will be setup correctly, + // and it will eventually call swapDocShells. + let ourTabBrowser = this.getTabBrowser(); + let otherTabBrowser = aOtherBrowser.getTabBrowser(); + if (ourTabBrowser && otherTabBrowser) { + let ourTab = ourTabBrowser.getTabForBrowser(this); + let otherTab = otherTabBrowser.getTabForBrowser(aOtherBrowser); + ourTabBrowser.swapBrowsers(ourTab, otherTab); + return; + } + + // One of us is not connected to a tabbrowser, so just swap. + this.swapDocShells(aOtherBrowser); + } + + swapDocShells(aOtherBrowser) { + if (this.isRemoteBrowser != aOtherBrowser.isRemoteBrowser) { + throw new Error( + "Can only swap docshells between browsers in the same process." + ); + } + + // Give others a chance to swap state. + // IMPORTANT: Since a swapDocShells call does not swap the messageManager + // instances attached to a browser to aOtherBrowser, others + // will need to add the message listeners to the new + // messageManager. + // This is not a bug in swapDocShells or the FrameLoader, + // merely a design decision: If message managers were swapped, + // so that no new listeners were needed, the new + // aOtherBrowser.messageManager would have listeners pointing + // to the JS global of the current browser, which would rather + // easily create leaks while swapping. + // IMPORTANT2: When the current browser element is removed from DOM, + // which is quite common after a swapDocShells call, its + // frame loader is destroyed, and that destroys the relevant + // message manager, which will remove the listeners. + let event = new CustomEvent("SwapDocShells", { detail: aOtherBrowser }); + this.dispatchEvent(event); + event = new CustomEvent("SwapDocShells", { detail: this }); + aOtherBrowser.dispatchEvent(event); + + // We need to swap fields that are tied to our docshell or related to + // the loaded page + // Fields which are built as a result of notifactions (pageshow/hide, + // DOMLinkAdded/Removed, onStateChange) should not be swapped here, + // because these notifications are dispatched again once the docshells + // are swapped. + var fieldsToSwap = ["_webBrowserFind", "_rdmFullZoom"]; + + if (this.isRemoteBrowser) { + fieldsToSwap.push( + ...[ + "_remoteWebNavigation", + "_remoteFinder", + "_documentURI", + "_documentContentType", + "_characterSet", + "_mayEnableCharacterEncodingMenu", + "_charsetAutodetected", + "_contentPrincipal", + "_contentPartitionedPrincipal", + "_isSyntheticDocument", + ] + ); + } + + var ourFieldValues = {}; + var otherFieldValues = {}; + for (let field of fieldsToSwap) { + ourFieldValues[field] = this[field]; + otherFieldValues[field] = aOtherBrowser[field]; + } + + if (window.PopupNotifications) { + PopupNotifications._swapBrowserNotifications(aOtherBrowser, this); + } + + try { + this.swapFrameLoaders(aOtherBrowser); + } catch (ex) { + // This may not be implemented for browser elements that are not + // attached to a BrowserDOMWindow. + } + + for (let field of fieldsToSwap) { + this[field] = otherFieldValues[field]; + aOtherBrowser[field] = ourFieldValues[field]; + } + + if (!this.isRemoteBrowser) { + // Null the current nsITypeAheadFind instances so that they're + // lazily re-created on access. We need to do this because they + // might have attached the wrong docShell. + this._fastFind = aOtherBrowser._fastFind = null; + } else { + // Rewire the remote listeners + this._remoteWebNavigation.swapBrowser(this); + aOtherBrowser._remoteWebNavigation.swapBrowser(aOtherBrowser); + + if (this._remoteFinder) { + this._remoteFinder.swapBrowser(this); + } + if (aOtherBrowser._remoteFinder) { + aOtherBrowser._remoteFinder.swapBrowser(aOtherBrowser); + } + } + + event = new CustomEvent("EndSwapDocShells", { detail: aOtherBrowser }); + this.dispatchEvent(event); + event = new CustomEvent("EndSwapDocShells", { detail: this }); + aOtherBrowser.dispatchEvent(event); + } + + getInPermitUnload(aCallback) { + if (this.isRemoteBrowser) { + let { remoteTab } = this.frameLoader; + if (!remoteTab) { + // If we're crashed, we're definitely not in this state anymore. + aCallback(false); + return; + } + + aCallback( + this._inPermitUnload.has(this.browsingContext.currentWindowGlobal) + ); + return; + } + + if (!this.docShell || !this.docShell.contentViewer) { + aCallback(false); + return; + } + aCallback(this.docShell.contentViewer.inPermitUnload); + } + + async asyncPermitUnload(action) { + let wgp = this.browsingContext.currentWindowGlobal; + if (this._inPermitUnload.has(wgp)) { + throw new Error("permitUnload is already running for this tab."); + } + + this._inPermitUnload.add(wgp); + try { + let permitUnload = await wgp.permitUnload( + action, + lazyPrefs.unloadTimeoutMs + ); + return { permitUnload }; + } finally { + this._inPermitUnload.delete(wgp); + } + } + + get hasBeforeUnload() { + function hasBeforeUnload(bc) { + if (bc.currentWindowContext?.hasBeforeUnload) { + return true; + } + return bc.children.some(hasBeforeUnload); + } + return hasBeforeUnload(this.browsingContext); + } + + permitUnload(action) { + if (this.isRemoteBrowser) { + if (!this.hasBeforeUnload) { + return { permitUnload: true }; + } + + let result; + let success; + + this.asyncPermitUnload(action).then( + val => { + result = val; + success = true; + }, + err => { + result = err; + success = false; + } + ); + + // The permitUnload() promise will, alas, not call its resolution + // callbacks after the browser window the promise lives in has closed, + // so we have to check for that case explicitly. + Services.tm.spinEventLoopUntilOrShutdown( + () => window.closed || success !== undefined + ); + if (success) { + return result; + } + throw result; + } + + if (!this.docShell || !this.docShell.contentViewer) { + return { permitUnload: true }; + } + return { + permitUnload: this.docShell.contentViewer.permitUnload(), + }; + } + + print(aOuterWindowID, aPrintSettings) { + if (!this.frameLoader) { + throw Components.Exception("No frame loader.", Cr.NS_ERROR_FAILURE); + } + + return this.frameLoader.print(aOuterWindowID, aPrintSettings); + } + + async drawSnapshot(x, y, w, h, scale, backgroundColor) { + let rect = new DOMRect(x, y, w, h); + try { + return this.browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + backgroundColor + ); + } catch (e) { + return false; + } + } + + dropLinks(aLinks, aTriggeringPrincipal) { + if (!this.droppedLinkHandler) { + return false; + } + let links = []; + for (let i = 0; i < aLinks.length; i += 3) { + links.push({ + url: aLinks[i], + name: aLinks[i + 1], + type: aLinks[i + 2], + }); + } + this.droppedLinkHandler(null, links, aTriggeringPrincipal); + return true; + } + + getContentBlockingLog() { + let windowGlobal = this.browsingContext.currentWindowGlobal; + if (!windowGlobal) { + return null; + } + return windowGlobal.contentBlockingLog; + } + + getContentBlockingEvents() { + let windowGlobal = this.browsingContext.currentWindowGlobal; + if (!windowGlobal) { + return 0; + } + return windowGlobal.contentBlockingEvents; + } + + // Send an asynchronous message to the remote child via an actor. + // Note: use this only for messages through an actor. For old-style + // messages, use the message manager. + // The value of the scope argument determines which browsing contexts + // are sent to: + // 'all' - send to actors associated with all descendant child frames. + // 'roots' - send only to actors associated with process roots. + // undefined/'' - send only to the top-level actor and not any descendants. + sendMessageToActor(messageName, args, actorName, scope) { + if (!this.frameLoader) { + return; + } + + function sendToChildren(browsingContext, childScope) { + let windowGlobal = browsingContext.currentWindowGlobal; + // If 'roots' is set, only send if windowGlobal.isProcessRoot is true. + if ( + windowGlobal && + (childScope != "roots" || windowGlobal.isProcessRoot) + ) { + windowGlobal.getActor(actorName).sendAsyncMessage(messageName, args); + } + + // Iterate as long as scope in assigned. Note that we use the original + // passed in scope, not childScope here. + if (scope) { + for (let context of browsingContext.children) { + sendToChildren(context, scope); + } + } + } + + // Pass no second argument to always send to the top-level browsing context. + sendToChildren(this.browsingContext); + } + + enterModalState() { + this.sendMessageToActor("EnterModalState", {}, "BrowserElement", "roots"); + } + + leaveModalState() { + this.sendMessageToActor( + "LeaveModalState", + { forceLeave: true }, + "BrowserElement", + "roots" + ); + } + + /** + * Can be called for a window with or without modal state. + * If the window is not in modal state, this is a no-op. + */ + maybeLeaveModalState() { + this.sendMessageToActor( + "LeaveModalState", + { forceLeave: false }, + "BrowserElement", + "roots" + ); + } + + getDevicePermissionOrigins(key) { + if (typeof key !== "string" || key.length === 0) { + throw new Error("Key must be non empty string."); + } + if (!this._devicePermissionOrigins) { + this._devicePermissionOrigins = new Map(); + } + let origins = this._devicePermissionOrigins.get(key); + if (!origins) { + origins = new Set(); + this._devicePermissionOrigins.set(key, origins); + } + return origins; + } + + get processSwitchBehavior() { + // If a `remotenessChangeHandler` is attached to this browser, it supports + // having its toplevel process switched dynamically in response to + // navigations. + if (this.hasAttribute("maychangeremoteness")) { + return Ci.nsIBrowser.PROCESS_BEHAVIOR_STANDARD; + } + + // For backwards compatibility, we need to mark remote, but + // non-`allowremote`, frames as `PROCESS_BEHAVIOR_SUBFRAME_ONLY`, as some + // tests rely on it. + // FIXME: Remove this? + if (this.isRemoteBrowser) { + return Ci.nsIBrowser.PROCESS_BEHAVIOR_SUBFRAME_ONLY; + } + // Otherwise, don't allow gecko-initiated toplevel process switches. + return Ci.nsIBrowser.PROCESS_BEHAVIOR_DISABLED; + } + + // This method is replaced by frontend code in order to delay performing the + // process switch until some async operatin is completed. + // + // This is used by tabbrowser to flush SessionStore before a process switch. + async prepareToChangeRemoteness() { + /* no-op unless replaced */ + } + + // This method is replaced by frontend code in order to handle restoring + // remote session history + // + // Called immediately after changing remoteness. If this method returns + // `true`, Gecko will assume frontend handled resuming the load, and will + // not attempt to resume the load itself. + afterChangeRemoteness(browser, redirectLoadSwitchId) { + /* no-op unless replaced */ + return false; + } + + // Called by Gecko before the remoteness change happens, allowing for + // listeners, etc. to be stashed before the process switch. + beforeChangeRemoteness() { + // Fire the `WillChangeBrowserRemoteness` event, which may be hooked by + // frontend code for custom behaviour. + let event = document.createEvent("Events"); + event.initEvent("WillChangeBrowserRemoteness", true, false); + this.dispatchEvent(event); + + // Destroy ourselves to unregister from observer notifications + // FIXME: Can we get away with something less destructive here? + this.destroy(); + } + + finishChangeRemoteness(redirectLoadSwitchId) { + // Re-construct ourselves after the destroy in `beforeChangeRemoteness`. + this.construct(); + + // Fire the `DidChangeBrowserRemoteness` event, which may be hooked by + // frontend code for custom behaviour. + let event = document.createEvent("Events"); + event.initEvent("DidChangeBrowserRemoteness", true, false); + this.dispatchEvent(event); + + // Call into frontend code which may want to handle the load (e.g. to + // while restoring session state). + return this.afterChangeRemoteness(redirectLoadSwitchId); + } + } + + MozXULElement.implementCustomInterface(MozBrowser, [Ci.nsIBrowser]); + customElements.define("browser", MozBrowser); +} diff --git a/toolkit/content/widgets/button.js b/toolkit/content/widgets/button.js new file mode 100644 index 0000000000..b3873d8646 --- /dev/null +++ b/toolkit/content/widgets/button.js @@ -0,0 +1,317 @@ +/* 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 ( + 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.documentElement.buttonBox; + if (buttonBox) { + this.fireAccessKeyButton(buttonBox, charPressedLower); + } + }); + } + + set type(val) { + this.setAttribute("type", val); + return val; + } + + get type() { + return this.getAttribute("type"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + return val; + } + + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set group(val) { + this.setAttribute("group", val); + return 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"); + } + return val; + } + + 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"); + } + + return val; + } + + 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 (cs.display == "-moz-popup" && 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(` + + + `), + true + ); + Object.defineProperty(this, "buttonFragment", { value: frag }); + return frag; + } + + static get menuFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + + + + + + `), + 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(); + } + } + + customElements.define("button", MozButton); +} diff --git a/toolkit/content/widgets/calendar.js b/toolkit/content/widgets/calendar.js new file mode 100644 index 0000000000..707dc9b818 --- /dev/null +++ b/toolkit/content/widgets/calendar.js @@ -0,0 +1,170 @@ +/* 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"; + +/** + * Initialize the Calendar and generate nodes for week headers and days, and + * attach event listeners. + * + * @param {Object} options + * { + * {Number} calViewSize: Number of days to appear on a calendar view + * {Function} getDayString: Transform day number to string + * {Function} getWeekHeaderString: Transform day of week number to string + * {Function} setSelection: Set selection for dateKeeper + * } + * @param {Object} context + * { + * {DOMElement} weekHeader + * {DOMElement} daysView + * } + */ +function Calendar(options, context) { + const DAYS_IN_A_WEEK = 7; + + this.context = context; + this.state = { + days: [], + weekHeaders: [], + setSelection: options.setSelection, + getDayString: options.getDayString, + getWeekHeaderString: options.getWeekHeaderString, + }; + this.elements = { + weekHeaders: this._generateNodes(DAYS_IN_A_WEEK, context.weekHeader), + daysView: this._generateNodes(options.calViewSize, context.daysView), + }; + + this._attachEventListeners(); +} + +Calendar.prototype = { + /** + * Set new properties and render them. + * + * @param {Object} props + * { + * {Boolean} isVisible: Whether or not the calendar is in view + * {Array} days: Data for days + * { + * {Date} dateObj + * {Number} content + * {Array} classNames + * {Boolean} enabled + * } + * {Array} weekHeaders: Data for weekHeaders + * { + * {Number} content + * {Array} classNames + * } + * } + */ + setProps(props) { + if (props.isVisible) { + // Transform the days and weekHeaders array for rendering + const days = props.days.map( + ({ dateObj, content, classNames, enabled }) => { + return { + dateObj, + textContent: this.state.getDayString(content), + className: classNames.join(" "), + enabled, + }; + } + ); + const weekHeaders = props.weekHeaders.map(({ content, classNames }) => { + return { + textContent: this.state.getWeekHeaderString(content), + className: classNames.join(" "), + }; + }); + // Update the DOM nodes states + this._render({ + elements: this.elements.daysView, + items: days, + prevState: this.state.days, + }); + this._render({ + elements: this.elements.weekHeaders, + items: weekHeaders, + prevState: this.state.weekHeaders, + }); + // Update the state to current + this.state.days = days; + this.state.weekHeaders = weekHeaders; + } + }, + + /** + * Render the items onto the DOM nodes + * @param {Object} + * { + * {Array} elements + * {Array} items + * {Array} prevState: state of items from last render + * } + */ + _render({ elements, items, prevState }) { + for (let i = 0, l = items.length; i < l; i++) { + let el = elements[i]; + + // Check if state from last render has changed, if so, update the elements + if (!prevState[i] || prevState[i].textContent != items[i].textContent) { + el.textContent = items[i].textContent; + } + if (!prevState[i] || prevState[i].className != items[i].className) { + el.className = items[i].className; + } + } + }, + + /** + * Generate DOM nodes + * + * @param {Number} size: Number of nodes to generate + * @param {DOMElement} context: Element to append the nodes to + * @return {Array} + */ + _generateNodes(size, context) { + let frag = document.createDocumentFragment(); + let refs = []; + + for (let i = 0; i < size; i++) { + let el = document.createElement("div"); + el.dataset.id = i; + refs.push(el); + frag.appendChild(el); + } + context.appendChild(frag); + + return refs; + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + if (event.target.parentNode == this.context.daysView) { + let targetId = event.target.dataset.id; + let targetObj = this.state.days[targetId]; + if (targetObj.enabled) { + this.state.setSelection(targetObj.dateObj); + } + } + break; + } + } + }, + + /** + * Attach event listener to daysView + */ + _attachEventListeners() { + this.context.daysView.addEventListener("click", this); + }, +}; diff --git a/toolkit/content/widgets/checkbox.js b/toolkit/content/widgets/checkbox.js new file mode 100644 index 0000000000..8b695dbe0e --- /dev/null +++ b/toolkit/content/widgets/checkbox.js @@ -0,0 +1,84 @@ +/* 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 MozCheckbox extends MozElements.BaseText { + static get markup() { + return ` + + + + + `; + } + + constructor() { + super(); + + // While it would seem we could do this by handling oncommand, we need can't + // because any external oncommand handlers might get called before ours, and + // then they would see the incorrect value of checked. + this.addEventListener("click", event => { + if (event.button === 0 && !this.disabled) { + this.checked = !this.checked; + } + }); + this.addEventListener("keypress", event => { + if (event.key == " ") { + this.checked = !this.checked; + // Prevent page from scrolling on the space key. + event.preventDefault(); + } + }); + } + + static get inheritedAttributes() { + return { + ".checkbox-check": "disabled,checked", + ".checkbox-label": "text=label,accesskey", + ".checkbox-icon": "src", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + this.initializeAttributeInheritance(); + } + + set checked(val) { + let change = val != (this.getAttribute("checked") == "true"); + if (val) { + this.setAttribute("checked", "true"); + } else { + this.removeAttribute("checked"); + } + + if (change) { + let event = document.createEvent("Events"); + event.initEvent("CheckboxStateChange", true, true); + this.dispatchEvent(event); + } + return val; + } + + get checked() { + return this.getAttribute("checked") == "true"; + } + } + + MozCheckbox.contentFragment = null; + + customElements.define("checkbox", MozCheckbox); +} diff --git a/toolkit/content/widgets/datekeeper.js b/toolkit/content/widgets/datekeeper.js new file mode 100644 index 0000000000..9ba08aeb37 --- /dev/null +++ b/toolkit/content/widgets/datekeeper.js @@ -0,0 +1,381 @@ +/* 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"; + +/** + * DateKeeper keeps track of the date states. + */ +function DateKeeper(props) { + this.init(props); +} + +{ + const DAYS_IN_A_WEEK = 7, + MONTHS_IN_A_YEAR = 12, + YEAR_VIEW_SIZE = 200, + YEAR_BUFFER_SIZE = 10, + // The min value is 0001-01-01 based on HTML spec: + // https://html.spec.whatwg.org/#valid-date-string + MIN_DATE = -62135596800000, + // The max value is derived from the ECMAScript spec (275760-09-13): + // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1 + MAX_DATE = 8640000000000000, + MAX_YEAR = 275760, + MAX_MONTH = 9; + + DateKeeper.prototype = { + get year() { + return this.state.dateObj.getUTCFullYear(); + }, + + get month() { + return this.state.dateObj.getUTCMonth(); + }, + + get selection() { + return this.state.selection; + }, + + /** + * Initialize DateKeeper + * @param {Number} year + * @param {Number} month + * @param {Number} day + * @param {Number} min + * @param {Number} max + * @param {Number} step + * @param {Number} stepBase + * @param {Number} firstDayOfWeek + * @param {Array} weekends + * @param {Number} calViewSize + */ + init({ + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek = 0, + weekends = [0], + calViewSize = 42, + }) { + const today = new Date(); + + this.state = { + step, + firstDayOfWeek, + weekends, + calViewSize, + // min & max are NaN if empty or invalid + min: new Date(Number.isNaN(min) ? MIN_DATE : min), + max: new Date(Number.isNaN(max) ? MAX_DATE : max), + stepBase: new Date(stepBase), + today: this._newUTCDate( + today.getFullYear(), + today.getMonth(), + today.getDate() + ), + weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends), + years: [], + dateObj: new Date(0), + selection: { year, month, day }, + }; + + this.setCalendarMonth({ + year: year === undefined ? today.getFullYear() : year, + month: month === undefined ? today.getMonth() : month, + }); + }, + + /** + * Set new calendar month. The year is always treated as full year, so the + * short-form is not supported. + * @param {Object} date parts + * { + * {Number} year [optional] + * {Number} month [optional] + * } + */ + setCalendarMonth({ year = this.year, month = this.month }) { + // Make sure the date is valid before setting. + // Use setUTCFullYear so that year 99 doesn't get parsed as 1999 + if (year > MAX_YEAR || (year === MAX_YEAR && month >= MAX_MONTH)) { + this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1); + } else if (year < 1 || (year === 1 && month < 0)) { + this.state.dateObj.setUTCFullYear(1, 0, 1); + } else { + this.state.dateObj.setUTCFullYear(year, month, 1); + } + }, + + /** + * Set selection date + * @param {Number} year + * @param {Number} month + * @param {Number} day + */ + setSelection({ year, month, day }) { + this.state.selection.year = year; + this.state.selection.month = month; + this.state.selection.day = day; + }, + + /** + * Set month. Makes sure the day is <= the last day of the month + * @param {Number} month + */ + setMonth(month) { + this.setCalendarMonth({ year: this.year, month }); + }, + + /** + * Set year. Makes sure the day is <= the last day of the month + * @param {Number} year + */ + setYear(year) { + this.setCalendarMonth({ year, month: this.month }); + }, + + /** + * Set month by offset. Makes sure the day is <= the last day of the month + * @param {Number} offset + */ + setMonthByOffset(offset) { + this.setCalendarMonth({ year: this.year, month: this.month + offset }); + }, + + /** + * Generate the array of months + * @return {Array} + * { + * {Number} value: Month in int + * {Boolean} enabled + * } + */ + getMonths() { + let months = []; + + for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { + months.push({ + value: i, + enabled: true, + }); + } + + return months; + }, + + /** + * Generate the array of years + * @return {Array} + * { + * {Number} value: Year in int + * {Boolean} enabled + * } + */ + getYears() { + let years = []; + + const firstItem = this.state.years[0]; + const lastItem = this.state.years[this.state.years.length - 1]; + const currentYear = this.year; + + // Generate new years array when the year is outside of the first & + // last item range. If not, return the cached result. + if ( + !firstItem || + !lastItem || + currentYear <= firstItem.value + YEAR_BUFFER_SIZE || + currentYear >= lastItem.value - YEAR_BUFFER_SIZE + ) { + // The year is set in the middle with items on both directions + for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) { + const year = currentYear + i; + if (year >= 1 && year <= MAX_YEAR) { + years.push({ + value: year, + enabled: true, + }); + } + } + this.state.years = years; + } + return this.state.years; + }, + + /** + * Get days for calendar + * @return {Array} + * { + * {Date} dateObj + * {Number} content + * {Array} classNames + * {Boolean} enabled + * } + */ + getDays() { + const firstDayOfMonth = this._getFirstCalendarDate( + this.state.dateObj, + this.state.firstDayOfWeek + ); + const month = this.month; + let days = []; + + for (let i = 0; i < this.state.calViewSize; i++) { + const dateObj = this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + firstDayOfMonth.getUTCDate() + i + ); + + let classNames = []; + let enabled = true; + + const isValid = + dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE; + if (!isValid) { + classNames.push("out-of-range"); + enabled = false; + + days.push({ + classNames, + enabled, + }); + continue; + } + + const isWeekend = this.state.weekends.includes(dateObj.getUTCDay()); + const isCurrentMonth = month == dateObj.getUTCMonth(); + const isSelection = + this.state.selection.year == dateObj.getUTCFullYear() && + this.state.selection.month == dateObj.getUTCMonth() && + this.state.selection.day == dateObj.getUTCDate(); + const isOutOfRange = + dateObj.getTime() < this.state.min.getTime() || + dateObj.getTime() > this.state.max.getTime(); + const isToday = this.state.today.getTime() == dateObj.getTime(); + const isOffStep = this._checkIsOffStep( + dateObj, + this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth(), + dateObj.getUTCDate() + 1 + ) + ); + + if (isWeekend) { + classNames.push("weekend"); + } + if (!isCurrentMonth) { + classNames.push("outside"); + } + if (isSelection && !isOutOfRange && !isOffStep) { + classNames.push("selection"); + } + if (isOutOfRange) { + classNames.push("out-of-range"); + enabled = false; + } + if (isToday) { + classNames.push("today"); + } + if (isOffStep) { + classNames.push("off-step"); + enabled = false; + } + days.push({ + dateObj, + content: dateObj.getUTCDate(), + classNames, + enabled, + }); + } + return days; + }, + + /** + * Check if a date is off step given a starting point and the next increment + * @param {Date} start + * @param {Date} next + * @return {Boolean} + */ + _checkIsOffStep(start, next) { + // If the increment is larger or equal to the step, it must not be off-step. + if (next - start >= this.state.step) { + return false; + } + // Calculate the last valid date + const lastValidStep = Math.floor( + (next - 1 - this.state.stepBase) / this.state.step + ); + const lastValidTimeInMs = + lastValidStep * this.state.step + this.state.stepBase.getTime(); + // The date is off-step if the last valid date is smaller than the start date + return lastValidTimeInMs < start.getTime(); + }, + + /** + * Get week headers for calendar + * @param {Number} firstDayOfWeek + * @param {Array} weekends + * @return {Array} + * { + * {Number} content + * {Array} classNames + * } + */ + _getWeekHeaders(firstDayOfWeek, weekends) { + let headers = []; + let dayOfWeek = firstDayOfWeek; + + for (let i = 0; i < DAYS_IN_A_WEEK; i++) { + headers.push({ + content: dayOfWeek % DAYS_IN_A_WEEK, + classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) + ? ["weekend"] + : [], + }); + dayOfWeek++; + } + return headers; + }, + + /** + * Get the first day on a calendar month + * @param {Date} dateObj + * @param {Number} firstDayOfWeek + * @return {Date} + */ + _getFirstCalendarDate(dateObj, firstDayOfWeek) { + const daysOffset = 1 - DAYS_IN_A_WEEK; + let firstDayOfMonth = this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth() + ); + let dayOfWeek = firstDayOfMonth.getUTCDay(); + + return this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + // When first calendar date is the same as first day of the week, add + // another row on top of it. + firstDayOfWeek == dayOfWeek + ? daysOffset + : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK + ); + }, + + /** + * Helper function for creating UTC dates + * @param {...[Number]} parts + * @return {Date} + */ + _newUTCDate(...parts) { + return new Date(new Date(0).setUTCFullYear(...parts)); + }, + }; +} diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js new file mode 100644 index 0000000000..193e896ac8 --- /dev/null +++ b/toolkit/content/widgets/datepicker.js @@ -0,0 +1,450 @@ +/* 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/. */ + +/* import-globals-from datekeeper.js */ +/* import-globals-from calendar.js */ +/* import-globals-from spinner.js */ + +"use strict"; + +function DatePicker(context) { + this.context = context; + this._attachEventListeners(); +} + +{ + const CAL_VIEW_SIZE = 42; + + DatePicker.prototype = { + /** + * Initializes the date picker. Set the default states and properties. + * @param {Object} props + * { + * {Number} year [optional] + * {Number} month [optional] + * {Number} date [optional] + * {Number} min + * {Number} max + * {Number} step + * {Number} stepBase + * {Number} firstDayOfWeek + * {Array} weekends + * {Array} monthStrings + * {Array} weekdayStrings + * {String} locale [optional]: User preferred locale + * } + */ + init(props = {}) { + this.props = props; + this._setDefaultState(); + this._createComponents(); + this._update(); + document.dispatchEvent(new CustomEvent("PickerReady")); + }, + + /* + * Set initial date picker states. + */ + _setDefaultState() { + const { + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek, + weekends, + monthStrings, + weekdayStrings, + locale, + dir, + } = this.props; + const dateKeeper = new DateKeeper({ + year, + month, + day, + min, + max, + step, + stepBase, + firstDayOfWeek, + weekends, + calViewSize: CAL_VIEW_SIZE, + }); + + document.dir = dir; + + this.state = { + dateKeeper, + locale, + isMonthPickerVisible: false, + datetimeOrders: new Intl.DateTimeFormat(locale) + .formatToParts(new Date(0)) + .map(part => part.type), + getDayString: day => + day ? new Intl.NumberFormat(locale).format(day) : "", + getWeekHeaderString: weekday => weekdayStrings[weekday], + getMonthString: month => monthStrings[month], + setSelection: date => { + dateKeeper.setSelection({ + year: date.getUTCFullYear(), + month: date.getUTCMonth(), + day: date.getUTCDate(), + }); + this._update(); + this._dispatchState(); + this._closePopup(); + }, + setYear: year => { + dateKeeper.setYear(year); + dateKeeper.setSelection({ + year, + month: dateKeeper.selection.month, + day: dateKeeper.selection.day, + }); + this._update(); + this._dispatchState(); + }, + setMonth: month => { + dateKeeper.setMonth(month); + dateKeeper.setSelection({ + year: dateKeeper.selection.year, + month, + day: dateKeeper.selection.day, + }); + this._update(); + this._dispatchState(); + }, + toggleMonthPicker: () => { + this.state.isMonthPickerVisible = !this.state.isMonthPickerVisible; + this._update(); + }, + }; + }, + + /** + * Initalize the date picker components. + */ + _createComponents() { + this.components = { + calendar: new Calendar( + { + calViewSize: CAL_VIEW_SIZE, + locale: this.state.locale, + setSelection: this.state.setSelection, + getDayString: this.state.getDayString, + getWeekHeaderString: this.state.getWeekHeaderString, + }, + { + weekHeader: this.context.weekHeader, + daysView: this.context.daysView, + } + ), + monthYear: new MonthYear( + { + setYear: this.state.setYear, + setMonth: this.state.setMonth, + getMonthString: this.state.getMonthString, + datetimeOrders: this.state.datetimeOrders, + locale: this.state.locale, + }, + { + monthYear: this.context.monthYear, + monthYearView: this.context.monthYearView, + } + ), + }; + }, + + /** + * Update date picker and its components. + */ + _update(options = {}) { + const { dateKeeper, isMonthPickerVisible } = this.state; + + if (isMonthPickerVisible) { + this.state.months = dateKeeper.getMonths(); + this.state.years = dateKeeper.getYears(); + } else { + this.state.days = dateKeeper.getDays(); + } + + this.components.monthYear.setProps({ + isVisible: isMonthPickerVisible, + dateObj: dateKeeper.state.dateObj, + months: this.state.months, + years: this.state.years, + toggleMonthPicker: this.state.toggleMonthPicker, + noSmoothScroll: options.noSmoothScroll, + }); + this.components.calendar.setProps({ + isVisible: !isMonthPickerVisible, + days: this.state.days, + weekHeaders: dateKeeper.state.weekHeaders, + }); + + isMonthPickerVisible + ? this.context.monthYearView.classList.remove("hidden") + : this.context.monthYearView.classList.add("hidden"); + }, + + /** + * Use postMessage to close the picker. + */ + _closePopup() { + window.postMessage( + { + name: "ClosePopup", + }, + "*" + ); + }, + + /** + * Use postMessage to pass the state of picker to the panel. + */ + _dispatchState() { + const { year, month, day } = this.state.dateKeeper.selection; + // The panel is listening to window for postMessage event, so we + // do postMessage to itself to send data to input boxes. + window.postMessage( + { + name: "PickerPopupChanged", + detail: { + year, + month, + day, + }, + }, + "*" + ); + }, + + /** + * Attach event listeners + */ + _attachEventListeners() { + window.addEventListener("message", this); + document.addEventListener("mouseup", this, { passive: true }); + document.addEventListener("mousedown", this); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setCapture(); + + if (event.target == this.context.buttonPrev) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(-1); + this._update(); + } else if (event.target == this.context.buttonNext) { + event.target.classList.add("active"); + this.state.dateKeeper.setMonthByOffset(1); + this._update(); + } + break; + } + case "mouseup": { + if ( + event.target == this.context.buttonPrev || + event.target == this.context.buttonNext + ) { + event.target.classList.remove("active"); + } + } + } + }, + + /** + * Handle postMessage events. + * + * @param {Event} event + */ + handleMessage(event) { + switch (event.data.name) { + case "PickerSetValue": { + this.set(event.data.detail); + break; + } + case "PickerInit": { + this.init(event.data.detail); + break; + } + } + }, + + /** + * Set the date state and update the components with the new state. + * + * @param {Object} dateState + * { + * {Number} year [optional] + * {Number} month [optional] + * {Number} date [optional] + * } + */ + set({ year, month, day }) { + if (!this.state) { + return; + } + + const { dateKeeper } = this.state; + + dateKeeper.setCalendarMonth({ + year, + month, + }); + dateKeeper.setSelection({ + year, + month, + day, + }); + this._update({ noSmoothScroll: true }); + }, + }; + + /** + * MonthYear is a component that handles the month & year spinners + * + * @param {Object} options + * { + * {String} locale + * {Function} setYear + * {Function} setMonth + * {Function} getMonthString + * {Array} datetimeOrders + * } + * @param {DOMElement} context + */ + function MonthYear(options, context) { + const spinnerSize = 5; + const yearFormat = new Intl.DateTimeFormat(options.locale, { + year: "numeric", + timeZone: "UTC", + }).format; + const dateFormat = new Intl.DateTimeFormat(options.locale, { + year: "numeric", + month: "long", + timeZone: "UTC", + }).format; + const spinnerOrder = + options.datetimeOrders.indexOf("month") < + options.datetimeOrders.indexOf("year") + ? "order-month-year" + : "order-year-month"; + + context.monthYearView.classList.add(spinnerOrder); + + this.context = context; + this.state = { dateFormat }; + this.props = {}; + this.components = { + month: new Spinner( + { + id: "spinner-month", + setValue: month => { + this.state.isMonthSet = true; + options.setMonth(month); + }, + getDisplayString: options.getMonthString, + viewportSize: spinnerSize, + }, + context.monthYearView + ), + year: new Spinner( + { + id: "spinner-year", + setValue: year => { + this.state.isYearSet = true; + options.setYear(year); + }, + getDisplayString: year => + yearFormat(new Date(new Date(0).setUTCFullYear(year))), + viewportSize: spinnerSize, + }, + context.monthYearView + ), + }; + + this._attachEventListeners(); + } + + MonthYear.prototype = { + /** + * Set new properties and pass them to components + * + * @param {Object} props + * { + * {Boolean} isVisible + * {Date} dateObj + * {Array} months + * {Array} years + * {Function} toggleMonthPicker + * } + */ + setProps(props) { + this.context.monthYear.textContent = this.state.dateFormat(props.dateObj); + + if (props.isVisible) { + this.context.monthYear.classList.add("active"); + this.components.month.setState({ + value: props.dateObj.getUTCMonth(), + items: props.months, + isInfiniteScroll: true, + isValueSet: this.state.isMonthSet, + smoothScroll: !(this.state.firstOpened || props.noSmoothScroll), + }); + this.components.year.setState({ + value: props.dateObj.getUTCFullYear(), + items: props.years, + isInfiniteScroll: false, + isValueSet: this.state.isYearSet, + smoothScroll: !(this.state.firstOpened || props.noSmoothScroll), + }); + this.state.firstOpened = false; + } else { + this.context.monthYear.classList.remove("active"); + this.state.isMonthSet = false; + this.state.isYearSet = false; + this.state.firstOpened = true; + } + + this.props = Object.assign(this.props, props); + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + this.props.toggleMonthPicker(); + break; + } + } + }, + + /** + * Attach event listener to monthYear button + */ + _attachEventListeners() { + this.context.monthYear.addEventListener("click", this); + }, + }; +} diff --git a/toolkit/content/widgets/datetimebox.css b/toolkit/content/widgets/datetimebox.css new file mode 100644 index 0000000000..8b694f2efc --- /dev/null +++ b/toolkit/content/widgets/datetimebox.css @@ -0,0 +1,62 @@ +/* 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/. */ + +@namespace url("http://www.w3.org/1999/xhtml"); +@namespace svg url("http://www.w3.org/2000/svg"); + +.datetimebox { + display: flex; + /* TODO: Enable selection once bug 1455893 is fixed */ + user-select: none; +} + +.datetime-input-box-wrapper { + display: inline-flex; + flex: 1; + background-color: inherit; + min-width: 0; + justify-content: space-between; + align-items: center; +} + +.datetime-input-edit-wrapper { + overflow: hidden; + white-space: nowrap; + flex-grow: 1; +} + +.datetime-edit-field { + display: inline; + text-align: center; + padding: 1px 3px; + border: 0; + margin: 0; + ime-mode: disabled; + outline: none; +} + +.datetime-edit-field:not([disabled="true"]):focus { + background-color: Highlight; + color: HighlightText; + outline: none; +} + +.datetime-edit-field[disabled="true"], +.datetime-edit-field[readonly="true"] { + user-select: none; +} + +.datetime-reset-button { + color: inherit; + fill: currentColor; + opacity: .5; + background-color: transparent; + border: none; + flex: none; + padding-inline: 2px; +} + +svg|svg.datetime-reset-button-svg { + pointer-events: none; +} diff --git a/toolkit/content/widgets/datetimebox.js b/toolkit/content/widgets/datetimebox.js new file mode 100644 index 0000000000..db7127f11d --- /dev/null +++ b/toolkit/content/widgets/datetimebox.js @@ -0,0 +1,1760 @@ +/* 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 a UA widget. It runs in per-origin UA widget scope, +// to be loaded by UAWidgetsChild.jsm. + +/* + * This is the class of entry. It will construct the actual implementation + * according to the value of the "type" property. + */ +this.DateTimeBoxWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgets when the "type" property changes. + */ + onchange() { + this.switchImpl(); + } + + /* + * Actually switch the implementation. + * - With type equal to "date", DateInputImplWidget should load. + * - With type equal to "time", TimeInputImplWidget should load. + * - Otherwise, nothing should load and loaded impl should be unloaded. + */ + switchImpl() { + let newImpl; + if (this.element.type == "date") { + newImpl = DateInputImplWidget; + } else if (this.element.type == "time") { + newImpl = TimeInputImplWidget; + } + // Skip if we are asked to load the same implementation. + // This can happen if the property is set again w/o value change. + if (this.impl && this.impl.constructor == newImpl) { + return; + } + if (this.impl) { + this.impl.destructor(); + this.shadowRoot.firstChild.remove(); + } + if (newImpl) { + this.impl = new newImpl(this.shadowRoot); + this.impl.onsetup(); + } else { + this.impl = undefined; + } + } + + destructor() { + if (!this.impl) { + return; + } + this.impl.destructor(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } +}; + +this.DateTimeInputBaseImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup() { + this.generateContent(); + + this.DEBUG = false; + this.mDateTimeBoxElement = this.shadowRoot.firstChild; + this.mInputElement = this.element; + this.mLocales = this.window.getWebExposedLocales(); + + this.mIsRTL = false; + let intlUtils = this.window.intlUtils; + if (intlUtils) { + this.mIsRTL = intlUtils.isAppLocaleRTL(); + } + + if (this.mIsRTL) { + let inputBoxWrapper = this.shadowRoot.getElementById("input-box-wrapper"); + inputBoxWrapper.dir = "rtl"; + } + + this.mMin = this.mInputElement.min; + this.mMax = this.mInputElement.max; + this.mStep = this.mInputElement.step; + this.mIsPickerOpen = false; + + this.mResetButton = this.shadowRoot.getElementById("reset-button"); + this.mResetButton.style.visibility = "hidden"; + this.mResetButton.addEventListener("mousedown", this, { + mozSystemGroup: true, + }); + + this.mInputElement.addEventListener( + "keypress", + this, + { + capture: true, + mozSystemGroup: true, + }, + false + ); + // This is to open the picker when input element is clicked (this + // includes padding area). + this.mInputElement.addEventListener( + "click", + this, + { mozSystemGroup: true }, + false + ); + + // Those events are dispatched to
with bubble set + // to false. They are trapped inside UA Widget Shadow DOM and are not + // dispatched to the document. + this.CONTROL_EVENTS.forEach(eventName => { + this.mDateTimeBoxElement.addEventListener(eventName, this, {}, false); + }); + } + + generateContent() { + /* + * Pass the markup through XML parser purely for the reason of loading the localization DTD. + * Remove it when migrate to Fluent (bug 1504363). + */ + const parser = new this.window.DOMParser(); + parser.forceEnableDTD(); + let parserDoc = parser.parseFromString( + ` + %datetimeboxDTD; + ]> +
+ + +
+
`, + "application/xml" + ); + + /* + * The
is also parsed in the document so that there is no + * need to create another XML document just to get the strings. + */ + let stringsElement = parserDoc.getElementById("strings"); + stringsElement.remove(); + for (let key in stringsElement.dataset) { + // key will be camelCase version of the attribute key above, + // like mYearPlaceHolder. + this[key] = stringsElement.dataset[key]; + } + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } + + destructor() { + this.mResetButton.addEventListener("mousedown", this, { + mozSystemGroup: true, + }); + + this.mInputElement.removeEventListener("keypress", this, { + capture: true, + mozSystemGroup: true, + }); + this.mInputElement.removeEventListener("click", this, { + mozSystemGroup: true, + }); + + this.CONTROL_EVENTS.forEach(eventName => { + this.mDateTimeBoxElement.removeEventListener(eventName, this); + }); + this.mInputElement = null; + } + + get FIELD_EVENTS() { + return ["focus", "blur", "copy", "cut", "paste"]; + } + + get CONTROL_EVENTS() { + return [ + "MozDateTimeValueChanged", + "MozNotifyMinMaxStepAttrChanged", + "MozFocusInnerTextBox", + "MozBlurInnerTextBox", + "MozDateTimeAttributeChanged", + "MozPickerValueChanged", + "MozSetDateTimePickerState", + ]; + } + + addEventListenersToField(aElement) { + // These events don't bubble out of the Shadow DOM, so we'll have to add + // event listeners specifically on each of the fields, not just + // on the + this.FIELD_EVENTS.forEach(eventName => { + aElement.addEventListener( + eventName, + this, + { mozSystemGroup: true }, + false + ); + }); + } + + removeEventListenersToField(aElement) { + if (!aElement) { + return; + } + + this.FIELD_EVENTS.forEach(eventName => { + aElement.removeEventListener(eventName, this, { mozSystemGroup: true }); + }); + } + + log(aMsg) { + if (this.DEBUG) { + this.window.dump("[DateTimeBox] " + aMsg + "\n"); + } + } + + createEditFieldAndAppend( + aPlaceHolder, + aLabel, + aIsNumeric, + aMinDigits, + aMaxLength, + aMinValue, + aMaxValue, + aPageUpDownInterval + ) { + let root = this.shadowRoot.getElementById("edit-wrapper"); + let field = this.shadowRoot.createElementAndAppendChildAt(root, "span"); + field.classList.add("datetime-edit-field"); + field.textContent = aPlaceHolder; + field.placeholder = aPlaceHolder; + field.tabIndex = this.mInputElement.tabIndex; + + field.setAttribute("readonly", this.mInputElement.readOnly); + field.setAttribute("disabled", this.mInputElement.disabled); + // Set property as well for convenience. + field.disabled = this.mInputElement.disabled; + field.readOnly = this.mInputElement.readOnly; + field.setAttribute("aria-label", aLabel); + + // Used to store the non-formatted value, cleared when value is + // cleared. + // DateTimeInputTypeBase::HasBadInput() will read this to decide + // if the input has value. + field.setAttribute("value", ""); + + if (aIsNumeric) { + field.classList.add("numeric"); + // Maximum value allowed. + field.setAttribute("min", aMinValue); + // Minumim value allowed. + field.setAttribute("max", aMaxValue); + // Interval when pressing pageUp/pageDown key. + field.setAttribute("pginterval", aPageUpDownInterval); + // Used to store what the user has already typed in the field, + // cleared when value is cleared and when field is blurred. + field.setAttribute("typeBuffer", ""); + // Minimum digits to display, padded with leading 0s. + field.setAttribute("mindigits", aMinDigits); + // Maximum length for the field, will be advance to the next field + // automatically if exceeded. + field.setAttribute("maxlength", aMaxLength); + // Set spinbutton ARIA role + field.setAttribute("role", "spinbutton"); + + if (this.mIsRTL) { + // Force the direction to be "ltr", so that the field stays in the + // same order even when it's empty (with placeholder). By using + // "embed", the text inside the element is still displayed based + // on its directionality. + field.style.unicodeBidi = "embed"; + field.style.direction = "ltr"; + } + } else { + // Set generic textbox ARIA role + field.setAttribute("role", "textbox"); + } + + return field; + } + + updateResetButtonVisibility() { + if (this.isAnyFieldAvailable(false) && !this.isRequired()) { + this.mResetButton.style.visibility = ""; + } else { + this.mResetButton.style.visibility = "hidden"; + } + } + + focusInnerTextBox() { + this.log("Focus inner editable field."); + + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + this.mLastFocusedField = child; + child.focus(); + this.log("focused"); + break; + } + } + + blurInnerTextBox() { + this.log("Blur inner editable field."); + + if (this.mLastFocusedField) { + this.mLastFocusedField.blur(); + } else { + // If .mLastFocusedField hasn't been set, blur all editable fields, + // so that the bound element will actually be blurred. Note that + // blurring on a element that has no focus won't have any effect. + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + child.blur(); + } + } + } + + notifyInputElementValueChanged() { + this.log("inputElementValueChanged"); + this.setFieldsFromInputValue(); + } + + notifyMinMaxStepAttrChanged() { + // No operation by default + } + + setValueFromPicker(aValue) { + this.setFieldsFromPicker(aValue); + } + + advanceToNextField(aReverse) { + this.log("advanceToNextField"); + + let focusedInput = this.mLastFocusedField; + let next = aReverse + ? focusedInput.previousElementSibling + : focusedInput.nextElementSibling; + if (!next && !aReverse) { + this.setInputValueFromFields(); + return; + } + + while (next) { + if (next.matches("span.datetime-edit-field")) { + next.focus(); + break; + } + next = aReverse ? next.previousElementSibling : next.nextElementSibling; + } + } + + setPickerState(aIsOpen) { + this.log("picker is now " + (aIsOpen ? "opened" : "closed")); + this.mIsPickerOpen = aIsOpen; + } + + updateEditAttributes() { + this.log("updateEditAttributes"); + + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + // "disabled" and "readonly" must be set as attributes because they + // are not defined properties of HTMLSpanElement, and the stylesheet + // checks the literal string attribute values. + child.setAttribute("disabled", this.mInputElement.disabled); + child.setAttribute("readonly", this.mInputElement.readOnly); + + // Set property as well for convenience. + child.disabled = this.mInputElement.disabled; + child.readOnly = this.mInputElement.readOnly; + + // tabIndex works on all elements + child.tabIndex = this.mInputElement.tabIndex; + } + + this.mResetButton.disabled = + this.mInputElement.disabled || this.mInputElement.readOnly; + this.updateResetButtonVisibility(); + } + + isEmpty(aValue) { + return aValue == undefined || 0 === aValue.length; + } + + getFieldValue(aField) { + if (!aField || !aField.classList.contains("numeric")) { + return undefined; + } + + let value = aField.getAttribute("value"); + // Avoid returning 0 when field is empty. + return this.isEmpty(value) ? undefined : Number(value); + } + + clearFieldValue(aField) { + aField.textContent = aField.placeholder; + aField.setAttribute("value", ""); + if (aField.classList.contains("numeric")) { + aField.setAttribute("typeBuffer", ""); + } + this.updateResetButtonVisibility(); + } + + setFieldValue() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + clearInputFields() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + setFieldsFromInputValue() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + setInputValueFromFields() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + setFieldsFromPicker() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + handleKeypress() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + handleKeyboardNav() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + getCurrentValue() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + isAnyFieldAvailable() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + notifyPicker() { + if (this.mIsPickerOpen && this.isAnyFieldAvailable(true)) { + this.mInputElement.updateDateTimePicker(this.getCurrentValue()); + } + } + + isDisabled() { + return this.mInputElement.hasAttribute("disabled"); + } + + isReadonly() { + return this.mInputElement.hasAttribute("readonly"); + } + + isEditable() { + return !this.isDisabled() && !this.isReadonly(); + } + + isRequired() { + return this.mInputElement.hasAttribute("required"); + } + + containingTree() { + return this.mInputElement.containingShadowRoot || this.document; + } + + handleEvent(aEvent) { + this.log("handleEvent: " + aEvent.type); + + if (!aEvent.isTrusted) { + return; + } + + switch (aEvent.type) { + case "MozDateTimeValueChanged": { + this.notifyInputElementValueChanged(); + break; + } + case "MozNotifyMinMaxStepAttrChanged": { + this.notifyMinMaxStepAttrChanged(); + break; + } + case "MozFocusInnerTextBox": { + this.focusInnerTextBox(); + break; + } + case "MozBlurInnerTextBox": { + this.blurInnerTextBox(); + break; + } + case "MozDateTimeAttributeChanged": { + this.updateEditAttributes(); + break; + } + case "MozPickerValueChanged": { + this.setValueFromPicker(aEvent.detail); + break; + } + case "MozSetDateTimePickerState": { + this.setPickerState(aEvent.detail); + break; + } + case "keypress": { + this.onKeyPress(aEvent); + break; + } + case "click": { + this.onClick(aEvent); + break; + } + case "focus": { + this.onFocus(aEvent); + break; + } + case "blur": { + this.onBlur(aEvent); + break; + } + case "mousedown": + case "copy": + case "cut": + case "paste": { + aEvent.preventDefault(); + break; + } + default: + break; + } + } + + onFocus(aEvent) { + this.log("onFocus originalTarget: " + aEvent.originalTarget); + if (this.containingTree().activeElement != this.mInputElement) { + return; + } + + let target = aEvent.originalTarget; + if (target.matches("span.datetime-edit-field")) { + if (target.disabled) { + return; + } + this.mLastFocusedField = target; + this.mInputElement.setFocusState(true); + } + } + + onBlur(aEvent) { + this.log( + "onBlur originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + + " rt: " + + aEvent.relatedTarget + ); + + let target = aEvent.originalTarget; + target.setAttribute("typeBuffer", ""); + this.setInputValueFromFields(); + // No need to set and unset the focus state if the focus is staying within + // our input. Same about closing the picker. + if (aEvent.relatedTarget != this.mInputElement) { + this.mInputElement.setFocusState(false); + if (this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + } + } + } + + onKeyPress(aEvent) { + this.log("onKeyPress key: " + aEvent.key); + + switch (aEvent.key) { + // Toggle the picker on space/enter, close on Escape. + case "Enter": + case "Escape": + case " ": { + if (this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + } else if (aEvent.key != "Escape") { + this.mInputElement.openDateTimePicker(this.getCurrentValue()); + } else { + // Don't preventDefault(); + break; + } + aEvent.preventDefault(); + break; + } + case "Backspace": { + // TODO(emilio, bug 1571533): These functions should look at + // defaultPrevented. + if (this.isEditable()) { + let targetField = aEvent.originalTarget; + this.clearFieldValue(targetField); + this.setInputValueFromFields(); + aEvent.preventDefault(); + } + break; + } + case "ArrowRight": + case "ArrowLeft": { + this.advanceToNextField(!(aEvent.key == "ArrowRight")); + aEvent.preventDefault(); + break; + } + case "ArrowUp": + case "ArrowDown": + case "PageUp": + case "PageDown": + case "Home": + case "End": { + this.handleKeyboardNav(aEvent); + aEvent.preventDefault(); + break; + } + default: { + // printable characters + if ( + aEvent.keyCode == 0 && + !(aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey) + ) { + this.handleKeypress(aEvent); + aEvent.preventDefault(); + } + break; + } + } + } + + onClick(aEvent) { + this.log( + "onClick originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + ); + + if (aEvent.defaultPrevented || !this.isEditable()) { + return; + } + + if (aEvent.originalTarget == this.mResetButton) { + this.clearInputFields(false); + } else if (!this.mIsPickerOpen) { + this.mInputElement.openDateTimePicker(this.getCurrentValue()); + } + } +}; + +this.DateInputImplWidget = class extends DateTimeInputBaseImplWidget { + constructor(shadowRoot) { + super(shadowRoot); + } + + onsetup() { + super.onsetup(); + + this.mMinMonth = 1; + this.mMaxMonth = 12; + this.mMinDay = 1; + this.mMaxDay = 31; + this.mMinYear = 1; + // Maximum year limited by ECMAScript date object range, year <= 275760. + this.mMaxYear = 275760; + this.mMonthDayLength = 2; + this.mYearLength = 4; + this.mMonthPageUpDownInterval = 3; + this.mDayPageUpDownInterval = 7; + this.mYearPageUpDownInterval = 10; + + this.buildEditFields(); + this.updateEditAttributes(); + + if (this.mInputElement.value) { + this.setFieldsFromInputValue(); + } + } + + destructor() { + this.removeEventListenersToField(this.mYearField); + this.removeEventListenersToField(this.mMonthField); + this.removeEventListenersToField(this.mDayField); + super.destructor(); + } + + buildEditFields() { + let root = this.shadowRoot.getElementById("edit-wrapper"); + + let yearMaxLength = this.mMaxYear.toString().length; + + let formatter = Intl.DateTimeFormat(this.mLocales, { + year: "numeric", + month: "numeric", + day: "numeric", + }); + formatter.formatToParts(Date.now()).map(part => { + switch (part.type) { + case "year": + this.mYearField = this.createEditFieldAndAppend( + this.mYearPlaceHolder, + this.mYearLabel, + true, + this.mYearLength, + yearMaxLength, + this.mMinYear, + this.mMaxYear, + this.mYearPageUpDownInterval + ); + this.addEventListenersToField(this.mYearField); + break; + case "month": + this.mMonthField = this.createEditFieldAndAppend( + this.mMonthPlaceHolder, + this.mMonthLabel, + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinMonth, + this.mMaxMonth, + this.mMonthPageUpDownInterval + ); + this.addEventListenersToField(this.mMonthField); + break; + case "day": + this.mDayField = this.createEditFieldAndAppend( + this.mDayPlaceHolder, + this.mDayLabel, + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinDay, + this.mMaxDay, + this.mDayPageUpDownInterval + ); + this.addEventListenersToField(this.mDayField); + break; + default: + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = part.value; + break; + } + }); + } + + clearInputFields(aFromInputElement) { + this.log("clearInputFields"); + + if (this.mMonthField) { + this.clearFieldValue(this.mMonthField); + } + + if (this.mDayField) { + this.clearFieldValue(this.mDayField); + } + + if (this.mYearField) { + this.clearFieldValue(this.mYearField); + } + + if (!aFromInputElement) { + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + } + } + + setFieldsFromInputValue() { + let value = this.mInputElement.value; + if (!value) { + this.clearInputFields(true); + return; + } + + this.log("setFieldsFromInputValue: " + value); + let [year, month, day] = value.split("-"); + + this.setFieldValue(this.mYearField, year); + this.setFieldValue(this.mMonthField, month); + this.setFieldValue(this.mDayField, day); + + this.notifyPicker(); + } + + setInputValueFromFields() { + if (this.isAnyFieldEmpty()) { + // Clear input element's value if any of the field has been cleared, + // otherwise update the validity state, since it may become "not" + // invalid if fields are not complete. + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + // We still need to notify picker in case any of the field has + // changed. + this.notifyPicker(); + return; + } + + let { year, month, day } = this.getCurrentValue(); + + // Convert to a valid date string according to: + // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-date-string + year = year.toString().padStart(this.mYearLength, "0"); + month = month < 10 ? "0" + month : month; + day = day < 10 ? "0" + day : day; + + let date = [year, month, day].join("-"); + + if (date == this.mInputElement.value) { + return; + } + + this.log("setInputValueFromFields: " + date); + this.notifyPicker(); + this.mInputElement.setUserInput(date); + } + + setFieldsFromPicker(aValue) { + let year = aValue.year; + let month = aValue.month; + let day = aValue.day; + + if (!this.isEmpty(year)) { + this.setFieldValue(this.mYearField, year); + } + + if (!this.isEmpty(month)) { + this.setFieldValue(this.mMonthField, month); + } + + if (!this.isEmpty(day)) { + this.setFieldValue(this.mDayField, day); + } + + // Update input element's .value if needed. + this.setInputValueFromFields(); + } + + handleKeypress(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) { + let buffer = targetField.getAttribute("typeBuffer") || ""; + + buffer = buffer.concat(key); + this.setFieldValue(targetField, buffer); + + let n = Number(buffer); + let max = targetField.getAttribute("max"); + let maxLength = targetField.getAttribute("maxlength"); + if (buffer.length >= maxLength || n * 10 > max) { + buffer = ""; + this.advanceToNextField(); + } + targetField.setAttribute("typeBuffer", buffer); + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + } + } + + incrementFieldValue(aTargetField, aTimes) { + let value = this.getFieldValue(aTargetField); + + // Use current date if field is empty. + if (this.isEmpty(value)) { + let now = new Date(); + + if (aTargetField == this.mYearField) { + value = now.getFullYear(); + } else if (aTargetField == this.mMonthField) { + value = now.getMonth() + 1; + } else if (aTargetField == this.mDayField) { + value = now.getDate(); + } else { + this.log("Field not supported in incrementFieldValue."); + return; + } + } + + let min = Number(aTargetField.getAttribute("min")); + let max = Number(aTargetField.getAttribute("max")); + + value += Number(aTimes); + if (value > max) { + value -= max - min + 1; + } else if (value < min) { + value += max - min + 1; + } + + this.setFieldValue(aTargetField, value); + } + + handleKeyboardNav(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + // Home/End key does nothing on year field. + if (targetField == this.mYearField && (key == "Home" || key == "End")) { + return; + } + + switch (key) { + case "ArrowUp": + this.incrementFieldValue(targetField, 1); + break; + case "ArrowDown": + this.incrementFieldValue(targetField, -1); + break; + case "PageUp": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, interval); + break; + } + case "PageDown": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, 0 - interval); + break; + } + case "Home": + let min = targetField.getAttribute("min"); + this.setFieldValue(targetField, min); + break; + case "End": + let max = targetField.getAttribute("max"); + this.setFieldValue(targetField, max); + break; + } + this.setInputValueFromFields(); + } + + getCurrentValue() { + let year = this.getFieldValue(this.mYearField); + let month = this.getFieldValue(this.mMonthField); + let day = this.getFieldValue(this.mDayField); + + let date = { year, month, day }; + + this.log("getCurrentValue: " + JSON.stringify(date)); + return date; + } + + setFieldValue(aField, aValue) { + if (!aField || !aField.classList.contains("numeric")) { + return; + } + + let value = Number(aValue); + if (isNaN(value)) { + this.log("NaN on setFieldValue!"); + return; + } + + let maxLength = aField.getAttribute("maxlength"); + if (aValue.length == maxLength) { + let min = Number(aField.getAttribute("min")); + let max = Number(aField.getAttribute("max")); + + if (value < min) { + value = min; + } else if (value > max) { + value = max; + } + } + + aField.setAttribute("value", value); + + // Display formatted value based on locale. + let minDigits = aField.getAttribute("mindigits"); + let formatted = value.toLocaleString(this.mLocales, { + minimumIntegerDigits: minDigits, + useGrouping: false, + }); + + aField.textContent = formatted; + aField.setAttribute("aria-valuetext", formatted); + this.updateResetButtonVisibility(); + } + + isAnyFieldAvailable(aForPicker) { + let { year, month, day } = this.getCurrentValue(); + + return !this.isEmpty(year) || !this.isEmpty(month) || !this.isEmpty(day); + } + + isAnyFieldEmpty() { + let { year, month, day } = this.getCurrentValue(); + + return this.isEmpty(year) || this.isEmpty(month) || this.isEmpty(day); + } +}; + +this.TimeInputImplWidget = class extends DateTimeInputBaseImplWidget { + constructor(shadowRoot) { + super(shadowRoot); + } + + onsetup() { + super.onsetup(); + + const kDefaultAMString = "AM"; + const kDefaultPMString = "PM"; + + let { amString, pmString } = this.getStringsForLocale(this.mLocales); + + this.mAMIndicator = amString || kDefaultAMString; + this.mPMIndicator = pmString || kDefaultPMString; + + this.mHour12 = this.is12HourTime(this.mLocales); + this.mMillisecSeparatorText = "."; + this.mMaxLength = 2; + this.mMillisecMaxLength = 3; + this.mDefaultStep = 60 * 1000; // in milliseconds + + this.mMinHour = this.mHour12 ? 1 : 0; + this.mMaxHour = this.mHour12 ? 12 : 23; + this.mMinMinute = 0; + this.mMaxMinute = 59; + this.mMinSecond = 0; + this.mMaxSecond = 59; + this.mMinMillisecond = 0; + this.mMaxMillisecond = 999; + + this.mHourPageUpDownInterval = 3; + this.mMinSecPageUpDownInterval = 10; + + this.buildEditFields(); + this.updateEditAttributes(); + + if (this.mInputElement.value) { + this.setFieldsFromInputValue(); + } + } + + destructor() { + this.removeEventListenersToField(this.mHourField); + this.removeEventListenersToField(this.mMinuteField); + this.removeEventListenersToField(this.mSecondField); + this.removeEventListenersToField(this.mMillisecField); + this.removeEventListenersToField(this.mDayPeriodField); + super.destructor(); + } + + get kMsPerSecond() { + return 1000; + } + + get kMsPerMinute() { + return 60 * 1000; + } + + getInputElementValues() { + let value = this.mInputElement.value; + if (value.length === 0) { + return {}; + } + + let hour, minute, second, millisecond; + [hour, minute, second] = value.split(":"); + if (second) { + [second, millisecond] = second.split("."); + + // Convert fraction of second to milliseconds. + if (millisecond && millisecond.length === 1) { + millisecond *= 100; + } else if (millisecond && millisecond.length === 2) { + millisecond *= 10; + } + } + + return { hour, minute, second, millisecond }; + } + + hasSecondField() { + return !!this.mSecondField; + } + + hasMillisecField() { + return !!this.mMillisecField; + } + + hasDayPeriodField() { + return !!this.mDayPeriodField; + } + + shouldShowSecondField() { + let { second } = this.getInputElementValues(); + if (second != undefined) { + return true; + } + + let stepBase = this.mInputElement.getStepBase(); + if (stepBase % this.kMsPerMinute != 0) { + return true; + } + + let step = this.mInputElement.getStep(); + if (step % this.kMsPerMinute != 0) { + return true; + } + + return false; + } + + shouldShowMillisecField() { + let { millisecond } = this.getInputElementValues(); + if (millisecond != undefined) { + return true; + } + + let stepBase = this.mInputElement.getStepBase(); + if (stepBase % this.kMsPerSecond != 0) { + return true; + } + + let step = this.mInputElement.getStep(); + if (step % this.kMsPerSecond != 0) { + return true; + } + + return false; + } + + rebuildEditFieldsIfNeeded() { + if ( + this.shouldShowSecondField() == this.hasSecondField() && + this.shouldShowMillisecField() == this.hasMillisecField() + ) { + return; + } + + let focused = this.mInputElement.matches(":focus"); + + let root = this.shadowRoot.getElementById("edit-wrapper"); + while (root.firstChild) { + root.firstChild.remove(); + } + + this.removeEventListenersToField(this.mHourField); + this.removeEventListenersToField(this.mMinuteField); + this.removeEventListenersToField(this.mSecondField); + this.removeEventListenersToField(this.mMillisecField); + this.removeEventListenersToField(this.mDayPeriodField); + + this.mHourField = null; + this.mMinuteField = null; + this.mSecondField = null; + this.mMillisecField = null; + this.mDayPeriodField = null; + + this.buildEditFields(); + if (focused) { + this.focusInnerTextBox(); + } + } + + buildEditFields() { + let root = this.shadowRoot.getElementById("edit-wrapper"); + + let options = { + hour: "numeric", + minute: "numeric", + hour12: this.mHour12, + }; + + if (this.shouldShowSecondField()) { + options.second = "numeric"; + } + + let formatter = Intl.DateTimeFormat(this.mLocales, options); + formatter.formatToParts(Date.now()).map(part => { + switch (part.type) { + case "hour": + this.mHourField = this.createEditFieldAndAppend( + this.mHourPlaceHolder, + this.mHourLabel, + true, + this.mMaxLength, + this.mMaxLength, + this.mMinHour, + this.mMaxHour, + this.mHourPageUpDownInterval + ); + this.addEventListenersToField(this.mHourField); + break; + case "minute": + this.mMinuteField = this.createEditFieldAndAppend( + this.mMinutePlaceHolder, + this.mMinuteLabel, + true, + this.mMaxLength, + this.mMaxLength, + this.mMinMinute, + this.mMaxMinute, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMinuteField); + break; + case "second": + this.mSecondField = this.createEditFieldAndAppend( + this.mSecondPlaceHolder, + this.mSecondLabel, + true, + this.mMaxLength, + this.mMaxLength, + this.mMinSecond, + this.mMaxSecond, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mSecondField); + if (this.shouldShowMillisecField()) { + // Intl.DateTimeFormat does not support millisecond, so we + // need to handle this on our own. + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = this.mMillisecSeparatorText; + this.mMillisecField = this.createEditFieldAndAppend( + this.mMillisecPlaceHolder, + this.mMillisecLabel, + true, + this.mMillisecMaxLength, + this.mMillisecMaxLength, + this.mMinMillisecond, + this.mMaxMillisecond, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMillisecField); + } + break; + case "dayPeriod": + this.mDayPeriodField = this.createEditFieldAndAppend( + this.mDayPeriodPlaceHolder, + this.mDayPeriodLabel, + false + ); + this.addEventListenersToField(this.mDayPeriodField); + + // Give aria autocomplete hint for am/pm + this.mDayPeriodField.setAttribute("aria-autocomplete", "inline"); + break; + default: + let span = this.shadowRoot.createElementAndAppendChildAt( + root, + "span" + ); + span.textContent = part.value; + break; + } + }); + } + + getStringsForLocale(aLocales) { + this.log("getStringsForLocale: " + aLocales); + + let intlUtils = this.window.intlUtils; + if (!intlUtils) { + return {}; + } + + let amString, pmString; + let keys = [ + "dates/gregorian/dayperiods/am", + "dates/gregorian/dayperiods/pm", + ]; + + let result = intlUtils.getDisplayNames(this.mLocales, { + style: "short", + keys, + }); + + [amString, pmString] = keys.map(key => result.values[key]); + + return { amString, pmString }; + } + + is12HourTime(aLocales) { + let options = new Intl.DateTimeFormat(aLocales, { + hour: "numeric", + }).resolvedOptions(); + + return options.hour12; + } + + setFieldsFromInputValue() { + let { hour, minute, second, millisecond } = this.getInputElementValues(); + + if (this.isEmpty(hour) && this.isEmpty(minute)) { + this.clearInputFields(true); + return; + } + + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + + this.setFieldValue(this.mHourField, hour); + this.setFieldValue(this.mMinuteField, minute); + if (this.mHour12) { + this.setDayPeriodValue( + hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator + ); + } + + if (this.hasSecondField()) { + this.setFieldValue(this.mSecondField, second != undefined ? second : 0); + } + + if (this.hasMillisecField()) { + this.setFieldValue( + this.mMillisecField, + millisecond != undefined ? millisecond : 0 + ); + } + + this.notifyPicker(); + } + + setInputValueFromFields() { + if (this.isAnyFieldEmpty()) { + // Clear input element's value if any of the field has been cleared, + // otherwise update the validity state, since it may become "not" + // invalid if fields are not complete. + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + // We still need to notify picker in case any of the field has + // changed. + this.notifyPicker(); + return; + } + + let { hour, minute, second, millisecond } = this.getCurrentValue(); + let dayPeriod = this.getDayPeriodValue(); + + // Convert to a valid time string according to: + // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-time-string + if (this.mHour12) { + if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { + hour += this.mMaxHour; + } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { + hour = 0; + } + } + + hour = hour < 10 ? "0" + hour : hour; + minute = minute < 10 ? "0" + minute : minute; + + let time = hour + ":" + minute; + if (second != undefined) { + second = second < 10 ? "0" + second : second; + time += ":" + second; + } + + if (millisecond != undefined) { + // Convert milliseconds to fraction of second. + millisecond = millisecond + .toString() + .padStart(this.mMillisecMaxLength, "0"); + time += "." + millisecond; + } + + if (time == this.mInputElement.value) { + return; + } + + this.log("setInputValueFromFields: " + time); + this.notifyPicker(); + this.mInputElement.setUserInput(time); + } + + setFieldsFromPicker(aValue) { + let hour = aValue.hour; + let minute = aValue.minute; + this.log("setFieldsFromPicker: " + hour + ":" + minute); + + if (!this.isEmpty(hour)) { + this.setFieldValue(this.mHourField, hour); + if (this.mHour12) { + this.setDayPeriodValue( + hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator + ); + } + } + + if (!this.isEmpty(minute)) { + this.setFieldValue(this.mMinuteField, minute); + } + + // Update input element's .value if needed. + this.setInputValueFromFields(); + } + + clearInputFields(aFromInputElement) { + this.log("clearInputFields"); + + if (this.mHourField) { + this.clearFieldValue(this.mHourField); + } + + if (this.mMinuteField) { + this.clearFieldValue(this.mMinuteField); + } + + if (this.hasSecondField()) { + this.clearFieldValue(this.mSecondField); + } + + if (this.hasMillisecField()) { + this.clearFieldValue(this.mMillisecField); + } + + if (this.hasDayPeriodField()) { + this.clearFieldValue(this.mDayPeriodField); + } + + if (!aFromInputElement) { + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + } + } + + notifyMinMaxStepAttrChanged() { + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + // Fill in values again. + this.setFieldsFromInputValue(); + } + + incrementFieldValue(aTargetField, aTimes) { + let value = this.getFieldValue(aTargetField); + + // Use current time if field is empty. + if (this.isEmpty(value)) { + let now = new Date(); + + if (aTargetField == this.mHourField) { + value = now.getHours(); + if (this.mHour12) { + value = value % this.mMaxHour || this.mMaxHour; + } + } else if (aTargetField == this.mMinuteField) { + value = now.getMinutes(); + } else if (aTargetField == this.mSecondField) { + value = now.getSeconds(); + } else if (aTargetField == this.mMillisecField) { + value = now.getMilliseconds(); + } else { + this.log("Field not supported in incrementFieldValue."); + return; + } + } + + let min = aTargetField.getAttribute("min"); + let max = aTargetField.getAttribute("max"); + + value += Number(aTimes); + if (value > max) { + value -= max - min + 1; + } else if (value < min) { + value += max - min + 1; + } + + this.setFieldValue(aTargetField, value); + } + + handleKeyboardNav(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (this.hasDayPeriodField() && targetField == this.mDayPeriodField) { + // Home/End key does nothing on AM/PM field. + if (key == "Home" || key == "End") { + return; + } + + this.setDayPeriodValue( + this.getDayPeriodValue() == this.mAMIndicator + ? this.mPMIndicator + : this.mAMIndicator + ); + this.setInputValueFromFields(); + return; + } + + switch (key) { + case "ArrowUp": + this.incrementFieldValue(targetField, 1); + break; + case "ArrowDown": + this.incrementFieldValue(targetField, -1); + break; + case "PageUp": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, interval); + break; + } + case "PageDown": { + let interval = targetField.getAttribute("pginterval"); + this.incrementFieldValue(targetField, 0 - interval); + break; + } + case "Home": + let min = targetField.getAttribute("min"); + this.setFieldValue(targetField, min); + break; + case "End": + let max = targetField.getAttribute("max"); + this.setFieldValue(targetField, max); + break; + } + this.setInputValueFromFields(); + } + + handleKeypress(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (this.hasDayPeriodField() && targetField == this.mDayPeriodField) { + if (key == "a" || key == "A") { + this.setDayPeriodValue(this.mAMIndicator); + } else if (key == "p" || key == "P") { + this.setDayPeriodValue(this.mPMIndicator); + } + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + return; + } + + if (targetField.classList.contains("numeric") && key.match(/[0-9]/)) { + let buffer = targetField.getAttribute("typeBuffer") || ""; + + buffer = buffer.concat(key); + this.setFieldValue(targetField, buffer); + + let n = Number(buffer); + let max = targetField.getAttribute("max"); + let maxLength = targetField.getAttribute("maxlength"); + if (buffer.length >= maxLength || n * 10 > max) { + buffer = ""; + this.advanceToNextField(); + } + targetField.setAttribute("typeBuffer", buffer); + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + } + } + + setFieldValue(aField, aValue) { + if (!aField || !aField.classList.contains("numeric")) { + return; + } + + let value = Number(aValue); + if (isNaN(value)) { + this.log("NaN on setFieldValue!"); + return; + } + + if (aField == this.mHourField) { + if (this.mHour12) { + // Try to change to 12hr format if user input is 0 or greater + // than 12. + let maxLength = aField.getAttribute("maxlength"); + if (value == 0 && aValue.length == maxLength) { + value = this.mMaxHour; + } else { + value = value > this.mMaxHour ? value % this.mMaxHour : value; + } + } else if (value > this.mMaxHour) { + value = this.mMaxHour; + } + } + + aField.setAttribute("value", value); + + let minDigits = aField.getAttribute("mindigits"); + let formatted = value.toLocaleString(this.mLocales, { + minimumIntegerDigits: minDigits, + useGrouping: false, + }); + + aField.textContent = formatted; + aField.setAttribute("aria-valuetext", formatted); + this.updateResetButtonVisibility(); + } + + getDayPeriodValue(aValue) { + if (!this.hasDayPeriodField()) { + return ""; + } + + let placeholder = this.mDayPeriodField.placeholder; + let value = this.mDayPeriodField.textContent; + + return value == placeholder ? "" : value; + } + + setDayPeriodValue(aValue) { + if (!this.hasDayPeriodField()) { + return; + } + + this.mDayPeriodField.textContent = aValue; + this.mDayPeriodField.setAttribute("value", aValue); + this.updateResetButtonVisibility(); + } + + isAnyFieldAvailable(aForPicker) { + let { hour, minute, second, millisecond } = this.getCurrentValue(); + let dayPeriod = this.getDayPeriodValue(); + + let available = !this.isEmpty(hour) || !this.isEmpty(minute); + if (available) { + return true; + } + + // Picker only cares about hour:minute. + if (aForPicker) { + return false; + } + + return ( + (this.hasDayPeriodField() && !this.isEmpty(dayPeriod)) || + (this.hasSecondField() && !this.isEmpty(second)) || + (this.hasMillisecField() && !this.isEmpty(millisecond)) + ); + } + + isAnyFieldEmpty() { + let { hour, minute, second, millisecond } = this.getCurrentValue(); + let dayPeriod = this.getDayPeriodValue(); + + return ( + this.isEmpty(hour) || + this.isEmpty(minute) || + (this.hasDayPeriodField() && this.isEmpty(dayPeriod)) || + (this.hasSecondField() && this.isEmpty(second)) || + (this.hasMillisecField() && this.isEmpty(millisecond)) + ); + } + + getCurrentValue() { + let hour = this.getFieldValue(this.mHourField); + if (!this.isEmpty(hour)) { + if (this.mHour12) { + let dayPeriod = this.getDayPeriodValue(); + if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { + hour += this.mMaxHour; + } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { + hour = 0; + } + } + } + + let minute = this.getFieldValue(this.mMinuteField); + let second = this.getFieldValue(this.mSecondField); + let millisecond = this.getFieldValue(this.mMillisecField); + + let time = { hour, minute, second, millisecond }; + + this.log("getCurrentValue: " + JSON.stringify(time)); + return time; + } +}; diff --git a/toolkit/content/widgets/dialog.js b/toolkit/content/widgets/dialog.js new file mode 100644 index 0000000000..e6d70934eb --- /dev/null +++ b/toolkit/content/widgets/dialog.js @@ -0,0 +1,517 @@ +/* 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 { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + class MozDialog extends MozXULElement { + constructor() { + super(); + + this.attachShadow({ mode: "open" }); + + document.addEventListener( + "keypress", + event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + this._hitEnter(event); + } else if ( + event.keyCode == KeyEvent.DOM_VK_ESCAPE && + !event.defaultPrevented + ) { + this.cancelDialog(); + } + }, + { mozSystemGroup: true } + ); + + if (AppConstants.platform == "macosx") { + document.addEventListener( + "keypress", + event => { + if (event.key == "." && event.metaKey) { + this.cancelDialog(); + } + }, + true + ); + } else { + this.addEventListener("focus", this, true); + this.shadowRoot.addEventListener("focus", this, true); + } + + // listen for when window is closed via native close buttons + window.addEventListener("close", event => { + if (!this.cancelDialog()) { + event.preventDefault(); + } + }); + + // for things that we need to initialize after onload fires + window.addEventListener("load", event => this.postLoadInit(event)); + } + + static get observedAttributes() { + return super.observedAttributes.concat("subdialog"); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "subdialog") { + console.assert( + newValue, + `Turning off subdialog style is not supported` + ); + if (this.isConnectedAndReady && !oldValue && newValue) { + this.shadowRoot.appendChild( + MozXULElement.parseXULToFragment(this.inContentStyle) + ); + } + return; + } + super.attributeChangedCallback(name, oldValue, newValue); + } + + static get inheritedAttributes() { + return { + ".dialog-button-box": + "pack=buttonpack,align=buttonalign,dir=buttondir,orient=buttonorient", + "[dlgtype='accept']": "disabled=buttondisabledaccept", + }; + } + + get inContentStyle() { + return ` + + `; + } + + get _markup() { + let buttons = AppConstants.XP_UNIX + ? ` + + + + + + + `; + } + + static get entities() { + return ["chrome://global/locale/notification.dtd"]; + } + + slotContents() { + if (this._hasSlotted) { + return; + } + this._hasSlotted = true; + this.appendChild(this.constructor.fragment); + + this.button = this.querySelector(".popup-notification-primary-button"); + this.secondaryButton = this.querySelector( + ".popup-notification-secondary-button" + ); + this.checkbox = this.querySelector(".popup-notification-checkbox"); + this.closebutton = this.querySelector(".popup-notification-closebutton"); + this.menubutton = this.querySelector(".popup-notification-dropmarker"); + this.menupopup = this.menubutton.querySelector("menupopup"); + + let popupnotificationfooter = this.querySelector( + "popupnotificationfooter" + ); + if (popupnotificationfooter) { + this.querySelector(".popup-notification-footer-container").append( + popupnotificationfooter + ); + } + + let popupnotificationheader = this.querySelector( + "popupnotificationheader" + ); + if (popupnotificationheader) { + this.querySelector(".popup-notification-header-container").append( + popupnotificationheader + ); + } + + for (let popupnotificationcontent of this.querySelectorAll( + "popupnotificationcontent" + )) { + this.appendNotificationContent(popupnotificationcontent); + } + + this.initializeAttributeInheritance(); + } + + appendNotificationContent(el) { + let nextSibling = this.querySelector( + ".popup-notification-body > .popup-notification-learnmore-link" + ); + nextSibling.before(el); + } + } + + customElements.define("popupnotification", MozPopupNotification); +} diff --git a/toolkit/content/widgets/radio.js b/toolkit/content/widgets/radio.js new file mode 100644 index 0000000000..6066241ed7 --- /dev/null +++ b/toolkit/content/widgets/radio.js @@ -0,0 +1,573 @@ +/* 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 connectedCallback fires first. But in (2), + // the children 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 gets added to an already connected radiogroup. + * This can happen due to DOM getting appended after the is created. + * When this happens, reinitialize the UI if necessary to make sure the state is + * consistent. + * + * @param {DOMNode} child + * The element that got added + */ + radioAttached(child) { + if (this.ignoreRadioChildConstruction) { + return; + } + if (!this._radioChildren || !this._radioChildren.includes(child)) { + this.init(); + } + } + + /** + * Called when a new gets removed from a radio group. + * + * @param {DOMNode} child + * The 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; + } + } + return val; + } + + 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; + } + return 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]; + return 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); + } + } + } + + return val; + } + + 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"); + } + } + return val; + } + + 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 ` + + + + + + `; + } + + 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); +})(); diff --git a/toolkit/content/widgets/richlistbox.js b/toolkit/content/widgets/richlistbox.js new file mode 100644 index 0000000000..e52c957a9b --- /dev/null +++ b/toolkit/content/widgets/richlistbox.js @@ -0,0 +1,1023 @@ +/* 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.import( + "resource://gre/modules/AppConstants.jsm" + ); + + /** + * 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]); + } + return val; + } + 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); + return val; + } + get selType() { + return this.getAttribute("seltype"); + } + + // nsIDOMXULSelectControlElement + set currentItem(val) { + if (this._currentItem == val) { + return val; + } + + 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"); + } + } + + return val; + } + 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) { + 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) { + this.ensureIndexIsVisible(this.getIndexOfItem(newItem)); + if (aIsSelectingRange) { + this.selectItemRange(null, newItem); + } else if (aIsSelecting) { + this.selectItem(newItem); + } + + this.currentItem = newItem; + } + } + + _moveByOffsetFromUserEvent(aOffset, aEvent) { + if (!aEvent.defaultPrevented) { + this._userSelecting = true; + this.moveByOffset(aOffset, !aEvent.ctrlKey, aEvent.shiftKey); + 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; + }); + } + + /** + * 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"); + } + return val; + } + + get searchLabel() { + return this.hasAttribute("searchlabel") + ? this.getAttribute("searchlabel") + : this.label; + } + /** + * nsIDOMXULSelectControlItemElement + */ + set value(val) { + this.setAttribute("value", val); + return val; + } + + get value() { + return this.getAttribute("value"); + } + + /** + * nsIDOMXULSelectControlItemElement + */ + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + + return 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"); + } + return val; + } + + get current() { + return this.getAttribute("current") == "true"; + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("richlistitem", MozElements.MozRichlistitem); +} diff --git a/toolkit/content/widgets/search-textbox.js b/toolkit/content/widgets/search-textbox.js new file mode 100644 index 0000000000..6a9ad2f261 --- /dev/null +++ b/toolkit/content/widgets/search-textbox.js @@ -0,0 +1,264 @@ +/* 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 MozSearchTextbox extends MozXULElement { + constructor() { + super(); + + this.inputField = document.createElement("input"); + + const METHODS = [ + "focus", + "blur", + "select", + "setUserInput", + "setSelectionRange", + ]; + for (const method of METHODS) { + this[method] = (...args) => this.inputField[method](...args); + } + + const READ_WRITE_PROPERTIES = [ + "defaultValue", + "placeholder", + "readOnly", + "size", + "selectionStart", + "selectionEnd", + ]; + for (const property of READ_WRITE_PROPERTIES) { + Object.defineProperty(this, property, { + enumerable: true, + get() { + return this.inputField[property]; + }, + set(val) { + return (this.inputField[property] = val); + }, + }); + } + + this.attachShadow({ mode: "open" }); + this.addEventListener("input", this); + this.addEventListener("keypress", this); + this.addEventListener("mousedown", this); + } + + static get inheritedAttributes() { + return { + input: + "value,maxlength,disabled,size,readonly,placeholder,tabindex,accesskey,mozactionhint,spellcheck", + ".textbox-search-icon": "label=searchbuttonlabel,disabled", + ".textbox-search-clear": "disabled", + }; + } + + static get markup() { + // TODO: Bug 1534799 - Convert string to Fluent and use manual DOM construction + return ` + + `; + } + + static get entities() { + return ["chrome://global/locale/textcontext.dtd"]; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.connected) { + return; + } + this.connected = true; + this.textContent = ""; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "chrome://global/skin/search-textbox.css"; + + const textboxSign = document.createXULElement("image"); + textboxSign.className = "textbox-search-sign"; + textboxSign.part = "search-sign"; + + const input = this.inputField; + input.setAttribute("mozactionhint", "search"); + input.addEventListener("focus", this); + input.addEventListener("blur", this); + + const searchBtn = (this._searchButtonIcon = document.createXULElement( + "image" + )); + searchBtn.className = "textbox-search-icon"; + searchBtn.addEventListener("click", e => this._iconClick(e)); + + let clearBtn = this.constructor.fragment; + clearBtn = this._searchClearIcon = clearBtn.querySelector( + ".textbox-search-clear" + ); + clearBtn.addEventListener("click", () => this._clearSearch()); + + const deck = (this._searchIcons = document.createXULElement("deck")); + deck.className = "textbox-search-icons"; + deck.append(searchBtn, clearBtn); + this.shadowRoot.append(stylesheet, textboxSign, input, deck); + + this._timer = null; + + // Ensure the button state is up to date: + // eslint-disable-next-line no-self-assign + this.searchButton = this.searchButton; + + this.initializeAttributeInheritance(); + } + + set timeout(val) { + this.setAttribute("timeout", val); + return val; + } + + get timeout() { + return parseInt(this.getAttribute("timeout")) || 500; + } + + set searchButton(val) { + if (val) { + this.setAttribute("searchbutton", "true"); + this.removeAttribute("aria-autocomplete"); + this._searchButtonIcon.setAttribute("role", "button"); + } else { + this.removeAttribute("searchbutton"); + this.setAttribute("aria-autocomplete", "list"); + this._searchButtonIcon.setAttribute("role", "none"); + } + return val; + } + + get searchButton() { + return this.getAttribute("searchbutton") == "true"; + } + + set value(val) { + this.inputField.value = val; + + if (val) { + this._searchIcons.selectedIndex = this.searchButton ? 0 : 1; + } else { + this._searchIcons.selectedIndex = 0; + } + + if (this._timer) { + clearTimeout(this._timer); + } + return val; + } + + get value() { + return this.inputField.value; + } + + get editor() { + return this.inputField.editor; + } + + set disabled(val) { + this.inputField.disabled = val; + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + return val; + } + + get disabled() { + return this.inputField.disabled; + } + + on_blur() { + this.removeAttribute("focused"); + } + + on_focus() { + this.setAttribute("focused", "true"); + } + + on_input() { + if (this.searchButton) { + this._searchIcons.selectedIndex = 0; + return; + } + if (this._timer) { + clearTimeout(this._timer); + } + this._timer = + this.timeout && setTimeout(this._fireCommand, this.timeout, this); + this._searchIcons.selectedIndex = this.value ? 1 : 0; + } + + on_keypress(event) { + switch (event.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + if (this._clearSearch()) { + event.preventDefault(); + event.stopPropagation(); + } + break; + case KeyEvent.DOM_VK_RETURN: + this._enterSearch(); + event.preventDefault(); + event.stopPropagation(); + break; + } + } + + on_mousedown(event) { + if (!this.hasAttribute("focused")) { + this.setSelectionRange(0, 0); + this.focus(); + } + } + + _fireCommand(me) { + if (me._timer) { + clearTimeout(me._timer); + } + me._timer = null; + me.doCommand(); + } + + _iconClick() { + if (this.searchButton) { + this._enterSearch(); + } else { + this.focus(); + } + } + + _enterSearch() { + if (this.disabled) { + return; + } + if (this.searchButton && this.value && !this.readOnly) { + this._searchIcons.selectedIndex = 1; + } + this._fireCommand(this); + } + + _clearSearch() { + if (!this.disabled && !this.readOnly && this.value) { + this.value = ""; + this._fireCommand(this); + this._searchIcons.selectedIndex = 0; + return true; + } + return false; + } + } + + customElements.define("search-textbox", MozSearchTextbox); +} diff --git a/toolkit/content/widgets/spinner.js b/toolkit/content/widgets/spinner.js new file mode 100644 index 0000000000..56bdbfd8a7 --- /dev/null +++ b/toolkit/content/widgets/spinner.js @@ -0,0 +1,538 @@ +/* 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"; + +/* + * The spinner is responsible for displaying the items, and does + * not care what the values represent. The setValue function is called + * when it detects a change in value triggered by scroll event. + * Supports scrolling, clicking on up or down, clicking on item, and + * dragging. + */ + +function Spinner(props, context) { + this.context = context; + this._init(props); +} + +{ + const ITEM_HEIGHT = 2.5, + VIEWPORT_SIZE = 7, + VIEWPORT_COUNT = 5; + + Spinner.prototype = { + /** + * Initializes a spinner. Set the default states and properties, cache + * element references, create the HTML markup, and add event listeners. + * + * @param {Object} props [Properties passed in from parent] + * { + * {Function} setValue: Takes a value and set the state to + * the parent component. + * {Function} getDisplayString: Takes a value, and output it + * as localized strings. + * {Number} viewportSize [optional]: Number of items in a + * viewport. + * {Boolean} hideButtons [optional]: Hide up & down buttons + * {Number} rootFontSize [optional]: Used to support zoom in/out + * } + */ + _init(props) { + const { + id, + setValue, + getDisplayString, + hideButtons, + rootFontSize = 10, + } = props; + + const spinnerTemplate = document.getElementById("spinner-template"); + const spinnerElement = document.importNode(spinnerTemplate.content, true); + + // Make sure viewportSize is an odd number because we want to have the selected + // item in the center. If it's an even number, use the default size instead. + const viewportSize = + props.viewportSize % 2 ? props.viewportSize : VIEWPORT_SIZE; + + this.state = { + items: [], + isScrolling: false, + }; + this.props = { + setValue, + getDisplayString, + viewportSize, + rootFontSize, + // We can assume that the viewportSize is an odd number. Calculate how many + // items we need to insert on top of the spinner so that the selected is at + // the center. Ex: if viewportSize is 5, we need 2 items on top. + viewportTopOffset: (viewportSize - 1) / 2, + }; + this.elements = { + container: spinnerElement.querySelector(".spinner-container"), + spinner: spinnerElement.querySelector(".spinner"), + up: spinnerElement.querySelector(".up"), + down: spinnerElement.querySelector(".down"), + itemsViewElements: [], + }; + + this.elements.spinner.style.height = ITEM_HEIGHT * viewportSize + "rem"; + + if (id) { + this.elements.container.id = id; + } + if (hideButtons) { + this.elements.container.classList.add("hide-buttons"); + } + + this.context.appendChild(spinnerElement); + this._attachEventListeners(); + }, + + /** + * Only the parent component calls setState on the spinner. + * It checks if the items have changed and updates the spinner. + * If only the value has changed, smooth scrolls to the new value. + * + * @param {Object} newState [The new spinner state] + * { + * {Number/String} value: The centered value + * {Array} items: The list of items for display + * {Boolean} isInfiniteScroll: Whether or not the spinner should + * have infinite scroll capability + * {Boolean} isValueSet: true if user has selected a value + * } + */ + setState(newState) { + const { value, items } = this.state; + const { + value: newValue, + items: newItems, + isValueSet, + isInvalid, + smoothScroll = true, + } = newState; + + if (this._isArrayDiff(newItems, items)) { + this.state = Object.assign(this.state, newState); + this._updateItems(); + this._scrollTo(newValue, true); + } else if (newValue != value) { + this.state = Object.assign(this.state, newState); + if (smoothScroll) { + this._smoothScrollTo(newValue, true); + } else { + this._scrollTo(newValue, true); + } + } + + if (isValueSet && !isInvalid) { + this._updateSelection(); + } else { + this._removeSelection(); + } + }, + + /** + * Whenever scroll event is detected: + * - Update the index state + * - If the value has changed, update the [value] state and call [setValue] + * - If infinite scrolling is on, reset the scrolling position if necessary + */ + _onScroll() { + const { items, itemsView, isInfiniteScroll } = this.state; + const { viewportSize, viewportTopOffset } = this.props; + const { spinner } = this.elements; + + this.state.index = this._getIndexByOffset(spinner.scrollTop); + + const value = itemsView[this.state.index + viewportTopOffset].value; + + // Call setValue if value has changed + if (this.state.value != value) { + this.state.value = value; + this.props.setValue(value); + } + + // Do infinite scroll when items length is bigger or equal to viewport + // and isInfiniteScroll is not false. + if (items.length >= viewportSize && isInfiniteScroll) { + // If the scroll position is near the top or bottom, jump back to the middle + // so user can keep scrolling up or down. + if ( + this.state.index < viewportSize || + this.state.index > itemsView.length - viewportSize + ) { + this._scrollTo(this.state.value, true); + } + } + + this.elements.spinner.classList.add("scrolling"); + }, + + /** + * Remove the "scrolling" state on scrollend. + */ + _onScrollend() { + this.elements.spinner.classList.remove("scrolling"); + }, + + /** + * Updates the spinner items to the current states. + */ + _updateItems() { + const { viewportSize, viewportTopOffset } = this.props; + const { items, isInfiniteScroll } = this.state; + + // Prepends null elements so the selected value is centered in spinner + let itemsView = new Array(viewportTopOffset).fill({}).concat(items); + + if (items.length >= viewportSize && isInfiniteScroll) { + // To achieve infinite scroll, we move the scroll position back to the + // center when it is near the top or bottom. The scroll momentum could + // be lost in the process, so to minimize that, we need at least 2 sets + // of items to act as buffer: one for the top and one for the bottom. + // But if the number of items is small ( < viewportSize * viewport count) + // we should add more sets. + let count = + Math.ceil((viewportSize * VIEWPORT_COUNT) / items.length) * 2; + for (let i = 0; i < count; i += 1) { + itemsView.push(...items); + } + } + + // Reuse existing DOM nodes when possible. Create or remove + // nodes based on how big itemsView is. + this._prepareNodes(itemsView.length, this.elements.spinner); + // Once DOM nodes are ready, set display strings using textContent + this._setDisplayStringAndClass( + itemsView, + this.elements.itemsViewElements + ); + + this.state.itemsView = itemsView; + }, + + /** + * Make sure the number or child elements is the same as length + * and keep the elements' references for updating textContent + * + * @param {Number} length [The number of child elements] + * @param {DOMElement} parent [The parent element reference] + */ + _prepareNodes(length, parent) { + const diff = length - parent.childElementCount; + + if (!diff) { + return; + } + + if (diff > 0) { + // Add more elements if length is greater than current + let frag = document.createDocumentFragment(); + + // Remove margin bottom on the last element before appending + if (parent.lastChild) { + parent.lastChild.style.marginBottom = ""; + } + + for (let i = 0; i < diff; i++) { + let el = document.createElement("div"); + frag.appendChild(el); + this.elements.itemsViewElements.push(el); + } + parent.appendChild(frag); + } else if (diff < 0) { + // Remove elements if length is less than current + for (let i = 0; i < Math.abs(diff); i++) { + parent.removeChild(parent.lastChild); + } + this.elements.itemsViewElements.splice(diff); + } + + parent.lastChild.style.marginBottom = + ITEM_HEIGHT * this.props.viewportTopOffset + "rem"; + }, + + /** + * Set the display string and class name to the elements. + * + * @param {Array} items + * [{ + * {Number/String} value: The value in its original form + * {Boolean} enabled: Whether or not the item is enabled + * }] + * @param {Array} elements + */ + _setDisplayStringAndClass(items, elements) { + const { getDisplayString } = this.props; + + items.forEach((item, index) => { + elements[index].textContent = + item.value != undefined ? getDisplayString(item.value) : ""; + elements[index].className = item.enabled ? "" : "disabled"; + }); + }, + + /** + * Attach event listeners to the spinner and buttons. + */ + _attachEventListeners() { + const { spinner, container } = this.elements; + + spinner.addEventListener("scroll", this, { passive: true }); + spinner.addEventListener("scrollend", this, { passive: true }); + container.addEventListener("mouseup", this, { passive: true }); + container.addEventListener("mousedown", this, { passive: true }); + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + const { mouseState = {}, index, itemsView } = this.state; + const { viewportTopOffset, setValue } = this.props; + const { spinner, up, down } = this.elements; + + switch (event.type) { + case "scroll": { + this._onScroll(); + break; + } + case "scrollend": { + this._onScrollend(); + break; + } + case "mousedown": { + this.state.mouseState = { + down: true, + layerX: event.layerX, + layerY: event.layerY, + }; + if (event.target == up) { + // An "active" class is needed to simulate :active pseudo-class + // because element is not focused. + event.target.classList.add("active"); + this._smoothScrollToIndex(index - 1); + } + if (event.target == down) { + event.target.classList.add("active"); + this._smoothScrollToIndex(index + 1); + } + if (event.target.parentNode == spinner) { + // Listen to dragging events + spinner.addEventListener("mousemove", this, { passive: true }); + spinner.addEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mouseup": { + this.state.mouseState.down = false; + if (event.target == up || event.target == down) { + event.target.classList.remove("active"); + } + if (event.target.parentNode == spinner) { + // Check if user clicks or drags, scroll to the item if clicked, + // otherwise get the current index and smooth scroll there. + if ( + event.layerX == mouseState.layerX && + event.layerY == mouseState.layerY + ) { + const newIndex = + this._getIndexByOffset(event.target.offsetTop) - + viewportTopOffset; + if (index == newIndex) { + // Set value manually if the clicked element is already centered. + // This happens when the picker first opens, and user pick the + // default value. + setValue(itemsView[index + viewportTopOffset].value); + } else { + this._smoothScrollToIndex(newIndex); + } + } else { + this._smoothScrollToIndex( + this._getIndexByOffset(spinner.scrollTop) + ); + } + // Stop listening to dragging + spinner.removeEventListener("mousemove", this, { passive: true }); + spinner.removeEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mouseleave": { + if (event.target == spinner) { + // Stop listening to drag event if mouse is out of the spinner + this._smoothScrollToIndex( + this._getIndexByOffset(spinner.scrollTop) + ); + spinner.removeEventListener("mousemove", this, { passive: true }); + spinner.removeEventListener("mouseleave", this, { passive: true }); + } + break; + } + case "mousemove": { + // Change spinner position on drag + spinner.scrollTop -= event.movementY; + break; + } + } + }, + + /** + * Find the index by offset + * @param {Number} offset: Offset value in pixel. + * @return {Number} Index number + */ + _getIndexByOffset(offset) { + return Math.round(offset / (ITEM_HEIGHT * this.props.rootFontSize)); + }, + + /** + * Find the index of a value that is the closest to the current position. + * If centering is true, find the index closest to the center. + * + * @param {Number/String} value: The value to find + * @param {Boolean} centering: Whether or not to find the value closest to center + * @return {Number} index of the value, returns -1 if value is not found + */ + _getScrollIndex(value, centering) { + const { itemsView } = this.state; + const { viewportTopOffset } = this.props; + + // If index doesn't exist, or centering is true, start from the middle point + let currentIndex = + centering || this.state.index == undefined + ? Math.round((itemsView.length - viewportTopOffset) / 2) + : this.state.index; + let closestIndex = itemsView.length; + let indexes = []; + let diff = closestIndex; + let isValueFound = false; + + // Find indexes of items match the value + itemsView.forEach((item, index) => { + if (item.value == value) { + indexes.push(index); + } + }); + + // Find the index closest to currentIndex + indexes.forEach(index => { + let d = Math.abs(index - currentIndex); + if (d < diff) { + diff = d; + closestIndex = index; + isValueFound = true; + } + }); + + return isValueFound ? closestIndex - viewportTopOffset : -1; + }, + + /** + * Scroll to a value. + * + * @param {Number/String} value: Value to scroll to + * @param {Boolean} centering: Whether or not to scroll to center location + */ + _scrollTo(value, centering) { + const index = this._getScrollIndex(value, centering); + // Do nothing if the value is not found + if (index > -1) { + this.state.index = index; + this.elements.spinner.scrollTop = + this.state.index * ITEM_HEIGHT * this.props.rootFontSize; + } + }, + + /** + * Smooth scroll to a value. + * + * @param {Number/String} value: Value to scroll to + */ + _smoothScrollTo(value) { + const index = this._getScrollIndex(value); + // Do nothing if the value is not found + if (index > -1) { + this.state.index = index; + this._smoothScrollToIndex(this.state.index); + } + }, + + /** + * Smooth scroll to a value based on the index + * + * @param {Number} index: Index number + */ + _smoothScrollToIndex(index) { + const element = this.elements.spinner.children[index]; + if (element) { + element.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + } + }, + + /** + * Update the selection state. + */ + _updateSelection() { + const { itemsViewElements, selected } = this.elements; + const { itemsView, index } = this.state; + const { viewportTopOffset } = this.props; + const currentItemIndex = index + viewportTopOffset; + + if (selected && selected != itemsViewElements[currentItemIndex]) { + this._removeSelection(); + } + + this.elements.selected = itemsViewElements[currentItemIndex]; + if (itemsView[currentItemIndex] && itemsView[currentItemIndex].enabled) { + this.elements.selected.classList.add("selection"); + } + }, + + /** + * Remove selection if selected exists and different from current + */ + _removeSelection() { + const { selected } = this.elements; + if (selected) { + selected.classList.remove("selection"); + } + }, + + /** + * Compares arrays of objects. It assumes the structure is an array of + * objects, and objects in a and b have the same number of properties. + * + * @param {Array} a + * @param {Array} b + * @return {Boolean} Returns true if a and b are different + */ + _isArrayDiff(a, b) { + // Check reference first, exit early if reference is the same. + if (a == b) { + return false; + } + + if (a.length != b.length) { + return true; + } + + for (let i = 0; i < a.length; i++) { + for (let prop in a[i]) { + if (a[i][prop] != b[i][prop]) { + return true; + } + } + } + return false; + }, + }; +} diff --git a/toolkit/content/widgets/stringbundle.js b/toolkit/content/widgets/stringbundle.js new file mode 100644 index 0000000000..c780950f16 --- /dev/null +++ b/toolkit/content/widgets/stringbundle.js @@ -0,0 +1,78 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + class MozStringbundle extends MozXULElement { + get stringBundle() { + if (!this._bundle) { + try { + this._bundle = Services.strings.createBundle(this.src); + } catch (e) { + dump("Failed to get stringbundle:\n"); + dump(e + "\n"); + } + } + return this._bundle; + } + + set src(val) { + this._bundle = null; + this.setAttribute("src", val); + return val; + } + + get src() { + return this.getAttribute("src"); + } + + get strings() { + // Note: this is a sucky method name! Should be: + // readonly attribute nsISimpleEnumerator strings; + return this.stringBundle.getSimpleEnumeration(); + } + + getString(aStringKey) { + try { + return this.stringBundle.GetStringFromName(aStringKey); + } catch (e) { + dump( + "*** Failed to get string " + + aStringKey + + " in bundle: " + + this.src + + "\n" + ); + throw e; + } + } + + getFormattedString(aStringKey, aStringsArray) { + try { + return this.stringBundle.formatStringFromName( + aStringKey, + aStringsArray + ); + } catch (e) { + dump( + "*** Failed to format string " + + aStringKey + + " in bundle: " + + this.src + + "\n" + ); + throw e; + } + } + } + + customElements.define("stringbundle", MozStringbundle); +} diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js new file mode 100644 index 0000000000..f09ac29995 --- /dev/null +++ b/toolkit/content/widgets/tabbox.js @@ -0,0 +1,831 @@ +/* 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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + let imports = {}; + ChromeUtils.defineModuleGetter( + imports, + "ShortcutUtils", + "resource://gre/modules/ShortcutUtils.jsm" + ); + + class MozTabbox extends MozXULElement { + constructor() { + super(); + this._handleMetaAltArrows = AppConstants.platform == "macosx"; + this.disconnectedCallback = this.disconnectedCallback.bind(this); + } + + connectedCallback() { + Services.els.addSystemEventListener(document, "keydown", this, false); + window.addEventListener("unload", this.disconnectedCallback, { + once: true, + }); + } + + disconnectedCallback() { + window.removeEventListener("unload", this.disconnectedCallback); + Services.els.removeSystemEventListener(document, "keydown", this, false); + } + + set handleCtrlTab(val) { + this.setAttribute("handleCtrlTab", val); + return val; + } + + get handleCtrlTab() { + return this.getAttribute("handleCtrlTab") != "false"; + } + + get tabs() { + if (this.hasAttribute("tabcontainer")) { + return document.getElementById(this.getAttribute("tabcontainer")); + } + return this.getElementsByTagNameNS( + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + "tabs" + ).item(0); + } + + get tabpanels() { + return this.getElementsByTagNameNS( + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", + "tabpanels" + ).item(0); + } + + set selectedIndex(val) { + let tabs = this.tabs; + if (tabs) { + tabs.selectedIndex = val; + } + this.setAttribute("selectedIndex", val); + return val; + } + + get selectedIndex() { + let tabs = this.tabs; + return tabs ? tabs.selectedIndex : -1; + } + + set selectedTab(val) { + if (val) { + let tabs = this.tabs; + if (tabs) { + tabs.selectedItem = val; + } + } + return val; + } + + get selectedTab() { + let tabs = this.tabs; + return tabs && tabs.selectedItem; + } + + set selectedPanel(val) { + if (val) { + let tabpanels = this.tabpanels; + if (tabpanels) { + tabpanels.selectedPanel = val; + } + } + return val; + } + + get selectedPanel() { + let tabpanels = this.tabpanels; + return tabpanels && tabpanels.selectedPanel; + } + + handleEvent(event) { + if (!event.isTrusted) { + // Don't let untrusted events mess with tabs. + return; + } + + // Skip this only if something has explicitly cancelled it. + if (event.defaultCancelled) { + return; + } + + // Don't check if the event was already consumed because tab + // navigation should always work for better user experience. + + const { ShortcutUtils } = imports; + + switch (ShortcutUtils.getSystemActionForEvent(event)) { + case ShortcutUtils.CYCLE_TABS: + Services.telemetry.keyedScalarAdd( + "browser.ui.interaction.keyboard", + "ctrl-tab", + 1 + ); + Services.prefs.setBoolPref( + "browser.engagement.ctrlTab.has-used", + true + ); + if (this.tabs && this.handleCtrlTab) { + this.tabs.advanceSelectedTab(event.shiftKey ? -1 : 1, true); + event.preventDefault(); + } + break; + case ShortcutUtils.PREVIOUS_TAB: + if (this.tabs) { + this.tabs.advanceSelectedTab(-1, true); + event.preventDefault(); + } + break; + case ShortcutUtils.NEXT_TAB: + if (this.tabs) { + this.tabs.advanceSelectedTab(1, true); + event.preventDefault(); + } + break; + } + } + } + + customElements.define("tabbox", MozTabbox); + + class MozTabpanels extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this._tabbox = null; + this._selectedPanel = this.children.item(this.selectedIndex); + } + + get tabbox() { + // Memoize the result rather than replacing this getter, so that + // it can be reset if the parent changes. + if (this._tabbox) { + return this._tabbox; + } + + let parent = this.parentNode; + while (parent) { + if (parent.localName == "tabbox") { + break; + } + parent = parent.parentNode; + } + + return (this._tabbox = parent); + } + + set selectedIndex(val) { + if (val < 0 || val >= this.children.length) { + return val; + } + + let panel = this._selectedPanel; + this._selectedPanel = this.children[val]; + + if (this.getAttribute("async") != "true") { + this.setAttribute("selectedIndex", val); + } + + if (this._selectedPanel != panel) { + let event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + } + return val; + } + + get selectedIndex() { + let indexStr = this.getAttribute("selectedIndex"); + return indexStr ? parseInt(indexStr) : -1; + } + + set selectedPanel(val) { + let selectedIndex = -1; + for ( + let panel = val; + panel != null; + panel = panel.previousElementSibling + ) { + ++selectedIndex; + } + this.selectedIndex = selectedIndex; + return val; + } + + get selectedPanel() { + return this._selectedPanel; + } + + /** + * nsIDOMXULRelatedElement + */ + getRelatedElement(aTabPanelElm) { + if (!aTabPanelElm) { + return null; + } + + let tabboxElm = this.tabbox; + if (!tabboxElm) { + return null; + } + + let tabsElm = tabboxElm.tabs; + if (!tabsElm) { + return null; + } + + // Return tab element having 'linkedpanel' attribute equal to the id + // of the tab panel or the same index as the tab panel element. + let tabpanelIdx = Array.prototype.indexOf.call( + this.children, + aTabPanelElm + ); + if (tabpanelIdx == -1) { + return null; + } + + let tabElms = tabsElm.allTabs; + let tabElmFromIndex = tabElms[tabpanelIdx]; + + let tabpanelId = aTabPanelElm.id; + if (tabpanelId) { + for (let idx = 0; idx < tabElms.length; idx++) { + let tabElm = tabElms[idx]; + if (tabElm.linkedPanel == tabpanelId) { + return tabElm; + } + } + } + + return tabElmFromIndex; + } + } + + MozXULElement.implementCustomInterface(MozTabpanels, [ + Ci.nsIDOMXULRelatedElement, + ]); + customElements.define("tabpanels", MozTabpanels); + + MozElements.MozTab = class MozTab extends MozElements.BaseText { + static get markup() { + return ` + + + + + `; + } + + constructor() { + super(); + + this.addEventListener("mousedown", this); + this.addEventListener("keydown", this); + + this.arrowKeysShouldWrap = AppConstants.platform == "macosx"; + } + + static get inheritedAttributes() { + return { + ".tab-middle": "align,dir,pack,orient,selected,visuallyselected", + ".tab-icon": "validate,src=image", + ".tab-text": "value=label,accesskey,crop,disabled", + }; + } + + connectedCallback() { + if (!this._initialized) { + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + this._initialized = true; + } + } + + on_mousedown(event) { + if (event.button != 0 || this.disabled) { + return; + } + + this.parentNode.ariaFocusedItem = null; + + if (this == this.parentNode.selectedItem) { + // This tab is already selected and we will fall + // through to mousedown behavior which sets focus on the current tab, + // Only a click on an already selected tab should focus the tab itself. + return; + } + + let stopwatchid = this.parentNode.getAttribute("stopwatchid"); + if (stopwatchid) { + TelemetryStopwatch.start(stopwatchid); + } + + // Call this before setting the 'ignorefocus' attribute because this + // will pass on focus if the formerly selected tab was focused as well. + this.closest("tabs")._selectNewTab(this); + + var isTabFocused = false; + try { + isTabFocused = document.commandDispatcher.focusedElement == this; + } catch (e) {} + + // Set '-moz-user-focus' to 'ignore' so that PostHandleEvent() can't + // focus the tab; we only want tabs to be focusable by the mouse if + // they are already focused. After a short timeout we'll reset + // '-moz-user-focus' so that tabs can be focused by keyboard again. + if (!isTabFocused) { + this.setAttribute("ignorefocus", "true"); + setTimeout(tab => tab.removeAttribute("ignorefocus"), 0, this); + } + + if (stopwatchid) { + TelemetryStopwatch.finish(stopwatchid); + } + } + + on_keydown(event) { + if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return; + } + switch (event.keyCode) { + case KeyEvent.DOM_VK_LEFT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? -1 : 1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_RIGHT: { + let direction = window.getComputedStyle(this.parentNode).direction; + this.container.advanceSelectedTab( + direction == "ltr" ? 1 : -1, + this.arrowKeysShouldWrap + ); + event.preventDefault(); + break; + } + + case KeyEvent.DOM_VK_UP: + this.container.advanceSelectedTab(-1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_DOWN: + this.container.advanceSelectedTab(1, this.arrowKeysShouldWrap); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_HOME: + this.container._selectNewTab(this.container.allTabs[0]); + event.preventDefault(); + break; + + case KeyEvent.DOM_VK_END: { + let { allTabs } = this.container; + this.container._selectNewTab(allTabs[allTabs.length - 1], -1); + event.preventDefault(); + break; + } + } + } + + set value(val) { + this.setAttribute("value", val); + return val; + } + + get value() { + return this.getAttribute("value"); + } + + get control() { + var parent = this.parentNode; + return parent.localName == "tabs" ? parent : null; + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + + set _selected(val) { + if (val) { + this.setAttribute("selected", "true"); + this.setAttribute("visuallyselected", "true"); + } else { + this.removeAttribute("selected"); + this.removeAttribute("visuallyselected"); + } + + return val; + } + + set linkedPanel(val) { + this.setAttribute("linkedpanel", val); + } + + get linkedPanel() { + return this.getAttribute("linkedpanel"); + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozTab, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("tab", MozElements.MozTab); + + class TabsBase extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("DOMMouseScroll", event => { + if (Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling")) { + if (event.detail > 0) { + this.advanceSelectedTab(1, false); + } else { + this.advanceSelectedTab(-1, false); + } + event.stopPropagation(); + } + }); + } + + // to be called from derived class connectedCallback + baseConnect() { + this._tabbox = null; + this.ACTIVE_DESCENDANT_ID = + "keyboard-focused-tab-" + Math.trunc(Math.random() * 1000000); + + if (!this.hasAttribute("orient")) { + this.setAttribute("orient", "horizontal"); + } + + if (this.tabbox && this.tabbox.hasAttribute("selectedIndex")) { + let selectedIndex = parseInt(this.tabbox.getAttribute("selectedIndex")); + this.selectedIndex = selectedIndex > 0 ? selectedIndex : 0; + return; + } + + let children = this.allTabs; + let 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; + } else { + this.selectedIndex = 0; + } + } + + /** + * nsIDOMXULSelectControlElement + */ + get itemCount() { + return this.allTabs.length; + } + + set value(val) { + this.setAttribute("value", val); + var children = this.allTabs; + for (var c = children.length - 1; c >= 0; c--) { + if (children[c].value == val) { + this.selectedIndex = c; + break; + } + } + return val; + } + + get value() { + return this.getAttribute("value"); + } + + get tabbox() { + if (!this._tabbox) { + // Memoize the result in a field rather than replacing this property, + // so that it can be reset along with the binding. + this._tabbox = this.closest("tabbox"); + } + + return this._tabbox; + } + + set selectedIndex(val) { + var tab = this.getItemAtIndex(val); + if (tab) { + for (let otherTab of this.allTabs) { + if (otherTab != tab && otherTab.selected) { + otherTab._selected = false; + } + } + tab._selected = true; + + this.setAttribute("value", tab.value); + + let linkedPanel = this.getRelatedElement(tab); + if (linkedPanel) { + this.tabbox.setAttribute("selectedIndex", val); + + // This will cause an onselect event to fire for the tabpanel + // element. + this.tabbox.tabpanels.selectedPanel = linkedPanel; + } + } + return val; + } + + get selectedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return i; + } + } + return -1; + } + + set selectedItem(val) { + if (val && !val.selected) { + // The selectedIndex setter ignores invalid values + // such as -1 if |val| isn't one of our child nodes. + this.selectedIndex = this.getIndexOfItem(val); + } + return val; + } + + get selectedItem() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].selected) { + return tabs[i]; + } + } + return null; + } + + get ariaFocusedIndex() { + const tabs = this.allTabs; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id == this.ACTIVE_DESCENDANT_ID) { + return i; + } + } + return -1; + } + + set ariaFocusedItem(val) { + let setNewItem = val && this.getIndexOfItem(val) != -1; + let clearExistingItem = this.ariaFocusedItem && (!val || setNewItem); + if (clearExistingItem) { + let ariaFocusedItem = this.ariaFocusedItem; + ariaFocusedItem.classList.remove("keyboard-focused-tab"); + ariaFocusedItem.id = ""; + this.selectedItem.removeAttribute("aria-activedescendant"); + let evt = new CustomEvent("AriaFocus"); + this.selectedItem.dispatchEvent(evt); + } + + if (setNewItem) { + this.ariaFocusedItem = null; + val.id = this.ACTIVE_DESCENDANT_ID; + val.classList.add("keyboard-focused-tab"); + this.selectedItem.setAttribute( + "aria-activedescendant", + this.ACTIVE_DESCENDANT_ID + ); + let evt = new CustomEvent("AriaFocus"); + val.dispatchEvent(evt); + } + + return val; + } + + get ariaFocusedItem() { + return document.getElementById(this.ACTIVE_DESCENDANT_ID); + } + + /** + * nsIDOMXULRelatedElement + */ + getRelatedElement(aTabElm) { + if (!aTabElm) { + return null; + } + + let tabboxElm = this.tabbox; + if (!tabboxElm) { + return null; + } + + let tabpanelsElm = tabboxElm.tabpanels; + if (!tabpanelsElm) { + return null; + } + + // Get linked tab panel by 'linkedpanel' attribute on the given tab + // element. + let linkedPanelId = aTabElm.linkedPanel; + if (linkedPanelId) { + return this.ownerDocument.getElementById(linkedPanelId); + } + + // otherwise linked tabpanel element has the same index as the given + // tab element. + let tabElmIdx = this.getIndexOfItem(aTabElm); + return tabpanelsElm.children[tabElmIdx]; + } + + getIndexOfItem(item) { + return Array.prototype.indexOf.call(this.allTabs, item); + } + + getItemAtIndex(index) { + return this.allTabs[index] || null; + } + + /** + * Find an adjacent tab. + * + * @param {Node} startTab A element to start searching from. + * @param {Number} opts.direction 1 to search forward, -1 to search backward. + * @param {Boolean} opts.wrap If true, wrap around if the search reaches + * the end (or beginning) of the tab strip. + * @param {Boolean} opts.startWithAdjacent + * If true (which is the default), start + * searching from the next tab after (or + * before) startTab. If false, startTab may + * be returned if it passes the filter. + * @param {Boolean} opts.advance If false, start searching with startTab. If + * true, start searching with an adjacent tab. + * @param {Function} opts.filter A function to select which tabs to return. + * + * @return {Node | null} The next element or, if none exists, null. + */ + findNextTab(startTab, opts = {}) { + let { + direction = 1, + wrap = false, + startWithAdjacent = true, + filter = tab => true, + } = opts; + + let tab = startTab; + if (!startWithAdjacent && filter(tab)) { + return tab; + } + + let children = this.allTabs; + let i = children.indexOf(tab); + if (i < 0) { + return null; + } + + while (true) { + i += direction; + if (wrap) { + if (i < 0) { + i = children.length - 1; + } else if (i >= children.length) { + i = 0; + } + } else if (i < 0 || i >= children.length) { + return null; + } + + tab = children[i]; + if (tab == startTab) { + return null; + } + if (filter(tab)) { + return tab; + } + } + } + + _selectNewTab(aNewTab, aFallbackDir, aWrap) { + this.ariaFocusedItem = null; + + aNewTab = this.findNextTab(aNewTab, { + direction: aFallbackDir, + wrap: aWrap, + startWithAdjacent: false, + filter: tab => + !tab.hidden && !tab.disabled && this._canAdvanceToTab(tab), + }); + + var isTabFocused = false; + try { + isTabFocused = + document.commandDispatcher.focusedElement == this.selectedItem; + } catch (e) {} + this.selectedItem = aNewTab; + if (isTabFocused) { + aNewTab.focus(); + } else if (this.getAttribute("setfocus") != "false") { + let selectedPanel = this.tabbox.selectedPanel; + document.commandDispatcher.advanceFocusIntoSubtree(selectedPanel); + + // Make sure that the focus doesn't move outside the tabbox + if (this.tabbox) { + try { + let el = document.commandDispatcher.focusedElement; + while (el && el != this.tabbox.tabpanels) { + if (el == this.tabbox || el == selectedPanel) { + return; + } + el = el.parentNode; + } + aNewTab.focus(); + } catch (e) {} + } + } + } + + _canAdvanceToTab(aTab) { + return true; + } + + advanceSelectedTab(aDir, aWrap) { + let startTab = this.ariaFocusedItem || this.selectedItem; + let newTab = this.findNextTab(startTab, { + direction: aDir, + wrap: aWrap, + }); + if (newTab && newTab != startTab) { + this._selectNewTab(newTab, aDir, aWrap); + } + } + + appendItem(label, value) { + var tab = document.createXULElement("tab"); + tab.setAttribute("label", label); + tab.setAttribute("value", value); + this.appendChild(tab); + return tab; + } + } + + MozXULElement.implementCustomInterface(TabsBase, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULRelatedElement, + ]); + + MozElements.TabsBase = TabsBase; + + class MozTabs extends TabsBase { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + let start = MozXULElement.parseXULToFragment( + `` + ); + this.insertBefore(start, this.firstChild); + + let end = MozXULElement.parseXULToFragment( + `` + ); + this.insertBefore(end, null); + + this.baseConnect(); + } + + // Accessor for tabs. This element has spacers as the first and + // last elements and s are everything in between. + get allTabs() { + let children = Array.from(this.children); + return children.splice(1, children.length - 2); + } + + appendChild(tab) { + // insert before the end spacer. + this.insertBefore(tab, this.lastChild); + } + } + + customElements.define("tabs", MozTabs); +} diff --git a/toolkit/content/widgets/text.js b/toolkit/content/widgets/text.js new file mode 100644 index 0000000000..402c08b284 --- /dev/null +++ b/toolkit/content/widgets/text.js @@ -0,0 +1,394 @@ +/* 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 { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + const MozXULTextElement = MozElements.MozElementMixin(XULTextElement); + + let gInsertSeparator = false; + let gAlwaysAppendAccessKey = false; + let gUnderlineAccesskey = + Services.prefs.getIntPref("ui.key.menuAccessKey") != 0; + if (gUnderlineAccesskey) { + try { + const nsIPrefLocalizedString = Ci.nsIPrefLocalizedString; + const prefNameInsertSeparator = + "intl.menuitems.insertseparatorbeforeaccesskeys"; + const prefNameAlwaysAppendAccessKey = + "intl.menuitems.alwaysappendaccesskeys"; + + let val = Services.prefs.getComplexValue( + prefNameInsertSeparator, + nsIPrefLocalizedString + ).data; + gInsertSeparator = val == "true"; + + val = Services.prefs.getComplexValue( + prefNameAlwaysAppendAccessKey, + nsIPrefLocalizedString + ).data; + gAlwaysAppendAccessKey = val == "true"; + } catch (e) { + gInsertSeparator = gAlwaysAppendAccessKey = true; + } + } + + class MozTextLabel extends MozXULTextElement { + constructor() { + super(); + this._lastFormattedAccessKey = null; + this.addEventListener("click", this._onClick); + } + + static get observedAttributes() { + return ["accesskey"]; + } + + set textContent(val) { + super.textContent = val; + this._lastFormattedAccessKey = null; + this.formatAccessKey(); + } + + get textContent() { + return super.textContent; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this.isConnectedAndReady || oldValue == newValue) { + return; + } + + // Note that this is only happening when "accesskey" attribute change: + this.formatAccessKey(); + } + + _onClick(event) { + let controlElement = this.labeledControlElement; + if (!controlElement || this.disabled) { + return; + } + controlElement.focus(); + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + if (controlElement.namespaceURI != XUL_NS) { + return; + } + + if ( + (controlElement.localName == "checkbox" || + controlElement.localName == "radio") && + controlElement.getAttribute("disabled") == "true" + ) { + return; + } + + if (controlElement.localName == "checkbox") { + controlElement.checked = !controlElement.checked; + } else if (controlElement.localName == "radio") { + controlElement.control.selectedItem = controlElement; + } + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.formatAccessKey(); + } + + set accessKey(val) { + this.setAttribute("accesskey", val); + var control = this.labeledControlElement; + if (control) { + control.setAttribute("accesskey", val); + } + } + + get accessKey() { + let accessKey = this.getAttribute("accesskey"); + return accessKey ? accessKey[0] : null; + } + + get labeledControlElement() { + let control = this.control; + return control ? document.getElementById(control) : null; + } + + set control(val) { + this.setAttribute("control", val); + } + + get control() { + return this.getAttribute("control"); + } + + // This is used to match the rendering of accesskeys from nsTextBoxFrame.cpp (i.e. when the + // label uses [value]). So this is just for when we have textContent. + formatAccessKey() { + // Skip doing any DOM manipulation whenever possible: + let accessKey = this.accessKey; + if ( + !gUnderlineAccesskey || + !this.isConnectedAndReady || + this._lastFormattedAccessKey == accessKey || + !this.textContent + ) { + return; + } + this._lastFormattedAccessKey = accessKey; + if (this.accessKeySpan) { + // Clear old accesskey + mergeElement(this.accessKeySpan); + this.accessKeySpan = null; + } + + if (this.hiddenColon) { + mergeElement(this.hiddenColon); + this.hiddenColon = null; + } + + if (this.accessKeyParens) { + this.accessKeyParens.remove(); + this.accessKeyParens = null; + } + + // If we used to have an accessKey but not anymore, we're done here + if (!accessKey) { + return; + } + + let labelText = this.textContent; + let accessKeyIndex = -1; + if (!gAlwaysAppendAccessKey) { + accessKeyIndex = labelText.indexOf(accessKey); + if (accessKeyIndex < 0) { + // Try again in upper case + accessKeyIndex = labelText + .toUpperCase() + .indexOf(accessKey.toUpperCase()); + } + } else if (labelText.endsWith(`(${accessKey.toUpperCase()})`)) { + accessKeyIndex = labelText.length - (1 + accessKey.length); // = index of accessKey. + } + + const HTML_NS = "http://www.w3.org/1999/xhtml"; + this.accessKeySpan = document.createElementNS(HTML_NS, "span"); + this.accessKeySpan.className = "accesskey"; + + // Note that if you change the following code, see the comment of + // nsTextBoxFrame::UpdateAccessTitle. + + // If accesskey is in the string, underline it: + if (accessKeyIndex >= 0) { + wrapChar(this, this.accessKeySpan, accessKeyIndex); + return; + } + + // If accesskey is not in string, append in parentheses + // If end is colon, we should insert before colon. + // i.e., "label:" -> "label(X):" + let colonHidden = false; + if (/:$/.test(labelText)) { + labelText = labelText.slice(0, -1); + this.hiddenColon = document.createElementNS(HTML_NS, "span"); + this.hiddenColon.className = "hiddenColon"; + this.hiddenColon.style.display = "none"; + // Hide the last colon by using span element. + // I.e., label: + wrapChar(this, this.hiddenColon, labelText.length); + colonHidden = true; + } + // If end is space(U+20), + // we should not add space before parentheses. + let endIsSpace = false; + if (/ $/.test(labelText)) { + endIsSpace = true; + } + + this.accessKeyParens = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "span" + ); + this.appendChild(this.accessKeyParens); + if (gInsertSeparator && !endIsSpace) { + this.accessKeyParens.textContent = " ("; + } else { + this.accessKeyParens.textContent = "("; + } + this.accessKeySpan.textContent = accessKey.toUpperCase(); + this.accessKeyParens.appendChild(this.accessKeySpan); + if (!colonHidden) { + this.accessKeyParens.appendChild(document.createTextNode(")")); + } else { + this.accessKeyParens.appendChild(document.createTextNode("):")); + } + } + } + + customElements.define("label", MozTextLabel); + + function mergeElement(element) { + // If the element has been removed already, return: + if (!element.isConnected) { + return; + } + if (element.previousSibling instanceof Text) { + element.previousSibling.appendData(element.textContent); + } else { + element.parentNode.insertBefore(element.firstChild, element); + } + element.remove(); + } + + function wrapChar(parent, element, index) { + let treeWalker = document.createNodeIterator( + parent, + NodeFilter.SHOW_TEXT, + null + ); + let node = treeWalker.nextNode(); + while (index >= node.length) { + index -= node.length; + node = treeWalker.nextNode(); + } + if (index) { + node = node.splitText(index); + } + + node.parentNode.insertBefore(element, node); + if (node.length > 1) { + node.splitText(1); + } + element.appendChild(node); + } + + class MozTextLink extends MozXULTextElement { + constructor() { + super(); + + this.addEventListener( + "click", + event => { + if (event.button == 0 || event.button == 1) { + this.open(event); + } + }, + true + ); + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + this.click(); + }); + } + + connectedCallback() { + this.classList.add("text-link"); + } + + set href(val) { + this.setAttribute("href", val); + return val; + } + + get href() { + return this.getAttribute("href"); + } + + open(aEvent) { + var href = this.href; + if (!href || this.disabled || aEvent.defaultPrevented) { + return; + } + + var uri = null; + try { + const nsISSM = Ci.nsIScriptSecurityManager; + const secMan = Cc["@mozilla.org/scriptsecuritymanager;1"].getService( + nsISSM + ); + + uri = Services.io.newURI(href); + + let principal; + if (this.getAttribute("useoriginprincipal") == "true") { + principal = this.nodePrincipal; + } else { + principal = secMan.createNullPrincipal({}); + } + try { + secMan.checkLoadURIWithPrincipal( + principal, + uri, + nsISSM.DISALLOW_INHERIT_PRINCIPAL + ); + } catch (ex) { + var msg = + "Error: Cannot open a " + + uri.scheme + + ": link using \ + the text-link binding."; + Cu.reportError(msg); + return; + } + + const cID = "@mozilla.org/uriloader/external-protocol-service;1"; + const nsIEPS = Ci.nsIExternalProtocolService; + var protocolSvc = Cc[cID].getService(nsIEPS); + + // if the scheme is not an exposed protocol, then opening this link + // should be deferred to the system's external protocol handler + if (!protocolSvc.isExposedProtocol(uri.scheme)) { + protocolSvc.loadURI(uri, principal); + aEvent.preventDefault(); + return; + } + } catch (ex) { + Cu.reportError(ex); + } + + aEvent.preventDefault(); + href = uri ? uri.spec : href; + + // Try handing off the link to the host application, e.g. for + // opening it in a tabbed browser. + var linkHandled = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + linkHandled.data = false; + let { shiftKey, ctrlKey, metaKey, altKey, button } = aEvent; + let data = { shiftKey, ctrlKey, metaKey, altKey, button, href }; + Services.obs.notifyObservers( + linkHandled, + "handle-xul-text-link", + JSON.stringify(data) + ); + if (linkHandled.data) { + return; + } + + // otherwise, fall back to opening the anchor directly + var win = window; + if (window.isChromeWindow) { + while (win.opener && !win.opener.closed) { + win = win.opener; + } + } + win.open(href, "_blank", "noopener"); + } + } + + customElements.define("text-link", MozTextLink, { extends: "label" }); +} diff --git a/toolkit/content/widgets/timekeeper.js b/toolkit/content/widgets/timekeeper.js new file mode 100644 index 0000000000..9e53ce602f --- /dev/null +++ b/toolkit/content/widgets/timekeeper.js @@ -0,0 +1,445 @@ +/* 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"; + +/** + * TimeKeeper keeps track of the time states. Given min, max, step, and + * format (12/24hr), TimeKeeper will determine the ranges of possible + * selections, and whether or not the current time state is out of range + * or off step. + * + * @param {Object} props + * { + * {Date} min + * {Date} max + * {Number} step + * {String} format: Either "12" or "24" + * } + */ +function TimeKeeper(props) { + this.props = props; + this.state = { time: new Date(0), ranges: {} }; +} + +{ + const DAY_PERIOD_IN_HOURS = 12, + SECOND_IN_MS = 1000, + MINUTE_IN_MS = 60000, + HOUR_IN_MS = 3600000, + DAY_PERIOD_IN_MS = 43200000, + DAY_IN_MS = 86400000, + TIME_FORMAT_24 = "24"; + + TimeKeeper.prototype = { + /** + * Getters for different time units. + * @return {Number} + */ + get hour() { + return this.state.time.getUTCHours(); + }, + get minute() { + return this.state.time.getUTCMinutes(); + }, + get second() { + return this.state.time.getUTCSeconds(); + }, + get millisecond() { + return this.state.time.getUTCMilliseconds(); + }, + get dayPeriod() { + // 0 stands for AM and 12 for PM + return this.state.time.getUTCHours() < DAY_PERIOD_IN_HOURS + ? 0 + : DAY_PERIOD_IN_HOURS; + }, + + /** + * Get the ranges of different time units. + * @return {Object} + * { + * {Array} dayPeriod + * {Array} hours + * {Array} minutes + * {Array} seconds + * {Array} milliseconds + * } + */ + get ranges() { + return this.state.ranges; + }, + + /** + * Set new time, check if the current state is valid, and set ranges. + * + * @param {Object} timeState: The new time + * { + * {Number} hour [optional] + * {Number} minute [optional] + * {Number} second [optional] + * {Number} millisecond [optional] + * } + */ + setState(timeState) { + const { min, max } = this.props; + const { hour, minute, second, millisecond } = timeState; + + if (hour != undefined) { + this.state.time.setUTCHours(hour); + } + if (minute != undefined) { + this.state.time.setUTCMinutes(minute); + } + if (second != undefined) { + this.state.time.setUTCSeconds(second); + } + if (millisecond != undefined) { + this.state.time.setUTCMilliseconds(millisecond); + } + + this.state.isOffStep = this._isOffStep(this.state.time); + this.state.isOutOfRange = this.state.time < min || this.state.time > max; + this.state.isInvalid = this.state.isOutOfRange || this.state.isOffStep; + + this._setRanges(this.dayPeriod, this.hour, this.minute, this.second); + }, + + /** + * Set day-period (AM/PM) + * @param {Number} dayPeriod: 0 as AM, 12 as PM + */ + setDayPeriod(dayPeriod) { + if (dayPeriod == this.dayPeriod) { + return; + } + + if (dayPeriod == 0) { + this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS }); + } else { + this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }); + } + }, + + /** + * Set hour in 24hr format (0 ~ 23) + * @param {Number} hour + */ + setHour(hour) { + this.setState({ hour }); + }, + + /** + * Set minute (0 ~ 59) + * @param {Number} minute + */ + setMinute(minute) { + this.setState({ minute }); + }, + + /** + * Set second (0 ~ 59) + * @param {Number} second + */ + setSecond(second) { + this.setState({ second }); + }, + + /** + * Set millisecond (0 ~ 999) + * @param {Number} millisecond + */ + setMillisecond(millisecond) { + this.setState({ millisecond }); + }, + + /** + * Calculate the range of possible choices for each time unit. + * Reuse the old result if the input has not changed. + * + * @param {Number} dayPeriod + * @param {Number} hour + * @param {Number} minute + * @param {Number} second + */ + _setRanges(dayPeriod, hour, minute, second) { + this.state.ranges.dayPeriod = + this.state.ranges.dayPeriod || this._getDayPeriodRange(); + + if (this.state.dayPeriod != dayPeriod) { + this.state.ranges.hours = this._getHoursRange(dayPeriod); + } + + if (this.state.hour != hour) { + this.state.ranges.minutes = this._getMinutesRange(hour); + } + + if (this.state.hour != hour || this.state.minute != minute) { + this.state.ranges.seconds = this._getSecondsRange(hour, minute); + } + + if ( + this.state.hour != hour || + this.state.minute != minute || + this.state.second != second + ) { + this.state.ranges.milliseconds = this._getMillisecondsRange( + hour, + minute, + second + ); + } + + // Save the time states for comparison. + this.state.dayPeriod = dayPeriod; + this.state.hour = hour; + this.state.minute = minute; + this.state.second = second; + }, + + /** + * Get the AM/PM range. Return an empty array if in 24hr mode. + * + * @return {Array} + */ + _getDayPeriodRange() { + if (this.props.format == TIME_FORMAT_24) { + return []; + } + + const start = 0; + const end = DAY_IN_MS - 1; + const minStep = DAY_PERIOD_IN_MS; + const formatter = time => + new Date(time).getUTCHours() < DAY_PERIOD_IN_HOURS + ? 0 + : DAY_PERIOD_IN_HOURS; + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the hours range. + * + * @param {Number} dayPeriod + * @return {Array} + */ + _getHoursRange(dayPeriod) { + const { format } = this.props; + const start = format == "24" ? 0 : dayPeriod * HOUR_IN_MS; + const end = format == "24" ? DAY_IN_MS - 1 : start + DAY_PERIOD_IN_MS - 1; + const minStep = HOUR_IN_MS; + const formatter = time => new Date(time).getUTCHours(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the minutes range + * + * @param {Number} hour + * @return {Array} + */ + _getMinutesRange(hour) { + const start = hour * HOUR_IN_MS; + const end = start + HOUR_IN_MS - 1; + const minStep = MINUTE_IN_MS; + const formatter = time => new Date(time).getUTCMinutes(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the seconds range + * + * @param {Number} hour + * @param {Number} minute + * @return {Array} + */ + _getSecondsRange(hour, minute) { + const start = hour * HOUR_IN_MS + minute * MINUTE_IN_MS; + const end = start + MINUTE_IN_MS - 1; + const minStep = SECOND_IN_MS; + const formatter = time => new Date(time).getUTCSeconds(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Get the milliseconds range + * @param {Number} hour + * @param {Number} minute + * @param {Number} second + * @return {Array} + */ + _getMillisecondsRange(hour, minute, second) { + const start = + hour * HOUR_IN_MS + minute * MINUTE_IN_MS + second * SECOND_IN_MS; + const end = start + SECOND_IN_MS - 1; + const minStep = 1; + const formatter = time => new Date(time).getUTCMilliseconds(); + + return this._getSteps(start, end, minStep, formatter); + }, + + /** + * Calculate the range of possible steps. + * + * @param {Number} startValue: Start time in ms + * @param {Number} endValue: End time in ms + * @param {Number} minStep: Smallest step in ms for the time unit + * @param {Function} formatter: Outputs time in a particular format + * @return {Array} + * { + * {Number} value + * {Boolean} enabled + * } + */ + _getSteps(startValue, endValue, minStep, formatter) { + const { min, max, step } = this.props; + // The timeStep should be big enough so that there won't be + // duplications. Ex: minimum step for minute should be 60000ms, + // if smaller than that, next step might return the same minute. + const timeStep = Math.max(minStep, step); + + // Make sure the starting point and end point is not off step + let time = + min.valueOf() + + Math.ceil((startValue - min.valueOf()) / timeStep) * timeStep; + let maxValue = + min.valueOf() + + Math.floor((max.valueOf() - min.valueOf()) / step) * step; + let steps = []; + + // Increment by timeStep until reaching the end of the range. + while (time <= endValue) { + steps.push({ + value: formatter(time), + // Check if the value is within the min and max. If it's out of range, + // also check for the case when minStep is too large, and has stepped out + // of range when it should be enabled. + enabled: + (time >= min.valueOf() && time <= max.valueOf()) || + (time > maxValue && + startValue <= maxValue && + endValue >= maxValue && + formatter(time) == formatter(maxValue)), + }); + time += timeStep; + } + + return steps; + }, + + /** + * A generic function for stepping up or down from a value of a range. + * It stops at the upper and lower limits. + * + * @param {Number} current: The current value + * @param {Number} offset: The offset relative to current value + * @param {Array} range: List of possible steps + * @return {Number} The new value + */ + _step(current, offset, range) { + const index = range.findIndex(step => step.value == current); + const newIndex = + offset > 0 + ? Math.min(index + offset, range.length - 1) + : Math.max(index + offset, 0); + return range[newIndex].value; + }, + + /** + * Step up or down AM/PM + * + * @param {Number} offset + */ + stepDayPeriodBy(offset) { + const current = this.dayPeriod; + const dayPeriod = this._step( + current, + offset, + this.state.ranges.dayPeriod + ); + + if (current != dayPeriod) { + this.hour < DAY_PERIOD_IN_HOURS + ? this.setState({ hour: this.hour + DAY_PERIOD_IN_HOURS }) + : this.setState({ hour: this.hour - DAY_PERIOD_IN_HOURS }); + } + }, + + /** + * Step up or down hours + * + * @param {Number} offset + */ + stepHourBy(offset) { + const current = this.hour; + const hour = this._step(current, offset, this.state.ranges.hours); + + if (current != hour) { + this.setState({ hour }); + } + }, + + /** + * Step up or down minutes + * + * @param {Number} offset + */ + stepMinuteBy(offset) { + const current = this.minute; + const minute = this._step(current, offset, this.state.ranges.minutes); + + if (current != minute) { + this.setState({ minute }); + } + }, + + /** + * Step up or down seconds + * + * @param {Number} offset + */ + stepSecondBy(offset) { + const current = this.second; + const second = this._step(current, offset, this.state.ranges.seconds); + + if (current != second) { + this.setState({ second }); + } + }, + + /** + * Step up or down milliseconds + * + * @param {Number} offset + */ + stepMillisecondBy(offset) { + const current = this.milliseconds; + const millisecond = this._step( + current, + offset, + this.state.ranges.millisecond + ); + + if (current != millisecond) { + this.setState({ millisecond }); + } + }, + + /** + * Checks if the time state is off step. + * + * @param {Date} time + * @return {Boolean} + */ + _isOffStep(time) { + const { min, step } = this.props; + + return (time.valueOf() - min.valueOf()) % step != 0; + }, + }; +} diff --git a/toolkit/content/widgets/timepicker.js b/toolkit/content/widgets/timepicker.js new file mode 100644 index 0000000000..645391ae73 --- /dev/null +++ b/toolkit/content/widgets/timepicker.js @@ -0,0 +1,288 @@ +/* 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/. */ + +/* import-globals-from timekeeper.js */ +/* import-globals-from spinner.js */ + +"use strict"; + +function TimePicker(context) { + this.context = context; + this._attachEventListeners(); +} + +{ + const DAY_PERIOD_IN_HOURS = 12, + DAY_IN_MS = 86400000; + + TimePicker.prototype = { + /** + * Initializes the time picker. Set the default states and properties. + * @param {Object} props + * { + * {Number} hour [optional]: Hour in 24 hours format (0~23), default is current hour + * {Number} minute [optional]: Minute (0~59), default is current minute + * {Number} min: Minimum time, in ms + * {Number} max: Maximum time, in ms + * {Number} step: Step size in ms + * {String} format [optional]: "12" for 12 hours, "24" for 24 hours format + * {String} locale [optional]: User preferred locale + * } + */ + init(props) { + this.props = props || {}; + this._setDefaultState(); + this._createComponents(); + this._setComponentStates(); + }, + + /* + * Set initial time states. If there's no hour & minute, it will + * use the current time. The Time module keeps track of the time states, + * and calculates the valid options given the time, min, max, step, + * and format (12 or 24). + */ + _setDefaultState() { + const { hour, minute, min, max, step, format } = this.props; + const now = new Date(); + + let timerHour = hour == undefined ? now.getHours() : hour; + let timerMinute = minute == undefined ? now.getMinutes() : minute; + let timeKeeper = new TimeKeeper({ + min: new Date(Number.isNaN(min) ? 0 : min), + max: new Date(Number.isNaN(max) ? DAY_IN_MS - 1 : max), + step, + format: format || "12", + }); + timeKeeper.setState({ hour: timerHour, minute: timerMinute }); + + this.state = { timeKeeper }; + }, + + /** + * Initalize the spinner components. + */ + _createComponents() { + const { locale, format } = this.props; + const { timeKeeper } = this.state; + + const wrapSetValueFn = setTimeFunction => { + return value => { + setTimeFunction(value); + this._setComponentStates(); + this._dispatchState(); + }; + }; + const numberFormat = new Intl.NumberFormat(locale).format; + + this.components = { + hour: new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setHour(value); + this.state.isHourSet = true; + }), + getDisplayString: hour => { + if (format == "24") { + return numberFormat(hour); + } + // Hour 0 in 12 hour format is displayed as 12. + const hourIn12 = hour % DAY_PERIOD_IN_HOURS; + return hourIn12 == 0 ? numberFormat(12) : numberFormat(hourIn12); + }, + }, + this.context + ), + minute: new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setMinute(value); + this.state.isMinuteSet = true; + }), + getDisplayString: minute => numberFormat(minute), + }, + this.context + ), + }; + + this._insertLayoutElement({ + tag: "div", + textContent: ":", + className: "colon", + insertBefore: this.components.minute.elements.container, + }); + + // The AM/PM spinner is only available in 12hr mode + // TODO: Replace AM & PM string with localized string + if (format == "12") { + this.components.dayPeriod = new Spinner( + { + setValue: wrapSetValueFn(value => { + timeKeeper.setDayPeriod(value); + this.state.isDayPeriodSet = true; + }), + getDisplayString: dayPeriod => (dayPeriod == 0 ? "AM" : "PM"), + hideButtons: true, + }, + this.context + ); + + this._insertLayoutElement({ + tag: "div", + className: "spacer", + insertBefore: this.components.dayPeriod.elements.container, + }); + } + }, + + /** + * Insert element for layout purposes. + * + * @param {Object} + * { + * {String} tag: The tag to create + * {DOMElement} insertBefore: The DOM node to insert before + * {String} className [optional]: Class name + * {String} textContent [optional]: Text content + * } + */ + _insertLayoutElement({ tag, insertBefore, className, textContent }) { + let el = document.createElement(tag); + el.textContent = textContent; + el.className = className; + this.context.insertBefore(el, insertBefore); + }, + + /** + * Set component states. + */ + _setComponentStates() { + const { timeKeeper, isHourSet, isMinuteSet, isDayPeriodSet } = this.state; + const isInvalid = timeKeeper.state.isInvalid; + // Value is set to min if it's first opened and time state is invalid + const setToMinValue = + !isHourSet && !isMinuteSet && !isDayPeriodSet && isInvalid; + + this.components.hour.setState({ + value: setToMinValue + ? timeKeeper.ranges.hours[0].value + : timeKeeper.hour, + items: timeKeeper.ranges.hours, + isInfiniteScroll: true, + isValueSet: isHourSet, + isInvalid, + }); + + this.components.minute.setState({ + value: setToMinValue + ? timeKeeper.ranges.minutes[0].value + : timeKeeper.minute, + items: timeKeeper.ranges.minutes, + isInfiniteScroll: true, + isValueSet: isMinuteSet, + isInvalid, + }); + + // The AM/PM spinner is only available in 12hr mode + if (this.props.format == "12") { + this.components.dayPeriod.setState({ + value: setToMinValue + ? timeKeeper.ranges.dayPeriod[0].value + : timeKeeper.dayPeriod, + items: timeKeeper.ranges.dayPeriod, + isInfiniteScroll: false, + isValueSet: isDayPeriodSet, + isInvalid, + }); + } + }, + + /** + * Dispatch CustomEvent to pass the state of picker to the panel. + */ + _dispatchState() { + const { hour, minute } = this.state.timeKeeper; + const { isHourSet, isMinuteSet, isDayPeriodSet } = this.state; + // The panel is listening to window for postMessage event, so we + // do postMessage to itself to send data to input boxes. + window.postMessage( + { + name: "PickerPopupChanged", + detail: { + hour, + minute, + isHourSet, + isMinuteSet, + isDayPeriodSet, + }, + }, + "*" + ); + }, + _attachEventListeners() { + window.addEventListener("message", this); + document.addEventListener("mousedown", this); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setCapture(); + break; + } + } + }, + + /** + * Handle postMessage events. + * + * @param {Event} event + */ + handleMessage(event) { + switch (event.data.name) { + case "PickerSetValue": { + this.set(event.data.detail); + break; + } + case "PickerInit": { + this.init(event.data.detail); + break; + } + } + }, + + /** + * Set the time state and update the components with the new state. + * + * @param {Object} timeState + * { + * {Number} hour [optional] + * {Number} minute [optional] + * {Number} second [optional] + * {Number} millisecond [optional] + * } + */ + set(timeState) { + if (timeState.hour != undefined) { + this.state.isHourSet = true; + } + if (timeState.minute != undefined) { + this.state.isMinuteSet = true; + } + this.state.timeKeeper.setState(timeState); + this._setComponentStates(); + }, + }; +} diff --git a/toolkit/content/widgets/toolbarbutton.js b/toolkit/content/widgets/toolbarbutton.js new file mode 100644 index 0000000000..6b4f5449e5 --- /dev/null +++ b/toolkit/content/widgets/toolbarbutton.js @@ -0,0 +1,227 @@ +/* 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 KEEP_CHILDREN = new Set([ + "observes", + "template", + "menupopup", + "panel", + "tooltip", + ]); + + window.addEventListener( + "popupshowing", + e => { + if (e.originalTarget.ownerDocument != document) { + return; + } + + e.originalTarget.setAttribute("hasbeenopened", "true"); + for (let el of e.originalTarget.querySelectorAll("toolbarbutton")) { + el.render(); + } + }, + { capture: true } + ); + + class MozToolbarbutton extends MozElements.ButtonBase { + static get inheritedAttributes() { + // Note: if you remove 'wrap' or 'label' from the inherited attributes, + // you'll need to add them to observedAttributes. + return { + ".toolbarbutton-icon": + "validate,src=image,label,type,consumeanchor,triggeringprincipal=iconloadingprincipal", + ".toolbarbutton-text": "accesskey,crop,dragover-top,wrap", + ".toolbarbutton-menu-dropmarker": "disabled,label", + + ".toolbarbutton-badge": "text=badge,style=badgeStyle", + }; + } + + static get fragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + + + `), + true + ); + Object.defineProperty(this, "fragment", { value: frag }); + return frag; + } + + static get badgedFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + + + + +