From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- toolkit/content/widgets/arrowscrollbox.js | 868 ++++ toolkit/content/widgets/autocomplete-input.js | 647 +++ toolkit/content/widgets/autocomplete-popup.js | 637 +++ .../content/widgets/autocomplete-richlistitem.js | 873 ++++ toolkit/content/widgets/browser-custom-element.js | 1959 +++++++++ toolkit/content/widgets/button.js | 312 ++ toolkit/content/widgets/calendar.js | 485 +++ toolkit/content/widgets/checkbox.js | 83 + toolkit/content/widgets/datekeeper.js | 424 ++ toolkit/content/widgets/datepicker.js | 597 +++ toolkit/content/widgets/datetimebox.css | 107 + toolkit/content/widgets/datetimebox.js | 1546 +++++++ toolkit/content/widgets/dialog.js | 545 +++ toolkit/content/widgets/docs/index.rst | 10 + toolkit/content/widgets/docs/ua_widget.rst | 58 + toolkit/content/widgets/editor.js | 203 + toolkit/content/widgets/findbar.js | 1379 ++++++ toolkit/content/widgets/general.js | 27 + toolkit/content/widgets/infobar.css | 99 + toolkit/content/widgets/lit-utils.mjs | 138 + toolkit/content/widgets/mach_commands.py | 208 + toolkit/content/widgets/marquee.css | 26 + toolkit/content/widgets/marquee.js | 417 ++ toolkit/content/widgets/menu.js | 493 +++ toolkit/content/widgets/menulist.js | 417 ++ toolkit/content/widgets/menupopup.js | 297 ++ toolkit/content/widgets/message-bar.css | 219 + toolkit/content/widgets/message-bar.js | 91 + .../widgets/moz-button-group/moz-button-group.css | 16 + .../widgets/moz-button-group/moz-button-group.mjs | 105 + .../moz-button-group/moz-button-group.stories.mjs | 57 + toolkit/content/widgets/moz-card/moz-card.css | 180 + toolkit/content/widgets/moz-card/moz-card.mjs | 112 + .../content/widgets/moz-card/moz-card.stories.mjs | 88 + .../widgets/moz-five-star/moz-five-star.css | 46 + .../widgets/moz-five-star/moz-five-star.mjs | 71 + .../moz-five-star/moz-five-star.stories.mjs | 51 + toolkit/content/widgets/moz-input-box.js | 224 + .../content/widgets/moz-label/README.stories.md | 20 + toolkit/content/widgets/moz-label/moz-label.css | 8 + toolkit/content/widgets/moz-label/moz-label.mjs | 298 ++ .../widgets/moz-label/moz-label.stories.mjs | 86 + .../widgets/moz-message-bar/README.stories.md | 67 + .../widgets/moz-message-bar/moz-message-bar.css | 211 + .../widgets/moz-message-bar/moz-message-bar.mjs | 176 + .../moz-message-bar/moz-message-bar.stories.mjs | 123 + .../widgets/moz-support-link/moz-support-link.mjs | 129 + .../moz-support-link/moz-support-link.stories.mjs | 67 + .../content/widgets/moz-toggle/README.stories.md | 80 + toolkit/content/widgets/moz-toggle/moz-toggle.css | 198 + toolkit/content/widgets/moz-toggle/moz-toggle.mjs | 133 + .../widgets/moz-toggle/moz-toggle.stories.mjs | 96 + toolkit/content/widgets/named-deck.js | 398 ++ toolkit/content/widgets/notificationbox.js | 831 ++++ .../content/widgets/panel-list/README.stories.md | 231 + toolkit/content/widgets/panel-list/panel-item.css | 96 + toolkit/content/widgets/panel-list/panel-list.css | 59 + toolkit/content/widgets/panel-list/panel-list.js | 836 ++++ .../widgets/panel-list/panel-list.stories.mjs | 147 + toolkit/content/widgets/panel.js | 293 ++ toolkit/content/widgets/popupnotification.js | 169 + toolkit/content/widgets/radio.js | 567 +++ toolkit/content/widgets/richlistbox.js | 1040 +++++ toolkit/content/widgets/search-textbox.js | 262 ++ toolkit/content/widgets/spinner.js | 637 +++ toolkit/content/widgets/stringbundle.js | 73 + toolkit/content/widgets/tabbox.js | 884 ++++ toolkit/content/widgets/text.js | 389 ++ toolkit/content/widgets/textrecognition.js | 366 ++ toolkit/content/widgets/timekeeper.js | 445 ++ toolkit/content/widgets/timepicker.js | 291 ++ toolkit/content/widgets/toolbarbutton.js | 231 + toolkit/content/widgets/tree.js | 1705 ++++++++ toolkit/content/widgets/vendor/lit.all.mjs | 4467 ++++++++++++++++++++ toolkit/content/widgets/videocontrols.js | 3365 +++++++++++++++ toolkit/content/widgets/wizard.js | 651 +++ 76 files changed, 34240 insertions(+) create mode 100644 toolkit/content/widgets/arrowscrollbox.js create mode 100644 toolkit/content/widgets/autocomplete-input.js create mode 100644 toolkit/content/widgets/autocomplete-popup.js create mode 100644 toolkit/content/widgets/autocomplete-richlistitem.js create mode 100644 toolkit/content/widgets/browser-custom-element.js create mode 100644 toolkit/content/widgets/button.js create mode 100644 toolkit/content/widgets/calendar.js create mode 100644 toolkit/content/widgets/checkbox.js create mode 100644 toolkit/content/widgets/datekeeper.js create mode 100644 toolkit/content/widgets/datepicker.js create mode 100644 toolkit/content/widgets/datetimebox.css create mode 100644 toolkit/content/widgets/datetimebox.js create mode 100644 toolkit/content/widgets/dialog.js create mode 100644 toolkit/content/widgets/docs/index.rst create mode 100644 toolkit/content/widgets/docs/ua_widget.rst create mode 100644 toolkit/content/widgets/editor.js create mode 100644 toolkit/content/widgets/findbar.js create mode 100644 toolkit/content/widgets/general.js create mode 100644 toolkit/content/widgets/infobar.css create mode 100644 toolkit/content/widgets/lit-utils.mjs create mode 100644 toolkit/content/widgets/mach_commands.py create mode 100644 toolkit/content/widgets/marquee.css create mode 100644 toolkit/content/widgets/marquee.js create mode 100644 toolkit/content/widgets/menu.js create mode 100644 toolkit/content/widgets/menulist.js create mode 100644 toolkit/content/widgets/menupopup.js create mode 100644 toolkit/content/widgets/message-bar.css create mode 100644 toolkit/content/widgets/message-bar.js create mode 100644 toolkit/content/widgets/moz-button-group/moz-button-group.css create mode 100644 toolkit/content/widgets/moz-button-group/moz-button-group.mjs create mode 100644 toolkit/content/widgets/moz-button-group/moz-button-group.stories.mjs create mode 100644 toolkit/content/widgets/moz-card/moz-card.css create mode 100644 toolkit/content/widgets/moz-card/moz-card.mjs create mode 100644 toolkit/content/widgets/moz-card/moz-card.stories.mjs create mode 100644 toolkit/content/widgets/moz-five-star/moz-five-star.css create mode 100644 toolkit/content/widgets/moz-five-star/moz-five-star.mjs create mode 100644 toolkit/content/widgets/moz-five-star/moz-five-star.stories.mjs create mode 100644 toolkit/content/widgets/moz-input-box.js create mode 100644 toolkit/content/widgets/moz-label/README.stories.md create mode 100644 toolkit/content/widgets/moz-label/moz-label.css create mode 100644 toolkit/content/widgets/moz-label/moz-label.mjs create mode 100644 toolkit/content/widgets/moz-label/moz-label.stories.mjs create mode 100644 toolkit/content/widgets/moz-message-bar/README.stories.md create mode 100644 toolkit/content/widgets/moz-message-bar/moz-message-bar.css create mode 100644 toolkit/content/widgets/moz-message-bar/moz-message-bar.mjs create mode 100644 toolkit/content/widgets/moz-message-bar/moz-message-bar.stories.mjs create mode 100644 toolkit/content/widgets/moz-support-link/moz-support-link.mjs create mode 100644 toolkit/content/widgets/moz-support-link/moz-support-link.stories.mjs create mode 100644 toolkit/content/widgets/moz-toggle/README.stories.md create mode 100644 toolkit/content/widgets/moz-toggle/moz-toggle.css create mode 100644 toolkit/content/widgets/moz-toggle/moz-toggle.mjs create mode 100644 toolkit/content/widgets/moz-toggle/moz-toggle.stories.mjs create mode 100644 toolkit/content/widgets/named-deck.js create mode 100644 toolkit/content/widgets/notificationbox.js create mode 100644 toolkit/content/widgets/panel-list/README.stories.md create mode 100644 toolkit/content/widgets/panel-list/panel-item.css create mode 100644 toolkit/content/widgets/panel-list/panel-list.css create mode 100644 toolkit/content/widgets/panel-list/panel-list.js create mode 100644 toolkit/content/widgets/panel-list/panel-list.stories.mjs create mode 100644 toolkit/content/widgets/panel.js create mode 100644 toolkit/content/widgets/popupnotification.js create mode 100644 toolkit/content/widgets/radio.js create mode 100644 toolkit/content/widgets/richlistbox.js create mode 100644 toolkit/content/widgets/search-textbox.js create mode 100644 toolkit/content/widgets/spinner.js create mode 100644 toolkit/content/widgets/stringbundle.js create mode 100644 toolkit/content/widgets/tabbox.js create mode 100644 toolkit/content/widgets/text.js create mode 100644 toolkit/content/widgets/textrecognition.js create mode 100644 toolkit/content/widgets/timekeeper.js create mode 100644 toolkit/content/widgets/timepicker.js create mode 100644 toolkit/content/widgets/toolbarbutton.js create mode 100644 toolkit/content/widgets/tree.js create mode 100644 toolkit/content/widgets/vendor/lit.all.mjs create mode 100644 toolkit/content/widgets/videocontrols.js create mode 100644 toolkit/content/widgets/wizard.js (limited to 'toolkit/content/widgets') diff --git a/toolkit/content/widgets/arrowscrollbox.js b/toolkit/content/widgets/arrowscrollbox.js new file mode 100644 index 0000000000..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 ` + + + + + + + + + + + + `; + } + + 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 and + in document for other usages */ + if (!popup) { + popup = document.createXULElement("panel", { + is: "autocomplete-richlistbox-popup", + }); + popup.setAttribute("type", "autocomplete-richlistbox"); + popup.setAttribute("noautofocus", "true"); + + if (!this._popupset) { + this._popupset = document.createXULElement("popupset"); + document.documentElement.appendChild(this._popupset); + } + + this._popupset.appendChild(popup); + } + popup.mInput = this; + + return (this._popup = popup); + } + + get popupElement() { + return this.popup; + } + + get controller() { + return this.mController; + } + + set popupOpen(val) { + if (val) { + this.openPopup(); + } else { + this.closePopup(); + } + } + + get popupOpen() { + return this.popup.popupOpen; + } + + set disableAutoComplete(val) { + this.setAttribute("disableautocomplete", val); + } + + 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 ` + + `; + } + + /** + * 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 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 ` + + + + + + + + + + + + `; + } + + get _typeIcon() { + return this.querySelector(".ac-type-icon"); + } + + get _titleText() { + return this.querySelector(".ac-title-text"); + } + + get _separator() { + return this.querySelector(".ac-separator"); + } + + get _urlText() { + return this.querySelector(".ac-url-text"); + } + + get _stringBundle() { + if (!this.__stringBundle) { + this.__stringBundle = Services.strings.createBundle( + "chrome://global/locale/autocomplete.properties" + ); + } + return this.__stringBundle; + } + + get boundaryCutoff() { + if (!this._boundaryCutoff) { + this._boundaryCutoff = Services.prefs.getIntPref( + "toolkit.autocomplete.richBoundaryCutoff" + ); + } + return this._boundaryCutoff; + } + + _cleanup() { + this.removeAttribute("url"); + this.removeAttribute("image"); + this.removeAttribute("title"); + this.removeAttribute("text"); + } + + _onOverflow() { + this._inOverflow = true; + this._handleOverflow(); + } + + _onUnderflow() { + this._inOverflow = false; + this._handleOverflow(); + } + + _getBoundaryIndices(aText, aSearchTokens) { + // Short circuit for empty search ([""] == "") + if (aSearchTokens == "") { + return [0, aText.length]; + } + + // Find which regions of text match the search terms + let regions = []; + for (let search of Array.prototype.slice.call(aSearchTokens)) { + let matchIndex = -1; + let searchLen = search.length; + + // Find all matches of the search terms, but stop early for perf + let lowerText = aText.substr(0, this.boundaryCutoff).toLowerCase(); + while ((matchIndex = lowerText.indexOf(search, matchIndex + 1)) >= 0) { + regions.push([matchIndex, matchIndex + searchLen]); + } + } + + // Sort the regions by start position then end position + regions = regions.sort((a, b) => { + let start = a[0] - b[0]; + return start == 0 ? a[1] - b[1] : start; + }); + + // Generate the boundary indices from each region + let start = 0; + let end = 0; + let boundaries = []; + let len = regions.length; + for (let i = 0; i < len; i++) { + // We have a new boundary if the start of the next is past the end + let region = regions[i]; + if (region[0] > end) { + // First index is the beginning of match + boundaries.push(start); + // Second index is the beginning of non-match + boundaries.push(end); + + // Track the new region now that we've stored the previous one + start = region[0]; + } + + // Push back the end index for the current or new region + end = Math.max(end, region[1]); + } + + // Add the last region + boundaries.push(start); + boundaries.push(end); + + // Put on the end boundary if necessary + if (end < aText.length) { + boundaries.push(aText.length); + } + + // Skip the first item because it's always 0 + return boundaries.slice(1); + } + + _getSearchTokens(aSearch) { + let search = aSearch.toLowerCase(); + return search.split(/\s+/); + } + + _setUpDescription(aDescriptionElement, aText) { + // Get rid of all previous text + if (!aDescriptionElement) { + return; + } + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + // Get the indices that separate match and non-match text + let search = this.getAttribute("text"); + let tokens = this._getSearchTokens(search); + let indices = this._getBoundaryIndices(aText, tokens); + + this._appendDescriptionSpans( + indices, + aText, + aDescriptionElement, + aDescriptionElement + ); + } + + _appendDescriptionSpans( + indices, + text, + spansParentElement, + descriptionElement + ) { + let next; + let start = 0; + let len = indices.length; + // Even indexed boundaries are matches, so skip the 0th if it's empty + for (let i = indices[0] == 0 ? 1 : 0; i < len; i++) { + next = indices[i]; + let spanText = text.substr(start, next - start); + start = next; + + if (i % 2 == 0) { + // Emphasize the text for even indices + let span = spansParentElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + this._setUpEmphasisSpan(span, descriptionElement); + span.textContent = spanText; + } else { + // Otherwise, it's plain text + spansParentElement.appendChild(document.createTextNode(spanText)); + } + } + } + + _setUpEmphasisSpan(aSpan, aDescriptionElement) { + aSpan.classList.add("ac-emphasize-text"); + switch (aDescriptionElement) { + case this._titleText: + aSpan.classList.add("ac-emphasize-text-title"); + break; + case this._urlText: + aSpan.classList.add("ac-emphasize-text-url"); + break; + } + } + + /** + * This will generate an array of emphasis pairs for use with + * _setUpEmphasisedSections(). Each pair is a tuple (array) that + * represents a block of text - containing the text of that block, and a + * boolean for whether that block should have an emphasis styling applied + * to it. + * + * These pairs are generated by parsing a localised string (aSourceString) + * with parameters, in the format that is used by + * nsIStringBundle.formatStringFromName(): + * + * "textA %1$S textB textC %2$S" + * + * Or: + * + * "textA %S" + * + * Where "%1$S", "%2$S", and "%S" are intended to be replaced by provided + * replacement strings. These are specified an array of tuples + * (aReplacements), each containing the replacement text and a boolean for + * whether that text should have an emphasis styling applied. This is used + * as a 1-based array - ie, "%1$S" is replaced by the item in the first + * index of aReplacements, "%2$S" by the second, etc. "%S" will always + * match the first index. + */ + _generateEmphasisPairs(aSourceString, aReplacements) { + let pairs = []; + + // Split on %S, %1$S, %2$S, etc. ie: + // "textA %S" + // becomes ["textA ", "%S"] + // "textA %1$S textB textC %2$S" + // becomes ["textA ", "%1$S", " textB textC ", "%2$S"] + let parts = aSourceString.split(/(%(?:[0-9]+\$)?S)/); + + for (let part of parts) { + // The above regex will actually give us an empty string at the + // end - we don't want that, as we don't want to later generate an + // empty text node for it. + if (part.length === 0) { + continue; + } + + // Determine if this token is a replacement token or a normal text + // token. If it is a replacement token, we want to extract the + // numerical number. However, we still want to match on "$S". + let match = part.match(/^%(?:([0-9]+)\$)?S$/); + + if (match) { + // "%S" doesn't have a numerical number in it, but will always + // be assumed to be 1. Furthermore, the input string specifies + // these with a 1-based index, but we want a 0-based index. + let index = (match[1] || 1) - 1; + + if (index >= 0 && index < aReplacements.length) { + pairs.push([...aReplacements[index]]); + } + } else { + pairs.push([part]); + } + } + + return pairs; + } + + /** + * _setUpEmphasisedSections() has the same use as _setUpDescription, + * except instead of taking a string and highlighting given tokens, it takes + * an array of pairs generated by _generateEmphasisPairs(). This allows + * control over emphasising based on specific blocks of text, rather than + * search for substrings. + */ + _setUpEmphasisedSections(aDescriptionElement, aTextPairs) { + // Get rid of all previous text + while (aDescriptionElement.hasChildNodes()) { + aDescriptionElement.firstChild.remove(); + } + + for (let [text, emphasise] of aTextPairs) { + if (emphasise) { + let span = aDescriptionElement.appendChild( + document.createElementNS("http://www.w3.org/1999/xhtml", "span") + ); + span.textContent = text; + switch (emphasise) { + case "match": + this._setUpEmphasisSpan(span, aDescriptionElement); + break; + } + } else { + aDescriptionElement.appendChild(document.createTextNode(text)); + } + } + } + + _unescapeUrl(url) { + return Services.textToSubURI.unEscapeURIForUI(url); + } + + _reuseAcItem() { + this.collapsed = false; + + // The popup may have changed size between now and the last + // time the item was shown, so always handle over/underflow. + let dwu = window.windowUtils; + let popupWidth = dwu.getBoundsWithoutFlushing(this.parentNode).width; + if (!this._previousPopupWidth || this._previousPopupWidth != popupWidth) { + this._previousPopupWidth = popupWidth; + this.handleOverUnderflow(); + } + } + + _adjustAcItem() { + let originalUrl = this.getAttribute("ac-value"); + let title = this.getAttribute("ac-comment"); + this.setAttribute("url", originalUrl); + this.setAttribute("image", this.getAttribute("ac-image")); + this.setAttribute("title", title); + this.setAttribute("text", this.getAttribute("ac-text")); + + let type = this.getAttribute("originaltype"); + let types = new Set(type.split(/\s+/)); + // Remove types that should ultimately not be in the `type` string. + types.delete("autofill"); + type = [...types][0] || ""; + this.setAttribute("type", type); + + let displayUrl = this._unescapeUrl(originalUrl); + + // Show the domain as the title if we don't have a title. + if (!title) { + try { + let uri = Services.io.newURI(originalUrl); + // Not all valid URLs have a domain. + if (uri.host) { + title = uri.host; + } + } catch (e) {} + if (!title) { + title = displayUrl; + } + } + + if (Array.isArray(title)) { + this._setUpEmphasisedSections(this._titleText, title); + } else { + this._setUpDescription(this._titleText, title); + } + this._setUpDescription(this._urlText, displayUrl); + + // Removing the max-width may be jarring when the item is visible, but + // we have no other choice to properly crop the text. + // Removing max-widths may cause overflow or underflow events, that + // will set the _inOverflow property. In case both the old and the new + // text are overflowing, the overflow event won't happen, and we must + // enforce an _handleOverflow() call to update the max-widths. + let wasInOverflow = this._inOverflow; + this._removeMaxWidths(); + if (wasInOverflow && this._inOverflow) { + this._handleOverflow(); + } + } + + _removeMaxWidths() { + if (this._hasMaxWidths) { + this._titleText.style.removeProperty("max-width"); + this._urlText.style.removeProperty("max-width"); + this._hasMaxWidths = false; + } + } + + /** + * This method truncates the displayed strings as necessary. + */ + _handleOverflow() { + let itemRect = this.parentNode.getBoundingClientRect(); + let titleRect = this._titleText.getBoundingClientRect(); + let separatorRect = this._separator.getBoundingClientRect(); + let urlRect = this._urlText.getBoundingClientRect(); + let separatorURLWidth = separatorRect.width + urlRect.width; + + // Total width for the title and URL is the width of the item + // minus the start of the title text minus a little optional extra padding. + // This extra padding amount is basically arbitrary but keeps the text + // from getting too close to the popup's edge. + let dir = this.getAttribute("dir"); + let titleStart = + dir == "rtl" + ? itemRect.right - titleRect.right + : titleRect.left - itemRect.left; + + let popup = this.parentNode.parentNode; + let itemWidth = + itemRect.width - + titleStart - + popup.overflowPadding - + (popup.margins ? popup.margins.end : 0); + + let titleWidth = titleRect.width; + if (titleWidth + separatorURLWidth > itemWidth) { + // The percentage of the item width allocated to the title. + let titlePct = 0.66; + + let titleAvailable = itemWidth - separatorURLWidth; + let titleMaxWidth = Math.max(titleAvailable, itemWidth * titlePct); + if (titleWidth > titleMaxWidth) { + this._titleText.style.maxWidth = titleMaxWidth + "px"; + } + let urlMaxWidth = Math.max( + itemWidth - titleWidth, + itemWidth * (1 - titlePct) + ); + urlMaxWidth -= separatorRect.width; + this._urlText.style.maxWidth = urlMaxWidth + "px"; + this._hasMaxWidths = true; + } + } + + handleOverUnderflow() { + this._removeMaxWidths(); + this._handleOverflow(); + } + }; + + MozXULElement.implementCustomInterface( + MozElements.MozAutocompleteRichlistitem, + [Ci.nsIDOMXULSelectControlItemElement] + ); + + class MozAutocompleteRichlistitemInsecureWarning extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + + this.addEventListener("click", event => { + if (event.button != 0) { + return; + } + + let baseURL = Services.urlFormatter.formatURLPref( + "app.support.baseURL" + ); + window.openTrustedLinkIn(baseURL + "insecure-password", "tab", { + relatedToCurrent: true, + }); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + super.connectedCallback(); + + // Unlike other autocomplete items, the height of the insecure warning + // increases by wrapping. So "forceHandleUnderflow" is for container to + // recalculate an item's height and width. + this.classList.add("forceHandleUnderflow"); + } + + static get inheritedAttributes() { + return { + ".ac-type-icon": "selected,current,type", + ".ac-site-icon": "src=image,selected,type", + ".ac-title-text": "selected", + ".ac-separator": "selected,type", + ".ac-url": "selected", + ".ac-url-text": "selected", + }; + } + + static get markup() { + return ` + + + + + + + + + + + + + + + + `; + } + + get _learnMoreString() { + if (!this.__learnMoreString) { + this.__learnMoreString = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .GetStringFromName("insecureFieldWarningLearnMore"); + } + return this.__learnMoreString; + } + + /** + * Override _getSearchTokens to have the Learn More text emphasized + */ + _getSearchTokens(aSearch) { + return [this._learnMoreString.toLowerCase()]; + } + } + + class MozAutocompleteRichlistitemLoginsFooter extends MozElements.MozAutocompleteRichlistitem {} + + class MozAutocompleteImportableLearnMoreRichlistitem extends MozElements.MozAutocompleteRichlistitem { + constructor() { + super(); + MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl"); + } + + static get markup() { + return ` + + + + + + + + + + + + + + + + `; + } + + // Override to avoid clearing out fluent description. + _setUpDescription() {} + } + + class MozAutocompleteTwoLineRichlistitem extends MozElements.MozRichlistitem { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.textContent = ""; + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + this.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 ` +
+ +
+
+
+
+ +
+ `; + } + + _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 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 ` +
+ +
+
+
+
+
+ `; + } + + _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 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(` + + + `), + true + ); + Object.defineProperty(this, "buttonFragment", { value: frag }); + return frag; + } + + static get menuFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + + + + + + `), + true + ); + Object.defineProperty(this, "menuFragment", { value: frag }); + return frag; + } + + get _hasConnected() { + return this.querySelector(":scope > .button-box") != null; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + + let fragment; + if (this.type === "menu") { + fragment = MozButton.menuFragment; + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN && event.key != " ") { + return; + } + + this.open = true; + // Prevent page from scrolling on the space key. + if (event.key == " ") { + event.preventDefault(); + } + }); + } else { + fragment = this.constructor.buttonFragment; + } + + this.appendChild(fragment.cloneNode(true)); + this.initializeAttributeInheritance(); + 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} days: Data for days + * { + * {Date} dateObj + * {Number} content + * {Array} classNames + * {Boolean} enabled + * } + * {Array} weekHeaders: Data for weekHeaders + * { + * {Number} content + * {Array} classNames + * } + * } + */ + setProps(props) { + if (props.isVisible) { + // Transform the days and weekHeaders array for rendering + const days = props.days.map( + ({ dateObj, content, classNames, enabled }) => { + return { + dateObj, + textContent: this.state.getDayString(content), + className: classNames.join(" "), + enabled, + }; + } + ); + const weekHeaders = props.weekHeaders.map(({ content, classNames }) => { + return { + textContent: this.state.getWeekHeaderString(content), + className: classNames.join(" "), + }; + }); + // Update the DOM nodes states + this._render({ + elements: this.elements.daysView, + items: days, + prevState: this.state.days, + }); + this._render({ + elements: this.elements.weekHeaders, + items: weekHeaders, + prevState: this.state.weekHeaders, + }); + // Update the state to current and place keyboard focus + this.state.days = days; + this.state.weekHeaders = weekHeaders; + this.focusDay(); + } + }, + + /** + * Render the items onto the DOM nodes + * @param {Object} + * { + * {Array} elements + * {Array} items + * {Array} prevState: state of items from last render + * } + */ + _render({ elements, items, prevState }) { + 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} + */ + _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 ` + + + + + `; + } + + 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} 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} + * { + * {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} + * { + * {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} + * { + * {Date} dateObj + * {Number} content + * {Array} classNames + * {Boolean} enabled + * } + */ + getDays() { + const firstDayOfMonth = this._getFirstCalendarDate( + this.state.dateObj, + this.state.firstDayOfWeek + ); + const month = this.month; + let days = []; + + for (let i = 0; i < this.state.calViewSize; i++) { + const dateObj = this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + firstDayOfMonth.getUTCDate() + i + ); + + let classNames = []; + let enabled = true; + + const isValid = + dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE; + if (!isValid) { + classNames.push("out-of-range"); + enabled = false; + + days.push({ + classNames, + enabled, + }); + continue; + } + + const isWeekend = this.state.weekends.includes(dateObj.getUTCDay()); + const isCurrentMonth = month == dateObj.getUTCMonth(); + const isSelection = + this.state.selection.year == dateObj.getUTCFullYear() && + this.state.selection.month == dateObj.getUTCMonth() && + this.state.selection.day == dateObj.getUTCDate(); + // 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} weekends + * @return {Array} + * { + * {Number} content + * {Array} classNames + * } + */ + _getWeekHeaders(firstDayOfWeek, weekends) { + let headers = []; + let dayOfWeek = firstDayOfWeek; + + for (let i = 0; i < DAYS_IN_A_WEEK; i++) { + headers.push({ + content: dayOfWeek % DAYS_IN_A_WEEK, + classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK) + ? ["weekend"] + : [], + }); + dayOfWeek++; + } + return headers; + }, + + /** + * Get the first day on a calendar month + * @param {Date} dateObj + * @param {Number} firstDayOfWeek + * @return {Date} + */ + _getFirstCalendarDate(dateObj, firstDayOfWeek) { + const daysOffset = 1 - DAYS_IN_A_WEEK; + let firstDayOfMonth = this._newUTCDate( + dateObj.getUTCFullYear(), + dateObj.getUTCMonth() + ); + let dayOfWeek = firstDayOfMonth.getUTCDay(); + + return this._newUTCDate( + firstDayOfMonth.getUTCFullYear(), + firstDayOfMonth.getUTCMonth(), + // When first calendar date is the same as first day of the week, add + // another row on top of it. + firstDayOfWeek == dayOfWeek + ? daysOffset + : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK + ); + }, + + /** + * Helper function for creating UTC dates + * @param {...[Number]} parts + * @return {Date} + */ + _newUTCDate(...parts) { + return new Date(new Date(0).setUTCFullYear(...parts)); + }, + }; +} diff --git a/toolkit/content/widgets/datepicker.js b/toolkit/content/widgets/datepicker.js new file mode 100644 index 0000000000..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} weekends + * {Array} monthStrings + * {Array} 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} 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} months + * {Array} 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
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( + `
+ + +
`, + "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 + 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 ` + + `; + } + + get _markup() { + let buttons = AppConstants.XP_UNIX + ? ` + + + + +
+`; + +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` +
+ ${this.type == "accordion" + ? html`
` + : ""} + ${this.heading} +
+ `; + } + + cardTemplate() { + if (this.type === "accordion") { + return html` +
+ ${this.headingTemplate()} +
+
+ `; + } + + return html` + ${this.headingTemplate()} +
+ +
+ `; + } + /** + * 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` + +
+ ${this.cardTemplate()} +
+ `; + } +} +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` +
+ +
${content}
+
+
+`; + +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` + + + `; + } +} +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` +
+ + +
+`; + +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 ` + + + + + + + + + `; + }, + get normal() { + delete this.normal; + this.normal = MozXULElement.parseXULToFragment( + ` + + ${this.editMenuItems} + + ` + ); + MozXULElement.insertFTLIfNeeded("toolkit/global/textActions.ftl"); + return this.normal; + }, + get spellcheck() { + delete this.spellcheck; + this.spellcheck = MozXULElement.parseXULToFragment( + ` + + + + + + ${this.editMenuItems} + + + + + + + ` + ); + 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 + + +``` + +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: + 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` + +
+ + +
+`; + +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 + + +``` + +## 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 + +``` + +And used as follows: + +```html + + +``` + +### 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 + +``` + +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` +
+ +
+ `; + } + return ""; + } + + headingTemplate() { + if (this.heading) { + return html`${this.heading}`; + } + return ""; + } + + closeButtonTemplate() { + if (this.dismissable) { + return html` + + `; + } + return ""; + } + + render() { + return html` + +
+
+
+ ${this.iconTemplate()} +
+ ${this.headingTemplate()} +
+ + ${this.message} + + + + +
+
+
+ + + +
+ ${this.closeButtonTemplate()} +
+ `; + } + + 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` + + ${hasSupportLink + ? html` + + ` + : ""} + ${hasActionButton + ? html` + + ` + : ""} + +`; + +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` + + +`; + +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 + + +``` + +## 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 + +``` + +And used as follows: + +```html + +``` + +### 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 + +``` + +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 + +``` + +``` +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` +

+ ${this.description} ${this.supportLinkTemplate()} +

+ `; + } + return ""; + } + + supportLinkTemplate() { + return html` `; + } + + buttonTemplate() { + const { pressed, disabled, description, ariaLabel, handleClick } = this; + return html` + + `; + } + + render() { + return html` + + ${this.label + ? html` + + ` + : 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` +
+ + ${hasSupportLink + ? html` + + ` + : ""} + +
+`; + +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 element. Set the target + * '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 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 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. + * + * + * + *

I like cats.

+ *

I like dogs.

+ *
+ * + * 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 + * element is a companion element that can update its state + * and change the view of a . + * + * 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. + * + * + *
Some info about cats.
+ *
Some dog stuff.
+ *
+ * + * 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 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 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 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 + + Apples + Bananas + +``` + +### 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 + + + + Apples + Apples + Apples + + +``` 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( + ` + + `, + "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 , 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` + + ${isOpen + ? "" + : html` + + + + + `} + + ${items.map(i => + i == "
" + ? html`
` + : html` + + ` + )} +
+ `; + +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", + "
", + { 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 ``; + } + 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 ` + + + + + + + + + + + + + + + + + + + + + + + +