From f3c90abeb0d2a36ac97ff680fdb5fe9113d30681 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:58:40 +0800 Subject: [PATCH] fix(header): settle the divider when readings change, keep density to the header layout, restore the chosen width --- docs/wiki/The-Dashboard.md | 6 + src/web/public/header-divider.js | 100 +++++++--- src/web/public/styles.css | 94 +++++---- test/header-divider.browser.test.ts | 300 ++++++++++++++++++++-------- 4 files changed, 347 insertions(+), 153 deletions(-) diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index b40ab49b..764afc94 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -178,6 +178,12 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w Hiding System Stats or Plan Usage still hides them in every style. +The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar +(it wraps onto a second line and the usage readings shed their labels, then their provider names, +then their percentages), left to widen it. Arrow keys resize it, and double-click or Home resets it. +It stops at the widest reading, the width is kept per device, and it is not shown on phones, in the +sidebar layout, in the vertical rail or in a solo window. + New header controls never appear on phones. Phone layout is deliberately minimal and is covered in [Mobile Guide](Mobile-Guide). diff --git a/src/web/public/header-divider.js b/src/web/public/header-divider.js index ded3ea86..33fc5603 100644 --- a/src/web/public/header-divider.js +++ b/src/web/public/header-divider.js @@ -63,9 +63,14 @@ handle.setAttribute('role', 'separator'); handle.setAttribute('aria-orientation', 'vertical'); handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)'); + handle.setAttribute('aria-valuemin', String(MIN_WIDTH)); handle.tabIndex = 0; right.insertBefore(handle, right.firstChild); + // The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading + // that needs more room), and the request is what comes back when the window grows again. + let wanted = readStored(); + const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 }); const current = () => right.getBoundingClientRect().width; @@ -87,20 +92,20 @@ else root.removeAttribute('data-header-density'); } - function apply(width, persist) { - if (width === null) { + /** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */ + function settle() { + if (wanted === null) { root.style.removeProperty('--header-right-width'); root.removeAttribute('data-header-right-width'); root.removeAttribute('data-header-density'); handle.removeAttribute('aria-valuenow'); - if (persist) writeStored(null); + handle.removeAttribute('aria-valuemax'); return; } const { header: h, brand: b } = bounds(); - let clamped = clampWidth(width, h, b); + let clamped = clampWidth(wanted, h, b); root.setAttribute('data-header-right-width', 'set'); - // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum: settle - // on a width no reading overflows at the density that width gets. + // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum. for (let i = 0; i < 3; i++) { root.style.setProperty('--header-right-width', clamped + 'px'); setDensity(clamped); @@ -109,49 +114,92 @@ clamped = floor; } handle.setAttribute('aria-valuenow', String(clamped)); - if (persist) writeStored(clamped); + handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b))); } + /** Several changes in one frame (a chip refill, a resize) settle once. */ + let settleFrame = 0; + function scheduleSettle() { + if (wanted === null || settleFrame) return; + settleFrame = requestAnimationFrame(() => { + settleFrame = 0; + settle(); + }); + } + + /** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */ + function relayout() { + window.app?.updateTabOverflowMode?.(); + window.app?.updateConnectionLines?.(); + } + + function apply(width) { + wanted = width === null ? null : Math.round(width); + writeStored(wanted); + settle(); + relayout(); + } + + // Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row + // can start reporting later, a header button can be switched on): re-settle when any child changes size. + const resizeObserver = new ResizeObserver(scheduleSettle); + const watch = () => { + for (const child of right.children) if (child !== handle) resizeObserver.observe(child); + }; + watch(); + new MutationObserver(() => { + watch(); + scheduleSettle(); + }).observe(right, { childList: true }); + handle.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; event.preventDefault(); handle.setPointerCapture?.(event.pointerId); document.body.classList.add('header-divider-dragging'); - const onMove = (e) => apply(header.getBoundingClientRect().right - e.clientX - 12, false); - const onUp = () => { + const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0; + let moveFrame = 0; + let lastX = event.clientX; + const onMove = (e) => { + lastX = e.clientX; + if (moveFrame) return; + moveFrame = requestAnimationFrame(() => { + moveFrame = 0; + wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight); + settle(); + }); + }; + const finish = () => { handle.removeEventListener('pointermove', onMove); - handle.removeEventListener('pointerup', onUp); - handle.removeEventListener('pointercancel', onUp); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + handle.removeEventListener('lostpointercapture', finish); + if (moveFrame) cancelAnimationFrame(moveFrame); + moveFrame = 0; document.body.classList.remove('header-divider-dragging'); - const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); - if (Number.isFinite(width)) writeStored(width); + if (wanted !== null) apply(wanted); }; handle.addEventListener('pointermove', onMove); - handle.addEventListener('pointerup', onUp); - handle.addEventListener('pointercancel', onUp); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + handle.addEventListener('lostpointercapture', finish); }); - handle.addEventListener('dblclick', () => apply(null, true)); + handle.addEventListener('dblclick', () => apply(null)); handle.addEventListener('keydown', (event) => { if (event.key === 'Home' || event.key === 'Escape') { - apply(null, true); + apply(null); } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - const step = event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP; - apply(current() + step, true); + apply((wanted ?? current()) + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP)); } else { return; } event.preventDefault(); }); - window.addEventListener('resize', () => { - const width = Number.parseFloat(root.style.getPropertyValue('--header-right-width')); - if (Number.isFinite(width)) apply(width, false); - }); - - const stored = readStored(); - if (stored !== null) apply(stored, false); + window.addEventListener('resize', scheduleSettle); + settle(); } window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM }; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a8c175fa..de886aae 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2275,47 +2275,66 @@ html[data-tab-orientation='vertical'].tab-rail-compact box-shadow: inset 0 0 0 2px var(--accent); } +/* Everything below applies only where the tabs share the header with the toolbar, i.e. where the handle is + visible to undo it: not in the sidebar layout, the vertical rail or a solo window, and not on a phone. */ + /* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */ -html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) - body:not(.solo-mode) - .header-right { - flex: 0 0 var(--header-right-width); - width: var(--header-right-width); - flex-wrap: wrap; - justify-content: flex-end; - align-content: center; - row-gap: 0.25rem; - min-height: var(--header-height); -} +@media (min-width: 769px) { + html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + .header-right { + flex: 0 0 var(--header-right-width); + width: var(--header-right-width); + flex-wrap: wrap; + justify-content: flex-end; + align-content: center; + row-gap: 0.25rem; + min-height: var(--header-height); + } -/* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): - tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. - The same rules apply under every header-stats style. */ -html[data-header-density] .header-system-stats .stat-label, -html[data-header-density] .header-plan-usage .pu-label { - display: none; -} + /* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): + tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. + The prefix outweighs the per-style rules further down, so every header-stats style follows it. */ + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats .stat-label, .header-plan-usage .pu-label) { + display: none; + } -html[data-header-density] .header-system-stats, -html[data-header-density] .header-plan-usage { - gap: 8px; - padding: 0 8px; -} + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats, .header-plan-usage) { + gap: 8px; + padding: 0 8px; + } -html[data-header-density='dense'] .header-plan-usage .pu-provider, -html[data-header-density='tiny'] .header-plan-usage .pu-provider { - font-size: 0; -} + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider { + font-size: 0; + } -html[data-header-density='dense'] .header-plan-usage .pu-provider::after, -html[data-header-density='tiny'] .header-plan-usage .pu-provider::after { - content: attr(data-short); - font-size: 0.6rem; -} + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider::after { + content: attr(data-short); + font-size: 0.6rem; + } -html[data-header-density='tiny'] .header-system-stats .stat-value, -html[data-header-density='tiny'] .header-plan-usage .pu-val { - display: none; + /* Rings-only is a Compact-style reading: the other styles have no rings, so they keep their values. */ + html[data-header-stats='compact'][data-header-density='tiny']:not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + :is(.header-system-stats .stat-value, .header-plan-usage .pu-val) { + display: none; + } } body.header-divider-dragging, @@ -2335,11 +2354,6 @@ body.solo-mode .header-divider { .header-divider { display: none; } - - html[data-header-right-width] .header-right { - flex: 0 1 auto; - width: auto; - } } /* Connection Indicator */ diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts index 1aa105a6..7f12f298 100644 --- a/test/header-divider.browser.test.ts +++ b/test/header-divider.browser.test.ts @@ -1,4 +1,8 @@ -/** Real Chromium: dragging the header divider resizes the toolbar and wraps it onto a second line. */ +/** + * Real Chromium, against the real header markup from index.html and the real styles.css: dragging the divider + * resizes the toolbar and wraps it onto a second line, the readings shed detail as it narrows, nothing pokes + * out past the divider (even for content that arrives later), and none of it applies where the handle is hidden. + */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -7,19 +11,28 @@ import { chromium, type Browser, type Page } from 'playwright'; const dir = resolve(import.meta.dirname, '../src/web/public'); const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8'); const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8'); +const header = /
[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0]; -const page = (root = 'data-tab-orientation="horizontal" data-session-list="header"') => ` - -
-
-
-
-
usage
-
stats
- -
-
-`; +const CHIP = + 'Claude' + + '5h49%' + + '7d72%' + + 'Copilot' + + 'mo81%'; + +const ORIGIN = 'http://app.test/'; + +/** The default layout attributes, with any `name="value"` in `extra` replacing the matching default. */ +function rootAttrs(extra = '') { + const attrs = new Map([ + ['data-header-stats', 'compact'], + ['data-tab-orientation', 'horizontal'], + ['data-session-list', 'header'], + ]); + for (const [, name, value] of extra.matchAll(/([\w-]+)="([^"]*)"/g)) attrs.set(name, value); + return [...attrs].map(([name, value]) => `${name}="${value}"`).join(' '); +} +type Attrs = { root?: string; body?: string }; describe('header divider (real Chromium)', () => { let browser: Browser; @@ -31,100 +44,213 @@ describe('header divider (real Chromium)', () => { await browser.close(); }); - async function open(html: string) { - p = await browser.newPage({ viewport: { width: 1400, height: 600 } }); - await p.setContent(html); + /** Open the real header; `stored` is the width kept from an earlier visit, `chip` fills the plan-usage chip. */ + async function open(options: { root?: string; body?: string; stored?: number; chip?: boolean; width?: number } = {}) { + p = await browser.newPage({ viewport: { width: options.width ?? 1400, height: 600 } }); + await p.route(ORIGIN, (route) => + route.fulfill({ + contentType: 'text/html', + body: `${header}`, + }) + ); + await p.goto(ORIGIN); + if (options.stored) + await p.evaluate((w) => localStorage.setItem('codeman-header-right-width', String(w)), options.stored); + if (options.chip) { + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + } await p.addScriptTag({ content: controller }); } const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON()); - - it('narrows the toolbar and wraps it onto a second line when dragged right, and resets on double-click', async () => { - await open(page()); - const before = await box('#headerRight'); + const settled = () => p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + const dragTo = async (toolbarWidth: number) => { const handle = await box('.header-divider'); - const y = handle.top + handle.height / 2; - await p.mouse.move(handle.left + 6, y); + const head = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + 10); await p.mouse.down(); - await p.mouse.move(handle.left + 6 + 330, y, { steps: 6 }); + await p.mouse.move(head.right - 12 - toolbarWidth, handle.top + 10, { steps: 6 }); await p.mouse.up(); - const after = await box('#headerRight'); - expect(after.width).toBeLessThan(before.width - 250); - expect(after.height).toBeGreaterThan(before.height + 10); - // setContent gives an opaque origin: storage is blocked, and the drag must still work. - expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).not.toBe( - '' - ); - const tabs = await box('.session-tabs-host'); - expect(tabs.width).toBeGreaterThan(before.width); - - await p.locator('.header-divider').dblclick(); - const reset = await box('#headerRight'); - expect(Math.abs(reset.width - before.width)).toBeLessThan(2); - expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).toBe(''); - await p.close(); - }); - - it('drops usage labels, then provider names, then percentages as the toolbar narrows', async () => { - const chip = - '
Claude' + - '5h49%
'; - await open(page('data-header-stats="compact" data-tab-orientation="horizontal" data-session-list="header"')); - await p.evaluate((html) => document.getElementById('headerRight')!.insertAdjacentHTML('beforeend', html), chip); - const visible = (sel: string) => p.locator(sel).isVisible(); - expect(await visible('.pu-label')).toBe(true); - const sizes: Array<[number, string]> = [ - [450, 'tight'], - [330, 'dense'], - [200, 'tiny'], - ]; - for (const [width, density] of sizes) { - const handle = await box('.header-divider'); - const header = await box('.header'); - await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); - await p.mouse.down(); - await p.mouse.move(header.right - width - 12, handle.top + handle.height / 2, { steps: 5 }); - await p.mouse.up(); - expect(await p.evaluate(() => document.documentElement.dataset.headerDensity)).toBe(density); - } - expect(await visible('.pu-label')).toBe(false); - expect(await visible('.pu-val')).toBe(false); - expect(await p.locator('.pu-provider').evaluate((el) => getComputedStyle(el, '::after').content)).toBe('"CL"'); - await p.close(); - }); - - it('stops the divider at the widest reading so nothing pokes out past it', async () => { - await open(page()); - const handle = await box('.header-divider'); - const header = await box('.header'); - await p.mouse.move(handle.left + 6, handle.top + handle.height / 2); - await p.mouse.down(); - await p.mouse.move(header.right - 20, handle.top + handle.height / 2, { steps: 8 }); - await p.mouse.up(); - const right = await box('#headerRight'); - const edge = right.left + 1; + await settled(); + }; + const readingsInside = async () => { + const edge = (await box('#headerRight')).left + 1; const lefts = await p .locator('#headerRight > :not(.header-divider)') - .evaluateAll((els) => els.map((el) => el.getBoundingClientRect().left)); - for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + .evaluateAll((els) => + els.filter((el) => (el as HTMLElement).offsetParent !== null).map((el) => el.getBoundingClientRect().left) + ); + return { edge, lefts }; + }; + const display = (sel: string) => + p + .locator(sel) + .first() + .evaluate((el) => getComputedStyle(el).display); + + it('narrows the toolbar and wraps it when dragged right, and resets on double-click', async () => { + await open({ chip: true }); + const before = await box('#headerRight'); + await dragTo(330); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 100); + expect(after.height).toBeGreaterThan(before.height + 10); + expect((await box('.session-tabs-host')).width).toBeGreaterThan(before.width / 2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).not.toBeNull(); + await p.locator('.header-divider').dblclick(); + expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).toBeNull(); await p.close(); }); - it('resizes from the keyboard', async () => { - await open(page()); + it('resizes from the keyboard and tells the tab strip to re-measure', async () => { + await open({ chip: true }); + await p.evaluate(() => { + (window as any).relayouts = 0; + (window as any).app = { + updateTabOverflowMode: () => (window as any).relayouts++, + updateConnectionLines: () => {}, + }; + }); const before = await box('#headerRight'); await p.locator('.header-divider').focus(); await p.keyboard.press('ArrowRight'); await p.keyboard.press('ArrowRight'); - const after = await box('#headerRight'); - expect(after.width).toBeLessThan(before.width - 20); + expect((await box('#headerRight')).width).toBeLessThan(before.width - 20); + expect(await p.evaluate(() => (window as any).relayouts)).toBeGreaterThanOrEqual(2); await p.keyboard.press('Home'); expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); await p.close(); }); - it('is hidden when the tabs live in the sidebar', async () => { - await open(page('data-tab-orientation="horizontal" data-session-list="sidebar"')); - expect(await p.locator('.header-divider').isVisible()).toBe(false); + it('stops the divider at the widest reading so nothing pokes out past it', async () => { + await open({ chip: true }); + await dragTo(20); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a reading inside the divider when it arrives after the width was applied', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a wide reading that is switched on later inside the divider', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate(() => { + const wide = document.createElement('div'); + wide.id = 'lateReading'; + wide.style.cssText = 'width:320px;flex:none;height:24px'; + document.getElementById('headerRight')!.appendChild(wide); + }); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + expect((await box('#headerRight')).width).toBeGreaterThanOrEqual(320); + await p.close(); + }); + + it('sheds labels, then provider names, then percentages as the toolbar narrows', async () => { + await open({ chip: true }); + expect(await display('.pu-label')).not.toBe('none'); + const seen: string[] = []; + for (const width of [450, 330, 200]) { + await dragTo(width); + seen.push((await p.evaluate(() => document.documentElement.dataset.headerDensity)) ?? ''); + } + expect(seen).toEqual(['tight', 'dense', 'tiny']); + expect(await display('.pu-label')).toBe('none'); + expect(await display('.pu-val')).toBe('none'); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el, '::after').content) + ).toBe('"CL"'); + await p.close(); + }); + + for (const style of ['tiles', 'classic']) { + it(`follows the density steps under the ${style} style, keeping values at the narrowest step`, async () => { + await open({ chip: true, root: `data-header-stats="${style}"` }); + await dragTo(330); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el).fontSize) + ).toBe('0px'); + await dragTo(200); + expect(await display('.pu-val')).not.toBe('none'); + await p.close(); + }); + } + + for (const [name, root, body] of [ + ['the sidebar layout', 'data-session-list="sidebar"', ''], + ['the vertical rail', 'data-tab-orientation="vertical"', ''], + ['a solo window', '', 'solo-mode'], + ] as const) { + it(`leaves a stored narrow width alone in ${name}, where the handle is hidden`, async () => { + await open({ chip: true, stored: 180, root, body }); + // the stored width is read at init, which already ran inside open() before storage was set + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '180')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-divider').isVisible()).toBe(false); + expect(await display('.stat-label')).not.toBe('none'); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('180px'); + await p.close(); + }); + } + + it('does not pin the toolbar to a stored width on a narrow viewport', async () => { + await open({ chip: true, width: 700 }); + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '300')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('300px'); + await p.close(); + }); + + it('gets the chosen width back when the window shrinks and grows again', async () => { + await open({ chip: true }); + await dragTo(700); + const chosen = (await box('#headerRight')).width; + await p.setViewportSize({ width: 1000, height: 600 }); + await settled(); + expect((await box('#headerRight')).width).toBeLessThan(chosen); + await p.setViewportSize({ width: 1400, height: 600 }); + await settled(); + expect(Math.abs((await box('#headerRight')).width - chosen)).toBeLessThan(2); + await p.close(); + }); + + it('clears the dragging state when the pointer capture is lost', async () => { + await open({ chip: true }); + const handle = await box('.header-divider'); + await p.mouse.move(handle.left + 6, handle.top + 10); + await p.mouse.down(); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(true); + await p.locator('.header-divider').evaluate((el) => el.dispatchEvent(new PointerEvent('lostpointercapture'))); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(false); + await p.mouse.up(); await p.close(); }); });