Merge pull request #721: feat(tabs): L-shaped header strip, wrapped rows run under the toolbar

This commit is contained in:
Codeman maintainer committed 2026-10-28 09:10:09 +01:00
commit 593b2eb2ea
6 files changed
+359 -2

No files matched your search

+89 -1
View File
@@ -5950,7 +5950,7 @@ class CodemanApp {
// tabs-auto-wrap is measured, not derived from settings — updateTabOverflowMode()
// drops it in sidebar mode, but drop it here too so nothing paints wrapped
// for a frame before the next measure.
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap');
if (sidebar) tabsEl.classList.remove('tabs-auto-wrap', 'tabs-corner');
// Collapse/expand changes whether the filter is reachable, so re-evaluate it
// here too — not only at the render tails.
this.applySidebarFilter(this._sidebarFilter);
@@ -6941,7 +6941,14 @@ class CodemanApp {
updateTabOverflowMode() {
const container = this.$('sessionTabs');
if (!container) return;
// The corner takes the toolbar out of the header's flow, which widens the
// strip, so it goes before the wrap is measured and comes back once decided.
container.classList.remove('tabs-corner');
this._decideTabOverflow(container);
this._syncTabStripCorner(container);
}
_decideTabOverflow(container) {
// Lineage routing room (session-lineage.js) changes the strip's padding and
// row gap, so it is decided before the wrap is measured below.
this._syncLineageGutter?.();
@@ -7003,6 +7010,87 @@ class CodemanApp {
container.classList.toggle('tabs-auto-wrap', shouldWrap);
}
/**
* L-shaped header strip: once the classic strip wraps, the toolbar
* (#headerRight) sits over the strip's top-right corner (styles.css,
* `.tabs-corner`) and the rows under the first one run the header's full
* width beneath it. The strip keeps the toolbar's corner clear with a float
* sized here, from the toolbar's own box, so a toolbar that wraps onto more
* lines (header-divider.js) or gains a reading keeps every tab out from
* under it. The grouped arrangements (state, case, ledger) lay their rows
* out themselves and keep the plain strip.
*/
_syncTabStripCorner(container) {
const right = this.$('headerRight');
const on =
!!right &&
container.offsetParent !== null &&
!!container.parentElement?.classList.contains('session-tabs-host') &&
(container.classList.contains('tabs-auto-wrap') || container.classList.contains('tabs-two-rows')) &&
!container.classList.contains('tabs-triage') &&
!container.classList.contains('tabs-clusters') &&
!container.classList.contains('tabs-ledger') &&
MobileDetection.getDeviceType() === 'desktop';
const off = () => {
container.classList.remove('tabs-corner');
container.style.removeProperty('--tab-corner-width');
container.style.removeProperty('--tab-corner-height');
};
// A strip past its max-height scrolls, and the float would scroll its tabs
// under the toolbar, which stays put: keep the plain wrap. The plain strip
// is measured here (updateTabOverflowMode took .tabs-corner off), before
// the class goes on; toggling it on a scrolled strip lets scroll anchoring
// move the strip.
if (!on || container.scrollHeight > container.clientHeight + 1) {
off();
return;
}
container.classList.add('tabs-corner');
if (!this._tabCornerObserver && typeof ResizeObserver !== 'undefined') {
let frame = 0;
// A toolbar that changes size (a reading arrives, the divider settles)
// moves the tabs without a render: re-decide the wrap and the corner, and
// redraw the lineage routes that follow the tabs.
this._tabCornerObserver = new ResizeObserver(() => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const strip = this.$('sessionTabs');
if (!strip || !/\btabs-(corner|auto-wrap|two-rows)\b/.test(strip.className)) return;
this.updateTabOverflowMode();
this.updateConnectionLines?.();
});
});
this._tabCornerObserver.observe(right);
}
const style = getComputedStyle(container);
const strip = container.getBoundingClientRect();
const box = right.getBoundingClientRect();
// The float sits at the strip's content padding edge (the plain strip did
// not scroll, so scrollTop is 0).
const contentTop = strip.top + parseFloat(style.paddingTop);
const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft);
const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight);
// A first tab that cannot fit beside the toolbar would drop the whole first
// line below it and leave an empty band: keep the plain strip instead.
const firstTab = container.querySelector(':scope > .session-tab');
if (firstTab && box.left - 8 - contentLeft < firstTab.offsetWidth + 2) {
off();
return;
}
const width = `${Math.max(0, Math.ceil(contentRight - box.left + 8))}px`;
const height = `${Math.max(0, Math.ceil(box.bottom - contentTop))}px`;
if (container.style.getPropertyValue('--tab-corner-width') !== width) {
container.style.setProperty('--tab-corner-width', width);
}
if (container.style.getPropertyValue('--tab-corner-height') !== height) {
container.style.setProperty('--tab-corner-height', height);
}
// A plain strip just under its max-height can pass it once the float is in:
// fall back to the plain strip, which fit.
if (container.scrollHeight > container.clientHeight + 1) off();
}
/**
* True when a case cluster in the header strip wraps inside its own box: a case
* wider than the whole strip (styles.css caps a box at the strip's width). The
+39
View File
@@ -616,6 +616,45 @@ body {
padding-left: var(--lineage-spine-channel);
}
/* L-shaped strip (`_syncTabStripCorner()`, app.js): a wrapped classic strip
runs the header's full width, the toolbar sits over its top-right corner,
and only the rows beside the toolbar stop short of it. A flex line cannot
be shortened on its own, so the strip lays its tabs out inline, around a
float the size of the toolbar's box (measured into --tab-corner-width and
--tab-corner-height). The strip is a scroll container, so it contains the
float and is never shorter than the toolbar it now holds up; a strip that
would actually scroll keeps the plain wrap instead. Inline tabs space their
rows with a bottom margin (the old row-gap, lineage's included); the strip
carries no font size of its own, so the whitespace between tabs is 0 wide. */
.header:has(> .session-tabs-host > .session-tabs.tabs-corner) > .header-right {
position: absolute;
top: 0.35rem;
right: 0.75rem;
}
.session-tabs-host > .session-tabs.tabs-corner {
display: block;
font-size: 0;
padding-bottom: 0;
}
.session-tabs-host > .session-tabs.tabs-corner::before {
content: '';
float: right;
width: var(--tab-corner-width, 0px);
height: var(--tab-corner-height, 0px);
}
.session-tabs-host > .session-tabs.tabs-corner > .session-tab {
display: inline-flex;
vertical-align: top;
margin: 0 2px var(--lineage-row-gap, 4px) 0;
}
.session-tabs-host > .session-tabs.lineage-tree.tabs-corner {
padding-bottom: calc(var(--lineage-bottom-room) - var(--lineage-row-gap));
}
.session-tabs::-webkit-scrollbar {
width: 4px;
height: 0;