mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
With five tabs open on a phone, the right-hand tabs were effectively unreachable. Selecting a tab only toggled the .active class, so the strip never moved, and every full rebuild (a task badge appearing, a session created elsewhere) replaced the strip's innerHTML, which resets scrollLeft to 0 and yanked a mid-swipe strip back to the first tab. Three changes, which only work together: * computeTabScrollLeft() (pure, constants.js) decides the scroll target from measured rects, and _scrollActiveTabIntoView() applies it on selection. Rect math on the strip's own scrollLeft rather than scrollIntoView(), which also scrolls ancestors: on a phone that is the document, under a fixed header and possibly an open keyboard. * _fullRenderSessionTabs() saves and restores scrollLeft across the rebuild, and re-reveals the active tab only when it actually changed (_lastRenderedActiveTabId), so a background render never undoes a manual swipe. * Mobile no longer hoists the active session to the front of the strip. That reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scrolling the active tab into view replaces it. Also sets overscroll-behavior-x: contain on the strip so a swipe that runs past the last tab stays in the strip instead of becoming the browser's back gesture. Tests: scroll-target math in test/tab-overflow.test.ts (runs in CI), plus five browser regressions in test/mobile/tabs.test.ts covering reveal-on- select in both directions, scroll preservation across an ambient rebuild, sessionOrder rendering on phones, and a real touch drag reaching the last tab. Closes #257 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
type ScrollInput = {
|
|
scrollLeft?: number;
|
|
clientWidth?: number;
|
|
scrollWidth?: number;
|
|
tabLeft?: number;
|
|
tabWidth?: number;
|
|
padding?: number;
|
|
};
|
|
|
|
function loadTabOverflowHelper() {
|
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
|
return (
|
|
context.window as {
|
|
CodemanTabOverflow: {
|
|
shouldAutoWrapTabs: (input: unknown) => boolean;
|
|
computeTabScrollLeft: (input: ScrollInput) => number;
|
|
TAB_SCROLL_REVEAL_PX: number;
|
|
};
|
|
}
|
|
).CodemanTabOverflow;
|
|
}
|
|
|
|
describe('tab overflow layout policy', () => {
|
|
it('auto-wraps desktop tabs when their rendered width exceeds available tab space', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
|
|
expect(
|
|
helper.shouldAutoWrapTabs({
|
|
deviceType: 'desktop',
|
|
manualTwoRows: false,
|
|
tabCount: 18,
|
|
scrollWidth: 1400,
|
|
clientWidth: 760,
|
|
})
|
|
).toBe(true);
|
|
});
|
|
|
|
it('does not auto-wrap when manual tall tabs are enabled or on mobile/tablet', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
|
|
expect(
|
|
helper.shouldAutoWrapTabs({
|
|
deviceType: 'desktop',
|
|
manualTwoRows: true,
|
|
tabCount: 18,
|
|
scrollWidth: 1400,
|
|
clientWidth: 760,
|
|
})
|
|
).toBe(false);
|
|
expect(
|
|
helper.shouldAutoWrapTabs({
|
|
deviceType: 'mobile',
|
|
manualTwoRows: false,
|
|
tabCount: 18,
|
|
scrollWidth: 1400,
|
|
clientWidth: 320,
|
|
})
|
|
).toBe(false);
|
|
});
|
|
|
|
it('respects the boundary conditions (exact fit, +1 tolerance, and tabCount < 2)', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
const base = { deviceType: 'desktop' as const, manualTwoRows: false, tabCount: 6 };
|
|
|
|
// Exact fit: no overflow, no wrap.
|
|
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 800, clientWidth: 800 })).toBe(false);
|
|
// Within the +1 sub-pixel tolerance: still no wrap.
|
|
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 801, clientWidth: 800 })).toBe(false);
|
|
// 2px over: wrap.
|
|
expect(helper.shouldAutoWrapTabs({ ...base, scrollWidth: 802, clientWidth: 800 })).toBe(true);
|
|
// A single overflowing tab must not wrap (need at least 2 to form a second row).
|
|
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Issue #257: the phone tab strip scrolls horizontally, so the active tab can
|
|
// sit entirely outside the visible slice. These pin the scroll target math that
|
|
// _scrollActiveTabIntoView() feeds with measured rects.
|
|
describe('mobile tab strip scroll-into-view policy', () => {
|
|
// A 5-tab phone strip: 335px visible of 558px of tabs.
|
|
const strip = { clientWidth: 335, scrollWidth: 558 };
|
|
const pad = 16;
|
|
|
|
it('scrolls right to reveal a tab past the right edge, leaving the reveal sliver', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
// Last tab: 458..558, strip parked at 0.
|
|
const target = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 458, tabWidth: 100 });
|
|
// 558 + 16 - 335 = 239, clamped to the 223px maximum.
|
|
expect(target).toBe(223);
|
|
// The revealed tab is now inside the window.
|
|
expect(458).toBeGreaterThanOrEqual(target);
|
|
expect(558).toBeLessThanOrEqual(target + strip.clientWidth);
|
|
});
|
|
|
|
it('scrolls left to reveal a tab before the left edge', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
// First tab: 0..150, strip scrolled to the end.
|
|
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 0, tabWidth: 150 })).toBe(0);
|
|
// A middle tab partially cut off on the left: reveal it with the sliver.
|
|
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 223, tabLeft: 200, tabWidth: 100 })).toBe(200 - pad);
|
|
});
|
|
|
|
it('leaves an already-visible tab alone (callers skip the write)', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
expect(helper.computeTabScrollLeft({ ...strip, scrollLeft: 100, tabLeft: 152, tabWidth: 100 })).toBe(100);
|
|
});
|
|
|
|
it('never scrolls a strip that fits, and never leaves the scrollable range', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
// Everything fits: nothing to scroll, whatever the tab geometry says.
|
|
expect(
|
|
helper.computeTabScrollLeft({ clientWidth: 900, scrollWidth: 400, scrollLeft: 0, tabLeft: 300, tabWidth: 100 })
|
|
).toBe(0);
|
|
// Clamped at both ends.
|
|
const low = helper.computeTabScrollLeft({ ...strip, scrollLeft: 40, tabLeft: 4, tabWidth: 100 });
|
|
expect(low).toBe(0);
|
|
const high = helper.computeTabScrollLeft({ ...strip, scrollLeft: 0, tabLeft: 500, tabWidth: 58 });
|
|
expect(high).toBeLessThanOrEqual(strip.scrollWidth - strip.clientWidth);
|
|
});
|
|
|
|
it('aligns the start of a tab too wide to fit the window', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
// 330px tab in a 335px window: no position shows it plus padding.
|
|
expect(
|
|
helper.computeTabScrollLeft({ clientWidth: 335, scrollWidth: 900, scrollLeft: 0, tabLeft: 400, tabWidth: 330 })
|
|
).toBe(400);
|
|
});
|
|
|
|
it('tolerates missing measurements instead of producing NaN', () => {
|
|
const helper = loadTabOverflowHelper();
|
|
expect(helper.computeTabScrollLeft({})).toBe(0);
|
|
expect(helper.computeTabScrollLeft(undefined as unknown as ScrollInput)).toBe(0);
|
|
expect(helper.TAB_SCROLL_REVEAL_PX).toBe(pad);
|
|
});
|
|
});
|