diff options
Diffstat (limited to 'toolkit/content/widgets')
76 files changed, 34240 insertions, 0 deletions
diff --git a/toolkit/content/widgets/arrowscrollbox.js b/toolkit/content/widgets/arrowscrollbox.js new file mode 100644 index 0000000000..28de96c8d7 --- /dev/null +++ b/toolkit/content/widgets/arrowscrollbox.js @@ -0,0 +1,868 @@ +/* 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 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 ` + <html:link rel="stylesheet" href="chrome://global/skin/toolbarbutton.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/arrowscrollbox.css"/> + <toolbarbutton id="scrollbutton-up" part="scrollbutton-up" keyNav="false"/> + <spacer part="overflow-start-indicator"/> + <box class="scrollbox-clip" part="scrollbox-clip" flex="1"> + <scrollbox part="scrollbox" flex="1"> + <html:slot/> + </scrollbox> + </box> + <spacer part="overflow-end-indicator"/> + <toolbarbutton id="scrollbutton-down" part="scrollbutton-down" keyNav="false"/> + `; + } + + 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); + } + + 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 average width 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 isAtEdge = (element, start) => { + let edge = start ? this.startEndProps[0] : this.startEndProps[1]; + let scrollEdge = this._boundsWithoutFlushing(this.scrollbox)[ + edge + ]; + let elementEdge = this._boundsWithoutFlushing(element)[edge]; + // This is enough slop (>2/3) so that no subpixel value should + // get us confused about whether we reached the end. + const EPSILON = 0.7; + if (start) { + return scrollEdge <= elementEdge + EPSILON; + } + return elementEdge <= scrollEdge + EPSILON; + }; + + let elements = this._getScrollableElements(); + let [startElement, endElement] = [ + elements[0], + elements[elements.length - 1], + ]; + if (this.isRTLScrollbox) { + [startElement, endElement] = [endElement, startElement]; + } + scrolledToStart = + startElement && isAtEdge(startElement, /* start = */ true); + scrolledToEnd = + endElement && isAtEdge(endElement, /* start = */ false); + if (this.isRTLScrollbox) { + [scrolledToStart, scrolledToEnd] = [ + scrolledToEnd, + scrolledToStart, + ]; + } + } + + 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; + } + + const { deltaMode } = event; + let doScroll = false; + let instant; + let scrollAmount = 0; + if (this.getAttribute("orient") == "vertical") { + doScroll = true; + scrollAmount = event.deltaY; + } 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; + scrollAmount = scrollByDelta; + if (deltaMode == event.DOM_DELTA_PIXEL) { + instant = true; + } + } + + if (this._prevMouseScrolls.length > 1) { + this._prevMouseScrolls.shift(); + } + this._prevMouseScrolls.push(isVertical); + } + + if (doScroll) { + let direction = scrollAmount < 0 ? -1 : 1; + + if (deltaMode == event.DOM_DELTA_PAGE) { + scrollAmount *= this.scrollClientSize; + } else if (deltaMode == event.DOM_DELTA_LINE) { + // Try to not scroll by more than one page when using line scrolling, + // so that all elements are scrollable. + let lineAmount = this.lineScrollAmount; + let clientSize = this.scrollClientSize; + if (Math.abs(scrollAmount * lineAmount) > clientSize) { + // NOTE: This still tries to scroll a non-fractional amount of + // items per line scrolled. + scrollAmount = + Math.max(1, Math.floor(clientSize / lineAmount)) * direction; + } + scrollAmount *= lineAmount; + } else { + // DOM_DELTA_PIXEL, leave scrollAmount untouched. + } + 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..36105ba4d7 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-input.js @@ -0,0 +1,647 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + class AutocompleteInput extends HTMLInputElement { + constructor() { + super(); + + this.popupSelectedIndex = -1; + + ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + }); + + 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 <popupset> and <panel> + 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); + } + + get disableAutoComplete() { + return this.getAttribute("disableautocomplete") == "true"; + } + + set completeDefaultIndex(val) { + this.setAttribute("completedefaultindex", val); + } + + get completeDefaultIndex() { + return this.getAttribute("completedefaultindex") == "true"; + } + + set completeSelectedIndex(val) { + this.setAttribute("completeselectedindex", val); + } + + get completeSelectedIndex() { + return this.getAttribute("completeselectedindex") == "true"; + } + + set forceComplete(val) { + this.setAttribute("forcecomplete", val); + } + + get forceComplete() { + return this.getAttribute("forcecomplete") == "true"; + } + + set minResultsForPopup(val) { + this.setAttribute("minresultsforpopup", val); + } + + get minResultsForPopup() { + var m = parseInt(this.getAttribute("minresultsforpopup")); + return isNaN(m) ? 1 : m; + } + + set timeout(val) { + this.setAttribute("timeout", val); + } + + get timeout() { + var t = parseInt(this.getAttribute("timeout")); + return isNaN(t) ? 50 : t; + } + + set searchParam(val) { + this.setAttribute("autocompletesearchparam", 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); + } + + get textValue() { + return this.value; + } + /** + * =================== nsIDOMXULMenuListElement =================== + */ + get editable() { + return true; + } + + set open(val) { + if (val) { + this.showHistoryPopup(); + } else { + this.closePopup(); + } + } + + get open() { + return this.getAttribute("open") == "true"; + } + + set value(val) { + 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); + } + + 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); + } + + 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); + } + + 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); + } + + get ignoreBlurWhileSearching() { + return this.getAttribute("ignoreblurwhilesearching") == "true"; + } + /** + * option to highlight entries that don't have any matches + */ + set highlightNonMatches(val) { + this.setAttribute("highlightnonmatches", val); + } + + get highlightNonMatches() { + return this.getAttribute("highlightnonmatches") == "true"; + } + + getSearchAt(aIndex) { + this.initSearchNames(); + return this.mSearchNames[aIndex]; + } + + 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..f033511e07 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-popup.js @@ -0,0 +1,637 @@ +/* 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.attachShadow({ mode: "open" }); + + { + let slot = document.createElement("slot"); + slot.part = "content"; + this.shadowRoot.appendChild(slot); + } + + 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 ` + <richlistbox class="autocomplete-richlistbox"/> + `; + } + + /** + * 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 + ); + } + } + + 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) {} + + get view() { + return this.mInput.controller; + } + + closePopup() { + if (this.mPopupOpen) { + this.hidePopup(); + this.style.removeProperty("--panel-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.style.setProperty("--panel-width", Math.max(width, 100) + "px"); + // 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) { + this._adjustHeightOnPopupShown = false; + 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(); + + // 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. + this.richlistbox.style.height = Math.ceil(height) + "px"; + } + + _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+$/, ""); + + // Generic items can pack their details as JSON inside label + try { + const details = JSON.parse(label); + if (details.title) { + value = details.title; + label = details.subtitle ?? ""; + } + } catch {} + + 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 <content> structure and overrided + // _adjustAcItem() are unreusable. + const UNREUSEABLE_STYLES = [ + "autofill-profile", + "autofill-footer", + "autofill-clear-button", + "autofill-insecureWarning", + "generatedPassword", + "generic", + "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 "generic": + options = { is: "autocomplete-two-line-richlistitem" }; + 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) { + this._adjustHeightOnPopupShown = false; + 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..ccbd37e132 --- /dev/null +++ b/toolkit/content/widgets/autocomplete-richlistitem.js @@ -0,0 +1,873 @@ +/* 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 { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" + ); + + 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 ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <hbox class="ac-title" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text"/> + </description> + </hbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center" aria-hidden="true"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + 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 ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <vbox class="ac-title" align="left"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text"/> + </description> + </vbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + 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 {} + + class MozAutocompleteImportableLearnMoreRichlistitem extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + } + + static get markup() { + return ` + <image class="ac-type-icon"/> + <image class="ac-site-icon"/> + <vbox class="ac-title" align="left"> + <description class="ac-text-overflow-container"> + <description class="ac-title-text" + data-l10n-id="autocomplete-import-learn-more"/> + </description> + </vbox> + <hbox class="ac-separator" align="center"> + <description class="ac-separator-text" value="—"/> + </hbox> + <hbox class="ac-url" align="center"> + <description class="ac-text-overflow-container"> + <description class="ac-url-text"/> + </description> + </hbox> + `; + } + + // 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.initializeSecondaryAction(); + this._adjustAcItem(); + } + + initializeSecondaryAction() { + const button = this.querySelector(".ac-secondary-action"); + + if (this.onSecondaryAction) { + button.addEventListener("mousedown", event => { + event.stopPropagation(); + this.onSecondaryAction(); + }); + } else { + button?.remove(); + } + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // getCommentAt: + ".line2-label": "text=ac-label", + ".ac-site-icon": "src=ac-image", + }; + } + + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="two-line-wrapper"> + <xul:image class="ac-site-icon"></xul:image> + <div class="labels-wrapper"> + <div class="label-row line1-label"></div> + <div class="label-row line2-label"></div> + </div> + <button class="ac-secondary-action"></button> + </div> + `; + } + + _adjustAcItem() {} + + _onOverflow() {} + + _onUnderflow() {} + + handleOverUnderflow() {} + } + + class MozAutocompleteLoginRichlistitem extends MozAutocompleteTwoLineRichlistitem { + connectedCallback() { + super.connectedCallback(); + this.firstChild.classList.add("ac-login-item"); + } + + onSecondaryAction() { + const details = JSON.parse(this.getAttribute("ac-label")); + LoginHelper.openPasswordManager(window, { + loginGuid: details?.guid, + }); + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // Don't inherit ac-label with getCommentAt since the label is JSON. + ".ac-site-icon": "src=ac-image", + }; + } + + _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 <span> 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"); + } + + static get inheritedAttributes() { + return { + // getLabelAt: + ".line1-label": "text=ac-value", + // Don't inherit ac-label with getCommentAt since the label is JSON. + }; + } + + static get markup() { + return ` + <div xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + class="two-line-wrapper"> + <xul:image class="ac-site-icon" /> + <div class="labels-wrapper"> + <div class="label-row line1-label" data-l10n-name="line1" /> + <div class="label-row line2-label" data-l10n-name="line2" /> + </div> + </div> + `; + } + + _adjustAcItem() { + super._adjustAcItem(); + document.l10n.setAttributes( + this.querySelector(".labels-wrapper"), + `autocomplete-import-logins-${this.getAttribute("ac-value")}`, + { + host: JSON.parse(this.getAttribute("ac-label")).hostname.replace( + /^www\./, + "" + ), + } + ); + } + } + + 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..e9b29034fa --- /dev/null +++ b/toolkit/content/widgets/browser-custom-element.js @@ -0,0 +1,1959 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + let lazy = {}; + + ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + Finder: "resource://gre/modules/Finder.sys.mjs", + FinderParent: "resource://gre/modules/FinderParent.sys.mjs", + PopupBlocker: "resource://gre/actors/PopupBlockingParent.sys.mjs", + SelectParentHelper: "resource://gre/actors/SelectParent.sys.mjs", + RemoteWebNavigation: "resource://gre/modules/RemoteWebNavigation.sys.mjs", + }); + + ChromeUtils.defineLazyGetter(lazy, "blankURI", () => + Services.io.newURI("about:blank") + ); + + let lazyPrefs = {}; + XPCOMUtils.defineLazyPreferenceGetter( + lazyPrefs, + "unloadTimeoutMs", + "dom.beforeunload_timeout_ms" + ); + Object.defineProperty(lazy, "ProcessHangMonitor", { + configurable: true, + get() { + // Import if we can - this is a browser/ module so it may not be + // available, in which case we return null. We replace this getter + // when the module becomes available (should be on delayed startup + // when the first browser window loads, via BrowserGlue.sys.mjs). + const kURL = "resource:///modules/ProcessHangMonitor.sys.mjs"; + if (Cu.isESModuleLoaded(kURL)) { + let { ProcessHangMonitor } = ChromeUtils.importESModule(kURL); + // eslint-disable-next-line mozilla/valid-lazy + Object.defineProperty(lazy, "ProcessHangMonitor", { + value: ProcessHangMonitor, + }); + return ProcessHangMonitor; + } + return null; + }, + }); + + // Get SessionStore module in the same as ProcessHangMonitor above. + Object.defineProperty(lazy, "SessionStore", { + configurable: true, + get() { + const kURL = "resource:///modules/sessionstore/SessionStore.sys.mjs"; + if (Cu.isESModuleLoaded(kURL)) { + let { SessionStore } = ChromeUtils.importESModule(kURL); + // eslint-disable-next-line mozilla/valid-lazy + Object.defineProperty(lazy, "SessionStore", { + value: SessionStore, + }); + return SessionStore; + } + return null; + }, + }); + + 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(); + + this._originalURI = null; + this._searchTerms = ""; + // When we open a prompt in reaction to a 401, if this 401 comes from + // a different base domain, the url of that site will be stored here + // and will be used for auth prompt spoofing protections. + // See bug 791594 for reference. + this._currentAuthPromptURI = null; + /** + * These are managed by the tabbrowser: + */ + this.droppedLinkHandler = null; + this.mIconURL = null; + this.lastURI = null; + + ChromeUtils.defineLazyGetter(this, "popupBlocker", () => { + return new lazy.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 { + if (!linkHandler.canDropLink(event, false)) { + return; + } + + // 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._originalURI = null; + + this._currentAuthPromptURI = null; + + this._searchTerms = ""; + + 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._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._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; + } + + // While an auth prompt from a base domain different than the current sites is open, we want to display the url of the cross domain site. + // This is to prevent possible auth spoofing scenarios. + // The URL of the requesting origin is provided by 'currentAuthPromptURI', this will only be non null while an auth prompt is open. + // See bug 791594 for reference. + get currentURI() { + if (this.currentAuthPromptURI) { + return this.currentAuthPromptURI; + } + 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")); + } + + 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) { + this._remoteFinder = new lazy.FinderParent(this); + } + return this._remoteFinder; + } + if (!this._finder) { + if (!this.docShell) { + return null; + } + + this._finder = new lazy.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; + } + + forceEncodingDetection() { + if (this.isRemoteBrowser) { + this.sendMessageToActor("ForceEncodingDetection", {}, "BrowserTab"); + } else { + this.docShell.forceEncodingDetection(); + } + } + + 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 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 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; + } + + set originalURI(aURI) { + if (aURI instanceof Ci.nsIURI) { + this._originalURI = aURI; + } + } + + get originalURI() { + return this._originalURI; + } + + set searchTerms(val) { + this._searchTerms = val; + } + + get searchTerms() { + return this._searchTerms; + } + + set currentAuthPromptURI(aURI) { + this._currentAuthPromptURI = aURI; + } + + get currentAuthPromptURI() { + return this._currentAuthPromptURI; + } + _wrapURIChangeCall(fn) { + if (!this.isRemoteBrowser) { + this.isNavigating = true; + try { + fn(); + } finally { + this.isNavigating = false; + } + } else { + fn(); + } + } + + goBack( + requireUserInteraction = lazy.BrowserUtils + .navigationRequireUserInteraction + ) { + var webNavigation = this.webNavigation; + if (webNavigation.canGoBack) { + this._wrapURIChangeCall(() => + webNavigation.goBack(requireUserInteraction) + ); + } + } + + goForward( + requireUserInteraction = lazy.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); + } + + _fixLoadParamsToLoadURIOptions(params) { + let loadFlags = + params.loadFlags || params.flags || Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + delete params.flags; + params.loadFlags = loadFlags; + } + + /** + * throws exception for unknown schemes + */ + loadURI(uri, params = {}) { + if (!uri) { + uri = lazy.blankURI; + } + this._fixLoadParamsToLoadURIOptions(params); + this._wrapURIChangeCall(() => this.webNavigation.loadURI(uri, params)); + } + + /** + * throws exception for unknown schemes + */ + fixupAndLoadURIString(uriString, params = {}) { + if (!uriString) { + this.loadURI(null, params); + return; + } + this._fixLoadParamsToLoadURIOptions(params); + this._wrapURIChangeCall(() => + this.webNavigation.fixupAndLoadURIString(uriString, params) + ); + } + + 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 { remoteTab } = this.frameLoader; + if (remoteTab) { + remoteTab.priorityHint = false; + remoteTab.deprioritize(); + } + } + + getTabBrowser() { + if (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 we're browsing from the tab crashed UI to a URI that keeps + // this browser non-remote, we'll handle that here. + lazy.SessionStore?.maybeExitCrashedState(this); + + 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 + ); + } + + constrainPopup(popup) { + if (this.getAttribute("constrainpopups") != "false") { + let constraintRect = this.getBoundingClientRect(); + constraintRect = new DOMRect( + constraintRect.left + window.mozInnerScreenX, + constraintRect.top + window.mozInnerScreenY, + constraintRect.width, + constraintRect.height + ); + popup.setConstraintRect(constraintRect); + } else { + popup.setConstraintRect(new DOMRect(0, 0, 0, 0)); + } + } + + 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 <browser> element may not be initialized yet. + */ + + this._remoteWebNavigation = new lazy.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 + ); + this._contentPartitionedPrincipal = this._contentPrincipal; + // CSP for about:blank is null; if we ever change _contentPrincipal above, + // we should re-evaluate the CSP here. + this._csp = null; + + 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 + console.error("Error enabling browser global history: ", ex); + } + } + } + } catch (e) { + console.error(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 custom elements don't have a "real" destructor. + * This method is called explicitly by tabbrowser, when changing remoteness, + * and when we're disconnected or the window unloads. + */ + destroy() { + elementsToDestroyOnUnload.delete(this); + + // If we're browsing from the tab crashed UI to a URI that causes the tab + // to go remote again, we catch this here, because swapping out the + // non-remote browser for a remote one doesn't cause the pagehide event + // to be fired. Previously, we used to do this in the frame script's + // unload handler. + lazy.SessionStore?.maybeExitCrashedState(this); + + // Make sure that any open select is closed. + let menulist = document.getElementById("ContentSelectDropdown"); + if (menulist?.open) { + lazy.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, + aDocumentURI, + aTitle, + aContentPrincipal, + aContentPartitionedPrincipal, + aCSP, + aReferrerInfo, + aIsSynthetic, + aHaveRequestContextID, + aRequestContextID, + aContentType + ) { + if (this.isRemoteBrowser && this.messageManager) { + if (aCharset != null) { + this._characterSet = aCharset; + this._mayEnableCharacterEncodingMenu = + aMayEnableCharacterEncodingMenu; + } + + 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; + } + } + } + + createAboutBlankDocumentViewer(aPrincipal, aPartitionedPrincipal) { + let principal = lazy.BrowserUtils.principalWithMatchingOA( + aPrincipal, + this.contentPrincipal + ); + let partitionedPrincipal = lazy.BrowserUtils.principalWithMatchingOA( + aPartitionedPrincipal, + this.contentPartitionedPrincipal + ); + + if (this.isRemoteBrowser) { + this.frameLoader.remoteTab.createAboutBlankDocumentViewer( + principal, + partitionedPrincipal + ); + } else { + this.docShell.createAboutBlankDocumentViewer( + principal, + partitionedPrincipal + ); + } + } + + _acquireAutoScrollWakeLock() { + const pm = Cc["@mozilla.org/power/powermanagerservice;1"].getService( + Ci.nsIPowerManagerService + ); + this._autoScrollWakelock = pm.newWakeLock("autoscroll", window); + } + + _releaseAutoScrollWakeLock() { + if (this._autoScrollWakelock) { + try { + this._autoScrollWakelock.unlock(); + } catch (e) { + // Ignore error since wake lock is already unlocked + } + this._autoScrollWakelock = null; + } + } + + 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", {}); + } + + try { + Services.obs.removeObserver(this.observer, "apz:cancel-autoscroll"); + } catch (ex) { + // It's not clear why this sometimes throws an exception + } + + if (this._autoScrollScrollId != null) { + this._autoScrollBrowsingContext.stopApzAutoscroll( + this._autoScrollScrollId, + this._autoScrollPresShellId + ); + + this._autoScrollScrollId = null; + this._autoScrollPresShellId = null; + } + + this._autoScrollBrowsingContext = null; + this._releaseAutoScrollWakeLock(); + } + } + + _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, + screenXDevPx, + screenYDevPx, + scrollId, + presShellId, + browsingContext, + }) { + if (!this.autoscrollEnabled) { + return { autoscrollEnabled: false, usingApz: false }; + } + + // The popup size is 32px for the circle plus space for a 4px box-shadow + // on each side. + const POPUP_SIZE = 40; + 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"; + } + + // In desktop pixels. + let screenXDesktopPx = screenXDevPx / window.desktopToDeviceScale; + let screenYDesktopPx = screenYDevPx / window.desktopToDeviceScale; + + let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + let screen = screenManager.screenForRect( + screenXDesktopPx, + screenYDesktopPx, + 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); + + // In CSS pixels + let popupX; + let popupY; + { + let cssToDesktopScale = + window.devicePixelRatio / window.desktopToDeviceScale; + + // 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. Use desktop pixels to deal correctly with + // multi-monitor / multi-dpi scenarios. + let left = {}, + top = {}, + width = {}, + height = {}; + screen.GetAvailRectDisplayPix(left, top, width, height); + + let popupSizeDesktopPx = POPUP_SIZE * cssToDesktopScale; + let minX = left.value + 0.5 * popupSizeDesktopPx; + let maxX = left.value + width.value - 0.5 * popupSizeDesktopPx; + let minY = top.value + 0.5 * popupSizeDesktopPx; + let maxY = top.value + height.value - 0.5 * popupSizeDesktopPx; + + popupX = + Math.max(minX, Math.min(maxX, screenXDesktopPx)) / cssToDesktopScale; + popupY = + Math.max(minY, Math.min(maxY, screenYDesktopPx)) / cssToDesktopScale; + } + + // In CSS pixels. + let screenX = screenXDevPx / window.devicePixelRatio; + let screenY = screenYDevPx / window.devicePixelRatio; + + this._autoScrollPopup.openPopupAtScreen(popupX, popupY); + this._ignoreMouseEvents = true; + this._startX = screenX; + this._startY = screenY; + this._autoScrollBrowsingContext = browsingContext; + this._acquireAutoScrollWakeLock(); + + 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 ( + scrollId != null && + this.mPrefs.getBoolPref("apz.autoscroll.enabled", false) + ) { + // 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 = browsingContext.startApzAutoscroll( + screenXDevPx, + screenYDevPx, + 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": + // The following mouse click/auxclick event on the autoscroller + // shouldn't be fired in web content for compatibility with Chrome. + aEvent.preventClickEvent(); + // fallthrough + 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": { + // TODO: When the autoscroller is closed by clicking outside of it, + // we need to prevent following click event for compatibility + // with Chrome. However, there is no way to do that for now. + 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", + "_contentPrincipal", + "_contentPartitionedPrincipal", + "_isSyntheticDocument", + "_originalURI", + "_userTypedValue", + ] + ); + } + + 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.docViewer) { + aCallback(false); + return; + } + aCallback(this.docShell.docViewer.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 }; + } + + // Don't bother asking if this browser is hung: + if ( + lazy.ProcessHangMonitor?.findActiveReport(this) || + lazy.ProcessHangMonitor?.findPausedReport(this) + ) { + 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.spinEventLoopUntilOrQuit( + "browser-custom-element.js:permitUnload", + () => window.closed || success !== undefined + ); + if (success) { + return result; + } + throw result; + } + + if (!this.docShell || !this.docShell.docViewer) { + return { permitUnload: true }; + } + return { + permitUnload: this.docShell.docViewer.permitUnload(), + }; + } + + /** + * Gets a screenshot of this browser as an ImageBitmap. + * + * @param {Number} x + * The x coordinate of the region from the underlying document to capture + * as a screenshot. This is ignored if fullViewport is true. + * @param {Number} y + * The y coordinate of the region from the underlying document to capture + * as a screenshot. This is ignored if fullViewport is true. + * @param {Number} w + * The width of the region from the underlying document to capture as a + * screenshot. This is ignored if fullViewport is true. + * @param {Number} h + * The height of the region from the underlying document to capture as a + * screenshot. This is ignored if fullViewport is true. + * @param {Number} scale + * The scale factor for the captured screenshot. See the documentation for + * WindowGlobalParent.drawSnapshot for more detail. + * @param {String} backgroundColor + * The default background color for the captured screenshot. See the + * documentation for WindowGlobalParent.drawSnapshot for more detail. + * @param {boolean|undefined} fullViewport + * True if the viewport rect should be captured. If this is true, the + * x, y, w and h parameters are ignored. Defaults to false. + * @returns {Promise} + * @resolves {ImageBitmap} + */ + async drawSnapshot( + x, + y, + w, + h, + scale, + backgroundColor, + fullViewport = false + ) { + let rect = fullViewport ? null : 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; + } + + // 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..ce48fac1e9 --- /dev/null +++ b/toolkit/content/widgets/button.js @@ -0,0 +1,312 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + class MozButtonBase extends MozElements.BaseText { + constructor() { + super(); + + /** + * While it would seem we could do this by handling oncommand, we can't + * because any external oncommand handlers might get called before ours, + * and then they would see the incorrect value of checked. Additionally + * a command attribute would redirect the command events anyway. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + this._handleClick(); + }); + + this.addEventListener("keypress", event => { + if (event.key != " ") { + return; + } + this._handleClick(); + // Prevent page from scrolling on the space key. + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if (this.hasMenu()) { + if (this.open) { + return; + } + } else if (!this.inRichListItem) { + if ( + event.keyCode == KeyEvent.DOM_VK_UP || + (event.keyCode == KeyEvent.DOM_VK_LEFT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "ltr") || + (event.keyCode == KeyEvent.DOM_VK_RIGHT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "rtl") + ) { + event.preventDefault(); + window.document.commandDispatcher.rewindFocus(); + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_DOWN || + (event.keyCode == KeyEvent.DOM_VK_RIGHT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "ltr") || + (event.keyCode == KeyEvent.DOM_VK_LEFT && + document.defaultView.getComputedStyle(this.parentNode) + .direction == "rtl") + ) { + event.preventDefault(); + window.document.commandDispatcher.advanceFocus(); + return; + } + } + + if ( + event.keyCode || + event.charCode <= 32 || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } // No printable char pressed, not a potential accesskey + + // Possible accesskey pressed + var charPressedLower = String.fromCharCode( + event.charCode + ).toLowerCase(); + + // If the accesskey of the current button is pressed, just activate it + if (this.accessKey.toLowerCase() == charPressedLower) { + this.click(); + return; + } + + // Search for accesskey in the list of buttons for this doc and each subdoc + // Get the buttons for the main document and all sub-frames + for ( + var frameCount = -1; + frameCount < window.top.frames.length; + frameCount++ + ) { + var doc = + frameCount == -1 + ? window.top.document + : window.top.frames[frameCount].document; + if (this.fireAccessKeyButton(doc.documentElement, charPressedLower)) { + return; + } + } + + // Test dialog buttons + let buttonBox = window.top.document.querySelector("dialog")?.buttonBox; + if (buttonBox) { + this.fireAccessKeyButton(buttonBox, charPressedLower); + } + }); + } + + set type(val) { + this.setAttribute("type", val); + } + + get type() { + return this.getAttribute("type"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + } + + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set group(val) { + this.setAttribute("group", val); + } + + get group() { + return this.getAttribute("group"); + } + + set open(val) { + if (this.hasMenu()) { + this.openMenu(val); + } else if (val) { + // Fall back to just setting the attribute + this.setAttribute("open", "true"); + } else { + this.removeAttribute("open"); + } + } + + get open() { + return this.hasAttribute("open"); + } + + set checked(val) { + if (this.type == "radio" && val) { + var sibs = this.parentNode.getElementsByAttribute("group", this.group); + for (var i = 0; i < sibs.length; ++i) { + sibs[i].removeAttribute("checked"); + } + } + + if (val) { + this.setAttribute("checked", "true"); + } else { + this.removeAttribute("checked"); + } + } + + get checked() { + return this.hasAttribute("checked"); + } + + filterButtons(node) { + // if the node isn't visible, don't descend into it. + var cs = node.ownerGlobal.getComputedStyle(node); + if (cs.visibility != "visible" || cs.display == "none") { + return NodeFilter.FILTER_REJECT; + } + // but it may be a popup element, in which case we look at "state"... + if (XULPopupElement.isInstance(node) && node.state != "open") { + return NodeFilter.FILTER_REJECT; + } + // OK - the node seems visible, so it is a candidate. + if (node.localName == "button" && node.accessKey && !node.disabled) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + } + + fireAccessKeyButton(aSubtree, aAccessKeyLower) { + var iterator = aSubtree.ownerDocument.createTreeWalker( + aSubtree, + NodeFilter.SHOW_ELEMENT, + this.filterButtons + ); + while (iterator.nextNode()) { + var test = iterator.currentNode; + if ( + test.accessKey.toLowerCase() == aAccessKeyLower && + !test.disabled && + !test.collapsed && + !test.hidden + ) { + test.focus(); + test.click(); + return true; + } + } + return false; + } + + _handleClick() { + if (!this.disabled) { + if (this.type == "checkbox") { + this.checked = !this.checked; + } else if (this.type == "radio") { + this.checked = true; + } + } + } + } + + MozXULElement.implementCustomInterface(MozButtonBase, [ + Ci.nsIDOMXULButtonElement, + ]); + + MozElements.ButtonBase = MozButtonBase; + + class MozButton extends MozButtonBase { + static get inheritedAttributes() { + return { + ".box-inherit": "align,dir,pack,orient", + ".button-icon": "src=image", + ".button-text": "value=label,accesskey,crop", + ".button-menu-dropmarker": "open,disabled,label", + }; + } + + get icon() { + return this.querySelector(".button-icon"); + } + + static get buttonFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="box-inherit button-box" align="center" pack="center" flex="1" anonid="button-box"> + <image class="button-icon"/> + <label class="button-text"/> + </hbox>`), + true + ); + Object.defineProperty(this, "buttonFragment", { value: frag }); + return frag; + } + + static get menuFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="box-inherit button-box" align="center" pack="center" flex="1"> + <hbox class="box-inherit" align="center" pack="center" flex="1"> + <image class="button-icon"/> + <label class="button-text"/> + </hbox> + <dropmarker class="button-menu-dropmarker"/> + </hbox>`), + true + ); + Object.defineProperty(this, "menuFragment", { value: frag }); + return frag; + } + + get _hasConnected() { + return this.querySelector(":scope > .button-box") != null; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + + let fragment; + if (this.type === "menu") { + fragment = MozButton.menuFragment; + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN && event.key != " ") { + return; + } + + this.open = true; + // Prevent page from scrolling on the space key. + if (event.key == " ") { + event.preventDefault(); + } + }); + } else { + fragment = this.constructor.buttonFragment; + } + + this.appendChild(fragment.cloneNode(true)); + this.initializeAttributeInheritance(); + this.inRichListItem = !!this.closest("richlistitem"); + } + } + + customElements.define("button", MozButton); +} diff --git a/toolkit/content/widgets/calendar.js b/toolkit/content/widgets/calendar.js new file mode 100644 index 0000000000..53a8a69adb --- /dev/null +++ b/toolkit/content/widgets/calendar.js @@ -0,0 +1,485 @@ +/* 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 + * {Function} setCalendarMonth: Update the month shown by the dateView + * to a specific month of a specific year + * } + * @param {Object} context + * { + * {DOMElement} weekHeader + * {DOMElement} daysView + * } + */ +function Calendar(options, context) { + this.context = context; + this.context.DAYS_IN_A_WEEK = 7; + this.state = { + days: [], + weekHeaders: [], + setSelection: options.setSelection, + setCalendarMonth: options.setCalendarMonth, + getDayString: options.getDayString, + getWeekHeaderString: options.getWeekHeaderString, + focusedDate: null, + }; + this.elements = { + weekHeaders: this._generateNodes( + this.context.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<Object>} days: Data for days + * { + * {Date} dateObj + * {Number} content + * {Array<String>} classNames + * {Boolean} enabled + * } + * {Array<Object>} weekHeaders: Data for weekHeaders + * { + * {Number} content + * {Array<String>} 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 and place keyboard focus + this.state.days = days; + this.state.weekHeaders = weekHeaders; + this.focusDay(); + } + }, + + /** + * Render the items onto the DOM nodes + * @param {Object} + * { + * {Array<DOMElement>} elements + * {Array<Object>} items + * {Array<Object>} prevState: state of items from last render + * } + */ + _render({ elements, items, prevState }) { + let selected = {}; + let today = {}; + let sameDay = {}; + let firstDay = {}; + + 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; + } + + if (el.tagName === "td") { + el.setAttribute("role", "gridcell"); + + // Flush states from the previous view + el.removeAttribute("tabindex"); + el.removeAttribute("aria-disabled"); + el.removeAttribute("aria-selected"); + el.removeAttribute("aria-current"); + + // Set new states and properties + if ( + this.state.focusedDate && + this._isSameDayOfMonth(items[i].dateObj, this.state.focusedDate) && + !el.classList.contains("outside") + ) { + // When any other date was focused previously, send the focus + // to the same day of month, but only within the current month + sameDay.el = el; + sameDay.dateObj = items[i].dateObj; + } + if (el.classList.contains("today")) { + // Current date/today is communicated to assistive technology + el.setAttribute("aria-current", "date"); + if (!el.classList.contains("outside")) { + today.el = el; + today.dateObj = items[i].dateObj; + } + } + if (el.classList.contains("selection")) { + // Selection is communicated to assistive technology + // and may be included in the focus order when from the current month + el.setAttribute("aria-selected", "true"); + + if (!el.classList.contains("outside")) { + selected.el = el; + selected.dateObj = items[i].dateObj; + } + } else if (el.classList.contains("out-of-range")) { + // Dates that are outside of the range are not selected and cannot be + el.setAttribute("aria-disabled", "true"); + el.removeAttribute("aria-selected"); + } else { + // Other dates are not selected, but could be + el.setAttribute("aria-selected", "false"); + } + if (el.textContent === "1" && !firstDay.el) { + // When no previous day, no selection, or no current day/today + // is present, make the first of the month focusable + firstDay.dateObj = items[i].dateObj; + firstDay.dateObj.setUTCDate("1"); + + if (this._isSameDay(items[i].dateObj, firstDay.dateObj)) { + firstDay.el = el; + firstDay.dateObj = items[i].dateObj; + } + } + } + } + + // The previously focused date (if the picker is updated and the grid still + // contains the date) is always focusable. The selected date on init is also + // always focusable. If neither exist, we make the current day or the first + // day of the month focusable. + if (sameDay.el) { + sameDay.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(sameDay.dateObj); + } else if (selected.el) { + selected.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(selected.dateObj); + } else if (today.el) { + today.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(today.dateObj); + } else if (firstDay.el) { + firstDay.el.setAttribute("tabindex", "0"); + this.state.focusedDate = new Date(firstDay.dateObj); + } + }, + + /** + * Generate DOM nodes with HTML table markup + * + * @param {Number} size: Number of nodes to generate + * @param {DOMElement} context: Element to append the nodes to + * @return {Array<DOMElement>} + */ + _generateNodes(size, context) { + let frag = document.createDocumentFragment(); + let refs = []; + + // Create table row to present a week: + let rowEl = document.createElement("tr"); + for (let i = 0; i < size; i++) { + // Create table cell for a table header (weekday) or body (date) + let el; + if (context.classList.contains("week-header")) { + el = document.createElement("th"); + el.setAttribute("scope", "col"); + // Explicitly assigning the role as a workaround for the bug 1711273: + el.setAttribute("role", "columnheader"); + } else { + el = document.createElement("td"); + } + + el.dataset.id = i; + refs.push(el); + rowEl.appendChild(el); + + // Ensure each table row (week) has only + // seven table cells (days) for a Gregorian calendar + if ((i + 1) % this.context.DAYS_IN_A_WEEK === 0) { + frag.appendChild(rowEl); + rowEl = document.createElement("tr"); + } + } + context.appendChild(frag); + + return refs; + }, + + /** + * Handle events + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "click": { + if (this.context.daysView.contains(event.target)) { + let targetId = event.target.dataset.id; + let targetObj = this.state.days[targetId]; + if (targetObj.enabled) { + this.state.setSelection(targetObj.dateObj); + } + } + break; + } + + case "keydown": { + // Providing keyboard navigation support in accordance with + // the ARIA Grid and Dialog design patterns + if (this.context.daysView.contains(event.target)) { + // If RTL, the offset direction for Right/Left needs to be reversed + const direction = Services.locale.isAppLocaleRTL ? -1 : 1; + + switch (event.key) { + case "Enter": + case " ": { + let targetId = event.target.dataset.id; + let targetObj = this.state.days[targetId]; + if (targetObj.enabled) { + this.state.setSelection(targetObj.dateObj); + } + break; + } + + case "ArrowRight": { + // Moves focus to the next day. If the next day is + // out-of-range, update the view to show the next month + this._handleKeydownEvent(1 * direction); + break; + } + case "ArrowLeft": { + // Moves focus to the previous day. If the next day is + // out-of-range, update the view to show the previous month + this._handleKeydownEvent(-1 * direction); + break; + } + case "ArrowUp": { + // Moves focus to the same day of the previous week. If the next + // day is out-of-range, update the view to show the previous month + this._handleKeydownEvent(-1 * this.context.DAYS_IN_A_WEEK); + break; + } + case "ArrowDown": { + // Moves focus to the same day of the next week. If the next + // day is out-of-range, update the view to show the previous month + this._handleKeydownEvent(1 * this.context.DAYS_IN_A_WEEK); + break; + } + case "Home": { + // Moves focus to the first day (ie. Sunday) of the current week + if (event.ctrlKey) { + // Moves focus to the first day of the current month + this.state.focusedDate.setUTCDate(1); + this._updateKeyboardFocus(); + } else { + this._handleKeydownEvent( + this.state.focusedDate.getUTCDay() * -1 + ); + } + break; + } + case "End": { + // Moves focus to the last day (ie. Saturday) of the current week + if (event.ctrlKey) { + // Moves focus to the last day of the current month + let lastDateOfMonth = new Date( + this.state.focusedDate.getUTCFullYear(), + this.state.focusedDate.getUTCMonth() + 1, + 0 + ); + this.state.focusedDate = lastDateOfMonth; + this._updateKeyboardFocus(); + } else { + this._handleKeydownEvent( + this.context.DAYS_IN_A_WEEK - + 1 - + this.state.focusedDate.getUTCDay() + ); + } + break; + } + case "PageUp": { + // Changes the view to the previous month/year + // and sets focus on the same day. + // If that day does not exist, then moves focus + // to the same day of the same week. + if (event.shiftKey) { + // Previous year + let prevYear = this.state.focusedDate.getUTCFullYear() - 1; + this.state.focusedDate.setUTCFullYear(prevYear); + } else { + // Previous month + let prevMonth = this.state.focusedDate.getUTCMonth() - 1; + this.state.focusedDate.setUTCMonth(prevMonth); + } + this.state.setCalendarMonth( + this.state.focusedDate.getUTCFullYear(), + this.state.focusedDate.getUTCMonth() + ); + this._updateKeyboardFocus(); + break; + } + case "PageDown": { + // Changes the view to the next month/year + // and sets focus on the same day. + // If that day does not exist, then moves focus + // to the same day of the same week. + if (event.shiftKey) { + // Next year + let nextYear = this.state.focusedDate.getUTCFullYear() + 1; + this.state.focusedDate.setUTCFullYear(nextYear); + } else { + // Next month + let nextMonth = this.state.focusedDate.getUTCMonth() + 1; + this.state.focusedDate.setUTCMonth(nextMonth); + } + this.state.setCalendarMonth( + this.state.focusedDate.getUTCFullYear(), + this.state.focusedDate.getUTCMonth() + ); + this._updateKeyboardFocus(); + break; + } + } + } + break; + } + } + }, + + /** + * Attach event listener to daysView + */ + _attachEventListeners() { + this.context.daysView.addEventListener("click", this); + this.context.daysView.addEventListener("keydown", this); + }, + + /** + * Find Data-id of the next element to focus on the daysView grid + * @param {Object} nextDate: Data object of the next element to focus + */ + _calculateNextId(nextDate) { + for (let i = 0; i < this.state.days.length; i++) { + if (this._isSameDay(this.state.days[i].dateObj, nextDate)) { + return i; + } + } + return null; + }, + + /** + * Comparing two date objects to ensure they produce the same date + * @param {Date} dateObj1: Date object from the updated state + * @param {Date} dateObj2: Date object from the previous state + * @return {Boolean} If two date objects are the same day + */ + _isSameDay(dateObj1, dateObj2) { + return ( + dateObj1.getUTCFullYear() == dateObj2.getUTCFullYear() && + dateObj1.getUTCMonth() == dateObj2.getUTCMonth() && + dateObj1.getUTCDate() == dateObj2.getUTCDate() + ); + }, + + /** + * Comparing two date objects to ensure they produce the same day of the month, + * while being on different months + * @param {Date} dateObj1: Date object from the updated state + * @param {Date} dateObj2: Date object from the previous state + * @return {Boolean} If two date objects are the same day of the month + */ + _isSameDayOfMonth(dateObj1, dateObj2) { + return dateObj1.getUTCDate() == dateObj2.getUTCDate(); + }, + + /** + * Manage focus for the keyboard navigation for the daysView grid + * @param {Number} offsetDays: The direction and the number of days to move + * the focus by, where a negative number (i.e. -1) + * moves the focus to the previous day + */ + _handleKeydownEvent(offsetDays) { + let newFocusedDay = this.state.focusedDate.getUTCDate() + offsetDays; + let newFocusedDate = new Date(this.state.focusedDate); + newFocusedDate.setUTCDate(newFocusedDay); + + // Update the month, if the next focused element is outside + if (newFocusedDate.getUTCMonth() !== this.state.focusedDate.getUTCMonth()) { + this.state.setCalendarMonth( + newFocusedDate.getUTCFullYear(), + newFocusedDate.getUTCMonth() + ); + } + this.state.focusedDate.setUTCDate(newFocusedDate.getUTCDate()); + this._updateKeyboardFocus(); + }, + + /** + * Update the daysView grid and send focus to the next day + * based on the current state fo the Calendar + */ + _updateKeyboardFocus() { + this._render({ + elements: this.elements.daysView, + items: this.state.days, + prevState: this.state.days, + }); + this.focusDay(); + }, + + /** + * Place keyboard focus on the calendar grid, when the datepicker is initiated or updated. + * A "tabindex" attribute is provided to only one date within the grid + * by the "render()" method and this focusable element will be focused. + */ + focusDay() { + const focusable = this.context.daysView.querySelector('[tabindex="0"]'); + if (focusable) { + focusable.focus(); + } + }, +}; diff --git a/toolkit/content/widgets/checkbox.js b/toolkit/content/widgets/checkbox.js new file mode 100644 index 0000000000..eaa0017b97 --- /dev/null +++ b/toolkit/content/widgets/checkbox.js @@ -0,0 +1,83 @@ +/* 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 ` + <image class="checkbox-check"/> + <hbox class="checkbox-label-box" flex="1"> + <image class="checkbox-icon"/> + <label class="checkbox-label" flex="1"/> + </hbox> + `; + } + + 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,native", + ".checkbox-label": "text=label,accesskey,native", + ".checkbox-icon": "src,native", + }; + } + + 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); + } + } + + 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..d720491d4b --- /dev/null +++ b/toolkit/content/widgets/datekeeper.js @@ -0,0 +1,424 @@ +/* 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, + // One day in ms since epoch. + ONE_DAY = 86400000; + + 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<Number>} 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 }, + }; + + if (year === undefined) { + year = today.getFullYear(); + } + if (month === undefined) { + month = today.getMonth(); + } + + const minYear = this.state.min.getFullYear(); + const maxYear = this.state.max.getFullYear(); + + // Choose a valid year for the value/min/max properties + const selectedYear = Math.min(Math.max(year, minYear), maxYear); + + // Choose the month that correspond to the selectedYear + let selectedMonth = 0; + + if (selectedYear === year) { + selectedMonth = month; + } else if (selectedYear === minYear) { + selectedMonth = this.state.min.getMonth(); + } else if (selectedYear === maxYear) { + selectedMonth = this.state.max.getMonth(); + } + + this.setCalendarMonth({ + year: selectedYear, + month: selectedMonth, + }); + }, + + /** + * 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<Object>} + * { + * {Number} value: Month in int + * {Boolean} enabled + * } + */ + getMonths() { + let months = []; + + const currentYear = this.year; + + const minYear = this.state.min.getFullYear(); + const minMonth = this.state.min.getMonth(); + const maxYear = this.state.max.getFullYear(); + const maxMonth = this.state.max.getMonth(); + + for (let i = 0; i < MONTHS_IN_A_YEAR; i++) { + const disabled = + (currentYear == minYear && i < minMonth) || + (currentYear == maxYear && i > maxMonth); + months.push({ + value: i, + enabled: !disabled, + }); + } + + return months; + }, + + /** + * Generate the array of years + * @return {Array<Object>} + * { + * {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; + + const minYear = Math.max(this.state.min.getFullYear(), 1); + const maxYear = Math.min(this.state.max.getFullYear(), MAX_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 >= minYear && year <= maxYear) { + years.push({ + value: year, + enabled: true, + }); + } + } + this.state.years = years; + } + return this.state.years; + }, + + /** + * Get days for calendar + * @return {Array<Object>} + * { + * {Date} dateObj + * {Number} content + * {Array<String>} 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(); + // The date is at 00:00, so if the minimum is that day at e.g. 01:00, + // we should arguably still be able to select that date. So we need to + // compare the date at the very end of the day for minimum purposes. + const isOutOfRange = + dateObj.getTime() + ONE_DAY - 1 < 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<Number>} weekends + * @return {Array<Object>} + * { + * {Number} content + * {Array<String>} 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..f771add298 --- /dev/null +++ b/toolkit/content/widgets/datepicker.js @@ -0,0 +1,597 @@ +/* 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<Number>} weekends + * {Array<String>} monthStrings + * {Array<String>} weekdayStrings + * {String} locale [optional]: User preferred locale + * } + */ + init(props = {}) { + this.props = props; + this._setDefaultState(); + this._createComponents(); + this._update(); + this.components.calendar.focusDay(); + // TODO(bug 1828721): This is a bit sad. + window.PICKER_READY = true; + 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(); + }, + setMonthByOffset: offset => { + dateKeeper.setMonthByOffset(offset); + this._update(); + }, + 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, + // Year and month could be changed without changing a selection + setCalendarMonth: (year, month) => { + this.state.dateKeeper.setCalendarMonth({ + year, + month, + }); + this._update(); + }, + 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; + + const calendarEls = [ + this.context.buttonPrev, + this.context.buttonNext, + this.context.weekHeader.parentNode, + this.context.buttonClear, + ]; + // Update MonthYear state and toggle visibility for sighted users + // and for assistive technology: + this.context.monthYearView.hidden = !isMonthPickerVisible; + for (let el of calendarEls) { + el.hidden = isMonthPickerVisible; + } + this.context.monthYearNav.toggleAttribute( + "monthPickerVisible", + isMonthPickerVisible + ); + 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, + }); + }, + + /** + * Use postMessage to close the picker. + */ + _closePopup(clear = false) { + window.postMessage( + { + name: "ClosePopup", + detail: clear, + }, + "*" + ); + }, + + /** + * 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); + document.addEventListener("keydown", this); + }, + + /** + * Handle events. + * + * @param {Event} event + */ + handleEvent(event) { + switch (event.type) { + case "message": { + this.handleMessage(event); + break; + } + case "keydown": { + switch (event.key) { + case "Enter": + case " ": + case "Escape": { + // If the target is a toggle or a spinner on the month-year panel + const isOnMonthPicker = + this.context.monthYearView.parentNode.contains(event.target); + + if (this.state.isMonthPickerVisible && isOnMonthPicker) { + // While a control on the month-year picker panel is focused, + // keep the spinner's selection and close the month-year dialog + event.stopPropagation(); + event.preventDefault(); + this.state.toggleMonthPicker(); + this.components.calendar.focusDay(); + break; + } + if (event.key == "Escape") { + // Close the date picker on Escape from within the picker + this._closePopup(); + break; + } + if (event.target == this.context.buttonPrev) { + event.target.classList.add("active"); + this.state.setMonthByOffset(-1); + this.context.buttonPrev.focus(); + } else if (event.target == this.context.buttonNext) { + event.target.classList.add("active"); + this.state.setMonthByOffset(1); + this.context.buttonNext.focus(); + } else if (event.target == this.context.buttonClear) { + event.target.classList.add("active"); + this._closePopup(/* clear = */ true); + } + break; + } + case "Tab": { + // Manage tab order of a daysView to prevent keyboard trap + if (event.target.tagName === "td") { + if (event.shiftKey) { + this.context.buttonNext.focus(); + } else if (!event.shiftKey) { + this.context.buttonClear.focus(); + } + event.stopPropagation(); + event.preventDefault(); + } + break; + } + } + break; + } + case "mousedown": { + // Use preventDefault to keep focus on input boxes + event.preventDefault(); + event.target.setPointerCapture(event.pointerId); + + if (event.target == this.context.buttonClear) { + event.target.classList.add("active"); + this._closePopup(/* clear = */ true); + } else 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": { + event.target.releasePointerCapture(event.pointerId); + + if ( + event.target == this.context.buttonPrev || + event.target == this.context.buttonNext + ) { + event.target.classList.remove("active"); + } + 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 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<String>} 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._updateButtonLabels(); + this._attachEventListeners(); + } + + MonthYear.prototype = { + /** + * Set new properties and pass them to components + * + * @param {Object} props + * { + * {Boolean} isVisible + * {Date} dateObj + * {Array<Object>} months + * {Array<Object>} years + * {Function} toggleMonthPicker + * } + */ + setProps(props) { + this.context.monthYear.textContent = this.state.dateFormat(props.dateObj); + const spinnerDialog = this.context.monthYearView.parentNode; + + if (props.isVisible) { + this.context.monthYear.classList.add("active"); + this.context.monthYear.setAttribute("aria-expanded", "true"); + // To prevent redundancy, as spinners will announce their value on change + this.context.monthYear.setAttribute("aria-live", "off"); + 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; + + // Set up spinner dialog container properties for assistive technology: + spinnerDialog.setAttribute("role", "dialog"); + spinnerDialog.setAttribute("aria-modal", "true"); + } else { + this.context.monthYear.classList.remove("active"); + this.context.monthYear.setAttribute("aria-expanded", "false"); + // To ensure calendar month's changes are announced: + this.context.monthYear.setAttribute("aria-live", "polite"); + // Remove spinner dialog container properties to ensure this hidden + // modal will be ignored by assistive technology, because even though + // the dialog is hidden, the toggle button is a visible descendant, + // so we must not treat its container as a dialog: + spinnerDialog.removeAttribute("role"); + spinnerDialog.removeAttribute("aria-modal"); + 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; + } + case "keydown": { + if (event.key === "Enter" || event.key === " ") { + event.stopPropagation(); + event.preventDefault(); + this.props.toggleMonthPicker(); + } + break; + } + } + }, + + /** + * Update localizable IDs of the spinner and its Prev/Next buttons + */ + _updateButtonLabels() { + document.l10n.setAttributes( + this.components.month.elements.spinner, + "date-spinner-month" + ); + document.l10n.setAttributes( + this.components.year.elements.spinner, + "date-spinner-year" + ); + document.l10n.setAttributes( + this.components.month.elements.up, + "date-spinner-month-previous" + ); + document.l10n.setAttributes( + this.components.month.elements.down, + "date-spinner-month-next" + ); + document.l10n.setAttributes( + this.components.year.elements.up, + "date-spinner-year-previous" + ); + document.l10n.setAttributes( + this.components.year.elements.down, + "date-spinner-year-next" + ); + document.l10n.translateRoots(); + }, + + /** + * Attach event listener to monthYear button + */ + _attachEventListeners() { + this.context.monthYear.addEventListener("click", this); + this.context.monthYear.addEventListener("keydown", this); + }, + }; +} diff --git a/toolkit/content/widgets/datetimebox.css b/toolkit/content/widgets/datetimebox.css new file mode 100644 index 0000000000..4c8914ace4 --- /dev/null +++ b/toolkit/content/widgets/datetimebox.css @@ -0,0 +1,107 @@ +/* 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/. */ + +.datetimebox { + display: flex; + line-height: normal; + /* 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; + + &:focus { + background-color: Highlight; + color: HighlightText; + outline: none; + } +} + +.datetime-calendar-button { + -moz-context-properties: fill; + color: inherit; + font-size: inherit; + fill: currentColor; + opacity: .65; + background-color: transparent; + border: none; + border-radius: 0.2em; + flex: none; + margin-block: 0; + margin-inline: 0.075em 0.15em; + padding: 0 0.15em; + line-height: 1; + + &:focus-visible { + outline: 0.15em solid SelectedItem; + } + + &:focus-visible, + &:hover { + opacity: 1; + } + + @media (prefers-contrast) { + opacity: 1; + background-color: ButtonFace; + color: ButtonText; + + > .datetime-calendar-button-svg { + background-color: ButtonFace; + -moz-context-properties: fill; + fill: ButtonText; + } + + &:focus-visible, + &:hover { + background-color: SelectedItem; + + > .datetime-calendar-button-svg { + background-color: SelectedItem; + -moz-context-properties: fill; + fill: SelectedItemText; + } + } + } +} + +.datetime-calendar-button-svg { + pointer-events: none; + /* When using a very small font-size, we don't want the button to take extra + * space (which will affect the baseline of the form control) */ + max-width: 1em; + max-height: 1em; +} + +:host(:is(:disabled, :read-only, [type="time"])) .datetime-calendar-button { + display: none; +} + +:host(:is(:disabled, :read-only)) .datetime-edit-field { + user-select: none; + pointer-events: none; + -moz-user-focus: none; +} diff --git a/toolkit/content/widgets/datetimebox.js b/toolkit/content/widgets/datetimebox.js new file mode 100644 index 0000000000..28b32fddfa --- /dev/null +++ b/toolkit/content/widgets/datetimebox.js @@ -0,0 +1,1546 @@ +/* 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; + // The DOMLocalization instance needs to allow for sync methods so that + // the placeholder value may be determined and set during the + // createEditFieldAndAppend() call. + this.l10n = new this.window.DOMLocalization( + ["toolkit/global/datetimebox.ftl"], + /* aSync = */ true + ); + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.onchange(/* aDestroy = */ false); + } + + /* + * Callback called by UAWidgets when the "type" property changes. + */ + onchange(aDestroy = true) { + let newType = this.element.type; + if (this.type == newType) { + return; + } + + if (aDestroy) { + this.teardown(); + } + this.type = newType; + this.setup(); + } + + shouldShowTime() { + return this.type == "time" || this.type == "datetime-local"; + } + + shouldShowDate() { + return this.type == "date" || this.type == "datetime-local"; + } + + teardown() { + this.mInputElement.removeEventListener("keydown", this, { + capture: true, + mozSystemGroup: true, + }); + this.mInputElement.removeEventListener("click", this, { + mozSystemGroup: true, + }); + + this.CONTROL_EVENTS.forEach(eventName => { + this.mDateTimeBoxElement.removeEventListener(eventName, this); + }); + + this.l10n.disconnectRoot(this.shadowRoot); + + this.removeEditFields(); + this.removeEventListenersToField(this.mCalendarButton); + + this.mInputElement = null; + + this.shadowRoot.firstChild.remove(); + } + + removeEditFields() { + this.removeEventListenersToField(this.mYearField); + this.removeEventListenersToField(this.mMonthField); + this.removeEventListenersToField(this.mDayField); + this.removeEventListenersToField(this.mHourField); + this.removeEventListenersToField(this.mMinuteField); + this.removeEventListenersToField(this.mSecondField); + this.removeEventListenersToField(this.mMillisecField); + this.removeEventListenersToField(this.mDayPeriodField); + + this.mYearField = null; + this.mMonthField = null; + this.mDayField = null; + this.mHourField = null; + this.mMinuteField = null; + this.mSecondField = null; + this.mMillisecField = null; + this.mDayPeriodField = null; + + let root = this.shadowRoot.getElementById("edit-wrapper"); + while (root.firstChild) { + root.firstChild.remove(); + } + } + + rebuildEditFieldsIfNeeded() { + if ( + this.shouldShowSecondField() == !!this.mSecondField && + this.shouldShowMillisecField() == !!this.mMillisecField + ) { + return; + } + + let focused = this.mInputElement.matches(":focus"); + + this.removeEditFields(); + this.buildEditFields(); + + if (focused) { + this._focusFirstField(); + } + } + + _focusFirstField() { + this.shadowRoot.querySelector(".datetime-edit-field")?.focus(); + } + + setup() { + this.DEBUG = false; + + this.l10n.connectRoot(this.shadowRoot); + + this.generateContent(); + + this.mDateTimeBoxElement = this.shadowRoot.firstChild; + this.mCalendarButton = this.shadowRoot.getElementById("calendar-button"); + 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.mIsPickerOpen = false; + + this.mMinMonth = 1; + this.mMaxMonth = 12; + this.mMinDay = 1; + this.mMaxDay = 31; + this.mMinYear = 1; + // Maximum year limited by ISO 8601. + this.mMaxYear = 9999; + this.mMonthDayLength = 2; + this.mYearLength = 4; + this.mMonthPageUpDownInterval = 3; + this.mDayPageUpDownInterval = 7; + this.mYearPageUpDownInterval = 10; + + 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.mInputElement.addEventListener( + "keydown", + this, + { + capture: true, + mozSystemGroup: true, + }, + false + ); + // This is to open the picker when input element is tapped on Android + // or for type=time inputs (this includes padding area). + this.isAndroid = this.window.navigator.appVersion.includes("Android"); + if (this.isAndroid || this.type == "time") { + this.mInputElement.addEventListener( + "click", + this, + { mozSystemGroup: true }, + false + ); + } + + // Those events are dispatched to <div class="datetimebox"> 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); + }); + + this.buildEditFields(); + this.buildCalendarBtn(); + this.updateEditAttributes(); + + if (this.mInputElement.value) { + this.setFieldsFromInputValue(); + } + + if (this.mInputElement.matches(":focus")) { + this._focusFirstField(); + } + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="datetimebox" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" type="text/css" href="chrome://global/content/bindings/datetimebox.css" /> + <div class="datetime-input-box-wrapper" id="input-box-wrapper" role="presentation"> + <span class="datetime-input-edit-wrapper" + id="edit-wrapper"> + <!-- Each of the date/time input types will append their input child + - elements here --> + </span> + <button data-l10n-id="datetime-calendar" class="datetime-calendar-button" id="calendar-button" aria-expanded="false"> + <svg role="none" class="datetime-calendar-button-svg" xmlns="http://www.w3.org/2000/svg" id="calendar-16" viewBox="0 0 16 16" width="16" height="16"> + <path d="M13.5 2H13V1c0-.6-.4-1-1-1s-1 .4-1 1v1H5V1c0-.6-.4-1-1-1S3 .4 3 1v1h-.5C1.1 2 0 3.1 0 4.5v9C0 14.9 1.1 16 2.5 16h11c1.4 0 2.5-1.1 2.5-2.5v-9C16 3.1 14.9 2 13.5 2zm0 12.5h-11c-.6 0-1-.4-1-1V6h13v7.5c0 .6-.4 1-1 1z"/> + </svg> + </button> + </div> + </div>`, + "application/xml" + ); + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n.translateRoots(); + } + + get FIELD_EVENTS() { + return ["focus", "blur", "copy", "cut", "paste"]; + } + + get CONTROL_EVENTS() { + return [ + "MozDateTimeValueChanged", + "MozNotifyMinMaxStepAttrChanged", + "MozDateTimeAttributeChanged", + "MozPickerValueChanged", + "MozSetDateTimePickerState", + "MozDateTimeShowPickerForJS", + ]; + } + + get showPickerOnClick() { + return this.isAndroid || this.type == "time"; + } + + 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 <input> + 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( + aL10nId, + aPlaceholderId, + 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.setAttribute("aria-valuetext", ""); + this.setFieldTabIndexAttribute(field); + + const placeholder = this.l10n.formatValueSync(aPlaceholderId); + field.placeholder = placeholder; + field.textContent = placeholder; + this.l10n.setAttributes(field, aL10nId); + + // 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; + } + + updateCalendarButtonState(isExpanded) { + this.mCalendarButton.setAttribute("aria-expanded", isExpanded); + } + + notifyInputElementValueChanged() { + this.log("inputElementValueChanged"); + this.setFieldsFromInputValue(); + } + + notifyMinMaxStepAttrChanged() { + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + // Fill in values again. + this.setFieldsFromInputValue(); + } + + setValueFromPicker(aValue) { + if (aValue) { + this.setFieldsFromPicker(aValue); + } else { + this.clearInputFields(); + } + } + + advanceToNextField(aReverse) { + this.log("advanceToNextField"); + + let focusedInput = this.mLastFocusedElement; + 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; + // Calendar button's expanded state mirrors this.mIsPickerOpen + this.updateCalendarButtonState(this.mIsPickerOpen); + } + + setFieldTabIndexAttribute(field) { + field.tabIndex = this.mInputElement.tabIndex; + } + + updateEditAttributes() { + this.log("updateEditAttributes"); + + let editRoot = this.shadowRoot.getElementById("edit-wrapper"); + + for (let child of editRoot.querySelectorAll( + ":scope > span.datetime-edit-field" + )) { + this.setFieldTabIndexAttribute(child); + } + } + + 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", ""); + aField.setAttribute("aria-valuetext", ""); + if (aField.classList.contains("numeric")) { + aField.setAttribute("typeBuffer", ""); + } + } + + openDateTimePicker() { + this.mInputElement.openDateTimePicker(this.getCurrentValue()); + } + + closeDateTimePicker() { + if (this.mIsPickerOpen) { + this.mInputElement.closeDateTimePicker(); + } + } + + notifyPicker() { + if (this.mIsPickerOpen && this.isAnyFieldAvailable(true)) { + this.mInputElement.updateDateTimePicker(this.getCurrentValue()); + } + } + + isDisabled() { + return this.mInputElement.matches(":disabled"); + } + + isReadonly() { + return this.mInputElement.matches(":read-only"); + } + + 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 "MozDateTimeAttributeChanged": { + this.updateEditAttributes(); + break; + } + case "MozPickerValueChanged": { + this.setValueFromPicker(aEvent.detail); + break; + } + case "MozSetDateTimePickerState": { + this.setPickerState(aEvent.detail); + break; + } + case "MozDateTimeShowPickerForJS": { + this.openDateTimePicker(); + break; + } + case "keydown": { + this.onKeyDown(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(".datetime-edit-field,.datetime-calendar-button")) { + if (target.disabled) { + return; + } + this.mLastFocusedElement = target; + this.mInputElement.setFocusState(true); + } + if (this.mIsPickerOpen && this.isPickerIrrelevantField(target)) { + this.closeDateTimePicker(); + } + } + + onBlur(aEvent) { + this.log( + "onBlur originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + + " rt: " + + aEvent.relatedTarget + + " open: " + + this.mIsPickerOpen + ); + + let target = aEvent.originalTarget; + target.setAttribute("typeBuffer", ""); + this.setInputValueFromFields(); + // No need to set and unset the focus state (or closing the picker) if the + // focus is staying within our input. + if (aEvent.relatedTarget == this.mInputElement) { + return; + } + + // If we're in chrome and the focus moves to a separate document + // (relatedTarget is null) we also don't want to close it, since it + // could've moved to the datetime popup itself. + if ( + !aEvent.relatedTarget && + this.window.isChromeWindow && + this.window == this.window.top + ) { + return; + } + + this.mInputElement.setFocusState(false); + if (this.mIsPickerOpen) { + this.closeDateTimePicker(); + } + } + + isTimeField(field) { + return ( + field == this.mHourField || + field == this.mMinuteField || + field == this.mSecondField || + field == this.mDayPeriodField + ); + } + + shouldOpenDateTimePickerOnKeyDown() { + if (!this.mLastFocusedElement) { + return true; + } + return !this.isPickerIrrelevantField(this.mLastFocusedElement); + } + + shouldOpenDateTimePickerOnClick(target) { + return !this.isPickerIrrelevantField(target); + } + + // Whether a given field is irrelevant for the purposes of the datetime + // picker. This is useful for datetime-local, which as of right now only + // shows a date picker (not a time picker). + isPickerIrrelevantField(field) { + if (this.type != "datetime-local") { + return false; + } + return this.isTimeField(field); + } + + onKeyDown(aEvent) { + this.log("onKeyDown key: " + aEvent.key); + + switch (aEvent.key) { + // Toggle the picker on Space/Enter on Calendar button or Space on input, + // close on Escape anywhere. + case "Escape": { + if (this.mIsPickerOpen) { + this.closeDateTimePicker(); + aEvent.preventDefault(); + } + break; + } + case "Enter": + case " ": { + // always close, if opened + if (this.mIsPickerOpen) { + this.closeDateTimePicker(); + } else if ( + // open on Space from anywhere within the input + aEvent.key == " " && + this.shouldOpenDateTimePickerOnKeyDown() + ) { + this.openDateTimePicker(); + } else if ( + // open from the Calendar button on either keydown + aEvent.originalTarget == this.mCalendarButton && + this.shouldOpenDateTimePickerOnKeyDown() + ) { + this.openDateTimePicker(); + } else { + // Don't preventDefault(); + break; + } + aEvent.preventDefault(); + break; + } + case "Delete": + case "Backspace": { + if (aEvent.originalTarget == this.mCalendarButton) { + // Do not remove Calendar button + aEvent.preventDefault(); + break; + } + if (this.isEditable()) { + // TODO(emilio, bug 1571533): These functions should look at + // defaultPrevented. + // Ctrl+Backspace/Delete on non-macOS and + // Cmd+Backspace/Delete on macOS to clear the field + if (aEvent.getModifierState("Accel")) { + // Clear the input's value + this.clearInputFields(false); + } else { + 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: { + // Handle printable characters (e.g. letters, digits and numpad digits) + if ( + aEvent.key.length === 1 && + !(aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey) + ) { + this.handleKeydown(aEvent); + aEvent.preventDefault(); + } + break; + } + } + } + + onClick(aEvent) { + this.log( + "onClick originalTarget: " + + aEvent.originalTarget + + " target: " + + aEvent.target + ); + + if (aEvent.defaultPrevented || !this.isEditable()) { + return; + } + + // We toggle the picker on click on the Calendar button on any platform. + // For Android and for type=time inputs, we also toggle the picker when + // clicking on the input field. + // + // We do not toggle the picker when clicking the input field for Calendar + // on desktop to avoid interfering with the default Calendar behavior. + if ( + aEvent.originalTarget == this.mCalendarButton || + this.showPickerOnClick + ) { + if ( + !this.mIsPickerOpen && + this.shouldOpenDateTimePickerOnClick(aEvent.originalTarget) + ) { + this.openDateTimePicker(); + } else { + this.closeDateTimePicker(); + } + } + } + + buildEditFields() { + let root = this.shadowRoot.getElementById("edit-wrapper"); + + let options = {}; + + if (this.shouldShowTime()) { + options.hour = options.minute = "numeric"; + options.hour12 = this.mHour12; + if (this.shouldShowSecondField()) { + options.second = "numeric"; + } + } + + if (this.shouldShowDate()) { + options.year = options.month = options.day = "numeric"; + } + + let formatter = Intl.DateTimeFormat(this.mLocales, options); + formatter.formatToParts(Date.now()).map(part => { + switch (part.type) { + case "year": + this.mYearField = this.createEditFieldAndAppend( + "datetime-year", + "datetime-year-placeholder", + true, + this.mYearLength, + this.mMaxYear.toString().length, + this.mMinYear, + this.mMaxYear, + this.mYearPageUpDownInterval + ); + this.addEventListenersToField(this.mYearField); + break; + case "month": + this.mMonthField = this.createEditFieldAndAppend( + "datetime-month", + "datetime-month-placeholder", + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinMonth, + this.mMaxMonth, + this.mMonthPageUpDownInterval + ); + this.addEventListenersToField(this.mMonthField); + break; + case "day": + this.mDayField = this.createEditFieldAndAppend( + "datetime-day", + "datetime-day-placeholder", + true, + this.mMonthDayLength, + this.mMonthDayLength, + this.mMinDay, + this.mMaxDay, + this.mDayPageUpDownInterval + ); + this.addEventListenersToField(this.mDayField); + break; + case "hour": + this.mHourField = this.createEditFieldAndAppend( + "datetime-hour", + "datetime-time-placeholder", + true, + this.mMaxLength, + this.mMaxLength, + this.mMinHour, + this.mMaxHour, + this.mHourPageUpDownInterval + ); + this.addEventListenersToField(this.mHourField); + break; + case "minute": + this.mMinuteField = this.createEditFieldAndAppend( + "datetime-minute", + "datetime-time-placeholder", + true, + this.mMaxLength, + this.mMaxLength, + this.mMinMinute, + this.mMaxMinute, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMinuteField); + break; + case "second": + this.mSecondField = this.createEditFieldAndAppend( + "datetime-second", + "datetime-time-placeholder", + 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( + "datetime-millisecond", + "datetime-time-placeholder", + true, + this.mMillisecMaxLength, + this.mMillisecMaxLength, + this.mMinMillisecond, + this.mMaxMillisecond, + this.mMinSecPageUpDownInterval + ); + this.addEventListenersToField(this.mMillisecField); + } + break; + case "dayPeriod": + this.mDayPeriodField = this.createEditFieldAndAppend( + "datetime-dayperiod", + "datetime-time-placeholder", + 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; + } + }); + } + + buildCalendarBtn() { + this.addEventListenersToField(this.mCalendarButton); + // This is to open the picker when a Calendar button is clicked (this + // includes padding area). + this.mCalendarButton.addEventListener( + "click", + this, + { mozSystemGroup: true }, + false + ); + } + + 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 (this.mHourField) { + this.clearFieldValue(this.mHourField); + } + + if (this.mMinuteField) { + this.clearFieldValue(this.mMinuteField); + } + + if (this.mSecondField) { + this.clearFieldValue(this.mSecondField); + } + + if (this.mMillisecField) { + this.clearFieldValue(this.mMillisecField); + } + + if (this.mDayPeriodField) { + this.clearFieldValue(this.mDayPeriodField); + } + + if (!aFromInputElement) { + if (this.mInputElement.value) { + this.mInputElement.setUserInput(""); + } else { + this.mInputElement.updateValidityState(); + } + } + } + + setFieldsFromInputValue() { + // Second and millisecond part are optional, rebuild edit fields if + // needed. + this.rebuildEditFieldsIfNeeded(); + + let value = this.mInputElement.value; + if (!value) { + this.clearInputFields(true); + return; + } + + let { year, month, day, hour, minute, second, millisecond } = + this.getInputElementValues(); + if (this.shouldShowDate()) { + this.log("setFieldsFromInputValue: " + value); + this.setFieldValue(this.mYearField, year); + this.setFieldValue(this.mMonthField, month); + this.setFieldValue(this.mDayField, day); + } + + if (this.shouldShowTime()) { + if (this.isEmpty(hour) && this.isEmpty(minute)) { + this.clearInputFields(true); + return; + } + + this.setFieldValue(this.mHourField, hour); + this.setFieldValue(this.mMinuteField, minute); + if (this.mHour12) { + this.setDayPeriodValue( + hour >= this.mMaxHour ? this.mPMIndicator : this.mAMIndicator + ); + } + + if (this.mSecondField) { + this.setFieldValue(this.mSecondField, second || 0); + } + + if (this.mMillisecField) { + this.setFieldValue(this.mMillisecField, 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 { year, month, day, hour, minute, second, millisecond, dayPeriod } = + this.getCurrentValue(); + + let time = ""; + let date = ""; + + // Convert to a valid time string according to: + // https://html.spec.whatwg.org/multipage/infrastructure.html#valid-time-string + if (this.shouldShowTime()) { + 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; + + 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 (this.shouldShowDate()) { + // 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; + date = [year, month, day].join("-"); + } + + let value; + if (date) { + value = date; + } + if (time) { + // https://html.spec.whatwg.org/#valid-normalised-local-date-and-time-string + value = value ? value + "T" + time : time; + } + + if (value == this.mInputElement.value) { + return; + } + this.log("setInputValueFromFields: " + value); + this.notifyPicker(); + this.mInputElement.setUserInput(value); + } + + setFieldsFromPicker({ year, month, day, 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); + } + + 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(); + } + + handleKeydown(aEvent) { + if (!this.isEditable()) { + return; + } + + let targetField = aEvent.originalTarget; + let key = aEvent.key; + + if (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 (targetField == this.mHourField) { + if (n * 10 > 23 || buffer.length === 2) { + buffer = ""; + this.advanceToNextField(); + } + } else if (buffer.length >= maxLength || n * 10 > max) { + buffer = ""; + this.advanceToNextField(); + } + targetField.setAttribute("typeBuffer", buffer); + if (!this.isAnyFieldEmpty()) { + this.setInputValueFromFields(); + } + } + } + + getCurrentValue() { + let value = {}; + if (this.shouldShowDate()) { + value.year = this.getFieldValue(this.mYearField); + value.month = this.getFieldValue(this.mMonthField); + value.day = this.getFieldValue(this.mDayField); + } + + if (this.shouldShowTime()) { + let dayPeriod = this.getDayPeriodValue(); + let hour = this.getFieldValue(this.mHourField); + if (!this.isEmpty(hour)) { + if (this.mHour12) { + if (dayPeriod == this.mPMIndicator && hour < this.mMaxHour) { + hour += this.mMaxHour; + } else if (dayPeriod == this.mAMIndicator && hour == this.mMaxHour) { + hour = 0; + } + } + } + value.hour = hour; + value.dayPeriod = dayPeriod; + value.minute = this.getFieldValue(this.mMinuteField); + value.second = this.getFieldValue(this.mSecondField); + value.millisecond = this.getFieldValue(this.mMillisecField); + } + + this.log("getCurrentValue: " + JSON.stringify(value)); + return value; + } + + 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. + switch (true) { + case value == 0 && aValue.length == 2: + value = this.mMaxHour; + this.setDayPeriodValue(this.mAMIndicator); + break; + + case value == this.mMaxHour: + this.setDayPeriodValue(this.mPMIndicator); + break; + + case value < 12: + if (!this.getDayPeriodValue()) { + this.setDayPeriodValue(this.mAMIndicator); + } + break; + + case value > 12 && value < 24: + value = value % this.mMaxHour; + this.setDayPeriodValue(this.mPMIndicator); + break; + + default: + value = Math.floor(value / 10); + break; + } + } else if (value > this.mMaxHour) { + value = this.mMaxHour; + } + } + + 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); + + let minDigits = aField.getAttribute("mindigits"); + let formatted = value.toLocaleString(this.mLocales, { + minimumIntegerDigits: minDigits, + useGrouping: false, + }); + + aField.textContent = formatted; + aField.setAttribute("aria-valuetext", formatted); + } + + isAnyFieldAvailable(aForPicker = false) { + let { year, month, day, hour, minute, second, millisecond } = + this.getCurrentValue(); + if ( + !this.isEmpty(year) || + !this.isEmpty(month) || + !this.isEmpty(day) || + !this.isEmpty(hour) || + !this.isEmpty(minute) + ) { + return true; + } + + // Picker doesn't care about seconds / milliseconds / day period. + if (aForPicker) { + return false; + } + + let dayPeriod = this.getDayPeriodValue(); + return ( + (this.mDayPeriodField && !this.isEmpty(dayPeriod)) || + (this.mSecondField && !this.isEmpty(second)) || + (this.mMillisecField && !this.isEmpty(millisecond)) + ); + } + + isAnyFieldEmpty() { + let { year, month, day, hour, minute, second, millisecond } = + this.getCurrentValue(); + return ( + (this.mYearField && this.isEmpty(year)) || + (this.mMonthField && this.isEmpty(month)) || + (this.mDayField && this.isEmpty(day)) || + (this.mHourField && this.isEmpty(hour)) || + (this.mMinuteField && this.isEmpty(minute)) || + (this.mDayPeriodField && this.isEmpty(this.getDayPeriodValue())) || + (this.mSecondField && this.isEmpty(second)) || + (this.mMillisecField && this.isEmpty(millisecond)) + ); + } + + get kMsPerSecond() { + return 1000; + } + + get kMsPerMinute() { + return 60 * 1000; + } + + getInputElementValues() { + let value = this.mInputElement.value; + if (value.length === 0) { + return {}; + } + + let date, time; + + let year, month, day, hour, minute, second, millisecond; + if (this.type == "date") { + date = value; + } + if (this.type == "time") { + time = value; + } + if (this.type == "datetime-local") { + // https://html.spec.whatwg.org/#valid-normalised-local-date-and-time-string + [date, time] = value.split("T"); + } + if (date) { + [year, month, day] = date.split("-"); + } + if (time) { + [hour, minute, second] = time.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 { year, month, day, hour, minute, second, millisecond }; + } + + shouldShowSecondField() { + if (!this.shouldShowTime()) { + return false; + } + 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() { + if (!this.shouldShowTime()) { + return false; + } + + 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; + } + + getStringsForLocale(aLocales) { + this.log("getStringsForLocale: " + aLocales); + + let intlUtils = this.window.intlUtils; + if (!intlUtils) { + return {}; + } + + let result = intlUtils.getDisplayNames(this.mLocales, { + type: "dayPeriod", + style: "short", + calendar: "gregory", + keys: ["am", "pm"], + }); + + let [amString, pmString] = result.values; + + return { amString, pmString }; + } + + is12HourTime(aLocales) { + let options = new Intl.DateTimeFormat(aLocales, { + hour: "numeric", + }).resolvedOptions(); + + return options.hour12; + } + + 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.mYearField) { + value = now.getFullYear(); + } else if (aTargetField == this.mMonthField) { + value = now.getMonth() + 1; + } else if (aTargetField == this.mDayField) { + value = now.getDate(); + } else 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 (targetField == this.mYearField && (key == "Home" || key == "End")) { + // Home/End key does nothing on year field. + return; + } + + if (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(); + } + + getDayPeriodValue() { + if (!this.mDayPeriodField) { + return ""; + } + + let placeholder = this.mDayPeriodField.placeholder; + let value = this.mDayPeriodField.textContent; + return value == placeholder ? "" : value; + } + + setDayPeriodValue(aValue) { + if (!this.mDayPeriodField) { + return; + } + + this.mDayPeriodField.textContent = aValue; + this.mDayPeriodField.setAttribute("value", aValue); + } +}; diff --git a/toolkit/content/widgets/dialog.js b/toolkit/content/widgets/dialog.js new file mode 100644 index 0000000000..52eb2168f8 --- /dev/null +++ b/toolkit/content/widgets/dialog.js @@ -0,0 +1,545 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + class MozDialog extends MozXULElement { + constructor() { + super(); + } + + 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 ` + <html:link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + `; + } + + get _markup() { + let buttons = AppConstants.XP_UNIX + ? ` + <hbox class="dialog-button-box"> + <button dlgtype="disclosure" hidden="true"/> + <button dlgtype="extra2" hidden="true"/> + <button dlgtype="extra1" hidden="true"/> + <spacer class="button-spacer" part="button-spacer" flex="1"/> + <button dlgtype="cancel"/> + <button dlgtype="accept"/> + </hbox>` + : ` + <hbox class="dialog-button-box" pack="end"> + <button dlgtype="extra2" hidden="true"/> + <spacer class="button-spacer" part="button-spacer" flex="1" hidden="true"/> + <button dlgtype="accept"/> + <button dlgtype="extra1" hidden="true"/> + <button dlgtype="cancel"/> + <button dlgtype="disclosure" hidden="true"/> + </hbox>`; + + return ` + <html:link rel="stylesheet" href="chrome://global/skin/button.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/dialog.css"/> + ${this.hasAttribute("subdialog") ? this.inContentStyle : ""} + <vbox class="box-inherit" part="content-box"> + <html:slot></html:slot> + </vbox> + ${buttons}`; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + if (this.hasConnected) { + return; + } + this.hasConnected = true; + this.attachShadow({ mode: "open" }); + + document.documentElement.setAttribute("role", "dialog"); + document.l10n?.connectRoot(this.shadowRoot); + + this.shadowRoot.textContent = ""; + this.shadowRoot.appendChild( + MozXULElement.parseXULToFragment(this._markup) + ); + this.initializeAttributeInheritance(); + + this._configureButtons(this.buttons); + + window.moveToAlertPosition = this.moveToAlertPosition; + window.centerWindowOnScreen = this.centerWindowOnScreen; + + 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(); + } + }); + + // Call postLoadInit for things that we need to initialize after onload. + if (document.readyState == "complete") { + this._postLoadInit(); + } else { + window.addEventListener("load", event => this._postLoadInit()); + } + } + + set buttons(val) { + this._configureButtons(val); + } + + get buttons() { + return this.getAttribute("buttons"); + } + + set defaultButton(val) { + this._setDefaultButton(val); + } + + get defaultButton() { + if (this.hasAttribute("defaultButton")) { + return this.getAttribute("defaultButton"); + } + return "accept"; // default to the accept button + } + + get _strBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://global/locale/dialog.properties" + ); + } + return this.__stringBundle; + } + + acceptDialog() { + return this._doButtonCommand("accept"); + } + + cancelDialog() { + return this._doButtonCommand("cancel"); + } + + getButton(aDlgType) { + return this._buttons[aDlgType]; + } + + get buttonBox() { + return this.shadowRoot.querySelector(".dialog-button-box"); + } + + // NOTE(emilio): This has to match AppWindow::IntrinsicallySizeShell, to + // prevent flickering, see bug 1799394. + _sizeToPreferredSize() { + const docEl = document.documentElement; + const prefWidth = (() => { + if (docEl.hasAttribute("width")) { + return parseInt(docEl.getAttribute("width")); + } + let prefWidthProp = docEl.getAttribute("prefwidth"); + if (prefWidthProp) { + let minWidth = parseFloat( + getComputedStyle(docEl).getPropertyValue(prefWidthProp) + ); + if (isFinite(minWidth)) { + return minWidth; + } + } + return 0; + })(); + window.sizeToContentConstrained({ prefWidth }); + } + + moveToAlertPosition() { + // hack. we need this so the window has something like its final size + if (window.outerWidth == 1) { + dump( + "Trying to position a sizeless window; caller should have called sizeToContent() or sizeTo(). See bug 75649.\n" + ); + this._sizeToPreferredSize(); + } + + if (opener) { + var xOffset = (opener.outerWidth - window.outerWidth) / 2; + var yOffset = opener.outerHeight / 5; + + var newX = opener.screenX + xOffset; + var newY = opener.screenY + yOffset; + } else { + newX = (screen.availWidth - window.outerWidth) / 2; + newY = (screen.availHeight - window.outerHeight) / 2; + } + + // ensure the window is fully onscreen (if smaller than the screen) + if (newX < screen.availLeft) { + newX = screen.availLeft + 20; + } + if (newX + window.outerWidth > screen.availLeft + screen.availWidth) { + newX = screen.availLeft + screen.availWidth - window.outerWidth - 20; + } + + if (newY < screen.availTop) { + newY = screen.availTop + 20; + } + if (newY + window.outerHeight > screen.availTop + screen.availHeight) { + newY = screen.availTop + screen.availHeight - window.outerHeight - 60; + } + + window.moveTo(newX, newY); + } + + centerWindowOnScreen() { + var xOffset = screen.availWidth / 2 - window.outerWidth / 2; + var yOffset = screen.availHeight / 2 - window.outerHeight / 2; + + xOffset = xOffset > 0 ? xOffset : 0; + yOffset = yOffset > 0 ? yOffset : 0; + window.moveTo(xOffset, yOffset); + } + + // Give focus to the first focusable element in the dialog + _setInitialFocusIfNeeded() { + let focusedElt = document.commandDispatcher.focusedElement; + if (focusedElt) { + return; + } + + const defaultButton = this.getButton(this.defaultButton); + Services.focus.moveFocus( + window, + null, + Services.focus.MOVEFOCUS_FORWARD, + Services.focus.FLAG_NOPARENTFRAME + ); + + focusedElt = document.commandDispatcher.focusedElement; + if (!focusedElt) { + return; // No focusable element? + } + + let firstFocusedElt = focusedElt; + while ( + focusedElt.localName == "tab" || + focusedElt.getAttribute("noinitialfocus") == "true" + ) { + Services.focus.moveFocus( + window, + focusedElt, + Services.focus.MOVEFOCUS_FORWARD, + Services.focus.FLAG_NOPARENTFRAME + ); + focusedElt = document.commandDispatcher.focusedElement; + if (focusedElt == firstFocusedElt) { + if (focusedElt.getAttribute("noinitialfocus") == "true") { + focusedElt.blur(); + } + // Didn't find anything else to focus, we're done. + return; + } + } + + if (firstFocusedElt.localName == "tab") { + if (focusedElt.hasAttribute("dlgtype")) { + // We don't want to focus on anonymous OK, Cancel, etc. buttons, + // so return focus to the tab itself + firstFocusedElt.focus(); + } + } else if ( + AppConstants.platform != "macosx" && + focusedElt.hasAttribute("dlgtype") && + focusedElt != defaultButton + ) { + defaultButton.focus(); + if (document.commandDispatcher.focusedElement != defaultButton) { + // If the default button is not focusable, then return focus to the + // initial element if possible, or blur otherwise. + if (firstFocusedElt.getAttribute("noinitialfocus") == "true") { + focusedElt.blur(); + } else { + firstFocusedElt.focus(); + } + } + } + } + + async _postLoadInit() { + this._setInitialFocusIfNeeded(); + let finalStep = () => { + this._sizeToPreferredSize(); + this._snapCursorToDefaultButtonIfNeeded(); + }; + // As a hack to ensure Windows sizes the window correctly, + // _sizeToPreferredSize() needs to happen after + // AppWindow::OnChromeLoaded. That one is called right after the load + // event dispatch but within the same task. Using direct dispatch let's + // all this code run before the next task (which might be a task to + // paint the window). + // But, MacOS doesn't like resizing after window/dialog becoming visible. + // Linux seems to be able to handle both cases. + if (Services.appinfo.OS == "Darwin") { + finalStep(); + } else { + Services.tm.dispatchDirectTaskToCurrentThread(finalStep); + } + } + + // This snaps the cursor to the default button rect on windows, when + // SPI_GETSNAPTODEFBUTTON is set. + async _snapCursorToDefaultButtonIfNeeded() { + const defaultButton = this.getButton(this.defaultButton); + if (!defaultButton) { + return; + } + try { + // FIXME(emilio, bug 1797624): This setTimeout() ensures enough time + // has passed so that the dialog vertical margin has been set by the + // front-end. For subdialogs, cursor positioning should probably be + // done by the opener instead, once the dialog is positioned. + await new Promise(r => setTimeout(r, 0)); + await window.promiseDocumentFlushed(() => {}); + window.notifyDefaultButtonLoaded(defaultButton); + } catch (e) {} + } + + _configureButtons(aButtons) { + // by default, get all the anonymous button elements + var buttons = {}; + this._buttons = buttons; + + for (let type of ["accept", "cancel", "extra1", "extra2", "disclosure"]) { + buttons[type] = this.shadowRoot.querySelector(`[dlgtype="${type}"]`); + } + + // look for any overriding explicit button elements + var exBtns = this.getElementsByAttribute("dlgtype", "*"); + var dlgtype; + for (let i = 0; i < exBtns.length; ++i) { + dlgtype = exBtns[i].getAttribute("dlgtype"); + buttons[dlgtype].hidden = true; // hide the anonymous button + buttons[dlgtype] = exBtns[i]; + } + + // add the label and oncommand handler to each button + for (dlgtype in buttons) { + var button = buttons[dlgtype]; + button.addEventListener( + "command", + this._handleButtonCommand.bind(this), + true + ); + + // don't override custom labels with pre-defined labels on explicit buttons + if (!button.hasAttribute("label")) { + // dialog attributes override the default labels in dialog.properties + if (this.hasAttribute("buttonlabel" + dlgtype)) { + button.setAttribute( + "label", + this.getAttribute("buttonlabel" + dlgtype) + ); + if (this.hasAttribute("buttonaccesskey" + dlgtype)) { + button.setAttribute( + "accesskey", + this.getAttribute("buttonaccesskey" + dlgtype) + ); + } + } else if (this.hasAttribute("buttonid" + dlgtype)) { + document.l10n.setAttributes( + button, + this.getAttribute("buttonid" + dlgtype) + ); + } else if (dlgtype != "extra1" && dlgtype != "extra2") { + button.setAttribute( + "label", + this._strBundle.GetStringFromName("button-" + dlgtype) + ); + var accessKey = this._strBundle.GetStringFromName( + "accesskey-" + dlgtype + ); + if (accessKey) { + button.setAttribute("accesskey", accessKey); + } + } + } + } + + // ensure that hitting enter triggers the default button command + // eslint-disable-next-line no-self-assign + this.defaultButton = this.defaultButton; + + // if there is a special button configuration, use it + if (aButtons) { + // expect a comma delimited list of dlgtype values + var list = aButtons.split(","); + + // mark shown dlgtypes as true + var shown = { + accept: false, + cancel: false, + disclosure: false, + extra1: false, + extra2: false, + }; + for (let i = 0; i < list.length; ++i) { + shown[list[i].replace(/ /g, "")] = true; + } + + // hide/show the buttons we want + for (dlgtype in buttons) { + buttons[dlgtype].hidden = !shown[dlgtype]; + } + + // show the spacer on Windows only when the extra2 button is present + if (AppConstants.platform == "win") { + let spacer = this.shadowRoot.querySelector(".button-spacer"); + spacer.removeAttribute("hidden"); + spacer.setAttribute("flex", shown.extra2 ? "1" : "0"); + } + } + } + + _setDefaultButton(aNewDefault) { + // remove the default attribute from the previous default button, if any + var oldDefaultButton = this.getButton(this.defaultButton); + if (oldDefaultButton) { + oldDefaultButton.removeAttribute("default"); + } + + var newDefaultButton = this.getButton(aNewDefault); + if (newDefaultButton) { + this.setAttribute("defaultButton", aNewDefault); + newDefaultButton.setAttribute("default", "true"); + } else { + this.setAttribute("defaultButton", "none"); + if (aNewDefault != "none") { + dump( + "invalid new default button: " + aNewDefault + ", assuming: none\n" + ); + } + } + } + + _handleButtonCommand(aEvent) { + return this._doButtonCommand(aEvent.target.getAttribute("dlgtype")); + } + + _doButtonCommand(aDlgType) { + var button = this.getButton(aDlgType); + if (!button.disabled) { + var noCancel = this._fireButtonEvent(aDlgType); + if (noCancel) { + if (aDlgType == "accept" || aDlgType == "cancel") { + var closingEvent = new CustomEvent("dialogclosing", { + bubbles: true, + detail: { button: aDlgType }, + }); + this.dispatchEvent(closingEvent); + window.close(); + } + } + return noCancel; + } + return true; + } + + _fireButtonEvent(aDlgType) { + var event = document.createEvent("Events"); + event.initEvent("dialog" + aDlgType, true, true); + + // handle dom event handlers + return this.dispatchEvent(event); + } + + _hitEnter(evt) { + if (evt.defaultPrevented) { + return; + } + + var btn = this.getButton(this.defaultButton); + if (btn) { + this._doButtonCommand(this.defaultButton); + } + } + + on_focus(event) { + let btn = this.getButton(this.defaultButton); + if (btn) { + btn.setAttribute( + "default", + event.originalTarget == btn || + !( + event.originalTarget.localName == "button" || + event.originalTarget.localName == "toolbarbutton" + ) + ); + } + } + } + + customElements.define("dialog", MozDialog); +} diff --git a/toolkit/content/widgets/docs/index.rst b/toolkit/content/widgets/docs/index.rst new file mode 100644 index 0000000000..2e576357f3 --- /dev/null +++ b/toolkit/content/widgets/docs/index.rst @@ -0,0 +1,10 @@ +=============== +Toolkit Widgets +=============== + +The ``/toolkit/content/widgets`` directory contains XBL bindings, Mozilla Custom Elements, and UA Widgets usable for all applications. + +.. toctree:: + :maxdepth: 1 + + ua_widget diff --git a/toolkit/content/widgets/docs/ua_widget.rst b/toolkit/content/widgets/docs/ua_widget.rst new file mode 100644 index 0000000000..5d01c95a3c --- /dev/null +++ b/toolkit/content/widgets/docs/ua_widget.rst @@ -0,0 +1,58 @@ +UA Widgets +========== + +Introduction +------------ + +User Agent Widgets (UA Widgets) are intended to be a replacement of our usage of XBL bindings in web content. These widgets run JavaScript inside extended principal per-origin sandboxes. They insert their own DOM inside of a special, closed Shadow Root inaccessible to the page, called a UA Widget Shadow Root. + +UA Widget lifecycle +------------------- + +UA Widgets are generally constructed when the element is appended to the document and destroyed when the element is removed from the tree. Yet, in order to be fast, specialization was made to each of the widgets. + +When the element is appended to the tree, a chrome-only ``UAWidgetSetupOrChange`` event is dispatched and is caught by a frame script, namely UAWidgetsChild. + +UAWidgetsChild then grabs the sandbox for that origin (lazily creating it as needed), loads the script as needed, and initializes an instance by calling the JS constructor with a reference to the UA Widget Shadow Root created by the DOM. We will discuss the sandbox in the latter section. + +The ``onsetup`` method is called right after the instance is constructed. The call to constructor must not throw, or UAWidgetsChild will be confused since an instance of the widget will not be returned, but the widget is already half-initalized. If the ``onsetup`` method call throws, UAWidgetsChild will still be able to hold the reference of the widget and call the teardown method later on. + +When the element is removed from the tree, ``UAWidgetTeardown`` is dispatched so UAWidgetsChild can destroy the widget, if it exists. If so, the UAWidgetsChild calls the ``teardown()`` method on the widget, causing the widget to destruct itself. + +Counter-intuitively, elements are not considered "removed from the tree" when the document is unloaded. This is considered safe as anything the widget touches should be reset or cleaned up when the document unloads. Please do not violate the assumption by having any browser state toggled by the teardown. + +When a UA Widget initializes, it should create its own DOM inside the passed UA Widget Shadow Root, including the ``<link>`` element necessary to load the stylesheet, add event listeners, etc. When destroyed (i.e. the teardown method is called), it should do the opposite. + +**Specialization**: for video controls, we do not want to do the work if the control is not needed (i.e. when the ``<video>`` or ``<audio>`` element has no "controls" attribute set), so we forgo dispatching the event from HTMLMediaElement in the BindToTree method. Instead, another ``UAWidgetSetupOrChange`` event will cause the sandbox and the widget instance to construct when the attribute is set to true. The same event is also responsible for triggering the ``onchange()`` method on UA Widgets if the widget is already initialized. + +Likewise, the datetime box widget is only loaded when the ``type`` attribute of an ``<input>`` is either `date` or `time`. + +The specialization does not apply to the lifecycle of the UA Widget Shadow Root. It is always constructed in order to suppress children of the DOM element from the web content from receiving a layout frame. + +UA Widget Shadow Root +--------------------- + +The UA Widget Shadow Root is a closed shadow root, with the UA Widget flag turned on. As a closed shadow root, it may not be accessed by other scripts. It is attached on host element which the spec disallow a shadow root to be attached. + +The UA Widget flag enables the security feature covered in the next section. + +**Side note**: XML pretty print hides its transformed content inside a UA Widget Shadow DOM as well, even though there isn't any JavaScript to run. This is set in order to leverage the same security feature and behaviors there. + +The JavaScript sandbox +---------------------- + +The sandboxes created for UA Widgets are per-origin and set to the expanded principal. This allows the script to access other DOM APIs unavailable to the web content, while keeping its principal tied to the document origin. They are created as needed, backing the lifecycle of the UA Widgets as previously mentioned. These sandbox globals are not associated with any window object because they are shared across all same-origin documents. It is the job of the UA Widget script to hold and manage the references of the window and document objects the widget is being initiated on, by accessing them from the UA Widget Shadow Root instance passed. + +While the closed shadow root technically prevents content from accessing the contents, we want a stronger guarantee to protect against accidental leakage of references to the UA Widget shadow tree into content script. Access to the UA Widget DOM is restricted by having their reflectors set in the UA Widgets scope, as opposed to the normal scope. To accomplish this, we avoid having any script (UA Widget script included) getting a hold of the reference of any created DOM element before appending to the Shadow DOM. Once the element is in the Shadow DOM, the binding mechanism will put the reflector in the desired scope as it is being accessed. + +To avoid creating reflectors before DOM insertion, the available DOM interfaces are limited. For example, instead of ``createElement()`` and ``appendChild()``, the script would have to call ``createElementAndAppendChildAt()`` available on the UA Widget Shadow Root instance, to avoid receiving a reference to the DOM element and thus triggering the creation of its reflector in the wrong scope, before the element is properly associated with the UA Widget shadow tree. To find out the differences, search for ``Func="IsChromeOrUAWidget"`` and ``Func="IsNotUAWidget"`` in in-tree WebIDL files. + +Other things to watch out for +----------------------------- + +As part of the implementation of the Web Platform, it is important to make sure the web-observable characteristics of the widget correctly reflect what the script on the web expects. + +* Do not dispatch non-spec compliant events on the UA Widget Shadow Root host element, as event listeners in web content scripts can access them. +* The layout and the dimensions of the widget should be ready by the time the constructor and ``onsetup`` returns, since they can be detectable as soon as the content script gets the reference of the host element (i.e. when ``appendChild()`` returns). In order to make this easier we load ``<link>`` elements load chrome stylesheets synchronously when inside a UA Widget Shadow DOM. +* There shouldn't be any white-spaces nodes in the Shadow DOM, because UA Widget could be placed inside ``white-space: pre``. See bug 1502205. +* CSP will block inline styles in the Shadow DOM. ``<link>`` is the only safe way to load styles. diff --git a/toolkit/content/widgets/editor.js b/toolkit/content/widgets/editor.js new file mode 100644 index 0000000000..9e5ffb542e --- /dev/null +++ b/toolkit/content/widgets/editor.js @@ -0,0 +1,203 @@ +/* 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`. +{ + /* globals XULFrameElement */ + + class MozEditor extends XULFrameElement { + connectedCallback() { + this._editorContentListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIURIContentListener", + "nsISupportsWeakReference", + ]), + doContent(contentType, isContentPreferred, request, contentHandler) { + return false; + }, + isPreferred(contentType, desiredContentType) { + return false; + }, + canHandleContent(contentType, isContentPreferred, desiredContentType) { + return false; + }, + loadCookie: null, + parentContentListener: null, + }; + + this._finder = null; + + this._fastFind = null; + + this._lastSearchString = null; + + // Make window editable immediately only + // if the "editortype" attribute is supplied + // This allows using same contentWindow for different editortypes, + // where the type is determined during the apps's window.onload handler. + if (this.editortype) { + this.makeEditable(this.editortype, true); + } + } + + get finder() { + if (!this._finder) { + if (!this.docShell) { + return null; + } + + let { Finder } = ChromeUtils.importESModule( + "resource://gre/modules/Finder.sys.mjs" + ); + this._finder = new Finder(this.docShell); + } + return this._finder; + } + + get fastFind() { + if (!this._fastFind) { + if (!("@mozilla.org/typeaheadfind;1" in Cc)) { + return null; + } + + if (!this.docShell) { + return null; + } + + this._fastFind = Cc["@mozilla.org/typeaheadfind;1"].createInstance( + Ci.nsITypeAheadFind + ); + this._fastFind.init(this.docShell); + } + return this._fastFind; + } + + set editortype(val) { + this.setAttribute("editortype", val); + } + + get editortype() { + return this.getAttribute("editortype"); + } + + get currentURI() { + return this.webNavigation.currentURI; + } + + get webBrowserFind() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + + get editingSession() { + return this.docShell.editingSession; + } + + get commandManager() { + return this.webNavigation + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsICommandManager); + } + + set fullZoom(val) { + this.browsingContext.fullZoom = val; + } + + get fullZoom() { + return this.browsingContext.fullZoom; + } + + set textZoom(val) { + this.browsingContext.textZoom = val; + } + + get textZoom() { + return this.browsingContext.textZoom; + } + + get isSyntheticDocument() { + return this.contentDocument.isSyntheticDocument; + } + + get messageManager() { + if (this.frameLoader) { + return this.frameLoader.messageManager; + } + return null; + } + + // Copied from toolkit/content/widgets/browser-custom-element.js. + // 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); + } + + get outerWindowID() { + return this.docShell.outerWindowID; + } + + makeEditable(editortype, waitForUrlLoad) { + let win = this.contentWindow; + this.editingSession.makeWindowEditable( + win, + editortype, + waitForUrlLoad, + true, + false + ); + this.setAttribute("editortype", editortype); + + this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIURIContentListener).parentContentListener = + this._editorContentListener; + } + + getEditor(containingWindow) { + return this.editingSession.getEditorForWindow(containingWindow); + } + + getHTMLEditor(containingWindow) { + var editor = this.editingSession.getEditorForWindow(containingWindow); + return editor.QueryInterface(Ci.nsIHTMLEditor); + } + } + + customElements.define("editor", MozEditor); +} diff --git a/toolkit/content/widgets/findbar.js b/toolkit/content/widgets/findbar.js new file mode 100644 index 0000000000..3cbce11771 --- /dev/null +++ b/toolkit/content/widgets/findbar.js @@ -0,0 +1,1379 @@ +/* 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 { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + const PREFS_TO_OBSERVE_BOOL = new Map([ + ["findAsYouType", "accessibility.typeaheadfind"], + ["manualFAYT", "accessibility.typeaheadfind.manual"], + ["typeAheadLinksOnly", "accessibility.typeaheadfind.linksonly"], + ["entireWord", "findbar.entireword"], + ["highlightAll", "findbar.highlightAll"], + ["useModalHighlight", "findbar.modalHighlight"], + ]); + const PREFS_TO_OBSERVE_INT = new Map([ + ["typeAheadCaseSensitive", "accessibility.typeaheadfind.casesensitive"], + ["matchDiacritics", "findbar.matchdiacritics"], + ]); + const PREFS_TO_OBSERVE_ALL = new Map([ + ...PREFS_TO_OBSERVE_BOOL, + ...PREFS_TO_OBSERVE_INT, + ]); + const TOPIC_MAC_APP_ACTIVATE = "mac_app_activate"; + + class MozFindbar extends MozXULElement { + static get markup() { + return ` + <hbox anonid="findbar-container" class="findbar-container" flex="1" align="center"> + <hbox anonid="findbar-textbox-wrapper" align="stretch"> + <html:input anonid="findbar-textbox" class="findbar-textbox" /> + <toolbarbutton anonid="find-previous" class="findbar-find-previous tabbable" + data-l10n-attrs="tooltiptext" data-l10n-id="findbar-previous" + oncommand="onFindAgainCommand(true);" disabled="true" /> + <toolbarbutton anonid="find-next" class="findbar-find-next tabbable" + data-l10n-id="findbar-next" oncommand="onFindAgainCommand(false);" disabled="true" /> + </hbox> + <checkbox anonid="highlight" class="findbar-highlight tabbable" + data-l10n-id="findbar-highlight-all2" oncommand="toggleHighlight(this.checked);"/> + <checkbox anonid="find-case-sensitive" class="findbar-case-sensitive tabbable" + data-l10n-id="findbar-case-sensitive" oncommand="_setCaseSensitivity(this.checked ? 1 : 0);"/> + <checkbox anonid="find-match-diacritics" class="findbar-match-diacritics tabbable" + data-l10n-id="findbar-match-diacritics" oncommand="_setDiacriticMatching(this.checked ? 1 : 0);"/> + <checkbox anonid="find-entire-word" class="findbar-entire-word tabbable" + data-l10n-id="findbar-entire-word" oncommand="toggleEntireWord(this.checked);"/> + <label anonid="match-case-status" class="findbar-label" + data-l10n-id="findbar-case-sensitive-status" hidden="true" /> + <label anonid="match-diacritics-status" class="findbar-label" + data-l10n-id="findbar-match-diacritics-status" hidden="true" /> + <label anonid="entire-word-status" class="findbar-label" + data-l10n-id="findbar-entire-word-status" hidden="true" /> + <label anonid="found-matches" class="findbar-label found-matches" hidden="true" /> + <image anonid="find-status-icon" class="find-status-icon" /> + <description anonid="find-status" control="findbar-textbox" class="findbar-label findbar-find-status" /> + </hbox> + <toolbarbutton anonid="find-closebutton" class="findbar-closebutton tabbable close-icon" + data-l10n-id="findbar-find-button-close" oncommand="close();"/> + `; + } + + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/findbar.ftl"); + this.destroy = this.destroy.bind(this); + + // We have to guard against `this.close` being |null| due to an unknown + // issue, which is tracked in bug 957999. + this.addEventListener( + "keypress", + event => { + if (event.keyCode == event.DOM_VK_ESCAPE) { + if (this.close) { + this.close(); + } + event.preventDefault(); + } + }, + true + ); + } + + connectedCallback() { + // Hide the findbar immediately without animation. This prevents a flicker in the case where + // we'll never be shown (i.e. adopting a tab that has a previously-opened-but-now-closed + // findbar into a new window). + this.setAttribute("noanim", "true"); + this.hidden = true; + this.appendChild(this.constructor.fragment); + if (AppConstants.platform == "macosx") { + this.insertBefore( + this.getElement("find-closebutton"), + this.getElement("findbar-container") + ); + } + + /** + * Please keep in sync with toolkit/modules/FindBarContent.sys.mjs + */ + this.FIND_NORMAL = 0; + + this.FIND_TYPEAHEAD = 1; + + this.FIND_LINKS = 2; + + this._findMode = 0; + + this._flashFindBar = 0; + + this._initialFlashFindBarCount = 6; + + /** + * For tests that need to know when the find bar is finished + * initializing, we store a promise to notify on. + */ + this._startFindDeferred = null; + + this._browser = null; + + this._destroyed = false; + + this._xulBrowserWindow = null; + + // These elements are accessed frequently and are therefore cached. + this._findField = this.getElement("findbar-textbox"); + this._foundMatches = this.getElement("found-matches"); + this._findStatusIcon = this.getElement("find-status-icon"); + this._findStatusDesc = this.getElement("find-status"); + + this._foundURL = null; + + let prefsvc = Services.prefs; + + this.quickFindTimeoutLength = prefsvc.getIntPref( + "accessibility.typeaheadfind.timeout" + ); + this._flashFindBar = prefsvc.getIntPref( + "accessibility.typeaheadfind.flashBar" + ); + + let observe = (this._observe = this.observe.bind(this)); + for (let [propName, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.addObserver(prefName, observe); + let prefGetter = PREFS_TO_OBSERVE_BOOL.has(propName) ? "Bool" : "Int"; + this["_" + propName] = prefsvc[`get${prefGetter}Pref`](prefName); + } + Services.obs.addObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + this._findResetTimeout = -1; + + // Make sure the FAYT keypress listener is attached by initializing the + // browser property. + if (this.getAttribute("browserid")) { + setTimeout(() => { + // eslint-disable-next-line no-self-assign + this.browser = this.browser; + }, 0); + } + + window.addEventListener("unload", this.destroy); + + this._findField.addEventListener("input", event => { + // We should do nothing during composition. E.g., composing string + // before converting may matches a forward word of expected word. + // After that, even if user converts the composition string to the + // expected word, it may find second or later searching word in the + // document. + if (this._isIMEComposing) { + return; + } + + const value = this._findField.value; + if (this._hadValue && !value) { + this._willfullyDeleted = true; + this._hadValue = false; + } else if (value.trim()) { + this._hadValue = true; + this._willfullyDeleted = false; + } + this._find(value); + }); + + this._findField.addEventListener("keypress", event => { + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: + if (this.findMode == this.FIND_NORMAL) { + let findString = this._findField; + if (!findString.value) { + return; + } + if (event.getModifierState("Accel")) { + this.getElement("highlight").click(); + return; + } + + this.onFindAgainCommand(event.shiftKey); + } else { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_TAB: + let shouldHandle = + !event.altKey && !event.ctrlKey && !event.metaKey; + if (shouldHandle && this.findMode != this.FIND_NORMAL) { + this._finishFAYT(event); + } + break; + case KeyEvent.DOM_VK_PAGE_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + this.browser.finder.keyPress(event); + event.preventDefault(); + } + break; + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_DOWN: + this.browser.finder.keyPress(event); + event.preventDefault(); + break; + } + }); + + this._findField.addEventListener("blur", event => { + // Note: This code used to remove the selection + // if it matched an editable. + this.browser.finder.enableSelection(); + }); + + this._findField.addEventListener("focus", event => { + this._updateBrowserWithState(); + }); + + this._findField.addEventListener("compositionstart", event => { + // Don't close the find toolbar while IME is composing. + let findbar = this; + findbar._isIMEComposing = true; + if (findbar._quickFindTimeout) { + clearTimeout(findbar._quickFindTimeout); + findbar._quickFindTimeout = null; + findbar._updateBrowserWithState(); + } + }); + + this._findField.addEventListener("compositionend", event => { + this._isIMEComposing = false; + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + }); + + this._findField.addEventListener("dragover", event => { + if (event.dataTransfer.types.includes("text/plain")) { + event.preventDefault(); + } + }); + + this._findField.addEventListener("drop", event => { + let value = event.dataTransfer.getData("text/plain"); + this._findField.value = value; + this._find(value); + event.stopPropagation(); + event.preventDefault(); + }); + } + + set findMode(val) { + this._findMode = val; + this._updateBrowserWithState(); + } + + get findMode() { + return this._findMode; + } + + set prefillWithSelection(val) { + this.setAttribute("prefillwithselection", val); + } + + get prefillWithSelection() { + return this.getAttribute("prefillwithselection") != "false"; + } + + get hasTransactions() { + if (this._findField.value) { + return true; + } + + // Watch out for lazy editor init + if (this._findField.editor) { + return this._findField.editor.canUndo || this._findField.editor.canRedo; + } + return false; + } + + set browser(val) { + function setFindbarInActor(browser, findbar) { + if (!browser.frameLoader) { + return; + } + + let windowGlobal = browser.browsingContext.currentWindowGlobal; + if (windowGlobal) { + let findbarParent = windowGlobal.getActor("FindBar"); + if (findbarParent) { + findbarParent.setFindbar(browser, findbar); + } + } + } + + if (this._browser) { + setFindbarInActor(this._browser, null); + + let finder = this._browser.finder; + if (finder) { + finder.removeResultListener(this); + } + } + + this._browser = val; + if (this._browser) { + // Need to do this to ensure the correct initial state. + this._updateBrowserWithState(); + + setFindbarInActor(this._browser, this); + + this._browser.finder.addResultListener(this); + } + } + + get browser() { + if (!this._browser) { + const id = this.getAttribute("browserid"); + if (id) { + this._browser = document.getElementById(id); + } + } + return this._browser; + } + + observe(subject, topic, prefName) { + if (topic == TOPIC_MAC_APP_ACTIVATE) { + this._onAppActivateMac(); + return; + } + + if (topic != "nsPref:changed") { + return; + } + + let prefsvc = Services.prefs; + + switch (prefName) { + case "accessibility.typeaheadfind": + this._findAsYouType = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.manual": + this._manualFAYT = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.timeout": + this.quickFindTimeoutLength = prefsvc.getIntPref(prefName); + break; + case "accessibility.typeaheadfind.linksonly": + this._typeAheadLinksOnly = prefsvc.getBoolPref(prefName); + break; + case "accessibility.typeaheadfind.casesensitive": + this._setCaseSensitivity(prefsvc.getIntPref(prefName)); + break; + case "findbar.entireword": + this._entireWord = prefsvc.getBoolPref(prefName); + this.toggleEntireWord(this._entireWord, true); + break; + case "findbar.highlightAll": + this.toggleHighlight(prefsvc.getBoolPref(prefName), true); + break; + case "findbar.matchdiacritics": + this._setDiacriticMatching(prefsvc.getIntPref(prefName)); + break; + case "findbar.modalHighlight": + this._useModalHighlight = prefsvc.getBoolPref(prefName); + if (this.browser.finder) { + this.browser.finder.onModalHighlightChange(this._useModalHighlight); + } + break; + } + } + + getElement(aAnonymousID) { + return this.querySelector(`[anonid=${aAnonymousID}]`); + } + + /** + * This is necessary because custom elements don't have a "real" destructor. + * This method is called explicitly from disconnectedCallback, and from + * an unload event handler that we add. + */ + destroy() { + if (this._destroyed) { + return; + } + window.removeEventListener("unload", this.destroy); + this._destroyed = true; + + this.browser?._finder?.destroy(); + + // Invoking this setter also removes the message listeners. + this.browser = null; + + let prefsvc = Services.prefs; + let observe = this._observe; + for (let [, prefName] of PREFS_TO_OBSERVE_ALL) { + prefsvc.removeObserver(prefName, observe); + } + + Services.obs.removeObserver(observe, TOPIC_MAC_APP_ACTIVATE); + + // Clear all timers that might still be running. + this._cancelTimers(); + } + + _cancelTimers() { + if (this._flashFindBarTimeout) { + clearInterval(this._flashFindBarTimeout); + this._flashFindBarTimeout = null; + } + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + this._quickFindTimeout = null; + } + if (this._findResetTimeout) { + clearTimeout(this._findResetTimeout); + this._findResetTimeout = null; + } + } + + _setFindCloseTimeout() { + if (this._quickFindTimeout) { + clearTimeout(this._quickFindTimeout); + } + + // Don't close the find toolbar while IME is composing OR when the + // findbar is already hidden. + if (this._isIMEComposing || this.hidden) { + this._quickFindTimeout = null; + this._updateBrowserWithState(); + return; + } + + if (this.quickFindTimeoutLength < 1) { + this._quickFindTimeout = null; + } else { + this._quickFindTimeout = setTimeout(() => { + if (this.findMode != this.FIND_NORMAL) { + this.close(); + } + this._quickFindTimeout = null; + }, this.quickFindTimeoutLength); + } + this._updateBrowserWithState(); + } + + /** + * Updates the search match count after each find operation on a new string. + */ + _updateMatchesCount() { + if (!this._dispatchFindEvent("matchescount")) { + return; + } + + this.browser.finder.requestMatchesCount( + this._findField.value, + this.findMode == this.FIND_LINKS + ); + } + + /** + * Turns highlighting of all occurrences on or off. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + toggleHighlight(highlight, fromPrefObserver) { + if (highlight === this._highlightAll) { + return; + } + + this.browser.finder.onHighlightAllChange(highlight); + + this._setHighlightAll(highlight, fromPrefObserver); + + if (!this._dispatchFindEvent("highlightallchange")) { + return; + } + + let word = this._findField.value; + // Bug 429723. Don't attempt to highlight "" + if (highlight && !word) { + return; + } + + this.browser.finder.highlight( + highlight, + word, + this.findMode == this.FIND_LINKS + ); + + // Update the matches count + this._updateMatchesCount(Ci.nsITypeAheadFind.FIND_FOUND); + } + + /** + * Updates the highlight-all mode of the findbar and its UI. + * + * @param {Boolean} highlight Whether to turn the highlight on or off. + * @param {Boolean} fromPrefObserver Whether the callee is the pref + * observer, which means we should not set + * the same pref again. + */ + _setHighlightAll(highlight, fromPrefObserver) { + if (typeof highlight != "boolean") { + highlight = this._highlightAll; + } + if (highlight !== this._highlightAll) { + this._highlightAll = highlight; + if (!fromPrefObserver) { + Services.telemetry.scalarAdd("findbar.highlight_all", 1); + Services.prefs.setBoolPref("findbar.highlightAll", highlight); + } + } + let checkbox = this.getElement("highlight"); + checkbox.checked = this._highlightAll; + } + + /** + * Updates the case-sensitivity mode of the findbar and its UI. + * + * @param {String} [str] The string for which case sensitivity might be + * turned on. This only used when case-sensitivity is + * in auto mode, see `_shouldBeCaseSensitive`. The + * default value for this parameter is the find-field + * value. + * @see _shouldBeCaseSensitive + */ + _updateCaseSensitivity(str) { + let val = str || this._findField.value; + + let caseSensitive = this._shouldBeCaseSensitive(val); + let checkbox = this.getElement("find-case-sensitive"); + let statusLabel = this.getElement("match-case-status"); + checkbox.checked = caseSensitive; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + if ( + this.findMode == this.FIND_NORMAL && + (this._typeAheadCaseSensitive == 0 || this._typeAheadCaseSensitive == 1) + ) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !caseSensitive; + } + + this.browser.finder.caseSensitive = caseSensitive; + } + + /** + * Sets the findbar case-sensitivity mode. + * + * @param {Number} caseSensitivity 0 - case insensitive, + * 1 - case sensitive, + * 2 - auto = case sensitive if the matching + * string contains upper case letters. + * @see _shouldBeCaseSensitive + */ + _setCaseSensitivity(caseSensitivity) { + this._typeAheadCaseSensitive = caseSensitivity; + this._updateCaseSensitivity(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("casesensitivitychange"); + Services.telemetry.scalarAdd("findbar.match_case", 1); + } + + /** + * Updates the diacritic-matching mode of the findbar and its UI. + * + * @param {String} [str] The string for which diacritic matching might be + * turned on. This is only used when diacritic + * matching is in auto mode, see + * `_shouldMatchDiacritics`. The default value for + * this parameter is the find-field value. + * @see _shouldMatchDiacritics. + */ + _updateDiacriticMatching(str) { + let val = str || this._findField.value; + + let matchDiacritics = this._shouldMatchDiacritics(val); + let checkbox = this.getElement("find-match-diacritics"); + let statusLabel = this.getElement("match-diacritics-status"); + checkbox.checked = matchDiacritics; + + // Show the checkbox on the full Find bar in non-auto mode. + // Show the label in all other cases. + if ( + this.findMode == this.FIND_NORMAL && + (this._matchDiacritics == 0 || this._matchDiacritics == 1) + ) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !matchDiacritics; + } + + this.browser.finder.matchDiacritics = matchDiacritics; + } + + /** + * Sets the findbar diacritic-matching mode + * @param {Number} diacriticMatching 0 - ignore diacritics, + * 1 - match diacritics, + * 2 - auto = match diacritics if the + * matching string contains + * diacritics. + * @see _shouldMatchDiacritics + */ + _setDiacriticMatching(diacriticMatching) { + this._matchDiacritics = diacriticMatching; + this._updateDiacriticMatching(); + this._findFailedString = null; + this._find(); + + this._dispatchFindEvent("diacriticmatchingchange"); + + Services.telemetry.scalarAdd("findbar.match_diacritics", 1); + } + + /** + * Updates the entire-word mode of the findbar and its UI. + */ + _setEntireWord() { + let entireWord = this._entireWord; + let checkbox = this.getElement("find-entire-word"); + let statusLabel = this.getElement("entire-word-status"); + checkbox.checked = entireWord; + + // Show the checkbox on the full Find bar. + // Show the label in all other cases. + if (this.findMode == this.FIND_NORMAL) { + checkbox.hidden = false; + statusLabel.hidden = true; + } else { + checkbox.hidden = true; + statusLabel.hidden = !entireWord; + } + + this.browser.finder.entireWord = entireWord; + } + + /** + * Sets the findbar entire-word mode. + * + * @param {Boolean} entireWord Whether or not entire-word mode should be + * turned on. + */ + toggleEntireWord(entireWord, fromPrefObserver) { + if (!fromPrefObserver) { + // Just set the pref; our observer will change the find bar behavior. + Services.prefs.setBoolPref("findbar.entireword", entireWord); + + Services.telemetry.scalarAdd("findbar.whole_words", 1); + return; + } + + this._findFailedString = null; + this._find(); + } + + /** + * Opens and displays the find bar. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Boolean} `true` if the find bar wasn't previously open, `false` + * otherwise. + */ + open(mode) { + if (mode != undefined) { + this.findMode = mode; + } + + this._findFailedString = null; + + this._updateFindUI(); + if (this.hidden) { + Services.telemetry.scalarAdd("findbar.shown", 1); + this.removeAttribute("noanim"); + this.hidden = false; + + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + + let event = document.createEvent("Events"); + event.initEvent("findbaropen", true, false); + this.dispatchEvent(event); + + this.browser.finder.onFindbarOpen(); + + return true; + } + return false; + } + + /** + * Closes the findbar. + * + * @param {Boolean} [noAnim] Whether to disable to closing animation. Used + * to close instantly and synchronously, when + * other operations depend on this state. + */ + close(noAnim) { + if (this.hidden) { + return; + } + + if (noAnim) { + this.setAttribute("noanim", true); + } + this.hidden = true; + + let event = document.createEvent("Events"); + event.initEvent("findbarclose", true, false); + this.dispatchEvent(event); + + // 'focusContent()' iterates over all listeners in the chrome + // process, so we need to call it from here. + this.browser.finder.focusContent(); + this.browser.finder.onFindbarClose(); + + this._cancelTimers(); + this._updateBrowserWithState(); + + this._findFailedString = null; + } + + clear() { + this.browser.finder.removeSelection(); + // Clear value and undo/redo transactions + this._findField.value = ""; + this._findField.editor?.clearUndoRedo(); + this.toggleHighlight(false); + this._updateStatusUI(); + this._enableFindButtons(false); + } + + _dispatchKeypressEvent(target, fakeEvent) { + if (!target) { + return; + } + + // The event information comes from the child process. + let event = new target.ownerGlobal.KeyboardEvent( + fakeEvent.type, + fakeEvent + ); + target.dispatchEvent(event); + } + + _updateStatusUIBar(foundURL) { + if (!this._xulBrowserWindow) { + try { + this._xulBrowserWindow = window.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).XULBrowserWindow; + } catch (ex) {} + if (!this._xulBrowserWindow) { + return false; + } + } + + // Call this has the same effect like hovering over link, + // the browser shows the URL as a tooltip. + this._xulBrowserWindow.setOverLink(foundURL || ""); + return true; + } + + _finishFAYT(keypressEvent) { + this.browser.finder.focusContent(); + + if (keypressEvent) { + keypressEvent.preventDefault(); + } + + this.browser.finder.keyPress(keypressEvent); + + this.close(); + return true; + } + + _shouldBeCaseSensitive(str) { + if (this._typeAheadCaseSensitive == 0) { + return false; + } + if (this._typeAheadCaseSensitive == 1) { + return true; + } + + return str != str.toLowerCase(); + } + + _shouldMatchDiacritics(str) { + if (this._matchDiacritics == 0) { + return false; + } + if (this._matchDiacritics == 1) { + return true; + } + + return str != str.normalize("NFD"); + } + + onMouseUp() { + if (!this.hidden && this.findMode != this.FIND_NORMAL) { + this.close(); + } + } + + /** + * We get a fake event object through an IPC message when FAYT is being used + * from within the browser. We then stuff that input in the find bar here. + * + * @param {Object} fakeEvent Event object that looks and quacks like a + * native DOM KeyPress event. + */ + _onBrowserKeypress(fakeEvent) { + const FAYT_LINKS_KEY = "'"; + const FAYT_TEXT_KEY = "/"; + + if (!this.hidden && this._findField == document.activeElement) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + if (this.findMode != this.FIND_NORMAL && this._quickFindTimeout) { + this._findField.select(); + this._findField.focus(); + this._dispatchKeypressEvent(this._findField, fakeEvent); + return; + } + + let key = fakeEvent.charCode + ? String.fromCharCode(fakeEvent.charCode) + : null; + let manualstartFAYT = + (key == FAYT_LINKS_KEY || key == FAYT_TEXT_KEY) && this._manualFAYT; + let autostartFAYT = + !manualstartFAYT && this._findAsYouType && key && key != " "; + if (manualstartFAYT || autostartFAYT) { + let mode = + key == FAYT_LINKS_KEY || (autostartFAYT && this._typeAheadLinksOnly) + ? this.FIND_LINKS + : this.FIND_TYPEAHEAD; + + // Clear bar first, so that when openFindBar() calls setCaseSensitivity() + // it doesn't get confused by a lingering value + this._findField.value = ""; + + this.open(mode); + this._setFindCloseTimeout(); + this._findField.select(); + this._findField.focus(); + + if (autostartFAYT) { + this._dispatchKeypressEvent(this._findField, fakeEvent); + } else { + this._updateStatusUI(Ci.nsITypeAheadFind.FIND_FOUND); + } + } + } + + _updateBrowserWithState() { + if (this._browser) { + this._browser.sendMessageToActor( + "Findbar:UpdateState", + { + findMode: this.findMode, + isOpenAndFocused: + !this.hidden && document.activeElement == this._findField, + hasQuickFindTimeout: !!this._quickFindTimeout, + }, + "FindBar", + "all" + ); + } + } + + _enableFindButtons(aEnable) { + this.getElement("find-next").disabled = this.getElement( + "find-previous" + ).disabled = !aEnable; + } + + /** + * Determines whether minimalist or general-purpose search UI is to be + * displayed when the find bar is activated. + */ + _updateFindUI() { + let showMinimalUI = this.findMode != this.FIND_NORMAL; + + let nodes = this.getElement("findbar-container").children; + let wrapper = this.getElement("findbar-textbox-wrapper"); + let foundMatches = this._foundMatches; + for (let node of nodes) { + if (node == wrapper || node == foundMatches) { + continue; + } + node.hidden = showMinimalUI; + } + this.getElement("find-next").hidden = this.getElement( + "find-previous" + ).hidden = showMinimalUI; + foundMatches.hidden = showMinimalUI || !foundMatches.value; + this._updateCaseSensitivity(); + this._updateDiacriticMatching(); + this._setEntireWord(); + this._setHighlightAll(); + + if (showMinimalUI) { + this._findField.classList.add("minimal"); + } else { + this._findField.classList.remove("minimal"); + } + + let l10nId; + if (this.findMode == this.FIND_TYPEAHEAD) { + l10nId = "findbar-fast-find"; + } else if (this.findMode == this.FIND_LINKS) { + l10nId = "findbar-fast-find-links"; + } else { + l10nId = "findbar-normal-find"; + } + document.l10n.setAttributes(this._findField, l10nId); + } + + _find(value) { + if (!this._dispatchFindEvent("")) { + return; + } + + let val = value || this._findField.value; + + // We have to carry around an explicit version of this, because + // finder.searchString doesn't update on failed searches. + this.browser._lastSearchString = val; + + // Only search on input if we don't have a last-failed string, + // or if the current search string doesn't start with it. + // In entire-word mode we always attemp a find; since sequential matching + // is not guaranteed, the first character typed may not be a word (no + // match), but the with the second character it may well be a word, + // thus a match. + if ( + !this._findFailedString || + !val.startsWith(this._findFailedString) || + this._entireWord + ) { + // Getting here means the user commanded a find op. Make sure any + // initial prefilling is ignored if it hasn't happened yet. + if (this._startFindDeferred) { + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + + this._enableFindButtons(val); + this._updateCaseSensitivity(val); + this._updateDiacriticMatching(val); + this._setEntireWord(); + + this.browser.finder.fastFind( + val, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + + if (this._findResetTimeout != -1) { + clearTimeout(this._findResetTimeout); + } + + // allow a search to happen on input again after a second has expired + // since the previous input, to allow for dynamic content and/ or page + // loading. + this._findResetTimeout = setTimeout(() => { + this._findFailedString = null; + this._findResetTimeout = -1; + }, 1000); + } + + _flash() { + if (this._flashFindBarCount === undefined) { + this._flashFindBarCount = this._initialFlashFindBarCount; + } + + if (this._flashFindBarCount-- == 0) { + clearInterval(this._flashFindBarTimeout); + this._findField.removeAttribute("flash"); + this._flashFindBarCount = 6; + return; + } + + this._findField.setAttribute( + "flash", + this._flashFindBarCount % 2 == 0 ? "false" : "true" + ); + } + + _findAgain(findPrevious) { + this.browser.finder.findAgain( + this._findField.value, + findPrevious, + this.findMode == this.FIND_LINKS, + this.findMode != this.FIND_NORMAL + ); + } + + _updateStatusUI(res, findPrevious) { + let statusL10nId; + switch (res) { + case Ci.nsITypeAheadFind.FIND_WRAPPED: + this._findStatusIcon.setAttribute("status", "wrapped"); + this._findField.removeAttribute("status"); + statusL10nId = findPrevious + ? "findbar-wrapped-to-bottom" + : "findbar-wrapped-to-top"; + break; + case Ci.nsITypeAheadFind.FIND_NOTFOUND: + this._findStatusDesc.setAttribute("status", "notfound"); + this._findStatusIcon.setAttribute("status", "notfound"); + this._findField.setAttribute("status", "notfound"); + this._foundMatches.hidden = true; + statusL10nId = "findbar-not-found"; + break; + case Ci.nsITypeAheadFind.FIND_PENDING: + this._findStatusIcon.setAttribute("status", "pending"); + this._findField.removeAttribute("status"); + this._findStatusDesc.removeAttribute("status"); + statusL10nId = ""; + break; + case Ci.nsITypeAheadFind.FIND_FOUND: + default: + this._findStatusIcon.removeAttribute("status"); + this._findField.removeAttribute("status"); + this._findStatusDesc.removeAttribute("status"); + statusL10nId = ""; + break; + } + if (statusL10nId) { + document.l10n.setAttributes(this._findStatusDesc, statusL10nId); + } else { + delete this._findStatusDesc.dataset.l10nId; + this._findStatusDesc.textContent = ""; + } + } + + updateControlState(result, findPrevious) { + this._updateStatusUI(result, findPrevious); + this._enableFindButtons( + result !== Ci.nsITypeAheadFind.FIND_NOTFOUND && !!this._findField.value + ); + } + + _dispatchFindEvent(type, findPrevious) { + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("find" + type, true, true, { + query: this._findField.value, + caseSensitive: !!this._typeAheadCaseSensitive, + matchDiacritics: !!this._matchDiacritics, + entireWord: this._entireWord, + highlightAll: this._highlightAll, + findPrevious, + }); + return this.dispatchEvent(event); + } + + /** + * Opens the findbar, focuses the findfield and selects its contents. + * Also flashes the findbar the first time it's used. + * + * @param {Number} mode The find mode to be used, which is either + * FIND_NORMAL, FIND_TYPEAHEAD or FIND_LINKS. If not + * passed, we revert to the last find mode if any or + * FIND_NORMAL. + * @return {Promise} A promise that will be resolved when the findbar is + * fully opened. + */ + startFind(mode) { + let prefsvc = Services.prefs; + let userWantsPrefill = true; + this.open(mode); + + if (this._flashFindBar) { + this._flashFindBarTimeout = setInterval(() => this._flash(), 500); + prefsvc.setIntPref( + "accessibility.typeaheadfind.flashBar", + --this._flashFindBar + ); + } + + this._startFindDeferred = Promise.withResolvers(); + let startFindPromise = this._startFindDeferred.promise; + + if (this.prefillWithSelection) { + userWantsPrefill = prefsvc.getBoolPref( + "accessibility.typeaheadfind.prefillwithselection" + ); + } + + if (this.prefillWithSelection && userWantsPrefill) { + this.browser.finder.getInitialSelection(); + + // NB: We have to focus this._findField here so tests that send + // key events can open and close the find bar synchronously. + this._findField.focus(); + + // (e10s) since we focus lets also select it, otherwise that would + // only happen in this.onCurrentSelection and, because it is async, + // there's a chance keypresses could come inbetween, leading to + // jumbled up queries. + this._findField.select(); + + return startFindPromise; + } + + // If userWantsPrefill is false but prefillWithSelection is true, + // then we might need to check the selection clipboard. Call + // onCurrentSelection to do so. + // Note: this.onCurrentSelection clears this._startFindDeferred. + this.onCurrentSelection("", true); + return startFindPromise; + } + + /** + * Convenient alias to startFind(gFindBar.FIND_NORMAL); + * + * You should generally map the window's find command to this method. + * e.g. <command name="cmd_find" oncommand="gFindBar.onFindCommand();"/> + */ + onFindCommand() { + return this.startFind(this.FIND_NORMAL); + } + + /** + * Stub for find-next and find-previous commands. + * + * @param {Boolean} findPrevious `true` for find-previous, `false` + * otherwise. + */ + onFindAgainCommand(findPrevious) { + if (findPrevious) { + Services.telemetry.scalarAdd("findbar.find_prev", 1); + } else { + Services.telemetry.scalarAdd("findbar.find_next", 1); + } + + let findString = + this._browser.finder.searchString || this._findField.value; + if (!findString) { + return this.startFind(); + } + + // We dispatch the findAgain event here instead of in _findAgain since + // if there is a find event handler that prevents the default then + // finder.searchString will never get updated which in turn means + // there would never be findAgain events because of the logic below. + if (!this._dispatchFindEvent("again", findPrevious)) { + return undefined; + } + + // user explicitly requested another search, so do it even if we think it'll fail + this._findFailedString = null; + + // Ensure the stored SearchString is in sync with what we want to find + if (this._findField.value != this._browser.finder.searchString) { + this._find(this._findField.value); + } else { + this._findAgain(findPrevious); + if (this._useModalHighlight) { + this.open(); + this._findField.focus(); + } + } + + return undefined; + } + + /** + * Fetches the currently selected text and sets that as the text to search + * next. This is a MacOS specific feature. + */ + onFindSelectionCommand() { + this.browser.finder.setSearchStringToSelection().then(searchInfo => { + if (searchInfo.selectedText) { + this._findField.value = searchInfo.selectedText; + } + }); + } + + _onAppActivateMac() { + const kPref = "accessibility.typeaheadfind.prefillwithselection"; + if (this.prefillWithSelection && Services.prefs.getBoolPref(kPref)) { + return; + } + + let clipboardSearchString = this._browser.finder.clipboardSearchString; + if ( + clipboardSearchString && + this._findField.value != clipboardSearchString && + !this._findField._willfullyDeleted + ) { + this._findField.value = clipboardSearchString; + this._findField._hadValue = true; + // Changing the search string makes the previous status invalid, so + // we better clear it here. + this._updateStatusUI(); + } + } + + /** + * This handles all the result changes for both type-ahead-find and + * highlighting. + * + * @param {Object} data A dictionary that holds the following properties: + * - {Number} result One of the FIND_* constants + * indicating the result of a search + * operation. + * - {Boolean} findBackwards If the search was done + * from the bottom to the + * top. This is used for + * status messages when + * reaching "the end of the + * page". + * - {String} linkURL When a link matched, then its + * URL. Always null when not in + * FIND_LINKS mode. + */ + onFindResult(data) { + if (data.result == Ci.nsITypeAheadFind.FIND_NOTFOUND) { + // If an explicit Find Again command fails, re-open the toolbar. + if (data.storeResult && this.open()) { + this._findField.select(); + this._findField.focus(); + } + this._findFailedString = data.searchString; + } else { + this._findFailedString = null; + } + + this._updateStatusUI(data.result, data.findBackwards); + this._updateStatusUIBar(data.linkURL); + + if (this.findMode != this.FIND_NORMAL) { + this._setFindCloseTimeout(); + } + } + + /** + * This handles all the result changes for matches counts. + * + * @param {Object} result Result Object, containing the total amount of + * matches and a vector of the current result. + * - {Number} total Total count number of matches found. + * - {Number} limit Current setting of the number of matches + * to hit to hit the limit. + * - {Number} current Vector of the current result. + */ + onMatchesCountResult(result) { + if (!result.total) { + delete this._foundMatches.dataset.l10nId; + this._foundMatches.hidden = true; + this._foundMatches.setAttribute("value", ""); + } else { + const l10nId = + result.total === -1 + ? "findbar-found-matches-count-limit" + : "findbar-found-matches"; + this._foundMatches.hidden = false; + document.l10n.setAttributes(this._foundMatches, l10nId, result); + } + } + + onHighlightFinished(result) { + // Noop. + } + + onCurrentSelection(selectionString, isInitialSelection) { + // Ignore the prefill if the user has already typed in the findbar, + // it would have been overwritten anyway. See bug 1198465. + if (isInitialSelection && !this._startFindDeferred) { + return; + } + + if ( + AppConstants.platform == "macosx" && + isInitialSelection && + !selectionString + ) { + let clipboardSearchString = this.browser.finder.clipboardSearchString; + if (clipboardSearchString) { + selectionString = clipboardSearchString; + } + } + + if (selectionString) { + this._findField.value = selectionString; + } + + if (isInitialSelection) { + this._enableFindButtons(!!this._findField.value); + this._findField.select(); + this._findField.focus(); + + this._startFindDeferred.resolve(); + this._startFindDeferred = null; + } + } + + /** + * This handler may cancel a request to focus content by returning |false| + * explicitly. + */ + shouldFocusContent() { + const fm = Services.focus; + if (fm.focusedWindow != window) { + return false; + } + + let focusedElement = fm.focusedElement; + if (!focusedElement) { + return false; + } + + let focusedParent = focusedElement.closest("findbar"); + if (focusedParent != this && focusedParent != this._findField) { + return false; + } + + return true; + } + + disconnectedCallback() { + // Empty the DOM. We will rebuild if reconnected. + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this.destroy(); + } + } + + customElements.define("findbar", MozFindbar); +} diff --git a/toolkit/content/widgets/general.js b/toolkit/content/widgets/general.js new file mode 100644 index 0000000000..14003a7558 --- /dev/null +++ b/toolkit/content/widgets/general.js @@ -0,0 +1,27 @@ +/* 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`. +{ + class MozCommandSet extends MozXULElement { + connectedCallback() { + if (this.getAttribute("commandupdater") === "true") { + const events = this.getAttribute("events") || "*"; + const targets = this.getAttribute("targets") || "*"; + document.commandDispatcher.addCommandUpdater(this, events, targets); + } + } + + disconnectedCallback() { + if (this.getAttribute("commandupdater") === "true") { + document.commandDispatcher.removeCommandUpdater(this); + } + } + } + + customElements.define("commandset", MozCommandSet); +} diff --git a/toolkit/content/widgets/infobar.css b/toolkit/content/widgets/infobar.css new file mode 100644 index 0000000000..ee811818b5 --- /dev/null +++ b/toolkit/content/widgets/infobar.css @@ -0,0 +1,99 @@ +/* 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/. */ + +:host(.infobar) { + --info-bar-background-color: light-dark(var(--color-white), rgb(66, 65, 77)); + --info-bar-text-color: light-dark(var(--color-gray-100), var(--color-gray-05)); + position: relative; + + &::before { + content: ""; + display: block; + width: 2px; + position: absolute; + top: 0; + inset-inline-start: 0; + height: 100%; + border-start-start-radius: 4px; + border-end-start-radius: 4px; + } + + .container { + /* Don't let lwthemes set a text-shadow. */ + text-shadow: none; + padding-block: 3px; + align-items: center; + } + + .content { + gap: 0 12px; + height: fit-content; + } + + .close { + margin-block: 4px; + margin-inline-start: 8px; + background-size: 12px; + height: 24px; + width: 24px; + align-self: flex-start; + } +} + +@media (prefers-contrast) { + :host(.infobar)::before { + background-color: CanvasText; + } +} + +@media not (prefers-contrast) { + :host(.infobar) { + box-shadow: 0 1px 2px rgba(58, 57, 68, 0.1); + background-color: var(--info-bar-background-color); + color: var(--info-bar-text-color); + + &::before { + background-image: linear-gradient(0deg, #9059ff 0%, #ff4aa2 52.08%, #ffbd4f 100%); + } + } +} + +:host([message-bar-type=infobar]:first-of-type) { + margin-top: 4px; +} + +:host([message-bar-type=infobar]) { + margin: 0 4px 4px; +} + +::slotted(.notification-button-container) { + gap: 8px; + display: inline-flex; +} + +::slotted(.text-link) { + margin: 0 !important; +} + +img.inline-icon { + /* Align inline icon images in the message content */ + vertical-align: middle; + /* Ensure they get the right fill color. */ + -moz-context-properties: fill; + fill: currentColor; +} + +strong { + font-weight: var(--font-weight-bold); +} + +/* type="system" infobar styles */ + +:host([type=system]) .icon { + display: none; +} + +:host([type=system]) .content { + margin-inline-start: 0; +} diff --git a/toolkit/content/widgets/lit-utils.mjs b/toolkit/content/widgets/lit-utils.mjs new file mode 100644 index 0000000000..87bc9073a0 --- /dev/null +++ b/toolkit/content/widgets/lit-utils.mjs @@ -0,0 +1,138 @@ +/* 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 { LitElement } from "chrome://global/content/vendor/lit.all.mjs"; + +/** + * Helper for our replacement of @query. Used with `static queries` property. + * + * https://github.com/lit/lit/blob/main/packages/reactive-element/src/decorators/query.ts + */ +function query(el, selector) { + return () => el.renderRoot.querySelector(selector); +} + +/** + * Helper for our replacement of @queryAll. Used with `static queries` property. + * + * https://github.com/lit/lit/blob/main/packages/reactive-element/src/decorators/query-all.ts + */ +function queryAll(el, selector) { + return () => el.renderRoot.querySelectorAll(selector); +} + +/** + * MozLitElement provides extensions to the lit-provided LitElement class. + * + ******* + * + * `@query` support (define a getter for a querySelector): + * + * static get queries() { + * return { + * propertyName: ".aNormal .cssSelector", + * anotherName: { all: ".selectorFor .querySelectorAll" }, + * }; + * } + * + * This example would add properties that would be written like this without + * using `queries`: + * + * get propertyName() { + * return this.renderRoot?.querySelector(".aNormal .cssSelector"); + * } + * + * get anotherName() { + * return this.renderRoot?.querySelectorAll(".selectorFor .querySelectorAll"); + * } + ******* + * + * Automatic Fluent support for shadow DOM. + * + * Fluent requires that a shadowRoot be connected before it can use Fluent. + * Shadow roots will get connected automatically. + * + ******* + * + * Test helper for sending events after a change: `dispatchOnUpdateComplete` + * + * When some async stuff is going on and you want to wait for it in a test, you + * can use `this.dispatchOnUpdateComplete(myEvent)` and have the test wait on + * your event. + * + * The component will then wait for your reactive property change to take effect + * and dispatch the desired event. + * + * Example: + * + * async onClick() { + * let response = await this.getServerResponse(this.data); + * // Show the response status to the user. + * this.responseStatus = respose.status; + * this.dispatchOnUpdateComplete( + * new CustomEvent("status-shown") + * ); + * } + * + * add_task(async testButton() { + * let button = this.setupAndGetButton(); + * button.click(); + * await BrowserTestUtils.waitForEvent(button, "status-shown"); + * }); + */ +export class MozLitElement extends LitElement { + constructor() { + super(); + let { queries } = this.constructor; + if (queries) { + for (let [selectorName, selector] of Object.entries(queries)) { + if (selector.all) { + Object.defineProperty(this, selectorName, { + get: queryAll(this, selector.all), + }); + } else { + Object.defineProperty(this, selectorName, { + get: query(this, selector), + }); + } + } + } + } + + connectedCallback() { + super.connectedCallback(); + if ( + this.renderRoot == this.shadowRoot && + !this._l10nRootConnected && + document.l10n + ) { + document.l10n.connectRoot(this.renderRoot); + this._l10nRootConnected = true; + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + if ( + this.renderRoot == this.shadowRoot && + this._l10nRootConnected && + document.l10n + ) { + document.l10n.disconnectRoot(this.renderRoot); + this._l10nRootConnected = false; + } + } + + async dispatchOnUpdateComplete(event) { + await this.updateComplete; + this.dispatchEvent(event); + } + + update() { + super.update(); + if (document.l10n) { + document.l10n.translateFragment(this.renderRoot); + } + } +} diff --git a/toolkit/content/widgets/mach_commands.py b/toolkit/content/widgets/mach_commands.py new file mode 100644 index 0000000000..58a8b8fcda --- /dev/null +++ b/toolkit/content/widgets/mach_commands.py @@ -0,0 +1,208 @@ +# 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 os +import re + +from mach.decorators import Command, CommandArgument + +FIXME_COMMENT = "// FIXME: replace with path to your reusable widget\n" +LICENSE_HEADER = """/* 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/. */ +""" + +JS_HEADER = """{license} +import {{ html }} from "../vendor/lit.all.mjs"; +import {{ MozLitElement }} from "../lit-utils.mjs"; + +/** + * Component description goes here. + * + * @tagname {element_name} + * @property {{string}} variant - Property description goes here + */ +export default class {class_name} extends MozLitElement {{ + static properties = {{ + variant: {{ type: String }}, + }}; + + constructor() {{ + super(); + this.variant = "default"; + }} + + render() {{ + return html` + <link rel="stylesheet" href="chrome://global/content/elements/{element_name}.css" /> + <div>Variant type: ${{this.variant}}</div> + `; + }} +}} +customElements.define("{element_name}", {class_name}); +""" + +STORY_HEADER = """{license} +{html_lit_import} +// eslint-disable-next-line import/no-unassigned-import +{fixme_comment}import "{element_path}"; + +export default {{ + title: "{story_prefix}/{story_name}", + component: "{element_name}", + argTypes: {{ + variant: {{ + options: ["default", "other"], + control: {{ type: "select" }}, + }}, + }}, +}}; + +const Template = ({{ variant }}) => html` + <{element_name} .variant=${{variant}}></{element_name}> +`; + +export const Default = Template.bind({{}}); +Default.args = {{ + variant: "default", +}}; +""" + + +def run_mach(command_context, cmd, **kwargs): + return command_context._mach_context.commands.dispatch( + cmd, command_context._mach_context, **kwargs + ) + + +def run_npm(command_context, args): + return run_mach( + command_context, "npm", args=[*args, "--prefix=browser/components/storybook"] + ) + + +@Command( + "addwidget", + category="misc", + description="Scaffold a front-end component.", +) +@CommandArgument( + "names", + nargs="+", + help="Component names to create in kebab-case, eg. my-card.", +) +def addwidget(command_context, names): + story_prefix = "UI Widgets" + html_lit_import = 'import { html } from "../vendor/lit.all.mjs";' + for name in names: + component_dir = "toolkit/content/widgets/{0}".format(name) + + try: + os.mkdir(component_dir) + except FileExistsError: + pass + + with open("{0}/{1}.mjs".format(component_dir, name), "w", newline="\n") as f: + class_name = "".join(p.capitalize() for p in name.split("-")) + f.write( + JS_HEADER.format( + license=LICENSE_HEADER, + element_name=name, + class_name=class_name, + ) + ) + + with open("{0}/{1}.css".format(component_dir, name), "w", newline="\n") as f: + f.write(LICENSE_HEADER) + + test_name = name.replace("-", "_") + test_path = "toolkit/content/tests/widgets/test_{0}.html".format(test_name) + jar_path = "toolkit/content/jar.mn" + jar_lines = None + with open(jar_path, "r") as f: + jar_lines = f.readlines() + elements_startswith = " content/global/elements/" + new_css_line = "{0}{1}.css (widgets/{1}/{1}.css)\n".format( + elements_startswith, name + ) + new_js_line = "{0}{1}.mjs (widgets/{1}/{1}.mjs)\n".format( + elements_startswith, name + ) + new_jar_lines = [] + found_elements_section = False + added_widget = False + for line in jar_lines: + if line.startswith(elements_startswith): + found_elements_section = True + if found_elements_section and not added_widget and line > new_css_line: + added_widget = True + new_jar_lines.append(new_css_line) + new_jar_lines.append(new_js_line) + new_jar_lines.append(line) + + with open(jar_path, "w", newline="\n") as f: + f.write("".join(new_jar_lines)) + + story_path = "{0}/{1}.stories.mjs".format(component_dir, name) + element_path = "./{0}.mjs".format(name) + with open(story_path, "w", newline="\n") as f: + story_name = " ".join( + name for name in re.findall(r"[A-Z][a-z]+", class_name) if name != "Moz" + ) + f.write( + STORY_HEADER.format( + license=LICENSE_HEADER, + element_name=name, + story_name=story_name, + story_prefix=story_prefix, + fixme_comment="", + element_path=element_path, + html_lit_import=html_lit_import, + ) + ) + + run_mach( + command_context, "addtest", argv=[test_path, "--suite", "mochitest-chrome"] + ) + + +@Command( + "addstory", + category="misc", + description="Scaffold a front-end Storybook story.", +) +@CommandArgument( + "name", + help="Story to create in kebab-case, eg. my-card.", +) +@CommandArgument( + "project_name", + type=str, + help='Name of the project or team for the new component to keep stories organized. Eg. "Credential Management"', +) +@CommandArgument( + "--path", + help="Path to the widget source, eg. /browser/components/my-module.mjs or chrome://browser/content/my-module.mjs", +) +def addstory(command_context, name, project_name, path): + html_lit_import = 'import { html } from "lit.all.mjs";' + story_path = "browser/components/storybook/stories/{0}.stories.mjs".format(name) + project_name = project_name.split() + project_name = " ".join(p.capitalize() for p in project_name) + story_prefix = "Domain-specific UI Widgets/{0}".format(project_name) + with open(story_path, "w", newline="\n") as f: + print(f"Creating new story {name} in {story_path}") + story_name = " ".join(p.capitalize() for p in name.split("-")) + f.write( + STORY_HEADER.format( + license=LICENSE_HEADER, + element_name=name, + story_name=story_name, + element_path=path, + fixme_comment="" if path else FIXME_COMMENT, + project_name=project_name, + story_prefix=story_prefix, + html_lit_import=html_lit_import, + ) + ) diff --git a/toolkit/content/widgets/marquee.css b/toolkit/content/widgets/marquee.css new file mode 100644 index 0000000000..b898cd0dce --- /dev/null +++ b/toolkit/content/widgets/marquee.css @@ -0,0 +1,26 @@ +/* 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/. */ + +.outerDiv { + overflow: hidden; + width: -moz-available; +} + +.horizontal > .innerDiv { + width: max-content; + /* We want to create overflow of twice our available space. */ + padding: 0 100%; +} + +/* disable scrolling in contenteditable */ +:host(:read-write) .innerDiv { + padding: 0 !important; +} + +/* When printing or when the user doesn't want movement, we disable scrolling */ +@media print, (prefers-reduced-motion) { + .innerDiv { + padding: 0 !important; + } +} diff --git a/toolkit/content/widgets/marquee.js b/toolkit/content/widgets/marquee.js new file mode 100644 index 0000000000..8b18703b92 --- /dev/null +++ b/toolkit/content/widgets/marquee.js @@ -0,0 +1,417 @@ +/* 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 the class of entry. It will construct the actual implementation + * according to the value of the "direction" property. + */ +this.MarqueeWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgetsChild wheen the direction property + * changes. + */ + onchange() { + this.switchImpl(); + } + + switchImpl() { + let newImpl; + switch (this.element.direction) { + case "up": + case "down": + newImpl = MarqueeVerticalImplWidget; + break; + case "left": + case "right": + newImpl = MarqueeHorizontalImplWidget; + break; + } + + // 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; + } + this.teardown(); + if (newImpl) { + this.impl = new newImpl(this.shadowRoot); + this.impl.onsetup(); + } + } + + teardown() { + if (!this.impl) { + return; + } + this.impl.teardown(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } +}; + +this.MarqueeBaseImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup() { + this.generateContent(); + + // Set up state. + this._currentDirection = this.element.direction || "left"; + this._currentLoop = this.element.loop; + this.dirsign = 1; + this.startAt = 0; + this.stopAt = 0; + this.newPosition = 0; + this.runId = 0; + this.originalHeight = 0; + this.invalidateCache = true; + + this._mutationObserver = new this.window.MutationObserver(aMutations => + this._mutationActor(aMutations) + ); + this._mutationObserver.observe(this.element, { + attributes: true, + attributeOldValue: true, + attributeFilter: ["loop", "", "behavior", "direction", "width", "height"], + }); + + // init needs to be run after the page has loaded in order to calculate + // the correct height/width + if (this.document.readyState == "complete") { + this.init(); + } else { + this.window.addEventListener("load", this, { once: true }); + } + + this.shadowRoot.addEventListener("marquee-start", this); + this.shadowRoot.addEventListener("marquee-stop", this); + } + + teardown() { + this._mutationObserver.disconnect(); + this.window.clearTimeout(this.runId); + + this.window.removeEventListener("load", this); + this.shadowRoot.removeEventListener("marquee-start", this); + this.shadowRoot.removeEventListener("marquee-stop", this); + } + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + switch (aEvent.type) { + case "load": + this.init(); + break; + case "marquee-start": + this.doStart(); + break; + case "marquee-stop": + this.doStop(); + break; + } + } + + get outerDiv() { + return this.shadowRoot.firstChild; + } + + get innerDiv() { + return this.shadowRoot.getElementById("innerDiv"); + } + + get scrollDelayWithTruespeed() { + if (this.element.scrollDelay < 60 && !this.element.trueSpeed) { + return 60; + } + return this.element.scrollDelay; + } + + doStart() { + if (this.runId == 0) { + var lambda = () => this._doMove(false); + this.runId = this.window.setTimeout( + lambda, + this.scrollDelayWithTruespeed - this._deltaStartStop + ); + this._deltaStartStop = 0; + } + } + + doStop() { + if (this.runId != 0) { + this._deltaStartStop = Date.now() - this._lastMoveDate; + this.window.clearTimeout(this.runId); + } + + this.runId = 0; + } + + _fireEvent(aName, aBubbles, aCancelable) { + var e = this.document.createEvent("Events"); + e.initEvent(aName, aBubbles, aCancelable); + this.element.dispatchEvent(e); + } + + _doMove(aResetPosition) { + this._lastMoveDate = Date.now(); + + // invalidateCache is true at first load and whenever an attribute + // is changed + if (this.invalidateCache) { + this.invalidateCache = false; // we only want this to run once every scroll direction change + + var corrvalue = 0; + + switch (this._currentDirection) { + case "up": + case "down": { + let height = this.window.getComputedStyle(this.element).height; + this.outerDiv.style.height = height; + if (this.originalHeight > this.outerDiv.offsetHeight) { + corrvalue = this.originalHeight - this.outerDiv.offsetHeight; + } + this.innerDiv.style.padding = height + " 0"; + let isUp = this._currentDirection == "up"; + if (isUp) { + this.dirsign = 1; + this.startAt = + this.element.behavior == "alternate" + ? this.originalHeight - corrvalue + : 0; + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? parseInt(height) + corrvalue + : this.originalHeight + parseInt(height); + } else { + this.dirsign = -1; + this.startAt = + this.element.behavior == "alternate" + ? parseInt(height) + corrvalue + : this.originalHeight + parseInt(height); + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? this.originalHeight - corrvalue + : 0; + } + break; + } + case "left": + case "right": + default: { + let isRight = this._currentDirection == "right"; + // NOTE: It's important to use getComputedStyle() to not account for the padding. + let innerWidth = parseInt( + this.window.getComputedStyle(this.innerDiv).width + ); + if (innerWidth > this.outerDiv.offsetWidth) { + corrvalue = innerWidth - this.outerDiv.offsetWidth; + } + let rtl = + this.window.getComputedStyle(this.element).direction == "rtl"; + if (isRight != rtl) { + this.dirsign = -1; + this.stopAt = + this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? innerWidth - corrvalue + : 0; + this.startAt = + this.outerDiv.offsetWidth + + (this.element.behavior == "alternate" + ? corrvalue + : innerWidth + this.stopAt); + } else { + this.dirsign = 1; + this.startAt = + this.element.behavior == "alternate" ? innerWidth - corrvalue : 0; + this.stopAt = + this.outerDiv.offsetWidth + + (this.element.behavior == "alternate" || + this.element.behavior == "slide" + ? corrvalue + : innerWidth + this.startAt); + } + if (rtl) { + this.startAt = -this.startAt; + this.stopAt = -this.stopAt; + this.dirsign = -this.dirsign; + } + break; + } + } + + if (aResetPosition) { + this.newPosition = this.startAt; + this._fireEvent("start", false, false); + } + } // end if + + this.newPosition = + this.newPosition + this.dirsign * this.element.scrollAmount; + + if ( + (this.dirsign == 1 && this.newPosition > this.stopAt) || + (this.dirsign == -1 && this.newPosition < this.stopAt) + ) { + switch (this.element.behavior) { + case "alternate": + // lets start afresh + this.invalidateCache = true; + + // swap direction + const swap = { left: "right", down: "up", up: "down", right: "left" }; + this._currentDirection = swap[this._currentDirection] || "left"; + this.newPosition = this.stopAt; + + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + if (this._currentLoop != 1) { + this._fireEvent("bounce", false, true); + } + break; + + case "slide": + if (this._currentLoop > 1) { + this.newPosition = this.startAt; + } + break; + + default: + this.newPosition = this.startAt; + + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + // dispatch start event, even when this._currentLoop == 1, comp. with IE6 + this._fireEvent("start", false, false); + } + + if (this._currentLoop > 1) { + this._currentLoop--; + } else if (this._currentLoop == 1) { + if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.stopAt; + } else { + this.outerDiv.scrollLeft = this.stopAt; + } + this.element.stop(); + this._fireEvent("finish", false, true); + return; + } + } else if ( + this._currentDirection == "up" || + this._currentDirection == "down" + ) { + this.outerDiv.scrollTop = this.newPosition; + } else { + this.outerDiv.scrollLeft = this.newPosition; + } + + var myThis = this; + var lambda = function myTimeOutFunction() { + myThis._doMove(false); + }; + this.runId = this.window.setTimeout(lambda, this.scrollDelayWithTruespeed); + } + + init() { + this.element.stop(); + + if (this._currentDirection == "up" || this._currentDirection == "down") { + // store the original height before we add padding + this.innerDiv.style.padding = 0; + this.originalHeight = this.innerDiv.offsetHeight; + } + + this._doMove(true); + } + + _mutationActor(aMutations) { + while (aMutations.length) { + var mutation = aMutations.shift(); + var attrName = mutation.attributeName.toLowerCase(); + var oldValue = mutation.oldValue; + var target = mutation.target; + var newValue = target.getAttribute(attrName); + + if (oldValue != newValue) { + this.invalidateCache = true; + switch (attrName) { + case "loop": + this._currentLoop = target.loop; + break; + case "direction": + this._currentDirection = target.direction; + break; + } + } + } + } +}; + +this.MarqueeHorizontalImplWidget = class extends MarqueeBaseImplWidget { + generateContent() { + // White-space isn't allowed because a marquee could be + // inside 'white-space: pre' + this.shadowRoot.innerHTML = `<div class="outerDiv horizontal" + ><link rel="stylesheet" href="chrome://global/content/elements/marquee.css" + /><div class="innerDiv" id="innerDiv" + ><slot + /></div + ></div>`; + } +}; + +this.MarqueeVerticalImplWidget = class extends MarqueeBaseImplWidget { + generateContent() { + // White-space isn't allowed because a marquee could be + // inside 'white-space: pre' + this.shadowRoot.innerHTML = `<div class="outerDiv vertical" + ><link rel="stylesheet" href="chrome://global/content/elements/marquee.css" + /><div class="innerDiv" id="innerDiv" + ><slot + /></div + ></div>`; + } +}; diff --git a/toolkit/content/widgets/menu.js b/toolkit/content/widgets/menu.js new file mode 100644 index 0000000000..f787747a01 --- /dev/null +++ b/toolkit/content/widgets/menu.js @@ -0,0 +1,493 @@ +/* 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. +{ + let imports = {}; + ChromeUtils.defineESModuleGetters(imports, { + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", + }); + + const MozMenuItemBaseMixin = Base => { + class MozMenuItemBase extends MozElements.BaseTextMixin(Base) { + // nsIDOMXULSelectControlItemElement + set value(val) { + this.setAttribute("value", val); + } + get value() { + return this.getAttribute("value"); + } + + // nsIDOMXULSelectControlItemElement + get selected() { + return this.getAttribute("selected") == "true"; + } + + // nsIDOMXULSelectControlItemElement + get control() { + var parent = this.parentNode; + // Return the parent if it is a menu or menulist. + if (parent && XULMenuElement.isInstance(parent.parentNode)) { + return parent.parentNode; + } + return null; + } + + // nsIDOMXULContainerItemElement + get parentContainer() { + for (var parent = this.parentNode; parent; parent = parent.parentNode) { + if (XULMenuElement.isInstance(parent)) { + return parent; + } + } + return null; + } + } + MozXULElement.implementCustomInterface(MozMenuItemBase, [ + Ci.nsIDOMXULSelectControlItemElement, + Ci.nsIDOMXULContainerItemElement, + ]); + return MozMenuItemBase; + }; + + const MozMenuBaseMixin = Base => { + class MozMenuBase extends MozMenuItemBaseMixin(Base) { + set open(val) { + this.openMenu(val); + } + + get open() { + return this.hasAttribute("open"); + } + + get itemCount() { + var menupopup = this.menupopup; + return menupopup ? menupopup.children.length : 0; + } + + get menupopup() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + for ( + var child = this.firstElementChild; + child; + child = child.nextElementSibling + ) { + if (child.namespaceURI == XUL_NS && child.localName == "menupopup") { + return child; + } + } + return null; + } + + appendItem(aLabel, aValue) { + var menupopup = this.menupopup; + if (!menupopup) { + menupopup = this.ownerDocument.createXULElement("menupopup"); + this.appendChild(menupopup); + } + + var menuitem = this.ownerDocument.createXULElement("menuitem"); + menuitem.setAttribute("label", aLabel); + menuitem.setAttribute("value", aValue); + + return menupopup.appendChild(menuitem); + } + + getIndexOfItem(aItem) { + var menupopup = this.menupopup; + if (menupopup) { + var items = menupopup.children; + var length = items.length; + for (var index = 0; index < length; ++index) { + if (items[index] == aItem) { + return index; + } + } + } + return -1; + } + + getItemAtIndex(aIndex) { + var menupopup = this.menupopup; + if (!menupopup || aIndex < 0 || aIndex >= menupopup.children.length) { + return null; + } + + return menupopup.children[aIndex]; + } + } + MozXULElement.implementCustomInterface(MozMenuBase, [ + Ci.nsIDOMXULContainerElement, + ]); + return MozMenuBase; + }; + + // The <menucaption> element is used for rendering <html:optgroup> inside of <html:select>, + // See SelectParentHelper.jsm. + class MozMenuCaption extends MozMenuBaseMixin(MozXULElement) { + static get inheritedAttributes() { + return { + ".menu-iconic-left": "selected,disabled,checked", + ".menu-iconic-icon": "src=image,validate,src", + ".menu-iconic-text": "value=label,crop,highlightable", + ".menu-iconic-highlightable-text": "text=label,crop,highlightable", + }; + } + + connectedCallback() { + this.textContent = ""; + this.appendChild( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon" aria-hidden="true"></image> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"></label> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"></label> + `) + ); + this.initializeAttributeInheritance(); + } + } + + customElements.define("menucaption", MozMenuCaption); + + // In general, wait to render menus and menuitems inside menupopups + // until they are going to be visible: + window.addEventListener( + "popupshowing", + e => { + if (e.originalTarget.ownerDocument != document) { + return; + } + e.originalTarget.setAttribute("hasbeenopened", "true"); + for (let el of e.originalTarget.querySelectorAll("menuitem, menu")) { + el.render(); + } + }, + { capture: true } + ); + + class MozMenuItem extends MozMenuItemBaseMixin(MozXULElement) { + static get observedAttributes() { + return super.observedAttributes.concat("acceltext", "key"); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name == "acceltext") { + if (this._ignoreAccelTextChange) { + this._ignoreAccelTextChange = false; + } else { + this._accelTextIsDerived = false; + this._computeAccelTextFromKeyIfNeeded(); + } + } + if (name == "key") { + this._computeAccelTextFromKeyIfNeeded(); + } + super.attributeChangedCallback(name, oldValue, newValue); + } + + static get inheritedAttributes() { + return { + ".menu-iconic-text": "value=label,crop,accesskey,highlightable", + ".menu-text": "value=label,crop,accesskey,highlightable", + ".menu-iconic-highlightable-text": + "text=label,crop,accesskey,highlightable", + ".menu-iconic-left": "selected,_moz-menuactive,disabled,checked", + ".menu-iconic-icon": + "src=image,validate,triggeringprincipal=iconloadingprincipal", + ".menu-iconic-accel": "value=acceltext", + ".menu-accel": "value=acceltext", + }; + } + + static get iconicNoAccelFragment() { + // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here. + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/> + `), + true + ); + Object.defineProperty(this, "iconicNoAccelFragment", { value: frag }); + return frag; + } + + static get iconicFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" aria-hidden="true"> + <label class="menu-iconic-accel"/> + </hbox> + `), + true + ); + Object.defineProperty(this, "iconicFragment", { value: frag }); + return frag; + } + + static get plainFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <label class="menu-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" aria-hidden="true"> + <label class="menu-accel"/> + </hbox> + `), + true + ); + Object.defineProperty(this, "plainFragment", { value: frag }); + return frag; + } + + get isIconic() { + let type = this.getAttribute("type"); + return ( + type == "checkbox" || + type == "radio" || + this.classList.contains("menuitem-iconic") + ); + } + + get isMenulistChild() { + return this.matches("menulist > menupopup > menuitem"); + } + + get isInHiddenMenupopup() { + return this.matches("menupopup:not([hasbeenopened]) menuitem"); + } + + _computeAccelTextFromKeyIfNeeded() { + if (!this._accelTextIsDerived && this.getAttribute("acceltext")) { + return; + } + let accelText = (() => { + if (!document.contains(this)) { + return null; + } + let keyId = this.getAttribute("key"); + if (!keyId) { + return null; + } + let key = document.getElementById(keyId); + if (!key) { + let msg = + `Key ${keyId} of menuitem ${this.getAttribute("label")} ` + + `could not be found`; + if (keyId.startsWith("ext-key-id-")) { + console.info(msg); + } else { + console.error(msg); + } + return null; + } + return imports.ShortcutUtils.prettifyShortcut(key); + })(); + + this._accelTextIsDerived = true; + // We need to ignore the next attribute change callback for acceltext, in + // order to not reenter here. + this._ignoreAccelTextChange = true; + if (accelText) { + this.setAttribute("acceltext", accelText); + } else { + this.removeAttribute("acceltext"); + } + } + + render() { + if (this.renderedOnce) { + return; + } + this.renderedOnce = true; + this.textContent = ""; + if (this.isMenulistChild) { + this.append(this.constructor.iconicNoAccelFragment.cloneNode(true)); + } else if (this.isIconic) { + this.append(this.constructor.iconicFragment.cloneNode(true)); + } else { + this.append(this.constructor.plainFragment.cloneNode(true)); + } + + this._computeAccelTextFromKeyIfNeeded(); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + if (this.renderedOnce) { + this._computeAccelTextFromKeyIfNeeded(); + } + // Eagerly render if we are being inserted into a menulist (since we likely need to + // size it), or into an already-opened menupopup (since we are already visible). + // Checking isConnectedAndReady is an optimization that will let us quickly skip + // non-menulists that are being connected during parse. + if ( + this.isMenulistChild || + (this.isConnectedAndReady && !this.isInHiddenMenupopup) + ) { + this.render(); + } + } + } + + customElements.define("menuitem", MozMenuItem); + + const isHiddenWindow = + document.documentURI == "chrome://browser/content/hiddenWindowMac.xhtml"; + + class MozMenu extends MozMenuBaseMixin( + MozElements.MozElementMixin(XULMenuElement) + ) { + static get inheritedAttributes() { + return { + ".menubar-text": "value=label,accesskey,crop", + ".menu-iconic-text": "value=label,accesskey,crop,highlightable", + ".menu-text": "value=label,accesskey,crop", + ".menu-iconic-highlightable-text": + "text=label,crop,accesskey,highlightable", + ".menubar-left": "src=image", + ".menu-iconic-icon": + "src=image,triggeringprincipal=iconloadingprincipal,validate", + ".menu-iconic-accel": "value=acceltext", + ".menu-right": "_moz-menuactive,disabled", + ".menu-accel": "value=acceltext", + }; + } + + get needsEagerRender() { + return ( + this.isMenubarChild || this.isMenulistChild || !this.isInHiddenMenupopup + ); + } + + get isMenubarChild() { + return this.matches("menubar > menu"); + } + + get isMenulistChild() { + return this.matches("menulist > menupopup > menu"); + } + + get isInHiddenMenupopup() { + return this.matches("menupopup:not([hasbeenopened]) menu"); + } + + get isIconic() { + return this.classList.contains("menu-iconic"); + } + + get fragment() { + let { isMenubarChild, isIconic } = this; + let fragment = null; + // Add aria-hidden="true" on all DOM, since XULMenuAccessible handles accessibility here. + if (isMenubarChild && isIconic) { + if (!MozMenu.menubarIconicFrag) { + MozMenu.menubarIconicFrag = MozXULElement.parseXULToFragment(` + <image class="menubar-left" aria-hidden="true"/> + <label class="menubar-text" crop="end" aria-hidden="true"/> + `); + } + fragment = document.importNode(MozMenu.menubarIconicFrag, true); + } + if (isMenubarChild && !isIconic) { + if (!MozMenu.menubarFrag) { + MozMenu.menubarFrag = MozXULElement.parseXULToFragment(` + <label class="menubar-text" crop="end" aria-hidden="true"/> + `); + } + fragment = document.importNode(MozMenu.menubarFrag, true); + } + if (!isMenubarChild && isIconic) { + if (!MozMenu.normalIconicFrag) { + MozMenu.normalIconicFrag = MozXULElement.parseXULToFragment(` + <hbox class="menu-iconic-left" align="center" pack="center" aria-hidden="true"> + <image class="menu-iconic-icon"/> + </hbox> + <label class="menu-iconic-text" flex="1" crop="end" aria-hidden="true"/> + <label class="menu-iconic-highlightable-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" anonid="accel" aria-hidden="true"> + <label class="menu-iconic-accel"/> + </hbox> + <hbox align="center" class="menu-right" aria-hidden="true"> + <image/> + </hbox> + `); + } + + fragment = document.importNode(MozMenu.normalIconicFrag, true); + } + if (!isMenubarChild && !isIconic) { + if (!MozMenu.normalFrag) { + MozMenu.normalFrag = MozXULElement.parseXULToFragment(` + <label class="menu-text" crop="end" aria-hidden="true"/> + <hbox class="menu-accel-container" anonid="accel" aria-hidden="true"> + <label class="menu-accel"/> + </hbox> + <hbox align="center" class="menu-right" aria-hidden="true"> + <image/> + </hbox> + `); + } + + fragment = document.importNode(MozMenu.normalFrag, true); + } + return fragment; + } + + render() { + // There are 2 main types of menus: + // (1) direct descendant of a menubar + // (2) all other menus + // There is also an "iconic" variation of (1) and (2) based on the class. + // To make this as simple as possible, we don't support menus being changed from one + // of these types to another after the initial DOM connection. It'd be possible to make + // this work by keeping track of the markup we prepend and then removing / re-prepending + // during a change, but it's not a feature we use anywhere currently. + if (this.renderedOnce) { + return; + } + this.renderedOnce = true; + + // There will be a <menupopup /> already. Don't clear it out, just put our markup before it. + this.prepend(this.fragment); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + // On OSX we will have a bunch of menus in the hidden window. They get converted + // into native menus based on the host attributes, so the inner DOM doesn't need + // to be created. + if (isHiddenWindow) { + return; + } + + if (this.delayConnectedCallback()) { + return; + } + + // Wait until we are going to be visible or required for sizing a popup. + if (!this.needsEagerRender) { + return; + } + + this.render(); + } + } + + customElements.define("menu", MozMenu); +} diff --git a/toolkit/content/widgets/menulist.js b/toolkit/content/widgets/menulist.js new file mode 100644 index 0000000000..4e66c030f3 --- /dev/null +++ b/toolkit/content/widgets/menulist.js @@ -0,0 +1,417 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const MozXULMenuElement = MozElements.MozElementMixin(XULMenuElement); + const MenuBaseControl = MozElements.BaseControlMixin(MozXULMenuElement); + + class MozMenuList extends MenuBaseControl { + constructor() { + super(); + + this.addEventListener( + "command", + event => { + if (event.target.parentNode.parentNode == this) { + this.selectedItem = event.target; + } + }, + true + ); + + this.addEventListener("popupshowing", event => { + if (event.target.parentNode == this) { + this.activeChild = null; + if (this.selectedItem) { + // Not ready for auto-setting the active child in hierarchies yet. + // For now, only do this when the outermost menupopup opens. + this.activeChild = this.mSelectedInternal; + } + } + }); + + this.addEventListener( + "keypress", + event => { + if ( + event.defaultPrevented || + event.altKey || + event.ctrlKey || + event.metaKey + ) { + return; + } + + if ( + AppConstants.platform === "macosx" && + !this.open && + (event.keyCode == KeyEvent.DOM_VK_UP || + event.keyCode == KeyEvent.DOM_VK_DOWN) + ) { + // This should open the menulist on macOS, see + // XULButtonElement::PostHandleEvent. + return; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_UP || + event.keyCode == KeyEvent.DOM_VK_DOWN || + event.keyCode == KeyEvent.DOM_VK_PAGE_UP || + event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN || + event.keyCode == KeyEvent.DOM_VK_HOME || + event.keyCode == KeyEvent.DOM_VK_END || + event.keyCode == KeyEvent.DOM_VK_BACK_SPACE || + event.charCode > 0 + ) { + // Moving relative to an item: start from the currently selected item + this.activeChild = this.mSelectedInternal; + if (this.handleKeyPress(event)) { + this.activeChild.doCommand(); + event.preventDefault(); + } + } + }, + { mozSystemGroup: true } + ); + + this.attachShadow({ mode: "open" }); + } + + static get inheritedAttributes() { + return { + image: "src=image", + "#label": "value=label,crop,accesskey", + "#highlightable-label": "text=label,crop,accesskey", + dropmarker: "disabled,open", + }; + } + + static get markup() { + // Accessibility information of these nodes will be presented + // on XULComboboxAccessible generated from <menulist>; + // hide these nodes from the accessibility tree. + return ` + <html:link href="chrome://global/skin/menulist.css" rel="stylesheet"/> + <hbox id="label-box" part="label-box" flex="1" role="none"> + <image part="icon" role="none"/> + <label id="label" part="label" crop="end" flex="1" role="none"/> + <label id="highlightable-label" part="label" crop="end" flex="1" role="none"/> + </hbox> + <dropmarker part="dropmarker" type="menu" role="none"/> + <html:slot/> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + if (!this.hasAttribute("popuponly")) { + this.shadowRoot.appendChild(this.constructor.fragment); + this._labelBox = this.shadowRoot.getElementById("label-box"); + this._dropmarker = this.shadowRoot.querySelector("dropmarker"); + this.initializeAttributeInheritance(); + } else { + this.shadowRoot.appendChild(document.createElement("slot")); + } + + this.mSelectedInternal = null; + this.mAttributeObserver = null; + this.setInitialSelection(); + } + + // nsIDOMXULSelectControlElement + set value(val) { + // if the new value is null, we still need to remove the old value + if (val == null) { + this.selectedItem = val; + return; + } + + var arr = null; + var popup = this.menupopup; + if (popup) { + arr = popup.getElementsByAttribute("value", val); + } + + if (arr && arr.item(0)) { + this.selectedItem = arr[0]; + } else { + this.selectedItem = null; + this.setAttribute("value", val); + } + } + + // nsIDOMXULSelectControlElement + get value() { + return this.getAttribute("value"); + } + + // nsIDOMXULMenuListElement + set image(val) { + this.setAttribute("image", val); + } + + // nsIDOMXULMenuListElement + get image() { + return this.getAttribute("image"); + } + + // nsIDOMXULMenuListElement + get label() { + return this.getAttribute("label"); + } + + set description(val) { + this.setAttribute("description", val); + } + + get description() { + return this.getAttribute("description"); + } + + // nsIDOMXULMenuListElement + set open(val) { + this.openMenu(val); + } + + // nsIDOMXULMenuListElement + get open() { + return this.hasAttribute("open"); + } + + // nsIDOMXULSelectControlElement + get itemCount() { + return this.menupopup ? this.menupopup.children.length : 0; + } + + get menupopup() { + var popup = this.firstElementChild; + while (popup && popup.localName != "menupopup") { + popup = popup.nextElementSibling; + } + return popup; + } + + // nsIDOMXULSelectControlElement + set selectedIndex(val) { + var popup = this.menupopup; + if (popup && 0 <= val) { + if (val < popup.children.length) { + this.selectedItem = popup.children[val]; + } + } else { + this.selectedItem = null; + } + } + + // nsIDOMXULSelectControlElement + get selectedIndex() { + // Quick and dirty. We won't deal with hierarchical menulists yet. + if ( + !this.selectedItem || + !this.mSelectedInternal.parentNode || + this.mSelectedInternal.parentNode.parentNode != this + ) { + return -1; + } + + var children = this.mSelectedInternal.parentNode.children; + var i = children.length; + while (i--) { + if (children[i] == this.mSelectedInternal) { + break; + } + } + + return i; + } + + // nsIDOMXULSelectControlElement + set selectedItem(val) { + var oldval = this.mSelectedInternal; + if (oldval == val) { + return; + } + + if (val && !this.contains(val)) { + return; + } + + if (oldval) { + oldval.removeAttribute("selected"); + this.mAttributeObserver.disconnect(); + } + + this.mSelectedInternal = val; + let attributeFilter = ["value", "label", "image", "description"]; + if (val) { + val.setAttribute("selected", "true"); + for (let attr of attributeFilter) { + if (val.hasAttribute(attr)) { + this.setAttribute(attr, val.getAttribute(attr)); + } else { + this.removeAttribute(attr); + } + } + + this.mAttributeObserver = new MutationObserver( + this.handleMutation.bind(this) + ); + this.mAttributeObserver.observe(val, { attributeFilter }); + } else { + for (let attr of attributeFilter) { + this.removeAttribute(attr); + } + } + + var event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + + event = document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + this.dispatchEvent(event); + } + + // nsIDOMXULSelectControlElement + get selectedItem() { + return this.mSelectedInternal; + } + + setInitialSelection() { + var popup = this.menupopup; + if (popup) { + var arr = popup.getElementsByAttribute("selected", "true"); + + var editable = this.editable; + var value = this.value; + if (!arr.item(0) && value) { + arr = popup.getElementsByAttribute( + editable ? "label" : "value", + value + ); + } + + if (arr.item(0)) { + this.selectedItem = arr[0]; + } else if (!editable) { + this.selectedIndex = 0; + } + } + } + + contains(item) { + if (!item) { + return false; + } + + var parent = item.parentNode; + return parent && parent.parentNode == this; + } + + handleMutation(aRecords) { + for (let record of aRecords) { + let t = record.target; + if (t == this.mSelectedInternal) { + let attrName = record.attributeName; + switch (attrName) { + case "value": + case "label": + case "image": + case "description": + if (t.hasAttribute(attrName)) { + this.setAttribute(attrName, t.getAttribute(attrName)); + } else { + this.removeAttribute(attrName); + } + } + } + } + } + + // nsIDOMXULSelectControlElement + getIndexOfItem(item) { + var popup = this.menupopup; + if (popup) { + var children = popup.children; + var i = children.length; + while (i--) { + if (children[i] == item) { + return i; + } + } + } + return -1; + } + + // nsIDOMXULSelectControlElement + getItemAtIndex(index) { + var popup = this.menupopup; + if (popup) { + var children = popup.children; + if (index >= 0 && index < children.length) { + return children[index]; + } + } + return null; + } + + appendItem(label, value, description) { + if (!this.menupopup) { + this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`)); + } + + var popup = this.menupopup; + popup.appendChild(MozXULElement.parseXULToFragment(`<menuitem />`)); + + var item = popup.lastElementChild; + if (label !== undefined) { + item.setAttribute("label", label); + } + item.setAttribute("value", value); + if (description) { + item.setAttribute("description", description); + } + + return item; + } + + removeAllItems() { + this.selectedItem = null; + var popup = this.menupopup; + if (popup) { + this.removeChild(popup); + } + } + + disconnectedCallback() { + if (this.mAttributeObserver) { + this.mAttributeObserver.disconnect(); + } + + if (this._labelBox) { + this._labelBox.remove(); + this._dropmarker.remove(); + this._labelBox = null; + this._dropmarker = null; + } + } + } + + MenuBaseControl.implementCustomInterface(MozMenuList, [ + Ci.nsIDOMXULMenuListElement, + Ci.nsIDOMXULSelectControlElement, + ]); + + customElements.define("menulist", MozMenuList); +} diff --git a/toolkit/content/widgets/menupopup.js b/toolkit/content/widgets/menupopup.js new file mode 100644 index 0000000000..31801d6a33 --- /dev/null +++ b/toolkit/content/widgets/menupopup.js @@ -0,0 +1,297 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + // For the non-native context menu styling, we need to know if we need + // a gutter for checkboxes. To do this, check whether there are any + // radio/checkbox type menuitems in a menupopup when showing it. We use a + // system bubbling event listener to ensure we run *after* the "normal" + // popupshowing listeners, so (visibility) changes they make to their items + // take effect first, before we check for checkable menuitems. + Services.els.addSystemEventListener( + document, + "popupshowing", + function (e) { + if (e.target.nodeName == "menupopup") { + let haveCheckableChild = e.target.querySelector( + `:scope > menuitem:not([hidden]):is([type=checkbox],[type=radio]${ + // On macOS, selected menuitems are checked regardless of type + AppConstants.platform == "macosx" + ? ",[checked=true],[selected=true]" + : "" + })` + ); + e.target.toggleAttribute("needsgutter", haveCheckableChild); + } + }, + false + ); + + class MozMenuPopup extends MozElements.MozElementMixin(XULPopupElement) { + constructor() { + super(); + + this.AUTOSCROLL_INTERVAL = 25; + this.NOT_DRAGGING = 0; + this.DRAG_OVER_BUTTON = -1; + this.DRAG_OVER_POPUP = 1; + this._draggingState = this.NOT_DRAGGING; + this._scrollTimer = 0; + + this.attachShadow({ mode: "open" }); + + this.addEventListener("popupshowing", event => { + if (event.target != this) { + return; + } + + // Make sure we generated shadow DOM to place menuitems into. + this.ensureInitialized(); + }); + + this.addEventListener("DOMMenuItemActive", this); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + + this.hasConnected = true; + if (this.parentNode?.localName == "menulist") { + this._setUpMenulistPopup(); + } + } + + initShadowDOM() { + // Retarget events from shadow DOM arrowscrollbox to the host. + this.scrollBox.addEventListener("scroll", ev => + this.dispatchEvent(new Event("scroll")) + ); + this.scrollBox.addEventListener("overflow", ev => + this.dispatchEvent(new Event("overflow")) + ); + this.scrollBox.addEventListener("underflow", ev => + this.dispatchEvent(new Event("underflow")) + ); + } + + ensureInitialized() { + this.shadowRoot; + } + + get shadowRoot() { + if (!super.shadowRoot.firstChild) { + // We generate shadow DOM lazily on popupshowing event to avoid extra + // load on the system during browser startup. + super.shadowRoot.appendChild(this.fragment); + this.initShadowDOM(); + } + return super.shadowRoot; + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment( + this.markup + ); + } + return document.importNode(this.constructor._fragment, true); + } + + get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/skin/global.css"/> + <html:style>${this.styles}</html:style> + <arrowscrollbox class="menupopup-arrowscrollbox" + part="arrowscrollbox content" + exportparts="scrollbox: arrowscrollbox-scrollbox" + flex="1" + orient="vertical" + smoothscroll="false"> + <html:slot></html:slot> + </arrowscrollbox> + `; + } + + get styles() { + return ` + :host(.in-menulist) arrowscrollbox::part(scrollbutton-up), + :host(.in-menulist) arrowscrollbox::part(scrollbutton-down) { + display: none; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox) { + overflow: auto; + margin: 0; + } + :host(.in-menulist) arrowscrollbox::part(scrollbox-clip) { + overflow: visible; + } + `; + } + + get scrollBox() { + if (!this._scrollBox) { + this._scrollBox = this.shadowRoot.querySelector("arrowscrollbox"); + } + return this._scrollBox; + } + + /** + * Adds event listeners for a MozMenuPopup inside a menulist element. + */ + _setUpMenulistPopup() { + // Access shadow root to generate menupoup shadow DOMs. We do generate + // shadow DOM on popupshowing, but it doesn't work for HTML:selects, + // which are implemented via menulist elements living in the main process. + // So make them a special case then. + this.ensureInitialized(); + this.classList.add("in-menulist"); + + this.addEventListener("popupshown", () => { + // Enable drag scrolling even when the mouse wasn't used. The + // mousemove handler will remove it if the mouse isn't down. + this._enableDragScrolling(false); + }); + + this.addEventListener("popuphidden", () => { + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + this.releaseCapture(); + this.scrollBox.scrollbox.scrollTop = 0; + }); + + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + if ( + this.state == "open" && + (event.target.localName == "menuitem" || + event.target.localName == "menu" || + event.target.localName == "menucaption") + ) { + this._enableDragScrolling(true); + } + }); + + this.addEventListener("mouseup", event => { + if (event.button != 0) { + return; + } + + this._draggingState = this.NOT_DRAGGING; + this._clearScrollTimer(); + }); + + this.addEventListener("mousemove", event => { + if (!this._draggingState) { + return; + } + + this._clearScrollTimer(); + + // If the user released the mouse before the menupopup opens, we will + // still be capturing, so check that the button is still pressed. If + // not, release the capture and do nothing else. This also handles if + // the dropdown was opened via the keyboard. + if (!(event.buttons & 1)) { + this._draggingState = this.NOT_DRAGGING; + this.releaseCapture(); + return; + } + + // If dragging outside the top or bottom edge of the menupopup, but + // within the menupopup area horizontally, scroll the list in that + // direction. The _draggingState flag is used to ensure that scrolling + // does not start until the mouse has moved over the menupopup first, + // preventing scrolling while over the dropdown button. + let popupRect = this.getOuterScreenRect(); + if ( + event.screenX >= popupRect.left && + event.screenX <= popupRect.right + ) { + if (this._draggingState == this.DRAG_OVER_BUTTON) { + if ( + event.screenY > popupRect.top && + event.screenY < popupRect.bottom + ) { + this._draggingState = this.DRAG_OVER_POPUP; + } + } + + if ( + this._draggingState == this.DRAG_OVER_POPUP && + (event.screenY <= popupRect.top || + event.screenY >= popupRect.bottom) + ) { + let scrollAmount = event.screenY <= popupRect.top ? -1 : 1; + this.scrollBox.scrollByIndex(scrollAmount, true); + + let win = this.ownerGlobal; + this._scrollTimer = win.setInterval(() => { + this.scrollBox.scrollByIndex(scrollAmount, true); + }, this.AUTOSCROLL_INTERVAL); + } + } + }); + + this._menulistPopupIsSetUp = true; + } + + _enableDragScrolling(overItem) { + if (!this._draggingState) { + this.setCaptureAlways(); + this._draggingState = overItem + ? this.DRAG_OVER_POPUP + : this.DRAG_OVER_BUTTON; + } + } + + _clearScrollTimer() { + if (this._scrollTimer) { + this.ownerGlobal.clearInterval(this._scrollTimer); + this._scrollTimer = 0; + } + } + + on_DOMMenuItemActive(event) { + // Scroll buttons may overlap the active item. In that case, scroll + // further to stay clear of the buttons. + if ( + this.parentNode?.localName == "menulist" || + !this.scrollBox.hasAttribute("overflowing") + ) { + return; + } + let item = event.target; + if (item.parentNode != this) { + return; + } + let itemRect = item.getBoundingClientRect(); + let buttonRect = this.scrollBox._scrollButtonUp.getBoundingClientRect(); + if (buttonRect.bottom > itemRect.top) { + this.scrollBox.scrollByPixels(itemRect.top - buttonRect.bottom, true); + } else { + buttonRect = this.scrollBox._scrollButtonDown.getBoundingClientRect(); + if (buttonRect.top < itemRect.bottom) { + this.scrollBox.scrollByPixels(itemRect.bottom - buttonRect.top, true); + } + } + } + } + + customElements.define("menupopup", MozMenuPopup); + + MozElements.MozMenuPopup = MozMenuPopup; +} diff --git a/toolkit/content/widgets/message-bar.css b/toolkit/content/widgets/message-bar.css new file mode 100644 index 0000000000..eddc5a3ae6 --- /dev/null +++ b/toolkit/content/widgets/message-bar.css @@ -0,0 +1,219 @@ +/* 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/. */ + +:host { + --info-icon-url: url("chrome://global/skin/icons/info-filled.svg"); + --warn-icon-url: url("chrome://global/skin/icons/warning.svg"); + --success-icon-url: url("chrome://global/skin/icons/check.svg"); + --error-icon-url: url("chrome://global/skin/icons/error.svg"); + --close-icon-url: url("chrome://global/skin/icons/close-12.svg"); + --close-fill-color: var(--in-content-icon-color); + --icon-size: 16px; + --close-icon-size: 28px; +} + +:host { + --message-bar-background-color: var(--in-content-box-info-background); + --message-bar-text-color: var(--in-content-text-color); + --message-bar-icon-url: var(--info-icon-url); + /* The default values of --in-content-button* are sufficient, even for dark themes */ +} + +:host([type=warning]) { + --message-bar-icon-url: var(--warn-icon-url); +} + +:host([type=success]) { + --message-bar-icon-url: var(--success-icon-url); +} + +:host([type=error]), +:host([type=critical]) { + --message-bar-icon-url: var(--error-icon-url); +} + +:host { + border: 1px solid transparent; + border-radius: 4px; +} + +/* Make the host to behave as a block by default, but allow hidden to hide it. */ +:host(:not([hidden])) { + display: block; +} + +::slotted(button) { + /* Enforce micro-button width. */ + min-width: -moz-fit-content !important; +} + +/* MessageBar Grid Layout */ + +.container { + background: var(--message-bar-background-color); + color: var(--message-bar-text-color); + + padding: 3px 7px; + position: relative; + + border-radius: 4px; + + display: flex; + /* Ensure that the message bar shadow dom elements are vertically aligned. */ + align-items: center; +} + +:host([align="center"]) .container { + justify-content: center; +} + +.content { + margin: 0 4px; + display: inline-block; + /* Ensure that the message bar content is vertically aligned. */ + align-items: center; + /* Ensure that the message bar content is wrapped. */ + word-break: break-word; +} + +/* MessageBar icon style */ + +.icon { + padding: 4px; + width: var(--icon-size); + height: var(--icon-size); + flex-shrink: 0; +} + +.icon::after { + display: inline-block; + appearance: none; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + content: ""; + background-image: var(--message-bar-icon-url); + background-size: var(--icon-size); + width: var(--icon-size); + height: var(--icon-size); +} + +/* Use a spacer to position the close button at the end, but also support + * centering if required. */ +.spacer { + flex-grow: 1; +} + +/* Close icon styles */ + +:host(:not([dismissable])) .close { + display: none; +} + +.close { + background-image: var(--close-icon-url); + background-repeat: no-repeat; + background-position: center center; + -moz-context-properties: fill; + fill: currentColor; + min-width: auto; + min-height: auto; + width: var(--close-icon-size); + height: var(--close-icon-size); + padding: 0; + flex-shrink: 0; + margin: 4px 8px; + background-size: 12px; +} + +@media (prefers-contrast) { + :host { + border-color: CanvasText; + } +} + +@media not (prefers-contrast) { + /* MessageBar colors by message type */ + /* Colors from: https://design.firefox.com/photon/components/message-bars.html#type-specific-style */ + + :host([type=warning]) { + /* Ensure colors within the bar are adjusted and controls are readable */ + color-scheme: light; + + --message-bar-background-color: var(--yellow-50); + --message-bar-text-color: var(--yellow-90); + + --in-content-button-background: var(--yellow-60); + --in-content-button-background-hover: var(--yellow-70); + --in-content-button-background-active: var(--yellow-80); + + --close-fill-color: var(--message-bar-text-color); + } + + :host([type=success]) { + /* Ensure colors within the bar are adjusted and controls are readable */ + color-scheme: light; + + --message-bar-background-color: var(--green-50); + --message-bar-text-color: var(--green-90); + + --in-content-button-background: var(--green-60); + --in-content-button-background-hover: var(--green-70); + --in-content-button-background-active: var(--green-80); + } + + :host([type=error]) { + --message-bar-background-color: var(--red-60); + --message-bar-text-color: #ffffff; + + --in-content-button-background: var(--red-70); + --in-content-button-background-hover: var(--red-80); + --in-content-button-background-active: var(--red-90); + } + + :host([type=info]) .icon { + color: rgb(0,144,237); + } + + :host([type=warning]) .icon { + color: rgb(255,164,54); + } + + :host([type=critical]) .icon { + color: rgb(226,40,80); + } + + .close { + fill: var(--close-fill-color); + } + + @media (prefers-color-scheme: dark) { + /* Don't set the background in prefers-contrast mode or macOS can end up + * with black on black text. */ + :host([type=info]) .icon { + color: rgb(128,235,255); + } + + :host([type=warning]) .icon { + color: rgb(255,189,79); + } + + :host([type=critical]) .icon { + color: rgb(255,154,162); + } + } +} + +strong { + font-weight: 600; +} + +.text-link:hover { + cursor: pointer; +} + +@keyframes spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} diff --git a/toolkit/content/widgets/message-bar.js b/toolkit/content/widgets/message-bar.js new file mode 100644 index 0000000000..d38347b40a --- /dev/null +++ b/toolkit/content/widgets/message-bar.js @@ -0,0 +1,91 @@ +/* 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`. +{ + class MessageBarElement extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({ mode: "open" }); + window.MozXULElement?.insertFTLIfNeeded( + "toolkit/global/notification.ftl" + ); + document.l10n.connectRoot(this.shadowRoot); + const content = this.constructor.template.content.cloneNode(true); + shadowRoot.append(content); + this.closeButton.addEventListener("click", () => this.dismiss(), { + once: true, + }); + } + + disconnectedCallback() { + this.dispatchEvent(new CustomEvent("message-bar:close")); + } + + get closeButton() { + return this.shadowRoot.querySelector("button.close"); + } + + static get template() { + const template = document.createElement("template"); + + const commonStyles = document.createElement("link"); + commonStyles.rel = "stylesheet"; + commonStyles.href = "chrome://global/skin/in-content/common.css"; + const messageBarStyles = document.createElement("link"); + messageBarStyles.rel = "stylesheet"; + messageBarStyles.href = + "chrome://global/content/elements/message-bar.css"; + template.content.append(commonStyles, messageBarStyles); + + // A container for the entire message bar content, + // most of the css rules needed to provide the + // expected message bar layout is applied on this + // element. + const container = document.createElement("div"); + container.part = "container"; + container.classList.add("container"); + template.content.append(container); + + const icon = document.createElement("span"); + icon.classList.add("icon"); + icon.part = "icon"; + container.append(icon); + + const barcontent = document.createElement("span"); + barcontent.classList.add("content"); + barcontent.append(document.createElement("slot")); + container.append(barcontent); + + const spacer = document.createElement("span"); + spacer.classList.add("spacer"); + container.append(spacer); + + const closeIcon = document.createElement("button"); + closeIcon.classList.add("close", "ghost-button"); + document.l10n.setAttributes(closeIcon, "notification-close-button"); + container.append(closeIcon); + + Object.defineProperty(this, "template", { + value: template, + }); + + return template; + } + + dismiss() { + this.dispatchEvent(new CustomEvent("message-bar:user-dismissed")); + this.close(); + } + + close() { + this.remove(); + } + } + + customElements.define("message-bar", MessageBarElement); +} diff --git a/toolkit/content/widgets/moz-button-group/moz-button-group.css b/toolkit/content/widgets/moz-button-group/moz-button-group.css new file mode 100644 index 0000000000..ba79d69e12 --- /dev/null +++ b/toolkit/content/widgets/moz-button-group/moz-button-group.css @@ -0,0 +1,16 @@ +/* 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/. */ + +:host { + display: flex; + justify-content: flex-end; +} + +::slotted(button) { + margin: 0 !important; +} + +::slotted(button:not(:first-child, .popup-notification-dropmarker)) { + margin-inline-start: var(--space-small) !important; +} diff --git a/toolkit/content/widgets/moz-button-group/moz-button-group.mjs b/toolkit/content/widgets/moz-button-group/moz-button-group.mjs new file mode 100644 index 0000000000..0706f94762 --- /dev/null +++ b/toolkit/content/widgets/moz-button-group/moz-button-group.mjs @@ -0,0 +1,105 @@ +/* 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 { html } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +export const PLATFORM_LINUX = "linux"; +export const PLATFORM_MACOS = "macosx"; +export const PLATFORM_WINDOWS = "win"; + +/** + * A grouping of buttons. Primary button order will be set automatically based + * on class="primary", type="submit" or autofocus attribute. Set slot="primary" + * on a primary button that does not have primary styling to set its position. + * + * @tagname moz-button-group + * @property {string} platform - The detected platform, set automatically. + */ +export default class MozButtonGroup extends MozLitElement { + static queries = { + defaultSlotEl: "slot:not([name])", + primarySlotEl: "slot[name=primary]", + }; + + static properties = { + platform: { state: true }, + }; + + constructor() { + super(); + this.#detectPlatform(); + } + + #detectPlatform() { + if (typeof AppConstants !== "undefined") { + this.platform = AppConstants.platform; + } else if (navigator.platform.includes("Linux")) { + this.platform = PLATFORM_LINUX; + } else if (navigator.platform.includes("Mac")) { + this.platform = PLATFORM_MACOS; + } else { + this.platform = PLATFORM_WINDOWS; + } + } + + onSlotchange(e) { + for (let child of this.defaultSlotEl.assignedNodes()) { + if (!(child instanceof Element)) { + // Text nodes won't support classList or getAttribute. + continue; + } + // Bug 1791816: These should check moz-button instead of button. + if ( + child.localName == "button" && + (child.classList.contains("primary") || + child.getAttribute("type") == "submit" || + child.hasAttribute("autofocus") || + child.hasAttribute("default")) + ) { + child.slot = "primary"; + } + } + this.#reorderLightDom(); + } + + #reorderLightDom() { + let primarySlottedChildren = [...this.primarySlotEl.assignedNodes()]; + if (this.platform == PLATFORM_WINDOWS) { + primarySlottedChildren.reverse(); + for (let child of primarySlottedChildren) { + child.parentElement.prepend(child); + } + } else { + for (let child of primarySlottedChildren) { + // Ensure the primary buttons are at the end of the light DOM. + child.parentElement.append(child); + } + } + } + + updated(changedProperties) { + if (changedProperties.has("platform")) { + this.#reorderLightDom(); + } + } + + render() { + let slots = [ + html` <slot @slotchange=${this.onSlotchange}></slot> `, + html` <slot name="primary"></slot> `, + ]; + if (this.platform == PLATFORM_WINDOWS) { + slots = [slots[1], slots[0]]; + } + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-button-group.css" + /> + ${slots} + `; + } +} +customElements.define("moz-button-group", MozButtonGroup); diff --git a/toolkit/content/widgets/moz-button-group/moz-button-group.stories.mjs b/toolkit/content/widgets/moz-button-group/moz-button-group.stories.mjs new file mode 100644 index 0000000000..444cc5373c --- /dev/null +++ b/toolkit/content/widgets/moz-button-group/moz-button-group.stories.mjs @@ -0,0 +1,57 @@ +/* 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 { html } from "../vendor/lit.all.mjs"; +import { + PLATFORM_LINUX, + PLATFORM_MACOS, + PLATFORM_WINDOWS, +} from "./moz-button-group.mjs"; + +export default { + title: "UI Widgets/Button Group", + component: "moz-button-group", + argTypes: { + platform: { + options: [PLATFORM_LINUX, PLATFORM_MACOS, PLATFORM_WINDOWS], + control: { type: "select" }, + }, + }, + parameters: { + status: "stable", + fluent: ` +moz-button-group-p = The button group is below. Card for emphasis. +moz-button-group-ok = OK +moz-button-group-cancel = Cancel + `, + }, +}; + +const Template = ({ platform }) => html` + <div class="card card-no-hover" style="max-width: 400px"> + <p data-l10n-id="moz-button-group-p"></p> + <moz-button-group .platform=${platform}> + <button class="primary" data-l10n-id="moz-button-group-ok"></button> + <button data-l10n-id="moz-button-group-cancel"></button> + </moz-button-group> + </div> +`; + +export const Default = Template.bind({}); +Default.args = { + // Platform will auto-detected. +}; + +export const Windows = Template.bind({}); +Windows.args = { + platform: PLATFORM_WINDOWS, +}; +export const Mac = Template.bind({}); +Mac.args = { + platform: PLATFORM_MACOS, +}; +export const Linux = Template.bind({}); +Linux.args = { + platform: PLATFORM_LINUX, +}; diff --git a/toolkit/content/widgets/moz-card/moz-card.css b/toolkit/content/widgets/moz-card/moz-card.css new file mode 100644 index 0000000000..52c0ac0980 --- /dev/null +++ b/toolkit/content/widgets/moz-card/moz-card.css @@ -0,0 +1,180 @@ +/* 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/. */ + + :host { + --card-border-color: color-mix(in srgb, currentColor 10%, transparent); + --card-border-radius: var(--border-radius-medium); + --card-border-width: var(--border-width); + --card-border: var(--card-border-width) solid var(--card-border-color); + --card-background-color: var(--box-background-color); + --card-focus-outline: var(--focus-outline); + --card-box-shadow: var(--box-shadow-10); + /* Bug 1791816, 1839523: replace with spacing tokens */ + --card-padding: 1em; + --card-gap: var(--card-padding); + --card-article-gap: 0.45em; + + /* Bug 1791816: replace with button tokens */ + @media (prefers-contrast) { + --button-border-color: var(--border-interactive-color); + --button-border-color-hover: var(--border-interactive-color-hover); + --button-border-color-active: var(--border-interactive-color-active); + --card-border-color: color-mix(in srgb, currentColor 41%, transparent); + } + /* Bug 1791816: replace with button tokens */ + @media (forced-colors) { + --button-background-color: ButtonFace; + --button-background-color-hover: SelectedItemText; + --button-background-color-active: SelectedItemText; + --button-border-color: var(--border-interactive-color); + --button-border-color-hover: var(--border-interactive-color-hover); + --button-border-color-active: var(--border-interactive-color-active); + --button-text-color: ButtonText; + --button-text-color-hover: SelectedItem; + --button-text-color-active: SelectedItem; + } +} + +:host { + display: block; + border: var(--card-border); + border-radius: var(--card-border-radius); + background-color: var(--card-background-color); + box-shadow: var(--card-box-shadow); + box-sizing: border-box; +} + +:host([type=accordion]) { + summary { + padding-block: var(--card-padding); + } + #content { + padding-block-end: var(--card-padding); + } +} +:host(:not([type=accordion])) { + .moz-card { + padding-block: var(--card-padding); + } +} + +.moz-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--card-article-gap); +} + +#moz-card-details { + width: 100%; +} + +summary { + cursor: pointer; +} + +#heading-wrapper { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--card-gap); + padding-inline: var(--card-padding); + border-radius: var(--card-border-radius); +} + +#heading { + font-size: var(--font-size-root); + font-weight: var(--font-weight-bold); +} + +#content { + align-self: stretch; + padding-inline: var(--card-padding); + border-end-start-radius: var(--card-border-radius); + border-end-end-radius: var(--card-border-radius); + + @media (prefers-contrast) { + :host([type=accordion]) & { + border-block-start: 0; + padding-block-start: var(--card-padding); + } + } +} + +details { + > summary { + list-style: none; + border-radius: var(--card-border-radius); + cursor: pointer; + + &:hover { + background-color: var(--button-background-color-hover); + } + @media (prefers-contrast) { + outline: var(--button-border-color) solid var(--border-width); + + &:hover { + outline-color: var(--button-border-color-hover); + } + + &:active { + outline-color: var(--button-border-color-active); + } + } + + @media (forced-colors) { + color: var(--button-text-color); + background-color: var(--button-background-color); + + &:hover { + background-color: var(--button-background-color-hover); + color: var(--button-text-color-hover); + } + + &:active { + background-color: var(--button-background-color-active); + color: var(--button-text-color-active); + } + } + } + + &[open] { + summary { + border-end-start-radius: 0; + border-end-end-radius: 0; + } + @media not (prefers-contrast) { + #content { + /* + There is a border shown above this element in prefers-contrast. + When there isn't a border, there's no need for the extra space. + */ + padding-block-start: 0; + } + } + } + + &:focus-visible { + outline: var(--card-focus-outline); + } +} + +.chevron-icon { + background-image: url("chrome://global/skin/icons/arrow-down.svg"); + background-position: center; + background-repeat: no-repeat; + -moz-context-properties: fill; + fill: currentColor; + width: 24px; + height: 24px; + min-width: 24px; + min-height: 24px; + padding: 0; + flex-shrink: 0; + align-self: flex-start; + + details[open] & { + background-image: url("chrome://global/skin/icons/arrow-up.svg"); + } +} diff --git a/toolkit/content/widgets/moz-card/moz-card.mjs b/toolkit/content/widgets/moz-card/moz-card.mjs new file mode 100644 index 0000000000..2bd6e0f987 --- /dev/null +++ b/toolkit/content/widgets/moz-card/moz-card.mjs @@ -0,0 +1,112 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * 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 { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; +import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; + +/** + * Cards contain content and actions about a single subject. + * There are two card types: + * The default type where no type attribute is required and the card + * will have no extra functionality. + * + * The "accordion" type will initially not show any content. The card + * will contain an arrow to expand the card so that all of the content + * is visible. + * + * + * @property {string} heading - The heading text that will be used for the card. + * @property {string} type - (optional) The type of card. No type specified + * will be the default card. The other available type is "accordion" + * @slot content - The content to show inside of the card. + */ +export default class MozCard extends MozLitElement { + static queries = { + detailsEl: "#moz-card-details", + headingEl: "#heading", + contentSlotEl: "#content", + }; + + static properties = { + heading: { type: String }, + type: { type: String, reflect: true }, + expanded: { type: Boolean }, + }; + + constructor() { + super(); + this.expanded = false; + this.type = "default"; + } + + headingTemplate() { + if (!this.heading) { + return ""; + } + return html` + <div id="heading-wrapper"> + ${this.type == "accordion" + ? html` <div class="chevron-icon"></div>` + : ""} + <span id="heading">${this.heading}</span> + </div> + `; + } + + cardTemplate() { + if (this.type === "accordion") { + return html` + <details id="moz-card-details"> + <summary>${this.headingTemplate()}</summary> + <div id="content"><slot></slot></div> + </details> + `; + } + + return html` + ${this.headingTemplate()} + <div id="content" aria-describedby="content"> + <slot></slot> + </div> + `; + } + /** + * Handles the click event on the chevron icon. + * + * Without this, the click event would be passed to + * toggleDetails which would force the details element + * to stay open. + * + * @memberof MozCard + */ + onDetailsClick() { + this.toggleDetails(); + } + + /** + * @param {boolean} force - Used to force open or force close the + * details element. + * @memberof MozCard + */ + toggleDetails(force) { + this.detailsEl.open = force ?? !this.detailsEl.open; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-card.css" + /> + <article + class="moz-card" + aria-labelledby=${ifDefined(this.heading ? "heading" : undefined)} + > + ${this.cardTemplate()} + </article> + `; + } +} +customElements.define("moz-card", MozCard); diff --git a/toolkit/content/widgets/moz-card/moz-card.stories.mjs b/toolkit/content/widgets/moz-card/moz-card.stories.mjs new file mode 100644 index 0000000000..da3279b2a4 --- /dev/null +++ b/toolkit/content/widgets/moz-card/moz-card.stories.mjs @@ -0,0 +1,88 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unresolved +import { html, ifDefined } from "lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-card.mjs"; + +export default { + title: "UI Widgets/Card", + component: "moz-card", + parameters: { + status: "in-development", + fluent: ` +moz-card-heading = + .heading = This is the label + `, + }, + argTypes: { + type: { + options: ["default", "accordion"], + control: { type: "select" }, + }, + }, +}; + +const Template = ({ l10nId, content, type }) => html` + <main style="max-width: 400px"> + <moz-card + type=${ifDefined(type)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="heading" + > + <div>${content}</div> + </moz-card> + </main> +`; + +export const DefaultCard = Template.bind({}); +DefaultCard.args = { + l10nId: "moz-card-heading", + content: "This is the content", +}; + +export const CardOnlyContent = Template.bind({}); +CardOnlyContent.args = { + content: "This card only contains content", +}; + +export const CardTypeAccordion = Template.bind({}); +CardTypeAccordion.args = { + ...DefaultCard.args, + content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Nunc velit turpis, mollis a ultricies vitae, accumsan ut augue. + In a eros ac dolor hendrerit varius et at mauris.`, + type: "accordion", +}; +CardTypeAccordion.parameters = { + a11y: { + config: { + rules: [ + /* + The accordion card can be expanded either by the chevron icon + button or by activating the details element. Mouse users can + click on the chevron button or the details element, while + keyboard users can tab to the details element and have a + focus ring around the details element in the card. + Additionally, the details element is announced as a button + so I don't believe we are providing a degraded experience + to non-mouse users. + + Bug 1854008: We should probably make the accordion button a + clickable div or something that isn't announced to screen + readers. + */ + { + id: "button-name", + reviewOnFail: true, + }, + { + id: "nested-interactive", + reviewOnFail: true, + }, + ], + }, + }, +}; diff --git a/toolkit/content/widgets/moz-five-star/moz-five-star.css b/toolkit/content/widgets/moz-five-star/moz-five-star.css new file mode 100644 index 0000000000..44d2a3e252 --- /dev/null +++ b/toolkit/content/widgets/moz-five-star/moz-five-star.css @@ -0,0 +1,46 @@ +/* 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/. */ + +:host { + display: flex; + justify-content: space-between; +} + +:host([hidden]) { + display: none; +} + +.stars { + --rating-star-size: 1em; + --rating-star-spacing: 0.3ch; + + display: inline-grid; + grid-template-columns: repeat(5, var(--rating-star-size)); + grid-column-gap: var(--rating-star-spacing); + align-content: center; +} + +.rating-star { + display: inline-block; + width: var(--rating-star-size); + height: var(--rating-star-size); + background-image: url("chrome://global/skin/icons/rating-star.svg#empty"); + background-position: center; + background-repeat: no-repeat; + background-size: 100%; + + fill: var(--icon-color); + -moz-context-properties: fill; +} + +.rating-star[fill="half"] { + background-image: url("chrome://global/skin/icons/rating-star.svg#half"); +} +.rating-star[fill="full"] { + background-image: url("chrome://global/skin/icons/rating-star.svg#full"); +} + +.rating-star[fill="half"]:dir(rtl) { + transform: scaleX(-1); +} diff --git a/toolkit/content/widgets/moz-five-star/moz-five-star.mjs b/toolkit/content/widgets/moz-five-star/moz-five-star.mjs new file mode 100644 index 0000000000..e95c74e0ed --- /dev/null +++ b/toolkit/content/widgets/moz-five-star/moz-five-star.mjs @@ -0,0 +1,71 @@ +/* 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 { ifDefined, html } from "../vendor/lit.all.mjs"; +import { MozLitElement } from "../lit-utils.mjs"; + +window.MozXULElement?.insertFTLIfNeeded("toolkit/global/mozFiveStar.ftl"); + +/** + * The visual representation is five stars, each of them either empty, + * half-filled or full. The fill state is derived from the rating, + * rounded to the nearest half. + * + * @tagname moz-five-star + * @property {number} rating - The rating out of 5. + * @property {string} title - The title text. + */ +export default class MozFiveStar extends MozLitElement { + static properties = { + rating: { type: Number, reflect: true }, + title: { type: String }, + }; + + static get queries() { + return { + starEls: { all: ".rating-star" }, + starsWrapperEl: ".stars", + }; + } + + getStarsFill() { + let starFill = []; + let roundedRating = Math.round(this.rating * 2) / 2; + for (let i = 1; i <= 5; i++) { + if (i <= roundedRating) { + starFill.push("full"); + } else if (i - roundedRating === 0.5) { + starFill.push("half"); + } else { + starFill.push("empty"); + } + } + return starFill; + } + + render() { + let starFill = this.getStarsFill(); + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-five-star.css" + /> + <div + class="stars" + role="img" + data-l10n-id=${ifDefined( + this.title ? undefined : "moz-five-star-rating" + )} + data-l10n-args=${ifDefined( + this.title ? undefined : JSON.stringify({ rating: this.rating ?? 0 }) + )} + > + ${starFill.map( + fill => html`<span class="rating-star" fill="${fill}"></span>` + )} + </div> + `; + } +} +customElements.define("moz-five-star", MozFiveStar); diff --git a/toolkit/content/widgets/moz-five-star/moz-five-star.stories.mjs b/toolkit/content/widgets/moz-five-star/moz-five-star.stories.mjs new file mode 100644 index 0000000000..cbbbe9da26 --- /dev/null +++ b/toolkit/content/widgets/moz-five-star/moz-five-star.stories.mjs @@ -0,0 +1,51 @@ +/* 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 { html, ifDefined } from "../vendor/lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-five-star.mjs"; + +export default { + title: "UI Widgets/Five Star", + component: "moz-five-star", + parameters: { + status: "in-development", + fluent: ` +moz-five-star-title = + .title = This is the title +moz-five-star-aria-label = + .aria-label = This is the aria-label + `, + }, +}; + +const Template = ({ rating, ariaLabel, l10nId }) => html` + <div style="max-width: 400px"> + <moz-five-star + rating=${rating} + aria-label=${ifDefined(ariaLabel)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="aria-label, title" + > + </moz-five-star> + </div> +`; + +export const FiveStar = Template.bind({}); +FiveStar.args = { + rating: 5.0, + l10nId: "moz-five-star-aria-label", +}; + +export const WithTitle = Template.bind({}); +WithTitle.args = { + ...FiveStar.args, + rating: 0, + l10nId: "moz-five-star-title", +}; + +export const Default = Template.bind({}); +Default.args = { + rating: 3.33, +}; diff --git a/toolkit/content/widgets/moz-input-box.js b/toolkit/content/widgets/moz-input-box.js new file mode 100644 index 0000000000..4704db6dc5 --- /dev/null +++ b/toolkit/content/widgets/moz-input-box.js @@ -0,0 +1,224 @@ +/* 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 cachedFragments = { + get editMenuItems() { + return ` + <menuitem data-l10n-id="text-action-undo" cmd="cmd_undo"></menuitem> + <menuitem data-l10n-id="text-action-redo" cmd="cmd_redo"></menuitem> + <menuseparator></menuseparator> + <menuitem data-l10n-id="text-action-cut" cmd="cmd_cut"></menuitem> + <menuitem data-l10n-id="text-action-copy" cmd="cmd_copy"></menuitem> + <menuitem data-l10n-id="text-action-paste" cmd="cmd_paste"></menuitem> + <menuitem data-l10n-id="text-action-delete" cmd="cmd_delete"></menuitem> + <menuitem data-l10n-id="text-action-select-all" cmd="cmd_selectAll"></menuitem> + `; + }, + get normal() { + delete this.normal; + this.normal = MozXULElement.parseXULToFragment( + ` + <menupopup class="textbox-contextmenu" showservicesmenu="true"> + ${this.editMenuItems} + </menupopup> + ` + ); + MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + return this.normal; + }, + get spellcheck() { + delete this.spellcheck; + this.spellcheck = MozXULElement.parseXULToFragment( + ` + <menupopup class="textbox-contextmenu" showservicesmenu="true"> + <menuitem data-l10n-id="text-action-spell-no-suggestions" anonid="spell-no-suggestions" disabled="true"></menuitem> + <menuitem data-l10n-id="text-action-spell-add-to-dictionary" anonid="spell-add-to-dictionary" oncommand="this.parentNode.parentNode.spellCheckerUI.addToDictionary();"></menuitem> + <menuitem data-l10n-id="text-action-spell-undo-add-to-dictionary" anonid="spell-undo-add-to-dictionary" oncommand="this.parentNode.parentNode.spellCheckerUI.undoAddToDictionary();"></menuitem> + <menuseparator anonid="spell-suggestions-separator"></menuseparator> + ${this.editMenuItems} + <menuseparator anonid="spell-check-separator"></menuseparator> + <menuitem data-l10n-id="text-action-spell-check-toggle" type="checkbox" anonid="spell-check-enabled" oncommand="this.parentNode.parentNode.spellCheckerUI.toggleEnabled();"></menuitem> + <menu data-l10n-id="text-action-spell-dictionaries" anonid="spell-dictionaries"> + <menupopup anonid="spell-dictionaries-menu" onpopupshowing="event.stopPropagation();" onpopuphiding="event.stopPropagation();"></menupopup> + </menu> + </menupopup> + ` + ); + return this.spellcheck; + }, + }; + + class MozInputBox extends MozXULElement { + static get observedAttributes() { + return ["spellcheck"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "spellcheck" && oldValue != newValue) { + this._initUI(); + } + } + + connectedCallback() { + this._initUI(); + } + + _initUI() { + this.spellcheck = this.hasAttribute("spellcheck"); + if (this.menupopup) { + this.menupopup.remove(); + } + + this.setAttribute("context", "_child"); + this.appendChild( + this.spellcheck + ? cachedFragments.spellcheck.cloneNode(true) + : cachedFragments.normal.cloneNode(true) + ); + this.menupopup = this.querySelector(".textbox-contextmenu"); + + this.menupopup.addEventListener("popupshowing", event => { + let input = this._input; + if (document.commandDispatcher.focusedElement != input) { + input.focus(); + } + this._doPopupItemEnabling(event); + }); + + if (this.spellcheck) { + this.menupopup.addEventListener("popuphiding", event => { + if (this.spellCheckerUI) { + this.spellCheckerUI.clearSuggestionsFromMenu(); + this.spellCheckerUI.clearDictionaryListFromMenu(); + } + }); + } + + this.menupopup.addEventListener("command", event => { + var cmd = event.originalTarget.getAttribute("cmd"); + if (cmd) { + this.doCommand(cmd); + event.stopPropagation(); + } + }); + } + + _doPopupItemEnablingSpell(event) { + var spellui = this.spellCheckerUI; + if (!spellui || !spellui.canSpellCheck) { + this._setMenuItemVisibility("spell-no-suggestions", false); + this._setMenuItemVisibility("spell-check-enabled", false); + this._setMenuItemVisibility("spell-check-separator", false); + this._setMenuItemVisibility("spell-add-to-dictionary", false); + this._setMenuItemVisibility("spell-undo-add-to-dictionary", false); + this._setMenuItemVisibility("spell-suggestions-separator", false); + this._setMenuItemVisibility("spell-dictionaries", false); + return; + } + + spellui.initFromEvent(event.rangeParent, event.rangeOffset); + + var enabled = spellui.enabled; + var showUndo = spellui.canSpellCheck && spellui.canUndo(); + + var enabledCheckbox = this.getMenuItem("spell-check-enabled"); + enabledCheckbox.setAttribute("checked", enabled); + + var overMisspelling = spellui.overMisspelling; + this._setMenuItemVisibility("spell-add-to-dictionary", overMisspelling); + this._setMenuItemVisibility("spell-undo-add-to-dictionary", showUndo); + this._setMenuItemVisibility( + "spell-suggestions-separator", + overMisspelling || showUndo + ); + + // suggestion list + var suggestionsSeparator = this.getMenuItem("spell-no-suggestions"); + var numsug = spellui.addSuggestionsToMenuOnParent( + event.target, + suggestionsSeparator, + 5 + ); + this._setMenuItemVisibility( + "spell-no-suggestions", + overMisspelling && numsug == 0 + ); + + // dictionary list + var dictionariesMenu = this.getMenuItem("spell-dictionaries-menu"); + var numdicts = spellui.addDictionaryListToMenu(dictionariesMenu, null); + this._setMenuItemVisibility( + "spell-dictionaries", + enabled && numdicts > 1 + ); + } + + _doPopupItemEnabling(event) { + if (this.spellcheck) { + this._doPopupItemEnablingSpell(event); + } + + let popupNode = event.target; + var children = popupNode.childNodes; + for (var i = 0; i < children.length; i++) { + var command = children[i].getAttribute("cmd"); + if (command) { + var controller = + document.commandDispatcher.getControllerForCommand(command); + var enabled = controller.isCommandEnabled(command); + if (enabled) { + children[i].removeAttribute("disabled"); + } else { + children[i].setAttribute("disabled", "true"); + } + } + } + } + + get spellCheckerUI() { + if (!this._spellCheckInitialized) { + this._spellCheckInitialized = true; + + try { + const { InlineSpellChecker } = ChromeUtils.importESModule( + "resource://gre/modules/InlineSpellChecker.sys.mjs" + ); + this.InlineSpellCheckerUI = new InlineSpellChecker( + this._input.editor + ); + } catch (ex) {} + } + + return this.InlineSpellCheckerUI; + } + + getMenuItem(anonid) { + return this.querySelector(`[anonid="${anonid}"]`); + } + + _setMenuItemVisibility(anonid, visible) { + this.getMenuItem(anonid).hidden = !visible; + } + + doCommand(command) { + var controller = + document.commandDispatcher.getControllerForCommand(command); + controller.doCommand(command); + } + + get _input() { + return ( + this.getElementsByAttribute("anonid", "input")[0] || + this.querySelector(".textbox-input") + ); + } + } + + customElements.define("moz-input-box", MozInputBox); +} diff --git a/toolkit/content/widgets/moz-label/README.stories.md b/toolkit/content/widgets/moz-label/README.stories.md new file mode 100644 index 0000000000..a3492ebefa --- /dev/null +++ b/toolkit/content/widgets/moz-label/README.stories.md @@ -0,0 +1,20 @@ +# MozLabel + +`moz-label` is an extension of the built-in `HTMLLabelElement` that provides accesskey styling and formatting as well as some click handling logic. + +```html story +<label is="moz-label" accesskey="c" for="check"> + This is a label with an accesskey: +</label> +<input id="check" type="checkbox" defaultChecked /> +``` + +Accesskey underlining is enabled by default on Windows and Linux. It is also enabled in Storybook on Mac for demonstrative purposes, but is usually controlled by the `ui.key.menuAccessKey` preference. + +## Component status + +At this time `moz-label` may not be suitable for general use in Firefox. + +`moz-label` is currently only used in the `moz-toggle` custom element. There are no instances in Firefox where we set an accesskey on a toggle, so it is still largely untested in the wild. + +Additionally there is at least [one outstanding bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1819469) related to accesskey handling in the shadow DOM. diff --git a/toolkit/content/widgets/moz-label/moz-label.css b/toolkit/content/widgets/moz-label/moz-label.css new file mode 100644 index 0000000000..8e0576075a --- /dev/null +++ b/toolkit/content/widgets/moz-label/moz-label.css @@ -0,0 +1,8 @@ +/* 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/. */ + +label span.accesskey { + text-decoration: underline; + text-decoration-skip-ink: none; +} diff --git a/toolkit/content/widgets/moz-label/moz-label.mjs b/toolkit/content/widgets/moz-label/moz-label.mjs new file mode 100644 index 0000000000..7812436ecd --- /dev/null +++ b/toolkit/content/widgets/moz-label/moz-label.mjs @@ -0,0 +1,298 @@ +/* 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/. */ + +/** + * An extension of the label element that provides accesskey styling and + * formatting as well as click handling logic. + * + * @tagname moz-label + * @attribute {string} accesskey - Key used for keyboard access. + */ +class MozTextLabel extends HTMLLabelElement { + #insertSeparator = false; + #alwaysAppendAccessKey = false; + #lastFormattedAccessKey = null; + + // Default to underlining accesskeys for Windows and Linux. + static #underlineAccesskey = !navigator.platform.includes("Mac"); + static get observedAttributes() { + return ["accesskey"]; + } + + static stylesheetUrl = "chrome://global/content/elements/moz-label.css"; + + constructor() { + super(); + this.#register(); + this.addEventListener("click", this._onClick); + } + + #register() { + if (window.IS_STORYBOOK) { + MozTextLabel.#underlineAccesskey = true; + } else if (typeof Services !== "undefined") { + MozTextLabel.#underlineAccesskey = !!Services.prefs.getIntPref( + "ui.key.menuAccessKey", + Number(!navigator.platform.includes("Mac")) + ); + if (MozTextLabel.#underlineAccesskey) { + try { + const nsIPrefLocalizedString = Ci.nsIPrefLocalizedString; + const prefNameInsertSeparator = + "intl.menuitems.insertseparatorbeforeaccesskeys"; + const prefNameAlwaysAppendAccessKey = + "intl.menuitems.alwaysappendaccesskeys"; + + let val = Services.prefs.getComplexValue( + prefNameInsertSeparator, + nsIPrefLocalizedString + ).data; + this.#insertSeparator = val == "true"; + val = Services.prefs.getComplexValue( + prefNameAlwaysAppendAccessKey, + nsIPrefLocalizedString + ).data; + this.#alwaysAppendAccessKey = val == "true"; + } catch (e) { + this.#insertSeparator = this.#alwaysAppendAccessKey = true; + } + } + } + } + + connectedCallback() { + this.#setStyles(); + this.formatAccessKey(); + } + + // Bug 1820588 - we may want to generalize this into + // MozHTMLElement.insertCssIfNeeded(style) + #setStyles() { + let root = this.getRootNode(); + let container = root.head ?? root; + + for (let link of container.querySelectorAll("link")) { + if (link.getAttribute("href") == this.constructor.stylesheetUrl) { + return; + } + } + + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = this.constructor.stylesheetUrl; + container.appendChild(style); + } + + set textContent(val) { + super.textContent = val; + this.#lastFormattedAccessKey = null; + this.formatAccessKey(); + } + + get textContent() { + return super.textContent; + } + + attributeChangedCallback(attrName, oldValue, newValue) { + if (oldValue == newValue) { + return; + } + + // Note that this is only happening when "accesskey" attribute changes. + this.formatAccessKey(); + } + + _onClick(event) { + let controlElement = this.labeledControlElement; + if (!controlElement || this.disabled) { + return; + } + controlElement.focus(); + + 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; + } + } + + set accessKey(val) { + this.setAttribute("accesskey", val); + let 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 ( + !MozTextLabel.#underlineAccesskey || + this.#lastFormattedAccessKey == accessKey || + !this.textContent || + !this.textContent.trim() + ) { + 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 (!this.#alwaysAppendAccessKey) { + 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<span style="display:none;">:</span> + 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 (this.#insertSeparator && !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("moz-label", MozTextLabel, { extends: "label" }); + +function mergeElement(element) { + // If the element has been removed already, return: + if (!element.isConnected) { + return; + } + // `isInstance` isn't available to web content (i.e. Storybook) so we need to + // fallback to using `instanceof`. + if ( + Text.hasOwnProperty("isInstance") + ? Text.isInstance(element.previousSibling) + : // eslint-disable-next-line mozilla/use-isInstance + element.previousSibling instanceof Text + ) { + element.previousSibling.appendData(element.textContent); + } else { + element.parentNode.insertBefore(element.firstChild, element); + } + element.remove(); +} + +function wrapChar(parentNode, element, index) { + let treeWalker = document.createNodeIterator( + parentNode, + 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); +} diff --git a/toolkit/content/widgets/moz-label/moz-label.stories.mjs b/toolkit/content/widgets/moz-label/moz-label.stories.mjs new file mode 100644 index 0000000000..f954d4fe3a --- /dev/null +++ b/toolkit/content/widgets/moz-label/moz-label.stories.mjs @@ -0,0 +1,86 @@ +/* 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 { html, ifDefined } from "../vendor/lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-label.mjs"; + +MozXULElement.insertFTLIfNeeded("locales-preview/moz-label.storybook.ftl"); + +export default { + title: "UI Widgets/Label", + component: "moz-label", + argTypes: { + inputType: { + options: ["checkbox", "radio"], + control: { type: "select" }, + }, + }, + parameters: { + status: { + type: "unstable", + links: [ + { + title: "Learn more", + href: "?path=/docs/ui-widgets-label-readme--page#component-status", + }, + ], + }, + }, +}; + +const Template = ({ + accesskey, + inputType, + disabled, + "data-l10n-id": dataL10nId, +}) => html` + <style> + div { + display: flex; + align-items: center; + } + + label { + margin-inline-end: 8px; + } + </style> + <div> + <label + is="moz-label" + accesskey=${ifDefined(accesskey)} + data-l10n-id=${ifDefined(dataL10nId)} + for="cheese" + > + </label> + <input + type=${inputType} + name="cheese" + id="cheese" + ?disabled=${disabled} + checked + /> + </div> +`; + +export const AccessKey = Template.bind({}); +AccessKey.args = { + accesskey: "c", + inputType: "checkbox", + disabled: false, + "data-l10n-id": "default-label", +}; + +export const AccessKeyNotInLabel = Template.bind({}); +AccessKeyNotInLabel.args = { + ...AccessKey.args, + accesskey: "x", + "data-l10n-id": "label-with-colon", +}; + +export const DisabledCheckbox = Template.bind({}); +DisabledCheckbox.args = { + ...AccessKey.args, + disabled: true, +}; diff --git a/toolkit/content/widgets/moz-message-bar/README.stories.md b/toolkit/content/widgets/moz-message-bar/README.stories.md new file mode 100644 index 0000000000..c3fc5a88eb --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/README.stories.md @@ -0,0 +1,67 @@ +# MozMessageBar + +`moz-message-bar` is a versatile user interface element designed to display messages or notifications. +These messages and notifications are nonmodal, and keep users informed without blocking access to the base page. +It supports various types of messages - info, warning, success, and error - each with distinct visual styling +to convey the message's urgency or importance. You can customize `moz-message-bar` by adding a message, message heading, +`moz-support-link`, actions buttons, or by making the message bar dismissable. + +```html story +<moz-message-bar dismissable + heading="Heading of the message bar" + message="Message for the user"> +</moz-message-bar> +``` + +## When to use + +* Use the message bar to display important announcements or notifications to the user. +* Use it to attract the user's attention without interrupting the user's task. + +## When not to use + +* Do not use the message bar for displaying critical alerts or warnings that require immediate and focused attention. + +## Code + +The source for `moz-message-bar` can be found under +[toolkit/content/widgets/moz-message-bar](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs). +You can find an examples of `moz-message-bar` in use in the Firefox codebase in +[about:addons](https://searchfox.org/mozilla-central/source/toolkit/mozapps/extensions/content/aboutaddons.html), +[unified extensions panel](https://searchfox.org/mozilla-central/source/browser/base/content/browser-addons.js) and +[shopping components](https://searchfox.org/mozilla-central/source/browser/components/shopping/content/shopping-message-bar.mjs). + +`moz-message-bar` can be imported into `.html`/`.xhtml` files: + +```html +<script type="module" src="chrome://global/content/elements/moz-message-bar.mjs"></script> +``` + +And used as follows: + +```html +<moz-message-bar dismissable + heading="Heading of the message bar" + message="Message for the user"> +</moz-message-bar> +``` + +### Fluent usage + +Generally the `heading` and `message` properties of +`moz-message-bar` will be provided via [Fluent attributes](https://mozilla-l10n.github.io/localizer-documentation/tools/fluent/basic_syntax.html#attributes). +To get this working you will need to specify a `data-l10n-id` as well as +`data-l10n-attrs` if you're providing a heading and a message: + +```html +<moz-message-bar data-l10n-id="with-heading-and-message" + data-l10n-attrs="heading, message"></moz-message-bar> +``` + +In which case your Fluent messages will look something like this: + +``` +with-heading-and-message = + .heading = Heading text goes here + .message = Message text goes here +``` diff --git a/toolkit/content/widgets/moz-message-bar/moz-message-bar.css b/toolkit/content/widgets/moz-message-bar/moz-message-bar.css new file mode 100644 index 0000000000..6d35009982 --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/moz-message-bar.css @@ -0,0 +1,211 @@ +/* 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/. */ + +:host { + /* Icon */ + --message-bar-icon-color: var(--icon-color-information); + --message-bar-icon-size: var(--size-item-small); + --message-bar-icon-close-color: var(--icon-color); + --message-bar-icon-close-url: url("chrome://global/skin/icons/close-12.svg"); + + /* Button */ + --message-bar-button-size-ghost: var(--button-min-height); + --message-bar-button-border-radius-ghost: var(--button-border-radius); + --message-bar-button-background-color-ghost-hover: var(--button-background-color-hover); + --message-bar-button-background-color-ghost-active: var(--button-background-color-active); + + /* Container */ + --message-bar-container-min-height: var(--size-item-large); + + /* Border */ + --message-bar-border-color: color-mix(in srgb, currentColor 9%, transparent); + --message-bar-border-radius: var(--border-radius-small); + --message-bar-border-width: var(--border-width); + + /* Text */ + --message-bar-text-color: var(--text-color); + --message-bar-text-line-height: 1.5em; + + /* Background */ + --message-bar-background-color: var(--color-background-information); + + background-color: var(--message-bar-background-color); + border: var(--message-bar-border-width) solid var(--message-bar-border-color); + border-radius: var(--message-bar-border-radius); + color: var(--message-bar-text-color); +} + +@media (prefers-contrast) { + :host { + --message-bar-border-color: var(--border-color); + } +} + +/* Make the host to behave as a block by default, but allow hidden to hide it. */ +:host(:not([hidden])) { + display: block; +} + +/* MozMessageBar layout */ + +.container { + display: flex; + gap: 8px; + min-height: var(--message-bar-container-min-height); + padding-inline: 16px 8px; + padding-block: 8px; +} + +.content { + display: flex; + flex-grow: 1; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-inline-start: 24px; +} + +.text-container { + display: flex; + gap: 4px 8px; + padding-block: calc((var(--message-bar-container-min-height) - var(--message-bar-text-line-height)) / 2); +} + +.text-content { + display: inline-flex; + gap: 4px 8px; + flex-wrap: wrap; + word-break: break-word; + line-height: var(--message-bar-text-line-height); +} + +/* MozMessageBar icon style */ + +.icon-container { + height: var(--message-bar-text-line-height); + display: flex; + justify-content: center; + align-items: center; + margin-inline-start: -24px; +} + +.icon { + width: var(--message-bar-icon-size); + height: var(--message-bar-icon-size); + flex-shrink: 0; + appearance: none; + -moz-context-properties: fill, stroke; + fill: currentColor; + stroke: currentColor; + color: var(--message-bar-icon-color); +} + +/* MozMessageBar heading style */ + +.heading { + font-weight: 600; +} + +/* MozMessageBar message style */ + +.message { + margin-inline-end: 4px; +} + +/* MozMessageBar link style */ + +.link { + display: inline-block; +} + +.link ::slotted(a) { + margin-inline-end: 4px; +} + +/* MozMessageBar actions style */ + +.actions { + display: none; +} + +.actions.active { + display: inline-flex; + gap: 8px; +} + +.actions ::slotted(button) { + /* Enforce micro-button width. */ + min-width: fit-content !important; + + margin: 0 !important; + padding: 4px 16px !important; +} + +/* Close icon styles */ + +.close { + background-image: var(--message-bar-icon-close-url); + background-repeat: no-repeat; + background-position: center center; + -moz-context-properties: fill; + fill: currentColor; + min-width: auto; + min-height: auto; + width: var(--message-bar-button-size-ghost); + height: var(--message-bar-button-size-ghost); + margin: 0; + padding: 0; + flex-shrink: 0; +} + +.ghost-button { + border-radius: var(--message-bar-button-border-radius-ghost); +} + +.ghost-button:enabled:hover { + background-color: var(--message-bar-button-background-color-ghost-hover); +} + +.ghost-button:enabled:hover:active { + background-color: var(--message-bar-button-background-color-ghost-active); +} + +@media not (prefers-contrast) { + /* MozMessageBar colors by message type */ + /* Colors from: https://www.figma.com/file/zd3B9UyknB2XNZNdrYLm2W/Outreachy?type=design&node-id=59-1921&mode=design&t=ZYS4e6pAbAlXGvun-4 */ + + :host([type=warning]) { + --message-bar-background-color: var(--color-background-warning); + + .icon { + --message-bar-icon-color: var(--icon-color-warning); + } + } + + :host([type=success]) { + --message-bar-background-color: var(--color-background-success); + + .icon { + --message-bar-icon-color: var(--icon-color-success); + } + } + + :host([type=error]), + :host([type=critical]) { + --message-bar-background-color: var(--color-background-critical); + + .icon { + --message-bar-icon-color: var(--icon-color-critical); + } + } + + .close { + fill: var(--message-bar-icon-close-color); + } + + .ghost-button { + border: none; + background-color: transparent; + } +} diff --git a/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs b/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs new file mode 100644 index 0000000000..58f41c28e4 --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs @@ -0,0 +1,176 @@ +/* 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 { html, ifDefined } from "../vendor/lit.all.mjs"; +import { MozLitElement } from "../lit-utils.mjs"; + +const messageTypeToIconData = { + info: { + iconSrc: "chrome://global/skin/icons/info-filled.svg", + l10nId: "moz-message-bar-icon-info", + }, + warning: { + iconSrc: "chrome://global/skin/icons/warning.svg", + l10nId: "moz-message-bar-icon-warning", + }, + success: { + iconSrc: "chrome://global/skin/icons/check-filled.svg", + l10nId: "moz-message-bar-icon-success", + }, + error: { + iconSrc: "chrome://global/skin/icons/error.svg", + l10nId: "moz-message-bar-icon-error", + }, + critical: { + iconSrc: "chrome://global/skin/icons/error.svg", + l10nId: "moz-message-bar-icon-error", + }, +}; + +/** + * A simple message bar element that can be used to display + * important information to users. + * + * @tagname moz-message-bar + * @property {string} type - The type of the displayed message. + * @property {string} heading - The heading of the message. + * @property {string} message - The message text. + * @property {boolean} dismissable - Whether or not the element is dismissable. + * @property {string} messageL10nId - l10n ID for the message. + * @property {string} messageL10nArgs - Any args needed for the message l10n ID. + * @fires message-bar:close + * Custom event indicating that message bar was closed. + * @fires message-bar:user-dismissed + * Custom event indicating that message bar was dismissed by the user. + */ + +export default class MozMessageBar extends MozLitElement { + static queries = { + actionsSlotEl: "slot[name=actions]", + actionsEl: ".actions", + closeButtonEl: "button.close", + supportLinkSlotEl: "slot[name=support-link]", + }; + + static properties = { + type: { type: String }, + heading: { type: String }, + message: { type: String }, + dismissable: { type: Boolean }, + messageL10nId: { type: String }, + messageL10nArgs: { type: String }, + }; + + constructor() { + super(); + window.MozXULElement?.insertFTLIfNeeded("toolkit/global/mozMessageBar.ftl"); + this.type = "info"; + this.dismissable = false; + } + + onSlotchange(e) { + let actions = this.actionsSlotEl.assignedNodes(); + this.actionsEl.classList.toggle("active", actions.length); + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("role", "status"); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.dispatchEvent(new CustomEvent("message-bar:close")); + } + + get supportLinkEls() { + return this.supportLinkSlotEl.assignedElements(); + } + + iconTemplate() { + let iconData = messageTypeToIconData[this.type]; + if (iconData) { + let { iconSrc, l10nId } = iconData; + return html` + <div class="icon-container"> + <img + class="icon" + src=${iconSrc} + data-l10n-id=${l10nId} + data-l10n-attrs="alt" + /> + </div> + `; + } + return ""; + } + + headingTemplate() { + if (this.heading) { + return html`<strong class="heading">${this.heading}</strong>`; + } + return ""; + } + + closeButtonTemplate() { + if (this.dismissable) { + return html` + <button + class="close ghost-button" + data-l10n-id="moz-message-bar-close-button" + @click=${this.dismiss} + ></button> + `; + } + return ""; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-message-bar.css" + /> + <div class="container"> + <div class="content"> + <div class="text-container"> + ${this.iconTemplate()} + <div class="text-content"> + ${this.headingTemplate()} + <div> + <span + class="message" + data-l10n-id=${ifDefined(this.messageL10nId)} + data-l10n-args=${ifDefined( + JSON.stringify(this.messageL10nArgs) + )} + > + ${this.message} + </span> + <span class="link"> + <slot name="support-link"></slot> + </span> + </div> + </div> + </div> + <span class="actions"> + <slot name="actions" @slotchange=${this.onSlotchange}></slot> + </span> + </div> + ${this.closeButtonTemplate()} + </div> + `; + } + + dismiss() { + this.dispatchEvent(new CustomEvent("message-bar:user-dismissed")); + this.close(); + } + + close() { + this.remove(); + } +} + +customElements.define("moz-message-bar", MozMessageBar); diff --git a/toolkit/content/widgets/moz-message-bar/moz-message-bar.stories.mjs b/toolkit/content/widgets/moz-message-bar/moz-message-bar.stories.mjs new file mode 100644 index 0000000000..65803eed9f --- /dev/null +++ b/toolkit/content/widgets/moz-message-bar/moz-message-bar.stories.mjs @@ -0,0 +1,123 @@ +/* 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/. */ +/* eslint-disable import/no-unassigned-import */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +import "./moz-message-bar.mjs"; +import "../moz-support-link/moz-support-link.mjs"; + +const fluentStrings = [ + "moz-message-bar-message", + "moz-message-bar-message-heading", + "moz-message-bar-message-heading-long", +]; + +export default { + title: "UI Widgets/Message Bar", + component: "moz-message-bar", + argTypes: { + type: { + options: ["info", "warning", "success", "error"], + control: { type: "select" }, + }, + l10nId: { + options: fluentStrings, + control: { type: "select" }, + }, + heading: { + table: { + disable: true, + }, + }, + message: { + table: { + disable: true, + }, + }, + }, + parameters: { + status: "stable", + fluent: ` +moz-message-bar-message = + .message = For your information message +moz-message-bar-message-heading = + .heading = Heading + .message = For your information message +moz-message-bar-message-heading-long = + .heading = A longer heading to check text wrapping in the message bar + .message = Some message that we use to check text wrapping. Some message that we use to check text wrapping. +moz-message-bar-button = Click me! + `, + }, +}; + +const Template = ({ + type, + heading, + message, + l10nId, + dismissable, + hasSupportLink, + hasActionButton, +}) => html` + <moz-message-bar + type=${type} + heading=${ifDefined(heading)} + message=${ifDefined(message)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="heading, message" + ?dismissable=${dismissable} + > + ${hasSupportLink + ? html` + <a + is="moz-support-link" + support-page="addons" + slot="support-link" + ></a> + ` + : ""} + ${hasActionButton + ? html` + <button data-l10n-id="moz-message-bar-button" slot="actions"></button> + ` + : ""} + </moz-message-bar> +`; + +export const Default = Template.bind({}); +Default.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: false, + hasSupportLink: false, + hasActionButton: false, +}; + +export const Dismissable = Template.bind({}); +Dismissable.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: true, + hasSupportLink: false, + hasActionButton: false, +}; + +export const WithActionButton = Template.bind({}); +WithActionButton.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: false, + hasSupportLink: false, + hasActionButton: true, +}; + +export const WithSupportLink = Template.bind({}); +WithSupportLink.args = { + type: "info", + l10nId: "moz-message-bar-message", + dismissable: false, + hasSupportLink: true, + hasActionButton: false, +}; diff --git a/toolkit/content/widgets/moz-support-link/moz-support-link.mjs b/toolkit/content/widgets/moz-support-link/moz-support-link.mjs new file mode 100644 index 0000000000..23f18ac434 --- /dev/null +++ b/toolkit/content/widgets/moz-support-link/moz-support-link.mjs @@ -0,0 +1,129 @@ +/* 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/. */ + +window.MozXULElement?.insertFTLIfNeeded("toolkit/global/mozSupportLink.ftl"); + +/** + * An extension of the anchor element that helps create links to Mozilla's + * support documentation. This should be used for SUMO links only - other "Learn + * more" links can use the regular anchor element. + * + * @tagname moz-support-link + * @attribute {string} support-page - Short-hand string from SUMO to the specific support page. + * @attribute {string} utm-content - UTM parameter for a URL, if it is an AMO URL. + * @attribute {string} data-l10n-id - Fluent ID used to generate the text content. + */ +export default class MozSupportLink extends HTMLAnchorElement { + static SUPPORT_URL = "https://www.mozilla.org/"; + static get observedAttributes() { + return ["support-page", "utm-content"]; + } + + /** + * Handles setting up the SUPPORT_URL preference getter. + * Without this, the tests for this component may not behave + * as expected. + * @private + * @memberof MozSupportLink + */ + #register() { + if (window.document.nodePrincipal?.isSystemPrincipal) { + // eslint-disable-next-line no-shadow + let { XPCOMUtils } = window.XPCOMUtils + ? window + : ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + XPCOMUtils.defineLazyPreferenceGetter( + MozSupportLink, + "SUPPORT_URL", + "app.support.baseURL", + "", + null, + val => Services.urlFormatter.formatURL(val) + ); + } else if (!window.IS_STORYBOOK) { + MozSupportLink.SUPPORT_URL = window.RPMGetFormatURLPref( + "app.support.baseURL" + ); + } + } + + connectedCallback() { + this.#register(); + this.#setHref(); + this.setAttribute("target", "_blank"); + this.addEventListener("click", this); + if ( + !this.getAttribute("data-l10n-id") && + !this.getAttribute("data-l10n-name") && + !this.childElementCount + ) { + document.l10n.setAttributes(this, "moz-support-link-text"); + } + document.l10n.translateFragment(this); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + } + + handleEvent(e) { + if (e.type == "click") { + if (window.openTrustedLinkIn) { + let where = whereToOpenLink(e, false, true); + if (where == "current") { + where = "tab"; + } + e.preventDefault(); + openTrustedLinkIn(this.href, where); + } + } + } + + attributeChangedCallback(attrName, oldVal, newVal) { + if (attrName === "support-page" || attrName === "utm-content") { + this.#setHref(); + } + } + + #setHref() { + let supportPage = this.getAttribute("support-page") ?? ""; + let base = MozSupportLink.SUPPORT_URL + supportPage; + this.href = this.hasAttribute("utm-content") + ? formatUTMParams(this.getAttribute("utm-content"), base) + : base; + } +} +customElements.define("moz-support-link", MozSupportLink, { extends: "a" }); + +/** + * Adds UTM parameters to a given URL, if it is an AMO URL. + * + * @param {string} contentAttribute + * Identifies the part of the UI with which the link is associated. + * @param {string} url + * @returns {string} + * The url with UTM parameters if it is an AMO URL. + * Otherwise the url in unmodified form. + */ +export function formatUTMParams(contentAttribute, url) { + if (!contentAttribute) { + return url; + } + let parsedUrl = new URL(url); + let domain = `.${parsedUrl.hostname}`; + if ( + !domain.endsWith(".mozilla.org") && + // For testing: addons-dev.allizom.org and addons.allizom.org + !domain.endsWith(".allizom.org") + ) { + return url; + } + + parsedUrl.searchParams.set("utm_source", "firefox-browser"); + parsedUrl.searchParams.set("utm_medium", "firefox-browser"); + parsedUrl.searchParams.set("utm_content", contentAttribute); + return parsedUrl.href; +} diff --git a/toolkit/content/widgets/moz-support-link/moz-support-link.stories.mjs b/toolkit/content/widgets/moz-support-link/moz-support-link.stories.mjs new file mode 100644 index 0000000000..9afb80fcd7 --- /dev/null +++ b/toolkit/content/widgets/moz-support-link/moz-support-link.stories.mjs @@ -0,0 +1,67 @@ +/* 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 { html, ifDefined } from "../vendor/lit.all.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "./moz-support-link.mjs"; + +MozXULElement.insertFTLIfNeeded( + "locales-preview/moz-support-link-storybook.ftl" +); +MozXULElement.insertFTLIfNeeded("toolkit/global/mozSupportLink.ftl"); + +const fluentStrings = [ + "storybook-amo-test", + "storybook-fluent-test", + "moz-support-link-text", +]; + +export default { + title: "UI Widgets/Support Link", + component: "moz-support-link", + argTypes: { + "data-l10n-id": { + options: [fluentStrings[0], fluentStrings[1], fluentStrings[2]], + control: { type: "select" }, + }, + onClick: { action: "clicked" }, + }, + parameters: { + status: "stable", + }, +}; + +const Template = ({ + "data-l10n-id": dataL10nId, + "support-page": supportPage, + "utm-content": utmContent, +}) => html` + <a + is="moz-support-link" + data-l10n-id=${ifDefined(dataL10nId)} + support-page=${ifDefined(supportPage)} + utm-content=${ifDefined(utmContent)} + > + </a> +`; + +export const withAMOUrl = Template.bind({}); +withAMOUrl.args = { + "data-l10n-id": fluentStrings[0], + "support-page": "addons", + "utm-content": "promoted-addon-badge", +}; + +export const Primary = Template.bind({}); +Primary.args = { + "support-page": "preferences", + "utm-content": "", +}; + +export const withFluentId = Template.bind({}); +withFluentId.args = { + "data-l10n-id": fluentStrings[1], + "support-page": "preferences", + "utm-content": "", +}; diff --git a/toolkit/content/widgets/moz-toggle/README.stories.md b/toolkit/content/widgets/moz-toggle/README.stories.md new file mode 100644 index 0000000000..8ab289fd92 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/README.stories.md @@ -0,0 +1,80 @@ +# MozToggle + +`moz-toggle` is a toggle element that can be used to switch between two states. +It may be helpful to think of it as a button that can be pressed or unpressed, +corresponding with "on" and "off" states. + +```html story +<moz-toggle pressed + label="Toggle label" + description="This is a demo toggle for the docs."> +</moz-toggle> +``` + +## When to use + +* Use a toggle for binary controls like on/off or enabled/disabled. +* Use when the action is performed immediately and doesn't require confirmation + or form submission. +* A toggle is like a switch. If it would be appropriate to use a switch in the + physical world for this action, it is likely appropriate to use a toggle in + software. + +## When not to use + +* If another action is required to execute the choice, use a checkbox (i.e. a + toggle should not generally be used as part of a form). + +## Code + +The source for `moz-toggle` can be found under +[toolkit/content/widgets/moz-toggle](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-toggle/moz-toggle.mjs). +You can find an examples of `moz-toggle` in use in the Firefox codebase in both +[about:preferences](https://searchfox.org/mozilla-central/source/browser/components/preferences/privacy.inc.xhtml#696) +and [about:addons](https://searchfox.org/mozilla-central/source/toolkit/mozapps/extensions/content/aboutaddons.html#182). + +`moz-toggle` can be imported into `.html`/`.xhtml` files: + +```html +<script type="module" src="chrome://global/content/elements/moz-toggle.mjs"></script> +``` + +And used as follows: + +```html +<moz-toggle pressed + label="Label for the toggle" + description="Longer explanation of what the toggle is for" + aria-label="Toggle label if label text isn't visible"></moz-toggle> +``` + +### Fluent usage + +Generally the `label`, `description`, and `aria-label` properties of +`moz-toggle` will be provided via [Fluent attributes](https://mozilla-l10n.github.io/localizer-documentation/tools/fluent/basic_syntax.html#attributes). +To get this working you will need to specify a `data-l10n-id` as well as +`data-l10n-attrs` if you're providing a label and a description: + +```html +<moz-toggle data-l10n-id="with-label-and-description" + data-l10n-attrs="label, description"></moz-toggle> +``` + +In which case your Fluent messages will look something like this: + +``` +with-label-and-description = + .label = Label text goes here + .description = Description text goes here +``` + +You do not have to specify `data-l10n-attrs` if you're only using an `aria-label`: + +```html +<moz-toggle data-l10n-id="with-aria-label-only"></moz-toggle> +``` + +``` +with-aria-label-only = + .aria-label = aria-label text goes here +``` diff --git a/toolkit/content/widgets/moz-toggle/moz-toggle.css b/toolkit/content/widgets/moz-toggle/moz-toggle.css new file mode 100644 index 0000000000..8b67a81878 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/moz-toggle.css @@ -0,0 +1,198 @@ +/* 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 url("chrome://global/skin/design-system/text-and-typography.css"); + +:host { + display: flex; + flex-direction: column; + gap: 4px; +} + +:host([disabled]) { + opacity: 0.4 +} + +::slotted(a[is="moz-support-link"]) { + display: inline-block; +} + +#moz-toggle-label { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.description-wrapper, +.description-wrapper ::slotted([slot="support-link"]) { + margin: 0; +} + +.toggle-button { + --toggle-background-color: var(--button-background-color); + --toggle-background-color-hover: var(--button-background-color-hover); + --toggle-background-color-active: var(--button-background-color-active); + --toggle-background-color-pressed: var(--color-accent-primary); + --toggle-background-color-pressed-hover: var(--color-accent-primary-hover); + --toggle-background-color-pressed-active: var(--color-accent-primary-active); + --toggle-border-color: var(--border-interactive-color); + --toggle-border-radius: var(--border-radius-circle); + --toggle-border-width: var(--border-width); + --toggle-height: var(--size-item-small); + --toggle-width: var(--size-item-large); + --toggle-dot-background-color: var(--toggle-border-color); + --toggle-dot-background-color-on-pressed: var(--color-canvas); + --toggle-dot-margin: 1px; + --toggle-dot-height: calc(var(--toggle-height) - 2 * var(--toggle-dot-margin) - 2 * var(--toggle-border-width)); + --toggle-dot-width: var(--toggle-dot-height); + --toggle-dot-transform-x: calc(var(--toggle-width) - 4 * var(--toggle-dot-margin) - var(--toggle-dot-width)); +} + +.toggle-button { + appearance: none; + padding: 0; + margin: 0; + border: var(--toggle-border-width) solid var(--toggle-border-color); + height: var(--toggle-height); + width: var(--toggle-width); + border-radius: var(--toggle-border-radius); + background: var(--toggle-background-color); + box-sizing: border-box; + flex-shrink: 0; +} + +.toggle-button:focus-visible { + outline: var(--focus-outline); + outline-offset: var(--focus-outline-offset); +} + +.toggle-button:enabled:hover { + background: var(--toggle-background-color-hover); + border-color: var(--toggle-border-color); +} + +.toggle-button:enabled:active { + background: var(--toggle-background-color-active); + border-color: var(--toggle-border-color); +} + +.toggle-button[aria-pressed="true"] { + background: var(--toggle-background-color-pressed); + border-color: transparent; +} + +.toggle-button[aria-pressed="true"]:enabled:hover { + background: var(--toggle-background-color-pressed-hover); + border-color: transparent; +} + +.toggle-button[aria-pressed="true"]:enabled:active { + background: var(--toggle-background-color-pressed-active); + border-color: transparent; +} + +.toggle-button::before { + display: block; + content: ""; + background-color: var(--toggle-dot-background-color); + height: var(--toggle-dot-height); + width: var(--toggle-dot-width); + margin: var(--toggle-dot-margin); + border-radius: var(--toggle-border-radius); + translate: 0; +} + +.toggle-button[aria-pressed="true"]::before { + translate: var(--toggle-dot-transform-x); + background-color: var(--toggle-dot-background-color-on-pressed); +} + +.toggle-button[aria-pressed="true"]:enabled:hover::before, +.toggle-button[aria-pressed="true"]:enabled:active::before { + background-color: var(--toggle-dot-background-color-on-pressed); +} + +.toggle-button[aria-pressed="true"]:-moz-locale-dir(rtl)::before, +.toggle-button[aria-pressed="true"]:dir(rtl)::before { + translate: calc(-1 * var(--toggle-dot-transform-x)); +} + +@media (prefers-reduced-motion: no-preference) { + .toggle-button::before { + transition: translate 100ms; + } +} + +@media (prefers-contrast) { + :host([disabled]) { + opacity: 1; + } + + :host([disabled]) > .toggle-button[aria-pressed="true"], + :host([disabled]) > .toggle-button { + background-color: var(--toggle-background-color-disabled); + border-color: var(--toggle-border-color-disabled); + } + + :host([disabled]) > .toggle-button[aria-pressed="false"]::before, + :host([disabled]) > .toggle-button[aria-pressed="true"]::before { + background-color: var(--toggle-background-color-disabled); + } + + .toggle-button { + --toggle-dot-background-color: var(--color-accent-primary); + --toggle-dot-background-color-hover: var(--color-accent-primary-hover); + --toggle-dot-background-color-active: var(--color-accent-primary-active); + --toggle-dot-background-color-on-pressed: var(--button-background-color); + --toggle-background-color-disabled: var(--button-background-color-disabled); + --toggle-border-color-hover: var(--border-interactive-color-hover); + --toggle-border-color-active: var(--border-interactive-color-active); + --toggle-border-color-disabled: var(--border-interactive-color-disabled); + } + + .toggle-button:enabled:hover { + border-color: var(--toggle-border-color-hover); + } + + .toggle-button:enabled:active { + border-color: var(--toggle-border-color-active); + } + + .toggle-button[aria-pressed="true"]:enabled { + border-color: var(--toggle-border-color); + position: relative; + } + + .toggle-button[aria-pressed="true"]:enabled:hover, + .toggle-button[aria-pressed="true"]:enabled:hover:active { + border-color: var(--toggle-border-color-hover); + } + + .toggle-button[aria-pressed="true"]:enabled:active { + background-color: var(--toggle-dot-background-color-active); + border-color: var(--toggle-dot-background-color-hover); + } + + .toggle-button[aria-pressed="true"]:enabled::after { + border: 1px solid var(--button-background-color); + content: ''; + position: absolute; + height: var(--toggle-height); + width: var(--toggle-width); + display: block; + border-radius: var(--toggle-border-radius); + inset: -2px; + } + + .toggle-button[aria-pressed="true"]:enabled:active::after { + border-color: var(--toggle-border-color-active); + } + + .toggle-button:hover::before, + .toggle-button:hover:active::before, + .toggle-button:active::before { + background-color: var(--toggle-dot-background-color-hover); + } +} diff --git a/toolkit/content/widgets/moz-toggle/moz-toggle.mjs b/toolkit/content/widgets/moz-toggle/moz-toggle.mjs new file mode 100644 index 0000000000..be7ee98f34 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/moz-toggle.mjs @@ -0,0 +1,133 @@ +/* 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 htp://mozilla.org/MPL/2.0/. */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +import { MozLitElement } from "../lit-utils.mjs"; +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-label.mjs"; + +/** + * A simple toggle element that can be used to switch between two states. + * + * @tagname moz-toggle + * @property {boolean} pressed - Whether or not the element is pressed. + * @property {boolean} disabled - Whether or not the element is disabled. + * @property {string} label - The label text. + * @property {string} description - The description text. + * @property {string} ariaLabel + * The aria-label text for cases where there is no visible label. + * @slot support-link - Used to append a moz-support-link to the description. + * @fires toggle + * Custom event indicating that the toggle's pressed state has changed. + */ +export default class MozToggle extends MozLitElement { + static shadowRootOptions = { + ...MozLitElement.shadowRootOptions, + delegatesFocus: true, + }; + + static properties = { + pressed: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + label: { type: String }, + description: { type: String }, + ariaLabel: { type: String, attribute: "aria-label" }, + accessKey: { type: String, attribute: "accesskey" }, + }; + + static get queries() { + return { + buttonEl: "#moz-toggle-button", + labelEl: "#moz-toggle-label", + descriptionEl: "#moz-toggle-description", + }; + } + + constructor() { + super(); + this.pressed = false; + this.disabled = false; + } + + handleClick() { + this.pressed = !this.pressed; + this.dispatchOnUpdateComplete( + new CustomEvent("toggle", { + bubbles: true, + composed: true, + }) + ); + } + + // Delegate clicks on the host to the input element + click() { + this.buttonEl.click(); + } + + descriptionTemplate() { + if (this.description) { + return html` + <p + id="moz-toggle-description" + class="description-wrapper text-deemphasized" + part="description" + > + ${this.description} ${this.supportLinkTemplate()} + </p> + `; + } + return ""; + } + + supportLinkTemplate() { + return html` <slot name="support-link"></slot> `; + } + + buttonTemplate() { + const { pressed, disabled, description, ariaLabel, handleClick } = this; + return html` + <button + id="moz-toggle-button" + part="button" + type="button" + class="toggle-button" + ?disabled=${disabled} + aria-pressed=${pressed} + aria-label=${ifDefined(ariaLabel ?? undefined)} + aria-describedby=${ifDefined( + description ? "moz-toggle-description" : undefined + )} + @click=${handleClick} + ></button> + `; + } + + render() { + return html` + <link + rel="stylesheet" + href="chrome://global/content/elements/moz-toggle.css" + /> + ${this.label + ? html` + <label + is="moz-label" + id="moz-toggle-label" + part="label" + for="moz-toggle-button" + accesskey=${ifDefined(this.accessKey)} + > + <span> + ${this.label} + ${!this.description ? this.supportLinkTemplate() : ""} + </span> + ${this.buttonTemplate()} + </label> + ` + : this.buttonTemplate()} + ${this.descriptionTemplate()} + `; + } +} +customElements.define("moz-toggle", MozToggle); diff --git a/toolkit/content/widgets/moz-toggle/moz-toggle.stories.mjs b/toolkit/content/widgets/moz-toggle/moz-toggle.stories.mjs new file mode 100644 index 0000000000..fa41e7c888 --- /dev/null +++ b/toolkit/content/widgets/moz-toggle/moz-toggle.stories.mjs @@ -0,0 +1,96 @@ +/* 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/. */ +/* eslint-disable import/no-unassigned-import */ + +import { html, ifDefined } from "../vendor/lit.all.mjs"; +import "./moz-toggle.mjs"; +import "../moz-support-link/moz-support-link.mjs"; + +export default { + title: "UI Widgets/Toggle", + component: "moz-toggle", + parameters: { + status: "in-development", + actions: { + handles: ["toggle"], + }, + fluent: ` +moz-toggle-aria-label = + .aria-label = This is the aria-label +moz-toggle-label = + .label = This is the label +moz-toggle-description = + .label = This is the label + .description = This is the description. + `, + }, +}; + +const Template = ({ + pressed, + disabled, + label, + description, + ariaLabel, + l10nId, + hasSupportLink, + accessKey, +}) => html` + <div style="max-width: 400px"> + <moz-toggle + ?pressed=${pressed} + ?disabled=${disabled} + label=${ifDefined(label)} + description=${ifDefined(description)} + aria-label=${ifDefined(ariaLabel)} + data-l10n-id=${ifDefined(l10nId)} + data-l10n-attrs="aria-label, description, label" + accesskey=${ifDefined(accessKey)} + > + ${hasSupportLink + ? html` + <a + is="moz-support-link" + support-page="addons" + slot="support-link" + ></a> + ` + : ""} + </moz-toggle> + </div> +`; + +export const Toggle = Template.bind({}); +Toggle.args = { + pressed: true, + disabled: false, + l10nId: "moz-toggle-aria-label", +}; + +export const ToggleDisabled = Template.bind({}); +ToggleDisabled.args = { + ...Toggle.args, + disabled: true, +}; + +export const WithLabel = Template.bind({}); +WithLabel.args = { + pressed: true, + disabled: false, + l10nId: "moz-toggle-label", + hasSupportLink: false, + accessKey: "h", +}; + +export const WithDescription = Template.bind({}); +WithDescription.args = { + ...WithLabel.args, + l10nId: "moz-toggle-description", +}; + +export const WithSupportLink = Template.bind({}); +WithSupportLink.args = { + ...WithDescription.args, + hasSupportLink: true, +}; diff --git a/toolkit/content/widgets/named-deck.js b/toolkit/content/widgets/named-deck.js new file mode 100644 index 0000000000..6b3b7a8835 --- /dev/null +++ b/toolkit/content/widgets/named-deck.js @@ -0,0 +1,398 @@ +/* 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`. +{ + /** + * This element is for use with the <named-deck> element. Set the target + * <named-deck>'s ID in the "deck" attribute and the button's selected state + * will reflect the deck's state. When the button is clicked, it will set the + * view in the <named-deck> to the button's "name" attribute. + * + * The "tab" role will be added unless a different role is provided. Wrapping + * a set of these buttons in a <button-group> element will add the key handling + * for a tablist. + * + * NOTE: This does not observe changes to the "deck" or "name" attributes, so + * changing them likely won't work properly. + * + * <button is="named-deck-button" deck="pet-deck" name="dogs">Dogs</button> + * <named-deck id="pet-deck"> + * <p name="cats">I like cats.</p> + * <p name="dogs">I like dogs.</p> + * </named-deck> + * + * let btn = document.querySelector('button[name="dogs"]'); + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; + * btn.selected == false; // Selected was pulled from the related deck. + * btn.click(); + * deck.selectedViewName == "dogs"; + * btn.selected == true; // Selected updated when view changed. + */ + class NamedDeckButton extends HTMLButtonElement { + connectedCallback() { + this.id = `${this.deckId}-button-${this.name}`; + if (!this.hasAttribute("role")) { + this.setAttribute("role", "tab"); + } + this.setSelectedFromDeck(); + this.addEventListener("click", this); + this.getRootNode().addEventListener("view-changed", this, { + capture: true, + }); + } + + disconnectedCallback() { + this.removeEventListener("click", this); + this.getRootNode().removeEventListener("view-changed", this, { + capture: true, + }); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "selected") { + this.selected = newVal; + } + } + + get deckId() { + return this.getAttribute("deck"); + } + + set deckId(val) { + this.setAttribute("deck", val); + } + + get deck() { + return this.getRootNode().querySelector(`#${this.deckId}`); + } + + handleEvent(e) { + if (e.type == "view-changed" && e.target.id == this.deckId) { + this.setSelectedFromDeck(); + } else if (e.type == "click") { + let { deck } = this; + if (deck) { + deck.selectedViewName = this.name; + } + } + } + + get name() { + return this.getAttribute("name"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + set selected(val) { + if (this.selected != val) { + this.toggleAttribute("selected", val); + } + this.setAttribute("aria-selected", !!val); + } + + setSelectedFromDeck() { + let { deck } = this; + this.selected = deck && deck.selectedViewName == this.name; + if (this.selected) { + this.dispatchEvent( + new CustomEvent("button-group:selected", { bubbles: true }) + ); + } + } + } + customElements.define("named-deck-button", NamedDeckButton, { + extends: "button", + }); + + class ButtonGroup extends HTMLElement { + static get observedAttributes() { + return ["orientation"]; + } + + connectedCallback() { + this.setAttribute("role", "tablist"); + + if (!this.observer) { + this.observer = new MutationObserver(changes => { + for (let change of changes) { + this.setChildAttributes(change.addedNodes); + for (let node of change.removedNodes) { + if (this.activeChild == node) { + // Ensure there's still an active child. + this.activeChild = this.firstElementChild; + } + } + } + }); + } + this.observer.observe(this, { childList: true }); + + // Set the role and tabindex for the current children. + this.setChildAttributes(this.children); + + // Try assigning the active child again, this will run through the checks + // to ensure it's still valid. + this.activeChild = this._activeChild; + + this.addEventListener("button-group:selected", this); + this.addEventListener("keydown", this); + this.addEventListener("mousedown", this); + this.getRootNode().addEventListener("keypress", this); + } + + disconnectedCallback() { + this.observer.disconnect(); + this.removeEventListener("button-group:selected", this); + this.removeEventListener("keydown", this); + this.removeEventListener("mousedown", this); + this.getRootNode().removeEventListener("keypress", this); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "orientation") { + if (this.isVertical) { + this.setAttribute("aria-orientation", this.orientation); + } else { + this.removeAttribute("aria-orientation"); + } + } + } + + setChildAttributes(nodes) { + for (let node of nodes) { + if (node.nodeType == Node.ELEMENT_NODE && node != this.activeChild) { + node.setAttribute("tabindex", "-1"); + } + } + } + + // The activeChild is the child that can be focused with tab. + get activeChild() { + return this._activeChild; + } + + set activeChild(node) { + let prevActiveChild = this._activeChild; + let newActiveChild; + + if (node && this.contains(node)) { + newActiveChild = node; + } else { + newActiveChild = this.firstElementChild; + } + + this._activeChild = newActiveChild; + + if (newActiveChild) { + newActiveChild.setAttribute("tabindex", "0"); + } + + if (prevActiveChild && prevActiveChild != newActiveChild) { + prevActiveChild.setAttribute("tabindex", "-1"); + } + } + + get isVertical() { + return this.orientation == "vertical"; + } + + get orientation() { + return this.getAttribute("orientation") == "vertical" + ? "vertical" + : "horizontal"; + } + + set orientation(val) { + if (val == "vertical") { + this.setAttribute("orientation", val); + } else { + this.removeAttribute("orientation"); + } + } + + _navigationKeys() { + if (this.isVertical) { + return { + previousKey: "ArrowUp", + nextKey: "ArrowDown", + }; + } + if (document.dir == "rtl") { + return { + previousKey: "ArrowRight", + nextKey: "ArrowLeft", + }; + } + return { + previousKey: "ArrowLeft", + nextKey: "ArrowRight", + }; + } + + handleEvent(e) { + let { previousKey, nextKey } = this._navigationKeys(); + if (e.type == "keydown" && (e.key == previousKey || e.key == nextKey)) { + this.setAttribute("last-input-type", "keyboard"); + e.preventDefault(); + let oldFocus = this.activeChild; + this.walker.currentNode = oldFocus; + let newFocus; + if (e.key == previousKey) { + newFocus = this.walker.previousNode(); + } else { + newFocus = this.walker.nextNode(); + } + if (newFocus) { + this.activeChild = newFocus; + this.dispatchEvent(new CustomEvent("button-group:key-selected")); + } + } else if (e.type == "button-group:selected") { + this.activeChild = e.target; + } else if (e.type == "mousedown") { + this.setAttribute("last-input-type", "mouse"); + } else if (e.type == "keypress" && e.key == "Tab") { + this.setAttribute("last-input-type", "keyboard"); + } + } + + get walker() { + if (!this._walker) { + this._walker = document.createTreeWalker( + this, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => { + if (node.hidden || node.disabled) { + return NodeFilter.FILTER_REJECT; + } + node.focus(); + return this.getRootNode().activeElement == node + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + } + ); + } + return this._walker; + } + } + customElements.define("button-group", ButtonGroup); + + /** + * A deck that is indexed by the "name" attribute of its children. The + * <named-deck-button> element is a companion element that can update its state + * and change the view of a <named-deck>. + * + * When the deck is connected it will set the first child as the selected view + * if a view is not already selected. + * + * The deck is implemented using a named slot. Setting a slot directly on a + * child element of the deck is not supported. + * + * You can get or set the selected view by name with the `selectedViewName` + * property or by setting the "selected-view" attribute. + * + * <named-deck> + * <section name="cats">Some info about cats.</section> + * <section name="dogs">Some dog stuff.</section> + * </named-deck> + * + * let deck = document.querySelector("named-deck"); + * deck.selectedViewName == "cats"; // Cat info is shown. + * deck.selectedViewName = "dogs"; + * deck.selectedViewName == "dogs"; // Dog stuff is shown. + * deck.setAttribute("selected-view", "cats"); + * deck.selectedViewName == "cats"; // Cat info is shown. + * + * Add the is-tabbed attribute to <named-deck> if you want + * each of its children to have a tabpanel role and aria-labelledby + * referencing the NamedDeckButton component. + */ + class NamedDeck extends HTMLElement { + static get observedAttributes() { + return ["selected-view"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + // Create a slot for the visible content. + let selectedSlot = document.createElement("slot"); + selectedSlot.setAttribute("name", "selected"); + this.shadowRoot.appendChild(selectedSlot); + + this.observer = new MutationObserver(() => { + this._setSelectedViewAttributes(); + }); + } + + connectedCallback() { + if (this.selectedViewName) { + // Make sure the selected view is shown. + this._setSelectedViewAttributes(); + } else { + // If there's no selected view, default to the first. + let firstView = this.firstElementChild; + if (firstView) { + // This will trigger showing the first view. + this.selectedViewName = firstView.getAttribute("name"); + } + } + this.observer.observe(this, { childList: true }); + } + + disconnectedCallback() { + this.observer.disconnect(); + } + + attributeChangedCallback(attr, oldVal, newVal) { + if (attr == "selected-view" && oldVal != newVal) { + // Update the slot attribute on the views. + this._setSelectedViewAttributes(); + + // Notify that the selected view changed. + this.dispatchEvent(new CustomEvent("view-changed")); + } + } + + get selectedViewName() { + return this.getAttribute("selected-view"); + } + + set selectedViewName(name) { + this.setAttribute("selected-view", name); + } + + /** + * Set the slot attribute on all of the views to ensure only the selected view + * is shown. + */ + _setSelectedViewAttributes() { + let { selectedViewName } = this; + for (let view of this.children) { + let name = view.getAttribute("name"); + + if (this.hasAttribute("is-tabbed")) { + view.setAttribute("aria-labelledby", `${this.id}-button-${name}`); + view.setAttribute("role", "tabpanel"); + } + + if (name === selectedViewName) { + view.slot = "selected"; + } else { + view.slot = ""; + } + } + } + } + customElements.define("named-deck", NamedDeck); +} diff --git a/toolkit/content/widgets/notificationbox.js b/toolkit/content/widgets/notificationbox.js new file mode 100644 index 0000000000..f23fb03a74 --- /dev/null +++ b/toolkit/content/widgets/notificationbox.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. If you need to +// define globals, wrap in a block to prevent leaking onto `window`. +{ + MozElements.NotificationBox = class NotificationBox { + /** + * Creates a new class to handle a notification box, but does not add any + * elements to the DOM until a notification has to be displayed. + * + * @param insertElementFn + * Called with the "notification-stack" element as an argument when the + * first notification has to be displayed. + */ + constructor(insertElementFn) { + this._insertElementFn = insertElementFn; + this._animating = false; + this.currentNotification = null; + } + + get stack() { + if (!this._stack) { + let stack = document.createXULElement("vbox"); + stack._notificationBox = this; + stack.className = "notificationbox-stack"; + stack.addEventListener("transitionend", event => { + if ( + (event.target.localName == "notification" || + event.target.localName == "notification-message") && + event.propertyName == "margin-top" + ) { + this._finishAnimation(); + } + }); + this._stack = stack; + this._insertElementFn(stack); + } + return this._stack; + } + + get _allowAnimation() { + return window.matchMedia("(prefers-reduced-motion: no-preference)") + .matches; + } + + get allNotifications() { + // Don't create any DOM if no new notification has been added yet. + if (!this._stack) { + return []; + } + + var closedNotification = this._closedNotification; + var notifications = [ + ...this.stack.getElementsByTagName("notification"), + ...this.stack.getElementsByTagName("notification-message"), + ]; + return notifications.filter(n => n != closedNotification); + } + + getNotificationWithValue(aValue) { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + if (aValue == notifications[n].getAttribute("value")) { + return notifications[n]; + } + } + return null; + } + + /** + * Creates a <notification> element and shows it. The calling code can modify + * the element synchronously to add features to the notification. + * + * aType + * String identifier that can uniquely identify the type of the notification. + * aNotification + * Object that contains any of the following properties, where only the + * priority must be specified: + * priority + * One of the PRIORITY_ constants. These determine the appearance of + * the notification based on severity (using the "type" attribute), and + * only the notification with the highest priority is displayed. + * label + * The main message text (as string), or object (with l10n-id, l10n-args), + * or a DocumentFragment containing elements to + * add as children of the notification's main <description> element. + * eventCallback + * This may be called with the "removed", "dismissed" or "disconnected" + * parameter: + * removed - notification has been removed + * dismissed - user dismissed notification + * disconnected - notification removed in any way + * notificationIs + * Defines a Custom Element name to use as the "is" value on creation. + * This allows subclassing the created element. + * telemetry + * Specifies the telemetry key to use that triggers when the notification + * is shown, dismissed and an action taken. This telemetry is a keyed scalar with keys for: + * 'shown', 'dismissed' and 'action'. If a button specifies a separate key, + * then 'action' is replaced by values specific to each button. The value telemetryFilter + * can be used to filter out each type. + * telemetryFilter + * If assigned, then an array of the telemetry types to send telemetry for. If not set, + * then all telemetry is sent. + * aButtons + * Array of objects defining action buttons: + * { + * label: + * Label of the <button> element. + * accessKey: + * Access key character for the <button> element. + * "l10n-id" + * Localization id for the <button>, to be used instead of + * specifying a separate label and access key. + * callback: + * When the button is used, this is called with the arguments: + * 1. The <notification> element. + * 2. This button object definition. + * 3. The <button> element. + * 4. The "command" event. + * If the callback returns false, the notification is closed. + * link: + * A url to open when the button is clicked. The button is + * rendered like a link. The callback is called as well. + * supportPage: + * Used for a support page link. If no other properties are specified, + * defaults to a link with a 'Learn more' label. + * popup: + * If specified, the button will open the popup element with this + * ID, anchored to the button. This is alternative to "callback". + * telemetry: + * Specifies the key to add for the telemetry to trigger when the + * button is pressed. If not specified, then 'action' is used for + * a press on any button. Specify this only if you want to distinguish + * which button has been pressed in telemetry data. + * is: + * Defines a Custom Element name to use as the "is" value on + * button creation. + * } + * + * @return The <notification> element that is shown. + */ + async appendNotification(aType, aNotification, aButtons) { + if ( + aNotification.priority < this.PRIORITY_SYSTEM || + aNotification.priority > this.PRIORITY_CRITICAL_HIGH + ) { + throw new Error( + "Invalid notification priority " + aNotification.priority + ); + } + + MozXULElement.insertFTLIfNeeded("toolkit/global/notification.ftl"); + + // Create the Custom Element and connect it to the document immediately. + let newitem; + if (!aNotification.notificationIs) { + if (!customElements.get("notification-message")) { + // There's some weird timing stuff when this element is created at + // script load time, we don't need it until now anyway so be lazy. + // Wrapped in a try/catch to handle rare cases where we start creating + // a notification but then the window gets closed/goes away. + try { + await createNotificationMessageElement(); + } catch (err) { + console.warn(err); + throw err; + } + } + newitem = document.createElement("notification-message"); + newitem.setAttribute("message-bar-type", "infobar"); + } else { + newitem = document.createXULElement( + "notification", + aNotification.notificationIs + ? { is: aNotification.notificationIs } + : {} + ); + } + + // Append or prepend notification, based on stack preference. + if (this.stack.hasAttribute("prepend-notifications")) { + this.stack.prepend(newitem); + } else { + this.stack.append(newitem); + } + + if (newitem.localName === "notification-message" && aNotification.label) { + newitem.label = aNotification.label; + } else if (newitem.messageText) { + // Custom notification classes may not have the messageText property. + // Can't use instanceof in case this was created from a different document: + if ( + aNotification.label && + typeof aNotification.label == "object" && + aNotification.label.nodeType && + aNotification.label.nodeType == + aNotification.label.DOCUMENT_FRAGMENT_NODE + ) { + newitem.messageText.appendChild(aNotification.label); + } else if ( + aNotification.label && + typeof aNotification.label == "object" && + "l10n-id" in aNotification.label + ) { + let message = document.createElement("span"); + document.l10n.setAttributes( + message, + aNotification.label["l10n-id"], + aNotification.label["l10n-args"] + ); + newitem.messageText.appendChild(message); + } else { + newitem.messageText.textContent = aNotification.label; + } + } + newitem.setAttribute("value", aType); + + newitem.eventCallback = aNotification.eventCallback; + + if (aButtons) { + newitem.setButtons(aButtons); + } + + if (aNotification.telemetry) { + newitem.telemetry = aNotification.telemetry; + if (aNotification.telemetryFilter) { + newitem.telemetryFilter = aNotification.telemetryFilter; + } + } + + newitem.priority = aNotification.priority; + if (aNotification.priority == this.PRIORITY_SYSTEM) { + newitem.setAttribute("type", "system"); + } else if (aNotification.priority >= this.PRIORITY_CRITICAL_LOW) { + newitem.setAttribute("type", "critical"); + } else if (aNotification.priority <= this.PRIORITY_INFO_HIGH) { + newitem.setAttribute("type", "info"); + } else { + newitem.setAttribute("type", "warning"); + } + + // Animate the notification. + newitem.style.display = "block"; + newitem.style.position = "fixed"; + newitem.style.top = "100%"; + newitem.style.marginTop = "-15px"; + newitem.style.opacity = "0"; + + // Ensure the DOM has been created for the Lit-based notification-message + // element so that we add the .animated class + it animates as expected. + await newitem.updateComplete; + this._showNotification(newitem, true); + + // Fire event for accessibility APIs + var event = document.createEvent("Events"); + event.initEvent("AlertActive", true, true); + newitem.dispatchEvent(event); + + // If the notification is not visible, don't call shown() on the + // new notification until it is visible. This will typically be + // a tabbrowser that does this when a tab is selected. + if (this.isShown) { + newitem.shown(); + } + + return newitem; + } + + removeNotification(aItem, aSkipAnimation) { + if (!aItem.parentNode) { + return; + } + this.currentNotification = aItem; + this.removeCurrentNotification(aSkipAnimation); + } + + _removeNotificationElement(aChild) { + let hadFocus = aChild.matches(":focus-within"); + + if (aChild.eventCallback) { + aChild.eventCallback("removed"); + } + aChild.remove(); + + // Make sure focus doesn't get lost (workaround for bug 570835). + if (hadFocus) { + Services.focus.moveFocus( + window, + this.stack, + Services.focus.MOVEFOCUS_FORWARD, + 0 + ); + } + } + + removeCurrentNotification(aSkipAnimation) { + this._showNotification(this.currentNotification, false, aSkipAnimation); + } + + removeAllNotifications(aImmediate) { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + if (aImmediate) { + this._removeNotificationElement(notifications[n]); + } else { + this.removeNotification(notifications[n]); + } + } + this.currentNotification = null; + + // Clean up any currently-animating notification; this is necessary + // if a notification was just opened and is still animating, but we + // want to close it *without* animating. This can even happen if + // animations get disabled (via prefers-reduced-motion) and this method + // is called immediately after an animated notification was displayed + // (although this case isn't very likely). + if (aImmediate || !this._allowAnimation) { + this._finishAnimation(); + } + } + + removeTransientNotifications() { + var notifications = this.allNotifications; + for (var n = notifications.length - 1; n >= 0; n--) { + var notification = notifications[n]; + if (notification.persistence) { + notification.persistence--; + } else if (Date.now() > notification.timeout) { + this.removeNotification(notification, true); + } + } + } + + shown() { + for (let notification of this.allNotifications) { + notification.shown(); + } + } + + get isShown() { + let stack = this.stack; + let parent = this.stack.parentNode; + if (parent.localName == "named-deck") { + return parent.selectedViewName == stack.getAttribute("name"); + } + + return true; + } + + _showNotification(aNotification, aSlideIn, aSkipAnimation) { + this._finishAnimation(); + + let { marginTop, marginBottom } = getComputedStyle(aNotification); + let baseHeight = aNotification.getBoundingClientRect().height; + var height = + baseHeight + parseInt(marginTop, 10) + parseInt(marginBottom, 10); + var skipAnimation = + aSkipAnimation || baseHeight == 0 || !this._allowAnimation; + aNotification.classList.toggle("animated", !skipAnimation); + + if (aSlideIn) { + this.currentNotification = aNotification; + aNotification.style.removeProperty("display"); + aNotification.style.removeProperty("position"); + aNotification.style.removeProperty("top"); + aNotification.style.removeProperty("margin-top"); + aNotification.style.removeProperty("opacity"); + + if (skipAnimation) { + return; + } + } else { + this._closedNotification = aNotification; + var notifications = this.allNotifications; + var idx = notifications.length - 1; + this.currentNotification = idx >= 0 ? notifications[idx] : null; + + if (skipAnimation) { + this._removeNotificationElement(this._closedNotification); + delete this._closedNotification; + return; + } + + aNotification.style.marginTop = -height + "px"; + aNotification.style.opacity = 0; + } + + this._animating = true; + } + + _finishAnimation() { + if (this._animating) { + this._animating = false; + if (this._closedNotification) { + this._removeNotificationElement(this._closedNotification); + delete this._closedNotification; + } + } + } + }; + + // These are defined on the instance prototype for backwards compatibility. + Object.assign(MozElements.NotificationBox.prototype, { + PRIORITY_SYSTEM: 0, + PRIORITY_INFO_LOW: 1, + PRIORITY_INFO_MEDIUM: 2, + PRIORITY_INFO_HIGH: 3, + PRIORITY_WARNING_LOW: 4, + PRIORITY_WARNING_MEDIUM: 5, + PRIORITY_WARNING_HIGH: 6, + PRIORITY_CRITICAL_LOW: 7, + PRIORITY_CRITICAL_MEDIUM: 8, + PRIORITY_CRITICAL_HIGH: 9, + }); + + MozElements.Notification = class Notification extends MozXULElement { + static get markup() { + return ` + <hbox class="messageDetails" align="center" flex="1" + oncommand="this.parentNode._doButtonCommand(event);"> + <image class="messageImage"/> + <description class="messageText" flex="1"/> + <spacer flex="1"/> + </hbox> + <toolbarbutton ondblclick="event.stopPropagation();" + class="messageCloseButton close-icon tabbable" + data-l10n-id="close-notification-message" + oncommand="this.parentNode.dismiss();"/> + `; + } + + constructor() { + super(); + this.persistence = 0; + this.priority = 0; + this.timeout = 0; + this.telemetry = null; + this._shown = false; + } + + connectedCallback() { + MozXULElement.insertFTLIfNeeded("toolkit/global/notification.ftl"); + this.appendChild(this.constructor.fragment); + + for (let [propertyName, selector] of [ + ["messageDetails", ".messageDetails"], + ["messageImage", ".messageImage"], + ["messageText", ".messageText"], + ["spacer", "spacer"], + ["buttonContainer", ".messageDetails"], + ["closeButton", ".messageCloseButton"], + ]) { + this[propertyName] = this.querySelector(selector); + } + } + + disconnectedCallback() { + if (this.eventCallback) { + this.eventCallback("disconnected"); + } + } + + setButtons(aButtons) { + for (let button of aButtons) { + let buttonElem; + + let link = button.link; + let localeId = button["l10n-id"]; + if (!link && button.supportPage) { + link = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + button.supportPage; + if (!button.label && !localeId) { + localeId = "notification-learnmore-default-label"; + } + } + + if (link) { + buttonElem = document.createXULElement("label", { + is: "text-link", + }); + buttonElem.setAttribute("href", link); + buttonElem.classList.add("notification-link"); + buttonElem.onclick = (...args) => this._doButtonCommand(...args); + } else { + buttonElem = document.createXULElement( + "button", + button.is ? { is: button.is } : {} + ); + buttonElem.classList.add("notification-button"); + + if (button.primary) { + buttonElem.classList.add("primary"); + } + } + + if (localeId) { + document.l10n.setAttributes(buttonElem, localeId); + } else { + buttonElem.setAttribute(link ? "value" : "label", button.label); + if (typeof button.accessKey == "string") { + buttonElem.setAttribute("accesskey", button.accessKey); + } + } + + if (link) { + this.messageText.appendChild(buttonElem); + } else { + this.messageDetails.appendChild(buttonElem); + } + buttonElem.buttonInfo = button; + } + } + + get control() { + return this.closest(".notificationbox-stack")._notificationBox; + } + + /** + * Changes the text of an existing notification. If the notification was + * created with a custom fragment, it will be overwritten with plain text + * or a localized message. + * + * @param {string | { "l10n-id": string, "l10n-args"?: string }} value + */ + set label(value) { + if (value && typeof value == "object" && "l10n-id" in value) { + const message = document.createElement("span"); + document.l10n.setAttributes( + message, + value["l10n-id"], + value["l10n-args"] + ); + while (this.messageText.firstChild) { + this.messageText.firstChild.remove(); + } + this.messageText.appendChild(message); + } else { + this.messageText.textContent = value; + } + } + + /** + * This method should only be called when the user has manually closed the + * notification. If you want to programmatically close the notification, you + * should call close() instead. + */ + dismiss() { + this._doTelemetry("dismissed"); + + if (this.eventCallback) { + this.eventCallback("dismissed"); + } + this.close(); + } + + close() { + if (!this.parentNode) { + return; + } + this.control.removeNotification(this); + } + + // This will be called when the host (such as a tabbrowser) determines that + // the notification is made visible to the user. + shown() { + if (!this._shown) { + this._shown = true; + this._doTelemetry("shown"); + } + } + + _doTelemetry(type) { + if ( + this.telemetry && + (!this.telemetryFilter || this.telemetryFilter.includes(type)) + ) { + Services.telemetry.keyedScalarAdd(this.telemetry, type, 1); + } + } + + _doButtonCommand(event) { + if (!("buttonInfo" in event.target)) { + return; + } + + var button = event.target.buttonInfo; + this._doTelemetry(button.telemetry || "action"); + + if (button.popup) { + document + .getElementById(button.popup) + .openPopup( + event.originalTarget, + "after_start", + 0, + 0, + false, + false, + event + ); + event.stopPropagation(); + } else { + var callback = button.callback; + if (callback) { + var result = callback(this, button, event.target, event); + if (!result) { + this.close(); + } + event.stopPropagation(); + } + } + } + }; + + customElements.define("notification", MozElements.Notification); + + async function createNotificationMessageElement() { + await window.ensureCustomElements("moz-message-bar"); + let MozMessageBar = customElements.get("moz-message-bar"); + class NotificationMessage extends MozMessageBar { + static queries = { + ...MozMessageBar.queries, + messageText: ".message", + messageImage: ".icon", + }; + + constructor() { + super(); + this.persistence = 0; + this.priority = 0; + this.timeout = 0; + this.telemetry = null; + this.dismissable = true; + this._shown = false; + + this.addEventListener("click", this); + this.addEventListener("command", this); + } + + connectedCallback() { + super.connectedCallback(); + this.#setStyles(); + + this.classList.add("infobar"); + this.setAlertRole(); + + this.buttonContainer = document.createElement("span"); + this.buttonContainer.classList.add("notification-button-container"); + this.buttonContainer.setAttribute("slot", "actions"); + this.appendChild(this.buttonContainer); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.eventCallback) { + this.eventCallback("disconnected"); + } + } + + #setStyles() { + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://global/content/elements/infobar.css"; + this.renderRoot.append(style); + } + + _doTelemetry(type) { + if ( + this.telemetry && + (!this.telemetryFilter || this.telemetryFilter.includes(type)) + ) { + Services.telemetry.keyedScalarAdd(this.telemetry, type, 1); + } + } + + get control() { + return this.closest(".notificationbox-stack")._notificationBox; + } + + close() { + if (!this.parentNode) { + return; + } + this.control.removeNotification(this); + } + + // This will be called when the host (such as a tabbrowser) determines that + // the notification is made visible to the user. + shown() { + if (!this._shown) { + this._shown = true; + this._doTelemetry("shown"); + } + } + + setAlertRole() { + // Wait a little for this to render before setting the role for more + // consistent alerts to screen readers. + this.removeAttribute("role"); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + this.setAttribute("role", "alert"); + }); + }); + } + + handleEvent(e) { + if (e.type == "click" && e.target.localName != "label") { + return; + } + + if ("buttonInfo" in e.target) { + let { buttonInfo } = e.target; + let { callback, popup } = buttonInfo; + + this._doTelemetry(buttonInfo.telemetry || "action"); + + if (popup) { + document + .getElementById(popup) + .openPopup( + e.originalTarget, + "after_start", + 0, + 0, + false, + false, + e + ); + e.stopPropagation(); + } else if (callback) { + if (!callback(this, buttonInfo, e.target, e)) { + this.close(); + } + e.stopPropagation(); + } + } + } + + /** + * Changes the text of an existing notification. If the notification was + * created with a custom fragment, it will be overwritten with plain text + * or a localized message. + * + * @param {string | { "l10n-id": string, "l10n-args"?: string }} value + */ + set label(value) { + if (value && typeof value == "object" && "l10n-id" in value) { + this.messageL10nId = value["l10n-id"]; + this.messageL10nArgs = value["l10n-args"]; + } else { + this.message = value; + } + this.setAlertRole(); + } + + setButtons(buttons) { + this._buttons = buttons; + for (let button of buttons) { + let link = button.link || button.supportPage; + let localeId = button["l10n-id"]; + + let buttonElem; + if (button.hasOwnProperty("supportPage")) { + window.ensureCustomElements("moz-support-link"); + buttonElem = document.createElement("a", { + is: "moz-support-link", + }); + buttonElem.classList.add("notification-link"); + buttonElem.setAttribute("support-page", button.supportPage); + } else if (link) { + buttonElem = document.createXULElement("label", { + is: "text-link", + }); + buttonElem.setAttribute("href", link); + buttonElem.classList.add("notification-link", "text-link"); + } else { + buttonElem = document.createXULElement( + "button", + button.is ? { is: button.is } : {} + ); + buttonElem.classList.add( + "notification-button", + "small-button", + "footer-button" + ); + + if (button.primary) { + buttonElem.classList.add("primary"); + } + } + + if (localeId) { + document.l10n.setAttributes(buttonElem, localeId); + } else { + buttonElem.setAttribute(link ? "value" : "label", button.label); + if (typeof button.accessKey == "string") { + buttonElem.setAttribute("accesskey", button.accessKey); + } + } + + if (link) { + buttonElem.setAttribute("slot", "support-link"); + this.appendChild(buttonElem); + } else { + this.buttonContainer.appendChild(buttonElem); + } + buttonElem.buttonInfo = button; + } + } + + dismiss() { + this._doTelemetry("dismissed"); + + if (this.eventCallback) { + this.eventCallback("dismissed"); + } + super.dismiss(); + } + } + if (!customElements.get("notification-message")) { + customElements.define("notification-message", NotificationMessage); + } + } +} diff --git a/toolkit/content/widgets/panel-list/README.stories.md b/toolkit/content/widgets/panel-list/README.stories.md new file mode 100644 index 0000000000..b8800e2b5f --- /dev/null +++ b/toolkit/content/widgets/panel-list/README.stories.md @@ -0,0 +1,231 @@ +# Panel Menu + +The `panel-list` and `panel-item` components work together to create a menu for +in-content contexts. The basic structure is a `panel-list` with `panel-item` +children and optional `hr` elements as separators. The `panel-list` will anchor +itself to the target of the initiating event when opened with +`panelList.toggle(event)`. + +Note: Nested menus are not currently supported. XUL is currently required to +support accesskey underlining (although using `moz-label` could change that). +Shortcuts are not displayed automatically in the `panel-item`. + +```html story +<panel-list stay-open open> + <panel-item action="new" accesskey="N">New</panel-item> + <panel-item accesskey="O">Open</panel-item> + <hr /> + <panel-item action="save" accesskey="S">Save</panel-item> + <hr /> + <panel-item accesskey="Q">Quit</panel-item> +</panel-list> +``` + +## Status + +Current status is listed as in-development since this is only intended for use +within in-content contexts. XUL is still required for accesskey underlining, but +could be migrated to use the `moz-label` component. This is a useful but +historical element that could likely use some attention at the API level and to +be brought up to our design systems standards. + +## When to use + +* When there are multiple options for something that would take too + much space with individual buttons. +* When the actions are not frequently needed. +* When you are within an in-content context. + +## When not to use + +* When there is only one action. +* When the actions are frequently needed. +* In the browser chrome, you probably want to use + [menupopup](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/menupopup.js) + or + [panel](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/panel.js) + instead. + +## Basic usage + +The source for `panel-list` can be found under +[toolkit/content/widgets/panel-list.js](https://searchfox.org/mozilla-central/source/toolkit/content/widgets/panel-list.js). +You can find an examples of `panel-list` in use in the Firefox codebase in both +[about:addons](https://searchfox.org/mozilla-central/source/toolkit/mozapps/extensions/content/aboutaddons.html#87,102,114) +and the +[migration-wizard](https://searchfox.org/mozilla-central/source/browser/components/migration/content/migration-dialog-window.html#18). + +`panel-list` will automatically be imported in chrome documents, both through +markup and through JS with `document.createElement("panel-list")` or by cloning +a template. + +```html +<!-- This will import `panel-list` if needed in most cases. --> +<panel-list></panel-list> +``` + +In non-chrome documents it can be imported into `.html`/`.xhtml` files: + +```html +<script src="chrome://global/content/elements/panel-list.js"></script> +``` + +And used as follows: + +```html +<panel-list> + <panel-item accesskey="N">New</panel-item> + <panel-item accesskey="O">Open</panel-item> + <hr /> + <panel-item accesskey="S">Save</panel-item> + <hr /> + <panel-item accesskey="Q">Quit</panel-item> +</panel-list> +``` + +The `toggle` method takes the event you received on your anchor button and opens +the menu attached to that element. + +```js +anchorButton.addEventListener("mousedown", e => panelList.toggle(e)); +``` + +Accesskeys are activated with the bare accesskey letter when the menu is opened. +So for this example after opening the menu pressing `s` will fire a click event +on the Save `panel-item`. + +Note: XUL is currently required for accesskey underlining, but can be [replaced +with `moz-label`](https://bugzilla.mozilla.org/show_bug.cgi?id=1828741) later. + +### Fluent usage + +The `panel-item` expects to have text content set by fluent. + +```html +<panel-list> + <panel-item data-l10n-id="menu-new"></panel-item> + <panel-item data-l10n-id="menu-save"></panel-item> +</panel-list> +``` + +In which case your Fluent messages will look something like this: + +``` +menu-new = New + .accesskey = N +menu-save = Save + .accesskey = S +``` + +## Advanced usage + +### Showing the menu + +By default the menu will be hidden. It is shown when the `open` attribute is +set, but that won't position the menu by default. + +To trigger the auto-positioning of the menu, it should be opened or closed using +the `toggle(event)` method. + +```js +function onMenuButton(event) { + document.querySelector("panel-list").toggle(event); +} +``` + +The `toggle(event)` method will use `event.target` as the anchor for the menu. + +To achieve the expected behaviour, the menu should open on `mousedown` for mouse +events, and `click` for keyboard events. This can be accomplished by checking +the `event.inputSource` property in chrome contexts or `event.detail` in +non-chrome contexts (`event.detail` will be the click count which is `0` when a +click is from the keyboard). + +```js +function openMenu(event) { + if ( + event.type == "mousedown" || + event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD || + !event.detail + ) { + document.querySelector("panel-list").toggle(event); + } +} + +let menuButton = document.getElementById("open-menu-button"); +menuButton.addEventListener("mousedown", openMenu); +menuButton.addEventListener("click", openMenu); +``` + +### Icons + +Icons can be added to the `panel-item`s by setting a `background-image` on +`panel-item::part(button)`. + +```css +panel-item[action="new"]::part(button) { + background-image: url("./new.svg"); +} + +panel-item[action="save"]::part(button) { + background-image: url("./save.svg"); +} +``` + +### Badging + +Icons may be badged by setting the `badged` attribute. This adds a dot next to +the icon. + +```html +<panel-list> + <panel-item action="new">New</panel-item> + <panel-item action="save" badged>Save</panel-item> +</panel-list> +``` + +```html story +<panel-list stay-open open> + <panel-item action="new">New</panel-item> + <panel-item action="save" badged>Save</panel-item> +</panel-list> +``` + +### Matching anchor width + +When using the `panel-list` like a `select` dropdown, it's nice to have it match +the size of the anchor button. You can see this in practice in the +[Wide variant](?path=/story/ui-widgets-panel-list--wide) and the +`migration-wizard`. Setting the `min-width-from-anchor` attribute will cause the +menu to match its anchor's width when it is opened. + +```html +<button class="current-selection">Apples</button> +<panel-list min-width-from-anchor> + <panel-item>Apples</panel-list> + <panel-item>Bananas</panel-list> +</panel-list> +``` + +### Usage in a XUL `panel` + +The "new" (as of early 2023) migration wizard uses the `panel-list` inside of a +XUL `panel` element to let its contents escape its container dialog by creating +an OS-level window. This can be useful if the menu could be larger than its +container, however in chrome contexts you are likely better off using +`menupopup`. + +By placing a `panel-list` inside of a XUL `panel` it will automatically defer +its positioning responsibilities to the XUL `panel` and it will then be able to +grow larger than its containing window if needed. + +```html +<!-- Assuming we're in a XUL document. --> +<panel> + <html:panel-list> + <html:panel-item>Apples</html:panel-item> + <html:panel-item>Apples</html:panel-item> + <html:panel-item>Apples</html:panel-item> + </html:panel-list> +</panel> +``` diff --git a/toolkit/content/widgets/panel-list/panel-item.css b/toolkit/content/widgets/panel-list/panel-item.css new file mode 100644 index 0000000000..28ff8a072f --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-item.css @@ -0,0 +1,96 @@ +/* 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/. */ + +:host(:not([hidden])) { + display: flex; + align-items: center; +} + +::slotted(a) { + margin-inline-end: 12px; +} + +:host button { + -moz-context-properties: fill; + fill: currentColor; +} + +:host([checked]) button { + background-image: url("chrome://global/skin/icons/check.svg"); +} + +button { + background-color: transparent; + color: inherit; + background-position: 8px center; + background-repeat: no-repeat; + background-size: 16px; + border: none; + position: relative; + display: block; + font: inherit; + padding: 4px 8px; + padding-inline-start: 32px; + text-align: start; + width: 100%; +} + +button:dir(rtl), +button:-moz-locale-dir(rtl) { + background-position-x: right 8px; +} + +:host([badged]) button::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + background-color: var(--in-content-accent-color); + position: absolute; + top: 4px; + inset-inline-start: 24px; +} + +button:enabled:hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +button:enabled:hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +button:focus-visible { + outline-offset: var(--in-content-focus-outline-inset); +} + +button:disabled { + opacity: 0.4; +} + +.submenu-container { + display: flex; + flex-direction: row; +} + +.submenu-icon { + display: inline-block; + background-image: url("chrome://global/skin/icons/arrow-right.svg"); + background-position: center center; + background-repeat: no-repeat; + fill: currentColor; + width: var(--size-item-small); + height: var(--size-item-small); + flex: 1 1 auto; + + &:dir(rtl) { + background-image: url("chrome://global/skin/icons/arrow-left.svg"); + } +} + +.submenu-label { + flex: 90% 1 0; +} diff --git a/toolkit/content/widgets/panel-list/panel-list.css b/toolkit/content/widgets/panel-list/panel-list.css new file mode 100644 index 0000000000..4358fc0cf8 --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-list.css @@ -0,0 +1,59 @@ +/* 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/. */ + +:host([showing]) { + visibility: hidden; +} + +:host(:not([open])) { + display: none; +} + +:host { + position: absolute; + font: menu; + background-color: var(--in-content-box-background); + border-radius: 4px; + padding: 6px 0; + margin-bottom: 16px; + box-shadow: var(--shadow-30); + min-width: 12em; + z-index: var(--z-index-popup, 10); + white-space: nowrap; + cursor: default; + overflow-y: auto; + box-sizing: border-box; +} + +:host(:not([slot=submenu])) { + max-height: 100%; +} + +:host([stay-open]) { + position: initial; + display: inline-block; +} + +:host([inxulpanel]) { + position: static; + margin: 0; +} + +:host(:not([inxulpanel])) { + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); +} + +.list { + margin: 0; + padding: 0; +} + +::slotted(hr:not([hidden])) { + display: block !important; + height: 1px !important; + background: var(--in-content-box-border-color) !important; + padding: 0 !important; + margin: 6px 0 !important; + border: none !important; +} diff --git a/toolkit/content/widgets/panel-list/panel-list.js b/toolkit/content/widgets/panel-list/panel-list.js new file mode 100644 index 0000000000..1cc1f865c3 --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-list.js @@ -0,0 +1,836 @@ +/* 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"; + +{ + class PanelList extends HTMLElement { + static get observedAttributes() { + return ["open"]; + } + + static get fragment() { + if (!this._template) { + let parser = new DOMParser(); + let cssPath = "chrome://global/content/elements/panel-list.css"; + let doc = parser.parseFromString( + ` + <template> + <link rel="stylesheet" href=${cssPath}> + <div class="arrow top" role="presentation"></div> + <div class="list" role="presentation"> + <slot></slot> + </div> + <div class="arrow bottom" role="presentation"></div> + </template> + `, + "text/html" + ); + this._template = document.importNode( + doc.querySelector("template"), + true + ); + } + return this._template.content.cloneNode(true); + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(this.constructor.fragment); + } + + connectedCallback() { + this.setAttribute("role", "menu"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name == "open" && newVal != oldVal) { + if (this.open) { + this.onShow(); + } else { + this.onHide(); + } + } + } + + get open() { + return this.hasAttribute("open"); + } + + set open(val) { + this.toggleAttribute("open", val); + } + + get stayOpen() { + return this.hasAttribute("stay-open"); + } + + set stayOpen(val) { + this.toggleAttribute("stay-open", val); + } + + getTargetForEvent(event) { + if (!event) { + return null; + } + if (event._savedComposedTarget) { + return event._savedComposedTarget; + } + if (event.composed) { + event._savedComposedTarget = + event.composedTarget || event.composedPath()[0]; + } + return event._savedComposedTarget || event.target; + } + + show(triggeringEvent, target) { + this.triggeringEvent = triggeringEvent; + this.lastAnchorNode = + target || this.getTargetForEvent(this.triggeringEvent); + + this.wasOpenedByKeyboard = + triggeringEvent && + (triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD || + triggeringEvent.inputSource == MouseEvent.MOZ_SOURCE_UNKNOWN || + triggeringEvent.code == "ArrowRight" || + triggeringEvent.code == "ArrowLeft"); + this.open = true; + + if (this.parentIsXULPanel()) { + this.toggleAttribute("inxulpanel", true); + let panel = this.parentElement; + panel.hidden = false; + // Bug 1842070 - There appears to be a race here where panel-lists + // embedded in XUL panels won't appear during the first call to show() + // without waiting for a mix of rAF and another tick of the event + // loop. + requestAnimationFrame(() => { + setTimeout(() => { + panel.openPopup( + this.lastAnchorNode, + "after_start", + 0, + 0, + false, + false, + this.triggeringEvent + ); + }, 0); + }); + } else { + this.toggleAttribute("inxulpanel", false); + } + } + + hide(triggeringEvent, { force = false } = {}, eventTarget) { + // It's possible this is being used in an unprivileged context, in which + // case it won't have access to Services / Services will be undeclared. + const autohideDisabled = this.hasServices() + ? Services.prefs.getBoolPref("ui.popup.disable_autohide", false) + : false; + + if (autohideDisabled && !force) { + // Don't hide if this wasn't "forced" (using escape or click in menu). + return; + } + let openingEvent = this.triggeringEvent; + this.triggeringEvent = triggeringEvent; + this.open = false; + + if (this.parentIsXULPanel()) { + // It's possible that we're being programattically hidden, in which + // case, we need to hide the XUL panel we're embedded in. If, however, + // we're being hidden because the XUL panel is being hidden, calling + // hidePopup again on it is a no-op. + let panel = this.parentElement; + panel.hidePopup(); + } + + let target = eventTarget || this.getTargetForEvent(openingEvent); + // Refocus the button that opened the menu if we have one. + if (target && this.wasOpenedByKeyboard) { + target.focus(); + } + } + + toggle(triggeringEvent, target = null) { + if (this.open) { + this.hide(triggeringEvent, { force: true }, target); + } else { + this.show(triggeringEvent, target); + } + } + + hasServices() { + // Safely check for Services without throwing a ReferenceError. + return typeof Services !== "undefined"; + } + + isDocumentRTL() { + if (this.hasServices()) { + return Services.locale.isAppLocaleRTL; + } + return document.dir === "rtl"; + } + + parentIsXULPanel() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return ( + this.parentElement?.namespaceURI == XUL_NS && + this.parentElement?.localName == "panel" + ); + } + + async setAlign() { + const hostElement = this.parentElement || this.getRootNode().host; + if (!hostElement) { + // This could get called before we're added to the DOM. + // Nothing to do in that case. + return; + } + + // Set the showing attribute to hide the panel until its alignment is set. + this.setAttribute("showing", "true"); + // Tell the host element to hide any overflow in case the panel extends off + // the page before the alignment is set. + hostElement.style.overflow = "hidden"; + + // Wait for a layout flush, then find the bounds. + let { + anchorBottom, // distance from the bottom of the anchor el to top of viewport. + anchorLeft, + anchorTop, + anchorWidth, + panelHeight, + panelWidth, + winHeight, + winScrollY, + winScrollX, + clientWidth, + } = await new Promise(resolve => { + this.style.left = 0; + this.style.top = 0; + + requestAnimationFrame(() => + setTimeout(() => { + let target = this.getTargetForEvent(this.triggeringEvent); + let anchorElement = target || hostElement; + // It's possible this is being used in a context where windowUtils is + // not available. In that case, fallback to using the element. + let getBounds = el => + window.windowUtils + ? window.windowUtils.getBoundsWithoutFlushing(el) + : el.getBoundingClientRect(); + // Use y since top is reserved. + let anchorBounds = getBounds(anchorElement); + let panelBounds = getBounds(this); + let clientWidth = document.scrollingElement.clientWidth; + + resolve({ + anchorBottom: anchorBounds.bottom, + anchorHeight: anchorBounds.height, + anchorLeft: anchorBounds.left, + anchorTop: anchorBounds.top, + anchorWidth: anchorBounds.width, + panelHeight: panelBounds.height, + panelWidth: panelBounds.width, + winHeight: innerHeight, + winScrollX: scrollX, + winScrollY: scrollY, + clientWidth, + }); + }, 0) + ); + }); + + // If we're embedded in a XUL panel, let it handle alignment. + if (!this.parentIsXULPanel()) { + // Calculate the left/right alignment. + let align; + let leftOffset; + let leftAlignX = anchorLeft; + let rightAlignX = anchorLeft + anchorWidth - panelWidth; + + if (this.isDocumentRTL()) { + // Prefer aligning on the right. + align = rightAlignX < 0 ? "left" : "right"; + } else { + // Prefer aligning on the left. + align = leftAlignX + panelWidth > clientWidth ? "right" : "left"; + } + leftOffset = align === "left" ? leftAlignX : rightAlignX; + + let bottomSpaceY = winHeight - anchorBottom; + + let valign; + let topOffset; + const VIEWPORT_PANEL_MIN_MARGIN = 10; // 10px ensures that the panel is not flush with the viewport. + + // Only want to valign top when there's more space between the bottom of the anchor element and the top of the viewport. + // If there's more space between the bottom of the anchor element and the bottom of the viewport, we valign bottom. + if ( + anchorBottom > bottomSpaceY && + anchorBottom + panelHeight > winHeight + ) { + // Never want to have a negative value for topOffset, so ensure it's at least 10px. + topOffset = Math.max( + anchorTop - panelHeight, + VIEWPORT_PANEL_MIN_MARGIN + ); + // Provide a max-height for larger elements which will provide scrolling as needed. + this.style.maxHeight = `${anchorTop + VIEWPORT_PANEL_MIN_MARGIN}px`; + valign = "top"; + } else { + topOffset = anchorBottom; + this.style.maxHeight = `${ + bottomSpaceY - VIEWPORT_PANEL_MIN_MARGIN + }px`; + valign = "bottom"; + } + + // Set the alignments and show the panel. + this.setAttribute("align", align); + this.setAttribute("valign", valign); + hostElement.style.overflow = ""; + + this.style.left = `${leftOffset + winScrollX}px`; + this.style.top = `${topOffset + winScrollY}px`; + } + + this.style.minWidth = this.hasAttribute("min-width-from-anchor") + ? `${anchorWidth}px` + : ""; + + this.removeAttribute("showing"); + } + + addHideListeners() { + if (this.hasAttribute("stay-open") && !this.lastAnchorNode.hasSubmenu) { + // This is intended for inspection in Storybook. + return; + } + // Hide when a panel-item is clicked in the list. + this.addEventListener("click", this); + // Allows submenus to stopPropagation when focus is already in the menu + this.addEventListener("keydown", this); + // We need Escape/Tab/ArrowDown to work when opened with the mouse. + document.addEventListener("keydown", this); + // Hide when a click is initiated outside the panel. + document.addEventListener("mousedown", this); + // Hide if focus changes and the panel isn't in focus. + document.addEventListener("focusin", this); + // Reset or focus tracking, we treat the first focusin differently. + this.focusHasChanged = false; + // Hide on resize, scroll or losing window focus. + window.addEventListener("resize", this); + window.addEventListener("scroll", this, { capture: true }); + window.addEventListener("blur", this); + if (this.parentIsXULPanel()) { + this.parentElement.addEventListener("popuphidden", this); + } + } + + removeHideListeners() { + this.removeEventListener("click", this); + this.removeEventListener("keydown", this); + document.removeEventListener("keydown", this); + document.removeEventListener("mousedown", this); + document.removeEventListener("focusin", this); + window.removeEventListener("resize", this); + window.removeEventListener("scroll", this, { capture: true }); + window.removeEventListener("blur", this); + if (this.parentIsXULPanel()) { + this.parentElement.removeEventListener("popuphidden", this); + } + } + + handleEvent(e) { + // Ignore the event if it caused the panel to open. + if (e == this.triggeringEvent) { + return; + } + + let target = this.getTargetForEvent(e); + let inPanelList = e.composed + ? e.composedPath().some(el => el == this) + : e.target.closest && e.target.closest("panel-list") == this; + + switch (e.type) { + case "resize": + case "scroll": + if (inPanelList) { + break; + } + // Intentional fall-through + case "blur": + case "popuphidden": + this.hide(); + break; + case "click": + if (inPanelList) { + this.hide(undefined, { force: true }); + } else { + // Avoid falling through to the default click handler of the parent. + e.stopPropagation(); + } + break; + case "mousedown": + // Close if there's a click started outside the panel. + if (!inPanelList) { + this.hide(); + } + break; + case "keydown": + if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Tab") { + // Ignore tabbing with a modifer other than shift. + if (e.key === "Tab" && (e.altKey || e.ctrlKey || e.metaKey)) { + return; + } + + // Don't scroll the page or let the regular tab order take effect. + e.preventDefault(); + + // Prevents the host panel list from responding to these events while + // the submenu is active. + e.stopPropagation(); + + // Keep moving to the next/previous element sibling until we find a + // panel-item that isn't hidden. + let moveForward = + e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey); + + let nextItem = moveForward + ? this.focusWalker.nextNode() + : this.focusWalker.previousNode(); + + // If the next item wasn't found, try looping to the top/bottom. + if (!nextItem) { + this.focusWalker.currentNode = this; + if (moveForward) { + nextItem = this.focusWalker.firstChild(); + } else { + nextItem = this.focusWalker.lastChild(); + } + } + break; + } else if (e.key === "Escape") { + this.hide(undefined, { force: true }); + } else if (!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) { + // Check if any of the children have an accesskey for this letter. + let item = this.querySelector( + `[accesskey="${e.key.toLowerCase()}"], + [accesskey="${e.key.toUpperCase()}"]` + ); + if (item) { + item.click(); + } + } + break; + case "focusin": + if ( + this.triggeringEvent && + target == this.getTargetForEvent(this.triggeringEvent) && + !this.focusHasChanged + ) { + // There will be a focusin after the mousedown that opens the panel + // using the mouse. Ignore the first focusin event if it's on the + // triggering target. + this.focusHasChanged = true; + } else if (!target || !inPanelList) { + // If the target isn't in the panel, hide. This will close when focus + // moves out of the panel. + this.hide(); + } else { + // Just record that there was a focusin event. + this.focusHasChanged = true; + } + break; + } + } + + /** + * A TreeWalker that can be used to focus elements. The returned element will + * be the element that has gained focus based on the requested movement + * through the tree. + * + * Example: + * + * this.focusWalker.currentNode = this; + * // Focus and get the first focusable child. + * let focused = this.focusWalker.nextNode(); + * // Focus the second focusable child. + * this.focusWalker.nextNode(); + */ + get focusWalker() { + if (!this._focusWalker) { + this._focusWalker = document.createTreeWalker( + this, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => { + // No need to look at hidden nodes. + if (node.hidden) { + return NodeFilter.FILTER_REJECT; + } + + // Focus the node, if it worked then this is the node we want. + node.focus(); + if (node === node.getRootNode().activeElement) { + return NodeFilter.FILTER_ACCEPT; + } + + // Continue into child nodes if the parent couldn't be focused. + return NodeFilter.FILTER_SKIP; + }, + } + ); + } + return this._focusWalker; + } + async setSubmenuAlign() { + const hostElement = + this.lastAnchorNode.parentElement || this.getRootNode().host; + // The showing attribute allows layout of the panel while remaining hidden + // from the user until alignment is set. + this.setAttribute("showing", "true"); + + // Wait for a layout flush, then find the bounds. + let { + anchorLeft, + anchorWidth, + anchorTop, + parentPanelTop, + panelWidth, + clientWidth, + } = await new Promise(resolve => { + requestAnimationFrame(() => { + // It's possible this is being used in a context where windowUtils is + // not available. In that case, fallback to using the element. + let getBounds = el => + window.windowUtils + ? window.windowUtils.getBoundsWithoutFlushing(el) + : el.getBoundingClientRect(); + // submenu item in the parent panel list + let anchorBounds = getBounds(this.lastAnchorNode); + let parentPanelBounds = getBounds(hostElement); + let panelBounds = getBounds(this); + let clientWidth = document.scrollingElement.clientWidth; + + resolve({ + anchorLeft: anchorBounds.left, + anchorWidth: anchorBounds.width, + anchorTop: anchorBounds.top, + parentPanelTop: parentPanelBounds.top, + panelWidth: panelBounds.width, + clientWidth, + }); + }); + }); + + let align = hostElement.getAttribute("align"); + + // we use document.scrollingElement.clientWidth to exclude the width + // of vertical scrollbars, because its inclusion can cause the submenu + // to open to the wrong side and be overlapped by the scrollbar. + if ( + align == "left" && + anchorLeft + anchorWidth + panelWidth < clientWidth + ) { + this.style.left = `${anchorWidth}px`; + this.style.right = ""; + } else { + this.style.right = `${anchorWidth}px`; + this.style.left = ""; + } + + let topOffset = + anchorTop - + parentPanelTop - + (parseFloat(window.getComputedStyle(this)?.paddingTop) || 0); + this.style.top = `${topOffset}px`; + + this.removeAttribute("showing"); + } + + async onShow() { + this.sendEvent("showing"); + this.addHideListeners(); + + if (this.lastAnchorNode?.hasSubmenu) { + await this.setSubmenuAlign(); + } else { + await this.setAlign(); + } + + // Always reset this regardless of how the panel list is opened + // so the first child will be focusable. + this.focusWalker.currentNode = this; + + // Wait until the next paint for the alignment to be set and panel to be + // visible. + requestAnimationFrame(() => { + if (this.wasOpenedByKeyboard) { + // Focus the first focusable panel-item if opened by keyboard. + this.focusWalker.nextNode(); + } + + this.lastAnchorNode?.setAttribute("aria-expanded", "true"); + + this.sendEvent("shown"); + }); + } + + onHide() { + requestAnimationFrame(() => { + this.sendEvent("hidden"); + this.lastAnchorNode?.setAttribute("aria-expanded", "false"); + }); + this.removeHideListeners(); + } + + sendEvent(name, detail) { + this.dispatchEvent( + new CustomEvent(name, { detail, bubbles: true, composed: true }) + ); + } + } + customElements.define("panel-list", PanelList); + + class PanelItem extends HTMLElement { + #initialized = false; + #defaultSlot; + + static get observedAttributes() { + return ["accesskey"]; + } + + constructor() { + super(); + this.attachShadow({ mode: "open" }); + + let style = document.createElement("link"); + style.rel = "stylesheet"; + style.href = "chrome://global/content/elements/panel-item.css"; + + this.button = document.createElement("button"); + this.button.setAttribute("role", "menuitem"); + this.button.setAttribute("part", "button"); + // Use a XUL label element if possible to show the accesskey. + this.label = document.createXULElement + ? document.createXULElement("label") + : document.createElement("span"); + + this.button.appendChild(this.label); + + let supportLinkSlot = document.createElement("slot"); + supportLinkSlot.name = "support-link"; + + this.#defaultSlot = document.createElement("slot"); + this.#defaultSlot.style.display = "none"; + + if (this.hasSubmenu) { + this.icon = document.createElement("div"); + this.icon.setAttribute("class", "submenu-icon"); + this.label.setAttribute("class", "submenu-label"); + + this.button.setAttribute("class", "submenu-container"); + this.button.appendChild(this.icon); + + this.submenuSlot = document.createElement("slot"); + this.submenuSlot.name = "submenu"; + + this.shadowRoot.append( + style, + this.button, + this.#defaultSlot, + this.submenuSlot + ); + } else { + this.shadowRoot.append( + style, + this.button, + supportLinkSlot, + this.#defaultSlot + ); + } + } + + connectedCallback() { + if (!this._l10nRootConnected && document.l10n) { + document.l10n.connectRoot(this.shadowRoot); + this._l10nRootConnected = true; + } + + if (!this.#initialized) { + this.#initialized = true; + // When click listeners are added to the panel-item it creates a node in + // the a11y tree for this element. This breaks the association between the + // menu and the button[role="menuitem"] in this shadow DOM and causes + // announcement issues with screen readers. (bug 995064) + this.setAttribute("role", "presentation"); + + this.#setLabelContents(); + + // When our content changes, move the text into the label. It doesn't work + // with a <slot>, unfortunately. + new MutationObserver(() => this.#setLabelContents()).observe(this, { + characterData: true, + childList: true, + subtree: true, + }); + + if (this.hasSubmenu) { + this.setSubmenuContents(); + } + } + + this.panel = + this.getRootNode()?.host?.closest("panel-list") || + this.closest("panel-list"); + + if (this.panel) { + this.panel.addEventListener("hidden", this); + this.panel.addEventListener("shown", this); + } + if (this.hasSubmenu) { + this.addEventListener("mouseenter", this); + this.addEventListener("mouseleave", this); + this.addEventListener("keydown", this); + } + } + + disconnectedCallback() { + if (this._l10nRootConnected) { + document.l10n.disconnectRoot(this.shadowRoot); + this._l10nRootConnected = false; + } + + if (this.panel) { + this.panel.removeEventListener("hidden", this); + this.panel.removeEventListener("shown", this); + this.panel = null; + } + + if (this.hasSubmenu) { + this.removeEventListener("mouseenter", this); + this.removeEventListener("mouseleave", this); + this.removeEventListener("keydown", this); + } + } + + get hasSubmenu() { + return this.hasAttribute("submenu"); + } + + attributeChangedCallback(name, oldVal, newVal) { + if (name === "accesskey") { + // Bug 1037709 - Accesskey doesn't work in shadow DOM. + // Ideally we'd have the accesskey set in shadow DOM, and on + // attributeChangedCallback we'd just update the shadow DOM accesskey. + + // Skip this change event if we caused it. + if (this._modifyingAccessKey) { + this._modifyingAccessKey = false; + return; + } + + this.label.accessKey = newVal || ""; + + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + if (!this.panel || !this.panel.open) { + // When the panel isn't open, just store the key for later. + this._accessKey = newVal || null; + this._modifyingAccessKey = true; + this.accessKey = ""; + } else { + this._accessKey = null; + } + } + } + + #setLabelContents() { + this.label.textContent = this.#defaultSlot + .assignedNodes() + .map(node => node.textContent) + .join(""); + } + + setSubmenuContents() { + this.submenuPanel = this.submenuSlot.assignedNodes()[0]; + this.shadowRoot.append(this.submenuPanel); + } + + get disabled() { + return this.button.hasAttribute("disabled"); + } + + set disabled(val) { + this.button.toggleAttribute("disabled", val); + } + + get checked() { + return this.hasAttribute("checked"); + } + + set checked(val) { + this.toggleAttribute("checked", val); + } + + focus() { + this.button.focus(); + } + + setArrowKeyRTL() { + let arrowOpenKey = "ArrowRight"; + let arrowCloseKey = "ArrowLeft"; + + if (this.submenuPanel.isDocumentRTL()) { + arrowOpenKey = "ArrowLeft"; + arrowCloseKey = "ArrowRight"; + } + return [arrowOpenKey, arrowCloseKey]; + } + + handleEvent(e) { + // Bug 1588156 - Accesskey is not ignored for hidden non-input elements. + // Since the accesskey won't be ignored, we need to remove it ourselves + // when the panel is closed, and move it back when it opens. + switch (e.type) { + case "shown": + if (this._accessKey) { + this.accessKey = this._accessKey; + this._accessKey = null; + } + break; + case "hidden": + if (this.accessKey) { + this._accessKey = this.accessKey; + this._modifyingAccessKey = true; + this.accessKey = ""; + } + break; + case "mouseenter": + case "mouseleave": + this.submenuPanel.toggle(e); + break; + case "keydown": + let [arrowOpenKey, arrowCloseKey] = this.setArrowKeyRTL(); + if (e.key === arrowOpenKey) { + this.submenuPanel.show(e, e.target); + e.stopPropagation(); + } + if (e.key === arrowCloseKey) { + this.submenuPanel.hide(e, { force: true }, e.target); + e.stopPropagation(); + } + break; + } + } + } + customElements.define("panel-item", PanelItem); +} diff --git a/toolkit/content/widgets/panel-list/panel-list.stories.mjs b/toolkit/content/widgets/panel-list/panel-list.stories.mjs new file mode 100644 index 0000000000..9c5a4cbe1f --- /dev/null +++ b/toolkit/content/widgets/panel-list/panel-list.stories.mjs @@ -0,0 +1,147 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unassigned-import +import "./panel-list.js"; +import { html, ifDefined } from "../vendor/lit.all.mjs"; + +export default { + title: "UI Widgets/Panel List", + component: "panel-list", + parameters: { + status: "in-development", + actions: { + handles: ["showing", "shown", "hidden", "click"], + }, + fluent: ` +panel-list-item-one = Item One +panel-list-item-two = Item Two (accesskey w) +panel-list-item-three = Item Three +panel-list-checked = Checked +panel-list-badged = Badged, look at me +panel-list-passwords = Passwords +panel-list-settings = Settings + `, + }, +}; + +function openMenu(event) { + if ( + event.type == "mousedown" || + event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD || + !event.detail + ) { + event.target.getRootNode().querySelector("panel-list").toggle(event); + } +} + +const Template = ({ isOpen, items, wideAnchor }) => + html` + <style> + panel-item[icon="passwords"]::part(button) { + background-image: url("chrome://browser/skin/login.svg"); + } + panel-item[icon="settings"]::part(button) { + background-image: url("chrome://global/skin/icons/settings.svg"); + } + button { + position: absolute; + background-image: url("chrome://global/skin/icons/more.svg"); + } + button[wide] { + width: 400px !important; + } + .end { + inset-inline-end: 30px; + } + + .bottom { + inset-block-end: 30px; + } + </style> + ${isOpen + ? "" + : html` + <button + class="ghost-button icon-button" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + <button + class="ghost-button icon-button end" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + <button + class="ghost-button icon-button bottom" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + <button + class="ghost-button icon-button bottom end" + @click=${openMenu} + @mousedown=${openMenu} + ?wide="${wideAnchor}" + ></button> + `} + <panel-list + ?stay-open=${isOpen} + ?open=${isOpen} + ?min-width-from-anchor=${wideAnchor} + > + ${items.map(i => + i == "<hr>" + ? html` <hr /> ` + : html` + <panel-item + icon=${i.icon ?? ""} + ?checked=${i.checked} + ?badged=${i.badged} + accesskey=${ifDefined(i.accesskey)} + data-l10n-id=${i.l10nId ?? i} + ></panel-item> + ` + )} + </panel-list> + `; + +export const Simple = Template.bind({}); +Simple.args = { + isOpen: false, + wideAnchor: false, + items: [ + "panel-list-item-one", + { l10nId: "panel-list-item-two", accesskey: "w" }, + "panel-list-item-three", + "<hr>", + { l10nId: "panel-list-checked", checked: true }, + { l10nId: "panel-list-badged", badged: true, icon: "settings" }, + ], +}; + +export const Icons = Template.bind({}); +Icons.args = { + isOpen: false, + wideAnchor: false, + items: [ + { l10nId: "panel-list-passwords", icon: "passwords" }, + { l10nId: "panel-list-settings", icon: "settings" }, + ], +}; + +export const Open = Template.bind({}); +Open.args = { + ...Simple.args, + wideAnchor: false, + isOpen: true, +}; + +export const Wide = Template.bind({}); +Wide.args = { + ...Simple.args, + wideAnchor: true, +}; diff --git a/toolkit/content/widgets/panel.js b/toolkit/content/widgets/panel.js new file mode 100644 index 0000000000..a301ecb1f2 --- /dev/null +++ b/toolkit/content/widgets/panel.js @@ -0,0 +1,293 @@ +/* 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 MozPanel extends MozElements.MozElementMixin(XULPopupElement) { + static get markup() { + return `<html:slot part="content" style="display: none !important"/>`; + } + constructor() { + super(); + + this._prevFocus = 0; + this._fadeTimer = null; + + this.attachShadow({ mode: "open" }); + + this.addEventListener("popupshowing", this); + this.addEventListener("popupshown", this); + this.addEventListener("popuphiding", this); + this.addEventListener("popuphidden", this); + this.addEventListener("popuppositioned", this); + } + + connectedCallback() { + // Create shadow DOM lazily if a panel is hidden. It helps to reduce + // cycles on startup. + if (!this.hidden) { + this.ensureInitialized(); + } + + if (this.isArrowPanel) { + if (!this.hasAttribute("flip")) { + this.setAttribute("flip", "both"); + } + if (!this.hasAttribute("side")) { + this.setAttribute("side", "top"); + } + if (!this.hasAttribute("position")) { + this.setAttribute("position", "bottomleft topleft"); + } + if (!this.hasAttribute("consumeoutsideclicks")) { + this.setAttribute("consumeoutsideclicks", "false"); + } + } + } + + ensureInitialized() { + // As an optimization, we don't slot contents if the panel is [hidden] in + // connectedCallback this means we can avoid running this code at startup + // and only need to do it when a panel is about to be shown. We then + // override the `hidden` setter and `removeAttribute` and call this + // function if the node is about to be shown. + if (this.shadowRoot.firstChild) { + return; + } + + this.shadowRoot.appendChild(this.constructor.fragment); + if (this.hasAttribute("neverhidden")) { + this.panelContent.style.display = ""; + } + } + + get panelContent() { + return this.shadowRoot.querySelector("[part=content]"); + } + + get hidden() { + return super.hidden; + } + + set hidden(v) { + if (!v) { + this.ensureInitialized(); + } + super.hidden = v; + } + + removeAttribute(name) { + if (name == "hidden") { + this.ensureInitialized(); + } + super.removeAttribute(name); + } + + get isArrowPanel() { + return this.getAttribute("type") == "arrow"; + } + + get noOpenOnAnchor() { + return this.hasAttribute("no-open-on-anchor"); + } + + _setSideAttribute(event) { + if (!this.isArrowPanel || !event.isAnchored) { + return; + } + + let position = event.alignmentPosition; + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + // The assigned side stays the same regardless of direction. + let isRTL = window.getComputedStyle(this).direction == "rtl"; + + if (position.indexOf("start_") == 0) { + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if ( + position.indexOf("before_") == 0 || + position.indexOf("after_") == 0 + ) { + if (position.indexOf("before_") == 0) { + this.setAttribute("side", "bottom"); + } else { + this.setAttribute("side", "top"); + } + } + + // This method isn't implemented by panel.js, but it can be added to + // individual instances that need to show an arrow. + this.setArrowPosition?.(event); + } + + on_popupshowing(event) { + if (event.target == this) { + this.panelContent.style.display = ""; + } + if (this.isArrowPanel && event.target == this) { + if (this.anchorNode && !this.noOpenOnAnchor) { + let anchorRoot = + this.anchorNode.closest("toolbarbutton, .anchor-root") || + this.anchorNode; + anchorRoot.setAttribute("open", "true"); + } + + if (this.getAttribute("animate") != "false") { + this.setAttribute("animate", "open"); + // the animating attribute prevents user interaction during transition + // it is removed when popupshown fires + this.setAttribute("animating", "true"); + } + + // set fading + var fade = this.getAttribute("fade"); + var fadeDelay = 0; + if (fade == "fast") { + fadeDelay = 1; + } else if (fade == "slow") { + fadeDelay = 4000; + } + + if (fadeDelay != 0) { + this._fadeTimer = setTimeout( + () => this.hidePopup(true), + fadeDelay, + this + ); + } + } + + // Capture the previous focus before has a chance to get set inside the panel + try { + this._prevFocus = Cu.getWeakReference( + document.commandDispatcher.focusedElement + ); + if (!this._prevFocus.get()) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + } + } catch (ex) { + this._prevFocus = Cu.getWeakReference(document.activeElement); + } + } + + on_popupshown(event) { + if (this.isArrowPanel && event.target == this) { + this.removeAttribute("animating"); + this.setAttribute("panelopen", "true"); + } + + // Fire event for accessibility APIs + let alertEvent = document.createEvent("Events"); + alertEvent.initEvent("AlertActive", true, true); + this.dispatchEvent(alertEvent); + } + + on_popuphiding(event) { + if (this.isArrowPanel && event.target == this) { + let animate = this.getAttribute("animate") != "false"; + + if (this._fadeTimer) { + clearTimeout(this._fadeTimer); + if (animate) { + this.setAttribute("animate", "fade"); + } + } else if (animate) { + this.setAttribute("animate", "cancel"); + } + + if (this.anchorNode && !this.noOpenOnAnchor) { + let anchorRoot = + this.anchorNode.closest("toolbarbutton, .anchor-root") || + this.anchorNode; + anchorRoot.removeAttribute("open"); + } + } + + try { + this._currentFocus = document.commandDispatcher.focusedElement; + } catch (e) { + this._currentFocus = document.activeElement; + } + } + + on_popuphidden(event) { + if (event.target == this && !this.hasAttribute("neverhidden")) { + this.panelContent.style.setProperty("display", "none", "important"); + } + if (this.isArrowPanel && event.target == this) { + this.removeAttribute("panelopen"); + if (this.getAttribute("animate") != "false") { + this.removeAttribute("animate"); + } + } + + function doFocus() { + // Focus was set on an element inside this panel, + // so we need to move it back to where it was previously. + // Elements can use refocused-by-panel to change their focus behaviour + // when re-focused by a panel hiding. + prevFocus.setAttribute("refocused-by-panel", true); + try { + let fm = Services.focus; + fm.setFocus(prevFocus, fm.FLAG_NOSCROLL); + } catch (e) { + prevFocus.focus(); + } + prevFocus.removeAttribute("refocused-by-panel"); + } + var currentFocus = this._currentFocus; + var prevFocus = this._prevFocus ? this._prevFocus.get() : null; + this._currentFocus = null; + this._prevFocus = null; + + // Avoid changing focus if focus changed while we hide the popup + // (This can happen e.g. if the popup is hiding as a result of a + // click/keypress that focused something) + let nowFocus; + try { + nowFocus = document.commandDispatcher.focusedElement; + } catch (e) { + nowFocus = document.activeElement; + } + if (nowFocus && nowFocus != currentFocus) { + return; + } + + if (prevFocus && this.getAttribute("norestorefocus") != "true") { + // Try to restore focus + try { + if (document.commandDispatcher.focusedWindow != window) { + // Focus has already been set to a window outside of this panel + return; + } + } catch (ex) {} + + if (!currentFocus) { + doFocus(); + return; + } + while (currentFocus) { + if (currentFocus == this) { + doFocus(); + return; + } + currentFocus = currentFocus.parentNode; + } + } + } + + on_popuppositioned(event) { + if (event.target == this) { + this._setSideAttribute(event); + } + } + } + + customElements.define("panel", MozPanel); +} diff --git a/toolkit/content/widgets/popupnotification.js b/toolkit/content/widgets/popupnotification.js new file mode 100644 index 0000000000..835151496c --- /dev/null +++ b/toolkit/content/widgets/popupnotification.js @@ -0,0 +1,169 @@ +/* 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 MozPopupNotification extends MozXULElement { + static get inheritedAttributes() { + return { + ".popup-notification-icon": "popupid,src=icon,class=iconclass,hasicon", + ".popup-notification-body": "popupid", + ".popup-notification-origin": "value=origin,tooltiptext=origin", + ".popup-notification-description": "popupid,id=descriptionid", + ".popup-notification-description > span:first-of-type": + "text=label,popupid", + ".popup-notification-description > b:first-of-type": + "text=name,popupid", + ".popup-notification-description > span:nth-of-type(2)": + "text=endlabel,popupid", + ".popup-notification-description > b:last-of-type": + "text=secondname,popupid", + ".popup-notification-description > span:last-of-type": + "text=secondendlabel,popupid", + ".popup-notification-hint-text": "text=hinttext", + ".popup-notification-closebutton": + "oncommand=closebuttoncommand,hidden=closebuttonhidden", + ".popup-notification-learnmore-link": + "onclick=learnmoreclick,href=learnmoreurl", + ".popup-notification-warning": "hidden=warninghidden,text=warninglabel", + ".popup-notification-secondary-button": + "oncommand=secondarybuttoncommand,label=secondarybuttonlabel,accesskey=secondarybuttonaccesskey,hidden=secondarybuttonhidden,dropmarkerhidden", + ".popup-notification-dropmarker": + "onpopupshown=dropmarkerpopupshown,hidden=dropmarkerhidden", + ".popup-notification-dropmarker > menupopup": "oncommand=menucommand", + ".popup-notification-primary-button": + "oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey,default=buttonhighlight,disabled=mainactiondisabled", + }; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this._hasSlotted) { + return; + } + + // If the label and/or accesskey for the primary button is set by + // inherited attributes, its data-l10n-id needs to be unset or + // DOM Localization will overwrite the values. + if (name === "buttonlabel" || name === "buttonaccesskey") { + this.button?.removeAttribute("data-l10n-id"); + } + + super.attributeChangedCallback(name, oldValue, newValue); + } + + show() { + this.slotContents(); + + if (this.checkboxState) { + this.checkbox.checked = this.checkboxState.checked; + this.checkbox.setAttribute("label", this.checkboxState.label); + this.checkbox.hidden = false; + } else { + this.checkbox.hidden = true; + // Reset checked state to avoid wrong using of previous value. + this.checkbox.checked = false; + } + + this.hidden = false; + } + + static get markup() { + return ` + <hbox class="popup-notification-header-container"></hbox> + <hbox align="start" class="popup-notification-body-container"> + <image class="popup-notification-icon"/> + <vbox pack="start" class="popup-notification-body"> + <hbox align="start"> + <vbox flex="1"> + <label class="popup-notification-origin header" crop="center"></label> + <!-- These need to be on the same line to avoid creating + whitespace between them (whitespace is added in the + localization file, if necessary). --> + <description class="popup-notification-description"><html:span></html:span><html:b></html:b><html:span></html:span><html:b></html:b><html:span></html:span></description> + <description class="popup-notification-hint-text"></description> + </vbox> + <toolbarbutton class="messageCloseButton close-icon popup-notification-closebutton tabbable" data-l10n-id="close-notification-message"></toolbarbutton> + </hbox> + <vbox class="popup-notification-bottom-content" align="start"> + <label class="popup-notification-learnmore-link" is="text-link" data-l10n-id="popup-notification-learn-more"></label> + <checkbox class="popup-notification-checkbox" oncommand="PopupNotifications._onCheckboxCommand(event)"/> + <description class="popup-notification-warning"/> + </vbox> + </vbox> + </hbox> + <hbox class="popup-notification-footer-container"></hbox> + <html:moz-button-group class="panel-footer"> + <button class="popup-notification-secondary-button footer-button"/> + <button type="menu" class="popup-notification-dropmarker footer-button" data-l10n-id="popup-notification-more-actions-button"> + <menupopup position="after_end" data-l10n-id="popup-notification-more-actions-button"> + </menupopup> + </button> + <button class="popup-notification-primary-button primary footer-button" data-l10n-id="popup-notification-default-button"/> + </html:moz-button-group> + `; + } + + slotContents() { + if (this._hasSlotted) { + return; + } + this._hasSlotted = true; + MozXULElement.insertFTLIfNeeded("toolkit/global/notification.ftl"); + MozXULElement.insertFTLIfNeeded("toolkit/global/popupnotification.ftl"); + this.appendChild(this.constructor.fragment); + + window.ensureCustomElements("moz-button-group"); + + this.button = this.querySelector(".popup-notification-primary-button"); + if ( + this.hasAttribute("buttonlabel") || + this.hasAttribute("buttonaccesskey") + ) { + this.button.removeAttribute("data-l10n-id"); + } + 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) { + this.querySelector(".popup-notification-bottom-content").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..482323acb9 --- /dev/null +++ b/toolkit/content/widgets/radio.js @@ -0,0 +1,567 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +(() => { + class MozRadiogroup extends MozElements.BaseControl { + constructor() { + super(); + + this.addEventListener("mousedown", event => { + if (this.disabled) { + event.preventDefault(); + } + }); + + /** + * keyboard navigation Here's how keyboard navigation works in radio groups on Windows: + * The group takes 'focus' + * The user is then free to navigate around inside the group + * using the arrow keys. Accessing previous or following radio buttons + * is done solely through the arrow keys and not the tab button. Tab + * takes you to the next widget in the tab order + */ + this.addEventListener("keypress", event => { + if (event.key != " " || event.originalTarget != this) { + return; + } + this.selectedItem = this.focusedItem; + this.selectedItem.doCommand(); + // Prevent page from scrolling on the space key. + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_UP || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(false); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_LEFT || + event.originalTarget != this + ) { + return; + } + // left arrow goes back when we are ltr, forward when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "rtl" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_DOWN || + event.originalTarget != this + ) { + return; + } + this.checkAdjacentElement(true); + event.stopPropagation(); + event.preventDefault(); + }); + + this.addEventListener("keypress", event => { + if ( + event.keyCode != KeyEvent.DOM_VK_RIGHT || + event.originalTarget != this + ) { + return; + } + // right arrow goes forward when we are ltr, back when we are rtl + this.checkAdjacentElement( + document.defaultView.getComputedStyle(this).direction == "ltr" + ); + event.stopPropagation(); + event.preventDefault(); + }); + + /** + * set a focused attribute on the selected item when the group + * receives focus so that we can style it as if it were focused even though + * it is not (Windows platform behaviour is for the group to receive focus, + * not the item + */ + this.addEventListener("focus", event => { + if (event.originalTarget != this) { + return; + } + this.setAttribute("focused", "true"); + if (this.focusedItem) { + return; + } + + var val = this.selectedItem; + if (!val || val.disabled || val.hidden || val.collapsed) { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + val = children[i]; + break; + } + } + } + this.focusedItem = val; + }); + + this.addEventListener("blur", event => { + if (event.originalTarget != this) { + return; + } + this.removeAttribute("focused"); + this.focusedItem = null; + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + // When this is called via `connectedCallback` there are two main variations: + // 1) The radiogroup and radio children are defined in markup. + // 2) We are appending a DocumentFragment + // In both cases, the <radiogroup> connectedCallback fires first. But in (2), + // the children <radio>s won't be upgraded yet, so r.control will be undefined. + // To avoid churn in this case where we would have to reinitialize the list as each + // child radio gets upgraded as a result of init(), ignore the resulting calls + // to radioAttached. + this.ignoreRadioChildConstruction = true; + this.init(); + this.ignoreRadioChildConstruction = false; + if (!this.value) { + this.selectedIndex = 0; + } + } + + init() { + this._radioChildren = null; + + if (this.getAttribute("disabled") == "true") { + this.disabled = true; + } + + var children = this._getRadioChildren(); + var length = children.length; + for (var i = 0; i < length; i++) { + if (children[i].getAttribute("selected") == "true") { + this.selectedIndex = i; + return; + } + } + + var value = this.value; + if (value) { + this.value = value; + } + } + + /** + * Called when a new <radio> gets added to an already connected radiogroup. + * This can happen due to DOM getting appended after the <radiogroup> is created. + * When this happens, reinitialize the UI if necessary to make sure the state is + * consistent. + * + * @param {DOMNode} child + * The <radio> element that got added + */ + radioAttached(child) { + if (this.ignoreRadioChildConstruction) { + return; + } + if (!this._radioChildren || !this._radioChildren.includes(child)) { + this.init(); + } + } + + /** + * Called when a new <radio> gets removed from a radio group. + * + * @param {DOMNode} child + * The <radio> element that got removed + */ + radioUnattached(child) { + // Just invalidate the cache, next time it's fetched it'll get rebuilt. + this._radioChildren = null; + } + + set value(val) { + this.setAttribute("value", val); + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; i++) { + if (String(children[i].value) == String(val)) { + this.selectedItem = children[i]; + break; + } + } + } + + get value() { + return this.getAttribute("value"); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + children[i].disabled = val; + } + } + + get disabled() { + if (this.getAttribute("disabled") == "true") { + return true; + } + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if ( + !children[i].hidden && + !children[i].collapsed && + !children[i].disabled + ) { + return false; + } + } + return true; + } + + get itemCount() { + return this._getRadioChildren().length; + } + + set selectedIndex(val) { + this.selectedItem = this._getRadioChildren()[val]; + } + + get selectedIndex() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return i; + } + } + return -1; + } + + set selectedItem(val) { + var focused = this.getAttribute("focused") == "true"; + var alreadySelected = false; + + if (val) { + alreadySelected = val.getAttribute("selected") == "true"; + val.setAttribute("focused", focused); + val.setAttribute("selected", "true"); + this.setAttribute("value", val.value); + } else { + this.removeAttribute("value"); + } + + // uncheck all other group nodes + var children = this._getRadioChildren(); + var previousItem = null; + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + if (children[i].getAttribute("selected") == "true") { + previousItem = children[i]; + } + + children[i].removeAttribute("selected"); + children[i].removeAttribute("focused"); + } + } + + var event = document.createEvent("Events"); + event.initEvent("select", false, true); + this.dispatchEvent(event); + + if (focused) { + if (alreadySelected) { + // Notify accessibility that this item got focus. + event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } else { + // Only report if actual change + if (val) { + // Accessibility will fire focus for this. + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + val.dispatchEvent(event); + } + + if (previousItem) { + event = document.createEvent("Events"); + event.initEvent("RadioStateChange", true, true); + previousItem.dispatchEvent(event); + } + } + } + } + + get selectedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].selected) { + return children[i]; + } + } + return null; + } + + set focusedItem(val) { + if (val) { + val.setAttribute("focused", "true"); + // Notify accessibility that this item got focus. + let event = document.createEvent("Events"); + event.initEvent("DOMMenuItemActive", true, true); + val.dispatchEvent(event); + } + + // unfocus all other group nodes + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i] != val) { + children[i].removeAttribute("focused"); + } + } + } + + get focusedItem() { + var children = this._getRadioChildren(); + for (var i = 0; i < children.length; ++i) { + if (children[i].getAttribute("focused") == "true") { + return children[i]; + } + } + return null; + } + + checkAdjacentElement(aNextFlag) { + var currentElement = this.focusedItem || this.selectedItem; + var i; + var children = this._getRadioChildren(); + for (i = 0; i < children.length; ++i) { + if (children[i] == currentElement) { + break; + } + } + var index = i; + + if (aNextFlag) { + do { + if (++i == children.length) { + i = 0; + } + if (i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } else { + do { + if (i == 0) { + i = children.length; + } + if (--i == index) { + break; + } + } while ( + children[i].hidden || + children[i].collapsed || + children[i].disabled + ); + // XXX check for display/visibility props too + + this.selectedItem = children[i]; + children[i].doCommand(); + } + } + + _getRadioChildren() { + if (this._radioChildren) { + return this._radioChildren; + } + + let radioChildren = []; + if (this.hasChildNodes()) { + for (let radio of this.querySelectorAll("radio")) { + customElements.upgrade(radio); + if (radio.control == this) { + radioChildren.push(radio); + } + } + } else { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + for (let radio of this.ownerDocument.getElementsByAttribute( + "group", + this.id + )) { + if (radio.namespaceURI == XUL_NS && radio.localName == "radio") { + customElements.upgrade(radio); + radioChildren.push(radio); + } + } + } + + return (this._radioChildren = radioChildren); + } + + getIndexOfItem(item) { + return this._getRadioChildren().indexOf(item); + } + + getItemAtIndex(index) { + var children = this._getRadioChildren(); + return index >= 0 && index < children.length ? children[index] : null; + } + + appendItem(label, value) { + var radio = document.createXULElement("radio"); + radio.setAttribute("label", label); + radio.setAttribute("value", value); + this.appendChild(radio); + return radio; + } + } + + MozXULElement.implementCustomInterface(MozRadiogroup, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULRadioGroupElement, + ]); + + customElements.define("radiogroup", MozRadiogroup); + + class MozRadio extends MozElements.BaseText { + static get markup() { + return ` + <image class="radio-check"></image> + <hbox class="radio-label-box" align="center" flex="1"> + <image class="radio-icon"></image> + <label class="radio-label" flex="1"></label> + </hbox> + `; + } + + static get inheritedAttributes() { + return { + ".radio-check": "disabled,selected", + ".radio-label": "text=label,accesskey,crop", + ".radio-icon": "src", + }; + } + + constructor() { + super(); + this.addEventListener("click", event => { + if (!this.disabled) { + this.control.selectedItem = this; + } + }); + + this.addEventListener("mousedown", event => { + if (!this.disabled) { + this.control.focusedItem = this; + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + if (!this.connectedOnce) { + this.connectedOnce = true; + // If the caller didn't provide custom content then append the default: + if (!this.firstElementChild) { + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + } + } + + var control = (this._control = this.control); + if (control) { + control.radioAttached(this); + } + } + + disconnectedCallback() { + if (this.control) { + this.control.radioUnattached(this); + } + this._control = null; + } + + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + get selected() { + return this.hasAttribute("selected"); + } + + get radioGroup() { + return this.control; + } + + get control() { + if (this._control) { + return this._control; + } + + var radiogroup = this.closest("radiogroup"); + if (radiogroup) { + return radiogroup; + } + + var group = this.getAttribute("group"); + if (!group) { + return null; + } + + var parent = this.ownerDocument.getElementById(group); + if (!parent || parent.localName != "radiogroup") { + parent = null; + } + return parent; + } + } + + MozXULElement.implementCustomInterface(MozRadio, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + customElements.define("radio", MozRadio); +})(); diff --git a/toolkit/content/widgets/richlistbox.js b/toolkit/content/widgets/richlistbox.js new file mode 100644 index 0000000000..904ef9ceec --- /dev/null +++ b/toolkit/content/widgets/richlistbox.js @@ -0,0 +1,1040 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + /** + * XUL:richlistbox element. + */ + MozElements.RichListBox = class RichListBox extends MozElements.BaseControl { + constructor() { + super(); + + this.selectedItems = new ChromeNodeList(); + this._currentIndex = null; + this._lastKeyTime = 0; + this._incrementalString = ""; + this._suppressOnSelect = false; + this._userSelecting = false; + this._selectTimeout = null; + this._currentItem = null; + this._selectionStart = null; + + this.addEventListener( + "keypress", + event => { + if (event.altKey || event.metaKey) { + return; + } + + switch (event.keyCode) { + case KeyEvent.DOM_VK_UP: + this._moveByOffsetFromUserEvent(-1, event); + break; + case KeyEvent.DOM_VK_DOWN: + this._moveByOffsetFromUserEvent(1, event); + break; + case KeyEvent.DOM_VK_HOME: + this._moveByOffsetFromUserEvent(-this.currentIndex, event); + break; + case KeyEvent.DOM_VK_END: + this._moveByOffsetFromUserEvent( + this.getRowCount() - this.currentIndex - 1, + event + ); + break; + case KeyEvent.DOM_VK_PAGE_UP: + this._moveByOffsetFromUserEvent(this.scrollOnePage(-1), event); + break; + case KeyEvent.DOM_VK_PAGE_DOWN: + this._moveByOffsetFromUserEvent(this.scrollOnePage(1), event); + break; + } + }, + { mozSystemGroup: true } + ); + + this.addEventListener("keypress", event => { + if (event.target != this) { + return; + } + + if ( + event.key == " " && + event.ctrlKey && + !event.shiftKey && + !event.altKey && + !event.metaKey && + this.currentItem && + this.selType == "multiple" + ) { + this.toggleItemSelection(this.currentItem); + } + + if (!event.charCode || event.altKey || event.ctrlKey || event.metaKey) { + return; + } + + if (event.timeStamp - this._lastKeyTime > 1000) { + this._incrementalString = ""; + } + + var key = String.fromCharCode(event.charCode).toLowerCase(); + this._incrementalString += key; + this._lastKeyTime = event.timeStamp; + + // If all letters in the incremental string are the same, just + // try to match the first one + var incrementalString = /^(.)\1+$/.test(this._incrementalString) + ? RegExp.$1 + : this._incrementalString; + var length = incrementalString.length; + + var rowCount = this.getRowCount(); + var l = this.selectedItems.length; + var start = l > 0 ? this.getIndexOfItem(this.selectedItems[l - 1]) : -1; + // start from the first element if none was selected or from the one + // following the selected one if it's a new or a repeated-letter search + if (start == -1 || length == 1) { + start++; + } + + for (var i = 0; i < rowCount; i++) { + var k = (start + i) % rowCount; + var listitem = this.getItemAtIndex(k); + if (!this._canUserSelect(listitem)) { + continue; + } + // allow richlistitems to specify the string being searched for + var searchText = + "searchLabel" in listitem + ? listitem.searchLabel + : listitem.getAttribute("label"); // (see also bug 250123) + searchText = searchText.substring(0, length).toLowerCase(); + if (searchText == incrementalString) { + this.ensureIndexIsVisible(k); + this.timedSelect(listitem, this._selectDelay); + break; + } + } + }); + + this.addEventListener("focus", event => { + if (this.getRowCount() > 0) { + if (this.currentIndex == -1) { + this.currentIndex = this.getIndexOfFirstVisibleRow(); + let currentItem = this.getItemAtIndex(this.currentIndex); + if (currentItem) { + this.selectItem(currentItem); + } + } else { + this._fireEvent(this.currentItem, "DOMMenuItemActive"); + } + } + this._lastKeyTime = 0; + }); + + this.addEventListener("click", event => { + // clicking into nothing should unselect multiple selections + if (event.originalTarget == this && this.selType == "multiple") { + this.clearSelection(); + this.currentItem = null; + } + }); + + this.addEventListener("MozSwipeGesture", event => { + // Only handle swipe gestures up and down + switch (event.direction) { + case event.DIRECTION_DOWN: + this.scrollTop = this.scrollHeight; + break; + case event.DIRECTION_UP: + this.scrollTop = 0; + break; + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("allowevents", "true"); + this._refreshSelection(); + } + + // nsIDOMXULSelectControlElement + set selectedItem(val) { + this.selectItem(val); + } + get selectedItem() { + return this.selectedItems.length ? this.selectedItems[0] : null; + } + + // nsIDOMXULSelectControlElement + set selectedIndex(val) { + if (val >= 0) { + // This is a micro-optimization so that a call to getIndexOfItem or + // getItemAtIndex caused by _fireOnSelect (especially for derived + // widgets) won't loop the children. + this._selecting = { + item: this.getItemAtIndex(val), + index: val, + }; + this.selectItem(this._selecting.item); + delete this._selecting; + } else { + this.clearSelection(); + this.currentItem = null; + } + } + get selectedIndex() { + if (this.selectedItems.length) { + return this.getIndexOfItem(this.selectedItems[0]); + } + return -1; + } + + // nsIDOMXULSelectControlElement + set value(val) { + var kids = this.getElementsByAttribute("value", val); + if (kids && kids.item(0)) { + this.selectItem(kids[0]); + } + } + get value() { + if (this.selectedItems.length) { + return this.selectedItem.value; + } + return null; + } + + // nsIDOMXULSelectControlElement + get itemCount() { + return this.itemChildren.length; + } + + // nsIDOMXULSelectControlElement + set selType(val) { + this.setAttribute("seltype", val); + } + get selType() { + return this.getAttribute("seltype"); + } + + // nsIDOMXULSelectControlElement + set currentItem(val) { + if (this._currentItem == val) { + return; + } + + if (this._currentItem) { + this._currentItem.current = false; + if (!val && !this.suppressMenuItemEvent) { + // An item is losing focus and there is no new item to focus. + // Notify a11y that there is no focused item. + this._fireEvent(this._currentItem, "DOMMenuItemInactive"); + } + } + this._currentItem = val; + + if (val) { + val.current = true; + if (!this.suppressMenuItemEvent) { + // Notify a11y that this item got focus. + this._fireEvent(val, "DOMMenuItemActive"); + } + } + } + get currentItem() { + return this._currentItem; + } + + // nsIDOMXULSelectControlElement + set currentIndex(val) { + if (val >= 0) { + this.currentItem = this.getItemAtIndex(val); + } else { + this.currentItem = null; + } + } + get currentIndex() { + return this.currentItem ? this.getIndexOfItem(this.currentItem) : -1; + } + + // nsIDOMXULSelectControlElement + get selectedCount() { + return this.selectedItems.length; + } + + get itemChildren() { + let children = Array.from(this.children).filter( + node => node.localName == "richlistitem" + ); + return children; + } + + set suppressOnSelect(val) { + this.setAttribute("suppressonselect", val); + } + get suppressOnSelect() { + return this.getAttribute("suppressonselect") == "true"; + } + + set _selectDelay(val) { + this.setAttribute("_selectDelay", val); + } + get _selectDelay() { + return this.getAttribute("_selectDelay") || 50; + } + + _fireOnSelect() { + // make sure not to modify last-selected when suppressing select events + // (otherwise we'll lose the selection when a template gets rebuilt) + if (this._suppressOnSelect || this.suppressOnSelect) { + return; + } + + // remember the current item and all selected items with IDs + var state = this.currentItem ? this.currentItem.id : ""; + if (this.selType == "multiple" && this.selectedCount) { + let getId = function getId(aItem) { + return aItem.id; + }; + state += + " " + [...this.selectedItems].filter(getId).map(getId).join(" "); + } + if (state) { + this.setAttribute("last-selected", state); + } else { + this.removeAttribute("last-selected"); + } + + // preserve the index just in case no IDs are available + if (this.currentIndex > -1) { + this._currentIndex = this.currentIndex + 1; + } + + var event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + + // always call this (allows a commandupdater without controller) + document.commandDispatcher.updateCommands("richlistbox-select"); + } + + getNextItem(aStartItem, aDelta) { + while (aStartItem) { + aStartItem = aStartItem.nextSibling; + if ( + aStartItem && + aStartItem.localName == "richlistitem" && + (!this._userSelecting || this._canUserSelect(aStartItem)) + ) { + --aDelta; + if (aDelta == 0) { + return aStartItem; + } + } + } + return null; + } + + getPreviousItem(aStartItem, aDelta) { + while (aStartItem) { + aStartItem = aStartItem.previousSibling; + if ( + aStartItem && + aStartItem.localName == "richlistitem" && + (!this._userSelecting || this._canUserSelect(aStartItem)) + ) { + --aDelta; + if (aDelta == 0) { + return aStartItem; + } + } + } + return null; + } + + appendItem(aLabel, aValue) { + var item = this.ownerDocument.createXULElement("richlistitem"); + item.setAttribute("value", aValue); + + var label = this.ownerDocument.createXULElement("label"); + label.setAttribute("value", aLabel); + label.setAttribute("flex", "1"); + label.setAttribute("crop", "end"); + item.appendChild(label); + + this.appendChild(item); + + return item; + } + + // nsIDOMXULSelectControlElement + getIndexOfItem(aItem) { + // don't search the children, if we're looking for none of them + if (aItem == null) { + return -1; + } + if (this._selecting && this._selecting.item == aItem) { + return this._selecting.index; + } + return this.itemChildren.indexOf(aItem); + } + + // nsIDOMXULSelectControlElement + getItemAtIndex(aIndex) { + if (this._selecting && this._selecting.index == aIndex) { + return this._selecting.item; + } + return this.itemChildren[aIndex] || null; + } + + // nsIDOMXULMultiSelectControlElement + addItemToSelection(aItem) { + if (this.selType != "multiple" && this.selectedCount) { + return; + } + + if (aItem.selected) { + return; + } + + this.selectedItems.append(aItem); + aItem.selected = true; + + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + removeItemFromSelection(aItem) { + if (!aItem.selected) { + return; + } + + this.selectedItems.remove(aItem); + aItem.selected = false; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + toggleItemSelection(aItem) { + if (aItem.selected) { + this.removeItemFromSelection(aItem); + } else { + this.addItemToSelection(aItem); + } + } + + // nsIDOMXULMultiSelectControlElement + selectItem(aItem) { + if (!aItem || aItem.disabled) { + return; + } + + if (this.selectedItems.length == 1 && this.selectedItems[0] == aItem) { + return; + } + + this._selectionStart = null; + + var suppress = this._suppressOnSelect; + this._suppressOnSelect = true; + + this.clearSelection(); + this.addItemToSelection(aItem); + this.currentItem = aItem; + + this._suppressOnSelect = suppress; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + selectItemRange(aStartItem, aEndItem) { + if (this.selType != "multiple") { + return; + } + + if (!aStartItem) { + aStartItem = this._selectionStart + ? this._selectionStart + : this.currentItem; + } + + if (!aStartItem) { + aStartItem = aEndItem; + } + + var suppressSelect = this._suppressOnSelect; + this._suppressOnSelect = true; + + this._selectionStart = aStartItem; + + var currentItem; + var startIndex = this.getIndexOfItem(aStartItem); + var endIndex = this.getIndexOfItem(aEndItem); + if (endIndex < startIndex) { + currentItem = aEndItem; + aEndItem = aStartItem; + aStartItem = currentItem; + } else { + currentItem = aStartItem; + } + + while (currentItem) { + this.addItemToSelection(currentItem); + if (currentItem == aEndItem) { + currentItem = this.getNextItem(currentItem, 1); + break; + } + currentItem = this.getNextItem(currentItem, 1); + } + + // Clear around new selection + // Don't use clearSelection() because it causes a lot of noise + // with respect to selection removed notifications used by the + // accessibility API support. + var userSelecting = this._userSelecting; + this._userSelecting = false; // that's US automatically unselecting + for (; currentItem; currentItem = this.getNextItem(currentItem, 1)) { + this.removeItemFromSelection(currentItem); + } + + for ( + currentItem = this.getItemAtIndex(0); + currentItem != aStartItem; + currentItem = this.getNextItem(currentItem, 1) + ) { + this.removeItemFromSelection(currentItem); + } + this._userSelecting = userSelecting; + + this._suppressOnSelect = suppressSelect; + + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + selectAll() { + this._selectionStart = null; + + var suppress = this._suppressOnSelect; + this._suppressOnSelect = true; + + var item = this.getItemAtIndex(0); + while (item) { + this.addItemToSelection(item); + item = this.getNextItem(item, 1); + } + + this._suppressOnSelect = suppress; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + clearSelection() { + if (this.selectedItems) { + while (this.selectedItems.length) { + let item = this.selectedItems[0]; + item.selected = false; + this.selectedItems.remove(item); + } + } + + this._selectionStart = null; + this._fireOnSelect(); + } + + // nsIDOMXULMultiSelectControlElement + getSelectedItem(aIndex) { + return aIndex < this.selectedItems.length + ? this.selectedItems[aIndex] + : null; + } + + ensureIndexIsVisible(aIndex) { + return this.ensureElementIsVisible(this.getItemAtIndex(aIndex)); + } + + ensureElementIsVisible(aElement, aAlignToTop) { + if (!aElement) { + return; + } + + // These calculations assume that there is no padding on the + // "richlistbox" element, although there might be a margin. + var targetRect = aElement.getBoundingClientRect(); + var scrollRect = this.getBoundingClientRect(); + var offset = targetRect.top - scrollRect.top; + if (!aAlignToTop && offset >= 0) { + // scrollRect.bottom wouldn't take a horizontal scroll bar into account + let scrollRectBottom = scrollRect.top + this.clientHeight; + offset = targetRect.bottom - scrollRectBottom; + if (offset <= 0) { + return; + } + } + this.scrollTop += offset; + } + + getIndexOfFirstVisibleRow() { + var children = this.itemChildren; + + for (var ix = 0; ix < children.length; ix++) { + if (this._isItemVisible(children[ix])) { + return ix; + } + } + + return -1; + } + + getRowCount() { + return this.itemChildren.length; + } + + scrollOnePage(aDirection) { + var children = this.itemChildren; + + if (!children.length) { + return 0; + } + + // If nothing is selected, we just select the first element + // at the extreme we're moving away from + if (!this.currentItem) { + return aDirection == -1 ? children.length : 0; + } + + // If the current item is visible, scroll by one page so that + // the new current item is at approximately the same position as + // the existing current item. + let height = this.getBoundingClientRect().height; + if (this._isItemVisible(this.currentItem)) { + this.scrollBy(0, height * aDirection); + } + + // Figure out, how many items fully fit into the view port + // (including the currently selected one), and determine + // the index of the first one lying (partially) outside + let currentItemRect = this.currentItem.getBoundingClientRect(); + var startBorder = currentItemRect.y; + if (aDirection == -1) { + startBorder += currentItemRect.height; + } + + var index = this.currentIndex; + for (var ix = index; 0 <= ix && ix < children.length; ix += aDirection) { + let childRect = children[ix].getBoundingClientRect(); + if (childRect.height == 0) { + continue; // hidden children have a y of 0 + } + var endBorder = childRect.y + (aDirection == -1 ? childRect.height : 0); + if ((endBorder - startBorder) * aDirection > height) { + break; // we've reached the desired distance + } + index = ix; + } + + return index != this.currentIndex + ? index - this.currentIndex + : aDirection; + } + + _refreshSelection() { + // when this method is called, we know that either the currentItem + // and selectedItems we have are null (ctor) or a reference to an + // element no longer in the DOM (template). + + // first look for the last-selected attribute + var state = this.getAttribute("last-selected"); + if (state) { + var ids = state.split(" "); + + var suppressSelect = this._suppressOnSelect; + this._suppressOnSelect = true; + this.clearSelection(); + for (let i = 1; i < ids.length; i++) { + var selectedItem = document.getElementById(ids[i]); + if (selectedItem) { + this.addItemToSelection(selectedItem); + } + } + + var currentItem = document.getElementById(ids[0]); + if (!currentItem && this._currentIndex) { + currentItem = this.getItemAtIndex( + Math.min(this._currentIndex - 1, this.getRowCount()) + ); + } + if (currentItem) { + this.currentItem = currentItem; + if (this.selType != "multiple" && this.selectedCount == 0) { + this.selectedItem = currentItem; + } + + if (this.getBoundingClientRect().height) { + this.ensureElementIsVisible(currentItem); + } else { + // XXX hack around a bug in ensureElementIsVisible as it will + // scroll beyond the last element, bug 493645. + this.ensureElementIsVisible(currentItem.previousElementSibling); + } + } + this._suppressOnSelect = suppressSelect; + // XXX actually it's just a refresh, but at least + // the Extensions manager expects this: + this._fireOnSelect(); + return; + } + + // try to restore the selected items according to their IDs + // (applies after a template rebuild, if last-selected was not set) + if (this.selectedItems) { + let itemIds = []; + for (let i = this.selectedCount - 1; i >= 0; i--) { + let selectedItem = this.selectedItems[i]; + itemIds.push(selectedItem.id); + this.selectedItems.remove(selectedItem); + } + for (let i = 0; i < itemIds.length; i++) { + let selectedItem = document.getElementById(itemIds[i]); + if (selectedItem) { + this.selectedItems.append(selectedItem); + } + } + } + if (this.currentItem && this.currentItem.id) { + this.currentItem = document.getElementById(this.currentItem.id); + } else { + this.currentItem = null; + } + + // if we have no previously current item or if the above check fails to + // find the previous nodes (which causes it to clear selection) + if (!this.currentItem && this.selectedCount == 0) { + this.currentIndex = this._currentIndex ? this._currentIndex - 1 : 0; + + // cf. listbox constructor: + // select items according to their attributes + var children = this.itemChildren; + for (let i = 0; i < children.length; ++i) { + if (children[i].getAttribute("selected") == "true") { + this.selectedItems.append(children[i]); + } + } + } + + if (this.selType != "multiple" && this.selectedCount == 0) { + this.selectedItem = this.currentItem; + } + } + + _isItemVisible(aItem) { + if (!aItem) { + return false; + } + + var y = this.getBoundingClientRect().y; + + // Partially visible items are also considered visible + let itemRect = aItem.getBoundingClientRect(); + return ( + itemRect.y + itemRect.height > y && itemRect.y < y + this.clientHeight + ); + } + + moveByOffset(aOffset, aIsSelecting, aIsSelectingRange, aEvent) { + if ((aIsSelectingRange || !aIsSelecting) && this.selType != "multiple") { + return; + } + + var newIndex = this.currentIndex + aOffset; + if (newIndex < 0) { + newIndex = 0; + } + + var numItems = this.getRowCount(); + if (newIndex > numItems - 1) { + newIndex = numItems - 1; + } + + var newItem = this.getItemAtIndex(newIndex); + // make sure that the item is actually visible/selectable + if (this._userSelecting && newItem && !this._canUserSelect(newItem)) { + newItem = + aOffset > 0 + ? this.getNextItem(newItem, 1) || this.getPreviousItem(newItem, 1) + : this.getPreviousItem(newItem, 1) || this.getNextItem(newItem, 1); + } + if (newItem) { + let hadFocus = this.currentItem.contains(document.activeElement); + this.ensureIndexIsVisible(this.getIndexOfItem(newItem)); + if (aIsSelectingRange) { + this.selectItemRange(null, newItem); + } else if (aIsSelecting) { + this.selectItem(newItem); + } + if (hadFocus) { + let flags = + Services.focus[ + aEvent.type.startsWith("key") ? "FLAG_BYKEY" : "FLAG_BYJS" + ]; + Services.focus.moveFocus( + window, + newItem, + Services.focus.MOVEFOCUS_FIRST, + flags + ); + } + + this.currentItem = newItem; + } + } + + _moveByOffsetFromUserEvent(aOffset, aEvent) { + if (!aEvent.defaultPrevented) { + this._userSelecting = true; + this.moveByOffset(aOffset, !aEvent.ctrlKey, aEvent.shiftKey, aEvent); + this._userSelecting = false; + aEvent.preventDefault(); + } + } + + _canUserSelect(aItem) { + var style = document.defaultView.getComputedStyle(aItem); + return ( + style.display != "none" && + style.visibility == "visible" && + style.MozUserInput != "none" + ); + } + + _selectTimeoutHandler(aMe) { + aMe._fireOnSelect(); + aMe._selectTimeout = null; + } + + timedSelect(aItem, aTimeout) { + var suppress = this._suppressOnSelect; + if (aTimeout != -1) { + this._suppressOnSelect = true; + } + + this.selectItem(aItem); + + this._suppressOnSelect = suppress; + + if (aTimeout != -1) { + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this._selectTimeout = window.setTimeout( + this._selectTimeoutHandler, + aTimeout, + this + ); + } + } + + /** + * For backwards-compatibility and for convenience. + * Use ensureElementIsVisible instead + */ + ensureSelectedElementIsVisible() { + return this.ensureElementIsVisible(this.selectedItem); + } + + _fireEvent(aTarget, aName) { + let event = document.createEvent("Events"); + event.initEvent(aName, true, true); + aTarget.dispatchEvent(event); + } + }; + + MozXULElement.implementCustomInterface(MozElements.RichListBox, [ + Ci.nsIDOMXULSelectControlElement, + Ci.nsIDOMXULMultiSelectControlElement, + ]); + + customElements.define("richlistbox", MozElements.RichListBox); + + /** + * XUL:richlistitem element. + */ + MozElements.MozRichlistitem = class MozRichlistitem extends ( + MozElements.BaseText + ) { + constructor() { + super(); + + this.selectedByMouseOver = false; + + /** + * If there is no modifier key, we select on mousedown, not + * click, so that drags work correctly. + */ + this.addEventListener("mousedown", event => { + var control = this.control; + if (!control || control.disabled) { + return; + } + if ( + (!event.ctrlKey || + (AppConstants.platform == "macosx" && event.button == 2)) && + !event.shiftKey && + !event.metaKey + ) { + if (!this.selected) { + control.selectItem(this); + } + control.currentItem = this; + } + }); + + /** + * On a click (up+down on the same item), deselect everything + * except this item. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + var control = this.control; + if (!control || control.disabled) { + return; + } + control._userSelecting = true; + if (control.selType != "multiple") { + control.selectItem(this); + } else if (event.ctrlKey || event.metaKey) { + control.toggleItemSelection(this); + control.currentItem = this; + } else if (event.shiftKey) { + control.selectItemRange(null, this); + control.currentItem = this; + } else { + /* We want to deselect all the selected items except what was + clicked, UNLESS it was a right-click. We have to do this + in click rather than mousedown so that you can drag a + selected group of items */ + + // use selectItemRange instead of selectItem, because this + // doesn't de- and reselect this item if it is selected + control.selectItemRange(this, this); + } + control._userSelecting = false; + }); + } + + connectedCallback() { + this._updateInnerControlsForSelection(this.selected); + } + + /** + * nsIDOMXULSelectControlItemElement + */ + get label() { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return Array.from( + this.getElementsByTagNameNS(XUL_NS, "label"), + label => label.value + ).join(" "); + } + + set searchLabel(val) { + if (val !== null) { + this.setAttribute("searchlabel", val); + } + // fall back to the label property (default value) + else { + this.removeAttribute("searchlabel"); + } + } + + get searchLabel() { + return this.hasAttribute("searchlabel") + ? this.getAttribute("searchlabel") + : this.label; + } + /** + * nsIDOMXULSelectControlItemElement + */ + set value(val) { + this.setAttribute("value", val); + } + + get value() { + return this.getAttribute("value"); + } + + /** + * nsIDOMXULSelectControlItemElement + */ + set selected(val) { + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + this._updateInnerControlsForSelection(val); + } + + get selected() { + return this.getAttribute("selected") == "true"; + } + /** + * nsIDOMXULSelectControlItemElement + */ + get control() { + var parent = this.parentNode; + while (parent) { + if (parent.localName == "richlistbox") { + return parent; + } + parent = parent.parentNode; + } + return null; + } + + set current(val) { + if (val) { + this.setAttribute("current", "true"); + } else { + this.removeAttribute("current"); + } + } + + get current() { + return this.getAttribute("current") == "true"; + } + + _updateInnerControlsForSelection(selected) { + for (let control of this.querySelectorAll("button,menulist")) { + if (!selected && control.tabIndex == 0) { + control.tabIndex = -1; + } else if (selected && control.tabIndex == -1) { + control.tabIndex = 0; + } + } + } + }; + + MozXULElement.implementCustomInterface(MozElements.MozRichlistitem, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("richlistitem", MozElements.MozRichlistitem); +} diff --git a/toolkit/content/widgets/search-textbox.js b/toolkit/content/widgets/search-textbox.js new file mode 100644 index 0000000000..abdcfa2999 --- /dev/null +++ b/toolkit/content/widgets/search-textbox.js @@ -0,0 +1,262 @@ +/* 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(); + + MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + + 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) { + 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,inputmode,spellcheck", + ".textbox-search-icon": "label=searchbuttonlabel,disabled", + ".textbox-search-clear": "disabled", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.connected) { + return; + } + + document.l10n.connectRoot(this.shadowRoot); + + 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("inputmode", "search"); + input.autocomplete = "off"; // not applicable in XUL docs and confuses aria. + 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)); + + const clearBtn = document.createXULElement("image"); + clearBtn.className = "textbox-search-clear"; + clearBtn.part = "clear-icon"; + clearBtn.setAttribute("role", "button"); + document.l10n.setAttributes( + clearBtn, + "text-action-search-text-box-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(); + } + + disconnectedCallback() { + document.l10n.disconnectRoot(this.shadowRoot); + } + + set timeout(val) { + this.setAttribute("timeout", val); + } + + get timeout() { + return parseInt(this.getAttribute("timeout")) || 500; + } + + set searchButton(val) { + if (val) { + this.setAttribute("searchbutton", "true"); + this.inputField.removeAttribute("aria-autocomplete"); + this._searchButtonIcon.setAttribute("role", "button"); + } else { + this.removeAttribute("searchbutton"); + this.inputField.setAttribute("aria-autocomplete", "list"); + this._searchButtonIcon.setAttribute("role", "none"); + } + } + + 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); + } + } + + 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"); + } + } + + 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..2adefc7392 --- /dev/null +++ b/toolkit/content/widgets/spinner.js @@ -0,0 +1,637 @@ +/* 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"; + + // Prepares the spinner container to function as a spinbutton and expose + // its properties to assistive technology + this.elements.spinner.setAttribute("role", "spinbutton"); + this.elements.spinner.setAttribute("tabindex", "0"); + // Remove up/down buttons from the focus order, because a keyboard-only + // user can adjust values by pressing Up/Down arrow keys on a spinbutton, + // otherwise it creates extra, redundant tab order stops for users + this.elements.up.setAttribute("tabindex", "-1"); + this.elements.down.setAttribute("tabindex", "-1"); + + 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, /* centering = */ true, /* smooth = */ false); + } else if (newValue != value) { + this.state = Object.assign(this.state, newState); + this._scrollTo(newValue, /* centering = */ true, smoothScroll); + } + + this.elements.spinner.setAttribute( + "aria-valuemin", + this.state.items[0].value + ); + this.elements.spinner.setAttribute( + "aria-valuemax", + this.state.items.at(-1).value + ); + this.elements.spinner.setAttribute("aria-valuenow", this.state.value); + if (!this.elements.spinner.getAttribute("aria-valuetext")) { + this.elements.spinner.setAttribute( + "aria-valuetext", + this.props.getDisplayString(this.state.value) + ); + } + + // Show selection even if it's passed down from the parent + if ((isValueSet && !isInvalid) || this.state.index) { + 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"); + this.elements.spinner.setAttribute( + "aria-valuetext", + this.props.getDisplayString(this.state.value) + ); + }, + + /** + * 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"); + // Spinbutton elements should be hidden from assistive technology: + el.setAttribute("aria-hidden", "true"); + 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<Object>} items + * [{ + * {Number/String} value: The value in its original form + * {Boolean} enabled: Whether or not the item is enabled + * }] + * @param {Array<DOMElement>} 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 }); + spinner.addEventListener("keydown", this); + container.addEventListener("mouseup", this, { passive: true }); + container.addEventListener("mousedown", this, { passive: true }); + container.addEventListener("keydown", this); + }, + + /** + * 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; + } + case "keydown": { + // Providing keyboard navigation support in accordance with + // the ARIA Spinbutton design pattern + if (event.target === spinner) { + switch (event.key) { + case "ArrowUp": { + // While the spinner is focused, selects previous value and centers it + this._setValueForSpinner(event, index - 1); + break; + } + case "ArrowDown": { + // While the spinner is focused, selects next value and centers it + this._setValueForSpinner(event, index + 1); + break; + } + case "PageUp": { + // While the spinner is focused, selects 5th value above and centers it + this._setValueForSpinner(event, index - 5); + break; + } + case "PageDown": { + // While the spinner is focused, selects 5th value below and centers it + this._setValueForSpinner(event, index + 5); + break; + } + case "Home": { + // While the spinner is focused, selects the min value and centers it + let targetValue; + for (let i = 0; i < this.state.items.length - 1; i++) { + if (this.state.items[i].enabled) { + targetValue = this.state.items[i].value; + break; + } + } + this._smoothScrollTo(targetValue); + event.stopPropagation(); + event.preventDefault(); + break; + } + case "End": { + // While the spinner is focused, selects the max value and centers it + let targetValue; + for (let i = this.state.items.length - 1; i >= 0; i--) { + if (this.state.items[i].enabled) { + targetValue = this.state.items[i].value; + break; + } + } + this._smoothScrollTo(targetValue); + event.stopPropagation(); + event.preventDefault(); + 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 based on the index + * + * @param {Number} index: Index number + * @param {Boolean} smooth: Whether or not scroll should be smooth by default + */ + _scrollToIndex(index, smooth) { + // Do nothing if the value is not found + if (index < 0) { + return; + } + this.state.index = index; + const element = this.elements.spinner.children[index]; + if (!element) { + return; + } + element.scrollIntoView({ + behavior: smooth ? "auto" : "instant", + block: "start", + }); + }, + + /** + * Scroll to a value. + * + * @param {Number/String} value: Value to scroll to + * @param {Boolean} centering: Whether or not to scroll to center location + * @param {Boolean} smooth: Whether or not scroll should be smooth by default + */ + _scrollTo(value, centering, smooth) { + const index = this._getScrollIndex(value, centering); + this._scrollToIndex(index, smooth); + }, + + _smoothScrollTo(value) { + this._scrollTo(value, /* centering = */ false, /* smooth = */ true); + }, + + _smoothScrollToIndex(index) { + this._scrollToIndex(index, /* smooth = */ true); + }, + + /** + * 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<Object>} a + * @param {Array<Object>} 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; + }, + + /** + * While the spinner is focused and keyboard command is used, selects an + * appropriate index and centers it, while preventing default behavior and + * stopping event propagation. + * + * @param {Object} event: Keyboard event + * @param {Number} index: The index of the expected next item + */ + _setValueForSpinner(event, index) { + this._smoothScrollToIndex(index); + event.stopPropagation(); + event.preventDefault(); + }, + }; +} diff --git a/toolkit/content/widgets/stringbundle.js b/toolkit/content/widgets/stringbundle.js new file mode 100644 index 0000000000..9a19ffc47f --- /dev/null +++ b/toolkit/content/widgets/stringbundle.js @@ -0,0 +1,73 @@ +/* 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`. +{ + 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); + } + + 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..997e8413f2 --- /dev/null +++ b/toolkit/content/widgets/tabbox.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 chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + let imports = {}; + ChromeUtils.defineESModuleGetters(imports, { + ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs", + }); + + 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); + } + + 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); + } + + 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; + } + } + } + + get selectedTab() { + let tabs = this.tabs; + return tabs && tabs.selectedItem; + } + + set selectedPanel(val) { + if (val) { + let tabpanels = this.tabpanels; + if (tabpanels) { + tabpanels.selectedPanel = 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; + } + + // Skip if chrome code has cancelled this: + if (event.defaultPreventedByChrome) { + 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 MozDeck extends MozXULElement { + get isAsync() { + return this.getAttribute("async") == "true"; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + this._selectedPanel = null; + this._inAsyncOperation = false; + + let selectCurrentIndex = () => { + // Try to select the new node if any. + let index = this.selectedIndex; + let oldPanel = this._selectedPanel; + this._selectedPanel = this.children.item(index) || null; + this.updateSelectedIndex(index, oldPanel); + }; + + this._mutationObserver = new MutationObserver(records => { + let anyRemovals = records.some(record => !!record.removedNodes.length); + if (anyRemovals) { + // Try to keep the current selected panel in-place first. + let index = Array.from(this.children).indexOf(this._selectedPanel); + if (index != -1) { + // Try to keep the same node selected. + this.setAttribute("selectedIndex", index); + } + } + // Select the current index if needed in case mutations have made that + // available where it wasn't before. + if (!this._inAsyncOperation) { + selectCurrentIndex(); + } + }); + + this._mutationObserver.observe(this, { + childList: true, + }); + + selectCurrentIndex(); + } + + disconnectedCallback() { + this._mutationObserver?.disconnect(); + this._mutationObserver = null; + } + + updateSelectedIndex( + val, + oldPanel = this.querySelector(":scope > .deck-selected") + ) { + this._inAsyncOperation = false; + if (oldPanel != this._selectedPanel) { + oldPanel?.classList.remove("deck-selected"); + this._selectedPanel?.classList.add("deck-selected"); + } + this.setAttribute("selectedIndex", val); + } + + set selectedIndex(val) { + if (val < 0 || val >= this.children.length) { + return; + } + + let oldPanel = this._selectedPanel; + this._selectedPanel = this.children[val]; + + this._inAsyncOperation = this.isAsync; + if (!this._inAsyncOperation) { + this.updateSelectedIndex(val, oldPanel); + } + + if (this._selectedPanel != oldPanel) { + let event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + } + } + + get selectedIndex() { + let indexStr = this.getAttribute("selectedIndex"); + return indexStr ? parseInt(indexStr) : 0; + } + + set selectedPanel(val) { + this.selectedIndex = Array.from(this.children).indexOf(val); + } + + get selectedPanel() { + return this._selectedPanel; + } + } + + customElements.define("deck", MozDeck); + + class MozTabpanels extends MozDeck { + constructor() { + super(); + this._tabbox = null; + } + + 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); + } + + /** + * 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 ` + <hbox class="tab-middle box-inherit" flex="1"> + <image class="tab-icon" role="presentation"></image> + <label class="tab-text" flex="1" role="presentation"></label> + </hbox> + `; + } + + 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); + } + + 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"); + } + } + + 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; + } + } + } + + 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) { + return; + } + 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; + } + } + + 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); + } + } + + 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); + } + } + + 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 <tab> 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 <tab> 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 = null; + + // Handle keyboard navigation for a hidden tab that can be selected, like the Firefox View tab, + // which has a random placement in this.allTabs. + if (startTab.hidden) { + if (aDir == 1) { + newTab = this.allTabs.find(tab => !tab.hidden); + } else { + newTab = this.allTabs.findLast(tab => !tab.hidden); + } + } else { + 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( + `<spacer class="tabs-left"/>` + ); + this.insertBefore(start, this.firstChild); + + let end = MozXULElement.parseXULToFragment( + `<spacer class="tabs-right" flex="1"/>` + ); + this.insertBefore(end, null); + + this.baseConnect(); + } + + // Accessor for tabs. This element has spacers as the first and + // last elements and <tab>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..ca10f1489e --- /dev/null +++ b/toolkit/content/widgets/text.js @@ -0,0 +1,389 @@ +/* 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 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<span style="display:none;">:</span> + 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 (Text.isInstance(element.previousSibling)) { + 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); + } + + 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."; + console.error(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) { + console.error(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/textrecognition.js b/toolkit/content/widgets/textrecognition.js new file mode 100644 index 0000000000..887d576770 --- /dev/null +++ b/toolkit/content/widgets/textrecognition.js @@ -0,0 +1,366 @@ +/* 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.TextRecognitionWidget = class { + /** + * @param {ShadowRoot} shadowRoot + * @param {Record<string, string | boolean | number>} _prefs + */ + constructor(shadowRoot, _prefs) { + /** @type {ShadowRoot} */ + this.shadowRoot = shadowRoot; + /** @type {HTMLElement} */ + this.element = shadowRoot.host; + /** @type {Document} */ + this.document = this.element.ownerDocument; + /** @type {Window} */ + this.window = this.document.defaultView; + /** @type {ResizeObserver} */ + this.resizeObserver = null; + /** @type {Map<HTMLSpanElement, DOMRect} */ + this.spanRects = new Map(); + /** @type {boolean} */ + this.isInitialized = false; + /** @type {null | number} */ + this.lastCanvasStyleWidth = null; + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.resizeObserver = new this.window.ResizeObserver(() => { + this.positionSpans(); + }); + this.resizeObserver.observe(this.element); + } + + positionSpans() { + if (!this.shadowRoot.firstChild) { + return; + } + this.lazilyInitialize(); + + /** @type {HTMLDivElement} */ + const div = this.shadowRoot.firstChild; + const canvas = div.querySelector("canvas"); + const spans = div.querySelectorAll("span"); + + // TODO Bug 1770438 - The <img> element does not currently let child elements be + // sized relative to the size of the containing <img> element. It would be better + // to teach the <img> element how to do this. For the prototype, do the more expensive + // operation of getting the bounding client rect, and handle the positioning manually. + const imgRect = this.element.getBoundingClientRect(); + div.style.width = imgRect.width + "px"; + div.style.height = imgRect.height + "px"; + canvas.style.width = imgRect.width + "px"; + canvas.style.height = imgRect.height + "px"; + + // The ctx is only available when redrawing the canvas. This is operation is only + // done when necessary, as it can be expensive. + /** @type {null | CanvasRenderingContext2D} */ + let ctx = null; + + if ( + // The canvas hasn't been drawn to yet. + this.lastCanvasStyleWidth === null || + // Only redraw when the image has grown 25% larger. This percentage was chosen + // as it visually seemed to work well, with the canvas never appearing blurry + // when manually testing it. + imgRect.width > this.lastCanvasStyleWidth * 1.25 + ) { + const dpr = this.window.devicePixelRatio; + canvas.width = imgRect.width * dpr; + canvas.height = imgRect.height * dpr; + this.lastCanvasStyleWidth = imgRect.width; + + ctx = canvas.getContext("2d"); + ctx.scale(dpr, dpr); + ctx.fillStyle = "#00000088"; + ctx.fillRect(0, 0, imgRect.width, imgRect.height); + + ctx.beginPath(); + } + + for (const span of spans) { + let spanRect = this.spanRects.get(span); + if (!spanRect) { + // This only needs to happen once. + spanRect = span.getBoundingClientRect(); + this.spanRects.set(span, spanRect); + } + + const points = span.dataset.points.split(",").map(p => Number(p)); + // Use the points in the string, e.g. + // "0.0275349,0.14537,0.0275349,0.244662,0.176966,0.244565,0.176966,0.145273" + // 0 1 2 3 4 5 6 7 + // ^ bottomleft ^ topleft ^ topright ^ bottomright + let [ + bottomLeftX, + bottomLeftY, + topLeftX, + topLeftY, + topRightX, + topRightY, + bottomRightX, + bottomRightY, + ] = points; + + // Invert the Y. + topLeftY = 1 - topLeftY; + topRightY = 1 - topRightY; + bottomLeftY = 1 - bottomLeftY; + bottomRightY = 1 - bottomRightY; + + // Create a projection matrix to position the <span> relative to the bounds. + // prettier-ignore + const mat4 = projectPoints( + spanRect.width, spanRect.height, + imgRect.width * topLeftX, imgRect.height * topLeftY, + imgRect.width * topRightX, imgRect.height * topRightY, + imgRect.width * bottomLeftX, imgRect.height * bottomLeftY, + imgRect.width * bottomRightX, imgRect.height * bottomRightY + ); + + span.style.transform = "matrix3d(" + mat4.join(", ") + ")"; + + if (ctx) { + const inset = 3; + ctx.moveTo( + imgRect.width * bottomLeftX + inset, + imgRect.height * bottomLeftY - inset + ); + ctx.lineTo( + imgRect.width * topLeftX + inset, + imgRect.height * topLeftY + inset + ); + ctx.lineTo( + imgRect.width * topRightX - inset, + imgRect.height * topRightY + inset + ); + ctx.lineTo( + imgRect.width * bottomRightX - inset, + imgRect.height * bottomRightY - inset + ); + ctx.closePath(); + } + } + + if (ctx) { + // This composite operation will cut out the quads. The color is arbitrary. + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(); + + // Creating a round line will grow the selection slightly, and round the corners. + ctx.lineWidth = 10; + ctx.lineJoin = "round"; + ctx.strokeStyle = "#ffffff"; + ctx.stroke(); + } + } + + teardown() { + this.shadowRoot.firstChild.remove(); + this.resizeObserver.disconnect(); + this.spanRects.clear(); + } + + lazilyInitialize() { + if (this.isInitialized) { + return; + } + this.isInitialized = true; + + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="textrecognition" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/textrecognition.css" /> + <canvas /> + <!-- The spans will be reattached here --> + </div>`, + "application/xml" + ); + if ( + this.shadowRoot.children.length !== 1 || + this.shadowRoot.firstChild.tagName !== "DIV" + ) { + throw new Error( + "Expected the shadowRoot to have a single div as the root element." + ); + } + + const spansDiv = this.shadowRoot.firstChild; + // Example layout of spansDiv: + // <div> + // <span data-points="0.0275349,0.14537,0.0275349,0.244662,0.176966,0.244565,0.176966,0.145273"> + // Text that has been recognized + // </span> + // ... + // </div> + spansDiv.remove(); + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true /* deep */ + ); + + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot.firstChild, + spansDiv, + true /* deep */ + ); + } +}; + +/** + * A three dimensional vector. + * + * @typedef {[number, number, number]} Vec3 + */ + +/** + * A 3x3 matrix. + * + * @typedef {[number, number, number, + * number, number, number, + * number, number, number]} Matrix3 + */ + +/** + * A 4x4 matrix. + * + * @typedef {[number, number, number, number, + * number, number, number, number, + * number, number, number, number, + * number, number, number, number]} Matrix4 + */ + +/** + * Compute the adjugate matrix. + * https://en.wikipedia.org/wiki/Adjugate_matrix + * + * @param {Matrix3} m + * @returns {Matrix3} + */ +function computeAdjugate(m) { + // prettier-ignore + return [ + m[4] * m[8] - m[5] * m[7], + m[2] * m[7] - m[1] * m[8], + m[1] * m[5] - m[2] * m[4], + m[5] * m[6] - m[3] * m[8], + m[0] * m[8] - m[2] * m[6], + m[2] * m[3] - m[0] * m[5], + m[3] * m[7] - m[4] * m[6], + m[1] * m[6] - m[0] * m[7], + m[0] * m[4] - m[1] * m[3], + ]; +} + +/** + * @param {Matrix3} a + * @param {Matrix3} b + * @returns {Matrix3} + */ +function multiplyMat3(a, b) { + let out = []; + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + let sum = 0; + for (let k = 0; k < 3; k++) { + sum += a[3 * i + k] * b[3 * k + j]; + } + out[3 * i + j] = sum; + } + } + return out; +} + +/** + * @param {Matrix3} m + * @param {Vec3} v + * @returns {Vec3} + */ +function multiplyMat3Vec3(m, v) { + // prettier-ignore + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ]; +} + +/** + * @returns {Matrix3} + */ +function basisToPoints(x1, y1, x2, y2, x3, y3, x4, y4) { + /** @type {Matrix3} */ + let mat3 = [x1, x2, x3, y1, y2, y3, 1, 1, 1]; + let vec3 = multiplyMat3Vec3(computeAdjugate(mat3), [x4, y4, 1]); + // prettier-ignore + return multiplyMat3( + mat3, + [ + vec3[0], 0, 0, + 0, vec3[1], 0, + 0, 0, vec3[2] + ] + ); +} + +/** + * @type {(...Matrix4) => Matrix3} + */ +// prettier-ignore +function general2DProjection( + x1s, y1s, x1d, y1d, + x2s, y2s, x2d, y2d, + x3s, y3s, x3d, y3d, + x4s, y4s, x4d, y4d +) { + let s = basisToPoints(x1s, y1s, x2s, y2s, x3s, y3s, x4s, y4s); + let d = basisToPoints(x1d, y1d, x2d, y2d, x3d, y3d, x4d, y4d); + return multiplyMat3(d, computeAdjugate(s)); +} + +/** + * Given a width and height, compute a projection matrix to points 1-4. + * + * The points (x1,y1) through (x4, y4) use the following ordering: + * + * w + * ┌─────┐ project 1 ─────── 2 + * h │ │ --> │ / + * └─────┘ │ / + * 3 ──── 4 + * + * @returns {Matrix4} + */ +function projectPoints(w, h, x1, y1, x2, y2, x3, y3, x4, y4) { + // prettier-ignore + const mat3 = general2DProjection( + 0, 0, x1, y1, + w, 0, x2, y2, + 0, h, x3, y3, + w, h, x4, y4 + ); + + for (let i = 0; i < 9; i++) { + mat3[i] = mat3[i] / mat3[8]; + } + + // prettier-ignore + return [ + mat3[0], mat3[3], 0, mat3[6], + mat3[1], mat3[4], 0, mat3[7], + 0, 0, 1, 0, + mat3[2], mat3[5], 0, mat3[8], + ]; +} 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<Number>} dayPeriod + * {Array<Number>} hours + * {Array<Number>} minutes + * {Array<Number>} seconds + * {Array<Number>} 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<Number>} + */ + _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<Number>} + */ + _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<Number>} + */ + _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<Number>} + */ + _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<Number>} + */ + _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<Object>} + * { + * {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<Object>} 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..83c4840a70 --- /dev/null +++ b/toolkit/content/widgets/timepicker.js @@ -0,0 +1,291 @@ +/* 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(); + // TODO(bug 1828721): This is a bit sad. + window.PICKER_READY = true; + document.dispatchEvent(new CustomEvent("PickerReady")); + }, + + /* + * 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..31dd0dc545 --- /dev/null +++ b/toolkit/content/widgets/toolbarbutton.js @@ -0,0 +1,231 @@ +/* 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(` + <image class="toolbarbutton-icon"></image> + <label class="toolbarbutton-text" crop="end" flex="1"></label> + `), + true + ); + Object.defineProperty(this, "fragment", { value: frag }); + return frag; + } + + static get badgedFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <stack class="toolbarbutton-badge-stack"> + <image class="toolbarbutton-icon"/> + <html:label class="toolbarbutton-badge"/> + </stack> + <label class="toolbarbutton-text" crop="end" flex="1"/> + `), + true + ); + Object.defineProperty(this, "badgedFragment", { value: frag }); + return frag; + } + + static get dropmarkerFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <dropmarker type="menu" class="toolbarbutton-menu-dropmarker"></dropmarker> + `), + true + ); + Object.defineProperty(this, "dropmarkerFragment", { value: frag }); + return frag; + } + + get _hasRendered() { + return this.querySelector(":scope > .toolbarbutton-text") != null; + } + + get _textNode() { + let node = this.getElementForAttrInheritance(".toolbarbutton-text"); + if (node) { + Object.defineProperty(this, "_textNode", { value: node }); + } + return node; + } + + _setLabel() { + let label = this.getAttribute("label") || ""; + let hasLabel = this.hasAttribute("label"); + if (this.getAttribute("wrap") == "true") { + this._textNode.removeAttribute("value"); + this._textNode.textContent = label; + } else { + this._textNode.textContent = ""; + if (hasLabel) { + this._textNode.setAttribute("value", label); + } else { + this._textNode.removeAttribute("value"); + } + } + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue || !this.initializedAttributeInheritance) { + return; + } + // Deal with single/multiline label inheritance: + if (name == "label" || name == "wrap") { + this._setLabel(); + } + // The normal implementation will deal with everything else. + super.attributeChangedCallback(name, oldValue, newValue); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + // Defer creating DOM elements for content inside popups. + // These will be added in the popupshown handler above. + let panel = this.closest("panel"); + if (panel && !panel.hasAttribute("hasbeenopened")) { + return; + } + + this.render(); + } + + render() { + if (this._hasRendered) { + return; + } + + let badged = this.getAttribute("badged") == "true"; + + if (badged) { + let moveChildren = []; + for (let child of this.children) { + if (!KEEP_CHILDREN.has(child.tagName)) { + moveChildren.push(child); + } + } + + this.appendChild(this.constructor.badgedFragment.cloneNode(true)); + + if (this.hasAttribute("wantdropmarker")) { + this.appendChild(this.constructor.dropmarkerFragment.cloneNode(true)); + } + + if (moveChildren.length) { + let { badgeStack, icon } = this; + for (let child of moveChildren) { + if (child.getAttribute("move-after-stack") === "true") { + this.appendChild(child); + } else { + badgeStack.insertBefore(child, icon); + } + } + } + } else { + let moveChildren = []; + for (let child of this.children) { + if (!KEEP_CHILDREN.has(child.tagName) && child.tagName != "box") { + // XBL toolbarbutton doesn't insert any anonymous content + // if it has a child of any other type + return; + } + + if (child.tagName == "box") { + moveChildren.push(child); + } + } + + this.appendChild(this.constructor.fragment.cloneNode(true)); + + if (this.hasAttribute("wantdropmarker")) { + this.appendChild(this.constructor.dropmarkerFragment.cloneNode(true)); + } + + // XBL toolbarbutton explicitly places any <box> children + // right before the menu marker. + for (let child of moveChildren) { + this.insertBefore(child, this.lastChild); + } + } + + this.initializeAttributeInheritance(); + this._setLabel(); + } + + get icon() { + return this.querySelector(".toolbarbutton-icon"); + } + + get badgeLabel() { + return this.querySelector(".toolbarbutton-badge"); + } + + get badgeStack() { + return this.querySelector(".toolbarbutton-badge-stack"); + } + + get multilineLabel() { + if (this.getAttribute("wrap") == "true") { + return this._textNode; + } + return null; + } + + get dropmarker() { + return this.querySelector(".toolbarbutton-menu-dropmarker"); + } + + get menupopup() { + return this.querySelector("menupopup"); + } + } + + customElements.define("toolbarbutton", MozToolbarbutton); +} diff --git a/toolkit/content/widgets/tree.js b/toolkit/content/widgets/tree.js new file mode 100644 index 0000000000..322e42586e --- /dev/null +++ b/toolkit/content/widgets/tree.js @@ -0,0 +1,1705 @@ +/* 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/. */ + +/* globals XULTreeElement */ + +"use strict"; + +// This is loaded into all XUL windows. Wrap in a block to prevent +// leaking to window scope. +{ + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + class MozTreeChildren extends MozElements.BaseControl { + constructor() { + super(); + + /** + * If there is no modifier key, we select on mousedown, not + * click, so that drags work correctly. + */ + this.addEventListener("mousedown", event => { + if (this.parentNode.disabled) { + return; + } + if ( + ((!event.getModifierState("Accel") || + !this.parentNode.pageUpOrDownMovesSelection) && + !event.shiftKey && + !event.metaKey) || + this.parentNode.view.selection.single + ) { + var b = this.parentNode; + var cell = b.getCellAt(event.clientX, event.clientY); + var view = this.parentNode.view; + + // save off the last selected row + this._lastSelectedRow = cell.row; + + if (cell.row == -1) { + return; + } + + if (cell.childElt == "twisty") { + return; + } + + if (cell.col && event.button == 0) { + if (cell.col.cycler) { + view.cycleCell(cell.row, cell.col); + return; + } else if (cell.col.type == window.TreeColumn.TYPE_CHECKBOX) { + if ( + this.parentNode.editable && + cell.col.editable && + view.isEditable(cell.row, cell.col) + ) { + var value = view.getCellValue(cell.row, cell.col); + value = value == "true" ? "false" : "true"; + view.setCellValue(cell.row, cell.col, value); + return; + } + } + } + + if (!view.selection.isSelected(cell.row)) { + view.selection.select(cell.row); + b.ensureRowIsVisible(cell.row); + } + } + }); + + /** + * On a click (up+down on the same item), deselect everything + * except this item. + */ + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + if (this.parentNode.disabled) { + return; + } + var b = this.parentNode; + var cell = b.getCellAt(event.clientX, event.clientY); + var view = this.parentNode.view; + + if (cell.row == -1) { + return; + } + + if (cell.childElt == "twisty") { + if ( + view.selection.currentIndex >= 0 && + view.isContainerOpen(cell.row) + ) { + var parentIndex = view.getParentIndex(view.selection.currentIndex); + while (parentIndex >= 0 && parentIndex != cell.row) { + parentIndex = view.getParentIndex(parentIndex); + } + if (parentIndex == cell.row) { + var parentSelectable = true; + if (parentSelectable) { + view.selection.select(parentIndex); + } + } + } + this.parentNode.changeOpenState(cell.row); + return; + } + + if (!view.selection.single) { + var augment = event.getModifierState("Accel"); + if (event.shiftKey) { + view.selection.rangedSelect(-1, cell.row, augment); + b.ensureRowIsVisible(cell.row); + return; + } + if (augment) { + view.selection.toggleSelect(cell.row); + b.ensureRowIsVisible(cell.row); + view.selection.currentIndex = cell.row; + return; + } + } + + /* 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 */ + + if (!cell.col) { + return; + } + + // if the last row has changed in between the time we + // mousedown and the time we click, don't fire the select handler. + // see bug #92366 + if ( + !cell.col.cycler && + this._lastSelectedRow == cell.row && + cell.col.type != window.TreeColumn.TYPE_CHECKBOX + ) { + view.selection.select(cell.row); + b.ensureRowIsVisible(cell.row); + } + }); + + /** + * double-click + */ + this.addEventListener("dblclick", event => { + if (this.parentNode.disabled) { + return; + } + var tree = this.parentNode; + var view = this.parentNode.view; + var row = view.selection.currentIndex; + + if (row == -1) { + return; + } + + var cell = tree.getCellAt(event.clientX, event.clientY); + + if (cell.childElt != "twisty") { + this.parentNode.startEditing(row, cell.col); + } + + if (this.parentNode._editingColumn || !view.isContainer(row)) { + return; + } + + // Cyclers and twisties respond to single clicks, not double clicks + if (cell.col && !cell.col.cycler && cell.childElt != "twisty") { + this.parentNode.changeOpenState(row); + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("slot", "treechildren"); + + this._lastSelectedRow = -1; + + if ("_ensureColumnOrder" in this.parentNode) { + this.parentNode._ensureColumnOrder(); + } + } + } + + customElements.define("treechildren", MozTreeChildren); + + class MozTreecolPicker extends MozElements.BaseControl { + static get markup() { + return ` + <button class="tree-columnpicker-button"/> + <menupopup anonid="popup"> + <menuseparator anonid="menuseparator"/> + <menuitem anonid="menuitem" data-l10n-id="tree-columnpicker-restore-order"/> + </menupopup> + `; + } + constructor() { + super(); + + window.MozXULElement.insertFTLIfNeeded("toolkit/global/tree.ftl"); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + let button = this.querySelector(".tree-columnpicker-button"); + let popup = this.querySelector('[anonid="popup"]'); + let menuitem = this.querySelector('[anonid="menuitem"]'); + + button.addEventListener("command", e => { + this.buildPopup(popup); + popup.openPopup(this, "after_end"); + e.preventDefault(); + }); + + menuitem.addEventListener("command", e => { + let tree = this.parentNode.parentNode; + tree.stopEditing(true); + this.style.order = ""; + tree._ensureColumnOrder(tree.NATURAL_ORDER); + e.preventDefault(); + }); + } + + buildPopup(aPopup) { + // We no longer cache the picker content, remove the old content related to + // the cols - menuitem and separator should stay. + aPopup.querySelectorAll("[colindex]").forEach(e => { + e.remove(); + }); + + var refChild = aPopup.firstChild; + + var tree = this.parentNode.parentNode; + for ( + var currCol = tree.columns.getFirstColumn(); + currCol; + currCol = currCol.getNext() + ) { + // Construct an entry for each column in the row, unless + // it is not being shown. + var currElement = currCol.element; + if (!currElement.hasAttribute("ignoreincolumnpicker")) { + var popupChild = document.createXULElement("menuitem"); + popupChild.setAttribute("type", "checkbox"); + var columnName = + currElement.getAttribute("display") || + currElement.getAttribute("label"); + popupChild.setAttribute("label", columnName); + popupChild.setAttribute("colindex", currCol.index); + if (currElement.getAttribute("hidden") != "true") { + popupChild.setAttribute("checked", "true"); + } + if (currCol.primary) { + popupChild.setAttribute("disabled", "true"); + } + if (currElement.hasAttribute("closemenu")) { + popupChild.setAttribute( + "closemenu", + currElement.getAttribute("closemenu") + ); + } + + popupChild.addEventListener("command", function () { + let colindex = this.getAttribute("colindex"); + let column = tree.columns[colindex]; + if (column) { + var element = column.element; + element.hidden = !element.hidden; + } + }); + + aPopup.insertBefore(popupChild, refChild); + } + } + + var hidden = !tree.enableColumnDrag; + aPopup.querySelectorAll(":scope > :not([colindex])").forEach(e => { + e.hidden = hidden; + }); + } + } + + customElements.define("treecolpicker", MozTreecolPicker); + + class MozTreecol extends MozElements.BaseControl { + static get observedAttributes() { + return ["primary", ...super.observedAttributes]; + } + + static get inheritedAttributes() { + return { + ".treecol-sortdirection": "sortdirection,hidden=hideheader", + ".treecol-text": "value=label,crop", + }; + } + + static get markup() { + return ` + <label class="treecol-text" flex="1" crop="end"></label> + <image class="treecol-sortdirection"></image> + `; + } + + get _tree() { + return this.parentNode?.parentNode; + } + + _invalidate() { + let tree = this._tree; + if (!tree || !XULTreeElement.isInstance(tree)) { + return; + } + tree.invalidate(); + tree.columns?.invalidateColumns(); + } + + constructor() { + super(); + + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + if (this._tree.enableColumnDrag) { + var XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + var cols = this.parentNode.getElementsByTagNameNS(XUL_NS, "treecol"); + + // only start column drag operation if there are at least 2 visible columns + var visible = 0; + for (var i = 0; i < cols.length; ++i) { + if (cols[i].getBoundingClientRect().width > 0) { + ++visible; + } + } + + if (visible > 1) { + window.addEventListener("mousemove", this._onDragMouseMove, true); + window.addEventListener("mouseup", this._onDragMouseUp, true); + document.treecolDragging = this; + this.mDragGesturing = true; + this.mStartDragX = event.clientX; + this.mStartDragY = event.clientY; + } + } + }); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + if (event.target != event.originalTarget) { + return; + } + + // On Windows multiple clicking on tree columns only cycles one time + // every 2 clicks. + if (AppConstants.platform == "win" && event.detail % 2 == 0) { + return; + } + + var tree = this._tree; + if (tree.columns) { + tree.view.cycleHeader(tree.columns.getColumnFor(this)); + } + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + if (this.hasAttribute("ordinal")) { + this.style.order = this.getAttribute("ordinal"); + } + if (this.hasAttribute("width")) { + this.style.width = this.getAttribute("width") + "px"; + } + + this._resizeObserver = new ResizeObserver(() => { + this._invalidate(); + }); + this._resizeObserver.observe(this); + } + + disconnectedCallback() { + this._resizeObserver?.unobserve(this); + this._resizeObserver = null; + } + + attributeChangedCallback(name, oldValue, newValue) { + super.attributeChangedCallback(name, oldValue, newValue); + this._invalidate(); + } + + set ordinal(val) { + this.style.order = val; + this.setAttribute("ordinal", val); + } + + get ordinal() { + var val = this.style.order; + if (val == "") { + return "1"; + } + + return "" + (val == "0" ? 0 : parseInt(val)); + } + + get _previousVisibleColumn() { + var tree = this.parentNode.parentNode; + let sib = tree.columns.getColumnFor(this).previousColumn; + while (sib) { + if (sib.element && sib.element.getBoundingClientRect().width > 0) { + return sib.element; + } + + sib = sib.previousColumn; + } + + return null; + } + + _onDragMouseMove(aEvent) { + var col = document.treecolDragging; + if (!col) { + return; + } + + // determine if we have moved the mouse far enough + // to initiate a drag + if (col.mDragGesturing) { + if ( + Math.abs(aEvent.clientX - col.mStartDragX) < 5 && + Math.abs(aEvent.clientY - col.mStartDragY) < 5 + ) { + return; + } + col.mDragGesturing = false; + col.setAttribute("dragging", "true"); + window.addEventListener("click", col._onDragMouseClick, true); + } + + var pos = {}; + var targetCol = col.parentNode.parentNode._getColumnAtX( + aEvent.clientX, + 0.5, + pos + ); + + // bail if we haven't mousemoved to a different column + if (col.mTargetCol == targetCol && col.mTargetDir == pos.value) { + return; + } + + var tree = col.parentNode.parentNode; + var sib; + var column; + if (col.mTargetCol) { + // remove previous insertbefore/after attributes + col.mTargetCol.removeAttribute("insertbefore"); + col.mTargetCol.removeAttribute("insertafter"); + column = tree.columns.getColumnFor(col.mTargetCol); + tree.invalidateColumn(column); + sib = col.mTargetCol._previousVisibleColumn; + if (sib) { + sib.removeAttribute("insertafter"); + column = tree.columns.getColumnFor(sib); + tree.invalidateColumn(column); + } + col.mTargetCol = null; + col.mTargetDir = null; + } + + if (targetCol) { + // set insertbefore/after attributes + if (pos.value == "after") { + targetCol.setAttribute("insertafter", "true"); + } else { + targetCol.setAttribute("insertbefore", "true"); + sib = targetCol._previousVisibleColumn; + if (sib) { + sib.setAttribute("insertafter", "true"); + column = tree.columns.getColumnFor(sib); + tree.invalidateColumn(column); + } + } + column = tree.columns.getColumnFor(targetCol); + tree.invalidateColumn(column); + col.mTargetCol = targetCol; + col.mTargetDir = pos.value; + } + } + + _onDragMouseUp(aEvent) { + var col = document.treecolDragging; + if (!col) { + return; + } + + if (!col.mDragGesturing) { + if (col.mTargetCol) { + // remove insertbefore/after attributes + var before = col.mTargetCol.hasAttribute("insertbefore"); + col.mTargetCol.removeAttribute( + before ? "insertbefore" : "insertafter" + ); + + var sib = col.mTargetCol._previousVisibleColumn; + if (before && sib) { + sib.removeAttribute("insertafter"); + } + + // Move the column only if it will result in a different column + // ordering + var move = true; + + // If this is a before move and the previous visible column is + // the same as the column we're moving, don't move + if (before && col == sib) { + move = false; + } else if (!before && col == col.mTargetCol) { + // If this is an after move and the column we're moving is + // the same as the target column, don't move. + move = false; + } + + if (move) { + col.parentNode.parentNode._reorderColumn( + col, + col.mTargetCol, + before + ); + } + + // repaint to remove lines + col.parentNode.parentNode.invalidate(); + + col.mTargetCol = null; + } + } else { + col.mDragGesturing = false; + } + + document.treecolDragging = null; + col.removeAttribute("dragging"); + + window.removeEventListener("mousemove", col._onDragMouseMove, true); + window.removeEventListener("mouseup", col._onDragMouseUp, true); + // we have to wait for the click event to fire before removing + // cancelling handler + var clickHandler = function (handler) { + window.removeEventListener("click", handler, true); + }; + window.setTimeout(clickHandler, 0, col._onDragMouseClick); + } + + _onDragMouseClick(aEvent) { + // prevent click event from firing after column drag and drop + aEvent.stopPropagation(); + aEvent.preventDefault(); + } + } + + customElements.define("treecol", MozTreecol); + + class MozTreecols extends MozElements.BaseControl { + static get inheritedAttributes() { + return { + treecolpicker: "tooltiptext=pickertooltiptext", + }; + } + + static get markup() { + return ` + <treecolpicker fixed="true"></treecolpicker> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.setAttribute("slot", "treecols"); + + if (!this.querySelector("treecolpicker")) { + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + } + + // Set resizeafter="farthest" on the splitters if nothing else has been + // specified. + for (let splitter of this.getElementsByTagName("splitter")) { + if (!splitter.hasAttribute("resizeafter")) { + splitter.setAttribute("resizeafter", "farthest"); + } + } + } + } + + customElements.define("treecols", MozTreecols); + + class MozTree extends MozElements.BaseControlMixin( + MozElements.MozElementMixin(XULTreeElement) + ) { + static get markup() { + return ` + <html:link rel="stylesheet" href="chrome://global/content/widgets.css" /> + <html:slot name="treecols"></html:slot> + <stack class="tree-stack" flex="1"> + <hbox class="tree-rows" flex="1"> + <hbox flex="1" class="tree-bodybox"> + <html:slot name="treechildren"></html:slot> + </hbox> + <scrollbar height="0" minwidth="0" minheight="0" orient="vertical" + class="hidevscroll-scrollbar scrollbar-topmost" + ></scrollbar> + </hbox> + <html:input class="tree-input" type="text" hidden="true"/> + </stack> + <hbox class="hidehscroll-box"> + <scrollbar orient="horizontal" flex="1" increment="16" class="scrollbar-topmost" ></scrollbar> + <scrollcorner class="hidevscroll-scrollcorner"></scrollcorner> + </hbox> + `; + } + + constructor() { + super(); + + // These enumerated constants are used as the first argument to + // _ensureColumnOrder to specify what column ordering should be used. + this.CURRENT_ORDER = 0; + this.NATURAL_ORDER = 1; // The original order, which is the DOM ordering + + this.attachShadow({ mode: "open" }); + let handledElements = this.constructor.fragment.querySelectorAll( + "scrollbar,scrollcorner" + ); + let stopAndPrevent = e => { + e.stopPropagation(); + e.preventDefault(); + }; + let stopProp = e => e.stopPropagation(); + for (let el of handledElements) { + el.addEventListener("click", stopAndPrevent); + el.addEventListener("contextmenu", stopAndPrevent); + el.addEventListener("dblclick", stopProp); + el.addEventListener("command", stopProp); + } + this.shadowRoot.appendChild(this.constructor.fragment); + + this.#verticalScrollbar = this.shadowRoot.querySelector( + "scrollbar[orient='vertical']" + ); + } + + static get inheritedAttributes() { + return { + ".hidehscroll-box": "collapsed=hidehscroll", + ".hidevscroll-scrollbar": "collapsed=hidevscroll", + ".hidevscroll-scrollcorner": "collapsed=hidevscroll", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + if (!this._eventListenersSetup) { + this._eventListenersSetup = true; + this.setupEventListeners(); + } + + this.setAttribute("hidevscroll", "true"); + this.setAttribute("hidehscroll", "true"); + + this.initializeAttributeInheritance(); + + this.pageUpOrDownMovesSelection = AppConstants.platform != "macosx"; + + this._inputField = null; + + this._editingRow = -1; + + this._editingColumn = null; + + this._columnsDirty = true; + + this._lastKeyTime = 0; + + this._incrementalString = ""; + + this._touchY = -1; + } + + setupEventListeners() { + this.addEventListener("underflow", event => { + // Scrollport event orientation + // 0: vertical + // 1: horizontal + // 2: both (not used) + if (event.target.tagName != "treechildren") { + return; + } + if (event.detail == 1) { + this.setAttribute("hidehscroll", "true"); + } else if (event.detail == 0) { + this.setAttribute("hidevscroll", "true"); + } + event.stopPropagation(); + }); + + this.addEventListener("overflow", event => { + if (event.target.tagName != "treechildren") { + return; + } + if (event.detail == 1) { + this.removeAttribute("hidehscroll"); + } else if (event.detail == 0) { + this.removeAttribute("hidevscroll"); + } + event.stopPropagation(); + }); + + this.addEventListener("touchstart", event => { + function isScrollbarElement(target) { + return ( + (target.localName == "thumb" || target.localName == "slider") && + target.namespaceURI == + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + ); + } + if ( + event.touches.length > 1 || + isScrollbarElement(event.touches[0].target) + ) { + // 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). + // Additionally, if the user lands on the scrollbar don't use this + // code for scrolling, instead allow gecko to handle scrollbar + // interaction normally. + this._touchY = -1; + } else { + this._touchY = event.touches[0].screenY; + } + }); + + this.addEventListener("touchmove", event => { + if (event.touches.length == 1 && this._touchY >= 0) { + var deltaY = this._touchY - event.touches[0].screenY; + var lines = Math.trunc(deltaY / this.rowHeight); + if (Math.abs(lines) > 0) { + this.scrollByLines(lines); + deltaY -= lines * this.rowHeight; + this._touchY = event.touches[0].screenY + deltaY; + } + event.preventDefault(); + } + }); + + this.addEventListener("touchend", event => { + this._touchY = -1; + }); + + // This event doesn't retarget, so listen on the shadow DOM directly + this.shadowRoot.addEventListener("MozMousePixelScroll", event => { + if (this.#canScroll(event)) { + event.preventDefault(); + } + }); + + // This event doesn't retarget, so listen on the shadow DOM directly + this.shadowRoot.addEventListener("DOMMouseScroll", event => { + if (!this.#canScroll(event)) { + return; + } + + event.preventDefault(); + + if (this._editingColumn) { + return; + } + + var rows = event.detail; + if (rows == UIEvent.SCROLL_PAGE_UP) { + this.scrollByPages(-1); + } else if (rows == UIEvent.SCROLL_PAGE_DOWN) { + this.scrollByPages(1); + } else { + this.scrollByLines(rows); + } + }); + + this.addEventListener("MozSwipeGesture", event => { + // Figure out which row to show + let targetRow = 0; + + // Only handle swipe gestures up and down + switch (event.direction) { + case event.DIRECTION_DOWN: + targetRow = this.view.rowCount - 1; + // Fall through for actual action + case event.DIRECTION_UP: + this.ensureRowIsVisible(targetRow); + break; + } + }); + + this.addEventListener("select", event => { + if (event.originalTarget == this) { + this.stopEditing(true); + } + }); + + this.addEventListener("focus", event => { + this.focused = true; + if (this.currentIndex == -1 && this.view.rowCount > 0) { + this.currentIndex = this.getFirstVisibleRow(); + } + }); + + this.addEventListener( + "blur", + event => { + this.focused = false; + if (event.target == this.inputField) { + this.stopEditing(true); + } + }, + true + ); + + this.addEventListener("keydown", event => { + if (event.altKey) { + return; + } + + let toggleClose = () => { + if (this._editingColumn) { + return; + } + + let row = this.currentIndex; + if (row < 0) { + return; + } + + if (this.changeOpenState(this.currentIndex, false)) { + event.preventDefault(); + return; + } + + let parentIndex = this.view.getParentIndex(this.currentIndex); + if (parentIndex >= 0) { + this.view.selection.select(parentIndex); + this.ensureRowIsVisible(parentIndex); + event.preventDefault(); + } + }; + + let toggleOpen = () => { + if (this._editingColumn) { + return; + } + + let row = this.currentIndex; + if (row < 0) { + return; + } + + if (this.changeOpenState(row, true)) { + event.preventDefault(); + return; + } + let c = row + 1; + let view = this.view; + if (c < view.rowCount && view.getParentIndex(c) == row) { + // If already opened, select the first child. + // The getParentIndex test above ensures that the children + // are already populated and ready. + this.view.selection.timedSelect(c, this._selectDelay); + this.ensureRowIsVisible(c); + event.preventDefault(); + } + }; + + switch (event.keyCode) { + case KeyEvent.DOM_VK_RETURN: { + if (this._handleEnter(event)) { + event.stopPropagation(); + event.preventDefault(); + } + break; + } + case KeyEvent.DOM_VK_ESCAPE: { + if (this._editingColumn) { + this.stopEditing(false); + this.focus(); + event.stopPropagation(); + event.preventDefault(); + } + break; + } + case KeyEvent.DOM_VK_LEFT: { + if (!this.isRTL) { + toggleClose(); + } else { + toggleOpen(); + } + break; + } + case KeyEvent.DOM_VK_RIGHT: { + if (!this.isRTL) { + toggleOpen(); + } else { + toggleClose(); + } + break; + } + case KeyEvent.DOM_VK_UP: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByOffsetShift(-1, 0, event); + } else { + this._moveByOffset(-1, 0, event); + } + break; + } + case KeyEvent.DOM_VK_DOWN: { + if (this._editingColumn) { + return; + } + if (event.getModifierState("Shift")) { + this._moveByOffsetShift(1, this.view.rowCount - 1, event); + } else { + this._moveByOffset(1, this.view.rowCount - 1, event); + } + break; + } + case KeyEvent.DOM_VK_PAGE_UP: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByPageShift(-1, 0, event); + } else { + this._moveByPage(-1, 0, event); + } + break; + } + case KeyEvent.DOM_VK_PAGE_DOWN: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveByPageShift(1, this.view.rowCount - 1, event); + } else { + this._moveByPage(1, this.view.rowCount - 1, event); + } + break; + } + case KeyEvent.DOM_VK_HOME: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveToEdgeShift(0, event); + } else { + this._moveToEdge(0, event); + } + break; + } + case KeyEvent.DOM_VK_END: { + if (this._editingColumn) { + return; + } + + if (event.getModifierState("Shift")) { + this._moveToEdgeShift(this.view.rowCount - 1, event); + } else { + this._moveToEdge(this.view.rowCount - 1, event); + } + break; + } + } + }); + + this.addEventListener("keypress", event => { + if (this._editingColumn) { + return; + } + + if (event.charCode == " ".charCodeAt(0)) { + var c = this.currentIndex; + if ( + !this.view.selection.isSelected(c) || + (!this.view.selection.single && event.getModifierState("Accel")) + ) { + this.view.selection.toggleSelect(c); + event.preventDefault(); + } + } else if ( + !this.disableKeyNavigation && + event.charCode > 0 && + !event.altKey && + !event.getModifierState("Accel") && + !event.metaKey && + !event.ctrlKey + ) { + var l = this._keyNavigate(event); + if (l >= 0) { + this.view.selection.timedSelect(l, this._selectDelay); + this.ensureRowIsVisible(l); + } + event.preventDefault(); + } + }); + } + + get body() { + return this.treeBody; + } + + get isRTL() { + return document.defaultView.getComputedStyle(this).direction == "rtl"; + } + + set editable(val) { + if (val) { + this.setAttribute("editable", "true"); + } else { + this.removeAttribute("editable"); + } + } + + get editable() { + return this.getAttribute("editable") == "true"; + } + /** + * ///////////////// nsIDOMXULSelectControlElement ///////////////// ///////////////// nsIDOMXULMultiSelectControlElement ///////////////// + */ + set selType(val) { + this.setAttribute("seltype", val); + } + + get selType() { + return this.getAttribute("seltype"); + } + + set currentIndex(val) { + if (this.view) { + this.view.selection.currentIndex = val; + } + } + + get currentIndex() { + if (this.view && this.view.selection) { + return this.view.selection.currentIndex; + } + return -1; + } + + set keepCurrentInView(val) { + if (val) { + this.setAttribute("keepcurrentinview", "true"); + } else { + this.removeAttribute("keepcurrentinview"); + } + } + + get keepCurrentInView() { + return this.getAttribute("keepcurrentinview") == "true"; + } + + set enableColumnDrag(val) { + if (val) { + this.setAttribute("enableColumnDrag", "true"); + } else { + this.removeAttribute("enableColumnDrag"); + } + } + + get enableColumnDrag() { + return this.hasAttribute("enableColumnDrag"); + } + + get inputField() { + if (!this._inputField) { + this._inputField = this.shadowRoot.querySelector(".tree-input"); + this._inputField.addEventListener("blur", () => this.stopEditing(true)); + } + return this._inputField; + } + + set disableKeyNavigation(val) { + if (val) { + this.setAttribute("disableKeyNavigation", "true"); + } else { + this.removeAttribute("disableKeyNavigation"); + } + } + + get disableKeyNavigation() { + return this.hasAttribute("disableKeyNavigation"); + } + + get editingRow() { + return this._editingRow; + } + + get editingColumn() { + return this._editingColumn; + } + + set _selectDelay(val) { + this.setAttribute("_selectDelay", val); + } + + get _selectDelay() { + return this.getAttribute("_selectDelay") || 50; + } + + // The first argument (order) can be either one of these constants: + // this.CURRENT_ORDER + // this.NATURAL_ORDER + _ensureColumnOrder(order = this.CURRENT_ORDER) { + if (this.columns) { + // update the ordinal position of each column to assure that it is + // an odd number and 2 positions above its next sibling + var cols = []; + + if (order == this.CURRENT_ORDER) { + for ( + let col = this.columns.getFirstColumn(); + col; + col = col.getNext() + ) { + cols.push(col.element); + } + } else { + // order == this.NATURAL_ORDER + cols = this.getElementsByTagName("treecol"); + } + + for (let i = 0; i < cols.length; ++i) { + cols[i].ordinal = i * 2 + 1; + } + // update the ordinal positions of splitters to even numbers, so that + // they are in between columns + var splitters = this.getElementsByTagName("splitter"); + for (let i = 0; i < splitters.length; ++i) { + splitters[i].style.order = (i + 1) * 2; + } + } + } + + _reorderColumn(aColMove, aColBefore, aBefore) { + this._ensureColumnOrder(); + + var i; + var cols = []; + var col = this.columns.getColumnFor(aColBefore); + if (parseInt(aColBefore.ordinal) < parseInt(aColMove.ordinal)) { + if (aBefore) { + cols.push(aColBefore); + } + for ( + col = col.getNext(); + col.element != aColMove; + col = col.getNext() + ) { + cols.push(col.element); + } + + aColMove.ordinal = cols[0].ordinal; + for (i = 0; i < cols.length; ++i) { + cols[i].ordinal = parseInt(cols[i].ordinal) + 2; + } + } else if (aColBefore.ordinal != aColMove.ordinal) { + if (!aBefore) { + cols.push(aColBefore); + } + for ( + col = col.getPrevious(); + col.element != aColMove; + col = col.getPrevious() + ) { + cols.push(col.element); + } + + aColMove.ordinal = cols[0].ordinal; + for (i = 0; i < cols.length; ++i) { + cols[i].ordinal = parseInt(cols[i].ordinal) - 2; + } + } else { + return; + } + this.columns.invalidateColumns(); + } + + _getColumnAtX(aX, aThresh, aPos) { + let isRTL = this.isRTL; + + if (aPos) { + aPos.value = isRTL ? "after" : "before"; + } + + var columns = []; + var col = this.columns.getFirstColumn(); + while (col) { + columns.push(col); + col = col.getNext(); + } + if (isRTL) { + columns.reverse(); + } + var currentX = this.getBoundingClientRect().x; + var adjustedX = aX + this.horizontalPosition; + for (var i = 0; i < columns.length; ++i) { + col = columns[i]; + var cw = col.element.getBoundingClientRect().width; + if (cw > 0) { + currentX += cw; + if (currentX - cw * aThresh > adjustedX) { + return col.element; + } + } + } + + if (aPos) { + aPos.value = isRTL ? "before" : "after"; + } + return columns.pop().element; + } + + changeOpenState(row, openState) { + if (row < 0 || !this.view.isContainer(row)) { + return false; + } + + if (this.view.isContainerOpen(row) != openState) { + this.view.toggleOpenState(row); + if (row == this.currentIndex) { + // Only fire event when current row is expanded or collapsed + // because that's all the assistive technology really cares about. + var event = document.createEvent("Events"); + event.initEvent("OpenStateChange", true, true); + this.dispatchEvent(event); + } + return true; + } + return false; + } + + _keyNavigate(event) { + var key = String.fromCharCode(event.charCode).toLowerCase(); + if (event.timeStamp - this._lastKeyTime > 1000) { + this._incrementalString = key; + } else { + this._incrementalString += key; + } + this._lastKeyTime = event.timeStamp; + + var length = this._incrementalString.length; + var incrementalString = this._incrementalString; + var charIndex = 1; + while ( + charIndex < length && + incrementalString[charIndex] == incrementalString[charIndex - 1] + ) { + charIndex++; + } + // If all letters in incremental string are same, just try to match the first one + if (charIndex == length) { + length = 1; + incrementalString = incrementalString.substring(0, length); + } + + var keyCol = this.columns.getKeyColumn(); + var rowCount = this.view.rowCount; + var start = 1; + + var c = this.currentIndex; + if (length > 1) { + start = 0; + if (c < 0) { + c = 0; + } + } + + for (var i = 0; i < rowCount; i++) { + var l = (i + start + c) % rowCount; + var cellText = this.view.getCellText(l, keyCol); + cellText = cellText.substring(0, length).toLowerCase(); + if (cellText == incrementalString) { + return l; + } + } + return -1; + } + + startEditing(row, column) { + if (!this.editable) { + return false; + } + if (row < 0 || row >= this.view.rowCount || !column) { + return false; + } + if (column.type !== window.TreeColumn.TYPE_TEXT) { + return false; + } + if (column.cycler || !this.view.isEditable(row, column)) { + return false; + } + + // Beyond this point, we are going to edit the cell. + if (this._editingColumn) { + this.stopEditing(); + } + + var input = this.inputField; + + this.ensureCellIsVisible(row, column); + + // Get the coordinates of the text inside the cell. + var textRect = this.getCoordsForCellItem(row, column, "text"); + + // Get the coordinates of the cell itself. + var cellRect = this.getCoordsForCellItem(row, column, "cell"); + + // Calculate the top offset of the textbox. + var style = window.getComputedStyle(input); + var topadj = parseInt(style.borderTopWidth) + parseInt(style.paddingTop); + input.style.top = `${textRect.y - topadj}px`; + + // The leftside of the textbox is aligned to the left side of the text + // in LTR mode, and left side of the cell in RTL mode. + let left = style.direction == "rtl" ? cellRect.x : textRect.x; + let scrollbarWidth = window.windowUtils.getBoundsWithoutFlushing( + this.#verticalScrollbar + ).width; + // Note: this won't be quite right in RTL for trees using twisties + // or indentation. bug 1708159 tracks fixing the implementation + // of getCoordsForCellItem which we called above so it provides + // better numbers in those cases. + let widthdiff = Math.abs(textRect.x - cellRect.x) - scrollbarWidth; + + input.style.left = `${left}px`; + input.style.height = `${ + textRect.height + + topadj + + parseInt(style.borderBottomWidth) + + parseInt(style.paddingBottom) + }px`; + input.style.width = `${cellRect.width - widthdiff}px`; + input.hidden = false; + + input.value = this.view.getCellText(row, column); + + input.select(); + input.focus(); + + this._editingRow = row; + this._editingColumn = column; + this.setAttribute("editing", "true"); + + this.invalidateCell(row, column); + return true; + } + + stopEditing(accept) { + if (!this._editingColumn) { + return; + } + + var input = this.inputField; + var editingRow = this._editingRow; + var editingColumn = this._editingColumn; + this._editingRow = -1; + this._editingColumn = null; + + // `this.view` could be null if the tree was hidden before we were called. + if (accept && this.view) { + var value = input.value; + this.view.setCellText(editingRow, editingColumn, value); + } + input.hidden = true; + input.value = ""; + this.removeAttribute("editing"); + } + + _moveByOffset(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (event.getModifierState("Accel") && this.view.selection.single) { + this.scrollByLines(offset); + return; + } + + var c = this.currentIndex + offset; + if (offset > 0 ? c > edge : c < edge) { + if ( + this.view.selection.isSelected(edge) && + this.view.selection.count <= 1 + ) { + return; + } + c = edge; + } + + if (!event.getModifierState("Accel")) { + this.view.selection.timedSelect(c, this._selectDelay); + } + // Ctrl+Up/Down moves the anchor without selecting + else { + this.currentIndex = c; + } + this.ensureRowIsVisible(c); + } + + _moveByOffsetShift(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.view.selection.single) { + this.scrollByLines(offset); + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + var c = this.currentIndex; + if (c == -1) { + c = 0; + } + + if (c == edge) { + if (this.view.selection.isSelected(c)) { + return; + } + } + + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + c + offset, + event.getModifierState("Accel") + ); + this.ensureRowIsVisible(c + offset); + } + + _moveByPage(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.pageUpOrDownMovesSelection == event.getModifierState("Accel")) { + this.scrollByPages(offset); + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + var c = this.currentIndex; + if (c == -1) { + return; + } + + if (c == edge && this.view.selection.isSelected(c)) { + this.ensureRowIsVisible(c); + return; + } + var i = this.getFirstVisibleRow(); + var p = this.getPageLength(); + + if (offset > 0) { + i += p - 1; + if (c >= i) { + i = c + p; + this.ensureRowIsVisible(i > edge ? edge : i); + } + i = i > edge ? edge : i; + } else if (c <= i) { + i = c <= p ? 0 : c - p; + this.ensureRowIsVisible(i); + } + this.view.selection.timedSelect(i, this._selectDelay); + } + + _moveByPageShift(offset, edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if ( + this.view.rowCount == 1 && + !this.view.selection.isSelected(0) && + !(this.pageUpOrDownMovesSelection == event.getModifierState("Accel")) + ) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + if (this.view.selection.single) { + return; + } + + var c = this.currentIndex; + if (c == -1) { + return; + } + if (c == edge && this.view.selection.isSelected(c)) { + this.ensureRowIsVisible(edge); + return; + } + var i = this.getFirstVisibleRow(); + var p = this.getPageLength(); + + if (offset > 0) { + i += p - 1; + if (c >= i) { + i = c + p; + this.ensureRowIsVisible(i > edge ? edge : i); + } + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + i > edge ? edge : i, + event.getModifierState("Accel") + ); + } else { + if (c <= i) { + i = c <= p ? 0 : c - p; + this.ensureRowIsVisible(i); + } + // Extend the selection from the existing pivot, if any + this.view.selection.rangedSelect( + -1, + i, + event.getModifierState("Accel") + ); + } + } + + _moveToEdge(edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if ( + this.view.selection.isSelected(edge) && + this.view.selection.count == 1 + ) { + this.currentIndex = edge; + return; + } + + // Normal behaviour is to select the first/last row + if (!event.getModifierState("Accel")) { + this.view.selection.timedSelect(edge, this._selectDelay); + } + // In a multiselect tree Ctrl+Home/End moves the anchor + else if (!this.view.selection.single) { + this.currentIndex = edge; + } + + this.ensureRowIsVisible(edge); + } + + _moveToEdgeShift(edge, event) { + event.preventDefault(); + + if (this.view.rowCount == 0) { + return; + } + + if (this.view.rowCount == 1 && !this.view.selection.isSelected(0)) { + this.view.selection.timedSelect(0, this._selectDelay); + return; + } + + if ( + this.view.selection.single || + (this.view.selection.isSelected(edge) && + this.view.selection.isSelected(this.currentIndex)) + ) { + return; + } + + // Extend the selection from the existing pivot, if any. + // -1 doesn't work here, so using currentIndex instead + this.view.selection.rangedSelect( + this.currentIndex, + edge, + event.getModifierState("Accel") + ); + + this.ensureRowIsVisible(edge); + } + + _handleEnter(event) { + if (this._editingColumn) { + this.stopEditing(true); + this.focus(); + return true; + } + + return this.changeOpenState(this.currentIndex); + } + + #verticalScrollbar = null; + #lastScrollEventTimeStampMap = new Map(); + + #canScroll(event) { + const lastScrollEventTimeStamp = this.#lastScrollEventTimeStampMap.get( + event.type + ); + this.#lastScrollEventTimeStampMap.set(event.type, event.timeStamp); + + if ( + window.windowUtils.getWheelScrollTarget() || + event.axis == event.HORIZONTAL_AXIS || + (this.getAttribute("allowunderflowscroll") == "true" && + this.getAttribute("hidevscroll") == "true") + ) { + return false; + } + + if ( + event.timeStamp - (lastScrollEventTimeStamp ?? 0) < + Services.prefs.getIntPref("mousewheel.scroll_series_timeout") + ) { + // If the time difference of previous event does not over the timeout, + // handle the event in tree as the same seies of events even if the + // current position is edge. + return true; + } + + const curpos = Number(this.#verticalScrollbar.getAttribute("curpos")); + return ( + (event.detail < 0 && 0 < curpos) || + (event.detail > 0 && + curpos < Number(this.#verticalScrollbar.getAttribute("maxpos"))) + ); + } + } + + MozXULElement.implementCustomInterface(MozTree, [ + Ci.nsIDOMXULMultiSelectControlElement, + ]); + customElements.define("tree", MozTree); +} diff --git a/toolkit/content/widgets/vendor/lit.all.mjs b/toolkit/content/widgets/vendor/lit.all.mjs new file mode 100644 index 0000000000..ec542440ae --- /dev/null +++ b/toolkit/content/widgets/vendor/lit.all.mjs @@ -0,0 +1,4467 @@ +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const NODE_MODE$1 = false; +const global$2 = window; +/** + * Whether the current browser supports `adoptedStyleSheets`. + */ +const supportsAdoptingStyleSheets = global$2.ShadowRoot && + (global$2.ShadyCSS === undefined || global$2.ShadyCSS.nativeShadow) && + 'adoptedStyleSheets' in Document.prototype && + 'replace' in CSSStyleSheet.prototype; +const constructionToken = Symbol(); +const cssTagCache = new WeakMap(); +/** + * A container for a string of CSS text, that may be used to create a CSSStyleSheet. + * + * CSSResult is the return value of `css`-tagged template literals and + * `unsafeCSS()`. In order to ensure that CSSResults are only created via the + * `css` tag and `unsafeCSS()`, CSSResult cannot be constructed directly. + */ +class CSSResult { + constructor(cssText, strings, safeToken) { + // This property needs to remain unminified. + this['_$cssResult$'] = true; + if (safeToken !== constructionToken) { + throw new Error('CSSResult is not constructable. Use `unsafeCSS` or `css` instead.'); + } + this.cssText = cssText; + this._strings = strings; + } + // This is a getter so that it's lazy. In practice, this means stylesheets + // are not created until the first element instance is made. + get styleSheet() { + // If `supportsAdoptingStyleSheets` is true then we assume CSSStyleSheet is + // constructable. + let styleSheet = this._styleSheet; + const strings = this._strings; + if (supportsAdoptingStyleSheets && styleSheet === undefined) { + const cacheable = strings !== undefined && strings.length === 1; + if (cacheable) { + styleSheet = cssTagCache.get(strings); + } + if (styleSheet === undefined) { + (this._styleSheet = styleSheet = new CSSStyleSheet()).replaceSync(this.cssText); + if (cacheable) { + cssTagCache.set(strings, styleSheet); + } + } + } + return styleSheet; + } + toString() { + return this.cssText; + } +} +const textFromCSSResult = (value) => { + // This property needs to remain unminified. + if (value['_$cssResult$'] === true) { + return value.cssText; + } + else if (typeof value === 'number') { + return value; + } + else { + throw new Error(`Value passed to 'css' function must be a 'css' function result: ` + + `${value}. Use 'unsafeCSS' to pass non-literal values, but take care ` + + `to ensure page security.`); + } +}; +/** + * Wrap a value for interpolation in a {@linkcode css} tagged template literal. + * + * This is unsafe because untrusted CSS text can be used to phone home + * or exfiltrate data to an attacker controlled site. Take care to only use + * this with trusted input. + */ +const unsafeCSS = (value) => new CSSResult(typeof value === 'string' ? value : String(value), undefined, constructionToken); +/** + * A template literal tag which can be used with LitElement's + * {@linkcode LitElement.styles} property to set element styles. + * + * For security reasons, only literal string values and number may be used in + * embedded expressions. To incorporate non-literal values {@linkcode unsafeCSS} + * may be used inside an expression. + */ +const css = (strings, ...values) => { + const cssText = strings.length === 1 + ? strings[0] + : values.reduce((acc, v, idx) => acc + textFromCSSResult(v) + strings[idx + 1], strings[0]); + return new CSSResult(cssText, strings, constructionToken); +}; +/** + * Applies the given styles to a `shadowRoot`. When Shadow DOM is + * available but `adoptedStyleSheets` is not, styles are appended to the + * `shadowRoot` to [mimic spec behavior](https://wicg.github.io/construct-stylesheets/#using-constructed-stylesheets). + * Note, when shimming is used, any styles that are subsequently placed into + * the shadowRoot should be placed *before* any shimmed adopted styles. This + * will match spec behavior that gives adopted sheets precedence over styles in + * shadowRoot. + */ +const adoptStyles = (renderRoot, styles) => { + if (supportsAdoptingStyleSheets) { + renderRoot.adoptedStyleSheets = styles.map((s) => s instanceof CSSStyleSheet ? s : s.styleSheet); + } + else { + styles.forEach((s) => { + const style = document.createElement('style'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nonce = global$2['litNonce']; + if (nonce !== undefined) { + style.setAttribute('nonce', nonce); + } + style.textContent = s.cssText; + renderRoot.appendChild(style); + }); + } +}; +const cssResultFromStyleSheet = (sheet) => { + let cssText = ''; + for (const rule of sheet.cssRules) { + cssText += rule.cssText; + } + return unsafeCSS(cssText); +}; +const getCompatibleStyle = supportsAdoptingStyleSheets || + (NODE_MODE$1 ) + ? (s) => s + : (s) => s instanceof CSSStyleSheet ? cssResultFromStyleSheet(s) : s; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var _d$1; +var _e; +const global$1 = window; +const trustedTypes$1 = global$1 + .trustedTypes; +// Temporary workaround for https://crbug.com/993268 +// Currently, any attribute starting with "on" is considered to be a +// TrustedScript source. Such boolean attributes must be set to the equivalent +// trusted emptyScript value. +const emptyStringForBooleanAttribute$1 = trustedTypes$1 + ? trustedTypes$1.emptyScript + : ''; +const polyfillSupport$2 = global$1.reactiveElementPolyfillSupport; +/* + * When using Closure Compiler, JSCompiler_renameProperty(property, object) is + * replaced at compile time by the munged name for object[property]. We cannot + * alias this function, so we have to use a small shim that has the same + * behavior when not compiling. + */ +/*@__INLINE__*/ +const JSCompiler_renameProperty = (prop, _obj) => prop; +const defaultConverter = { + toAttribute(value, type) { + switch (type) { + case Boolean: + value = value ? emptyStringForBooleanAttribute$1 : null; + break; + case Object: + case Array: + // if the value is `null` or `undefined` pass this through + // to allow removing/no change behavior. + value = value == null ? value : JSON.stringify(value); + break; + } + return value; + }, + fromAttribute(value, type) { + let fromValue = value; + switch (type) { + case Boolean: + fromValue = value !== null; + break; + case Number: + fromValue = value === null ? null : Number(value); + break; + case Object: + case Array: + // Do *not* generate exception when invalid JSON is set as elements + // don't normally complain on being mis-configured. + // TODO(sorvell): Do generate exception in *dev mode*. + try { + // Assert to adhere to Bazel's "must type assert JSON parse" rule. + fromValue = JSON.parse(value); + } + catch (e) { + fromValue = null; + } + break; + } + return fromValue; + }, +}; +/** + * Change function that returns true if `value` is different from `oldValue`. + * This method is used as the default for a property's `hasChanged` function. + */ +const notEqual = (value, old) => { + // This ensures (old==NaN, value==NaN) always returns false + return old !== value && (old === old || value === value); +}; +const defaultPropertyDeclaration = { + attribute: true, + type: String, + converter: defaultConverter, + reflect: false, + hasChanged: notEqual, +}; +/** + * The Closure JS Compiler doesn't currently have good support for static + * property semantics where "this" is dynamic (e.g. + * https://github.com/google/closure-compiler/issues/3177 and others) so we use + * this hack to bypass any rewriting by the compiler. + */ +const finalized = 'finalized'; +/** + * Base element class which manages element properties and attributes. When + * properties change, the `update` method is asynchronously called. This method + * should be supplied by subclassers to render updates as desired. + * @noInheritDoc + */ +class ReactiveElement extends HTMLElement { + constructor() { + super(); + this.__instanceProperties = new Map(); + /** + * True if there is a pending update as a result of calling `requestUpdate()`. + * Should only be read. + * @category updates + */ + this.isUpdatePending = false; + /** + * Is set to `true` after the first update. The element code cannot assume + * that `renderRoot` exists before the element `hasUpdated`. + * @category updates + */ + this.hasUpdated = false; + /** + * Name of currently reflecting property + */ + this.__reflectingProperty = null; + this._initialize(); + } + /** + * Adds an initializer function to the class that is called during instance + * construction. + * + * This is useful for code that runs against a `ReactiveElement` + * subclass, such as a decorator, that needs to do work for each + * instance, such as setting up a `ReactiveController`. + * + * ```ts + * const myDecorator = (target: typeof ReactiveElement, key: string) => { + * target.addInitializer((instance: ReactiveElement) => { + * // This is run during construction of the element + * new MyController(instance); + * }); + * } + * ``` + * + * Decorating a field will then cause each instance to run an initializer + * that adds a controller: + * + * ```ts + * class MyElement extends LitElement { + * @myDecorator foo; + * } + * ``` + * + * Initializers are stored per-constructor. Adding an initializer to a + * subclass does not add it to a superclass. Since initializers are run in + * constructors, initializers will run in order of the class hierarchy, + * starting with superclasses and progressing to the instance's class. + * + * @nocollapse + */ + static addInitializer(initializer) { + var _a; + this.finalize(); + ((_a = this._initializers) !== null && _a !== void 0 ? _a : (this._initializers = [])).push(initializer); + } + /** + * Returns a list of attributes corresponding to the registered properties. + * @nocollapse + * @category attributes + */ + static get observedAttributes() { + // note: piggy backing on this to ensure we're finalized. + this.finalize(); + const attributes = []; + // Use forEach so this works even if for/of loops are compiled to for loops + // expecting arrays + this.elementProperties.forEach((v, p) => { + const attr = this.__attributeNameForProperty(p, v); + if (attr !== undefined) { + this.__attributeToPropertyMap.set(attr, p); + attributes.push(attr); + } + }); + return attributes; + } + /** + * Creates a property accessor on the element prototype if one does not exist + * and stores a {@linkcode PropertyDeclaration} for the property with the + * given options. The property setter calls the property's `hasChanged` + * property option or uses a strict identity check to determine whether or not + * to request an update. + * + * This method may be overridden to customize properties; however, + * when doing so, it's important to call `super.createProperty` to ensure + * the property is setup correctly. This method calls + * `getPropertyDescriptor` internally to get a descriptor to install. + * To customize what properties do when they are get or set, override + * `getPropertyDescriptor`. To customize the options for a property, + * implement `createProperty` like this: + * + * ```ts + * static createProperty(name, options) { + * options = Object.assign(options, {myOption: true}); + * super.createProperty(name, options); + * } + * ``` + * + * @nocollapse + * @category properties + */ + static createProperty(name, options = defaultPropertyDeclaration) { + // if this is a state property, force the attribute to false. + if (options.state) { + // Cast as any since this is readonly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options.attribute = false; + } + // Note, since this can be called by the `@property` decorator which + // is called before `finalize`, we ensure finalization has been kicked off. + this.finalize(); + this.elementProperties.set(name, options); + // Do not generate an accessor if the prototype already has one, since + // it would be lost otherwise and that would never be the user's intention; + // Instead, we expect users to call `requestUpdate` themselves from + // user-defined accessors. Note that if the super has an accessor we will + // still overwrite it + if (!options.noAccessor && !this.prototype.hasOwnProperty(name)) { + const key = typeof name === 'symbol' ? Symbol() : `__${name}`; + const descriptor = this.getPropertyDescriptor(name, key, options); + if (descriptor !== undefined) { + Object.defineProperty(this.prototype, name, descriptor); + } + } + } + /** + * Returns a property descriptor to be defined on the given named property. + * If no descriptor is returned, the property will not become an accessor. + * For example, + * + * ```ts + * class MyElement extends LitElement { + * static getPropertyDescriptor(name, key, options) { + * const defaultDescriptor = + * super.getPropertyDescriptor(name, key, options); + * const setter = defaultDescriptor.set; + * return { + * get: defaultDescriptor.get, + * set(value) { + * setter.call(this, value); + * // custom action. + * }, + * configurable: true, + * enumerable: true + * } + * } + * } + * ``` + * + * @nocollapse + * @category properties + */ + static getPropertyDescriptor(name, key, options) { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get() { + return this[key]; + }, + set(value) { + const oldValue = this[name]; + this[key] = value; + this.requestUpdate(name, oldValue, options); + }, + configurable: true, + enumerable: true, + }; + } + /** + * Returns the property options associated with the given property. + * These options are defined with a `PropertyDeclaration` via the `properties` + * object or the `@property` decorator and are registered in + * `createProperty(...)`. + * + * Note, this method should be considered "final" and not overridden. To + * customize the options for a given property, override + * {@linkcode createProperty}. + * + * @nocollapse + * @final + * @category properties + */ + static getPropertyOptions(name) { + return this.elementProperties.get(name) || defaultPropertyDeclaration; + } + /** + * Creates property accessors for registered properties, sets up element + * styling, and ensures any superclasses are also finalized. Returns true if + * the element was finalized. + * @nocollapse + */ + static finalize() { + if (this.hasOwnProperty(finalized)) { + return false; + } + this[finalized] = true; + // finalize any superclasses + const superCtor = Object.getPrototypeOf(this); + superCtor.finalize(); + // Create own set of initializers for this class if any exist on the + // superclass and copy them down. Note, for a small perf boost, avoid + // creating initializers unless needed. + if (superCtor._initializers !== undefined) { + this._initializers = [...superCtor._initializers]; + } + this.elementProperties = new Map(superCtor.elementProperties); + // initialize Map populated in observedAttributes + this.__attributeToPropertyMap = new Map(); + // make any properties + // Note, only process "own" properties since this element will inherit + // any properties defined on the superClass, and finalization ensures + // the entire prototype chain is finalized. + if (this.hasOwnProperty(JSCompiler_renameProperty('properties'))) { + const props = this.properties; + // support symbols in properties (IE11 does not support this) + const propKeys = [ + ...Object.getOwnPropertyNames(props), + ...Object.getOwnPropertySymbols(props), + ]; + // This for/of is ok because propKeys is an array + for (const p of propKeys) { + // note, use of `any` is due to TypeScript lack of support for symbol in + // index types + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.createProperty(p, props[p]); + } + } + this.elementStyles = this.finalizeStyles(this.styles); + return true; + } + /** + * Takes the styles the user supplied via the `static styles` property and + * returns the array of styles to apply to the element. + * Override this method to integrate into a style management system. + * + * Styles are deduplicated preserving the _last_ instance in the list. This + * is a performance optimization to avoid duplicated styles that can occur + * especially when composing via subclassing. The last item is kept to try + * to preserve the cascade order with the assumption that it's most important + * that last added styles override previous styles. + * + * @nocollapse + * @category styles + */ + static finalizeStyles(styles) { + const elementStyles = []; + if (Array.isArray(styles)) { + // Dedupe the flattened array in reverse order to preserve the last items. + // Casting to Array<unknown> works around TS error that + // appears to come from trying to flatten a type CSSResultArray. + const set = new Set(styles.flat(Infinity).reverse()); + // Then preserve original order by adding the set items in reverse order. + for (const s of set) { + elementStyles.unshift(getCompatibleStyle(s)); + } + } + else if (styles !== undefined) { + elementStyles.push(getCompatibleStyle(styles)); + } + return elementStyles; + } + /** + * Returns the property name for the given attribute `name`. + * @nocollapse + */ + static __attributeNameForProperty(name, options) { + const attribute = options.attribute; + return attribute === false + ? undefined + : typeof attribute === 'string' + ? attribute + : typeof name === 'string' + ? name.toLowerCase() + : undefined; + } + /** + * Internal only override point for customizing work done when elements + * are constructed. + * + * @internal + */ + _initialize() { + var _a; + this.__updatePromise = new Promise((res) => (this.enableUpdating = res)); + this._$changedProperties = new Map(); + this.__saveInstanceProperties(); + // ensures first update will be caught by an early access of + // `updateComplete` + this.requestUpdate(); + (_a = this.constructor._initializers) === null || _a === void 0 ? void 0 : _a.forEach((i) => i(this)); + } + /** + * Registers a `ReactiveController` to participate in the element's reactive + * update cycle. The element automatically calls into any registered + * controllers during its lifecycle callbacks. + * + * If the element is connected when `addController()` is called, the + * controller's `hostConnected()` callback will be immediately called. + * @category controllers + */ + addController(controller) { + var _a, _b; + ((_a = this.__controllers) !== null && _a !== void 0 ? _a : (this.__controllers = [])).push(controller); + // If a controller is added after the element has been connected, + // call hostConnected. Note, re-using existence of `renderRoot` here + // (which is set in connectedCallback) to avoid the need to track a + // first connected state. + if (this.renderRoot !== undefined && this.isConnected) { + (_b = controller.hostConnected) === null || _b === void 0 ? void 0 : _b.call(controller); + } + } + /** + * Removes a `ReactiveController` from the element. + * @category controllers + */ + removeController(controller) { + var _a; + // Note, if the indexOf is -1, the >>> will flip the sign which makes the + // splice do nothing. + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.splice(this.__controllers.indexOf(controller) >>> 0, 1); + } + /** + * Fixes any properties set on the instance before upgrade time. + * Otherwise these would shadow the accessor and break these properties. + * The properties are stored in a Map which is played back after the + * constructor runs. Note, on very old versions of Safari (<=9) or Chrome + * (<=41), properties created for native platform properties like (`id` or + * `name`) may not have default values set in the element constructor. On + * these browsers native properties appear on instances and therefore their + * default value will overwrite any element default (e.g. if the element sets + * this.id = 'id' in the constructor, the 'id' will become '' since this is + * the native platform default). + */ + __saveInstanceProperties() { + // Use forEach so this works even if for/of loops are compiled to for loops + // expecting arrays + this.constructor.elementProperties.forEach((_v, p) => { + if (this.hasOwnProperty(p)) { + this.__instanceProperties.set(p, this[p]); + delete this[p]; + } + }); + } + /** + * Returns the node into which the element should render and by default + * creates and returns an open shadowRoot. Implement to customize where the + * element's DOM is rendered. For example, to render into the element's + * childNodes, return `this`. + * + * @return Returns a node into which to render. + * @category rendering + */ + createRenderRoot() { + var _a; + const renderRoot = (_a = this.shadowRoot) !== null && _a !== void 0 ? _a : this.attachShadow(this.constructor.shadowRootOptions); + adoptStyles(renderRoot, this.constructor.elementStyles); + return renderRoot; + } + /** + * On first connection, creates the element's renderRoot, sets up + * element styling, and enables updating. + * @category lifecycle + */ + connectedCallback() { + var _a; + // create renderRoot before first update. + if (this.renderRoot === undefined) { + this.renderRoot = this.createRenderRoot(); + } + this.enableUpdating(true); + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.forEach((c) => { var _a; return (_a = c.hostConnected) === null || _a === void 0 ? void 0 : _a.call(c); }); + } + /** + * Note, this method should be considered final and not overridden. It is + * overridden on the element instance with a function that triggers the first + * update. + * @category updates + */ + enableUpdating(_requestedUpdate) { } + /** + * Allows for `super.disconnectedCallback()` in extensions while + * reserving the possibility of making non-breaking feature additions + * when disconnecting at some point in the future. + * @category lifecycle + */ + disconnectedCallback() { + var _a; + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.forEach((c) => { var _a; return (_a = c.hostDisconnected) === null || _a === void 0 ? void 0 : _a.call(c); }); + } + /** + * Synchronizes property values when attributes change. + * + * Specifically, when an attribute is set, the corresponding property is set. + * You should rarely need to implement this callback. If this method is + * overridden, `super.attributeChangedCallback(name, _old, value)` must be + * called. + * + * See [using the lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks) + * on MDN for more information about the `attributeChangedCallback`. + * @category attributes + */ + attributeChangedCallback(name, _old, value) { + this._$attributeToProperty(name, value); + } + __propertyToAttribute(name, value, options = defaultPropertyDeclaration) { + var _a; + const attr = this.constructor.__attributeNameForProperty(name, options); + if (attr !== undefined && options.reflect === true) { + const converter = ((_a = options.converter) === null || _a === void 0 ? void 0 : _a.toAttribute) !== + undefined + ? options.converter + : defaultConverter; + const attrValue = converter.toAttribute(value, options.type); + // Track if the property is being reflected to avoid + // setting the property again via `attributeChangedCallback`. Note: + // 1. this takes advantage of the fact that the callback is synchronous. + // 2. will behave incorrectly if multiple attributes are in the reaction + // stack at time of calling. However, since we process attributes + // in `update` this should not be possible (or an extreme corner case + // that we'd like to discover). + // mark state reflecting + this.__reflectingProperty = name; + if (attrValue == null) { + this.removeAttribute(attr); + } + else { + this.setAttribute(attr, attrValue); + } + // mark state not reflecting + this.__reflectingProperty = null; + } + } + /** @internal */ + _$attributeToProperty(name, value) { + var _a; + const ctor = this.constructor; + // Note, hint this as an `AttributeMap` so closure clearly understands + // the type; it has issues with tracking types through statics + const propName = ctor.__attributeToPropertyMap.get(name); + // Use tracking info to avoid reflecting a property value to an attribute + // if it was just set because the attribute changed. + if (propName !== undefined && this.__reflectingProperty !== propName) { + const options = ctor.getPropertyOptions(propName); + const converter = typeof options.converter === 'function' + ? { fromAttribute: options.converter } + : ((_a = options.converter) === null || _a === void 0 ? void 0 : _a.fromAttribute) !== undefined + ? options.converter + : defaultConverter; + // mark state reflecting + this.__reflectingProperty = propName; + this[propName] = converter.fromAttribute(value, options.type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ); + // mark state not reflecting + this.__reflectingProperty = null; + } + } + /** + * Requests an update which is processed asynchronously. This should be called + * when an element should update based on some state not triggered by setting + * a reactive property. In this case, pass no arguments. It should also be + * called when manually implementing a property setter. In this case, pass the + * property `name` and `oldValue` to ensure that any configured property + * options are honored. + * + * @param name name of requesting property + * @param oldValue old value of requesting property + * @param options property options to use instead of the previously + * configured options + * @category updates + */ + requestUpdate(name, oldValue, options) { + let shouldRequestUpdate = true; + // If we have a property key, perform property update steps. + if (name !== undefined) { + options = + options || + this.constructor.getPropertyOptions(name); + const hasChanged = options.hasChanged || notEqual; + if (hasChanged(this[name], oldValue)) { + if (!this._$changedProperties.has(name)) { + this._$changedProperties.set(name, oldValue); + } + // Add to reflecting properties set. + // Note, it's important that every change has a chance to add the + // property to `_reflectingProperties`. This ensures setting + // attribute + property reflects correctly. + if (options.reflect === true && this.__reflectingProperty !== name) { + if (this.__reflectingProperties === undefined) { + this.__reflectingProperties = new Map(); + } + this.__reflectingProperties.set(name, options); + } + } + else { + // Abort the request if the property should not be considered changed. + shouldRequestUpdate = false; + } + } + if (!this.isUpdatePending && shouldRequestUpdate) { + this.__updatePromise = this.__enqueueUpdate(); + } + // Note, since this no longer returns a promise, in dev mode we return a + // thenable which warns if it's called. + return undefined; + } + /** + * Sets up the element to asynchronously update. + */ + async __enqueueUpdate() { + this.isUpdatePending = true; + try { + // Ensure any previous update has resolved before updating. + // This `await` also ensures that property changes are batched. + await this.__updatePromise; + } + catch (e) { + // Refire any previous errors async so they do not disrupt the update + // cycle. Errors are refired so developers have a chance to observe + // them, and this can be done by implementing + // `window.onunhandledrejection`. + Promise.reject(e); + } + const result = this.scheduleUpdate(); + // If `scheduleUpdate` returns a Promise, we await it. This is done to + // enable coordinating updates with a scheduler. Note, the result is + // checked to avoid delaying an additional microtask unless we need to. + if (result != null) { + await result; + } + return !this.isUpdatePending; + } + /** + * Schedules an element update. You can override this method to change the + * timing of updates by returning a Promise. The update will await the + * returned Promise, and you should resolve the Promise to allow the update + * to proceed. If this method is overridden, `super.scheduleUpdate()` + * must be called. + * + * For instance, to schedule updates to occur just before the next frame: + * + * ```ts + * override protected async scheduleUpdate(): Promise<unknown> { + * await new Promise((resolve) => requestAnimationFrame(() => resolve())); + * super.scheduleUpdate(); + * } + * ``` + * @category updates + */ + scheduleUpdate() { + return this.performUpdate(); + } + /** + * Performs an element update. Note, if an exception is thrown during the + * update, `firstUpdated` and `updated` will not be called. + * + * Call `performUpdate()` to immediately process a pending update. This should + * generally not be needed, but it can be done in rare cases when you need to + * update synchronously. + * + * Note: To ensure `performUpdate()` synchronously completes a pending update, + * it should not be overridden. In LitElement 2.x it was suggested to override + * `performUpdate()` to also customizing update scheduling. Instead, you should now + * override `scheduleUpdate()`. For backwards compatibility with LitElement 2.x, + * scheduling updates via `performUpdate()` continues to work, but will make + * also calling `performUpdate()` to synchronously process updates difficult. + * + * @category updates + */ + performUpdate() { + var _b; + // Abort any update if one is not pending when this is called. + // This can happen if `performUpdate` is called early to "flush" + // the update. + if (!this.isUpdatePending) { + return; + } + // create renderRoot before first update. + if (!this.hasUpdated) ; + // Mixin instance properties once, if they exist. + if (this.__instanceProperties) { + // Use forEach so this works even if for/of loops are compiled to for loops + // expecting arrays + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.__instanceProperties.forEach((v, p) => (this[p] = v)); + this.__instanceProperties = undefined; + } + let shouldUpdate = false; + const changedProperties = this._$changedProperties; + try { + shouldUpdate = this.shouldUpdate(changedProperties); + if (shouldUpdate) { + this.willUpdate(changedProperties); + (_b = this.__controllers) === null || _b === void 0 ? void 0 : _b.forEach((c) => { var _a; return (_a = c.hostUpdate) === null || _a === void 0 ? void 0 : _a.call(c); }); + this.update(changedProperties); + } + else { + this.__markUpdated(); + } + } + catch (e) { + // Prevent `firstUpdated` and `updated` from running when there's an + // update exception. + shouldUpdate = false; + // Ensure element can accept additional updates after an exception. + this.__markUpdated(); + throw e; + } + // The update is no longer considered pending and further updates are now allowed. + if (shouldUpdate) { + this._$didUpdate(changedProperties); + } + } + /** + * Invoked before `update()` to compute values needed during the update. + * + * Implement `willUpdate` to compute property values that depend on other + * properties and are used in the rest of the update process. + * + * ```ts + * willUpdate(changedProperties) { + * // only need to check changed properties for an expensive computation. + * if (changedProperties.has('firstName') || changedProperties.has('lastName')) { + * this.sha = computeSHA(`${this.firstName} ${this.lastName}`); + * } + * } + * + * render() { + * return html`SHA: ${this.sha}`; + * } + * ``` + * + * @category updates + */ + willUpdate(_changedProperties) { } + // Note, this is an override point for polyfill-support. + // @internal + _$didUpdate(changedProperties) { + var _a; + (_a = this.__controllers) === null || _a === void 0 ? void 0 : _a.forEach((c) => { var _a; return (_a = c.hostUpdated) === null || _a === void 0 ? void 0 : _a.call(c); }); + if (!this.hasUpdated) { + this.hasUpdated = true; + this.firstUpdated(changedProperties); + } + this.updated(changedProperties); + } + __markUpdated() { + this._$changedProperties = new Map(); + this.isUpdatePending = false; + } + /** + * Returns a Promise that resolves when the element has completed updating. + * The Promise value is a boolean that is `true` if the element completed the + * update without triggering another update. The Promise result is `false` if + * a property was set inside `updated()`. If the Promise is rejected, an + * exception was thrown during the update. + * + * To await additional asynchronous work, override the `getUpdateComplete` + * method. For example, it is sometimes useful to await a rendered element + * before fulfilling this Promise. To do this, first await + * `super.getUpdateComplete()`, then any subsequent state. + * + * @return A promise of a boolean that resolves to true if the update completed + * without triggering another update. + * @category updates + */ + get updateComplete() { + return this.getUpdateComplete(); + } + /** + * Override point for the `updateComplete` promise. + * + * It is not safe to override the `updateComplete` getter directly due to a + * limitation in TypeScript which means it is not possible to call a + * superclass getter (e.g. `super.updateComplete.then(...)`) when the target + * language is ES5 (https://github.com/microsoft/TypeScript/issues/338). + * This method should be overridden instead. For example: + * + * ```ts + * class MyElement extends LitElement { + * override async getUpdateComplete() { + * const result = await super.getUpdateComplete(); + * await this._myChild.updateComplete; + * return result; + * } + * } + * ``` + * + * @return A promise of a boolean that resolves to true if the update completed + * without triggering another update. + * @category updates + */ + getUpdateComplete() { + return this.__updatePromise; + } + /** + * Controls whether or not `update()` should be called when the element requests + * an update. By default, this method always returns `true`, but this can be + * customized to control when to update. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + shouldUpdate(_changedProperties) { + return true; + } + /** + * Updates the element. This method reflects property values to attributes. + * It can be overridden to render and keep updated element DOM. + * Setting properties inside this method will *not* trigger + * another update. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + update(_changedProperties) { + if (this.__reflectingProperties !== undefined) { + // Use forEach so this works even if for/of loops are compiled to for + // loops expecting arrays + this.__reflectingProperties.forEach((v, k) => this.__propertyToAttribute(k, this[k], v)); + this.__reflectingProperties = undefined; + } + this.__markUpdated(); + } + /** + * Invoked whenever the element is updated. Implement to perform + * post-updating tasks via DOM APIs, for example, focusing an element. + * + * Setting properties inside this method will trigger the element to update + * again after this update cycle completes. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + updated(_changedProperties) { } + /** + * Invoked when the element is first updated. Implement to perform one time + * work on the element after update. + * + * ```ts + * firstUpdated() { + * this.renderRoot.getElementById('my-text-area').focus(); + * } + * ``` + * + * Setting properties inside this method will trigger the element to update + * again after this update cycle completes. + * + * @param _changedProperties Map of changed properties with old values + * @category updates + */ + firstUpdated(_changedProperties) { } +} +_e = finalized; +/** + * Marks class as having finished creating properties. + */ +ReactiveElement[_e] = true; +/** + * Memoized list of all element properties, including any superclass properties. + * Created lazily on user subclasses when finalizing the class. + * @nocollapse + * @category properties + */ +ReactiveElement.elementProperties = new Map(); +/** + * Memoized list of all element styles. + * Created lazily on user subclasses when finalizing the class. + * @nocollapse + * @category styles + */ +ReactiveElement.elementStyles = []; +/** + * Options used when calling `attachShadow`. Set this property to customize + * the options for the shadowRoot; for example, to create a closed + * shadowRoot: `{mode: 'closed'}`. + * + * Note, these options are used in `createRenderRoot`. If this method + * is customized, options should be respected if possible. + * @nocollapse + * @category rendering + */ +ReactiveElement.shadowRootOptions = { mode: 'open' }; +// Apply polyfills if available +polyfillSupport$2 === null || polyfillSupport$2 === void 0 ? void 0 : polyfillSupport$2({ ReactiveElement }); +// IMPORTANT: do not change the property name or the assignment expression. +// This line will be used in regexes to search for ReactiveElement usage. +((_d$1 = global$1.reactiveElementVersions) !== null && _d$1 !== void 0 ? _d$1 : (global$1.reactiveElementVersions = [])).push('1.5.0'); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var _d; +// Use window for browser builds because IE11 doesn't have globalThis. +const global = window; +const __moz_domParser = new DOMParser(); +const wrap$1 = (node) => node; +const trustedTypes = global.trustedTypes; +/** + * Our TrustedTypePolicy for HTML which is declared using the html template + * tag function. + * + * That HTML is a developer-authored constant, and is parsed with innerHTML + * before any untrusted expressions have been mixed in. Therefor it is + * considered safe by construction. + */ +const policy = trustedTypes + ? trustedTypes.createPolicy('lit-html', { + createHTML: (s) => s, + }) + : undefined; +// Added to an attribute name to mark the attribute as bound so we can find +// it easily. +const boundAttributeSuffix = '$lit$'; +// This marker is used in many syntactic positions in HTML, so it must be +// a valid element name and attribute name. We don't support dynamic names (yet) +// but this at least ensures that the parse tree is closer to the template +// intention. +const marker = `lit$${String(Math.random()).slice(9)}$`; +// String used to tell if a comment is a marker comment +const markerMatch = '?' + marker; +// Text used to insert a comment marker node. We use processing instruction +// syntax because it's slightly smaller, but parses as a comment node. +const nodeMarker = `<${markerMatch}>`; +const d = document; +// Creates a dynamic marker. We never have to search for these in the DOM. +const createMarker$1 = (v = '') => d.createComment(v); +const isPrimitive$1 = (value) => value === null || (typeof value != 'object' && typeof value != 'function'); +const isArray = Array.isArray; +const isIterable = (value) => isArray(value) || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof (value === null || value === void 0 ? void 0 : value[Symbol.iterator]) === 'function'; +const SPACE_CHAR = `[ \t\n\f\r]`; +const ATTR_VALUE_CHAR = `[^ \t\n\f\r"'\`<>=]`; +const NAME_CHAR = `[^\\s"'>=/]`; +// These regexes represent the five parsing states that we care about in the +// Template's HTML scanner. They match the *end* of the state they're named +// after. +// Depending on the match, we transition to a new state. If there's no match, +// we stay in the same state. +// Note that the regexes are stateful. We utilize lastIndex and sync it +// across the multiple regexes used. In addition to the five regexes below +// we also dynamically create a regex to find the matching end tags for raw +// text elements. +/** + * End of text is: `<` followed by: + * (comment start) or (tag) or (dynamic tag binding) + */ +const textEndRegex = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g; +const COMMENT_START = 1; +const TAG_NAME = 2; +const DYNAMIC_TAG_NAME = 3; +const commentEndRegex = /-->/g; +/** + * Comments not started with <!--, like </{, can be ended by a single `>` + */ +const comment2EndRegex = />/g; +/** + * The tagEnd regex matches the end of the "inside an opening" tag syntax + * position. It either matches a `>`, an attribute-like sequence, or the end + * of the string after a space (attribute-name position ending). + * + * See attributes in the HTML spec: + * https://www.w3.org/TR/html5/syntax.html#elements-attributes + * + * " \t\n\f\r" are HTML space characters: + * https://infra.spec.whatwg.org/#ascii-whitespace + * + * So an attribute is: + * * The name: any character except a whitespace character, ("), ('), ">", + * "=", or "/". Note: this is different from the HTML spec which also excludes control characters. + * * Followed by zero or more space characters + * * Followed by "=" + * * Followed by zero or more space characters + * * Followed by: + * * Any character except space, ('), ("), "<", ">", "=", (`), or + * * (") then any non-("), or + * * (') then any non-(') + */ +const tagEndRegex = new RegExp(`>|${SPACE_CHAR}(?:(${NAME_CHAR}+)(${SPACE_CHAR}*=${SPACE_CHAR}*(?:${ATTR_VALUE_CHAR}|("|')|))|$)`, 'g'); +const ENTIRE_MATCH = 0; +const ATTRIBUTE_NAME = 1; +const SPACES_AND_EQUALS = 2; +const QUOTE_CHAR = 3; +const singleQuoteAttrEndRegex = /'/g; +const doubleQuoteAttrEndRegex = /"/g; +/** + * Matches the raw text elements. + * + * Comments are not parsed within raw text elements, so we need to search their + * text content for marker strings. + */ +const rawTextElement = /^(?:script|style|textarea|title)$/i; +/** TemplateResult types */ +const HTML_RESULT$1 = 1; +const SVG_RESULT$1 = 2; +// TemplatePart types +// IMPORTANT: these must match the values in PartType +const ATTRIBUTE_PART = 1; +const CHILD_PART = 2; +const PROPERTY_PART = 3; +const BOOLEAN_ATTRIBUTE_PART = 4; +const EVENT_PART = 5; +const ELEMENT_PART = 6; +const COMMENT_PART = 7; +/** + * Generates a template literal tag function that returns a TemplateResult with + * the given result type. + */ +const tag = (type) => (strings, ...values) => { + return { + // This property needs to remain unminified. + ['_$litType$']: type, + strings, + values, + }; +}; +/** + * Interprets a template literal as an HTML template that can efficiently + * render to and update a container. + * + * ```ts + * const header = (title: string) => html`<h1>${title}</h1>`; + * ``` + * + * The `html` tag returns a description of the DOM to render as a value. It is + * lazy, meaning no work is done until the template is rendered. When rendering, + * if a template comes from the same expression as a previously rendered result, + * it's efficiently updated instead of replaced. + */ +const html$1 = tag(HTML_RESULT$1); +/** + * Interprets a template literal as an SVG fragment that can efficiently + * render to and update a container. + * + * ```ts + * const rect = svg`<rect width="10" height="10"></rect>`; + * + * const myImage = html` + * <svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg"> + * ${rect} + * </svg>`; + * ``` + * + * The `svg` *tag function* should only be used for SVG fragments, or elements + * that would be contained **inside** an `<svg>` HTML element. A common error is + * placing an `<svg>` *element* in a template tagged with the `svg` tag + * function. The `<svg>` element is an HTML element and should be used within a + * template tagged with the {@linkcode html} tag function. + * + * In LitElement usage, it's invalid to return an SVG fragment from the + * `render()` method, as the SVG fragment will be contained within the element's + * shadow root and thus cannot be used within an `<svg>` HTML element. + */ +const svg$1 = tag(SVG_RESULT$1); +/** + * A sentinel value that signals that a value was handled by a directive and + * should not be written to the DOM. + */ +const noChange = Symbol.for('lit-noChange'); +/** + * A sentinel value that signals a ChildPart to fully clear its content. + * + * ```ts + * const button = html`${ + * user.isAdmin + * ? html`<button>DELETE</button>` + * : nothing + * }`; + * ``` + * + * Prefer using `nothing` over other falsy values as it provides a consistent + * behavior between various expression binding contexts. + * + * In child expressions, `undefined`, `null`, `''`, and `nothing` all behave the + * same and render no nodes. In attribute expressions, `nothing` _removes_ the + * attribute, while `undefined` and `null` will render an empty string. In + * property expressions `nothing` becomes `undefined`. + */ +const nothing = Symbol.for('lit-nothing'); +/** + * The cache of prepared templates, keyed by the tagged TemplateStringsArray + * and _not_ accounting for the specific template tag used. This means that + * template tags cannot be dynamic - the must statically be one of html, svg, + * or attr. This restriction simplifies the cache lookup, which is on the hot + * path for rendering. + */ +const templateCache = new WeakMap(); +const walker = d.createTreeWalker(d, 129 /* NodeFilter.SHOW_{ELEMENT|COMMENT} */, null, false); +/** + * Returns an HTML string for the given TemplateStringsArray and result type + * (HTML or SVG), along with the case-sensitive bound attribute names in + * template order. The HTML contains comment markers denoting the `ChildPart`s + * and suffixes on bound attributes denoting the `AttributeParts`. + * + * @param strings template strings array + * @param type HTML or SVG + * @return Array containing `[html, attrNames]` (array returned for terseness, + * to avoid object fields since this code is shared with non-minified SSR + * code) + */ +const getTemplateHtml = (strings, type) => { + // Insert makers into the template HTML to represent the position of + // bindings. The following code scans the template strings to determine the + // syntactic position of the bindings. They can be in text position, where + // we insert an HTML comment, attribute value position, where we insert a + // sentinel string and re-write the attribute name, or inside a tag where + // we insert the sentinel string. + const l = strings.length - 1; + // Stores the case-sensitive bound attribute names in the order of their + // parts. ElementParts are also reflected in this array as undefined + // rather than a string, to disambiguate from attribute bindings. + const attrNames = []; + let html = type === SVG_RESULT$1 ? '<svg>' : ''; + // When we're inside a raw text tag (not it's text content), the regex + // will still be tagRegex so we can find attributes, but will switch to + // this regex when the tag ends. + let rawTextEndRegex; + // The current parsing state, represented as a reference to one of the + // regexes + let regex = textEndRegex; + for (let i = 0; i < l; i++) { + const s = strings[i]; + // The index of the end of the last attribute name. When this is + // positive at end of a string, it means we're in an attribute value + // position and need to rewrite the attribute name. + // We also use a special value of -2 to indicate that we encountered + // the end of a string in attribute name position. + let attrNameEndIndex = -1; + let attrName; + let lastIndex = 0; + let match; + // The conditions in this loop handle the current parse state, and the + // assignments to the `regex` variable are the state transitions. + while (lastIndex < s.length) { + // Make sure we start searching from where we previously left off + regex.lastIndex = lastIndex; + match = regex.exec(s); + if (match === null) { + break; + } + lastIndex = regex.lastIndex; + if (regex === textEndRegex) { + if (match[COMMENT_START] === '!--') { + regex = commentEndRegex; + } + else if (match[COMMENT_START] !== undefined) { + // We started a weird comment, like </{ + regex = comment2EndRegex; + } + else if (match[TAG_NAME] !== undefined) { + if (rawTextElement.test(match[TAG_NAME])) { + // Record if we encounter a raw-text element. We'll switch to + // this regex at the end of the tag. + rawTextEndRegex = new RegExp(`</${match[TAG_NAME]}`, 'g'); + } + regex = tagEndRegex; + } + else if (match[DYNAMIC_TAG_NAME] !== undefined) { + regex = tagEndRegex; + } + } + else if (regex === tagEndRegex) { + if (match[ENTIRE_MATCH] === '>') { + // End of a tag. If we had started a raw-text element, use that + // regex + regex = rawTextEndRegex !== null && rawTextEndRegex !== void 0 ? rawTextEndRegex : textEndRegex; + // We may be ending an unquoted attribute value, so make sure we + // clear any pending attrNameEndIndex + attrNameEndIndex = -1; + } + else if (match[ATTRIBUTE_NAME] === undefined) { + // Attribute name position + attrNameEndIndex = -2; + } + else { + attrNameEndIndex = regex.lastIndex - match[SPACES_AND_EQUALS].length; + attrName = match[ATTRIBUTE_NAME]; + regex = + match[QUOTE_CHAR] === undefined + ? tagEndRegex + : match[QUOTE_CHAR] === '"' + ? doubleQuoteAttrEndRegex + : singleQuoteAttrEndRegex; + } + } + else if (regex === doubleQuoteAttrEndRegex || + regex === singleQuoteAttrEndRegex) { + regex = tagEndRegex; + } + else if (regex === commentEndRegex || regex === comment2EndRegex) { + regex = textEndRegex; + } + else { + // Not one of the five state regexes, so it must be the dynamically + // created raw text regex and we're at the close of that element. + regex = tagEndRegex; + rawTextEndRegex = undefined; + } + } + // We have four cases: + // 1. We're in text position, and not in a raw text element + // (regex === textEndRegex): insert a comment marker. + // 2. We have a non-negative attrNameEndIndex which means we need to + // rewrite the attribute name to add a bound attribute suffix. + // 3. We're at the non-first binding in a multi-binding attribute, use a + // plain marker. + // 4. We're somewhere else inside the tag. If we're in attribute name + // position (attrNameEndIndex === -2), add a sequential suffix to + // generate a unique attribute name. + // Detect a binding next to self-closing tag end and insert a space to + // separate the marker from the tag end: + const end = regex === tagEndRegex && strings[i + 1].startsWith('/>') ? ' ' : ''; + html += + regex === textEndRegex + ? s + nodeMarker + : attrNameEndIndex >= 0 + ? (attrNames.push(attrName), + s.slice(0, attrNameEndIndex) + + boundAttributeSuffix + + s.slice(attrNameEndIndex)) + + marker + + end + : s + + marker + + (attrNameEndIndex === -2 ? (attrNames.push(undefined), i) : end); + } + const htmlResult = html + (strings[l] || '<?>') + (type === SVG_RESULT$1 ? '</svg>' : ''); + // A security check to prevent spoofing of Lit template results. + // In the future, we may be able to replace this with Array.isTemplateObject, + // though we might need to make that check inside of the html and svg + // functions, because precompiled templates don't come in as + // TemplateStringArray objects. + if (!Array.isArray(strings) || !strings.hasOwnProperty('raw')) { + let message = 'invalid template strings array'; + throw new Error(message); + } + // Returned as an array for terseness + return [ + policy !== undefined + ? policy.createHTML(htmlResult) + : htmlResult, + attrNames, + ]; +}; +class Template { + constructor( + // This property needs to remain unminified. + { strings, ['_$litType$']: type }, options) { + /** @internal */ + this.parts = []; + let node; + let nodeIndex = 0; + let attrNameIndex = 0; + const partCount = strings.length - 1; + const parts = this.parts; + // Create template element + const [html, attrNames] = getTemplateHtml(strings, type); + this.el = Template.createElement(html, options); + walker.currentNode = this.el.content; + // Reparent SVG nodes into template root + if (type === SVG_RESULT$1) { + const content = this.el.content; + const svgElement = content.firstChild; + svgElement.remove(); + content.append(...svgElement.childNodes); + } + // Walk the template to find binding markers and create TemplateParts + while ((node = walker.nextNode()) !== null && parts.length < partCount) { + if (node.nodeType === 1) { + // TODO (justinfagnani): for attempted dynamic tag names, we don't + // increment the bindingIndex, and it'll be off by 1 in the element + // and off by two after it. + if (node.hasAttributes()) { + // We defer removing bound attributes because on IE we might not be + // iterating attributes in their template order, and would sometimes + // remove an attribute that we still need to create a part for. + const attrsToRemove = []; + for (const name of node.getAttributeNames()) { + // `name` is the name of the attribute we're iterating over, but not + // _neccessarily_ the name of the attribute we will create a part + // for. They can be different in browsers that don't iterate on + // attributes in source order. In that case the attrNames array + // contains the attribute name we'll process next. We only need the + // attribute name here to know if we should process a bound attribute + // on this element. + if (name.endsWith(boundAttributeSuffix) || + name.startsWith(marker)) { + const realName = attrNames[attrNameIndex++]; + attrsToRemove.push(name); + if (realName !== undefined) { + // Lowercase for case-sensitive SVG attributes like viewBox + const value = node.getAttribute(realName.toLowerCase() + boundAttributeSuffix); + const statics = value.split(marker); + const m = /([.?@])?(.*)/.exec(realName); + parts.push({ + type: ATTRIBUTE_PART, + index: nodeIndex, + name: m[2], + strings: statics, + ctor: m[1] === '.' + ? PropertyPart + : m[1] === '?' + ? BooleanAttributePart + : m[1] === '@' + ? EventPart + : AttributePart, + }); + } + else { + parts.push({ + type: ELEMENT_PART, + index: nodeIndex, + }); + } + } + } + for (const name of attrsToRemove) { + node.removeAttribute(name); + } + } + // TODO (justinfagnani): benchmark the regex against testing for each + // of the 3 raw text element names. + if (rawTextElement.test(node.tagName)) { + // For raw text elements we need to split the text content on + // markers, create a Text node for each segment, and create + // a TemplatePart for each marker. + const strings = node.textContent.split(marker); + const lastIndex = strings.length - 1; + if (lastIndex > 0) { + node.textContent = trustedTypes + ? trustedTypes.emptyScript + : ''; + // Generate a new text node for each literal section + // These nodes are also used as the markers for node parts + // We can't use empty text nodes as markers because they're + // normalized when cloning in IE (could simplify when + // IE is no longer supported) + for (let i = 0; i < lastIndex; i++) { + node.append(strings[i], createMarker$1()); + // Walk past the marker node we just added + walker.nextNode(); + parts.push({ type: CHILD_PART, index: ++nodeIndex }); + } + // Note because this marker is added after the walker's current + // node, it will be walked to in the outer loop (and ignored), so + // we don't need to adjust nodeIndex here + node.append(strings[lastIndex], createMarker$1()); + } + } + } + else if (node.nodeType === 8) { + const data = node.data; + if (data === markerMatch) { + parts.push({ type: CHILD_PART, index: nodeIndex }); + } + else { + let i = -1; + while ((i = node.data.indexOf(marker, i + 1)) !== -1) { + // Comment node has a binding marker inside, make an inactive part + // The binding won't work, but subsequent bindings will + parts.push({ type: COMMENT_PART, index: nodeIndex }); + // Move to the end of the match + i += marker.length - 1; + } + } + } + nodeIndex++; + } + } + // Overridden via `litHtmlPolyfillSupport` to provide platform support. + /** @nocollapse */ + static createElement(html, _options) { + const doc = __moz_domParser.parseFromString(`<template>${html}</template>`, 'text/html'); + return document.importNode(doc.querySelector('template'), true); + } +} +function resolveDirective(part, value, parent = part, attributeIndex) { + var _a, _b, _c; + var _d; + // Bail early if the value is explicitly noChange. Note, this means any + // nested directive is still attached and is not run. + if (value === noChange) { + return value; + } + let currentDirective = attributeIndex !== undefined + ? (_a = parent.__directives) === null || _a === void 0 ? void 0 : _a[attributeIndex] + : parent.__directive; + const nextDirectiveConstructor = isPrimitive$1(value) + ? undefined + : // This property needs to remain unminified. + value['_$litDirective$']; + if ((currentDirective === null || currentDirective === void 0 ? void 0 : currentDirective.constructor) !== nextDirectiveConstructor) { + // This property needs to remain unminified. + (_b = currentDirective === null || currentDirective === void 0 ? void 0 : currentDirective['_$notifyDirectiveConnectionChanged']) === null || _b === void 0 ? void 0 : _b.call(currentDirective, false); + if (nextDirectiveConstructor === undefined) { + currentDirective = undefined; + } + else { + currentDirective = new nextDirectiveConstructor(part); + currentDirective._$initialize(part, parent, attributeIndex); + } + if (attributeIndex !== undefined) { + ((_c = (_d = parent).__directives) !== null && _c !== void 0 ? _c : (_d.__directives = []))[attributeIndex] = + currentDirective; + } + else { + parent.__directive = currentDirective; + } + } + if (currentDirective !== undefined) { + value = resolveDirective(part, currentDirective._$resolve(part, value.values), currentDirective, attributeIndex); + } + return value; +} +/** + * An updateable instance of a Template. Holds references to the Parts used to + * update the template instance. + */ +class TemplateInstance { + constructor(template, parent) { + /** @internal */ + this._parts = []; + /** @internal */ + this._$disconnectableChildren = undefined; + this._$template = template; + this._$parent = parent; + } + // Called by ChildPart parentNode getter + get parentNode() { + return this._$parent.parentNode; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + // This method is separate from the constructor because we need to return a + // DocumentFragment and we don't want to hold onto it with an instance field. + _clone(options) { + var _a; + const { el: { content }, parts: parts, } = this._$template; + const fragment = ((_a = options === null || options === void 0 ? void 0 : options.creationScope) !== null && _a !== void 0 ? _a : d).importNode(content, true); + walker.currentNode = fragment; + let node = walker.nextNode(); + let nodeIndex = 0; + let partIndex = 0; + let templatePart = parts[0]; + while (templatePart !== undefined) { + if (nodeIndex === templatePart.index) { + let part; + if (templatePart.type === CHILD_PART) { + part = new ChildPart$1(node, node.nextSibling, this, options); + } + else if (templatePart.type === ATTRIBUTE_PART) { + part = new templatePart.ctor(node, templatePart.name, templatePart.strings, this, options); + } + else if (templatePart.type === ELEMENT_PART) { + part = new ElementPart(node, this, options); + } + this._parts.push(part); + templatePart = parts[++partIndex]; + } + if (nodeIndex !== (templatePart === null || templatePart === void 0 ? void 0 : templatePart.index)) { + node = walker.nextNode(); + nodeIndex++; + } + } + return fragment; + } + _update(values) { + let i = 0; + for (const part of this._parts) { + if (part !== undefined) { + if (part.strings !== undefined) { + part._$setValue(values, part, i); + // The number of values the part consumes is part.strings.length - 1 + // since values are in between template spans. We increment i by 1 + // later in the loop, so increment it by part.strings.length - 2 here + i += part.strings.length - 2; + } + else { + part._$setValue(values[i]); + } + } + i++; + } + } +} +class ChildPart$1 { + constructor(startNode, endNode, parent, options) { + var _a; + this.type = CHILD_PART; + this._$committedValue = nothing; + // The following fields will be patched onto ChildParts when required by + // AsyncDirective + /** @internal */ + this._$disconnectableChildren = undefined; + this._$startNode = startNode; + this._$endNode = endNode; + this._$parent = parent; + this.options = options; + // Note __isConnected is only ever accessed on RootParts (i.e. when there is + // no _$parent); the value on a non-root-part is "don't care", but checking + // for parent would be more code + this.__isConnected = (_a = options === null || options === void 0 ? void 0 : options.isConnected) !== null && _a !== void 0 ? _a : true; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + var _a, _b; + // ChildParts that are not at the root should always be created with a + // parent; only RootChildNode's won't, so they return the local isConnected + // state + return (_b = (_a = this._$parent) === null || _a === void 0 ? void 0 : _a._$isConnected) !== null && _b !== void 0 ? _b : this.__isConnected; + } + /** + * The parent node into which the part renders its content. + * + * A ChildPart's content consists of a range of adjacent child nodes of + * `.parentNode`, possibly bordered by 'marker nodes' (`.startNode` and + * `.endNode`). + * + * - If both `.startNode` and `.endNode` are non-null, then the part's content + * consists of all siblings between `.startNode` and `.endNode`, exclusively. + * + * - If `.startNode` is non-null but `.endNode` is null, then the part's + * content consists of all siblings following `.startNode`, up to and + * including the last child of `.parentNode`. If `.endNode` is non-null, then + * `.startNode` will always be non-null. + * + * - If both `.endNode` and `.startNode` are null, then the part's content + * consists of all child nodes of `.parentNode`. + */ + get parentNode() { + let parentNode = wrap$1(this._$startNode).parentNode; + const parent = this._$parent; + if (parent !== undefined && + parentNode.nodeType === 11 /* Node.DOCUMENT_FRAGMENT */) { + // If the parentNode is a DocumentFragment, it may be because the DOM is + // still in the cloned fragment during initial render; if so, get the real + // parentNode the part will be committed into by asking the parent. + parentNode = parent.parentNode; + } + return parentNode; + } + /** + * The part's leading marker node, if any. See `.parentNode` for more + * information. + */ + get startNode() { + return this._$startNode; + } + /** + * The part's trailing marker node, if any. See `.parentNode` for more + * information. + */ + get endNode() { + return this._$endNode; + } + _$setValue(value, directiveParent = this) { + value = resolveDirective(this, value, directiveParent); + if (isPrimitive$1(value)) { + // Non-rendering child values. It's important that these do not render + // empty text nodes to avoid issues with preventing default <slot> + // fallback content. + if (value === nothing || value == null || value === '') { + if (this._$committedValue !== nothing) { + this._$clear(); + } + this._$committedValue = nothing; + } + else if (value !== this._$committedValue && value !== noChange) { + this._commitText(value); + } + // This property needs to remain unminified. + } + else if (value['_$litType$'] !== undefined) { + this._commitTemplateResult(value); + } + else if (value.nodeType !== undefined) { + this._commitNode(value); + } + else if (isIterable(value)) { + this._commitIterable(value); + } + else { + // Fallback, will render the string representation + this._commitText(value); + } + } + _insert(node, ref = this._$endNode) { + return wrap$1(wrap$1(this._$startNode).parentNode).insertBefore(node, ref); + } + _commitNode(value) { + if (this._$committedValue !== value) { + this._$clear(); + this._$committedValue = this._insert(value); + } + } + _commitText(value) { + // If the committed value is a primitive it means we called _commitText on + // the previous render, and we know that this._$startNode.nextSibling is a + // Text node. We can now just replace the text content (.data) of the node. + if (this._$committedValue !== nothing && + isPrimitive$1(this._$committedValue)) { + const node = wrap$1(this._$startNode).nextSibling; + node.data = value; + } + else { + { + this._commitNode(d.createTextNode(value)); + } + } + this._$committedValue = value; + } + _commitTemplateResult(result) { + var _a; + // This property needs to remain unminified. + const { values, ['_$litType$']: type } = result; + // If $litType$ is a number, result is a plain TemplateResult and we get + // the template from the template cache. If not, result is a + // CompiledTemplateResult and _$litType$ is a CompiledTemplate and we need + // to create the <template> element the first time we see it. + const template = typeof type === 'number' + ? this._$getTemplate(result) + : (type.el === undefined && + (type.el = Template.createElement(type.h, this.options)), + type); + if (((_a = this._$committedValue) === null || _a === void 0 ? void 0 : _a._$template) === template) { + this._$committedValue._update(values); + } + else { + const instance = new TemplateInstance(template, this); + const fragment = instance._clone(this.options); + instance._update(values); + this._commitNode(fragment); + this._$committedValue = instance; + } + } + // Overridden via `litHtmlPolyfillSupport` to provide platform support. + /** @internal */ + _$getTemplate(result) { + let template = templateCache.get(result.strings); + if (template === undefined) { + templateCache.set(result.strings, (template = new Template(result))); + } + return template; + } + _commitIterable(value) { + // For an Iterable, we create a new InstancePart per item, then set its + // value to the item. This is a little bit of overhead for every item in + // an Iterable, but it lets us recurse easily and efficiently update Arrays + // of TemplateResults that will be commonly returned from expressions like: + // array.map((i) => html`${i}`), by reusing existing TemplateInstances. + // If value is an array, then the previous render was of an + // iterable and value will contain the ChildParts from the previous + // render. If value is not an array, clear this part and make a new + // array for ChildParts. + if (!isArray(this._$committedValue)) { + this._$committedValue = []; + this._$clear(); + } + // Lets us keep track of how many items we stamped so we can clear leftover + // items from a previous render + const itemParts = this._$committedValue; + let partIndex = 0; + let itemPart; + for (const item of value) { + if (partIndex === itemParts.length) { + // If no existing part, create a new one + // TODO (justinfagnani): test perf impact of always creating two parts + // instead of sharing parts between nodes + // https://github.com/lit/lit/issues/1266 + itemParts.push((itemPart = new ChildPart$1(this._insert(createMarker$1()), this._insert(createMarker$1()), this, this.options))); + } + else { + // Reuse an existing part + itemPart = itemParts[partIndex]; + } + itemPart._$setValue(item); + partIndex++; + } + if (partIndex < itemParts.length) { + // itemParts always have end nodes + this._$clear(itemPart && wrap$1(itemPart._$endNode).nextSibling, partIndex); + // Truncate the parts array so _value reflects the current state + itemParts.length = partIndex; + } + } + /** + * Removes the nodes contained within this Part from the DOM. + * + * @param start Start node to clear from, for clearing a subset of the part's + * DOM (used when truncating iterables) + * @param from When `start` is specified, the index within the iterable from + * which ChildParts are being removed, used for disconnecting directives in + * those Parts. + * + * @internal + */ + _$clear(start = wrap$1(this._$startNode).nextSibling, from) { + var _a; + (_a = this._$notifyConnectionChanged) === null || _a === void 0 ? void 0 : _a.call(this, false, true, from); + while (start && start !== this._$endNode) { + const n = wrap$1(start).nextSibling; + wrap$1(start).remove(); + start = n; + } + } + /** + * Implementation of RootPart's `isConnected`. Note that this metod + * should only be called on `RootPart`s (the `ChildPart` returned from a + * top-level `render()` call). It has no effect on non-root ChildParts. + * @param isConnected Whether to set + * @internal + */ + setConnected(isConnected) { + var _a; + if (this._$parent === undefined) { + this.__isConnected = isConnected; + (_a = this._$notifyConnectionChanged) === null || _a === void 0 ? void 0 : _a.call(this, isConnected); + } + } +} +class AttributePart { + constructor(element, name, strings, parent, options) { + this.type = ATTRIBUTE_PART; + /** @internal */ + this._$committedValue = nothing; + /** @internal */ + this._$disconnectableChildren = undefined; + this.element = element; + this.name = name; + this._$parent = parent; + this.options = options; + if (strings.length > 2 || strings[0] !== '' || strings[1] !== '') { + this._$committedValue = new Array(strings.length - 1).fill(new String()); + this.strings = strings; + } + else { + this._$committedValue = nothing; + } + } + get tagName() { + return this.element.tagName; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + /** + * Sets the value of this part by resolving the value from possibly multiple + * values and static strings and committing it to the DOM. + * If this part is single-valued, `this._strings` will be undefined, and the + * method will be called with a single value argument. If this part is + * multi-value, `this._strings` will be defined, and the method is called + * with the value array of the part's owning TemplateInstance, and an offset + * into the value array from which the values should be read. + * This method is overloaded this way to eliminate short-lived array slices + * of the template instance values, and allow a fast-path for single-valued + * parts. + * + * @param value The part value, or an array of values for multi-valued parts + * @param valueIndex the index to start reading values from. `undefined` for + * single-valued parts + * @param noCommit causes the part to not commit its value to the DOM. Used + * in hydration to prime attribute parts with their first-rendered value, + * but not set the attribute, and in SSR to no-op the DOM operation and + * capture the value for serialization. + * + * @internal + */ + _$setValue(value, directiveParent = this, valueIndex, noCommit) { + const strings = this.strings; + // Whether any of the values has changed, for dirty-checking + let change = false; + if (strings === undefined) { + // Single-value binding case + value = resolveDirective(this, value, directiveParent, 0); + change = + !isPrimitive$1(value) || + (value !== this._$committedValue && value !== noChange); + if (change) { + this._$committedValue = value; + } + } + else { + // Interpolation case + const values = value; + value = strings[0]; + let i, v; + for (i = 0; i < strings.length - 1; i++) { + v = resolveDirective(this, values[valueIndex + i], directiveParent, i); + if (v === noChange) { + // If the user-provided value is `noChange`, use the previous value + v = this._$committedValue[i]; + } + change || (change = !isPrimitive$1(v) || v !== this._$committedValue[i]); + if (v === nothing) { + value = nothing; + } + else if (value !== nothing) { + value += (v !== null && v !== void 0 ? v : '') + strings[i + 1]; + } + // We always record each value, even if one is `nothing`, for future + // change detection. + this._$committedValue[i] = v; + } + } + if (change && !noCommit) { + this._commitValue(value); + } + } + /** @internal */ + _commitValue(value) { + if (value === nothing) { + wrap$1(this.element).removeAttribute(this.name); + } + else { + wrap$1(this.element).setAttribute(this.name, (value !== null && value !== void 0 ? value : '')); + } + } +} +class PropertyPart extends AttributePart { + constructor() { + super(...arguments); + this.type = PROPERTY_PART; + } + /** @internal */ + _commitValue(value) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.element[this.name] = value === nothing ? undefined : value; + } +} +// Temporary workaround for https://crbug.com/993268 +// Currently, any attribute starting with "on" is considered to be a +// TrustedScript source. Such boolean attributes must be set to the equivalent +// trusted emptyScript value. +const emptyStringForBooleanAttribute = trustedTypes + ? trustedTypes.emptyScript + : ''; +class BooleanAttributePart extends AttributePart { + constructor() { + super(...arguments); + this.type = BOOLEAN_ATTRIBUTE_PART; + } + /** @internal */ + _commitValue(value) { + if (value && value !== nothing) { + wrap$1(this.element).setAttribute(this.name, emptyStringForBooleanAttribute); + } + else { + wrap$1(this.element).removeAttribute(this.name); + } + } +} +class EventPart extends AttributePart { + constructor(element, name, strings, parent, options) { + super(element, name, strings, parent, options); + this.type = EVENT_PART; + } + // EventPart does not use the base _$setValue/_resolveValue implementation + // since the dirty checking is more complex + /** @internal */ + _$setValue(newListener, directiveParent = this) { + var _a; + newListener = + (_a = resolveDirective(this, newListener, directiveParent, 0)) !== null && _a !== void 0 ? _a : nothing; + if (newListener === noChange) { + return; + } + const oldListener = this._$committedValue; + // If the new value is nothing or any options change we have to remove the + // part as a listener. + const shouldRemoveListener = (newListener === nothing && oldListener !== nothing) || + newListener.capture !== + oldListener.capture || + newListener.once !== + oldListener.once || + newListener.passive !== + oldListener.passive; + // If the new value is not nothing and we removed the listener, we have + // to add the part as a listener. + const shouldAddListener = newListener !== nothing && + (oldListener === nothing || shouldRemoveListener); + if (shouldRemoveListener) { + this.element.removeEventListener(this.name, this, oldListener); + } + if (shouldAddListener) { + // Beware: IE11 and Chrome 41 don't like using the listener as the + // options object. Figure out how to deal w/ this in IE11 - maybe + // patch addEventListener? + this.element.addEventListener(this.name, this, newListener); + } + this._$committedValue = newListener; + } + handleEvent(event) { + var _a, _b; + if (typeof this._$committedValue === 'function') { + this._$committedValue.call((_b = (_a = this.options) === null || _a === void 0 ? void 0 : _a.host) !== null && _b !== void 0 ? _b : this.element, event); + } + else { + this._$committedValue.handleEvent(event); + } + } +} +class ElementPart { + constructor(element, parent, options) { + this.element = element; + this.type = ELEMENT_PART; + /** @internal */ + this._$disconnectableChildren = undefined; + this._$parent = parent; + this.options = options; + } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + _$setValue(value) { + resolveDirective(this, value); + } +} +/** + * END USERS SHOULD NOT RELY ON THIS OBJECT. + * + * Private exports for use by other Lit packages, not intended for use by + * external users. + * + * We currently do not make a mangled rollup build of the lit-ssr code. In order + * to keep a number of (otherwise private) top-level exports mangled in the + * client side code, we export a _$LH object containing those members (or + * helper methods for accessing private fields of those members), and then + * re-export them for use in lit-ssr. This keeps lit-ssr agnostic to whether the + * client-side code is being used in `dev` mode or `prod` mode. + * + * This has a unique name, to disambiguate it from private exports in + * lit-element, which re-exports all of lit-html. + * + * @private + */ +const _$LH = { + // Used in lit-ssr + _boundAttributeSuffix: boundAttributeSuffix, + _marker: marker, + _markerMatch: markerMatch, + _HTML_RESULT: HTML_RESULT$1, + _getTemplateHtml: getTemplateHtml, + // Used in hydrate + _TemplateInstance: TemplateInstance, + _isIterable: isIterable, + _resolveDirective: resolveDirective, + // Used in tests and private-ssr-support + _ChildPart: ChildPart$1, + _AttributePart: AttributePart, + _BooleanAttributePart: BooleanAttributePart, + _EventPart: EventPart, + _PropertyPart: PropertyPart, + _ElementPart: ElementPart, +}; +// Apply polyfills if available +const polyfillSupport$1 = global.litHtmlPolyfillSupport; +polyfillSupport$1 === null || polyfillSupport$1 === void 0 ? void 0 : polyfillSupport$1(Template, ChildPart$1); +// IMPORTANT: do not change the property name or the assignment expression. +// This line will be used in regexes to search for lit-html usage. +((_d = global.litHtmlVersions) !== null && _d !== void 0 ? _d : (global.litHtmlVersions = [])).push('2.5.0'); +/** + * Renders a value, usually a lit-html TemplateResult, to the container. + * + * This example renders the text "Hello, Zoe!" inside a paragraph tag, appending + * it to the container `document.body`. + * + * ```js + * import {html, render} from 'lit'; + * + * const name = "Zoe"; + * render(html`<p>Hello, ${name}!</p>`, document.body); + * ``` + * + * @param value Any [renderable + * value](https://lit.dev/docs/templates/expressions/#child-expressions), + * typically a {@linkcode TemplateResult} created by evaluating a template tag + * like {@linkcode html} or {@linkcode svg}. + * @param container A DOM container to render to. The first render will append + * the rendered value to the container, and subsequent renders will + * efficiently update the rendered value if the same result type was + * previously rendered there. + * @param options See {@linkcode RenderOptions} for options documentation. + * @see + * {@link https://lit.dev/docs/libraries/standalone-templates/#rendering-lit-html-templates| Rendering Lit HTML Templates} + */ +const render = (value, container, options) => { + var _a, _b; + const partOwnerNode = (_a = options === null || options === void 0 ? void 0 : options.renderBefore) !== null && _a !== void 0 ? _a : container; + // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let part = partOwnerNode['_$litPart$']; + if (part === undefined) { + const endNode = (_b = options === null || options === void 0 ? void 0 : options.renderBefore) !== null && _b !== void 0 ? _b : null; + // This property needs to remain unminified. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + partOwnerNode['_$litPart$'] = part = new ChildPart$1(container.insertBefore(createMarker$1(), endNode), endNode, undefined, options !== null && options !== void 0 ? options : {}); + } + part._$setValue(value); + return part; +}; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var _b, _c; +// For backwards compatibility export ReactiveElement as UpdatingElement. Note, +// IE transpilation requires exporting like this. +const UpdatingElement = ReactiveElement; +/** + * Base element class that manages element properties and attributes, and + * renders a lit-html template. + * + * To define a component, subclass `LitElement` and implement a + * `render` method to provide the component's template. Define properties + * using the {@linkcode LitElement.properties properties} property or the + * {@linkcode property} decorator. + */ +class LitElement extends ReactiveElement { + constructor() { + super(...arguments); + /** + * @category rendering + */ + this.renderOptions = { host: this }; + this.__childPart = undefined; + } + /** + * @category rendering + */ + createRenderRoot() { + var _a; + var _b; + const renderRoot = super.createRenderRoot(); + // When adoptedStyleSheets are shimmed, they are inserted into the + // shadowRoot by createRenderRoot. Adjust the renderBefore node so that + // any styles in Lit content render before adoptedStyleSheets. This is + // important so that adoptedStyleSheets have precedence over styles in + // the shadowRoot. + (_a = (_b = this.renderOptions).renderBefore) !== null && _a !== void 0 ? _a : (_b.renderBefore = renderRoot.firstChild); + return renderRoot; + } + /** + * Updates the element. This method reflects property values to attributes + * and calls `render` to render DOM via lit-html. Setting properties inside + * this method will *not* trigger another update. + * @param changedProperties Map of changed properties with old values + * @category updates + */ + update(changedProperties) { + // Setting properties in `render` should not trigger an update. Since + // updates are allowed after super.update, it's important to call `render` + // before that. + const value = this.render(); + if (!this.hasUpdated) { + this.renderOptions.isConnected = this.isConnected; + } + super.update(changedProperties); + this.__childPart = render(value, this.renderRoot, this.renderOptions); + } + /** + * Invoked when the component is added to the document's DOM. + * + * In `connectedCallback()` you should setup tasks that should only occur when + * the element is connected to the document. The most common of these is + * adding event listeners to nodes external to the element, like a keydown + * event handler added to the window. + * + * ```ts + * connectedCallback() { + * super.connectedCallback(); + * addEventListener('keydown', this._handleKeydown); + * } + * ``` + * + * Typically, anything done in `connectedCallback()` should be undone when the + * element is disconnected, in `disconnectedCallback()`. + * + * @category lifecycle + */ + connectedCallback() { + var _a; + super.connectedCallback(); + (_a = this.__childPart) === null || _a === void 0 ? void 0 : _a.setConnected(true); + } + /** + * Invoked when the component is removed from the document's DOM. + * + * This callback is the main signal to the element that it may no longer be + * used. `disconnectedCallback()` should ensure that nothing is holding a + * reference to the element (such as event listeners added to nodes external + * to the element), so that it is free to be garbage collected. + * + * ```ts + * disconnectedCallback() { + * super.disconnectedCallback(); + * window.removeEventListener('keydown', this._handleKeydown); + * } + * ``` + * + * An element may be re-connected after being disconnected. + * + * @category lifecycle + */ + disconnectedCallback() { + var _a; + super.disconnectedCallback(); + (_a = this.__childPart) === null || _a === void 0 ? void 0 : _a.setConnected(false); + } + /** + * Invoked on each update to perform rendering tasks. This method may return + * any value renderable by lit-html's `ChildPart` - typically a + * `TemplateResult`. Setting properties inside this method will *not* trigger + * the element to update. + * @category rendering + */ + render() { + return noChange; + } +} +/** + * Ensure this class is marked as `finalized` as an optimization ensuring + * it will not needlessly try to `finalize`. + * + * Note this property name is a string to prevent breaking Closure JS Compiler + * optimizations. See @lit/reactive-element for more information. + */ +LitElement['finalized'] = true; +// This property needs to remain unminified. +LitElement['_$litElement$'] = true; +// Install hydration if available +(_b = globalThis.litElementHydrateSupport) === null || _b === void 0 ? void 0 : _b.call(globalThis, { LitElement }); +// Apply polyfills if available +const polyfillSupport = globalThis.litElementPolyfillSupport; +polyfillSupport === null || polyfillSupport === void 0 ? void 0 : polyfillSupport({ LitElement }); +/** + * END USERS SHOULD NOT RELY ON THIS OBJECT. + * + * Private exports for use by other Lit packages, not intended for use by + * external users. + * + * We currently do not make a mangled rollup build of the lit-ssr code. In order + * to keep a number of (otherwise private) top-level exports mangled in the + * client side code, we export a _$LE object containing those members (or + * helper methods for accessing private fields of those members), and then + * re-export them for use in lit-ssr. This keeps lit-ssr agnostic to whether the + * client-side code is being used in `dev` mode or `prod` mode. + * + * This has a unique name, to disambiguate it from private exports in + * lit-html, since this module re-exports all of lit-html. + * + * @private + */ +const _$LE = { + _$attributeToProperty: (el, name, value) => { + // eslint-disable-next-line + el._$attributeToProperty(name, value); + }, + // eslint-disable-next-line + _$changedProperties: (el) => el._$changedProperties, +}; +// IMPORTANT: do not change the property name or the assignment expression. +// This line will be used in regexes to search for LitElement usage. +((_c = globalThis.litElementVersions) !== null && _c !== void 0 ? _c : (globalThis.litElementVersions = [])).push('3.2.2'); + +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * @fileoverview + * + * This file exports a boolean const whose value will depend on what environment + * the module is being imported from. + */ +const NODE_MODE = false; +/** + * A boolean that will be `true` in server environments like Node, and `false` + * in browser environments. Note that your server environment or toolchain must + * support the `"node"` export condition for this to be `true`. + * + * This can be used when authoring components to change behavior based on + * whether or not the component is executing in an SSR context. + */ +const isServer = NODE_MODE; + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const { _ChildPart: ChildPart } = _$LH; +const wrap = (node) => node; +/** + * Tests if a value is a primitive value. + * + * See https://tc39.github.io/ecma262/#sec-typeof-operator + */ +const isPrimitive = (value) => value === null || (typeof value != 'object' && typeof value != 'function'); +const TemplateResultType = { + HTML: 1, + SVG: 2, +}; +/** + * Tests if a value is a TemplateResult. + */ +const isTemplateResult = (value, type) => type === undefined + ? // This property needs to remain unminified. + (value === null || value === void 0 ? void 0 : value['_$litType$']) !== undefined + : (value === null || value === void 0 ? void 0 : value['_$litType$']) === type; +/** + * Tests if a value is a DirectiveResult. + */ +const isDirectiveResult = (value) => +// This property needs to remain unminified. +(value === null || value === void 0 ? void 0 : value['_$litDirective$']) !== undefined; +/** + * Retrieves the Directive class for a DirectiveResult + */ +const getDirectiveClass = (value) => +// This property needs to remain unminified. +value === null || value === void 0 ? void 0 : value['_$litDirective$']; +/** + * Tests whether a part has only a single-expression with no strings to + * interpolate between. + * + * Only AttributePart and PropertyPart can have multiple expressions. + * Multi-expression parts have a `strings` property and single-expression + * parts do not. + */ +const isSingleExpression = (part) => part.strings === undefined; +const createMarker = () => document.createComment(''); +/** + * Inserts a ChildPart into the given container ChildPart's DOM, either at the + * end of the container ChildPart, or before the optional `refPart`. + * + * This does not add the part to the containerPart's committed value. That must + * be done by callers. + * + * @param containerPart Part within which to add the new ChildPart + * @param refPart Part before which to add the new ChildPart; when omitted the + * part added to the end of the `containerPart` + * @param part Part to insert, or undefined to create a new part + */ +const insertPart = (containerPart, refPart, part) => { + var _a; + const container = wrap(containerPart._$startNode).parentNode; + const refNode = refPart === undefined ? containerPart._$endNode : refPart._$startNode; + if (part === undefined) { + const startNode = wrap(container).insertBefore(createMarker(), refNode); + const endNode = wrap(container).insertBefore(createMarker(), refNode); + part = new ChildPart(startNode, endNode, containerPart, containerPart.options); + } + else { + const endNode = wrap(part._$endNode).nextSibling; + const oldParent = part._$parent; + const parentChanged = oldParent !== containerPart; + if (parentChanged) { + (_a = part._$reparentDisconnectables) === null || _a === void 0 ? void 0 : _a.call(part, containerPart); + // Note that although `_$reparentDisconnectables` updates the part's + // `_$parent` reference after unlinking from its current parent, that + // method only exists if Disconnectables are present, so we need to + // unconditionally set it here + part._$parent = containerPart; + // Since the _$isConnected getter is somewhat costly, only + // read it once we know the subtree has directives that need + // to be notified + let newConnectionState; + if (part._$notifyConnectionChanged !== undefined && + (newConnectionState = containerPart._$isConnected) !== + oldParent._$isConnected) { + part._$notifyConnectionChanged(newConnectionState); + } + } + if (endNode !== refNode || parentChanged) { + let start = part._$startNode; + while (start !== endNode) { + const n = wrap(start).nextSibling; + wrap(container).insertBefore(start, refNode); + start = n; + } + } + } + return part; +}; +/** + * Sets the value of a Part. + * + * Note that this should only be used to set/update the value of user-created + * parts (i.e. those created using `insertPart`); it should not be used + * by directives to set the value of the directive's container part. Directives + * should return a value from `update`/`render` to update their part state. + * + * For directives that require setting their part value asynchronously, they + * should extend `AsyncDirective` and call `this.setValue()`. + * + * @param part Part to set + * @param value Value to set + * @param index For `AttributePart`s, the index to set + * @param directiveParent Used internally; should not be set by user + */ +const setChildPartValue = (part, value, directiveParent = part) => { + part._$setValue(value, directiveParent); + return part; +}; +// A sentinal value that can never appear as a part value except when set by +// live(). Used to force a dirty-check to fail and cause a re-render. +const RESET_VALUE = {}; +/** + * Sets the committed value of a ChildPart directly without triggering the + * commit stage of the part. + * + * This is useful in cases where a directive needs to update the part such + * that the next update detects a value change or not. When value is omitted, + * the next update will be guaranteed to be detected as a change. + * + * @param part + * @param value + */ +const setCommittedValue = (part, value = RESET_VALUE) => (part._$committedValue = value); +/** + * Returns the committed value of a ChildPart. + * + * The committed value is used for change detection and efficient updates of + * the part. It can differ from the value set by the template or directive in + * cases where the template value is transformed before being commited. + * + * - `TemplateResult`s are committed as a `TemplateInstance` + * - Iterables are committed as `Array<ChildPart>` + * - All other types are committed as the template value or value returned or + * set by a directive. + * + * @param part + */ +const getCommittedValue = (part) => part._$committedValue; +/** + * Removes a ChildPart from the DOM, including any of its content. + * + * @param part The Part to remove + */ +const removePart = (part) => { + var _a; + (_a = part._$notifyConnectionChanged) === null || _a === void 0 ? void 0 : _a.call(part, false, true); + let start = part._$startNode; + const end = wrap(part._$endNode).nextSibling; + while (start !== end) { + const n = wrap(start).nextSibling; + wrap(start).remove(); + start = n; + } +}; +const clearPart = (part) => { + part._$clear(); +}; + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const PartType = { + ATTRIBUTE: 1, + CHILD: 2, + PROPERTY: 3, + BOOLEAN_ATTRIBUTE: 4, + EVENT: 5, + ELEMENT: 6, +}; +/** + * Creates a user-facing directive function from a Directive class. This + * function has the same parameters as the directive's render() method. + */ +const directive = (c) => (...values) => ({ + // This property needs to remain unminified. + ['_$litDirective$']: c, + values, +}); +/** + * Base class for creating custom directives. Users should extend this class, + * implement `render` and/or `update`, and then pass their subclass to + * `directive`. + */ +class Directive { + constructor(_partInfo) { } + // See comment in Disconnectable interface for why this is a getter + get _$isConnected() { + return this._$parent._$isConnected; + } + /** @internal */ + _$initialize(part, parent, attributeIndex) { + this.__part = part; + this._$parent = parent; + this.__attributeIndex = attributeIndex; + } + /** @internal */ + _$resolve(part, props) { + return this.update(part, props); + } + update(_part, props) { + return this.render(...props); + } +} + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Recursively walks down the tree of Parts/TemplateInstances/Directives to set + * the connected state of directives and run `disconnected`/ `reconnected` + * callbacks. + * + * @return True if there were children to disconnect; false otherwise + */ +const notifyChildrenConnectedChanged = (parent, isConnected) => { + var _a, _b; + const children = parent._$disconnectableChildren; + if (children === undefined) { + return false; + } + for (const obj of children) { + // The existence of `_$notifyDirectiveConnectionChanged` is used as a "brand" to + // disambiguate AsyncDirectives from other DisconnectableChildren + // (as opposed to using an instanceof check to know when to call it); the + // redundancy of "Directive" in the API name is to avoid conflicting with + // `_$notifyConnectionChanged`, which exists `ChildParts` which are also in + // this list + // Disconnect Directive (and any nested directives contained within) + // This property needs to remain unminified. + (_b = (_a = obj)['_$notifyDirectiveConnectionChanged']) === null || _b === void 0 ? void 0 : _b.call(_a, isConnected, false); + // Disconnect Part/TemplateInstance + notifyChildrenConnectedChanged(obj, isConnected); + } + return true; +}; +/** + * Removes the given child from its parent list of disconnectable children, and + * if the parent list becomes empty as a result, removes the parent from its + * parent, and so forth up the tree when that causes subsequent parent lists to + * become empty. + */ +const removeDisconnectableFromParent = (obj) => { + let parent, children; + do { + if ((parent = obj._$parent) === undefined) { + break; + } + children = parent._$disconnectableChildren; + children.delete(obj); + obj = parent; + } while ((children === null || children === void 0 ? void 0 : children.size) === 0); +}; +const addDisconnectableToParent = (obj) => { + // Climb the parent tree, creating a sparse tree of children needing + // disconnection + for (let parent; (parent = obj._$parent); obj = parent) { + let children = parent._$disconnectableChildren; + if (children === undefined) { + parent._$disconnectableChildren = children = new Set(); + } + else if (children.has(obj)) { + // Once we've reached a parent that already contains this child, we + // can short-circuit + break; + } + children.add(obj); + installDisconnectAPI(parent); + } +}; +/** + * Changes the parent reference of the ChildPart, and updates the sparse tree of + * Disconnectable children accordingly. + * + * Note, this method will be patched onto ChildPart instances and called from + * the core code when parts are moved between different parents. + */ +function reparentDisconnectables(newParent) { + if (this._$disconnectableChildren !== undefined) { + removeDisconnectableFromParent(this); + this._$parent = newParent; + addDisconnectableToParent(this); + } + else { + this._$parent = newParent; + } +} +/** + * Sets the connected state on any directives contained within the committed + * value of this part (i.e. within a TemplateInstance or iterable of + * ChildParts) and runs their `disconnected`/`reconnected`s, as well as within + * any directives stored on the ChildPart (when `valueOnly` is false). + * + * `isClearingValue` should be passed as `true` on a top-level part that is + * clearing itself, and not as a result of recursively disconnecting directives + * as part of a `clear` operation higher up the tree. This both ensures that any + * directive on this ChildPart that produced a value that caused the clear + * operation is not disconnected, and also serves as a performance optimization + * to avoid needless bookkeeping when a subtree is going away; when clearing a + * subtree, only the top-most part need to remove itself from the parent. + * + * `fromPartIndex` is passed only in the case of a partial `_clear` running as a + * result of truncating an iterable. + * + * Note, this method will be patched onto ChildPart instances and called from the + * core code when parts are cleared or the connection state is changed by the + * user. + */ +function notifyChildPartConnectedChanged(isConnected, isClearingValue = false, fromPartIndex = 0) { + const value = this._$committedValue; + const children = this._$disconnectableChildren; + if (children === undefined || children.size === 0) { + return; + } + if (isClearingValue) { + if (Array.isArray(value)) { + // Iterable case: Any ChildParts created by the iterable should be + // disconnected and removed from this ChildPart's disconnectable + // children (starting at `fromPartIndex` in the case of truncation) + for (let i = fromPartIndex; i < value.length; i++) { + notifyChildrenConnectedChanged(value[i], false); + removeDisconnectableFromParent(value[i]); + } + } + else if (value != null) { + // TemplateInstance case: If the value has disconnectable children (will + // only be in the case that it is a TemplateInstance), we disconnect it + // and remove it from this ChildPart's disconnectable children + notifyChildrenConnectedChanged(value, false); + removeDisconnectableFromParent(value); + } + } + else { + notifyChildrenConnectedChanged(this, isConnected); + } +} +/** + * Patches disconnection API onto ChildParts. + */ +const installDisconnectAPI = (obj) => { + var _a, _b; + var _c, _d; + if (obj.type == PartType.CHILD) { + (_a = (_c = obj)._$notifyConnectionChanged) !== null && _a !== void 0 ? _a : (_c._$notifyConnectionChanged = notifyChildPartConnectedChanged); + (_b = (_d = obj)._$reparentDisconnectables) !== null && _b !== void 0 ? _b : (_d._$reparentDisconnectables = reparentDisconnectables); + } +}; +/** + * An abstract `Directive` base class whose `disconnected` method will be + * called when the part containing the directive is cleared as a result of + * re-rendering, or when the user calls `part.setConnected(false)` on + * a part that was previously rendered containing the directive (as happens + * when e.g. a LitElement disconnects from the DOM). + * + * If `part.setConnected(true)` is subsequently called on a + * containing part, the directive's `reconnected` method will be called prior + * to its next `update`/`render` callbacks. When implementing `disconnected`, + * `reconnected` should also be implemented to be compatible with reconnection. + * + * Note that updates may occur while the directive is disconnected. As such, + * directives should generally check the `this.isConnected` flag during + * render/update to determine whether it is safe to subscribe to resources + * that may prevent garbage collection. + */ +class AsyncDirective extends Directive { + constructor() { + super(...arguments); + // @internal + this._$disconnectableChildren = undefined; + } + /** + * Initialize the part with internal fields + * @param part + * @param parent + * @param attributeIndex + */ + _$initialize(part, parent, attributeIndex) { + super._$initialize(part, parent, attributeIndex); + addDisconnectableToParent(this); + this.isConnected = part._$isConnected; + } + // This property needs to remain unminified. + /** + * Called from the core code when a directive is going away from a part (in + * which case `shouldRemoveFromParent` should be true), and from the + * `setChildrenConnected` helper function when recursively changing the + * connection state of a tree (in which case `shouldRemoveFromParent` should + * be false). + * + * @param isConnected + * @param isClearingDirective - True when the directive itself is being + * removed; false when the tree is being disconnected + * @internal + */ + ['_$notifyDirectiveConnectionChanged'](isConnected, isClearingDirective = true) { + var _a, _b; + if (isConnected !== this.isConnected) { + this.isConnected = isConnected; + if (isConnected) { + (_a = this.reconnected) === null || _a === void 0 ? void 0 : _a.call(this); + } + else { + (_b = this.disconnected) === null || _b === void 0 ? void 0 : _b.call(this); + } + } + if (isClearingDirective) { + notifyChildrenConnectedChanged(this, isConnected); + removeDisconnectableFromParent(this); + } + } + /** + * Sets the value of the directive's Part outside the normal `update`/`render` + * lifecycle of a directive. + * + * This method should not be called synchronously from a directive's `update` + * or `render`. + * + * @param directive The directive to update + * @param value The value to set + */ + setValue(value) { + if (isSingleExpression(this.__part)) { + this.__part._$setValue(value, this); + } + else { + const newValues = [...this.__part._$committedValue]; + newValues[this.__attributeIndex] = value; + this.__part._$setValue(newValues, this, 0); + } + } + /** + * User callbacks for implementing logic to release any resources/subscriptions + * that may have been retained by this directive. Since directives may also be + * re-connected, `reconnected` should also be implemented to restore the + * working state of the directive prior to the next render. + */ + disconnected() { } + reconnected() { } +} + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +// Note, this module is not included in package exports so that it's private to +// our first-party directives. If it ends up being useful, we can open it up and +// export it. +/** + * Helper to iterate an AsyncIterable in its own closure. + * @param iterable The iterable to iterate + * @param callback The callback to call for each value. If the callback returns + * `false`, the loop will be broken. + */ +const forAwaitOf = async (iterable, callback) => { + for await (const v of iterable) { + if ((await callback(v)) === false) { + return; + } + } +}; +/** + * Holds a reference to an instance that can be disconnected and reconnected, + * so that a closure over the ref (e.g. in a then function to a promise) does + * not strongly hold a ref to the instance. Approximates a WeakRef but must + * be manually connected & disconnected to the backing instance. + */ +class PseudoWeakRef { + constructor(ref) { + this._ref = ref; + } + /** + * Disassociates the ref with the backing instance. + */ + disconnect() { + this._ref = undefined; + } + /** + * Reassociates the ref with the backing instance. + */ + reconnect(ref) { + this._ref = ref; + } + /** + * Retrieves the backing instance (will be undefined when disconnected) + */ + deref() { + return this._ref; + } +} +/** + * A helper to pause and resume waiting on a condition in an async function + */ +class Pauser { + constructor() { + this._promise = undefined; + this._resolve = undefined; + } + /** + * When paused, returns a promise to be awaited; when unpaused, returns + * undefined. Note that in the microtask between the pauser being resumed + * an an await of this promise resolving, the pauser could be paused again, + * hence callers should check the promise in a loop when awaiting. + * @returns A promise to be awaited when paused or undefined + */ + get() { + return this._promise; + } + /** + * Creates a promise to be awaited + */ + pause() { + var _a; + (_a = this._promise) !== null && _a !== void 0 ? _a : (this._promise = new Promise((resolve) => (this._resolve = resolve))); + } + /** + * Resolves the promise which may be awaited + */ + resume() { + var _a; + (_a = this._resolve) === null || _a === void 0 ? void 0 : _a.call(this); + this._promise = this._resolve = undefined; + } +} + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class AsyncReplaceDirective extends AsyncDirective { + constructor() { + super(...arguments); + this.__weakThis = new PseudoWeakRef(this); + this.__pauser = new Pauser(); + } + // @ts-expect-error value not used, but we want a nice parameter for docs + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render(value, _mapper) { + return noChange; + } + update(_part, [value, mapper]) { + // If our initial render occurs while disconnected, ensure that the pauser + // and weakThis are in the disconnected state + if (!this.isConnected) { + this.disconnected(); + } + // If we've already set up this particular iterable, we don't need + // to do anything. + if (value === this.__value) { + return; + } + this.__value = value; + let i = 0; + const { __weakThis: weakThis, __pauser: pauser } = this; + // Note, the callback avoids closing over `this` so that the directive + // can be gc'ed before the promise resolves; instead `this` is retrieved + // from `weakThis`, which can break the hard reference in the closure when + // the directive disconnects + forAwaitOf(value, async (v) => { + // The while loop here handles the case that the connection state + // thrashes, causing the pauser to resume and then get re-paused + while (pauser.get()) { + await pauser.get(); + } + // If the callback gets here and there is no `this`, it means that the + // directive has been disconnected and garbage collected and we don't + // need to do anything else + const _this = weakThis.deref(); + if (_this !== undefined) { + // Check to make sure that value is the still the current value of + // the part, and if not bail because a new value owns this part + if (_this.__value !== value) { + return false; + } + // As a convenience, because functional-programming-style + // transforms of iterables and async iterables requires a library, + // we accept a mapper function. This is especially convenient for + // rendering a template for each item. + if (mapper !== undefined) { + v = mapper(v, i); + } + _this.commitValue(v, i); + i++; + } + return true; + }); + return noChange; + } + // Override point for AsyncAppend to append rather than replace + commitValue(value, _index) { + this.setValue(value); + } + disconnected() { + this.__weakThis.disconnect(); + this.__pauser.pause(); + } + reconnected() { + this.__weakThis.reconnect(this); + this.__pauser.resume(); + } +} +/** + * A directive that renders the items of an async iterable[1], replacing + * previous values with new values, so that only one value is ever rendered + * at a time. This directive may be used in any expression type. + * + * Async iterables are objects with a `[Symbol.asyncIterator]` method, which + * returns an iterator who's `next()` method returns a Promise. When a new + * value is available, the Promise resolves and the value is rendered to the + * Part controlled by the directive. If another value other than this + * directive has been set on the Part, the iterable will no longer be listened + * to and new values won't be written to the Part. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of + * + * @param value An async iterable + * @param mapper An optional function that maps from (value, index) to another + * value. Useful for generating templates for each item in the iterable. + */ +const asyncReplace = directive(AsyncReplaceDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class AsyncAppendDirective extends AsyncReplaceDirective { + // Override AsyncReplace to narrow the allowed part type to ChildPart only + constructor(partInfo) { + super(partInfo); + if (partInfo.type !== PartType.CHILD) { + throw new Error('asyncAppend can only be used in child expressions'); + } + } + // Override AsyncReplace to save the part since we need to append into it + update(part, params) { + this.__childPart = part; + return super.update(part, params); + } + // Override AsyncReplace to append rather than replace + commitValue(value, index) { + // When we get the first value, clear the part. This lets the + // previous value display until we can replace it. + if (index === 0) { + clearPart(this.__childPart); + } + // Create and insert a new part and set its value to the next value + const newPart = insertPart(this.__childPart); + setChildPartValue(newPart, value); + } +} +/** + * A directive that renders the items of an async iterable[1], appending new + * values after previous values, similar to the built-in support for iterables. + * This directive is usable only in child expressions. + * + * Async iterables are objects with a [Symbol.asyncIterator] method, which + * returns an iterator who's `next()` method returns a Promise. When a new + * value is available, the Promise resolves and the value is appended to the + * Part controlled by the directive. If another value other than this + * directive has been set on the Part, the iterable will no longer be listened + * to and new values won't be written to the Part. + * + * [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of + * + * @param value An async iterable + * @param mapper An optional function that maps from (value, index) to another + * value. Useful for generating templates for each item in the iterable. + */ +const asyncAppend = directive(AsyncAppendDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class CacheDirective extends Directive { + constructor(partInfo) { + super(partInfo); + this._templateCache = new WeakMap(); + } + render(v) { + // Return an array of the value to induce lit-html to create a ChildPart + // for the value that we can move into the cache. + return [v]; + } + update(containerPart, [v]) { + // If the previous value is a TemplateResult and the new value is not, + // or is a different Template as the previous value, move the child part + // into the cache. + if (isTemplateResult(this._value) && + (!isTemplateResult(v) || this._value.strings !== v.strings)) { + // This is always an array because we return [v] in render() + const partValue = getCommittedValue(containerPart); + const childPart = partValue.pop(); + let cachedContainerPart = this._templateCache.get(this._value.strings); + if (cachedContainerPart === undefined) { + const fragment = document.createDocumentFragment(); + cachedContainerPart = render(nothing, fragment); + cachedContainerPart.setConnected(false); + this._templateCache.set(this._value.strings, cachedContainerPart); + } + // Move into cache + setCommittedValue(cachedContainerPart, [childPart]); + insertPart(cachedContainerPart, undefined, childPart); + } + // If the new value is a TemplateResult and the previous value is not, + // or is a different Template as the previous value, restore the child + // part from the cache. + if (isTemplateResult(v)) { + if (!isTemplateResult(this._value) || this._value.strings !== v.strings) { + const cachedContainerPart = this._templateCache.get(v.strings); + if (cachedContainerPart !== undefined) { + // Move the cached part back into the container part value + const partValue = getCommittedValue(cachedContainerPart); + const cachedPart = partValue.pop(); + // Move cached part back into DOM + clearPart(containerPart); + insertPart(containerPart, undefined, cachedPart); + setCommittedValue(containerPart, [cachedPart]); + } + } + this._value = v; + } + else { + this._value = undefined; + } + return this.render(v); + } +} +/** + * Enables fast switching between multiple templates by caching the DOM nodes + * and TemplateInstances produced by the templates. + * + * Example: + * + * ```js + * let checked = false; + * + * html` + * ${cache(checked ? html`input is checked` : html`input is not checked`)} + * ` + * ``` + */ +const cache = directive(CacheDirective); + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Chooses and evaluates a template function from a list based on matching + * the given `value` to a case. + * + * Cases are structured as `[caseValue, func]`. `value` is matched to + * `caseValue` by strict equality. The first match is selected. Case values + * can be of any type including primitives, objects, and symbols. + * + * This is similar to a switch statement, but as an expression and without + * fallthrough. + * + * @example + * + * ```ts + * render() { + * return html` + * ${choose(this.section, [ + * ['home', () => html`<h1>Home</h1>`], + * ['about', () => html`<h1>About</h1>`] + * ], + * () => html`<h1>Error</h1>`)} + * `; + * } + * ``` + */ +const choose = (value, cases, defaultCase) => { + for (const c of cases) { + const caseValue = c[0]; + if (caseValue === value) { + const fn = c[1]; + return fn(); + } + } + return defaultCase === null || defaultCase === void 0 ? void 0 : defaultCase(); +}; + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class ClassMapDirective extends Directive { + constructor(partInfo) { + var _a; + super(partInfo); + if (partInfo.type !== PartType.ATTRIBUTE || + partInfo.name !== 'class' || + ((_a = partInfo.strings) === null || _a === void 0 ? void 0 : _a.length) > 2) { + throw new Error('`classMap()` can only be used in the `class` attribute ' + + 'and must be the only part in the attribute.'); + } + } + render(classInfo) { + // Add spaces to ensure separation from static classes + return (' ' + + Object.keys(classInfo) + .filter((key) => classInfo[key]) + .join(' ') + + ' '); + } + update(part, [classInfo]) { + var _a, _b; + // Remember dynamic classes on the first render + if (this._previousClasses === undefined) { + this._previousClasses = new Set(); + if (part.strings !== undefined) { + this._staticClasses = new Set(part.strings + .join(' ') + .split(/\s/) + .filter((s) => s !== '')); + } + for (const name in classInfo) { + if (classInfo[name] && !((_a = this._staticClasses) === null || _a === void 0 ? void 0 : _a.has(name))) { + this._previousClasses.add(name); + } + } + return this.render(classInfo); + } + const classList = part.element.classList; + // Remove old classes that no longer apply + // We use forEach() instead of for-of so that we don't require down-level + // iteration. + this._previousClasses.forEach((name) => { + if (!(name in classInfo)) { + classList.remove(name); + this._previousClasses.delete(name); + } + }); + // Add or remove classes based on their classMap value + for (const name in classInfo) { + // We explicitly want a loose truthy check of `value` because it seems + // more convenient that '' and 0 are skipped. + const value = !!classInfo[name]; + if (value !== this._previousClasses.has(name) && + !((_b = this._staticClasses) === null || _b === void 0 ? void 0 : _b.has(name))) { + if (value) { + classList.add(name); + this._previousClasses.add(name); + } + else { + classList.remove(name); + this._previousClasses.delete(name); + } + } + } + return noChange; + } +} +/** + * A directive that applies dynamic CSS classes. + * + * This must be used in the `class` attribute and must be the only part used in + * the attribute. It takes each property in the `classInfo` argument and adds + * the property name to the element's `classList` if the property value is + * truthy; if the property value is falsey, the property name is removed from + * the element's `class`. + * + * For example `{foo: bar}` applies the class `foo` if the value of `bar` is + * truthy. + * + * @param classInfo + */ +const classMap = directive(ClassMapDirective); + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +// A sentinal that indicates guard() hasn't rendered anything yet +const initialValue = {}; +class GuardDirective extends Directive { + constructor() { + super(...arguments); + this._previousValue = initialValue; + } + render(_value, f) { + return f(); + } + update(_part, [value, f]) { + if (Array.isArray(value)) { + // Dirty-check arrays by item + if (Array.isArray(this._previousValue) && + this._previousValue.length === value.length && + value.every((v, i) => v === this._previousValue[i])) { + return noChange; + } + } + else if (this._previousValue === value) { + // Dirty-check non-arrays by identity + return noChange; + } + // Copy the value if it's an array so that if it's mutated we don't forget + // what the previous values were. + this._previousValue = Array.isArray(value) ? Array.from(value) : value; + const r = this.render(value, f); + return r; + } +} +/** + * Prevents re-render of a template function until a single value or an array of + * values changes. + * + * Values are checked against previous values with strict equality (`===`), and + * so the check won't detect nested property changes inside objects or arrays. + * Arrays values have each item checked against the previous value at the same + * index with strict equality. Nested arrays are also checked only by strict + * equality. + * + * Example: + * + * ```js + * html` + * <div> + * ${guard([user.id, company.id], () => html`...`)} + * </div> + * ` + * ``` + * + * In this case, the template only rerenders if either `user.id` or `company.id` + * changes. + * + * guard() is useful with immutable data patterns, by preventing expensive work + * until data updates. + * + * Example: + * + * ```js + * html` + * <div> + * ${guard([immutableItems], () => immutableItems.map(i => html`${i}`))} + * </div> + * ` + * ``` + * + * In this case, items are mapped over only when the array reference changes. + * + * @param value the value to check before re-rendering + * @param f the template function + */ +const guard = directive(GuardDirective); + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * For AttributeParts, sets the attribute if the value is defined and removes + * the attribute if the value is undefined. + * + * For other part types, this directive is a no-op. + */ +const ifDefined = (value) => value !== null && value !== void 0 ? value : nothing; + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function* join(items, joiner) { + const isFunction = typeof joiner === 'function'; + if (items !== undefined) { + let i = -1; + for (const value of items) { + if (i > -1) { + yield isFunction ? joiner(i) : joiner; + } + i++; + yield value; + } + } +} + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class Keyed extends Directive { + constructor() { + super(...arguments); + this.key = nothing; + } + render(k, v) { + this.key = k; + return v; + } + update(part, [k, v]) { + if (k !== this.key) { + // Clear the part before returning a value. The one-arg form of + // setCommittedValue sets the value to a sentinel which forces a + // commit the next render. + setCommittedValue(part); + this.key = k; + } + return v; + } +} +/** + * Associates a renderable value with a unique key. When the key changes, the + * previous DOM is removed and disposed before rendering the next value, even + * if the value - such as a template - is the same. + * + * This is useful for forcing re-renders of stateful components, or working + * with code that expects new data to generate new HTML elements, such as some + * animation techniques. + */ +const keyed = directive(Keyed); + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class LiveDirective extends Directive { + constructor(partInfo) { + super(partInfo); + if (!(partInfo.type === PartType.PROPERTY || + partInfo.type === PartType.ATTRIBUTE || + partInfo.type === PartType.BOOLEAN_ATTRIBUTE)) { + throw new Error('The `live` directive is not allowed on child or event bindings'); + } + if (!isSingleExpression(partInfo)) { + throw new Error('`live` bindings can only contain a single expression'); + } + } + render(value) { + return value; + } + update(part, [value]) { + if (value === noChange || value === nothing) { + return value; + } + const element = part.element; + const name = part.name; + if (part.type === PartType.PROPERTY) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (value === element[name]) { + return noChange; + } + } + else if (part.type === PartType.BOOLEAN_ATTRIBUTE) { + if (!!value === element.hasAttribute(name)) { + return noChange; + } + } + else if (part.type === PartType.ATTRIBUTE) { + if (element.getAttribute(name) === String(value)) { + return noChange; + } + } + // Resets the part's value, causing its dirty-check to fail so that it + // always sets the value. + setCommittedValue(part); + return value; + } +} +/** + * Checks binding values against live DOM values, instead of previously bound + * values, when determining whether to update the value. + * + * This is useful for cases where the DOM value may change from outside of + * lit-html, such as with a binding to an `<input>` element's `value` property, + * a content editable elements text, or to a custom element that changes it's + * own properties or attributes. + * + * In these cases if the DOM value changes, but the value set through lit-html + * bindings hasn't, lit-html won't know to update the DOM value and will leave + * it alone. If this is not what you want--if you want to overwrite the DOM + * value with the bound value no matter what--use the `live()` directive: + * + * ```js + * html`<input .value=${live(x)}>` + * ``` + * + * `live()` performs a strict equality check against the live DOM value, and if + * the new value is equal to the live value, does nothing. This means that + * `live()` should not be used when the binding will cause a type conversion. If + * you use `live()` with an attribute binding, make sure that only strings are + * passed in, or the binding will update every render. + */ +const live = directive(LiveDirective); + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Returns an iterable containing the result of calling `f(value)` on each + * value in `items`. + * + * @example + * + * ```ts + * render() { + * return html` + * <ul> + * ${map(items, (i) => html`<li>${i}</li>`)} + * </ul> + * `; + * } + * ``` + */ +function* map(items, f) { + if (items !== undefined) { + let i = 0; + for (const value of items) { + yield f(value, i++); + } + } +} + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function* range(startOrEnd, end, step = 1) { + const start = end === undefined ? 0 : startOrEnd; + end !== null && end !== void 0 ? end : (end = startOrEnd); + for (let i = start; step > 0 ? i < end : end < i; i += step) { + yield i; + } +} + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Creates a new Ref object, which is container for a reference to an element. + */ +const createRef = () => new Ref(); +/** + * An object that holds a ref value. + */ +class Ref { +} +// When callbacks are used for refs, this map tracks the last value the callback +// was called with, for ensuring a directive doesn't clear the ref if the ref +// has already been rendered to a new spot. It is double-keyed on both the +// context (`options.host`) and the callback, since we auto-bind class methods +// to `options.host`. +const lastElementForContextAndCallback = new WeakMap(); +class RefDirective extends AsyncDirective { + render(_ref) { + return nothing; + } + update(part, [ref]) { + var _a; + const refChanged = ref !== this._ref; + if (refChanged && this._ref !== undefined) { + // The ref passed to the directive has changed; + // unset the previous ref's value + this._updateRefValue(undefined); + } + if (refChanged || this._lastElementForRef !== this._element) { + // We either got a new ref or this is the first render; + // store the ref/element & update the ref value + this._ref = ref; + this._context = (_a = part.options) === null || _a === void 0 ? void 0 : _a.host; + this._updateRefValue((this._element = part.element)); + } + return nothing; + } + _updateRefValue(element) { + var _a; + if (typeof this._ref === 'function') { + // If the current ref was called with a previous value, call with + // `undefined`; We do this to ensure callbacks are called in a consistent + // way regardless of whether a ref might be moving up in the tree (in + // which case it would otherwise be called with the new value before the + // previous one unsets it) and down in the tree (where it would be unset + // before being set). Note that element lookup is keyed by + // both the context and the callback, since we allow passing unbound + // functions that are called on options.host, and we want to treat + // these as unique "instances" of a function. + const context = (_a = this._context) !== null && _a !== void 0 ? _a : globalThis; + let lastElementForCallback = lastElementForContextAndCallback.get(context); + if (lastElementForCallback === undefined) { + lastElementForCallback = new WeakMap(); + lastElementForContextAndCallback.set(context, lastElementForCallback); + } + if (lastElementForCallback.get(this._ref) !== undefined) { + this._ref.call(this._context, undefined); + } + lastElementForCallback.set(this._ref, element); + // Call the ref with the new element value + if (element !== undefined) { + this._ref.call(this._context, element); + } + } + else { + this._ref.value = element; + } + } + get _lastElementForRef() { + var _a, _b, _c; + return typeof this._ref === 'function' + ? (_b = lastElementForContextAndCallback + .get((_a = this._context) !== null && _a !== void 0 ? _a : globalThis)) === null || _b === void 0 ? void 0 : _b.get(this._ref) + : (_c = this._ref) === null || _c === void 0 ? void 0 : _c.value; + } + disconnected() { + // Only clear the box if our element is still the one in it (i.e. another + // directive instance hasn't rendered its element to it before us); that + // only happens in the event of the directive being cleared (not via manual + // disconnection) + if (this._lastElementForRef === this._element) { + this._updateRefValue(undefined); + } + } + reconnected() { + // If we were manually disconnected, we can safely put our element back in + // the box, since no rendering could have occurred to change its state + this._updateRefValue(this._element); + } +} +/** + * Sets the value of a Ref object or calls a ref callback with the element it's + * bound to. + * + * A Ref object acts as a container for a reference to an element. A ref + * callback is a function that takes an element as its only argument. + * + * The ref directive sets the value of the Ref object or calls the ref callback + * during rendering, if the referenced element changed. + * + * Note: If a ref callback is rendered to a different element position or is + * removed in a subsequent render, it will first be called with `undefined`, + * followed by another call with the new element it was rendered to (if any). + * + * ```js + * // Using Ref object + * const inputRef = createRef(); + * render(html`<input ${ref(inputRef)}>`, container); + * inputRef.value.focus(); + * + * // Using callback + * const callback = (inputElement) => inputElement.focus(); + * render(html`<input ${ref(callback)}>`, container); + * ``` + */ +const ref = directive(RefDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +// Helper for generating a map of array item to its index over a subset +// of an array (used to lazily generate `newKeyToIndexMap` and +// `oldKeyToIndexMap`) +const generateMap = (list, start, end) => { + const map = new Map(); + for (let i = start; i <= end; i++) { + map.set(list[i], i); + } + return map; +}; +class RepeatDirective extends Directive { + constructor(partInfo) { + super(partInfo); + if (partInfo.type !== PartType.CHILD) { + throw new Error('repeat() can only be used in text expressions'); + } + } + _getValuesAndKeys(items, keyFnOrTemplate, template) { + let keyFn; + if (template === undefined) { + template = keyFnOrTemplate; + } + else if (keyFnOrTemplate !== undefined) { + keyFn = keyFnOrTemplate; + } + const keys = []; + const values = []; + let index = 0; + for (const item of items) { + keys[index] = keyFn ? keyFn(item, index) : index; + values[index] = template(item, index); + index++; + } + return { + values, + keys, + }; + } + render(items, keyFnOrTemplate, template) { + return this._getValuesAndKeys(items, keyFnOrTemplate, template).values; + } + update(containerPart, [items, keyFnOrTemplate, template]) { + var _a; + // Old part & key lists are retrieved from the last update (which may + // be primed by hydration) + const oldParts = getCommittedValue(containerPart); + const { values: newValues, keys: newKeys } = this._getValuesAndKeys(items, keyFnOrTemplate, template); + // We check that oldParts, the committed value, is an Array as an + // indicator that the previous value came from a repeat() call. If + // oldParts is not an Array then this is the first render and we return + // an array for lit-html's array handling to render, and remember the + // keys. + if (!Array.isArray(oldParts)) { + this._itemKeys = newKeys; + return newValues; + } + // In SSR hydration it's possible for oldParts to be an arrray but for us + // to not have item keys because the update() hasn't run yet. We set the + // keys to an empty array. This will cause all oldKey/newKey comparisons + // to fail and execution to fall to the last nested brach below which + // reuses the oldPart. + const oldKeys = ((_a = this._itemKeys) !== null && _a !== void 0 ? _a : (this._itemKeys = [])); + // New part list will be built up as we go (either reused from + // old parts or created for new keys in this update). This is + // saved in the above cache at the end of the update. + const newParts = []; + // Maps from key to index for current and previous update; these + // are generated lazily only when needed as a performance + // optimization, since they are only required for multiple + // non-contiguous changes in the list, which are less common. + let newKeyToIndexMap; + let oldKeyToIndexMap; + // Head and tail pointers to old parts and new values + let oldHead = 0; + let oldTail = oldParts.length - 1; + let newHead = 0; + let newTail = newValues.length - 1; + // Overview of O(n) reconciliation algorithm (general approach + // based on ideas found in ivi, vue, snabbdom, etc.): + // + // * We start with the list of old parts and new values (and + // arrays of their respective keys), head/tail pointers into + // each, and we build up the new list of parts by updating + // (and when needed, moving) old parts or creating new ones. + // The initial scenario might look like this (for brevity of + // the diagrams, the numbers in the array reflect keys + // associated with the old parts or new values, although keys + // and parts/values are actually stored in parallel arrays + // indexed using the same head/tail pointers): + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [ , , , , , , ] + // newKeys: [0, 2, 1, 4, 3, 7, 6] <- reflects the user's new + // item order + // newHead ^ ^ newTail + // + // * Iterate old & new lists from both sides, updating, + // swapping, or removing parts at the head/tail locations + // until neither head nor tail can move. + // + // * Example below: keys at head pointers match, so update old + // part 0 in-place (no need to move it) and record part 0 in + // the `newParts` list. The last thing we do is advance the + // `oldHead` and `newHead` pointers (will be reflected in the + // next diagram). + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [0, , , , , , ] <- heads matched: update 0 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance both oldHead + // & newHead + // newHead ^ ^ newTail + // + // * Example below: head pointers don't match, but tail + // pointers do, so update part 6 in place (no need to move + // it), and record part 6 in the `newParts` list. Last, + // advance the `oldTail` and `oldHead` pointers. + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [0, , , , , , 6] <- tails matched: update 6 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance both oldTail + // & newTail + // newHead ^ ^ newTail + // + // * If neither head nor tail match; next check if one of the + // old head/tail items was removed. We first need to generate + // the reverse map of new keys to index (`newKeyToIndexMap`), + // which is done once lazily as a performance optimization, + // since we only hit this case if multiple non-contiguous + // changes were made. Note that for contiguous removal + // anywhere in the list, the head and tails would advance + // from either end and pass each other before we get to this + // case and removals would be handled in the final while loop + // without needing to generate the map. + // + // * Example below: The key at `oldTail` was removed (no longer + // in the `newKeyToIndexMap`), so remove that part from the + // DOM and advance just the `oldTail` pointer. + // + // oldHead v v oldTail + // oldKeys: [0, 1, 2, 3, 4, 5, 6] + // newParts: [0, , , , , , 6] <- 5 not in new map: remove + // newKeys: [0, 2, 1, 4, 3, 7, 6] 5 and advance oldTail + // newHead ^ ^ newTail + // + // * Once head and tail cannot move, any mismatches are due to + // either new or moved items; if a new key is in the previous + // "old key to old index" map, move the old part to the new + // location, otherwise create and insert a new part. Note + // that when moving an old part we null its position in the + // oldParts array if it lies between the head and tail so we + // know to skip it when the pointers get there. + // + // * Example below: neither head nor tail match, and neither + // were removed; so find the `newHead` key in the + // `oldKeyToIndexMap`, and move that old part's DOM into the + // next head position (before `oldParts[oldHead]`). Last, + // null the part in the `oldPart` array since it was + // somewhere in the remaining oldParts still to be scanned + // (between the head and tail pointers) so that we know to + // skip that old part on future iterations. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, , , , , 6] <- stuck: update & move 2 + // newKeys: [0, 2, 1, 4, 3, 7, 6] into place and advance + // newHead + // newHead ^ ^ newTail + // + // * Note that for moves/insertions like the one above, a part + // inserted at the head pointer is inserted before the + // current `oldParts[oldHead]`, and a part inserted at the + // tail pointer is inserted before `newParts[newTail+1]`. The + // seeming asymmetry lies in the fact that new parts are + // moved into place outside in, so to the right of the head + // pointer are old parts, and to the right of the tail + // pointer are new parts. + // + // * We always restart back from the top of the algorithm, + // allowing matching and simple updates in place to + // continue... + // + // * Example below: the head pointers once again match, so + // simply update part 1 and record it in the `newParts` + // array. Last, advance both head pointers. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, , , , 6] <- heads matched: update 1 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance both oldHead + // & newHead + // newHead ^ ^ newTail + // + // * As mentioned above, items that were moved as a result of + // being stuck (the final else clause in the code below) are + // marked with null, so we always advance old pointers over + // these so we're comparing the next actual old value on + // either end. + // + // * Example below: `oldHead` is null (already placed in + // newParts), so advance `oldHead`. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] <- old head already used: + // newParts: [0, 2, 1, , , , 6] advance oldHead + // newKeys: [0, 2, 1, 4, 3, 7, 6] + // newHead ^ ^ newTail + // + // * Note it's not critical to mark old parts as null when they + // are moved from head to tail or tail to head, since they + // will be outside the pointer range and never visited again. + // + // * Example below: Here the old tail key matches the new head + // key, so the part at the `oldTail` position and move its + // DOM to the new head position (before `oldParts[oldHead]`). + // Last, advance `oldTail` and `newHead` pointers. + // + // oldHead v v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, 4, , , 6] <- old tail matches new + // newKeys: [0, 2, 1, 4, 3, 7, 6] head: update & move 4, + // advance oldTail & newHead + // newHead ^ ^ newTail + // + // * Example below: Old and new head keys match, so update the + // old head part in place, and advance the `oldHead` and + // `newHead` pointers. + // + // oldHead v oldTail + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, 4, 3, ,6] <- heads match: update 3 + // newKeys: [0, 2, 1, 4, 3, 7, 6] and advance oldHead & + // newHead + // newHead ^ ^ newTail + // + // * Once the new or old pointers move past each other then all + // we have left is additions (if old list exhausted) or + // removals (if new list exhausted). Those are handled in the + // final while loops at the end. + // + // * Example below: `oldHead` exceeded `oldTail`, so we're done + // with the main loop. Create the remaining part and insert + // it at the new head position, and the update is complete. + // + // (oldHead > oldTail) + // oldKeys: [0, 1, -, 3, 4, 5, 6] + // newParts: [0, 2, 1, 4, 3, 7 ,6] <- create and insert 7 + // newKeys: [0, 2, 1, 4, 3, 7, 6] + // newHead ^ newTail + // + // * Note that the order of the if/else clauses is not + // important to the algorithm, as long as the null checks + // come first (to ensure we're always working on valid old + // parts) and that the final else clause comes last (since + // that's where the expensive moves occur). The order of + // remaining clauses is is just a simple guess at which cases + // will be most common. + // + // * Note, we could calculate the longest + // increasing subsequence (LIS) of old items in new position, + // and only move those not in the LIS set. However that costs + // O(nlogn) time and adds a bit more code, and only helps + // make rare types of mutations require fewer moves. The + // above handles removes, adds, reversal, swaps, and single + // moves of contiguous items in linear time, in the minimum + // number of moves. As the number of multiple moves where LIS + // might help approaches a random shuffle, the LIS + // optimization becomes less helpful, so it seems not worth + // the code at this point. Could reconsider if a compelling + // case arises. + while (oldHead <= oldTail && newHead <= newTail) { + if (oldParts[oldHead] === null) { + // `null` means old part at head has already been used + // below; skip + oldHead++; + } + else if (oldParts[oldTail] === null) { + // `null` means old part at tail has already been used + // below; skip + oldTail--; + } + else if (oldKeys[oldHead] === newKeys[newHead]) { + // Old head matches new head; update in place + newParts[newHead] = setChildPartValue(oldParts[oldHead], newValues[newHead]); + oldHead++; + newHead++; + } + else if (oldKeys[oldTail] === newKeys[newTail]) { + // Old tail matches new tail; update in place + newParts[newTail] = setChildPartValue(oldParts[oldTail], newValues[newTail]); + oldTail--; + newTail--; + } + else if (oldKeys[oldHead] === newKeys[newTail]) { + // Old head matches new tail; update and move to new tail + newParts[newTail] = setChildPartValue(oldParts[oldHead], newValues[newTail]); + insertPart(containerPart, newParts[newTail + 1], oldParts[oldHead]); + oldHead++; + newTail--; + } + else if (oldKeys[oldTail] === newKeys[newHead]) { + // Old tail matches new head; update and move to new head + newParts[newHead] = setChildPartValue(oldParts[oldTail], newValues[newHead]); + insertPart(containerPart, oldParts[oldHead], oldParts[oldTail]); + oldTail--; + newHead++; + } + else { + if (newKeyToIndexMap === undefined) { + // Lazily generate key-to-index maps, used for removals & + // moves below + newKeyToIndexMap = generateMap(newKeys, newHead, newTail); + oldKeyToIndexMap = generateMap(oldKeys, oldHead, oldTail); + } + if (!newKeyToIndexMap.has(oldKeys[oldHead])) { + // Old head is no longer in new list; remove + removePart(oldParts[oldHead]); + oldHead++; + } + else if (!newKeyToIndexMap.has(oldKeys[oldTail])) { + // Old tail is no longer in new list; remove + removePart(oldParts[oldTail]); + oldTail--; + } + else { + // Any mismatches at this point are due to additions or + // moves; see if we have an old part we can reuse and move + // into place + const oldIndex = oldKeyToIndexMap.get(newKeys[newHead]); + const oldPart = oldIndex !== undefined ? oldParts[oldIndex] : null; + if (oldPart === null) { + // No old part for this value; create a new one and + // insert it + const newPart = insertPart(containerPart, oldParts[oldHead]); + setChildPartValue(newPart, newValues[newHead]); + newParts[newHead] = newPart; + } + else { + // Reuse old part + newParts[newHead] = setChildPartValue(oldPart, newValues[newHead]); + insertPart(containerPart, oldParts[oldHead], oldPart); + // This marks the old part as having been used, so that + // it will be skipped in the first two checks above + oldParts[oldIndex] = null; + } + newHead++; + } + } + } + // Add parts for any remaining new values + while (newHead <= newTail) { + // For all remaining additions, we insert before last new + // tail, since old pointers are no longer valid + const newPart = insertPart(containerPart, newParts[newTail + 1]); + setChildPartValue(newPart, newValues[newHead]); + newParts[newHead++] = newPart; + } + // Remove any remaining unused old parts + while (oldHead <= oldTail) { + const oldPart = oldParts[oldHead++]; + if (oldPart !== null) { + removePart(oldPart); + } + } + // Save order of new parts for next round + this._itemKeys = newKeys; + // Directly set part value, bypassing it's dirty-checking + setCommittedValue(containerPart, newParts); + return noChange; + } +} +/** + * A directive that repeats a series of values (usually `TemplateResults`) + * generated from an iterable, and updates those items efficiently when the + * iterable changes based on user-provided `keys` associated with each item. + * + * Note that if a `keyFn` is provided, strict key-to-DOM mapping is maintained, + * meaning previous DOM for a given key is moved into the new position if + * needed, and DOM will never be reused with values for different keys (new DOM + * will always be created for new keys). This is generally the most efficient + * way to use `repeat` since it performs minimum unnecessary work for insertions + * and removals. + * + * The `keyFn` takes two parameters, the item and its index, and returns a unique key value. + * + * ```js + * html` + * <ol> + * ${repeat(this.items, (item) => item.id, (item, index) => { + * return html`<li>${index}: ${item.name}</li>`; + * })} + * </ol> + * ` + * ``` + * + * **Important**: If providing a `keyFn`, keys *must* be unique for all items in a + * given call to `repeat`. The behavior when two or more items have the same key + * is undefined. + * + * If no `keyFn` is provided, this directive will perform similar to mapping + * items to values, and DOM will be reused against potentially different items. + */ +const repeat = directive(RepeatDirective); + +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class StyleMapDirective extends Directive { + constructor(partInfo) { + var _a; + super(partInfo); + if (partInfo.type !== PartType.ATTRIBUTE || + partInfo.name !== 'style' || + ((_a = partInfo.strings) === null || _a === void 0 ? void 0 : _a.length) > 2) { + throw new Error('The `styleMap` directive must be used in the `style` attribute ' + + 'and must be the only part in the attribute.'); + } + } + render(styleInfo) { + return Object.keys(styleInfo).reduce((style, prop) => { + return style + prop.slice(0, 0); + }, ''); + } + update(part, [styleInfo]) { + const { style } = part.element; + if (this._previousStyleProperties === undefined) { + this._previousStyleProperties = new Set(); + } + // Remove old properties that no longer exist in styleInfo + // We use forEach() instead of for-of so that re don't require down-level + // iteration. + this._previousStyleProperties.forEach((name) => { + // If the name isn't in styleInfo or it's null/undefined + if (styleInfo[name] == null) { + this._previousStyleProperties.delete(name); + if (name.includes('-')) { + style.removeProperty(name); + } + else { + // Note reset using empty string (vs null) as IE11 does not always + // reset via null (https://developer.mozilla.org/en-US/docs/Web/API/ElementCSSInlineStyle/style#setting_styles) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + style[name] = ''; + } + } + }); + // Add or update properties + for (const name in styleInfo) { + const value = styleInfo[name]; + if (value != null) { + this._previousStyleProperties.add(name); + if (name.includes('-')) { + style.setProperty(name, value); + } + else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + style[name] = value; + } + } + } + return noChange; + } +} +/** + * A directive that applies CSS properties to an element. + * + * `styleMap` can only be used in the `style` attribute and must be the only + * expression in the attribute. It takes the property names in the + * {@link StyleInfo styleInfo} object and adds the property values as CSS + * properties. Property names with dashes (`-`) are assumed to be valid CSS + * property names and set on the element's style object using `setProperty()`. + * Names without dashes are assumed to be camelCased JavaScript property names + * and set on the element's style object using property assignment, allowing the + * style object to translate JavaScript-style names to CSS property names. + * + * For example `styleMap({backgroundColor: 'red', 'border-top': '5px', '--size': + * '0'})` sets the `background-color`, `border-top` and `--size` properties. + * + * @param styleInfo + * @see {@link https://lit.dev/docs/templates/directives/#stylemap styleMap code samples on Lit.dev} + */ +const styleMap = directive(StyleMapDirective); + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +class TemplateContentDirective extends Directive { + constructor(partInfo) { + super(partInfo); + if (partInfo.type !== PartType.CHILD) { + throw new Error('templateContent can only be used in child bindings'); + } + } + render(template) { + if (this._previousTemplate === template) { + return noChange; + } + this._previousTemplate = template; + return document.importNode(template.content, true); + } +} +/** + * Renders the content of a template element as HTML. + * + * Note, the template should be developer controlled and not user controlled. + * Rendering a user-controlled template with this directive + * could lead to cross-site-scripting vulnerabilities. + */ +const templateContent = directive(TemplateContentDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const HTML_RESULT = 1; +class UnsafeHTMLDirective extends Directive { + constructor(partInfo) { + super(partInfo); + this._value = nothing; + if (partInfo.type !== PartType.CHILD) { + throw new Error(`${this.constructor.directiveName}() can only be used in child bindings`); + } + } + render(value) { + if (value === nothing || value == null) { + this._templateResult = undefined; + return (this._value = value); + } + if (value === noChange) { + return value; + } + if (typeof value != 'string') { + throw new Error(`${this.constructor.directiveName}() called with a non-string value`); + } + if (value === this._value) { + return this._templateResult; + } + this._value = value; + const strings = [value]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + strings.raw = strings; + // WARNING: impersonating a TemplateResult like this is extremely + // dangerous. Third-party directives should not do this. + return (this._templateResult = { + // Cast to a known set of integers that satisfy ResultType so that we + // don't have to export ResultType and possibly encourage this pattern. + // This property needs to remain unminified. + ['_$litType$']: this.constructor + .resultType, + strings, + values: [], + }); + } +} +UnsafeHTMLDirective.directiveName = 'unsafeHTML'; +UnsafeHTMLDirective.resultType = HTML_RESULT; +/** + * Renders the result as HTML, rather than text. + * + * The values `undefined`, `null`, and `nothing`, will all result in no content + * (empty string) being rendered. + * + * Note, this is unsafe to use with any user-provided input that hasn't been + * sanitized or escaped, as it may lead to cross-site-scripting + * vulnerabilities. + */ +const unsafeHTML = directive(UnsafeHTMLDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const SVG_RESULT = 2; +class UnsafeSVGDirective extends UnsafeHTMLDirective { +} +UnsafeSVGDirective.directiveName = 'unsafeSVG'; +UnsafeSVGDirective.resultType = SVG_RESULT; +/** + * Renders the result as SVG, rather than text. + * + * The values `undefined`, `null`, and `nothing`, will all result in no content + * (empty string) being rendered. + * + * Note, this is unsafe to use with any user-provided input that hasn't been + * sanitized or escaped, as it may lead to cross-site-scripting + * vulnerabilities. + */ +const unsafeSVG = directive(UnsafeSVGDirective); + +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const isPromise = (x) => { + return !isPrimitive(x) && typeof x.then === 'function'; +}; +// Effectively infinity, but a SMI. +const _infinity = 0x3fffffff; +class UntilDirective extends AsyncDirective { + constructor() { + super(...arguments); + this.__lastRenderedIndex = _infinity; + this.__values = []; + this.__weakThis = new PseudoWeakRef(this); + this.__pauser = new Pauser(); + } + render(...args) { + var _a; + return (_a = args.find((x) => !isPromise(x))) !== null && _a !== void 0 ? _a : noChange; + } + update(_part, args) { + const previousValues = this.__values; + let previousLength = previousValues.length; + this.__values = args; + const weakThis = this.__weakThis; + const pauser = this.__pauser; + // If our initial render occurs while disconnected, ensure that the pauser + // and weakThis are in the disconnected state + if (!this.isConnected) { + this.disconnected(); + } + for (let i = 0; i < args.length; i++) { + // If we've rendered a higher-priority value already, stop. + if (i > this.__lastRenderedIndex) { + break; + } + const value = args[i]; + // Render non-Promise values immediately + if (!isPromise(value)) { + this.__lastRenderedIndex = i; + // Since a lower-priority value will never overwrite a higher-priority + // synchronous value, we can stop processing now. + return value; + } + // If this is a Promise we've already handled, skip it. + if (i < previousLength && value === previousValues[i]) { + continue; + } + // We have a Promise that we haven't seen before, so priorities may have + // changed. Forget what we rendered before. + this.__lastRenderedIndex = _infinity; + previousLength = 0; + // Note, the callback avoids closing over `this` so that the directive + // can be gc'ed before the promise resolves; instead `this` is retrieved + // from `weakThis`, which can break the hard reference in the closure when + // the directive disconnects + Promise.resolve(value).then(async (result) => { + // If we're disconnected, wait until we're (maybe) reconnected + // The while loop here handles the case that the connection state + // thrashes, causing the pauser to resume and then get re-paused + while (pauser.get()) { + await pauser.get(); + } + // If the callback gets here and there is no `this`, it means that the + // directive has been disconnected and garbage collected and we don't + // need to do anything else + const _this = weakThis.deref(); + if (_this !== undefined) { + const index = _this.__values.indexOf(value); + // If state.values doesn't contain the value, we've re-rendered without + // the value, so don't render it. Then, only render if the value is + // higher-priority than what's already been rendered. + if (index > -1 && index < _this.__lastRenderedIndex) { + _this.__lastRenderedIndex = index; + _this.setValue(result); + } + } + }); + } + return noChange; + } + disconnected() { + this.__weakThis.disconnect(); + this.__pauser.pause(); + } + reconnected() { + this.__weakThis.reconnect(this); + this.__pauser.resume(); + } +} +/** + * Renders one of a series of values, including Promises, to a Part. + * + * Values are rendered in priority order, with the first argument having the + * highest priority and the last argument having the lowest priority. If a + * value is a Promise, low-priority values will be rendered until it resolves. + * + * The priority of values can be used to create placeholder content for async + * data. For example, a Promise with pending content can be the first, + * highest-priority, argument, and a non_promise loading indicator template can + * be used as the second, lower-priority, argument. The loading indicator will + * render immediately, and the primary content will render when the Promise + * resolves. + * + * Example: + * + * ```js + * const content = fetch('./content.txt').then(r => r.text()); + * html`${until(content, html`<span>Loading...</span>`)}` + * ``` + */ +const until = directive(UntilDirective); +/** + * The type of the class that powers this directive. Necessary for naming the + * directive's return type. + */ +// export type {UntilDirective}; + +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function when(condition, trueCase, falseCase) { + return condition ? trueCase() : falseCase === null || falseCase === void 0 ? void 0 : falseCase(); +} + +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * Prevents JSON injection attacks. + * + * The goals of this brand: + * 1) fast to check + * 2) code is small on the wire + * 3) multiple versions of Lit in a single page will all produce mutually + * interoperable StaticValues + * 4) normal JSON.parse (without an unusual reviver) can not produce a + * StaticValue + * + * Symbols satisfy (1), (2), and (4). We use Symbol.for to satisfy (3), but + * we don't care about the key, so we break ties via (2) and use the empty + * string. + */ +const brand = Symbol.for(''); +/** Safely extracts the string part of a StaticValue. */ +const unwrapStaticValue = (value) => { + if ((value === null || value === void 0 ? void 0 : value.r) !== brand) { + return undefined; + } + return value === null || value === void 0 ? void 0 : value['_$litStatic$']; +}; +/** + * Wraps a string so that it behaves like part of the static template + * strings instead of a dynamic value. + * + * Users must take care to ensure that adding the static string to the template + * results in well-formed HTML, or else templates may break unexpectedly. + * + * Note that this function is unsafe to use on untrusted content, as it will be + * directly parsed into HTML. Do not pass user input to this function + * without sanitizing it. + * + * Static values can be changed, but they will cause a complete re-render + * since they effectively create a new template. + */ +const unsafeStatic = (value) => ({ + ['_$litStatic$']: value, + r: brand, +}); +const textFromStatic = (value) => { + if (value['_$litStatic$'] !== undefined) { + return value['_$litStatic$']; + } + else { + throw new Error(`Value passed to 'literal' function must be a 'literal' result: ${value}. Use 'unsafeStatic' to pass non-literal values, but + take care to ensure page security.`); + } +}; +/** + * Tags a string literal so that it behaves like part of the static template + * strings instead of a dynamic value. + * + * The only values that may be used in template expressions are other tagged + * `literal` results or `unsafeStatic` values (note that untrusted content + * should never be passed to `unsafeStatic`). + * + * Users must take care to ensure that adding the static string to the template + * results in well-formed HTML, or else templates may break unexpectedly. + * + * Static values can be changed, but they will cause a complete re-render since + * they effectively create a new template. + */ +const literal = (strings, ...values) => ({ + ['_$litStatic$']: values.reduce((acc, v, idx) => acc + textFromStatic(v) + strings[idx + 1], strings[0]), + r: brand, +}); +const stringsCache = new Map(); +/** + * Wraps a lit-html template tag (`html` or `svg`) to add static value support. + */ +const withStatic = (coreTag) => (strings, ...values) => { + const l = values.length; + let staticValue; + let dynamicValue; + const staticStrings = []; + const dynamicValues = []; + let i = 0; + let hasStatics = false; + let s; + while (i < l) { + s = strings[i]; + // Collect any unsafeStatic values, and their following template strings + // so that we treat a run of template strings and unsafe static values as + // a single template string. + while (i < l && + ((dynamicValue = values[i]), + (staticValue = unwrapStaticValue(dynamicValue))) !== undefined) { + s += staticValue + strings[++i]; + hasStatics = true; + } + dynamicValues.push(dynamicValue); + staticStrings.push(s); + i++; + } + // If the last value isn't static (which would have consumed the last + // string), then we need to add the last string. + if (i === l) { + staticStrings.push(strings[l]); + } + if (hasStatics) { + const key = staticStrings.join('$$lit$$'); + strings = stringsCache.get(key); + if (strings === undefined) { + // Beware: in general this pattern is unsafe, and doing so may bypass + // lit's security checks and allow an attacker to execute arbitrary + // code and inject arbitrary content. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + staticStrings.raw = staticStrings; + stringsCache.set(key, (strings = staticStrings)); + } + values = dynamicValues; + } + return coreTag(strings, ...values); +}; +/** + * Interprets a template literal as an HTML template that can efficiently + * render to and update a container. + * + * Includes static value support from `lit-html/static.js`. + */ +const html = withStatic(html$1); +/** + * Interprets a template literal as an SVG template that can efficiently + * render to and update a container. + * + * Includes static value support from `lit-html/static.js`. + */ +const svg = withStatic(svg$1); + +export { AsyncDirective, AsyncReplaceDirective, CSSResult, Directive, LitElement, PartType, ReactiveElement, TemplateResultType, UnsafeHTMLDirective, UntilDirective, UpdatingElement, _$LE, _$LH, adoptStyles, asyncAppend, asyncReplace, cache, choose, classMap, clearPart, createRef, css, defaultConverter, directive, getCommittedValue, getCompatibleStyle, getDirectiveClass, guard, html$1 as html, ifDefined, insertPart, isDirectiveResult, isPrimitive, isServer, isSingleExpression, isTemplateResult, join, keyed, literal, live, map, noChange, notEqual, nothing, range, ref, removePart, render, repeat, setChildPartValue, setCommittedValue, html as staticHtml, svg as staticSvg, styleMap, supportsAdoptingStyleSheets, svg$1 as svg, templateContent, unsafeCSS, unsafeHTML, unsafeSVG, unsafeStatic, until, when, withStatic }; diff --git a/toolkit/content/widgets/videocontrols.js b/toolkit/content/widgets/videocontrols.js new file mode 100644 index 0000000000..21c8946e60 --- /dev/null +++ b/toolkit/content/widgets/videocontrols.js @@ -0,0 +1,3365 @@ +/* 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 "controls" property. + */ +this.VideoControlsWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + + this.isMobile = this.window.navigator.appVersion.includes("Android"); + } + + /* + * Callback called by UAWidgets right after constructor. + */ + onsetup() { + this.switchImpl(); + } + + /* + * Callback called by UAWidgets when the "controls" property changes. + */ + onchange() { + this.switchImpl(); + } + + /* + * Actually switch the implementation. + * - With "controls" set, the VideoControlsImplWidget controls should load. + * - Without it, on mobile, the NoControlsMobileImplWidget should load, so + * the user could see the click-to-play button when the video/audio is blocked. + * - Without it, on desktop, the NoControlsPictureInPictureImpleWidget should load + * if the video is being viewed in Picture-in-Picture. + */ + switchImpl() { + let newImpl; + let pageURI = this.document.documentURI; + if (this.element.controls) { + newImpl = VideoControlsImplWidget; + } else if (this.isMobile) { + newImpl = NoControlsMobileImplWidget; + } else if (VideoControlsWidget.isPictureInPictureVideo(this.element)) { + newImpl = NoControlsPictureInPictureImplWidget; + } else if ( + pageURI.startsWith("http://") || + pageURI.startsWith("https://") + ) { + newImpl = NoControlsDesktopImplWidget; + } + + // Skip if we are asked to load the same implementation, and + // the underlying element state hasn't changed in ways that we + // care about. This can happen if the property is set again + // without a value change. + if ( + this.impl && + this.impl.constructor == newImpl && + this.impl.elementStateMatches(this.element) + ) { + return; + } + if (this.impl) { + this.impl.teardown(); + this.shadowRoot.firstChild.remove(); + } + if (newImpl) { + this.impl = new newImpl(this.shadowRoot, this.prefs); + + this.mDirection = "ltr"; + let intlUtils = this.window.intlUtils; + if (intlUtils) { + this.mDirection = intlUtils.isAppLocaleRTL() ? "rtl" : "ltr"; + } + + this.impl.onsetup(this.mDirection); + } else { + this.impl = undefined; + } + } + + teardown() { + if (!this.impl) { + return; + } + this.impl.teardown(); + this.shadowRoot.firstChild.remove(); + delete this.impl; + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + + if (!this.impl) { + return; + } + + this.impl.onPrefChange(prefName, prefValue); + } + + // If you change this, also change SEEK_TIME_SECS in PictureInPictureChild.sys.mjs + static SEEK_TIME_SECS = 5; + + static isPictureInPictureVideo(someVideo) { + return someVideo.isCloningElementVisually; + } + + /** + * Returns true if a <video> meets the requirements to show the Picture-in-Picture + * toggle. Those requirements currently are: + * + * 1. The video must be 45 seconds in length or longer. + * 2. Neither the width or the height of the video can be less than 140px. + * 3. The video must have audio. + * 4. The video must not a MediaStream video (Bug 1592539) + * + * This can be overridden via the + * media.videocontrols.picture-in-picture.video-toggle.always-show pref, which + * is mostly used for testing. + * + * @param {Object} prefs + * The preferences set that was passed to the UAWidget. + * @param {Element} someVideo + * The <video> to test. + * @param {Object} reflowedDimensions + * An object representing the reflowed dimensions of the <video>. Properties + * are: + * + * videoWidth (Number): + * The width of the video in pixels. + * + * videoHeight (Number): + * The height of the video in pixels. + * + * @return {Boolean} + */ + static shouldShowPictureInPictureToggle( + prefs, + someVideo, + reflowedDimensions + ) { + if ( + prefs[ + "media.videocontrols.picture-in-picture.respect-disablePictureInPicture" + ] && + someVideo.disablePictureInPicture + ) { + return false; + } + + if (isNaN(someVideo.duration)) { + return false; + } + + if ( + prefs["media.videocontrols.picture-in-picture.video-toggle.always-show"] + ) { + return true; + } + + const MIN_VIDEO_LENGTH = + prefs[ + "media.videocontrols.picture-in-picture.video-toggle.min-video-secs" + ]; + + if (someVideo.duration < MIN_VIDEO_LENGTH) { + return false; + } + + const MIN_VIDEO_DIMENSION = 140; // pixels + if ( + reflowedDimensions.videoWidth < MIN_VIDEO_DIMENSION || + reflowedDimensions.videoHeight < MIN_VIDEO_DIMENSION + ) { + return false; + } + + return true; + } + + /** + * Some variations on the Picture-in-Picture toggle are being experimented with. + * These variations have slightly different setup parameters from the currently + * shipping toggle, so this method sets up the experimental toggles in the event + * that they're being used. It also will enable the appropriate stylesheet for + * the preferred toggle experiment. + * + * @param {Object} prefs + * The preferences set that was passed to the UAWidget. + * @param {ShadowRoot} shadowRoot + * The shadowRoot of the <video> element where the video controls are. + * @param {Element} toggle + * The toggle element. + * @param {Object} reflowedDimensions + * An object representing the reflowed dimensions of the <video>. Properties + * are: + * + * videoWidth (Number): + * The width of the video in pixels. + * + * videoHeight (Number): + * The height of the video in pixels. + */ + static setupToggle(prefs, toggle, reflowedDimensions) { + // These thresholds are all in pixels + const SMALL_VIDEO_WIDTH_MAX = 320; + const MEDIUM_VIDEO_WIDTH_MAX = 720; + + let isSmall = reflowedDimensions.videoWidth <= SMALL_VIDEO_WIDTH_MAX; + toggle.toggleAttribute("small-video", isSmall); + toggle.toggleAttribute( + "medium-video", + !isSmall && reflowedDimensions.videoWidth <= MEDIUM_VIDEO_WIDTH_MAX + ); + + toggle.setAttribute( + "position", + prefs["media.videocontrols.picture-in-picture.video-toggle.position"] + ); + toggle.toggleAttribute( + "has-used", + prefs["media.videocontrols.picture-in-picture.video-toggle.has-used"] + ); + } +}; + +this.VideoControlsImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + debug: false, + video: null, + videocontrols: null, + controlBar: null, + playButton: null, + muteButton: null, + volumeControl: null, + durationLabel: null, + positionLabel: null, + scrubber: null, + progressBar: null, + bufferBar: null, + statusOverlay: null, + controlsSpacer: null, + clickToPlay: null, + controlsOverlay: null, + fullscreenButton: null, + layoutControls: null, + isShowingPictureInPictureMessage: false, + l10n: this.l10n, + + textTracksCount: 0, + videoEvents: [ + "play", + "pause", + "ended", + "volumechange", + "loadeddata", + "loadstart", + "timeupdate", + "progress", + "playing", + "waiting", + "canplay", + "canplaythrough", + "seeking", + "seeked", + "emptied", + "loadedmetadata", + "error", + "suspend", + "stalled", + "mozvideoonlyseekbegin", + "mozvideoonlyseekcompleted", + "durationchange", + ], + + firstFrameShown: false, + timeUpdateCount: 0, + maxCurrentTimeSeen: 0, + isPausedByDragging: false, + _isAudioOnly: false, + + get isAudioTag() { + return this.video.localName == "audio"; + }, + + get isAudioOnly() { + return this._isAudioOnly; + }, + set isAudioOnly(val) { + this._isAudioOnly = val; + this.setFullscreenButtonState(); + this.updatePictureInPictureToggleDisplay(); + + if (!this.isTopLevelSyntheticDocument) { + return; + } + if (this._isAudioOnly) { + this.video.style.height = this.controlBarMinHeight + "px"; + this.video.style.width = "66%"; + } else { + this.video.style.removeProperty("height"); + this.video.style.removeProperty("width"); + } + }, + + suppressError: false, + + setupStatusFader(immediate) { + // Since the play button will be showing, we don't want to + // show the throbber behind it. The throbber here will + // only show if needed after the play button has been pressed. + if (!this.clickToPlay.hidden) { + this.startFadeOut(this.statusOverlay, true); + return; + } + + var show = false; + if ( + this.video.seeking || + (this.video.error && !this.suppressError) || + this.video.networkState == this.video.NETWORK_NO_SOURCE || + (this.video.networkState == this.video.NETWORK_LOADING && + (this.video.paused || this.video.ended + ? this.video.readyState < this.video.HAVE_CURRENT_DATA + : this.video.readyState < this.video.HAVE_FUTURE_DATA)) || + (this.timeUpdateCount <= 1 && + !this.video.ended && + this.video.readyState < this.video.HAVE_FUTURE_DATA && + this.video.networkState == this.video.NETWORK_LOADING) + ) { + show = true; + } + + // Explicitly hide the status fader if this + // is audio only until bug 619421 is fixed. + if (this.isAudioOnly) { + show = false; + } + + if (this._showThrobberTimer) { + show = true; + } + + this.log( + "Status overlay: seeking=" + + this.video.seeking + + " error=" + + this.video.error + + " readyState=" + + this.video.readyState + + " paused=" + + this.video.paused + + " ended=" + + this.video.ended + + " networkState=" + + this.video.networkState + + " timeUpdateCount=" + + this.timeUpdateCount + + " _showThrobberTimer=" + + this._showThrobberTimer + + " --> " + + (show ? "SHOW" : "HIDE") + ); + this.startFade(this.statusOverlay, show, immediate); + }, + + /* + * Set the initial state of the controls. The UA widget is normally created along + * with video element, but could be attached at any point (eg, if the video is + * removed from the document and then reinserted). Thus, some one-time events may + * have already fired, and so we'll need to explicitly check the initial state. + */ + setupInitialState() { + this.setPlayButtonState(this.video.paused); + + this.setFullscreenButtonState(); + + var duration = Math.round(this.video.duration * 1000); // in ms + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + this.log( + "Initial playback position is at " + currentTime + " of " + duration + ); + // It would be nice to retain maxCurrentTimeSeen, but it would be difficult + // to determine if the media source changed while we were detached. + this.maxCurrentTimeSeen = currentTime; + this.showPosition(currentTime, duration); + + // If we have metadata, check if this is a <video> without + // video data, or a video with no audio track. + if (this.video.readyState >= this.video.HAVE_METADATA) { + if ( + this.video.localName == "video" && + (this.video.videoWidth == 0 || this.video.videoHeight == 0) + ) { + this.isAudioOnly = true; + } + + // We have to check again if the media has audio here. + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.disabled = true; + } + } + + // The video itself might not be fullscreen, but part of the + // document might be, in which case we set this attribute to + // apply any styles for the DOM fullscreen case. + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } + + if (this.isAudioOnly) { + this.startFadeOut(this.clickToPlay, true); + } + + // If the first frame hasn't loaded, kick off a throbber fade-in. + if (this.video.readyState >= this.video.HAVE_CURRENT_DATA) { + this.firstFrameShown = true; + } + + // We can't determine the exact buffering status, but do know if it's + // fully loaded. (If it's still loading, it will fire a progress event + // and we'll figure out the exact state then.) + this.bufferBar.max = 100; + if (this.video.readyState >= this.video.HAVE_METADATA) { + this.showBuffered(); + } else { + this.bufferBar.value = 0; + } + + // Set the current status icon. + if (this.hasError()) { + this.startFadeOut(this.clickToPlay, true); + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + } else if (VideoControlsWidget.isPictureInPictureVideo(this.video)) { + this.setShowPictureInPictureMessage(true); + } + + if (this.video.readyState >= this.video.HAVE_METADATA) { + // According to the spec[1], at the HAVE_METADATA (or later) state, we know + // the video duration and dimensions, which means we can calculate whether or + // not to show the Picture-in-Picture toggle now. + // + // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata + this.updatePictureInPictureToggleDisplay(); + } + + let adjustableControls = [ + ...this.prioritizedControls, + this.controlBar, + this.clickToPlay, + ]; + + for (let control of adjustableControls) { + if (!control) { + break; + } + + this.defineControlProperties(control); + } + this.adjustControlSize(); + + // Can only update the volume controls once we've computed + // _volumeControlWidth, since the volume slider implementation + // depends on it. + this.updateVolumeControls(); + }, + + defineControlProperties(control) { + let throwOnGet = { + get() { + throw new Error("Please don't trigger reflow. See bug 1493525."); + }, + }; + Object.defineProperties(control, { + // We should directly access CSSOM to get pre-defined style instead of + // retrieving computed dimensions from layout. + minWidth: { + get: () => { + let controlId = control.id; + let propertyName = `--${controlId}-width`; + if (control.modifier) { + propertyName += "-" + control.modifier; + } + let preDefinedSize = + this.controlBarComputedStyles.getPropertyValue(propertyName); + + // The stylesheet from <link> might not be loaded if the + // element was inserted into a hidden iframe. + // We can safely return 0 here for now, given that the controls + // will be resized again, by the resizevideocontrols event, + // from nsVideoFrame, when the element is visible. + if (!preDefinedSize) { + return 0; + } + + return parseInt(preDefinedSize, 10); + }, + }, + offsetLeft: throwOnGet, + offsetTop: throwOnGet, + offsetWidth: throwOnGet, + offsetHeight: throwOnGet, + offsetParent: throwOnGet, + clientLeft: throwOnGet, + clientTop: throwOnGet, + clientWidth: throwOnGet, + clientHeight: throwOnGet, + getClientRects: throwOnGet, + getBoundingClientRect: throwOnGet, + isAdjustableControl: { + value: true, + }, + modifier: { + value: "", + writable: true, + }, + isWanted: { + value: true, + writable: true, + }, + hidden: { + set: v => { + control._isHiddenExplicitly = v; + control._updateHiddenAttribute(); + }, + get: () => { + return ( + control.hasAttribute("hidden") || + control.classList.contains("fadeout") + ); + }, + }, + hiddenByAdjustment: { + set: v => { + control._isHiddenByAdjustment = v; + control._updateHiddenAttribute(); + }, + get: () => control._isHiddenByAdjustment, + }, + _isHiddenByAdjustment: { + value: false, + writable: true, + }, + _isHiddenExplicitly: { + value: false, + writable: true, + }, + _updateHiddenAttribute: { + value: () => { + control.toggleAttribute( + "hidden", + control._isHiddenExplicitly || control._isHiddenByAdjustment + ); + }, + }, + }); + }, + + updatePictureInPictureToggleDisplay() { + if (this.isAudioOnly) { + this.pictureInPictureToggle.hidden = true; + return; + } + + // We only want to show the toggle when the closed captions menu + // is closed, in order to avoid visual overlap. + if ( + this.pipToggleEnabled && + !this.isShowingPictureInPictureMessage && + this.textTrackListContainer.hidden && + VideoControlsWidget.shouldShowPictureInPictureToggle( + this.prefs, + this.video, + this.reflowedDimensions + ) + ) { + this.pictureInPictureToggle.hidden = false; + VideoControlsWidget.setupToggle( + this.prefs, + this.pictureInPictureToggle, + this.reflowedDimensions + ); + } else { + this.pictureInPictureToggle.hidden = true; + } + }, + + setupNewLoadState() { + // For videos with |autoplay| set, we'll leave the controls initially hidden, + // so that they don't get in the way of the playing video. Otherwise we'll + // go ahead and reveal the controls now, so they're an obvious user cue. + var shouldShow = + !this.dynamicControls || (this.video.paused && !this.video.autoplay); + // Hide the overlay if the video time is non-zero or if an error occurred to workaround bug 718107. + let shouldClickToPlayShow = + shouldShow && + !this.isAudioOnly && + this.video.currentTime == 0 && + !this.hasError() && + !this.isShowingPictureInPictureMessage; + this.startFade(this.clickToPlay, shouldClickToPlayShow, true); + this.startFade(this.controlBar, shouldShow, true); + }, + + get dynamicControls() { + // Don't fade controls for <audio> elements. + var enabled = !this.isAudioOnly; + + // Allow tests to explicitly suppress the fading of controls. + if (this.video.hasAttribute("mozNoDynamicControls")) { + enabled = false; + } + + // If the video hits an error, suppress controls if it + // hasn't managed to do anything else yet. + if (!this.firstFrameShown && this.hasError()) { + enabled = false; + } + + return enabled; + }, + + updateVolume() { + const volume = this.volumeControl.value; + this.setVolume(volume / 100); + }, + + updateVolumeControls() { + var volume = this.video.muted ? 0 : this.video.volume; + var volumePercentage = Math.round(volume * 100); + this.updateMuteButtonState(); + this.volumeControl.value = volumePercentage; + }, + + /* + * We suspend a video element's video decoder if the video + * element is invisible. However, resuming the video decoder + * takes time and we show the throbber UI if it takes more than + * 250 ms. + * + * When an already-suspended video element becomes visible, we + * resume its video decoder immediately and queue a video-only seek + * task to seek the resumed video decoder to the current position; + * meanwhile, we also file a "mozvideoonlyseekbegin" event which + * we used to start the timer here. + * + * Once the queued seek operation is done, we dispatch a + * "canplay" event which indicates that the resuming operation + * is completed. + */ + SHOW_THROBBER_TIMEOUT_MS: 250, + _showThrobberTimer: null, + _delayShowThrobberWhileResumingVideoDecoder() { + this._showThrobberTimer = this.window.setTimeout(() => { + this.statusIcon.setAttribute("type", "throbber"); + // Show the throbber immediately since we have waited for SHOW_THROBBER_TIMEOUT_MS. + // We don't want to wait for another animation delay(750ms) and the + // animation duration(300ms). + this.setupStatusFader(true); + }, this.SHOW_THROBBER_TIMEOUT_MS); + }, + _cancelShowThrobberWhileResumingVideoDecoder() { + if (this._showThrobberTimer) { + this.window.clearTimeout(this._showThrobberTimer); + this._showThrobberTimer = null; + } + }, + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + this.log("Drop untrusted event ----> " + aEvent.type); + return; + } + + this.log("Got event ----> " + aEvent.type); + + if (this.videoEvents.includes(aEvent.type)) { + this.handleVideoEvent(aEvent); + } else { + this.handleControlEvent(aEvent); + } + }, + + handleVideoEvent(aEvent) { + switch (aEvent.type) { + case "play": + this.setPlayButtonState(false); + this.setupStatusFader(); + if ( + !this._triggeredByControls && + this.dynamicControls && + this.isTouchControls + ) { + this.startFadeOut(this.controlBar); + } + if (!this._triggeredByControls) { + this.startFadeOut(this.clickToPlay, true); + } + this._triggeredByControls = false; + break; + case "pause": + // Little white lie: if we've internally paused the video + // while dragging the scrubber, don't change the button state. + if (!this.scrubber.isDragging) { + this.setPlayButtonState(true); + } + this.setupStatusFader(); + break; + case "ended": + this.setPlayButtonState(true); + // We throttle timechange events, so the thumb might not be + // exactly at the end when the video finishes. + this.showPosition( + Math.round(this.video.currentTime * 1000), + Math.round(this.video.duration * 1000) + ); + this.startFadeIn(this.controlBar); + this.setupStatusFader(); + break; + case "volumechange": + this.updateVolumeControls(); + // Show the controls to highlight the changing volume, + // but only if the click-to-play overlay has already + // been hidden (we don't hide controls when the overlay is visible). + if (this.clickToPlay.hidden && !this.isAudioOnly) { + this.startFadeIn(this.controlBar); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + break; + case "loadedmetadata": + // If a <video> doesn't have any video data, treat it as <audio> + // and show the controls (they won't fade back out) + if ( + this.video.localName == "video" && + (this.video.videoWidth == 0 || this.video.videoHeight == 0) + ) { + this.isAudioOnly = true; + this.startFadeOut(this.clickToPlay, true); + this.startFadeIn(this.controlBar); + this.setFullscreenButtonState(); + } + this.showPosition( + Math.round(this.video.currentTime * 1000), + Math.round(this.video.duration * 1000) + ); + if (!this.isAudioOnly && !this.video.mozHasAudio) { + this.muteButton.setAttribute("noAudio", "true"); + this.muteButton.disabled = true; + } + this.adjustControlSize(); + this.updatePictureInPictureToggleDisplay(); + break; + case "durationchange": + this.updatePictureInPictureToggleDisplay(); + break; + case "loadeddata": + this.firstFrameShown = true; + this.setupStatusFader(); + break; + case "loadstart": + this.maxCurrentTimeSeen = 0; + this.controlsSpacer.removeAttribute("aria-label"); + this.statusOverlay.removeAttribute("status"); + this.statusIcon.setAttribute("type", "throbber"); + this.isAudioOnly = this.isAudioTag; + this.setPlayButtonState(true); + this.setupNewLoadState(); + this.setupStatusFader(); + break; + case "progress": + this.statusIcon.removeAttribute("stalled"); + this.showBuffered(); + this.setupStatusFader(); + break; + case "stalled": + this.statusIcon.setAttribute("stalled", "true"); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "suspend": + this.setupStatusFader(); + break; + case "timeupdate": + var currentTime = Math.round(this.video.currentTime * 1000); // in ms + var duration = Math.round(this.video.duration * 1000); // in ms + + // If playing/seeking after the video ended, we won't get a "play" + // event, so update the button state here. + if (!this.video.paused) { + this.setPlayButtonState(false); + } + + this.timeUpdateCount++; + // Whether we show the statusOverlay sometimes depends + // on whether we've seen more than one timeupdate + // event (if we haven't, there hasn't been any + // "playback activity" and we may wish to show the + // statusOverlay while we wait for HAVE_ENOUGH_DATA). + // If we've seen more than 2 timeupdate events, + // the count is no longer relevant to setupStatusFader. + if (this.timeUpdateCount <= 2) { + this.setupStatusFader(); + } + + // If the user is dragging the scrubber ignore the delayed seek + // responses (don't yank the thumb away from the user) + if (this.scrubber.isDragging) { + return; + } + this.showPosition(currentTime, duration); + this.showBuffered(); + break; + case "emptied": + this.bufferBar.value = 0; + this.showPosition(0, 0); + break; + case "seeking": + this.showBuffered(); + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "waiting": + this.statusIcon.setAttribute("type", "throbber"); + this.setupStatusFader(); + break; + case "seeked": + case "playing": + case "canplay": + case "canplaythrough": + this.setupStatusFader(); + break; + case "error": + // We'll show the error status icon when we receive an error event + // under either of the following conditions: + // 1. The video has its error attribute set; this means we're loading + // from our src attribute, and the load failed, or we we're loading + // from source children and the decode or playback failed after we + // determined our selected resource was playable. + // 2. The video's networkState is NETWORK_NO_SOURCE. This means we we're + // loading from child source elements, but we were unable to select + // any of the child elements for playback during resource selection. + if (this.hasError()) { + this.suppressError = false; + this.startFadeOut(this.clickToPlay, true); + this.statusIcon.setAttribute("type", "error"); + this.updateErrorText(); + this.setupStatusFader(true); + // If video hasn't shown anything yet, disable the controls. + if (!this.firstFrameShown && !this.isAudioOnly) { + this.startFadeOut(this.controlBar); + } + this.controlsSpacer.removeAttribute("hideCursor"); + } + break; + case "mozvideoonlyseekbegin": + this._delayShowThrobberWhileResumingVideoDecoder(); + break; + case "mozvideoonlyseekcompleted": + this._cancelShowThrobberWhileResumingVideoDecoder(); + this.setupStatusFader(); + break; + default: + this.log("!!! media event " + aEvent.type + " not handled!"); + } + }, + + handleControlEvent(aEvent) { + switch (aEvent.type) { + case "click": + switch (aEvent.currentTarget) { + case this.muteButton: + this.toggleMute(); + break; + case this.castingButton: + this.toggleCasting(); + break; + case this.closedCaptionButton: + this.toggleClosedCaption(); + break; + case this.fullscreenButton: + this.toggleFullscreen(); + break; + case this.playButton: + case this.clickToPlay: + case this.controlsSpacer: + this.clickToPlayClickHandler(aEvent); + break; + case this.textTrackList: + const index = +aEvent.originalTarget.getAttribute("index"); + this.changeTextTrack(index); + this.closedCaptionButton.focus(); + break; + case this.videocontrols: + // Prevent any click event within media controls from dispatching through to video. + aEvent.stopPropagation(); + break; + } + break; + case "dblclick": + this.toggleFullscreen(); + break; + case "resizevideocontrols": + // Since this event come from the layout, this is the only place + // we are sure of that probing into layout won't trigger or force + // reflow. + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = true; + this.updateReflowedDimensions(); + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = false; + + let scrubberWasHidden = this.scrubberStack.hidden; + this.adjustControlSize(); + if (scrubberWasHidden && !this.scrubberStack.hidden) { + // requestAnimationFrame + setTimeout of 0ms is a best effort way to avoid + // triggering reflows, but cannot fully guarantee a reflow will not happen. + this.window.requestAnimationFrame(() => + this.window.setTimeout(() => { + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = true; + this.updateReflowedDimensions(); + this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = false; + }, 0) + ); + } + this.updatePictureInPictureToggleDisplay(); + break; + case "fullscreenchange": + this.onFullscreenChange(); + break; + case "keypress": + this.keyHandler(aEvent); + break; + case "dragstart": + aEvent.preventDefault(); // prevent dragging of controls image (bug 517114) + break; + case "input": + switch (aEvent.currentTarget) { + case this.scrubber: + this.onScrubberInput(aEvent); + break; + case this.volumeControl: + this.updateVolume(); + break; + } + break; + case "change": + switch (aEvent.currentTarget) { + case this.scrubber: + this.onScrubberChange(aEvent); + break; + case this.video.textTracks: + this.setClosedCaptionButtonState(); + break; + } + break; + case "mouseup": + // add mouseup listener additionally to handle the case that `change` event + // isn't fired when the input value before/after dragging are the same. (bug 1328061) + this.onScrubberChange(aEvent); + break; + case "addtrack": + this.onTextTrackAdd(aEvent); + break; + case "removetrack": + this.onTextTrackRemove(aEvent); + break; + case "media-videoCasting": + this.updateCasting(aEvent.detail); + break; + case "focusin": + // Show the controls to highlight the focused control, but only + // under certain conditions: + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + // The click-to-play overlay must already be hidden (we don't + // hide controls when the overlay is visible). + this.clickToPlay.hidden && + // Don't do this if the controls are static. + this.dynamicControls && + // If the mouse is hovering over the control bar, the controls + // are already showing and they shouldn't hide, so don't mess + // with them. + // We use "div:hover" instead of just ":hover" so this works in + // quirks mode documents. See + // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk + !this.controlBar.matches("div:hover") + ) { + this.startFadeIn(this.controlBar); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + break; + case "mousedown": + // We only listen for mousedown on sliders. + // If this slider isn't focused already, mousedown will focus it. + // We don't want that because it will then handle additional keys. + // For example, we don't want the up/down arrow keys to seek after + // the scrubber is clicked. To prevent that, we need to redirect + // focus. However, dragging only works while the slider is focused, + // so we must redirect focus after mouseup. + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + !aEvent.currentTarget.matches(":focus") + ) { + aEvent.currentTarget.addEventListener( + "mouseup", + aEvent => { + if (aEvent.currentTarget.matches(":focus")) { + // We can't use target.blur() because that will blur the + // video element as well. + this.video.focus(); + } + }, + { once: true } + ); + } + break; + default: + this.log("!!! control event " + aEvent.type + " not handled!"); + } + }, + + terminate() { + if (this.videoEvents) { + for (let event of this.videoEvents) { + try { + this.video.removeEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } catch (ex) {} + } + } + + try { + for (let { el, type, capture = false } of this.controlsEvents) { + el.removeEventListener(type, this, { + mozSystemGroup: true, + capture, + }); + } + } catch (ex) {} + + this.window.clearTimeout(this._showControlsTimeout); + this.window.clearTimeout(this._hideControlsTimeout); + this._cancelShowThrobberWhileResumingVideoDecoder(); + + this.log("--- videocontrols terminated ---"); + }, + + hasError() { + // We either have an explicit error, or the resource selection + // algorithm is running and we've tried to load something and failed. + // Note: we don't consider the case where we've tried to load but + // there's no sources to load as an error condition, as sites may + // do this intentionally to work around requires-user-interaction to + // play restrictions, and we don't want to display a debug message + // if that's the case. + return ( + this.video.error != null || + (this.video.networkState == this.video.NETWORK_NO_SOURCE && + this.hasSources()) + ); + }, + + setShowPictureInPictureMessage(showMessage) { + this.pictureInPictureOverlay.hidden = !showMessage; + this.isShowingPictureInPictureMessage = showMessage; + }, + + hasSources() { + if ( + this.video.hasAttribute("src") && + this.video.getAttribute("src") !== "" + ) { + return true; + } + for ( + var child = this.video.firstChild; + child !== null; + child = child.nextElementSibling + ) { + if (child instanceof this.window.HTMLSourceElement) { + return true; + } + } + return false; + }, + + updateErrorText() { + let error; + let v = this.video; + // It is possible to have both v.networkState == NETWORK_NO_SOURCE + // as well as v.error being non-null. In this case, we will show + // the v.error.code instead of the v.networkState error. + if (v.error) { + switch (v.error.code) { + case v.error.MEDIA_ERR_ABORTED: + error = "errorAborted"; + break; + case v.error.MEDIA_ERR_NETWORK: + error = "errorNetwork"; + break; + case v.error.MEDIA_ERR_DECODE: + error = "errorDecode"; + break; + case v.error.MEDIA_ERR_SRC_NOT_SUPPORTED: + error = + v.networkState == v.NETWORK_NO_SOURCE + ? "errorNoSource" + : "errorSrcNotSupported"; + break; + default: + error = "errorGeneric"; + break; + } + } else if (v.networkState == v.NETWORK_NO_SOURCE) { + error = "errorNoSource"; + } else { + return; // No error found. + } + + let label = this.shadowRoot.getElementById(error); + this.controlsSpacer.setAttribute("aria-label", label.textContent); + this.statusOverlay.setAttribute("status", error); + }, + + formatTime(aTime) { + aTime = Math.round(aTime / 1000); + let hours = Math.floor(aTime / 3600); + let mins = Math.floor((aTime % 3600) / 60); + let secs = Math.floor(aTime % 60); + let timeString; + if (secs < 10) { + secs = "0" + secs; + } + if (hours) { + if (mins < 10) { + mins = "0" + mins; + } + timeString = hours + ":" + mins + ":" + secs; + } else { + timeString = mins + ":" + secs; + } + return timeString; + }, + + pauseVideoDuringDragging() { + if ( + !this.video.paused && + !this.isPausedByDragging && + this.scrubber.isDragging + ) { + this.isPausedByDragging = true; + this.video.pause(); + } + }, + + onScrubberInput(e) { + const duration = Math.round(this.video.duration * 1000); // in ms + let time = this.scrubber.value; + + this.seekToPosition(time); + this.showPosition(time, duration); + this.updateScrubberProgress(); + + this.scrubber.isDragging = true; + this.pauseVideoDuringDragging(); + }, + + onScrubberChange(e) { + this.scrubber.isDragging = false; + + if (this.isPausedByDragging) { + this.video.play(); + this.isPausedByDragging = false; + } + }, + + updateScrubberProgress() { + const positionPercent = (this.scrubber.value / this.scrubber.max) * 100; + + if (!isNaN(positionPercent) && positionPercent != Infinity) { + this.progressBar.value = positionPercent; + } else { + this.progressBar.value = 0; + } + }, + + seekToPosition(newPosition) { + newPosition /= 1000; // convert from ms + this.log("+++ seeking to " + newPosition); + this.video.currentTime = newPosition; + }, + + setVolume(newVolume) { + this.log("*** setting volume to " + newVolume); + this.video.volume = newVolume; + this.video.muted = false; + }, + + showPosition(currentTimeMs, durationMs) { + // If the duration is unknown (because the server didn't provide + // it, or the video is a stream), then we want to fudge the duration + // by using the maximum playback position that's been seen. + if (currentTimeMs > this.maxCurrentTimeSeen) { + this.maxCurrentTimeSeen = currentTimeMs; + } + this.log( + "time update @ " + currentTimeMs + "ms of " + durationMs + "ms" + ); + + let durationIsInfinite = durationMs == Infinity; + if (isNaN(durationMs) || durationIsInfinite) { + durationMs = this.maxCurrentTimeSeen; + } + this.log("durationMs is " + durationMs + "ms.\n"); + + let scrubberProgress = Math.abs( + currentTimeMs / durationMs - this.scrubber.value / this.scrubber.max + ); + let devPxProgress = + scrubberProgress * + this.reflowedDimensions.scrubberWidth * + this.window.devicePixelRatio; + // Hack: if we haven't updated the scrubber width to be non-0, but + // the scrubber stack is visible, assume there is progress. + // This should be rectified by the next time we do layout (see handling + // of resizevideocontrols events in handleEvent). + if ( + !this.reflowedDimensions.scrubberWidth && + !this.scrubberStack.hidden + ) { + devPxProgress = 1; + } + // Update the scrubber only if it will move by at least 1 pixel + // Note that this.scrubber.max can be "" if unitialized, + // and either or both of currentTimeMs or durationMs can be 0, leading + // to NaN or Infinity values for devPxProgress. + if (!this.scrubber.max || isNaN(devPxProgress) || devPxProgress > 0.5) { + this.scrubber.max = durationMs; + this.scrubber.value = currentTimeMs; + this.updateScrubberProgress(); + } + + // If the duration is over an hour, thumb should show h:mm:ss instead + // of mm:ss, which makes it bigger. We set the modifier prop which + // informs CSS custom properties used elsewhere to determine minimum + // widths we need to show stuff. + let modifier = durationMs >= 3600000 ? "long" : ""; + this.positionDurationBox.modifier = this.durationSpan.modifier = + modifier; + + // Update the text-based labels: + let position = this.formatTime(currentTimeMs); + let duration = durationIsInfinite ? "" : this.formatTime(durationMs); + if ( + this.positionString != position || + this.durationString != duration + ) { + // Only update the DOM if there is a visible change. + this._updatePositionLabels(position, duration); + } + }, + + _updatePositionLabels(position, duration) { + this.positionString = position; + this.durationString = duration; + + this.l10n.setAttributes( + this.positionDurationBox, + "videocontrols-position-and-duration-labels", + { position, duration } + ); + this.l10n.setAttributes( + this.scrubber, + "videocontrols-scrubber-position-and-duration", + { position, duration } + ); + }, + + showBuffered() { + function bsearch(haystack, needle, cmp) { + var length = haystack.length; + var low = 0; + var high = length; + while (low < high) { + var probe = low + ((high - low) >> 1); + var r = cmp(haystack, probe, needle); + if (r == 0) { + return probe; + } else if (r > 0) { + low = probe + 1; + } else { + high = probe; + } + } + return -1; + } + + function bufferedCompare(buffered, i, time) { + if (time > buffered.end(i)) { + return 1; + } else if (time >= buffered.start(i)) { + return 0; + } + return -1; + } + + var duration = Math.round(this.video.duration * 1000); + if (isNaN(duration) || duration == Infinity) { + duration = this.maxCurrentTimeSeen; + } + + // Find the range that the current play position is in and use that + // range for bufferBar. At some point we may support multiple ranges + // displayed in the bar. + var currentTime = this.video.currentTime; + var buffered = this.video.buffered; + var index = bsearch(buffered, currentTime, bufferedCompare); + var endTime = 0; + if (index >= 0) { + endTime = Math.round(buffered.end(index) * 1000); + } + if (this.duration == duration && this.buffered == endTime) { + // Avoid modifying the DOM if there is no update to show. + return; + } + + this.bufferBar.max = this.duration = duration; + this.bufferBar.value = this.buffered = endTime; + // Progress bars are automatically reported by screen readers even when + // they aren't focused, which intrudes on the audio being played. + // Ideally, we'd just change the a11y role of bufferBar, but there's + // no role which will let us just expose text via an ARIA attribute. + // Therefore, we hide bufferBar for a11y and expose the info as + // off-screen text. + this.bufferA11yVal.textContent = + (this.bufferBar.position * 100).toFixed() + "%"; + }, + + _controlsHiddenByTimeout: false, + _showControlsTimeout: 0, + SHOW_CONTROLS_TIMEOUT_MS: 500, + _showControlsFn() { + if (this.video.matches("video:hover")) { + this.startFadeIn(this.controlBar, false); + this._showControlsTimeout = 0; + this._controlsHiddenByTimeout = false; + } + }, + + _hideControlsTimeout: 0, + _hideControlsFn() { + if (!this.scrubber.isDragging) { + this.startFade(this.controlBar, false); + this._hideControlsTimeout = 0; + this._controlsHiddenByTimeout = true; + } + }, + HIDE_CONTROLS_TIMEOUT_MS: 2000, + + // By "Video" we actually mean the video controls container, + // because we don't want to consider the padding of <video> added + // by the web content. + isMouseOverVideo(event) { + // XXX: this triggers reflow too, but the layout should only be dirty + // if the web content touches it while the mouse is moving. + let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY); + + // As long as this is not null, the cursor is over something within our + // Shadow DOM. + return !!el; + }, + + isMouseOverControlBar(event) { + // XXX: this triggers reflow too, but the layout should only be dirty + // if the web content touches it while the mouse is moving. + let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY); + while (el && el !== this.shadowRoot) { + if (el == this.controlBar) { + return true; + } + el = el.parentNode; + } + return false; + }, + + onMouseMove(event) { + // If the controls are static, don't change anything. + if (!this.dynamicControls) { + return; + } + + this.window.clearTimeout(this._hideControlsTimeout); + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if (!this.firstFrameShown && !this.video.autoplay) { + return; + } + + if (this._controlsHiddenByTimeout) { + this._showControlsTimeout = this.window.setTimeout( + () => this._showControlsFn(), + this.SHOW_CONTROLS_TIMEOUT_MS + ); + } else { + this.startFade(this.controlBar, true); + } + + // Hide the controls if the mouse cursor is left on top of the video + // but above the control bar and if the click-to-play overlay is hidden. + if ( + (this._controlsHiddenByTimeout || + !this.isMouseOverControlBar(event)) && + this.clickToPlay.hidden + ) { + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + }, + + onMouseInOut(event) { + // If the controls are static, don't change anything. + if (!this.dynamicControls) { + return; + } + + this.window.clearTimeout(this._hideControlsTimeout); + + let isMouseOverVideo = this.isMouseOverVideo(event); + + // Suppress fading out the controls until the video has rendered + // its first frame. But since autoplay videos start off with no + // controls, let them fade-out so the controls don't get stuck on. + if ( + !this.firstFrameShown && + !isMouseOverVideo && + !this.video.autoplay + ) { + return; + } + + if (!isMouseOverVideo && !this.isMouseOverControlBar(event)) { + this.adjustControlSize(); + + // Keep the controls visible if the click-to-play is visible. + if (!this.clickToPlay.hidden) { + return; + } + + this.startFadeOut(this.controlBar, false); + this.hideClosedCaptionMenu(); + this.window.clearTimeout(this._showControlsTimeout); + this._controlsHiddenByTimeout = false; + } + }, + + startFadeIn(element, immediate) { + this.startFade(element, true, immediate); + }, + + startFadeOut(element, immediate) { + this.startFade(element, false, immediate); + }, + + animationMap: new WeakMap(), + + animationProps: { + clickToPlay: { + keyframes: [ + { transform: "scale(3)", opacity: 0 }, + { transform: "scale(1)", opacity: 0.55 }, + ], + options: { + easing: "ease", + duration: 400, + // The fill mode here and below is a workaround to avoid flicker + // due to bug 1495350. + fill: "both", + }, + }, + controlBar: { + keyframes: [{ opacity: 0 }, { opacity: 1 }], + options: { + easing: "ease", + duration: 200, + fill: "both", + }, + }, + statusOverlay: { + keyframes: [ + { opacity: 0 }, + { opacity: 0, offset: 0.72 }, // ~750ms into animation + { opacity: 1 }, + ], + options: { + duration: 1050, + fill: "both", + }, + }, + }, + + startFade(element, fadeIn, immediate = false) { + let animationProp = this.animationProps[element.id]; + if (!animationProp) { + throw new Error( + "Element " + + element.id + + " has no transition. Toggle the hidden property directly." + ); + } + + let animation = this.animationMap.get(element); + if (!animation) { + animation = new this.window.Animation( + new this.window.KeyframeEffect( + element, + animationProp.keyframes, + animationProp.options + ) + ); + + this.animationMap.set(element, animation); + } + + if (fadeIn) { + if (element == this.controlBar) { + this.controlsSpacer.removeAttribute("hideCursor"); + // Ensure the Full Screen button is in the tab order. + this.fullscreenButton.removeAttribute("tabindex"); + } + + // hidden state should be controlled by adjustControlSize + if (element.isAdjustableControl && element.hiddenByAdjustment) { + return; + } + + // No need to fade in again if the hidden property returns false + // (not hidden and not fading out.) + if (!element.hidden) { + return; + } + + // Unhide + element.hidden = false; + } else { + if (element == this.controlBar) { + if (!this.hasError() && this.isVideoInFullScreen) { + this.controlsSpacer.setAttribute("hideCursor", true); + } + if ( + !this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] + ) { + // The Full Screen button is currently the only tabbable button + // when the controls are shown. Remove it from the tab order when + // visually hidden to prevent visual confusion. + this.fullscreenButton.setAttribute("tabindex", "-1"); + } + } + + // No need to fade out if the hidden property returns true + // (hidden or is fading out) + if (element.hidden) { + return; + } + } + + element.classList.toggle("fadeout", !fadeIn); + element.classList.toggle("fadein", fadeIn); + let finishedPromise; + if (!immediate) { + // At this point, if there is a pending animation, we just stop it to avoid it happening. + // If there is a running animation, we reverse it, to have it rewind to the beginning. + // If there is an idle/finished animation, we schedule a new one that reverses the finished one. + if (animation.pending) { + // Animation is running but pending. + // Just cancel the pending animation to stop its effect. + animation.cancel(); + finishedPromise = Promise.resolve(); + } else { + switch (animation.playState) { + case "idle": + case "finished": + // There is no animation currently playing. + // Schedule a new animation with the desired playback direction. + animation.playbackRate = fadeIn ? 1 : -1; + animation.play(); + break; + case "running": + // Allow the animation to play from its current position in + // reverse to finish. + animation.reverse(); + break; + case "pause": + throw new Error("Animation should never reach pause state."); + default: + throw new Error( + "Unknown Animation playState: " + animation.playState + ); + } + finishedPromise = animation.finished; + } + } else { + // immediate + animation.cancel(); + finishedPromise = Promise.resolve(); + } + finishedPromise.then( + animation => { + if (element == this.controlBar) { + this.onControlBarAnimationFinished(); + } + element.classList.remove(fadeIn ? "fadein" : "fadeout"); + if (!fadeIn) { + element.hidden = true; + } + if (animation) { + // Explicitly clear the animation effect so that filling animations + // stop overwriting stylesheet styles. Remove when bug 1495350 is + // fixed and animations are no longer filling animations. + // This also stops them from accumulating (See bug 1253476). + animation.cancel(); + } + }, + () => { + /* Do nothing on rejection */ + } + ); + }, + + _triggeredByControls: false, + + startPlay() { + this._triggeredByControls = true; + this.hideClickToPlay(); + this.video.play(); + }, + + togglePause() { + if (this.video.paused || this.video.ended) { + this.startPlay(); + } else { + this.video.pause(); + } + + // We'll handle style changes in the event listener for + // the "play" and "pause" events, same as if content + // script was controlling video playback. + }, + + get isVideoWithoutAudioTrack() { + return ( + this.video.readyState >= this.video.HAVE_METADATA && + !this.isAudioOnly && + !this.video.mozHasAudio + ); + }, + + toggleMute() { + if (this.isVideoWithoutAudioTrack) { + return; + } + this.video.muted = !this.isEffectivelyMuted; + if (this.video.volume === 0) { + this.video.volume = 0.5; + } + + // We'll handle style changes in the event listener for + // the "volumechange" event, same as if content script was + // controlling volume. + }, + + get isVideoInFullScreen() { + return this.video.isSameNode( + this.video.getRootNode().fullscreenElement + ); + }, + + toggleFullscreen() { + // audio tags cannot toggle fullscreen + if (!this.isAudioTag) { + this.isVideoInFullScreen + ? this.document.exitFullscreen() + : this.video.requestFullscreen(); + } + }, + + setFullscreenButtonState() { + if (this.isAudioOnly || !this.document.fullscreenEnabled) { + this.controlBar.setAttribute("fullscreen-unavailable", true); + this.adjustControlSize(); + return; + } + this.controlBar.removeAttribute("fullscreen-unavailable"); + this.adjustControlSize(); + + var id = this.isVideoInFullScreen + ? "videocontrols-exitfullscreen-button" + : "videocontrols-enterfullscreen-button"; + this.l10n.setAttributes(this.fullscreenButton, id); + + if (this.isVideoInFullScreen) { + this.fullscreenButton.setAttribute("fullscreened", "true"); + } else { + this.fullscreenButton.removeAttribute("fullscreened"); + } + }, + + onFullscreenChange() { + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } else { + this.videocontrols.removeAttribute("inDOMFullscreen"); + } + + if (this.isVideoInFullScreen) { + this.startFadeOut(this.controlBar, true); + } + + this.setFullscreenButtonState(); + }, + + clickToPlayClickHandler(e) { + if (e.button != 0) { + return; + } + if (this.hasError() && !this.suppressError) { + // Errors that can be dismissed should be placed here as we discover them. + if (this.video.error.code != this.video.error.MEDIA_ERR_ABORTED) { + return; + } + this.startFadeOut(this.statusOverlay, true); + this.suppressError = true; + return; + } + if (e.defaultPrevented) { + return; + } + if (this.playButton.hasAttribute("paused")) { + this.startPlay(); + } else { + this.video.pause(); + } + }, + hideClickToPlay() { + let videoHeight = this.reflowedDimensions.videoHeight; + let videoWidth = this.reflowedDimensions.videoWidth; + + // The play button will animate to 3x its size. This + // shows the animation unless the video is too small + // to show 2/3 of the animation. + let animationScale = 2; + let animationMinSize = this.clickToPlay.minWidth * animationScale; + + let immediate = + animationMinSize > videoWidth || + animationMinSize > videoHeight - this.controlBarMinHeight; + this.startFadeOut(this.clickToPlay, immediate); + }, + + setPlayButtonState(aPaused) { + if (aPaused) { + this.playButton.setAttribute("paused", "true"); + } else { + this.playButton.removeAttribute("paused"); + } + + var id = aPaused + ? "videocontrols-play-button" + : "videocontrols-pause-button"; + this.l10n.setAttributes(this.playButton, id); + this.l10n.setAttributes(this.clickToPlay, id); + }, + + get isEffectivelyMuted() { + return this.video.muted || !this.video.volume; + }, + + updateMuteButtonState() { + var muted = this.isEffectivelyMuted; + + if (muted) { + this.muteButton.setAttribute("muted", "true"); + } else { + this.muteButton.removeAttribute("muted"); + } + + var id = muted + ? "videocontrols-unmute-button" + : "videocontrols-mute-button"; + this.l10n.setAttributes(this.muteButton, id); + }, + + keyboardVolumeDecrease() { + const oldval = this.video.volume; + this.video.volume = oldval < 0.1 ? 0 : oldval - 0.1; + this.video.muted = false; + }, + + keyboardVolumeIncrease() { + const oldval = this.video.volume; + this.video.volume = oldval > 0.9 ? 1 : oldval + 0.1; + this.video.muted = false; + }, + + keyboardSeekBack(tenPercent) { + const oldval = this.video.currentTime; + let newval; + if (tenPercent) { + newval = + oldval - + (this.video.duration || this.maxCurrentTimeSeen / 1000) / 10; + } else { + newval = oldval - VideoControlsWidget.SEEK_TIME_SECS; + } + this.video.currentTime = Math.max(0, newval); + }, + + keyboardSeekForward(tenPercent) { + const oldval = this.video.currentTime; + const maxtime = this.video.duration || this.maxCurrentTimeSeen / 1000; + let newval; + if (tenPercent) { + newval = oldval + maxtime / 10; + } else { + newval = oldval + VideoControlsWidget.SEEK_TIME_SECS; + } + this.video.currentTime = Math.min(newval, maxtime); + }, + + keyHandler(event) { + // Ignore keys when content might be providing its own. + if (!this.video.hasAttribute("controls")) { + return; + } + + let keystroke = ""; + if (event.altKey) { + keystroke += "alt-"; + } + if (event.shiftKey) { + keystroke += "shift-"; + } + if (this.window.navigator.platform.startsWith("Mac")) { + if (event.metaKey) { + keystroke += "accel-"; + } + if (event.ctrlKey) { + keystroke += "control-"; + } + } else { + if (event.metaKey) { + keystroke += "meta-"; + } + if (event.ctrlKey) { + keystroke += "accel-"; + } + } + if (event.key == " ") { + keystroke += "Space"; + } else { + keystroke += event.key; + } + + this.log("Got keystroke: " + keystroke); + + // If unmodified cursor keys are pressed when a slider is focused, we + // should act on that slider. For example, if we're focused on the + // volume slider, rightArrow should increase the volume, not seek. + // Normally, we'd just pass the keys through to the slider in this case. + // However, the native adjustment is too small, so we override it. + try { + const target = event.originalTarget; + const allTabbable = + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]; + switch (keystroke) { + case "Space" /* Play */: + if (target.localName === "button" && !target.disabled) { + break; + } + this.togglePause(); + break; + case "ArrowDown" /* Volume decrease */: + if (allTabbable && target == this.scrubber) { + this.keyboardSeekBack(/* tenPercent */ false); + } else if (target.classList.contains("textTrackItem")) { + target.nextSibling?.focus(); + } else { + this.keyboardVolumeDecrease(); + } + break; + case "ArrowUp" /* Volume increase */: + if (allTabbable && target == this.scrubber) { + this.keyboardSeekForward(/* tenPercent */ false); + } else if (target.classList.contains("textTrackItem")) { + target.previousSibling?.focus(); + } else { + this.keyboardVolumeIncrease(); + } + break; + case "accel-ArrowDown" /* Mute */: + this.video.muted = true; + break; + case "accel-ArrowUp" /* Unmute */: + this.video.muted = false; + break; + case "ArrowLeft" /* Seek back 5 seconds */: + if (allTabbable && target == this.volumeControl) { + this.keyboardVolumeDecrease(); + } else { + this.keyboardSeekBack(/* tenPercent */ false); + } + break; + case "accel-ArrowLeft" /* Seek back 10% */: + this.keyboardSeekBack(/* tenPercent */ true); + break; + case "ArrowRight" /* Seek forward 5 seconds */: + if (allTabbable && target == this.volumeControl) { + this.keyboardVolumeIncrease(); + } else { + this.keyboardSeekForward(/* tenPercent */ false); + } + break; + case "accel-ArrowRight" /* Seek forward 10% */: + this.keyboardSeekForward(/* tenPercent */ true); + break; + case "Home" /* Seek to beginning */: + this.video.currentTime = 0; + break; + case "End" /* Seek to end */: + if (this.video.currentTime != this.video.duration) { + this.video.currentTime = + this.video.duration || this.maxCurrentTimeSeen / 1000; + } + break; + case "Escape" /* Escape */: + if ( + target.classList.contains("textTrackItem") && + !this.textTrackListContainer.hidden + ) { + this.toggleClosedCaption(); + this.closedCaptionButton.focus(); + } + break; + default: + return; + } + } catch (e) { + /* ignore any exception from setting .currentTime */ + } + + event.preventDefault(); // Prevent page scrolling + }, + + checkTextTrackSupport(textTrack) { + return textTrack.kind == "subtitles" || textTrack.kind == "captions"; + }, + + get isCastingAvailable() { + return !this.isAudioOnly && this.video.mozAllowCasting; + }, + + get isClosedCaptionAvailable() { + // There is no rendering area, no need to show the caption. + if (this.isAudioOnly) { + return false; + } + return this.overlayableTextTracks.length; + }, + + get overlayableTextTracks() { + return Array.prototype.filter.call( + this.video.textTracks, + this.checkTextTrackSupport + ); + }, + + get currentTextTrackIndex() { + const showingTT = this.overlayableTextTracks.find( + tt => tt.mode == "showing" + ); + + // fallback to off button if there's no showing track. + return showingTT ? showingTT.index : 0; + }, + + get isCastingOn() { + return this.isCastingAvailable && this.video.mozIsCasting; + }, + + setCastingButtonState() { + if (this.isCastingOn) { + this.castingButton.setAttribute("enabled", "true"); + } else { + this.castingButton.removeAttribute("enabled"); + } + + this.adjustControlSize(); + }, + + updateCasting(eventDetail) { + let castingData = JSON.parse(eventDetail); + if ("allow" in castingData) { + this.video.mozAllowCasting = !!castingData.allow; + } + + if ("active" in castingData) { + this.video.mozIsCasting = !!castingData.active; + } + this.setCastingButtonState(); + }, + + get isClosedCaptionOn() { + for (let tt of this.overlayableTextTracks) { + if (tt.mode === "showing") { + return true; + } + } + + return false; + }, + + setClosedCaptionButtonState() { + if (this.isClosedCaptionOn) { + this.closedCaptionButton.setAttribute("enabled", "true"); + } else { + this.closedCaptionButton.removeAttribute("enabled"); + } + + let ttItems = this.textTrackList.childNodes; + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx == this.currentTextTrackIndex) { + tti.setAttribute("aria-checked", "true"); + } else { + tti.setAttribute("aria-checked", "false"); + } + } + + this.adjustControlSize(); + }, + + addNewTextTrack(tt) { + if (!this.checkTextTrackSupport(tt)) { + return; + } + + if (tt.index && tt.index < this.textTracksCount) { + // Don't create items for initialized tracks. However, we + // still need to care about mode since TextTrackManager would + // turn on the first available track automatically. + if (tt.mode === "showing") { + this.changeTextTrack(tt.index); + } + return; + } + + tt.index = this.textTracksCount++; + + const ttBtn = this.shadowRoot.createElementAndAppendChildAt( + this.textTrackList, + "button" + ); + ttBtn.textContent = tt.label || ""; + + ttBtn.classList.add("textTrackItem"); + ttBtn.setAttribute("index", tt.index); + ttBtn.setAttribute("role", "menuitemradio"); + + if (tt.mode === "showing" && tt.index) { + this.changeTextTrack(tt.index); + } + }, + + changeTextTrack(index) { + for (let tt of this.overlayableTextTracks) { + if (tt.index === index) { + tt.mode = "showing"; + } else { + tt.mode = "disabled"; + } + } + + if (!this.textTrackListContainer.hidden) { + this.toggleClosedCaption(); + } + }, + + onControlBarAnimationFinished() { + this.hideClosedCaptionMenu(); + this.video.dispatchEvent( + new this.window.CustomEvent("controlbarchange") + ); + this.adjustControlSize(); + }, + + toggleCasting() { + this.videocontrols.dispatchEvent( + new this.window.CustomEvent("VideoBindingCast") + ); + }, + + hideClosedCaptionMenu() { + this.textTrackListContainer.hidden = true; + this.closedCaptionButton.setAttribute("aria-expanded", "false"); + this.updatePictureInPictureToggleDisplay(); + }, + + showClosedCaptionMenu() { + this.textTrackListContainer.hidden = false; + this.closedCaptionButton.setAttribute("aria-expanded", "true"); + this.updatePictureInPictureToggleDisplay(); + }, + + toggleClosedCaption() { + if (this.textTrackListContainer.hidden) { + this.showClosedCaptionMenu(); + if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) { + // If we're about to hide the controls after focus, prevent that, as + // that will dismiss the CC menu before the user can use it. + this.textTrackList.firstChild.focus(); + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = 0; + } + } else { + this.hideClosedCaptionMenu(); + // If the CC menu was shown via the keyboard, we may have prevented + // the controls from hiding. We can now hide them. + if ( + this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] && + !this.controlBar.hidden && + // The click-to-play overlay must already be hidden (we don't + // hide controls when the overlay is visible). + this.clickToPlay.hidden && + // Don't do this if the controls are static. + this.dynamicControls && + // If the mouse is hovering over the control bar, the controls + // shouldn't hide. + // We use "div:hover" instead of just ":hover" so this works in + // quirks mode documents. See + // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk + !this.controlBar.matches("div:hover") + ) { + this.window.clearTimeout(this._hideControlsTimeout); + this._hideControlsTimeout = this.window.setTimeout( + () => this._hideControlsFn(), + this.HIDE_CONTROLS_TIMEOUT_MS + ); + } + } + }, + + onTextTrackAdd(trackEvent) { + this.addNewTextTrack(trackEvent.track); + this.setClosedCaptionButtonState(); + }, + + onTextTrackRemove(trackEvent) { + const toRemoveIndex = trackEvent.track.index; + const ttItems = this.textTrackList.childNodes; + + if (!ttItems) { + return; + } + + for (let tti of ttItems) { + const idx = +tti.getAttribute("index"); + + if (idx === toRemoveIndex) { + tti.remove(); + this.textTracksCount--; + } + + this.video.dispatchEvent( + new this.window.CustomEvent("texttrackchange") + ); + } + + this.setClosedCaptionButtonState(); + }, + + initTextTracks() { + // add 'off' button anyway as new text track might be + // dynamically added after initialization. + const offLabel = this.textTrackList.getAttribute("offlabel"); + this.addNewTextTrack({ + label: offLabel, + kind: "subtitles", + }); + + for (let tt of this.overlayableTextTracks) { + this.addNewTextTrack(tt); + } + + this.setClosedCaptionButtonState(); + // Hide the Closed Caption menu when the user moves focus + this.hideClosedCaptionMenu = this.hideClosedCaptionMenu.bind(this); + this.closedCaptionButton.addEventListener( + "focus", + this.hideClosedCaptionMenu + ); + this.fullscreenButton.addEventListener( + "focus", + this.hideClosedCaptionMenu + ); + }, + + log(msg) { + if (this.debug) { + this.window.console.log("videoctl: " + msg + "\n"); + } + }, + + get isTopLevelSyntheticDocument() { + return ( + this.document.mozSyntheticDocument && this.window === this.window.top + ); + }, + + controlBarMinHeight: 40, + controlBarMinVisibleHeight: 28, + + reflowTriggeringCallValidator: { + isReflowTriggeringPropsAllowed: false, + reflowTriggeringProps: Object.freeze([ + "offsetLeft", + "offsetTop", + "offsetWidth", + "offsetHeight", + "offsetParent", + "clientLeft", + "clientTop", + "clientWidth", + "clientHeight", + "getClientRects", + "getBoundingClientRect", + ]), + get(obj, prop) { + if ( + !this.isReflowTriggeringPropsAllowed && + this.reflowTriggeringProps.includes(prop) + ) { + throw new Error("Please don't trigger reflow. See bug 1493525."); + } + let val = obj[prop]; + if (typeof val == "function") { + return function () { + return val.apply(obj, arguments); + }; + } + return val; + }, + + set(obj, prop, value) { + return Reflect.set(obj, prop, value); + }, + }, + + installReflowCallValidator(element) { + return new Proxy(element, this.reflowTriggeringCallValidator); + }, + + reflowedDimensions: { + // Set the dimensions to intrinsic <video> dimensions before the first + // update. + // These values are not picked up by <audio> in adjustControlSize() + // (except for the fact that they are non-zero), + // it takes controlBarMinHeight and the value below instead. + videoHeight: 150, + videoWidth: 300, + + // <audio> takes this width to grow/shrink controls. + // The initial value has to be smaller than the calculated minRequiredWidth + // so that we don't run into bug 1495821 (see comment on adjustControlSize() + // below) + videocontrolsWidth: 0, + + // Used to decide if updating the scrubber progress will make a visible + // change (ie. make it move by at least one pixel). + // The default value is set to Infinity so that any small change is + // assumed to cause a visible change until updateReflowedDimensions + // has been called. (See bug 1817604) + scrubberWidth: Infinity, + }, + + updateReflowedDimensions() { + this.reflowedDimensions.videoHeight = this.video.clientHeight; + this.reflowedDimensions.videoWidth = this.video.clientWidth; + this.reflowedDimensions.videocontrolsWidth = + this.videocontrols.clientWidth; + this.reflowedDimensions.scrubberWidth = this.scrubber.clientWidth; + }, + + /** + * adjustControlSize() considers outer dimensions of the <video>/<audio> element + * from layout, and accordingly, sets/hides the controls, and adjusts + * the width/height of the control bar. + * + * It's important to remember that for <audio>, layout (specifically, + * nsVideoFrame) rely on us to expose the intrinsic dimensions of the + * control bar to properly size the <audio> element. We interact with layout + * by: + * + * 1) When the element has a non-zero height, explicitly set the height + * of the control bar to a size between controlBarMinHeight and + * controlBarMinVisibleHeight in response. + * Note: the logic here is flawed and had caused the end height to be + * depend on its previous state, see bug 1495817. + * 2) When the element has a outer width smaller or equal to minControlBarPaddingWidth, + * explicitly set the control bar to minRequiredWidth, so that when the + * outer width is unset, the audio element could go back to minRequiredWidth. + * Otherwise, set the width of the control bar to be the current outer width. + * Note: the logic here is also flawed; when the control bar is set to + * the current outer width, it never go back when the width is unset, + * see bug 1495821. + */ + adjustControlSize() { + const minControlBarPaddingWidth = 18; + + this.fullscreenButton.isWanted = !this.controlBar.hasAttribute( + "fullscreen-unavailable" + ); + this.castingButton.isWanted = this.isCastingAvailable; + this.closedCaptionButton.isWanted = this.isClosedCaptionAvailable; + this.volumeStack.isWanted = !this.muteButton.hasAttribute("noAudio"); + + let minRequiredWidth = this.prioritizedControls + .filter(control => control && control.isWanted) + .reduce( + (accWidth, cc) => accWidth + cc.minWidth, + minControlBarPaddingWidth + ); + // Skip the adjustment in case the stylesheets haven't been loaded yet. + if (!minRequiredWidth) { + return; + } + + let givenHeight = this.reflowedDimensions.videoHeight; + let videoWidth = + (this.isAudioOnly + ? this.reflowedDimensions.videocontrolsWidth + : this.reflowedDimensions.videoWidth) || minRequiredWidth; + let videoHeight = this.isAudioOnly + ? this.controlBarMinHeight + : givenHeight; + let videocontrolsWidth = this.reflowedDimensions.videocontrolsWidth; + + let widthUsed = minControlBarPaddingWidth; + let preventAppendControl = false; + + for (let [index, control] of this.prioritizedControls.entries()) { + // The "durationSpan" element is disconnected from the document during l10n so + // we check if our reference to "durationSpan" is the connected one and if not we + // replace it with the correct one + if (control.id === "durationSpan" && !control.isConnected) { + const durationSpan = this.durationSpan; + if (durationSpan) { + this.defineControlProperties(durationSpan); + this.prioritizedControls[index] = durationSpan; + control = durationSpan; + } + } + if (!control.isWanted) { + control.hiddenByAdjustment = true; + continue; + } + + control.hiddenByAdjustment = + preventAppendControl || widthUsed + control.minWidth > videoWidth; + + if (control.hiddenByAdjustment) { + preventAppendControl = true; + } else { + widthUsed += control.minWidth; + } + } + + // Use flexible spacer to separate controls when scrubber is hidden. + // As long as muteButton hidden, which means only play button presents, + // hide spacer and make playButton centered. + this.controlBarSpacer.hidden = + !this.scrubberStack.hidden || this.muteButton.hidden; + + // Since the size of videocontrols is expanded with controlBar in <audio>, we + // should fix the dimensions in order not to recursively trigger reflow afterwards. + if (this.isAudioTag) { + if (givenHeight) { + // The height of controlBar should be capped with the bounds between controlBarMinHeight + // and controlBarMinVisibleHeight. + let controlBarHeight = Math.max( + Math.min(givenHeight, this.controlBarMinHeight), + this.controlBarMinVisibleHeight + ); + this.controlBar.style.height = `${controlBarHeight}px`; + } + // Bug 1367875: Set minimum required width to controlBar if the given size is smaller than padding. + // This can help us expand the control and restore to the default size the next time we need + // to adjust the sizing. + if (videocontrolsWidth <= minControlBarPaddingWidth) { + this.controlBar.style.width = `${minRequiredWidth}px`; + } else { + this.controlBar.style.width = `${videoWidth}px`; + } + return; + } + + if ( + videoHeight < this.controlBarMinHeight || + widthUsed === minControlBarPaddingWidth + ) { + this.controlBar.setAttribute("size", "hidden"); + this.controlBar.hiddenByAdjustment = true; + } else { + this.controlBar.removeAttribute("size"); + this.controlBar.hiddenByAdjustment = false; + } + + // Adjust clickToPlayButton size. + const minVideoSideLength = Math.min(videoWidth, videoHeight); + const clickToPlayViewRatio = 0.15; + const clickToPlayScaledSize = Math.max( + this.clickToPlay.minWidth, + minVideoSideLength * clickToPlayViewRatio + ); + + if ( + clickToPlayScaledSize >= videoWidth || + clickToPlayScaledSize + this.controlBarMinHeight / 2 >= + videoHeight / 2 + ) { + this.clickToPlay.hiddenByAdjustment = true; + } else { + if ( + this.clickToPlay.hidden && + !this.video.played.length && + this.video.paused + ) { + this.clickToPlay.hiddenByAdjustment = false; + } + this.clickToPlay.style.width = `${clickToPlayScaledSize}px`; + this.clickToPlay.style.height = `${clickToPlayScaledSize}px`; + } + }, + + get pipToggleEnabled() { + return ( + this.prefs[ + "media.videocontrols.picture-in-picture.video-toggle.enabled" + ] && this.prefs["media.videocontrols.picture-in-picture.enabled"] + ); + }, + + get positionDurationBox() { + return this.shadowRoot.getElementById("positionDurationBox"); + }, + + get durationSpan() { + return this.positionDurationBox?.getElementsByTagName("span")[0]; + }, + + init(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.video = this.installReflowCallValidator(shadowRoot.host); + this.videocontrols = this.installReflowCallValidator( + shadowRoot.firstChild + ); + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + this.prefs = prefs; + + this.controlsContainer = + this.shadowRoot.getElementById("controlsContainer"); + this.statusIcon = this.shadowRoot.getElementById("statusIcon"); + this.controlBar = this.shadowRoot.getElementById("controlBar"); + this.playButton = this.shadowRoot.getElementById("playButton"); + this.controlBarSpacer = + this.shadowRoot.getElementById("controlBarSpacer"); + this.muteButton = this.shadowRoot.getElementById("muteButton"); + this.volumeStack = this.shadowRoot.getElementById("volumeStack"); + this.volumeControl = this.shadowRoot.getElementById("volumeControl"); + this.progressBar = this.shadowRoot.getElementById("progressBar"); + this.bufferBar = this.shadowRoot.getElementById("bufferBar"); + this.bufferA11yVal = this.shadowRoot.getElementById("bufferA11yVal"); + this.scrubberStack = this.shadowRoot.getElementById("scrubberStack"); + this.scrubber = this.shadowRoot.getElementById("scrubber"); + this.durationLabel = this.shadowRoot.getElementById("durationLabel"); + this.positionLabel = this.shadowRoot.getElementById("positionLabel"); + this.statusOverlay = this.shadowRoot.getElementById("statusOverlay"); + this.controlsOverlay = + this.shadowRoot.getElementById("controlsOverlay"); + this.pictureInPictureOverlay = this.shadowRoot.getElementById( + "pictureInPictureOverlay" + ); + this.controlsSpacer = this.shadowRoot.getElementById("controlsSpacer"); + this.clickToPlay = this.shadowRoot.getElementById("clickToPlay"); + this.fullscreenButton = + this.shadowRoot.getElementById("fullscreenButton"); + this.castingButton = this.shadowRoot.getElementById("castingButton"); + this.closedCaptionButton = this.shadowRoot.getElementById( + "closedCaptionButton" + ); + this.textTrackList = this.shadowRoot.getElementById("textTrackList"); + this.textTrackListContainer = this.shadowRoot.getElementById( + "textTrackListContainer" + ); + this.pictureInPictureToggle = this.shadowRoot.getElementById( + "pictureInPictureToggle" + ); + + let isMobile = this.window.navigator.appVersion.includes("Android"); + if (isMobile) { + this.controlsContainer.classList.add("mobile"); + } + + // TODO: Switch to touch controls on touch-based desktops (bug 1447547) + this.isTouchControls = isMobile; + if (this.isTouchControls) { + this.controlsContainer.classList.add("touch"); + } + + // XXX: Calling getComputedStyle() here by itself doesn't cause any reflow, + // but there is no guard proventing accessing any properties and methods + // of this saved CSSStyleDeclaration instance that could trigger reflow. + this.controlBarComputedStyles = this.window.getComputedStyle( + this.controlBar + ); + + // Hide and show control in certain order. + this.prioritizedControls = [ + this.playButton, + this.muteButton, + this.fullscreenButton, + this.castingButton, + this.closedCaptionButton, + this.positionDurationBox, + this.scrubberStack, + this.durationSpan, + this.volumeStack, + ]; + + this.isAudioOnly = this.isAudioTag; + this.setupInitialState(); + this.setupNewLoadState(); + this.initTextTracks(); + + // Use the handleEvent() callback for all media events. + // Only the "error" event listener must capture, so that it can trap error + // events from <source> children, which don't bubble. But we use capture + // for all events in order to simplify the event listener add/remove. + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } + + this.controlsEvents = [ + { el: this.muteButton, type: "click" }, + { el: this.castingButton, type: "click" }, + { el: this.closedCaptionButton, type: "click" }, + { el: this.fullscreenButton, type: "click" }, + { el: this.playButton, type: "click" }, + { el: this.clickToPlay, type: "click" }, + + // On touch videocontrols, tapping controlsSpacer should show/hide + // the control bar, instead of playing the video or toggle fullscreen. + { el: this.controlsSpacer, type: "click", nonTouchOnly: true }, + { el: this.controlsSpacer, type: "dblclick", nonTouchOnly: true }, + + { el: this.textTrackList, type: "click" }, + + { el: this.videocontrols, type: "resizevideocontrols" }, + + { el: this.document, type: "fullscreenchange" }, + { el: this.video, type: "keypress", capture: true }, + + // Prevent any click event within media controls from dispatching through to video. + { el: this.videocontrols, type: "click", mozSystemGroup: false }, + + // prevent dragging of controls image (bug 517114) + { el: this.videocontrols, type: "dragstart" }, + + { el: this.scrubber, type: "input" }, + { el: this.scrubber, type: "change" }, + // add mouseup listener additionally to handle the case that `change` event + // isn't fired when the input value before/after dragging are the same. (bug 1328061) + { el: this.scrubber, type: "mouseup" }, + { el: this.volumeControl, type: "input" }, + { el: this.video.textTracks, type: "addtrack" }, + { el: this.video.textTracks, type: "removetrack" }, + { el: this.video.textTracks, type: "change" }, + + { el: this.video, type: "media-videoCasting", touchOnly: true }, + + { el: this.controlBar, type: "focusin" }, + { el: this.scrubber, type: "mousedown" }, + { el: this.volumeControl, type: "mousedown" }, + ]; + + for (let { + el, + type, + nonTouchOnly = false, + touchOnly = false, + mozSystemGroup = true, + capture = false, + } of this.controlsEvents) { + if ( + (this.isTouchControls && nonTouchOnly) || + (!this.isTouchControls && touchOnly) + ) { + continue; + } + el.addEventListener(type, this, { mozSystemGroup, capture }); + } + + this.log("--- videocontrols initialized ---"); + }, + }; + + this.TouchUtils = { + videocontrols: null, + video: null, + controlsTimer: null, + controlsTimeout: 5000, + + get visible() { + return ( + !this.Utils.controlBar.hasAttribute("fadeout") && + !this.Utils.controlBar.hidden + ); + }, + + firstShow: false, + + toggleControls() { + if (!this.Utils.dynamicControls || !this.visible) { + this.showControls(); + } else { + this.delayHideControls(0); + } + }, + + showControls() { + if (this.Utils.dynamicControls) { + this.Utils.startFadeIn(this.Utils.controlBar); + this.delayHideControls(this.controlsTimeout); + } + }, + + clearTimer() { + if (this.controlsTimer) { + this.window.clearTimeout(this.controlsTimer); + this.controlsTimer = null; + } + }, + + delayHideControls(aTimeout) { + this.clearTimer(); + this.controlsTimer = this.window.setTimeout( + () => this.hideControls(), + aTimeout + ); + }, + + hideControls() { + if (!this.Utils.dynamicControls) { + return; + } + this.Utils.startFadeOut(this.Utils.controlBar); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "click": + switch (aEvent.currentTarget) { + case this.Utils.playButton: + if (!this.video.paused) { + this.delayHideControls(0); + } else { + this.showControls(); + } + break; + case this.Utils.muteButton: + this.delayHideControls(this.controlsTimeout); + break; + } + break; + case "touchstart": + this.clearTimer(); + break; + case "touchend": + this.delayHideControls(this.controlsTimeout); + break; + case "mouseup": + if (aEvent.originalTarget == this.Utils.controlsSpacer) { + if (this.firstShow) { + this.Utils.video.play(); + this.firstShow = false; + } + this.toggleControls(); + } + + break; + } + }, + + terminate() { + try { + for (let { el, type, mozSystemGroup = true } of this.controlsEvents) { + el.removeEventListener(type, this, { mozSystemGroup }); + } + } catch (ex) {} + + this.clearTimer(); + }, + + init(shadowRoot, utils) { + this.Utils = utils; + this.videocontrols = this.Utils.videocontrols; + this.video = this.Utils.video; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.controlsEvents = [ + { el: this.Utils.playButton, type: "click" }, + { el: this.Utils.scrubber, type: "touchstart" }, + { el: this.Utils.scrubber, type: "touchend" }, + { el: this.Utils.muteButton, type: "click" }, + { el: this.Utils.controlsSpacer, type: "mouseup" }, + ]; + + for (let { el, type, mozSystemGroup = true } of this.controlsEvents) { + el.addEventListener(type, this, { mozSystemGroup }); + } + + // The first time the controls appear we want to just display + // a play button that does not fade away. The firstShow property + // makes that happen. But because of bug 718107 this init() method + // may be called again when we switch in or out of fullscreen + // mode. So we only set firstShow if we're not autoplaying and + // if we are at the beginning of the video and not already playing + if ( + !this.video.autoplay && + this.Utils.dynamicControls && + this.video.paused && + this.video.currentTime === 0 + ) { + this.firstShow = true; + } + + // If the video is not at the start, then we probably just + // transitioned into or out of fullscreen mode, and we don't want + // the controls to remain visible. this.controlsTimeout is a full + // 5s, which feels too long after the transition. + if (this.video.currentTime !== 0) { + this.delayHideControls(this.Utils.HIDE_CONTROLS_TIMEOUT_MS); + } + }, + }; + + this.Utils.init(this.shadowRoot, this.prefs); + if (this.Utils.isTouchControls) { + this.TouchUtils.init(this.shadowRoot, this.Utils); + } + this.shadowRoot.firstChild.dispatchEvent( + new this.window.CustomEvent("VideoBindingAttached") + ); + + this._setupEventListeners(); + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <link rel="stylesheet" href="chrome://global/skin/media/pipToggle.css" /> + + <div id="controlsContainer" class="controlsContainer" role="none"> + <div id="statusOverlay" class="statusOverlay stackItem" hidden="true"> + <div id="statusIcon" class="statusIcon"></div> + <bdi class="statusLabel" id="errorAborted" data-l10n-id="videocontrols-error-aborted"></bdi> + <bdi class="statusLabel" id="errorNetwork" data-l10n-id="videocontrols-error-network"></bdi> + <bdi class="statusLabel" id="errorDecode" data-l10n-id="videocontrols-error-decode"></bdi> + <bdi class="statusLabel" id="errorSrcNotSupported" data-l10n-id="videocontrols-error-src-not-supported"></bdi> + <bdi class="statusLabel" id="errorNoSource" data-l10n-id="videocontrols-error-no-source"></bdi> + <bdi class="statusLabel" id="errorGeneric" data-l10n-id="videocontrols-error-generic"></bdi> + </div> + + <div id="pictureInPictureOverlay" class="pictureInPictureOverlay stackItem" status="pictureInPicture" hidden="true"> + <div class="statusIcon" type="pictureInPicture"></div> + <bdi class="statusLabel" id="pictureInPicture" data-l10n-id="videocontrols-status-picture-in-picture"></bdi> + </div> + + <div id="controlsOverlay" class="controlsOverlay stackItem" role="none"> + <div class="controlsSpacerStack"> + <div id="controlsSpacer" class="controlsSpacer stackItem" role="none"></div> + <button id="clickToPlay" class="clickToPlay" hidden="true"></button> + </div> + + <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true"> + <div class="pip-small clickable"></div> + <div class="pip-expanded clickable"> + <span class="pip-icon-label clickable"> + <span class="pip-icon"></span> + <span class="pip-label" data-l10n-id="videocontrols-picture-in-picture-toggle-label2"></span> + </span> + <div class="pip-explainer clickable" data-l10n-id="videocontrols-picture-in-picture-explainer3"></div> + </div> + <div class="pip-icon clickable"></div> + </button> + + <div id="controlBar" class="controlBar" role="none" hidden="true"> + <button id="playButton" + class="button playButton" + tabindex="-1"/> + <div id="scrubberStack" class="scrubberStack progressContainer" role="none"> + <div class="progressBackgroundBar stackItem" role="none"> + <div class="progressStack" role="none"> + <progress id="bufferBar" class="bufferBar" value="0" max="100" aria-hidden="true"></progress> + <span class="a11y-only" role="status" aria-live="off"> + <span data-l10n-id="videocontrols-buffer-bar-label"></span> + <span id="bufferA11yVal"></span> + </span> + <progress id="progressBar" class="progressBar" value="0" max="100" aria-hidden="true"></progress> + </div> + </div> + <input type="range" id="scrubber" class="scrubber" tabindex="-1" data-l10n-attrs="aria-valuetext" value="0"/> + </div> + <bdi id="positionLabel" class="positionLabel" role="presentation"></bdi> + <bdi id="durationLabel" class="durationLabel" role="presentation"></bdi> + <bdi id="positionDurationBox" class="positionDurationBox" aria-hidden="true"> + <span id="durationSpan" class="duration" role="none" + data-l10n-name="position-duration-format"></span> + </bdi> + <div id="controlBarSpacer" class="controlBarSpacer" hidden="true" role="none"></div> + <button id="muteButton" + class="button muteButton" + tabindex="-1"/> + <div id="volumeStack" class="volumeStack progressContainer" role="none"> + <input type="range" id="volumeControl" class="volumeControl" min="0" max="100" step="1" tabindex="-1" + data-l10n-id="videocontrols-volume-control"/> + </div> + <button id="castingButton" class="button castingButton" + data-l10n-id="videocontrols-casting-button-label"/> + <button id="closedCaptionButton" class="button closedCaptionButton" aria-controls="textTrackList" + aria-haspopup="menu" aria-expanded="false" data-l10n-id="videocontrols-closed-caption-button"/> + <div id="textTrackListContainer" class="textTrackListContainer" hidden="true" role="presentation"> + <div id="textTrackList" role="menu" class="textTrackList" + data-l10n-id="videocontrols-closed-caption-off" data-l10n-attrs="offlabel"/> + </div> + <button id="fullscreenButton" + class="button fullscreenButton"/> + </div> + </div> + </div> + </div>`, + "application/xml" + ); + this.l10n = new this.window.DOMLocalization( + ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"], + true + ); + this.l10n.connectRoot(this.shadowRoot); + if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) { + // Make all of the individual controls tabbable. + for (const el of parserDoc.documentElement.querySelectorAll( + '[tabindex="-1"]' + )) { + el.removeAttribute("tabindex"); + } + } + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n.translateRoots(); + } + + elementStateMatches(element) { + let elementInPiP = VideoControlsWidget.isPictureInPictureVideo(element); + return this.isShowingPictureInPictureMessage == elementInPiP; + } + + teardown() { + this.Utils.terminate(); + this.TouchUtils.terminate(); + this.l10n.disconnectRoot(this.shadowRoot); + this.l10n = null; + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + this.Utils.updatePictureInPictureToggleDisplay(); + } + + _setupEventListeners() { + this.shadowRoot.firstChild.addEventListener("mouseover", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseInOut(event); + } + }); + + this.shadowRoot.firstChild.addEventListener("mouseout", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseInOut(event); + } + }); + + this.shadowRoot.firstChild.addEventListener("mousemove", event => { + if (!this.Utils.isTouchControls) { + this.Utils.onMouseMove(event); + } + }); + } +}; + +this.NoControlsMobileImplWidget = class { + constructor(shadowRoot) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + videoEvents: ["play", "playing"], + videoControlEvents: ["MozNoControlsBlockedVideo"], + terminate() { + for (let event of this.videoEvents) { + try { + this.video.removeEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } catch (ex) {} + } + + for (let event of this.videoControlEvents) { + try { + this.videocontrols.removeEventListener(event, this); + } catch (ex) {} + } + + try { + this.clickToPlay.removeEventListener("click", this, { + mozSystemGroup: true, + }); + } catch (ex) {} + }, + + hasError() { + return ( + this.video.error != null || + this.video.networkState == this.video.NETWORK_NO_SOURCE + ); + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "play": + this.noControlsOverlay.hidden = true; + break; + case "playing": + this.noControlsOverlay.hidden = true; + break; + case "MozNoControlsBlockedVideo": + this.blockedVideoHandler(); + break; + case "click": + this.clickToPlayClickHandler(aEvent); + break; + } + }, + + blockedVideoHandler() { + if (this.hasError()) { + this.noControlsOverlay.hidden = true; + return; + } + this.noControlsOverlay.hidden = false; + }, + + clickToPlayClickHandler(e) { + if (e.button != 0) { + return; + } + + this.noControlsOverlay.hidden = true; + this.video.play(); + }, + + init(shadowRoot) { + this.shadowRoot = shadowRoot; + this.video = shadowRoot.host; + this.videocontrols = shadowRoot.firstChild; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.controlsContainer = + this.shadowRoot.getElementById("controlsContainer"); + this.clickToPlay = this.shadowRoot.getElementById("clickToPlay"); + this.noControlsOverlay = + this.shadowRoot.getElementById("controlsContainer"); + + let isMobile = this.window.navigator.appVersion.includes("Android"); + if (isMobile) { + this.controlsContainer.classList.add("mobile"); + } + + // TODO: Switch to touch controls on touch-based desktops (bug 1447547) + this.isTouchControls = isMobile; + if (this.isTouchControls) { + this.controlsContainer.classList.add("touch"); + } + + this.clickToPlay.addEventListener("click", this, { + mozSystemGroup: true, + }); + + for (let event of this.videoEvents) { + this.video.addEventListener(event, this, { + capture: true, + mozSystemGroup: true, + }); + } + + for (let event of this.videoControlEvents) { + this.videocontrols.addEventListener(event, this); + } + }, + }; + this.Utils.init(this.shadowRoot); + this.Utils.video.dispatchEvent( + new this.window.CustomEvent("MozNoControlsVideoBindingAttached") + ); + } + + elementStateMatches(element) { + return true; + } + + teardown() { + this.Utils.terminate(); + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <div id="controlsContainer" class="controlsContainer" role="none" hidden="true"> + <div class="controlsOverlay stackItem"> + <div class="controlsSpacerStack"> + <button id="clickToPlay" class="clickToPlay"></button> + </div> + </div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + } +}; + +this.NoControlsPictureInPictureImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + } + + elementStateMatches(element) { + return true; + } + + teardown() {} + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <div id="controlsContainer" class="controlsContainer" role="none"> + <div class="pictureInPictureOverlay stackItem" status="pictureInPicture"> + <div id="statusIcon" class="statusIcon" type="pictureInPicture"></div> + <bdi class="statusLabel" id="pictureInPicture" data-l10n-id="videocontrols-status-picture-in-picture"></bdi> + </div> + <div class="controlsOverlay stackItem"></div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n = new this.window.DOMLocalization([ + "branding/brand.ftl", + "toolkit/global/videocontrols.ftl", + ]); + this.l10n.connectRoot(this.shadowRoot); + this.l10n.translateRoots(); + } +}; + +this.NoControlsDesktopImplWidget = class { + constructor(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.element = shadowRoot.host; + this.document = this.element.ownerDocument; + this.window = this.document.defaultView; + this.prefs = prefs; + } + + onsetup(direction) { + this.generateContent(); + + this.shadowRoot.firstElementChild.setAttribute("localedir", direction); + + this.Utils = { + handleEvent(event) { + switch (event.type) { + case "fullscreenchange": { + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } else { + this.videocontrols.removeAttribute("inDOMFullscreen"); + } + break; + } + case "resizevideocontrols": { + this.updateReflowedDimensions(); + this.updatePictureInPictureToggleDisplay(); + break; + } + case "durationchange": + // Intentional fall-through + case "emptied": + // Intentional fall-through + case "loadedmetadata": { + this.updatePictureInPictureToggleDisplay(); + break; + } + } + }, + + updatePictureInPictureToggleDisplay() { + if ( + this.pipToggleEnabled && + VideoControlsWidget.shouldShowPictureInPictureToggle( + this.prefs, + this.video, + this.reflowedDimensions + ) + ) { + this.pictureInPictureToggle.hidden = false; + VideoControlsWidget.setupToggle( + this.prefs, + this.pictureInPictureToggle, + this.reflowedDimensions + ); + } else { + this.pictureInPictureToggle.hidden = true; + } + }, + + init(shadowRoot, prefs) { + this.shadowRoot = shadowRoot; + this.prefs = prefs; + this.video = shadowRoot.host; + this.videocontrols = shadowRoot.firstChild; + this.document = this.videocontrols.ownerDocument; + this.window = this.document.defaultView; + this.shadowRoot = shadowRoot; + + this.pictureInPictureToggle = this.shadowRoot.getElementById( + "pictureInPictureToggle" + ); + + if (this.document.fullscreenElement) { + this.videocontrols.setAttribute("inDOMFullscreen", true); + } + + // Default the Picture-in-Picture toggle button to being hidden. We might unhide it + // later if we determine that this video is qualified to show it. + this.pictureInPictureToggle.hidden = true; + + if (this.video.readyState >= this.video.HAVE_METADATA) { + // According to the spec[1], at the HAVE_METADATA (or later) state, we know + // the video duration and dimensions, which means we can calculate whether or + // not to show the Picture-in-Picture toggle now. + // + // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata + this.updatePictureInPictureToggleDisplay(); + } + + this.document.addEventListener("fullscreenchange", this, { + capture: true, + }); + + this.video.addEventListener("emptied", this); + this.video.addEventListener("loadedmetadata", this); + this.video.addEventListener("durationchange", this); + this.videocontrols.addEventListener("resizevideocontrols", this); + }, + + terminate() { + this.document.removeEventListener("fullscreenchange", this, { + capture: true, + }); + + this.video.removeEventListener("emptied", this); + this.video.removeEventListener("loadedmetadata", this); + this.video.removeEventListener("durationchange", this); + this.videocontrols.removeEventListener("resizevideocontrols", this); + }, + + updateReflowedDimensions() { + this.reflowedDimensions.videoHeight = this.video.clientHeight; + this.reflowedDimensions.videoWidth = this.video.clientWidth; + this.reflowedDimensions.videocontrolsWidth = + this.videocontrols.clientWidth; + }, + + reflowedDimensions: { + // Set the dimensions to intrinsic <video> dimensions before the first + // update. + videoHeight: 150, + videoWidth: 300, + videocontrolsWidth: 0, + }, + + get pipToggleEnabled() { + return ( + this.prefs[ + "media.videocontrols.picture-in-picture.video-toggle.enabled" + ] && this.prefs["media.videocontrols.picture-in-picture.enabled"] + ); + }, + }; + this.Utils.init(this.shadowRoot, this.prefs); + } + + elementStateMatches(element) { + return true; + } + + teardown() { + this.Utils.terminate(); + } + + onPrefChange(prefName, prefValue) { + this.prefs[prefName] = prefValue; + this.Utils.updatePictureInPictureToggleDisplay(); + } + + generateContent() { + const parser = new this.window.DOMParser(); + let parserDoc = parser.parseFromString( + `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none"> + <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" /> + <link rel="stylesheet" href="chrome://global/skin/media/pipToggle.css" /> + + <div id="controlsContainer" class="controlsContainer" role="none"> + <div class="controlsOverlay stackItem"> + <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true"> + <div class="pip-small clickable"></div> + <div class="pip-expanded clickable"> + <span class="pip-icon-label clickable"> + <span class="pip-icon"></span> + <span class="pip-label" data-l10n-id="videocontrols-picture-in-picture-toggle-label2"></span> + </span> + <div class="pip-explainer clickable" data-l10n-id="videocontrols-picture-in-picture-explainer3"></div> + </div> + <div class="pip-icon"></div> + </button> + </div> + </div> + </div>`, + "application/xml" + ); + this.shadowRoot.importNodeAndAppendChildAt( + this.shadowRoot, + parserDoc.documentElement, + true + ); + this.l10n = new this.window.DOMLocalization([ + "branding/brand.ftl", + "toolkit/global/videocontrols.ftl", + ]); + this.l10n.connectRoot(this.shadowRoot); + this.l10n.translateRoots(); + } +}; diff --git a/toolkit/content/widgets/wizard.js b/toolkit/content/widgets/wizard.js new file mode 100644 index 0000000000..6eb4bcb517 --- /dev/null +++ b/toolkit/content/widgets/wizard.js @@ -0,0 +1,651 @@ +/* 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 { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + + // Note: MozWizard currently supports adding, but not removing MozWizardPage + // children. + class MozWizard extends MozXULElement { + constructor() { + super(); + + // About this._accessMethod: + // There are two possible access methods: "sequential" and "random". + // "sequential" causes the MozWizardPage's to be displayed in the order + // that they are added to the DOM. + // The "random" method name is a bit misleading since the pages aren't + // displayed in a random order. Instead, each MozWizardPage must have + // a "next" attribute containing the id of the MozWizardPage that should + // be loaded next. + this._accessMethod = null; + this._currentPage = null; + this._canAdvance = true; + this._canRewind = false; + this._hasLoaded = false; + this._hasStarted = false; // Whether any MozWizardPage has been shown yet + this._wizardButtonsReady = false; + this.pageCount = 0; + this._pageStack = []; + + this._bundle = Services.strings.createBundle( + "chrome://global/locale/wizard.properties" + ); + + this.addEventListener( + "keypress", + event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + this._hitEnter(event); + } else if ( + event.keyCode == KeyEvent.DOM_VK_ESCAPE && + !event.defaultPrevented + ) { + this.cancel(); + } + }, + { mozSystemGroup: true } + ); + + /* + XXX(ntim): We import button.css here for the wizard-buttons children + This won't be needed after bug 1624888. + */ + this.attachShadow({ mode: "open" }).appendChild( + MozXULElement.parseXULToFragment(` + <html:link rel="stylesheet" href="chrome://global/skin/button.css"/> + <html:link rel="stylesheet" href="chrome://global/skin/wizard.css"/> + <hbox class="wizard-header"></hbox> + <html:slot name="wizardpage" class="wizard-page-box" style="display: grid; flex: 1;"/> + <html:slot/> + <wizard-buttons class="wizard-buttons"></wizard-buttons> + `) + ); + this.initializeAttributeInheritance(); + + this._wizardButtons = this.shadowRoot.querySelector(".wizard-buttons"); + + this._wizardHeader = this.shadowRoot.querySelector(".wizard-header"); + this._wizardHeader.appendChild( + MozXULElement.parseXULToFragment( + AppConstants.platform == "macosx" + ? `<stack class="wizard-header-stack" flex="1"> + <vbox class="wizard-header-box-1"> + <vbox class="wizard-header-box-text"> + <label class="wizard-header-label"/> + </vbox> + </vbox> + <hbox class="wizard-header-box-icon"> + <spacer flex="1"/> + <image class="wizard-header-icon"/> + </hbox> + </stack>` + : `<hbox class="wizard-header-box-1" flex="1"> + <vbox class="wizard-header-box-text" flex="1"> + <label class="wizard-header-label"/> + <label class="wizard-header-description"/> + </vbox> + <image class="wizard-header-icon"/> + </hbox>` + ) + ); + } + + static get inheritedAttributes() { + return { + ".wizard-buttons": "pagestep,firstpage,lastpage", + }; + } + + connectedCallback() { + if (document.l10n) { + document.l10n.connectRoot(this.shadowRoot); + } + document.documentElement.setAttribute("role", "dialog"); + document.documentElement.classList.add("wizard-window"); + this._maybeStartWizard(); + + window.addEventListener("close", event => { + if (this.cancel()) { + event.preventDefault(); + } + }); + + // Give focus to the first focusable element in the wizard, do it after + // onload completes, see bug 103197. + window.addEventListener("load", () => + window.setTimeout(() => { + this._hasLoaded = true; + if (!document.commandDispatcher.focusedElement) { + document.commandDispatcher.advanceFocusIntoSubtree(this); + } + try { + let button = this._wizardButtons.defaultButton; + if (button) { + window.notifyDefaultButtonLoaded(button); + } + } catch (e) {} + }, 0) + ); + } + + set title(val) { + document.title = val; + } + + get title() { + return document.title; + } + + set canAdvance(val) { + this.getButton("next").disabled = !val; + this._canAdvance = val; + } + + get canAdvance() { + return this._canAdvance; + } + + set canRewind(val) { + this.getButton("back").disabled = !val; + this._canRewind = val; + } + + get canRewind() { + return this._canRewind; + } + + get pageStep() { + return this._pageStack.length; + } + + get wizardPages() { + return this.getElementsByTagNameNS(XUL_NS, "wizardpage"); + } + + set currentPage(val) { + if (!val) { + return; + } + + this._currentPage?.classList.remove("selected"); + val.classList.add("selected"); + + this._currentPage = val; + + // Setting this attribute allows wizard's clients to dynamically + // change the styles of each page based on purpose of the page. + this.setAttribute("currentpageid", val.pageid); + + this._initCurrentPage(); + + this._advanceFocusToPage(val); + + this._fireEvent(val, "pageshow"); + } + + get currentPage() { + return this._currentPage; + } + + set pageIndex(val) { + if (val < 0 || val >= this.pageCount) { + return; + } + + var page = this.wizardPages[val]; + this._pageStack[this._pageStack.length - 1] = page; + this.currentPage = page; + } + + get pageIndex() { + return this._currentPage ? this._currentPage.pageIndex : -1; + } + + get onFirstPage() { + return this._pageStack.length == 1; + } + + get onLastPage() { + var cp = this.currentPage; + return ( + cp && + ((this._accessMethod == "sequential" && + cp.pageIndex == this.pageCount - 1) || + (this._accessMethod == "random" && cp.next == "")) + ); + } + + getButton(aDlgType) { + return this._wizardButtons.getButton(aDlgType); + } + + getPageById(aPageId) { + var els = this.getElementsByAttribute("pageid", aPageId); + return els.item(0); + } + + extra1() { + if (this.currentPage) { + this._fireEvent(this.currentPage, "extra1"); + } + } + + extra2() { + if (this.currentPage) { + this._fireEvent(this.currentPage, "extra2"); + } + } + + rewind() { + if (!this.canRewind) { + return; + } + + if (this.currentPage && !this._fireEvent(this.currentPage, "pagehide")) { + return; + } + + if ( + this.currentPage && + !this._fireEvent(this.currentPage, "pagerewound") + ) { + return; + } + + if (!this._fireEvent(this, "wizardback")) { + return; + } + + this._pageStack.pop(); + this.currentPage = this._pageStack[this._pageStack.length - 1]; + this.setAttribute("pagestep", this._pageStack.length); + } + + advance(aPageId) { + if (!this.canAdvance) { + return; + } + + if (this.currentPage && !this._fireEvent(this.currentPage, "pagehide")) { + return; + } + + if ( + this.currentPage && + !this._fireEvent(this.currentPage, "pageadvanced") + ) { + return; + } + + if (this.onLastPage && !aPageId) { + if (this._fireEvent(this, "wizardfinish")) { + window.setTimeout(function () { + window.close(); + }, 1); + } + } else { + if (!this._fireEvent(this, "wizardnext")) { + return; + } + + let page; + if (aPageId) { + page = this.getPageById(aPageId); + } else if (this.currentPage) { + if (this._accessMethod == "random") { + page = this.getPageById(this.currentPage.next); + } else { + page = this.wizardPages[this.currentPage.pageIndex + 1]; + } + } else { + page = this.wizardPages[0]; + } + + if (page) { + this._pageStack.push(page); + this.setAttribute("pagestep", this._pageStack.length); + + this.currentPage = page; + } + } + } + + goTo(aPageId) { + var page = this.getPageById(aPageId); + if (page) { + this._pageStack[this._pageStack.length - 1] = page; + this.currentPage = page; + } + } + + cancel() { + if (!this._fireEvent(this, "wizardcancel")) { + return true; + } + + window.close(); + window.setTimeout(function () { + window.close(); + }, 1); + return false; + } + + _initCurrentPage() { + this.canRewind = !this.onFirstPage; + this.setAttribute("firstpage", String(this.onFirstPage)); + if (AppConstants.platform == "linux") { + this.getButton("back").hidden = this.onFirstPage; + } + + if (this.onLastPage) { + this.canAdvance = true; + this.setAttribute("lastpage", "true"); + } else { + this.setAttribute("lastpage", "false"); + } + + this._adjustWizardHeader(); + this._wizardButtons.onPageChange(); + } + + _advanceFocusToPage(aPage) { + if (!this._hasLoaded) { + return; + } + + // XXX: it'd be correct to advance focus into the panel, however we can't do + // it until bug 1558990 is fixed, so moving the focus into a wizard itsef + // as a workaround - it's same behavior but less optimal. + document.commandDispatcher.advanceFocusIntoSubtree(this); + + // if advanceFocusIntoSubtree tries to focus one of our + // dialog buttons, then remove it and put it on the root + var focused = document.commandDispatcher.focusedElement; + if (focused && focused.hasAttribute("dlgtype")) { + this.focus(); + } + } + + _registerPage(aPage) { + aPage.pageIndex = this.pageCount; + this.pageCount += 1; + if (!this._accessMethod) { + this._accessMethod = aPage.next == "" ? "sequential" : "random"; + } + if (!this._maybeStartWizard() && this._hasStarted) { + // If the wizard has already started, adding a page might require + // updating elements to reflect that (ex: changing the Finish button to + // the Next button). + this._initCurrentPage(); + } + } + + _onWizardButtonsReady() { + this._wizardButtonsReady = true; + this._maybeStartWizard(); + } + + _maybeStartWizard() { + if ( + !this._hasStarted && + this.isConnected && + this._wizardButtonsReady && + this.pageCount > 0 + ) { + this._hasStarted = true; + this.advance(); + return true; + } + return false; + } + + _adjustWizardHeader() { + let labelElement = this._wizardHeader.querySelector( + ".wizard-header-label" + ); + // First deal with fluent. Ideally, we'd stop supporting anything else, + // but some comm-central consumers still use DTDs. (bug 1627049). + // Removing the DTD support is bug 1627051. + if (this.currentPage.hasAttribute("data-header-label-id")) { + let id = this.currentPage.getAttribute("data-header-label-id"); + document.l10n.setAttributes(labelElement, id); + } else { + // Otherwise, make sure we remove any fluent IDs leftover: + if (labelElement.hasAttribute("data-l10n-id")) { + labelElement.removeAttribute("data-l10n-id"); + } + // And use the label attribute or the default: + var label = this.currentPage.getAttribute("label") || ""; + if (!label && this.onFirstPage && this._bundle) { + if (AppConstants.platform == "macosx") { + label = this._bundle.GetStringFromName("default-first-title-mac"); + } else { + label = this._bundle.formatStringFromName("default-first-title", [ + this.title, + ]); + } + } else if (!label && this.onLastPage && this._bundle) { + if (AppConstants.platform == "macosx") { + label = this._bundle.GetStringFromName("default-last-title-mac"); + } else { + label = this._bundle.formatStringFromName("default-last-title", [ + this.title, + ]); + } + } + labelElement.textContent = label; + } + let headerDescEl = this._wizardHeader.querySelector( + ".wizard-header-description" + ); + if (headerDescEl) { + headerDescEl.textContent = this.currentPage.getAttribute("description"); + } + } + + _hitEnter(evt) { + if (!evt.defaultPrevented) { + this.advance(); + } + } + + _fireEvent(aTarget, aType) { + var event = document.createEvent("Events"); + event.initEvent(aType, true, true); + + // handle dom event handlers + return aTarget.dispatchEvent(event); + } + } + + customElements.define("wizard", MozWizard); + + class MozWizardPage extends MozXULElement { + constructor() { + super(); + this.pageIndex = -1; + } + connectedCallback() { + this.setAttribute("slot", "wizardpage"); + + let wizard = this.closest("wizard"); + if (wizard) { + wizard._registerPage(this); + } + } + get pageid() { + return this.getAttribute("pageid"); + } + set pageid(val) { + this.setAttribute("pageid", val); + } + get next() { + return this.getAttribute("next"); + } + set next(val) { + this.setAttribute("next", val); + this.parentNode._accessMethod = "random"; + } + } + + customElements.define("wizardpage", MozWizardPage); + + class MozWizardButtons extends MozXULElement { + connectedCallback() { + this._wizard = this.getRootNode().host; + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + + MozXULElement.insertFTLIfNeeded("toolkit/global/wizard.ftl"); + + this._wizardButtonDeck = this.querySelector(".wizard-next-deck"); + + this.initializeAttributeInheritance(); + + const listeners = [ + ["back", () => this._wizard.rewind()], + ["next", () => this._wizard.advance()], + ["finish", () => this._wizard.advance()], + ["cancel", () => this._wizard.cancel()], + ["extra1", () => this._wizard.extra1()], + ["extra2", () => this._wizard.extra2()], + ]; + for (let [name, listener] of listeners) { + let btn = this.getButton(name); + if (btn) { + btn.addEventListener("command", listener); + } + } + + this._wizard._onWizardButtonsReady(); + } + + static get inheritedAttributes() { + return AppConstants.platform == "macosx" + ? { + "[dlgtype='next']": "hidden=lastpage", + } + : null; + } + + static get markup() { + if (AppConstants.platform == "macosx") { + return ` + <vbox flex="1"> + <hbox class="wizard-buttons-btm"> + <button class="wizard-button" dlgtype="extra1" hidden="true"/> + <button class="wizard-button" dlgtype="extra2" hidden="true"/> + <button data-l10n-id="wizard-macos-button-cancel" + class="wizard-button" dlgtype="cancel"/> + <spacer flex="1"/> + <button data-l10n-id="wizard-macos-button-back" + class="wizard-button wizard-nav-button" dlgtype="back"/> + <button data-l10n-id="wizard-macos-button-next" + class="wizard-button wizard-nav-button" dlgtype="next" + default="true" /> + <button data-l10n-id="wizard-macos-button-finish" class="wizard-button" + dlgtype="finish" default="true" /> + </hbox> + </vbox>`; + } + + let buttons = + AppConstants.platform == "linux" + ? ` + <button data-l10n-id="wizard-linux-button-cancel" + class="wizard-button" + dlgtype="cancel"/> + <spacer style="width: 24px;"/> + <button data-l10n-id="wizard-linux-button-back" + class="wizard-button" dlgtype="back"/> + <deck class="wizard-next-deck"> + <hbox> + <button data-l10n-id="wizard-linux-button-finish" + class="wizard-button" + dlgtype="finish" default="true" flex="1"/> + </hbox> + <hbox> + <button data-l10n-id="wizard-linux-button-next" + class="wizard-button" dlgtype="next" + default="true" flex="1"/> + </hbox> + </deck>` + : ` + <button data-l10n-id="wizard-win-button-back" + class="wizard-button" dlgtype="back"/> + <deck class="wizard-next-deck"> + <hbox> + <button data-l10n-id="wizard-win-button-finish" + class="wizard-button" + dlgtype="finish" default="true" flex="1"/> + </hbox> + <hbox> + <button data-l10n-id="wizard-win-button-next" + class="wizard-button" dlgtype="next" + default="true" flex="1"/> + </hbox> + </deck> + <button data-l10n-id="wizard-win-button-cancel" + class="wizard-button" + dlgtype="cancel"/>`; + + return ` + <vbox class="wizard-buttons-box-1" flex="1"> + <separator class="wizard-buttons-separator groove"/> + <hbox class="wizard-buttons-box-2"> + <button class="wizard-button" dlgtype="extra1" hidden="true"/> + <button class="wizard-button" dlgtype="extra2" hidden="true"/> + <spacer flex="1" anonid="spacer"/> + ${buttons} + </hbox> + </vbox>`; + } + + onPageChange() { + if (AppConstants.platform == "macosx") { + this.getButton("finish").hidden = !( + this.getAttribute("lastpage") == "true" + ); + } else if (this.getAttribute("lastpage") == "true") { + this._wizardButtonDeck.selectedIndex = 0; + } else { + this._wizardButtonDeck.selectedIndex = 1; + } + } + + getButton(type) { + return this.querySelector(`[dlgtype="${type}"]`); + } + + get defaultButton() { + let buttons = this._wizardButtonDeck.selectedPanel.getElementsByTagNameNS( + XUL_NS, + "button" + ); + for (let i = 0; i < buttons.length; i++) { + if ( + buttons[i].getAttribute("default") == "true" && + !buttons[i].hidden && + !buttons[i].disabled + ) { + return buttons[i]; + } + } + return null; + } + } + + customElements.define("wizard-buttons", MozWizardButtons); +} |