diff --git a/locales/en-US/browser/browser/zen-general.ftl b/locales/en-US/browser/browser/zen-general.ftl
index d1229b04f..fa55d4811 100644
--- a/locales/en-US/browser/browser/zen-general.ftl
+++ b/locales/en-US/browser/browser/zen-general.ftl
@@ -151,3 +151,5 @@ zen-window-sync-migration-dialog-accept = Got It
zen-appmenu-new-blank-window =
.label = New blank window
+zen-spaces-search-placeholder =
+ .placeholder = Search your spaces...
diff --git a/src/browser/base/content/zen-panels/spaces-search.inc b/src/browser/base/content/zen-panels/spaces-search.inc
new file mode 100644
index 000000000..1b676c2dd
--- /dev/null
+++ b/src/browser/base/content/zen-panels/spaces-search.inc
@@ -0,0 +1,23 @@
+# 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/.
+
+
diff --git a/src/browser/base/content/zen-popupset.inc.xhtml b/src/browser/base/content/zen-popupset.inc.xhtml
index 9d6e1c06c..c19efc783 100644
--- a/src/browser/base/content/zen-popupset.inc.xhtml
+++ b/src/browser/base/content/zen-popupset.inc.xhtml
@@ -5,6 +5,7 @@
#include zen-panels/theme-picker.inc
#include zen-panels/emojis-picker.inc
#include zen-panels/folders-search.inc
+#include zen-panels/spaces-search.inc
#include zen-panels/site-data.inc
#include zen-panels/popups.inc
diff --git a/src/zen/common/moz.build b/src/zen/common/moz.build
index 115234dad..27dfec3c4 100644
--- a/src/zen/common/moz.build
+++ b/src/zen/common/moz.build
@@ -5,6 +5,7 @@
EXTRA_JS_MODULES += [
"sys/ZenActorsManager.sys.mjs",
"sys/ZenCustomizableUI.sys.mjs",
+ "sys/ZenSearchPopup.sys.mjs",
"sys/ZenUIMigration.sys.mjs",
]
diff --git a/src/zen/common/sys/ZenSearchPopup.sys.mjs b/src/zen/common/sys/ZenSearchPopup.sys.mjs
new file mode 100644
index 000000000..3f36410cc
--- /dev/null
+++ b/src/zen/common/sys/ZenSearchPopup.sys.mjs
@@ -0,0 +1,152 @@
+/* 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/. */
+
+/*
+ * Generic searchable XUL driver. One instance owns one panel +
+ * its search input + list + (optional) no-results element, and exposes
+ * `populate(items)` and `open(anchor, options)`.
+ *
+ * Each item passed to `populate` is `{ label, render?, onPick }`:
+ * - label: string used for the data-label search filter.
+ * - render: optional () => Element factory. If omitted a bare hbox
+ * with a is created.
+ * - onPick: callback invoked when the item is clicked or activated
+ * via Enter on the keyboard.
+ *
+ * The driver handles:
+ * - filtering by lowercased substring match against data-label;
+ * - arrow-key / Tab navigation with [selected="true"] highlight;
+ * - Enter to activate the highlighted item;
+ * - autofocus of the search input on popupshown;
+ * - cleanup of all listeners on popuphidden.
+ */
+export class ZenSearchPopup {
+ #panel = null;
+ #searchInput = null;
+ #list = null;
+ #noResults = null;
+ #itemSelector = ".zen-search-popup-item";
+ #items = [];
+
+ /**
+ * @param {object} aOptions
+ * @param {Element} aOptions.panel The XUL element.
+ * @param {Element} aOptions.searchInput The search .
+ * @param {Element} aOptions.list The container holding items.
+ * @param {Element} [aOptions.noResults] Optional "no results" element.
+ * @param {string} [aOptions.itemSelector] Per-item selector. Default
+ * is `.zen-search-popup-item`; custom items must carry that class
+ * or override this option.
+ */
+ constructor({ panel, searchInput, list, noResults, itemSelector }) {
+ this.#panel = panel;
+ this.#searchInput = searchInput;
+ this.#list = list;
+ this.#noResults = noResults;
+ if (itemSelector) this.#itemSelector = itemSelector;
+ }
+
+ populate(items) {
+ this.#items = items;
+ this.#list.innerHTML = "";
+ const doc = this.#panel.ownerDocument;
+ for (const item of items) {
+ let node;
+ if (typeof item.render === "function") {
+ node = item.render();
+ } else {
+ node = doc.createXULElement("hbox");
+ const label = doc.createXULElement("label");
+ label.setAttribute("value", item.label);
+ node.appendChild(label);
+ }
+ node.classList.add(this.#itemSelector.replace(/^\./, ""));
+ node.setAttribute("data-label", item.label);
+ node.addEventListener("click", () => {
+ this.#panel.hidePopup();
+ item.onPick?.(item);
+ });
+ this.#list.appendChild(node);
+ }
+ }
+
+ open(anchor, { position = "after_end", onShown, onHidden } = {}) {
+ if (!this.#panel || !this.#list) return;
+
+ this.#panel.hidden = false;
+
+ if (this.#searchInput) this.#searchInput.value = "";
+ if (this.#noResults) this.#noResults.hidden = true;
+
+ const doc = this.#panel.ownerDocument;
+ const sel = this.#itemSelector;
+
+ const onSearch = () => {
+ const query = (this.#searchInput?.value || "").toLowerCase();
+ let visible = 0;
+ for (const item of this.#list.querySelectorAll(sel)) {
+ const label = item.getAttribute("data-label")?.toLowerCase() || "";
+ const found = label.includes(query);
+ item.hidden = !found;
+ if (found) visible++;
+ }
+ if (this.#noResults) this.#noResults.hidden = visible > 0;
+ };
+ if (this.#searchInput) {
+ this.#searchInput.addEventListener("input", onSearch);
+ }
+
+ const onKeyDown = event => {
+ if (
+ event.key === "ArrowDown" ||
+ event.key === "ArrowUp" ||
+ event.key === "Tab"
+ ) {
+ event.preventDefault();
+ const isUp =
+ event.key === "ArrowUp" || (event.key === "Tab" && event.shiftKey);
+ const items = Array.from(this.#list.querySelectorAll(sel)).filter(
+ it => !it.hidden
+ );
+ if (!items.length) return;
+ let index = items.indexOf(
+ this.#list.querySelector(`${sel}[selected="true"]`)
+ );
+ index = isUp
+ ? (index - 1 + items.length) % items.length
+ : (index + 1) % items.length;
+ items.forEach(it => it.removeAttribute("selected"));
+ const target = items[index];
+ target.setAttribute("selected", "true");
+ target.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ } else if (event.key === "Enter") {
+ const sel2 = this.#list.querySelector(`${sel}[selected="true"]`);
+ if (sel2) sel2.click();
+ }
+ };
+ doc.addEventListener("keydown", onKeyDown);
+
+ const onPanelShown = event => {
+ if (event.target !== this.#panel) return;
+ this.#searchInput?.focus();
+ this.#searchInput?.select?.();
+ onShown?.();
+ };
+ this.#panel.addEventListener("popupshown", onPanelShown);
+
+ const onPanelHidden = event => {
+ if (event.target !== this.#panel) return;
+ if (this.#searchInput) {
+ this.#searchInput.removeEventListener("input", onSearch);
+ }
+ doc.removeEventListener("keydown", onKeyDown);
+ this.#panel.removeEventListener("popupshown", onPanelShown);
+ this.#panel.removeEventListener("popuphidden", onPanelHidden);
+ onHidden?.();
+ };
+ this.#panel.addEventListener("popuphidden", onPanelHidden);
+
+ this.#panel.openPopup(anchor, position);
+ }
+}
diff --git a/src/zen/folders/ZenFolders.mjs b/src/zen/folders/ZenFolders.mjs
index fe2b7c2c7..882ba66b8 100644
--- a/src/zen/folders/ZenFolders.mjs
+++ b/src/zen/folders/ZenFolders.mjs
@@ -3,6 +3,7 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
import { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs";
+import { ZenSearchPopup } from "resource:///modules/ZenSearchPopup.sys.mjs";
function formatRelativeTime(timestamp) {
const now = Date.now();
@@ -42,6 +43,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
);
#popup = null;
+ #searchPopup = null;
#popupTimer = null;
#mouseTimer = null;
#lastHighlightedGroup = null;
@@ -188,15 +190,12 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
#initTabsPopup() {
this.#popup = document.getElementById("zen-folder-tabs-popup");
-
- const search = this.#popup.querySelector("#zen-folder-tabs-list-search");
- const tabsList = this.#popup.querySelector("#zen-folder-tabs-list");
-
- search.addEventListener("input", () => {
- const query = search.value.toLowerCase();
- for (const item of tabsList.children) {
- item.hidden = !item.getAttribute("data-label").includes(query);
- }
+ this.#searchPopup = new ZenSearchPopup({
+ panel: this.#popup,
+ searchInput: this.#popup.querySelector("#zen-folder-tabs-list-search"),
+ list: this.#popup.querySelector("#zen-folder-tabs-list"),
+ noResults: document.getElementById("zen-folder-tabs-search-no-results"),
+ itemSelector: ".folders-tabs-list-item",
});
this.#popup.addEventListener("mouseover", () => {
@@ -788,93 +787,18 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
document.getElementById("zen-folder-tabs-search-no-results").hidden = true;
this.#populateTabsList(activeGroup);
- const search = this.#popup.querySelector("#zen-folder-tabs-list-search");
- document.l10n.setArgs(search, {
- "folder-name": activeGroup.name,
- });
- const tabsList = this.#popup.querySelector("#zen-folder-tabs-list");
-
- const onSearchInput = () => {
- const query = search.value.toLowerCase();
- let foundTabs = 0;
- for (const item of tabsList.children) {
- const found = item.getAttribute("data-label").includes(query);
- item.hidden = !found;
- if (found) {
- foundTabs++;
- }
- }
- document.getElementById("zen-folder-tabs-search-no-results").hidden =
- foundTabs > 0;
- };
- search.addEventListener("input", onSearchInput);
-
- const onKeyDown = event => {
- // Arrow down and up to navigate through the list
- if (
- event.key === "ArrowDown" ||
- event.key === "ArrowUp" ||
- event.key === "Tab"
- ) {
- event.preventDefault();
- let isUp =
- event.key === "ArrowUp" || (event.key === "Tab" && event.shiftKey);
- const items = Array.from(tabsList.children).filter(
- item => !item.hidden
- );
- if (items.length === 0) {
- return;
- }
- let index = items.indexOf(
- tabsList.querySelector(".folders-tabs-list-item[selected]")
- );
- if (!isUp) {
- index = (index + 1) % items.length;
- } else {
- index = (index - 1 + items.length) % items.length;
- }
- items.forEach(item => item.removeAttribute("selected"));
- const targetItem = items[index];
- targetItem.setAttribute("selected", "true");
- targetItem.scrollIntoView({ block: "start", behavior: "smooth" });
- } else if (event.key === "Enter") {
- // Enter to select the currently highlighted item
- const highlightedItem = tabsList.querySelector(
- ".folders-tabs-list-item[selected]"
- );
- if (highlightedItem) {
- highlightedItem.click();
- }
- }
- };
- document.addEventListener("keydown", onKeyDown);
+ document.l10n.setArgs(
+ this.#popup.querySelector("#zen-folder-tabs-list-search"),
+ { "folder-name": activeGroup.name }
+ );
const target = event.target;
target.setAttribute("open", true);
- const handlePopupHidden = event => {
- if (event.target !== this.#popup) {
- return;
- }
- search.value = "";
- target.removeAttribute("open");
- search.removeEventListener("input", onSearchInput);
- document.removeEventListener("keydown", onKeyDown);
- };
-
- this.#popup.addEventListener(
- "popupshown",
- () => {
- search.focus();
- search.select();
- },
- { once: true }
- );
-
- this.#popup.addEventListener("popuphidden", handlePopupHidden, {
- once: true,
+ this.#searchPopup.open(target, {
+ position: this.#searchPopupOptions,
+ onHidden: () => target.removeAttribute("open"),
});
- this.#popup.openPopup(target, this.#searchPopupOptions);
}
get #searchPopupOptions() {
diff --git a/src/zen/little-window/ZenLittleWindow.sys.mjs b/src/zen/little-window/ZenLittleWindow.sys.mjs
index ff31c5654..1157d5bde 100644
--- a/src/zen/little-window/ZenLittleWindow.sys.mjs
+++ b/src/zen/little-window/ZenLittleWindow.sys.mjs
@@ -4,6 +4,7 @@
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { ZenSpacesSearch } from "resource:///modules/zen/ZenSpacesSearch.sys.mjs";
const lazy = {};
@@ -77,6 +78,7 @@ class nsZenLittleWindow {
if (!this.#isLittleWindow(win)) {
return;
}
+ ZenSpacesSearch.init(win);
const observer = new win.ResizeObserver(entries => {
if (win.closed) {
return;
diff --git a/src/zen/little-window/ZenSpacesSearch.sys.mjs b/src/zen/little-window/ZenSpacesSearch.sys.mjs
new file mode 100644
index 000000000..5994e1380
--- /dev/null
+++ b/src/zen/little-window/ZenSpacesSearch.sys.mjs
@@ -0,0 +1,142 @@
+/* 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 { ZenSearchPopup } from "resource:///modules/ZenSearchPopup.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ZenSessionStore: "resource:///modules/zen/ZenSessionManager.sys.mjs",
+});
+
+/*
+ * Owns the "send to a synced window" split-button that lives in the
+ * nav-bar of a little window. Main click opens the urlbar's current
+ * value in a fresh synced browser window using the active workspace;
+ * the dropdown opens a ZenSearchPopup over #zen-spaces-popup so the
+ * user can pick a different workspace to land in.
+ */
+class ZenSpacesSearchService {
+ /**
+ * Per-window setup.
+ * @param {Window} aWindow A little window.
+ */
+ init(aWindow) {
+ if (!aWindow || aWindow._zenSpacesSearchInited) return;
+ aWindow._zenSpacesSearchInited = true;
+
+ const doc = aWindow.document;
+ const panel = doc.getElementById("zen-spaces-popup");
+ if (!panel) return;
+
+ const popup = new ZenSearchPopup({
+ panel,
+ searchInput: doc.getElementById("zen-spaces-list-search"),
+ list: doc.getElementById("zen-spaces-list"),
+ noResults: doc.getElementById("zen-spaces-search-no-results"),
+ itemSelector: ".zen-spaces-list-item",
+ });
+
+ const parts = this.#injectButton(aWindow);
+ if (!parts) return;
+ const { button, main, dropmarker } = parts;
+
+ main.addEventListener("click", event => {
+ if (event.button !== 0) return;
+ this.#openInWorkspace(aWindow, null);
+ });
+
+ dropmarker.addEventListener("click", event => {
+ if (event.button !== 0) return;
+ event.stopPropagation();
+ this.#openSpacesPopup(aWindow, popup, button);
+ });
+ }
+
+ #injectButton(aWindow) {
+ const doc = aWindow.document;
+ const target = doc.getElementById("nav-bar-customization-target");
+ if (!target) return null;
+
+ const button = doc.createXULElement("hbox");
+ button.id = "zen-little-window-send-to-window";
+ button.setAttribute("removable", "false");
+
+ const main = doc.createXULElement("hbox");
+ main.classList.add("zen-stw-main");
+
+ const prefix = doc.createXULElement("label");
+ prefix.classList.add("zen-stw-prefix");
+ prefix.setAttribute(
+ "data-l10n-id",
+ "zen-little-window-send-to-window-prefix"
+ );
+
+ const spaceName = doc.createXULElement("label");
+ spaceName.classList.add("zen-stw-space-name");
+ spaceName.setAttribute(
+ "value",
+ aWindow.gZenWorkspaces?.getActiveWorkspaceFromCache?.()?.name || ""
+ );
+
+ main.appendChild(prefix);
+ main.appendChild(spaceName);
+
+ const separator = doc.createXULElement("hbox");
+ separator.classList.add("zen-stw-separator");
+
+ const dropmarker = doc.createXULElement("hbox");
+ dropmarker.classList.add("zen-stw-dropmarker");
+ const dropIcon = doc.createXULElement("image");
+ dropIcon.classList.add("zen-stw-dropmarker-icon");
+ dropmarker.appendChild(dropIcon);
+
+ button.appendChild(main);
+ button.appendChild(separator);
+ button.appendChild(dropmarker);
+
+ target.appendChild(button);
+ return { button, main, dropmarker, spaceName };
+ }
+
+ #openSpacesPopup(aWindow, popup, anchor) {
+ const workspaces = lazy.ZenSessionStore.getClonedSpaces();
+
+ popup.populate(
+ workspaces.map(space => ({
+ label: space.name || space.uuid,
+ render: () => {
+ const node = aWindow.document.createXULElement("hbox");
+ const label = aWindow.document.createXULElement("label");
+ label.setAttribute("value", space.name || space.uuid);
+ label.classList.add("zen-spaces-list-item-label");
+ node.appendChild(label);
+ return node;
+ },
+ onPick: () => this.#openInWorkspace(aWindow, space.uuid),
+ }))
+ );
+ popup.open(anchor);
+ }
+
+ #openInWorkspace(aWindow, workspaceUuid) {
+ const url = aWindow.gURLBar?.value?.trim();
+ if (!url) return;
+
+ const args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ const urlString = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ urlString.data = url;
+ args.appendElement(urlString);
+
+ const opts = { args, zenSyncedWindow: true };
+ if (workspaceUuid) opts.zenInitialWorkspace = workspaceUuid;
+
+ const newWin = aWindow.OpenBrowserWindow(opts);
+ if (newWin) aWindow.close();
+ }
+}
+
+export const ZenSpacesSearch = new ZenSpacesSearchService();
diff --git a/src/zen/little-window/moz.build b/src/zen/little-window/moz.build
index 977f6828d..6009183cb 100644
--- a/src/zen/little-window/moz.build
+++ b/src/zen/little-window/moz.build
@@ -4,4 +4,5 @@
EXTRA_JS_MODULES.zen += [
"ZenLittleWindow.sys.mjs",
+ "ZenSpacesSearch.sys.mjs",
]
diff --git a/src/zen/little-window/zen-little-window.css b/src/zen/little-window/zen-little-window.css
index df9426e21..bbf485f15 100644
--- a/src/zen/little-window/zen-little-window.css
+++ b/src/zen/little-window/zen-little-window.css
@@ -8,10 +8,115 @@
* suppressed so the window acts as a quick search/launch box.
*/
-:root[zen-little-window="true"] {
+#zen-little-window-send-to-window {
+ align-items: stretch;
+ border-radius: var(--border-radius-small);
+ background: light-dark(white, rgba(0, 0, 0, 0.4));
+ height: 32px;
+ margin-inline: 4px;
+ overflow: hidden;
+ user-select: none;
+ align-self: center;
+ box-shadow: 0 0 1px 2px rgba(0, 0, 0, 0.01);
- #urlbar {
- width: 100%;
+ .zen-stw-main,
+ .zen-stw-dropmarker {
+ align-items: center;
+ padding-inline: 12px;
+ transition: background 80ms ease;
+
+ &:hover {
+ background: var(--toolbarbutton-active-background, color-mix(in srgb, currentColor 12%, transparent));
+ }
+ }
+
+ .zen-stw-prefix {
+ color: color-mix(in srgb, currentColor 60%, transparent);
+ margin-inline-end: 6px;
+ }
+
+ .zen-stw-space-name {
+ color: var(--zen-primary-color, currentColor);
+ font-weight: 600;
+ }
+
+ .zen-stw-separator {
+ width: 1px;
+ background: color-mix(in srgb, currentColor 18%, transparent);
+ margin-block: 6px;
+ }
+
+ .zen-stw-dropmarker {
+ padding-inline: 8px;
+ }
+
+ .zen-stw-dropmarker-icon {
+ width: 12px;
+ height: 12px;
+ -moz-context-properties: fill;
+ fill: color-mix(in srgb, currentColor 65%, transparent);
+ list-style-image: url("chrome://global/skin/icons/arrow-down.svg");
+ }
+}
+
+#zen-spaces-popup {
+ --arrowpanel-padding: 0;
+ --zen-spaces-list-padding: 6px;
+ padding: var(--zen-spaces-list-padding);
+ min-width: 250px;
+
+ .zen-spaces-list-header {
+ display: flex;
+ flex-direction: row;
+ padding: 6px;
+ border-bottom: 1px solid color-mix(in srgb, currentColor, transparent 90%);
+ }
+
+ .zen-spaces-list-search-icon {
+ width: 14px;
+ height: 14px;
+ margin-inline: 4px 6px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ opacity: 0.6;
+ }
+
+ #zen-spaces-list-search {
+ flex: 1;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: inherit;
+ font: inherit;
+ }
+
+ .zen-spaces-list-item {
+ padding: 6px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+
+ &:hover,
+ &[selected="true"] {
+ background: var(--toolbarbutton-hover-background);
+ }
+ &[active="true"]::after {
+ content: "•";
+ margin-inline-start: auto;
+ opacity: 0.7;
+ }
+ }
+
+ #zen-spaces-search-no-results {
+ padding: 12px;
+ opacity: 0.7;
+ justify-content: center;
+ }
+}
+
+:root[zen-little-window="true"] {
+ toolbarspring[cui-areatype="toolbar"],
+ #nav-bar-customization-target > .toolbarbutton-1[disabled="true"] {
+ display: none;
}
&[zen-has-empty-tab="true"] {
@@ -24,7 +129,8 @@
}
#urlbar[breakout-extend] {
- min-width: 100% !important;
+ min-width: 600px !important;
+ max-width: 600px !important;
left: 50% !important;
top: 0 !important;
transform: translate(10px, 0) !important;
@@ -43,4 +149,14 @@
}
}
}
+
+ @media (-moz-platform: macos) {
+ #nav-bar {
+ padding-inline-start: 6px;
+ }
+ }
+
+ #urlbar-container {
+ --border-radius-medium: var(--border-radius-small);
+ }
}