commit da8b29b1ad4ecf62939e62a8fce8ca61bcab5d74
parent b652076c527679b102ff06d4926b2277c263ec43
Author: Jonathan Sudiaman <jsudiaman@mozilla.com>
Date: Wed, 29 Oct 2025 14:00:05 +0000
Bug 1986950 - Implement footer for inactive tab/content area r=tabbrowser-reviewers,desktop-theme-reviewers,sclements,dao
The inactive view includes a small footer that displays the favicon and domain name of the site.
Differential Revision: https://phabricator.services.mozilla.com/D269281
Diffstat:
7 files changed, 398 insertions(+), 17 deletions(-)
diff --git a/browser/base/content/browser-main.js b/browser/base/content/browser-main.js
@@ -21,6 +21,7 @@
Services.scriptloader.loadSubScript("chrome://browser/content/browser-unified-extensions.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/drag-and-drop.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tab-stacking.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/split-view-footer.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tab.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tabbrowser.js", this);
Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser/tabgroup.js", this);
diff --git a/browser/components/tabbrowser/content/split-view-footer.js b/browser/components/tabbrowser/content/split-view-footer.js
@@ -0,0 +1,212 @@
+/* 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`.
+{
+ ChromeUtils.defineESModuleGetters(this, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ });
+
+ /**
+ * A footer which appears in the corner of the inactive panel in split view.
+ *
+ * The footer displays the favicon and domain name of the site.
+ */
+ class MozSplitViewFooter extends MozXULElement {
+ #initialized = false;
+
+ #isInsecure = false;
+ /** @type {HTMLSpanElement} */
+ securityElement = null;
+
+ /** @type {HTMLImageElement} */
+ iconElement = null;
+ #iconSrc = "";
+
+ /** @type {HTMLSpanElement} */
+ uriElement = null;
+ /** @type {nsIURI} */
+ #uri = null;
+
+ #browserProgressListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ Ci.nsIWebProgressListener,
+ Ci.nsISupportsWeakReference,
+ ]),
+ onLocationChange: (aWebProgress, aRequest, aLocation) => {
+ if (aWebProgress?.isTopLevel && aLocation) {
+ this.#updateUri(aLocation);
+ }
+ },
+ onSecurityChange: (aWebProgress, aRequest, aState) =>
+ this.#toggleInsecure(
+ !!(
+ aState & Ci.nsIWebProgressListener.STATE_IS_INSECURE ||
+ aState & Ci.nsIWebProgressListener.STATE_IS_BROKEN
+ )
+ ),
+ };
+
+ /** @type {XULElement} */
+ #tab = null;
+
+ static markup = `
+ <hbox class="split-view-security-warning" hidden="">
+ <html:img role="presentation" src="chrome://global/skin/icons/security-broken.svg" />
+ <html:span data-l10n-id="urlbar-trust-icon-notsecure-label"></html:span>
+ </hbox>
+ <html:img class="split-view-icon" hidden="" role="presentation"/>
+ <html:span class="split-view-uri"></html:span>
+ `;
+
+ connectedCallback() {
+ if (this.#initialized) {
+ return;
+ }
+ this.appendChild(this.constructor.fragment);
+
+ this.securityElement = this.querySelector(".split-view-security-warning");
+ this.iconElement = this.querySelector(".split-view-icon");
+ this.uriElement = this.querySelector(".split-view-uri");
+
+ // Ensure these elements are up-to-date, as this info may have been set
+ // prior to inserting this element into the DOM.
+ this.#updateSecurityElement();
+ this.#updateIconElement();
+ this.#updateUriElement();
+
+ this.#initialized = true;
+ }
+
+ disconnectedCallback() {
+ this.#resetTab();
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "TabAttrModified":
+ for (const attribute of e.detail.changed) {
+ this.#handleTabAttrModified(attribute);
+ }
+ break;
+ }
+ }
+
+ #handleTabAttrModified(attribute) {
+ switch (attribute) {
+ case "image":
+ this.#updateIconSrc(this.#tab.image);
+ break;
+ }
+ }
+
+ /**
+ * Update the insecure flag and refresh the security warning visibility.
+ *
+ * @param {boolean} isInsecure
+ */
+ #toggleInsecure(isInsecure) {
+ this.#isInsecure = isInsecure;
+ if (this.securityElement) {
+ this.#updateSecurityElement();
+ }
+ }
+
+ #updateSecurityElement() {
+ const isWebsite =
+ this.#uri.schemeIs("http") || this.#uri.schemeIs("https");
+ this.securityElement.hidden = !isWebsite || !this.#isInsecure;
+ }
+
+ /**
+ * Update the footer icon to the given source URI.
+ *
+ * @param {string} iconSrc
+ */
+ #updateIconSrc(iconSrc) {
+ this.#iconSrc = iconSrc;
+ if (this.iconElement) {
+ this.#updateIconElement();
+ }
+ }
+
+ #updateIconElement() {
+ if (this.#iconSrc) {
+ this.iconElement.setAttribute("src", this.#iconSrc);
+ } else {
+ this.iconElement.removeAttribute("src");
+ }
+ this.iconElement.hidden = !this.#iconSrc;
+ }
+
+ /**
+ * Update the footer URI display with the formatted domain string.
+ *
+ * @param {nsIURI} uri
+ */
+ #updateUri(uri) {
+ this.#uri = uri;
+ if (this.uriElement) {
+ this.#updateUriElement();
+ }
+ if (this.securityElement) {
+ this.#updateSecurityElement();
+ }
+ }
+
+ #updateUriElement() {
+ const uriString = this.#uri
+ ? BrowserUtils.formatURIForDisplay(this.#uri)
+ : "";
+ this.uriElement.textContent = uriString;
+ }
+
+ /**
+ * Link the footer to the provided tab so it stays in sync.
+ *
+ * @param {MozTabbrowserTab} tab
+ */
+ setTab(tab) {
+ this.#resetTab();
+
+ // Track favicon changes.
+ this.#updateIconSrc(tab.image);
+ tab.addEventListener("TabAttrModified", this);
+
+ // Track URI and security changes.
+ this.#updateUri(tab.linkedBrowser.currentURI);
+ const securityState = tab.linkedBrowser.securityUI.state;
+ this.#toggleInsecure(
+ !!(
+ securityState & Ci.nsIWebProgressListener.STATE_IS_INSECURE ||
+ securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN
+ )
+ );
+ tab.linkedBrowser.addProgressListener(
+ this.#browserProgressListener,
+ Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_SECURITY
+ );
+
+ this.#tab = tab;
+ }
+
+ /**
+ * Remove the footer's association with the current tab.
+ */
+ #resetTab() {
+ if (this.#tab) {
+ this.#tab.removeEventListener("TabAttrModified", this);
+ this.#tab.linkedBrowser?.removeProgressListener(
+ this.#browserProgressListener
+ );
+ }
+ this.#tab = null;
+ }
+ }
+
+ customElements.define("split-view-footer", MozSplitViewFooter);
+}
diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js
@@ -3255,6 +3255,7 @@
const panels = [];
for (const tab of tabs) {
this._insertBrowser(tab);
+ this.#insertSplitViewFooter(tab);
tab.linkedBrowser.docShellIsActive = true;
panels.push(tab.linkedPanel);
}
@@ -3273,6 +3274,21 @@
}
/**
+ * Ensures the split view footer exists for the given tab.
+ *
+ * @param {MozTabbrowserTab} tab
+ */
+ #insertSplitViewFooter(tab) {
+ const panelEl = document.getElementById(tab.linkedPanel);
+ if (panelEl.querySelector("split-view-footer")) {
+ return;
+ }
+ const footer = document.createXULElement("split-view-footer");
+ footer.setTab(tab);
+ panelEl.appendChild(footer);
+ }
+
+ /**
* @param {string} id
* @param {string} color
* @param {boolean} collapsed
@@ -8586,10 +8602,6 @@
modifiedAttrs.push("progress");
}
- if (modifiedAttrs.length) {
- gBrowser._tabAttrModified(this.mTab, modifiedAttrs);
- }
-
if (aWebProgress.isTopLevel) {
let isSuccessful = Components.isSuccessCode(aStatus);
if (!isSuccessful && !this.mTab.isEmpty) {
@@ -8634,6 +8646,7 @@
!(originalLocation.spec in FAVICON_DEFAULTS)
) {
this.mTab.removeAttribute("image");
+ modifiedAttrs.push("image");
} else {
// Bug 1804166: Allow new tabs to set the favicon correctly if the
// new tabs behavior is set to open a blank page
@@ -8650,6 +8663,10 @@
if (this.mTab.selected) {
gBrowser._isBusy = false;
}
+
+ if (modifiedAttrs.length) {
+ gBrowser._tabAttrModified(this.mTab, modifiedAttrs);
+ }
}
if (ignoreBlank) {
diff --git a/browser/components/tabbrowser/jar.mn b/browser/components/tabbrowser/jar.mn
@@ -8,6 +8,7 @@ browser.jar:
content/browser/tabbrowser/browser-fullZoom.js (content/browser-fullZoom.js)
content/browser/tabbrowser/drag-and-drop.js (content/drag-and-drop.js)
content/browser/tabbrowser/tab-stacking.js (content/tab-stacking.js)
+ content/browser/tabbrowser/split-view-footer.js (content/split-view-footer.js)
content/browser/tabbrowser/tab.js (content/tab.js)
content/browser/tabbrowser/tab-hover-preview.mjs (content/tab-hover-preview.mjs)
content/browser/tabbrowser/tabbrowser.js (content/tabbrowser.js)
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview.js
@@ -4,17 +4,18 @@
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
- set: [["sidebar.verticalTabs", true]],
+ set: [
+ ["sidebar.verticalTabs", true],
+ ["dom.security.https_first", false],
+ ],
});
});
registerCleanupFunction(async function () {
- await SpecialPowers.pushPrefEnv({
- set: [
- ["sidebar.verticalTabs", false],
- ["sidebar.revamp", false],
- ],
- });
+ Services.prefs.clearUserPref("sidebar.revamp");
+ Services.prefs.clearUserPref(
+ "browser.toolbarbuttons.introduced.sidebar-button"
+ );
});
async function addTabAndLoadBrowser() {
@@ -239,3 +240,100 @@ add_task(async function test_resize_split_view_panels() {
splitView.close();
});
+
+add_task(async function test_split_view_panel_footers() {
+ const tab1 = await addTabAndLoadBrowser();
+ const tab2 = await addTabAndLoadBrowser();
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ info("Activate split view.");
+ const splitView = gBrowser.addTabSplitView([tab1, tab2]);
+ await checkSplitViewPanelVisible(tab1, true);
+ await checkSplitViewPanelVisible(tab2, true);
+
+ const panel1 = document.getElementById(tab1.linkedPanel);
+ const panel2 = document.getElementById(tab2.linkedPanel);
+ const panel1Footer = panel1.querySelector("split-view-footer");
+ const panel2Footer = panel2.querySelector("split-view-footer");
+
+ info("Focus the first panel.");
+ await SimpleTest.promiseFocus(tab1.linkedBrowser);
+ Assert.ok(
+ BrowserTestUtils.isHidden(panel1Footer),
+ "First (active) panel does not contain a footer."
+ );
+ Assert.ok(
+ BrowserTestUtils.isVisible(panel2Footer),
+ "Second (inactive) panel contains a footer."
+ );
+ Assert.equal(
+ panel2Footer.uriElement.textContent,
+ "example.com",
+ "Footer displays the domain name of the site."
+ );
+
+ info("Focus the second panel.");
+ await SimpleTest.promiseFocus(tab2.linkedBrowser);
+ Assert.ok(
+ BrowserTestUtils.isVisible(panel1Footer),
+ "First panel now contains a footer."
+ );
+ Assert.ok(
+ BrowserTestUtils.isHidden(panel2Footer),
+ "Second panel no longer contains a footer."
+ );
+
+ info("Navigate to a different location.");
+ const promiseLoaded = BrowserTestUtils.browserLoaded(tab1.linkedBrowser);
+ BrowserTestUtils.startLoadingURIString(tab1.linkedBrowser, "about:robots");
+ await promiseLoaded;
+ Assert.equal(
+ panel1Footer.uriElement.textContent,
+ "about:robots",
+ "Footer displays the new location."
+ );
+
+ splitView.close();
+});
+
+add_task(async function test_split_view_security_warning() {
+ const tab1 = await addTabAndLoadBrowser();
+ const tab2 = await addTabAndLoadBrowser();
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ info("Activate split view.");
+ const splitView = gBrowser.addTabSplitView([tab1, tab2]);
+ await checkSplitViewPanelVisible(tab1, true);
+ await checkSplitViewPanelVisible(tab2, true);
+ await SimpleTest.promiseFocus(tab1.linkedBrowser);
+
+ const inactivePanel = document.getElementById(tab2.linkedPanel);
+ const footer = inactivePanel.querySelector("split-view-footer");
+ Assert.ok(
+ BrowserTestUtils.isHidden(footer.securityElement),
+ "No security warning for HTTPS."
+ );
+
+ info("Load an insecure website.");
+ let promiseLoaded = BrowserTestUtils.browserLoaded(tab2.linkedBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ tab2.linkedBrowser,
+ "http://example.com/" // eslint-disable-line @microsoft/sdl/no-insecure-url
+ );
+ await promiseLoaded;
+ Assert.ok(
+ BrowserTestUtils.isVisible(footer.securityElement),
+ "Security warning for HTTP."
+ );
+
+ info("Load a local site.");
+ promiseLoaded = BrowserTestUtils.browserLoaded(tab2.linkedBrowser);
+ BrowserTestUtils.startLoadingURIString(tab2.linkedBrowser, "about:robots");
+ await promiseLoaded;
+ Assert.ok(
+ BrowserTestUtils.isHidden(footer.securityElement),
+ "No security warning for local sites."
+ );
+
+ splitView.close();
+});
diff --git a/browser/themes/shared/tabbrowser/content-area.css b/browser/themes/shared/tabbrowser/content-area.css
@@ -155,7 +155,7 @@
--panel-min-width: 140px;
.split-view-panel {
- position: static;
+ position: relative;
flex: 1;
min-width: var(--panel-min-width);
max-width: calc(100% - var(--panel-min-width));
@@ -165,6 +165,7 @@
.browserStack {
border-radius: var(--border-radius-medium);
overflow: clip;
+ outline: var(--border-width) solid var(--border-color);
.deck-selected > .browserContainer > & {
outline: var(--focus-outline);
@@ -190,9 +191,51 @@
.split-view-panel[width] {
flex: none;
}
+
+ /* Display split view footer within inactive panels. */
+ .split-view-panel:not(.deck-selected) > split-view-footer {
+ display: inherit;
+ }
}
}
+/* Split view */
+
+split-view-footer {
+ max-width: calc(100% - var(--space-xsmall));
+ display: none;
+ position: absolute;
+ inset-block-end: 0;
+ inset-inline-end: 0;
+
+ border-block-start: 1px solid var(--border-color);
+ border-inline-start: 1px solid var(--border-color);
+ border-start-start-radius: var(--border-radius-medium);
+ border-start-end-radius: 0;
+ border-end-end-radius: var(--border-radius-medium);
+ border-end-start-radius: 0;
+
+ padding-block: var(--space-xxsmall);
+ padding-inline: var(--space-small) var(--space-xsmall);
+}
+
+.split-view-icon {
+ width: var(--icon-size);
+ height: var(--icon-size);
+}
+
+.split-view-uri {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+split-view-footer,
+.split-view-security-warning {
+ align-items: center;
+ gap: var(--space-small);
+ white-space: nowrap;
+}
+
/* Status panel */
#statuspanel {
@@ -252,13 +295,11 @@
}
}
-#statuspanel-label {
+#statuspanel-label,
+split-view-footer {
color-scheme: env(-moz-content-preferred-color-scheme);
- margin: 0;
- padding: 2px 4px;
background-color: -moz-dialog;
- border: 1px none ThreeDShadow;
- border-top-style: solid;
+ border-color: ThreeDShadow;
color: -moz-dialogText;
text-shadow: none;
@@ -267,6 +308,14 @@
border-color: light-dark(#ddd, hsl(240, 1%, 40%));
color: light-dark(#444, rgb(249, 249, 250));
}
+}
+
+#statuspanel-label {
+ margin: 0;
+ padding: 2px 4px;
+ border-width: 1px;
+ border-style: none;
+ border-top-style: solid;
#statuspanel:not([mirror]) > &:-moz-locale-dir(ltr),
#statuspanel[mirror] > &:-moz-locale-dir(rtl) {
diff --git a/toolkit/content/widgets/tabbox.js b/toolkit/content/widgets/tabbox.js
@@ -277,6 +277,7 @@
handleEvent(e) {
switch (e.type) {
+ case "click":
case "focus": {
const browser = e.currentTarget;
const tab = browser.getTabBrowser().getTabForBrowser(browser);
@@ -361,6 +362,7 @@
const panelEl = document.getElementById(panel);
panelEl?.classList.add("split-view-panel");
panelEl?.setAttribute("column", i);
+ panelEl?.querySelector("browser")?.addEventListener("click", this);
panelEl?.querySelector("browser")?.addEventListener("focus", this);
}
this.#splitViewPanels = newPanels;
@@ -382,6 +384,7 @@
const panelEl = document.getElementById(panel);
panelEl?.classList.remove("split-view-panel");
panelEl?.removeAttribute("column");
+ panelEl?.querySelector("browser")?.removeEventListener("click", this);
panelEl?.querySelector("browser")?.removeEventListener("focus", this);
if (updateArray) {
const index = this.#splitViewPanels.indexOf(panel);