diff --git a/src/static/css/pad/form.css b/src/static/css/pad/form.css index 5475d7d5c6a..8dd9f40da4a 100644 --- a/src/static/css/pad/form.css +++ b/src/static/css/pad/form.css @@ -132,11 +132,23 @@ select, .nice-select { bottom: calc(100% + 5px); top: auto; } -.toolbar .nice-select .list { +.toolbar .nice-select .list, +/* Popups are scroll containers (see popup.css), which would otherwise clip the + absolutely-positioned dropdown list. Float it above the popup with fixed + positioning, matching how the toolbar dropdowns escape their container. */ +.popup .nice-select .list { position: fixed; top: auto; left: auto; } +/* The default .reverse rule above sets bottom: calc(100% + 5px) so an + absolutely-positioned list opens upward inside its parent. Once the list is + position:fixed, that percentage resolves against the viewport instead and + would push the list off-screen, so we let JS place it via `top` only. */ +.toolbar .nice-select.reverse .list, +.popup .nice-select.reverse .list { + bottom: auto; +} .nice-select .list:hover .option:not(:hover) { background-color: transparent !important; } diff --git a/src/static/css/pad/popup.css b/src/static/css/pad/popup.css index a0d989c147b..2035774d6af 100644 --- a/src/static/css/pad/popup.css +++ b/src/static/css/pad/popup.css @@ -30,6 +30,17 @@ background: #f7f7f7; min-width: min(300px, 90vw); max-width: min(600px, 95vw); + /* Constrain height so popups (notably Settings with Pad-wide Settings + enabled) scroll instead of cropping items off-screen on short windows. + Fixes #7696. */ + max-height: calc(100vh - 20px); + overflow-y: auto; +} + +/* Chat manages its own scroll and floats author-colour pickers outside the + popup, so it must not become a scroll container. */ +.popup#users .popup-content { + overflow: visible; } .popup input[type=text] { width: 100%; @@ -76,10 +87,6 @@ } .popup-content { max-height: 80vh; - overflow: auto; - } - .popup#users .popup-content { - overflow: visible; } } /* Move popup to the bottom, except popup linked to left toolbar, like hyperklink popup */ diff --git a/src/static/js/vendors/nice-select.ts b/src/static/js/vendors/nice-select.ts index ec76b3cef15..4f1ff8ab16d 100644 --- a/src/static/js/vendors/nice-select.ts +++ b/src/static/js/vendors/nice-select.ts @@ -123,6 +123,25 @@ } $dropdown.find('.list').css('max-height', $maxListHeight + 'px'); + // Popups are scroll containers (since #7696) which would clip the + // absolutely-positioned dropdown list. The list is repositioned with + // `position: fixed` (see form.css) so it floats above the popup; we + // need viewport-relative coordinates here. Done after the reverse + // class is decided so we know which side of the dropdown to anchor. + if ($dropdown.closest('.toolbar').length === 0 + && $dropdown.closest('.popup-content').length > 0) { + var rect = $dropdown[0].getBoundingClientRect(); + var $list = $dropdown.find('.list'); + $list.css('left', rect.left); + $list.css('min-width', $dropdown.outerWidth() + 'px'); + // Clear .reverse's `bottom: calc(100% + 5px)` — with position:fixed + // it would resolve against the viewport and push the list offscreen. + $list.css('bottom', 'auto'); + $list.css('top', $dropdown.hasClass('reverse') + ? rect.top - $maxListHeight - 5 + : rect.bottom); + } + } else { $dropdown.trigger('focus'); } diff --git a/src/tests/frontend-new/specs/pad_settings.spec.ts b/src/tests/frontend-new/specs/pad_settings.spec.ts index 9adbd25c185..1fbd74f86d8 100644 --- a/src/tests/frontend-new/specs/pad_settings.spec.ts +++ b/src/tests/frontend-new/specs/pad_settings.spec.ts @@ -167,6 +167,59 @@ test.describe('creator-owned pad settings', () => { await context2.close(); }); + // #7696: on a short viewport the settings popup must scroll so items in + // Pad-wide Settings (notably "Delete pad") stay reachable instead of being + // cropped off-screen with no scrollbar. + test('settings popup stays scrollable when the viewport is short', async ({page}) => { + await page.setViewportSize({width: 900, height: 500}); + await goToNewPad(page); + await showSettings(page); + + const popupContent = page.locator('#settings > .popup-content'); + await expect(popupContent).toBeVisible(); + await expect(page.locator('#pad-settings-section')).toBeVisible(); + + // The popup must declare scrollable overflow (otherwise the previous bug + // recurs even if content happens to fit by coincidence). + await expect(popupContent).toHaveCSS('overflow-y', 'auto'); + + // Delete pad sits at the bottom of Pad-wide Settings; on a short viewport + // it starts off-screen and must become reachable by scrolling the popup. + const deletePad = page.locator('#delete-pad'); + await expect(deletePad).not.toBeInViewport(); + await deletePad.scrollIntoViewIfNeeded(); + await expect(deletePad).toBeInViewport(); + }); + + // #7696 follow-up: the Pad-wide font/language nice-select dropdowns sit + // near the bottom of the popup, so opening one triggers the .reverse path + // (open upward). Floating the list with position:fixed must not pick up + // the default `.reverse { bottom: calc(100% + 5px) }` rule, which would + // resolve against the viewport and place the list off-screen. + test('Pad-wide font dropdown opens visibly when popup is scrolled to bottom', async ({page}) => { + await page.setViewportSize({width: 900, height: 500}); + await goToNewPad(page); + await showSettings(page); + + // Force the font dropdown into the lower portion of the viewport so + // .reverse triggers and the list opens upward. + await page.locator('#settings > .popup-content').evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + + const fontDropdown = page.locator('#padsettings-viewfontmenu + .nice-select'); + await expect(fontDropdown).toBeInViewport(); + + await fontDropdown.click(); + const list = fontDropdown.locator('.list'); + await expect(list).toBeVisible(); + await expect(list).toBeInViewport(); + + // The first option must be reachable so users can actually pick a font. + await fontDropdown.locator('.option').first().click(); + await expect(fontDropdown).not.toHaveClass(/open/); + }); + // #7592: ticking "Disable chat" must visibly disable the dependent // "Chat always on screen" / "Show Chat and Users" toggles, not just // make the underlying inputs non-interactive.