// #508: the summary-panel settings dialog scrolls in Home Assistant — wheel and // touch. In HA `hp-dialog` renders `ha-dialog`, whose own `.body` is the // scroller; `.summary-editor` (overflow:auto + overscroll-behavior:contain) was // not height-bound there, and a scroll container that never scrolls stops // Chromium's scroll chaining for wheel and touch alike. `flex-content` binds it. // // The stub below reproduces the HA 2026.9 ha-dialog contract that matters: // a fixed, height-capped `dialog` laid out as a flex column, a `.body` with // overflow:auto, `:host([flexcontent]) .body` as a flex column, and the // full-screen media query for phones. It is registered BEFORE the dialog is // opened, so hp-dialog takes its HA branch. Witness (c) proves the stub // reproduces the bug: without `flexcontent` the editor does not scroll. import { launch, check, finish } from './serve.mjs'; const STUB = String.raw` class HaDialogStub extends HTMLElement { static get observedAttributes() { return ['open']; } constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '
' + '
' + '
'; } set open(value) { this._open = !!value; this._sync(); } get open() { return !!this._open; } connectedCallback() { this._sync(); queueMicrotask(() => this.dispatchEvent(new Event('opened'))); } _sync() { const dialog = this.shadowRoot.querySelector('dialog'); if (!dialog || !this.isConnected) return; if (this._open && !dialog.open) dialog.showModal(); if (!this._open && dialog.open) dialog.close(); } } customElements.define('ha-dialog', HaDialogStub); `; async function scenario(viewport, { flexContent = true } = {}) { const { page, browser } = await launch(viewport, 1, [], { hasTouch: viewport.width < 500 }); await page.waitForFunction(() => !!window.__card?._summary); const opened = await page.evaluate(async ({ stub, flexContent }) => { // eslint-disable-next-line no-new-func new Function(stub)(); const card = window.__card; const root = () => card.shadowRoot || card.renderRoot; const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); const settle = async () => { await card.updateComplete; await frame(); await card.updateComplete; }; card._config = { ...card._config, language: 'en', kiosk: false }; card._serverCanWrite = true; card._haSummaryPanelApi = 1; card.narrow = innerWidth < 500; const config = structuredClone(card._serverCfg); const blocks = []; for (let index = 0; index < 6; index += 1) { blocks.push({ id: `block-${index}`, title: `Block ${index}`, visible: true, scope: { type: 'all' }, values: [ { id: `devices-${index}`, label: 'Devices', source: { type: 'system', key: 'device_count' } }, { id: `area-${index}`, label: 'Floor area', source: { type: 'system', key: 'total_area' } }, { id: `clock-${index}`, label: 'Date and time', source: { type: 'system', key: 'datetime' } }, ] }); } config.settings = { ...config.settings, summary_panel: { version: 1, title: 'Summary', show_on_mobile: true, blocks } }; card._serverCfg = config; card._settings = config.settings; card._sendConfigCandidate = async () => {}; card._summary.updated(); card.requestUpdate(); await settle(); root().querySelector('.summary-control')?.querySelector('button:first-child')?.click(); for (let i = 0; i < 80 && !root().querySelector('hp-dialog[data-kind="summary"]'); i += 1) { await frame(); } const dialog = root().querySelector('hp-dialog[data-kind="summary"]'); if (!dialog) return { dialog: false }; if (!flexContent) { dialog.flexContent = false; dialog.removeAttribute('flex-content'); } await dialog.updateComplete; await settle(); await new Promise((resolve) => setTimeout(resolve, 150)); const editor = dialog.querySelector('.summary-editor'); const ha = dialog.shadowRoot.querySelector('ha-dialog'); const body = ha?.shadowRoot.querySelector('.body'); const native = ha?.shadowRoot.querySelector('dialog'); const rect = (node) => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }; const footer = dialog.querySelector('[slot="footer"]'); window.__508 = { dialog, editor, body }; return { dialog: true, haBranch: !!ha, flexContent: ha?.hasAttribute('flexcontent') === true, editor: rect(editor), editorScroll: [editor.scrollHeight, editor.clientHeight], body: rect(body), bodyScroll: [body.scrollHeight, body.clientHeight], dialogRect: rect(native), footer: footer ? rect(footer) : null, inner: [innerWidth, innerHeight], }; }, { stub: STUB, flexContent }); const readScroll = () => page.evaluate(() => ({ editor: window.__508.editor.scrollTop, body: window.__508.body.scrollTop })); return { page, browser, opened, readScroll }; } // (a) desktop: the wheel over the content scrolls .summary-editor; header/footer stay { const { page, browser, opened, readScroll } = await scenario({ width: 1280, height: 720 }); check('desktop.haBranch', opened.haBranch); check('desktop.flexContent', opened.flexContent); check('desktop.editorIsTheScroller', opened.editorScroll[0] > opened.editorScroll[1] + 50); check('desktop.bodyDoesNotScroll', opened.bodyScroll[0] <= opened.bodyScroll[1] + 1); const footerBefore = opened.footer; await page.mouse.move(opened.editor.x + opened.editor.w / 2, opened.editor.y + Math.min(opened.editor.h / 2, 160)); await page.mouse.wheel(0, 240); await page.waitForTimeout(250); const after = await readScroll(); check('desktop.wheelScrollsEditor', after.editor >= 100); const footerAfter = await page.evaluate(() => { const r = window.__508.dialog.querySelector('[slot="footer"]').getBoundingClientRect(); return { y: r.y, h: r.height }; }); check('desktop.footerStays', Math.abs(footerAfter.y - footerBefore.y) < 1 && footerAfter.y + footerAfter.h <= opened.inner[1] + 1); await browser.close(); } // (b) phone: a touch swipe scrolls the editor; the dialog is not taller than the viewport { const { page, browser, opened, readScroll } = await scenario({ width: 390, height: 844 }); check('phone.haBranch', opened.haBranch); check('phone.dialogFitsViewport', opened.dialogRect.h <= opened.inner[1] + 1); check('phone.footerInViewport', !!opened.footer && opened.footer.y + opened.footer.h <= opened.inner[1] + 1); check('phone.editorIsTheScroller', opened.editorScroll[0] > opened.editorScroll[1] + 50); const cdp = await page.context().newCDPSession(page); const x = opened.editor.x + opened.editor.w / 2; const y0 = opened.editor.y + Math.min(opened.editor.h - 40, 300); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: y0 }] }); for (let step = 1; step <= 8; step += 1) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y0 - step * 30 }] }); } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await page.waitForTimeout(400); const after = await readScroll(); check('phone.touchScrollsEditor', after.editor >= 80); await browser.close(); } // (c) the stub reproduces the bug: without flexcontent the editor is unbounded and nothing scrolls { const { page, browser, opened, readScroll } = await scenario({ width: 1280, height: 720 }, { flexContent: false }); check('witness.noFlexContent', opened.flexContent === false); check('witness.editorUnbounded', opened.editorScroll[0] === opened.editorScroll[1]); await page.mouse.move(opened.editor.x + opened.editor.w / 2, opened.editor.y + 160); await page.mouse.wheel(0, 240); await page.waitForTimeout(250); const after = await readScroll(); check('witness.wheelBlocked', after.editor === 0 && after.body === 0); await browser.close(); } finish();