feat(theseus): Firefox-style tab overflow — scroll arrows, earliest tabs slide out

Tabs keep a readable minimum width (76px). Once they overflow, the row
scrolls: the earliest tabs slide out on the left and an arrow at each end
moves the row by 60% of its width, disabled at its end of travel and
hidden while everything fits. The wheel scrolls the row too, the selected
tab is brought into view when the selection changes (never while the user
is scrolling), and the + button stays outside the row. Moves are immediate
rather than animated: the chrome view has no smooth scrolling, and frame
callbacks stop while the window is occluded, which stranded a frame-driven
slide at its start.
This commit is contained in:
Local Dev 2026-09-20 16:50:24 +02:00
parent f5877bc890
commit b7b705827a

View file

@ -55,10 +55,18 @@
matter how many tabs are open; the row scrolls (wheel) once tabs hit
their minimum width, like Chrome. */
.tabs > .newtab { flex: none; margin-left: 2px; }
/* Firefox-style overflow: once tabs hit their minimum width the row
scrolls, the earliest tabs slide out on the left, and an arrow at each
end moves the row (they only appear when there is something to scroll). */
.tabscroll { flex: none; width: 24px; height: 28px; margin: 0 2px; padding: 0; border: 1px solid var(--line2); border-bottom: none;
border-radius: 8px 8px 0 0; background: var(--surface); color: var(--mut); cursor: pointer; font: 16px/1 system-ui, sans-serif;
-webkit-app-region: no-drag; }
.tabscroll:hover { color: var(--ink); background: var(--hover); }
.tabscroll:disabled { opacity: .3; cursor: default; }
/* Same-size tabs: each tab claims an equal share of the row, capped at
200px so 2 tabs don't stretch across the whole window. With many tabs
they shrink down to icon + close (34px) before anything is clipped. */
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 34px; padding: 6px 10px;
.tab { display: flex; align-items: center; gap: 8px; flex: 1 1 0; max-width: 200px; min-width: 76px; padding: 6px 10px;
background: var(--surface); border: 1px solid var(--line2); border-bottom: none; border-radius: 8px 8px 0 0;
font-size: 12.5px; cursor: default; color: var(--mut); user-select: none; }
.tab.dragging { opacity: .45; cursor: grabbing; }
@ -421,7 +429,7 @@
.bcnrbar .bdismiss { color: var(--dim); text-decoration: none; } .bcnrbar .bdismiss:hover { color: var(--ink); }
</style></head>
<body>
<div class="tabs" id="tabstrip"><div class="tabrow" id="tabs"></div></div>
<div class="tabs" id="tabstrip"><button class="tabscroll" id="tabsLeft" title="Scroll tabs left" hidden></button><div class="tabrow" id="tabs"></div><button class="tabscroll" id="tabsRight" title="Scroll tabs right" hidden></button></div>
<div class="bar" id="bar">
<div class="nav">
<button class="ic" id="back" title="Back"><svg viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round"><path d="M13 8H3M6 5L3 8l3 3"/></svg></button>
@ -1338,21 +1346,60 @@
const keep = new Set(order);
for (const el of [...box.children]) if (!keep.has(el)) el.remove();
order.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); });
// Keep the selected tab in view when the row has scrolled.
// Bring the selected tab into view when the SELECTION changes (or a tab
// appears). Not on every tabs event — those fire on every title/favicon/
// loading change, and re-snapping then would undo the user's own
// scrolling with the arrows or the wheel.
const act = box.querySelector(".tab.active");
if (act && box.scrollWidth > box.clientWidth) {
const l = act.offsetLeft, r = l + act.offsetWidth;
if (l < box.scrollLeft) box.scrollLeft = l - 4;
else if (r > box.scrollLeft + box.clientWidth) box.scrollLeft = r - box.clientWidth + 4;
if (act) {
const key = act.dataset.id;
if (key !== lastScrolledKey && performance.now() > userScrollUntil && box.scrollWidth > box.clientWidth) {
const l = act.offsetLeft, r = l + act.offsetWidth;
if (l < box.scrollLeft) box.scrollLeft = l - 4;
else if (r > box.scrollLeft + box.clientWidth) box.scrollLeft = r - box.clientWidth + 4;
}
lastScrolledKey = key;
}
updateTabScroll();
}
let lastScrolledKey = null;
// While the user is scrolling the row (arrows / wheel), renders must not
// snap it back to the selected tab.
let userScrollUntil = 0;
// Arrows at both ends of the row: shown only while the row overflows,
// each disabled at its end of travel. A click moves by ~60% of the
// visible width; the wheel scrolls too.
function updateTabScroll() {
const box = $("tabs"), left = $("tabsLeft"), right = $("tabsRight");
const over = box.scrollWidth > box.clientWidth + 1;
left.hidden = right.hidden = !over;
if (!over) return;
left.disabled = box.scrollLeft <= 0;
right.disabled = box.scrollLeft >= box.scrollWidth - box.clientWidth - 1;
}
// Immediate, not animated: the chrome view has no smooth scrolling
// (scrollBy({behavior:"smooth"}) moves nothing) and animation frames
// stop whenever the window is occluded, which left a frame-driven slide
// stuck at its start.
function slideTabs(dir) {
const box = $("tabs");
userScrollUntil = performance.now() + 1500;
box.scrollLeft = Math.max(0, Math.min(box.scrollWidth - box.clientWidth, box.scrollLeft + dir * Math.round(box.clientWidth * 0.6)));
updateTabScroll(); // the scroll event itself waits for a frame
}
$("tabsLeft").onclick = () => slideTabs(-1);
$("tabsRight").onclick = () => slideTabs(1);
$("tabs").addEventListener("scroll", updateTabScroll, { passive: true });
window.addEventListener("resize", () => requestAnimationFrame(updateTabScroll));
// Wheel over the row scrolls it sideways (there is no visible scrollbar).
$("tabs").addEventListener("wheel", (e) => {
const box = $("tabs");
if (box.scrollWidth <= box.clientWidth) return;
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (!d) return;
userScrollUntil = performance.now() + 1500;
box.scrollLeft += d;
updateTabScroll();
e.preventDefault();
}, { passive: false });