diff --git a/src/tab.js b/src/tab.js index dbdca21..509a494 100644 --- a/src/tab.js +++ b/src/tab.js @@ -239,6 +239,9 @@ Object.assign(SideTab, { getAllTabsViews() { return document.getElementsByClassName("tab"); }, + getVisibleTabViews() { + return document.querySelectorAll(".tab:not(.hidden)"); + }, _syncThrobberAnimations() { requestAnimationFrame(() => { if (!document.body.getAnimations) { // this API is available only in Nightly so far diff --git a/src/tabcenter.css b/src/tabcenter.css index ae549da..a9a198e 100644 --- a/src/tabcenter.css +++ b/src/tabcenter.css @@ -5,6 +5,8 @@ --tab-background-pinned: 0, 0%, 97%; --tab-background-active: 0, 0%, 87%; --tab-background-hover: 0, 0%, 91%; + --tab-background-selected: 0, 0%, 91%; + --tab-background-active-and-selected: 0, 0%, 82%; --tab-border-color: hsla(0, 0%, 0%, 0.06); --searchbox-background: #fff; --primary-text-color: #18191a; @@ -356,6 +358,14 @@ body[platform="win"] #searchbox-input:placeholder-shown { background-color: hsl(var(--tab-background-hover)); } +.tab.selected { + background-color: hsl(var(--tab-background-selected)); +} + +.tab.active.selected { + background-color: hsl(var(--tab-background-active-and-selected)); +} + /* Tab loading burst is disabled because it triggers on non top-level pages (try it on GitHub). If you don't mind that, you can re-enable the feature by disabling the @@ -643,6 +653,14 @@ body[platform="win"] #searchbox-input:placeholder-shown { --tab-background: var(--tab-background-hover); } +.tab.selected > .tab-title-wrapper::after { + --tab-background: var(--tab-background-selected); +} + +.tab.active.selected > .tab-title-wrapper::after { + --tab-background: var(--tab-background-active-and-selected); +} + .tab:hover > .tab-title-wrapper::after { transform: translateX(0); } diff --git a/src/tabcenter.html b/src/tabcenter.html index 5f15d05..95539ab 100644 --- a/src/tabcenter.html +++ b/src/tabcenter.html @@ -13,7 +13,7 @@
diff --git a/src/tabcenter.js b/src/tabcenter.js index f1ab844..3a378ef 100644 --- a/src/tabcenter.js +++ b/src/tabcenter.js @@ -28,6 +28,7 @@ TabCenter.prototype = { this.setupListeners(); browser.runtime.getPlatformInfo().then((platform) => { document.body.setAttribute("platform", platform.os); + this.platform = platform.os; }); }, setupListeners() { @@ -38,13 +39,19 @@ TabCenter.prototype = { this._searchBoxInput.addEventListener("input", (e) => { this.sideTabList.filter(e.target.value); }); - this._searchBoxInput.addEventListener("focus", () => { + const onSearchboxFocus = () => { searchbox.classList.add("focused"); this._newTabLabelView.classList.add("hidden"); - }); + }; + this._searchBoxInput.addEventListener("focus", onSearchboxFocus); + // We won't get this message reliably since the item has autofocus. + if (document.activeElement === this._searchBoxInput) { + onSearchboxFocus(); + } this._searchBoxInput.addEventListener("blur", () => { searchbox.classList.remove("focused"); this._newTabLabelView.classList.remove("hidden"); + this.sideTabList.clearSelection(); }); this._newTabButtonView.addEventListener("click", () => { if (!this._newTabMenuShown) { @@ -85,6 +92,41 @@ TabCenter.prototype = { windowId: this.windowId }); }); + this._searchBoxInput.addEventListener("keypress", e => { + let delta = 0; + switch (e.key) { + case "Enter": + // Select whatever is already selected. Clear the current search and + // selection by default, but allow users to keep them using shift+enter. + this.sideTabList.commitSelection(!e.shiftKey); + e.preventDefault(); + break; + case "ArrowDown": + delta = 1; + break; + case "ArrowUp": + delta = -1; + break; + // Apple supports emacs-style movement keys (ctrl+{n,p,f,b,a,e}) in + // most text boxes/dropdowns/etc. Most users are completely unaware + // of it, but I think the relevant items are worth supporting here. + // (Think "next line" for Ctrl+N, and "previous line" for Ctrl+P) + case "n": case "N": + if (e.ctrlKey && this.platform === "mac") { + delta = 1; + } + break; + case "p": case "P": + if (e.ctrlKey && this.platform === "mac") { + delta = -1; + } + break; + } + if (delta !== 0) { + this.sideTabList.moveSelection(delta); + e.preventDefault(); + } + }); browser.storage.onChanged.addListener(changes => { if (changes.darkTheme) { this.toggleTheme(changes.darkTheme.newValue); diff --git a/src/tablist.js b/src/tablist.js index aed6d99..c32d62c 100644 --- a/src/tablist.js +++ b/src/tablist.js @@ -13,6 +13,7 @@ function SideTabList() { this._tabsShrinked = false; this.windowId = null; this._filterActive = false; + this._selected = null; this.view = document.getElementById("tablist"); this.pinnedview = document.getElementById("pinnedtablist"); this._wrapperView = document.getElementById("tablist-wrapper"); @@ -114,6 +115,7 @@ SideTabList.prototype = { if (!this.checkWindow(tab)) { return; } + this.clearSelection(); if (changeInfo.hasOwnProperty("title")) { this.setTitle(tab); } @@ -417,6 +419,7 @@ SideTabList.prototype = { await browser.tabs.move(tab.id, { index: lastIndex }); }, clearSearch() { + this.clearSelection(); if (!this._filterActive) { return; } @@ -442,6 +445,111 @@ SideTabList.prototype = { this._moreTabsView.removeAttribute("hasMoreTabs"); } this.maybeShrinkTabs(); + if (!this._filterActive) { + this.clearSelection(); + } else { + // If there's a currently selected tab, try and use it if possible. + let index = this._getInitialSelectionIndex(this._selected); + this.setSelectionIndex(index); + } + }, + clearSelection() { + let selected = document.querySelector(".selected"); + if (selected) { + selected.classList.remove("selected"); + } + this._selected = null; + }, + setSelectionIndex(num) { + this.clearSelection(); + if (num < 0) { + return; + } + // This is a little awkward, but it could be worse. + let visible = SideTab.getVisibleTabViews(); + if (num > visible.length) { + // Should this be an error? moveSelection is expected to clamp. + return; + } + visible[num].classList.add("selected"); + this._selected = SideTab.tabIdForView(visible[num]); + this.scrollToTab(this._selected); + }, + _getInitialSelectionIndex(goalTabId = null) { + // only visible tabs considered. + let numPinned = 0; + let numUnpinned = 0; + let goalIndex = -1; + + let curIndex = 0; // Index ignoring invisible tabs. + for (let tab of this.tabs.values()) { + if (!tab.visible) { + continue; + } + if (tab.pinned) { + numPinned++; + } else { + numUnpinned++; + } + if (tab.id === goalTabId) { + goalIndex = curIndex; + } + ++curIndex; + } + if (numUnpinned === 0 && numPinned === 0) { + return -1; + } + // If our goal tab is visible, return it's index. + if (goalIndex >= 0) { + return goalIndex; + } + // Otherwise, if there are unpinned tabs, we use the first unpinned tab. + if (numUnpinned > 0) { + return numPinned; + } + // If there are no unpinned tabs but there are pinned tabs, return the + // the first pinned tab (which is always 0) + if (numPinned > 0) { + return 0; + } + // Otherwise there are no visible tabs at all, so we return -1 to indicate + // that no selection should be used. + return -1; + }, + moveSelection(delta) { + // This is awkward because we don't have a datastructure that can answer + // positional information about tabs cheaply. + let tabs = Array.from(SideTab.getAllTabsViews(), el => { + return this.tabs.get(SideTab.tabIdForView(el)); + }); + let visibleTabs = tabs.filter(tab => tab.visible); + // Note: this._selected can be null (if they start using the arrows when + // ther's nothing in the searchbox), and this will do the right thing here + // so long as tab.id is never null. + let curSelectedIndex = visibleTabs.findIndex(t => t.id === this._selected); + if (curSelectedIndex < 0) { + curSelectedIndex = this._getInitialSelectionIndex(); + if (curSelectedIndex < 0) { + // No visible tabs, or hypothetically some other reason we shouldn't + // be selecting anything. + this.clearSelection(); + return; + } + } + let nextIndex = curSelectedIndex + delta; + // Clamp to [0, visibleTabs.length), so that navigating up or down + // off the end sticks in place. + nextIndex = Math.max(0, Math.min(visibleTabs.length - 1, nextIndex)); + this.setSelectionIndex(nextIndex); + }, + commitSelection(clearSearch) { + if (this._selected === null) { + return; + } + browser.tabs.update(this._selected, {active: true}); + if (clearSearch) { + this.clearSearch(); + } }, async populate(windowId) { if (windowId && this.windowId === null) { @@ -619,6 +727,8 @@ SideTabList.prototype = { if (!sidetab) { return; } + // Clear our selection whenever the tab list changes. (Is this too eager?) + this.clearSelection(); let element = sidetab.view; let parent = sidetab.pinned ? this.pinnedview : this.view; let elements = SideTab.getAllTabsViews();