From 07a42b8832403877e114f3e271709b5664bde9f7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 5 Aug 2026 18:29:19 +0300 Subject: [PATCH] fix(ui): contain dropdown scroll geometry --- docs/COMPONENTS.md | 2 ++ packages/ui-core/styles.css | 2 ++ registry/components.json | 5 +++-- scripts/floating-position-contract.test.mjs | 13 +++++++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 7558342..2495ec6 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -56,6 +56,8 @@ Dropdown владеет floating-layer поведением: - переворачивается вверх при недостатке места; - ограничивает размеры viewport; - обновляется при resize и scroll; +- сохраняет border-box геометрию при собственной прокрутке и не накапливает padding в измеренной ширине; +- удерживает wheel/trackpad overscroll внутри меню, не прокручивая лежащую под portal поверхность; - закрывается, когда trigger полностью уходит за границы viewport; - закрывается по outside pointer и Escape; - после Escape возвращает фокус на реальную trigger-кнопку, не закрывая родительское окно; diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index cb2a9f1..0bf3400 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -1893,10 +1893,12 @@ textarea.nodedc-field__control { position: fixed; z-index: var(--nodedc-layer-popover); display: grid; + box-sizing: border-box; gap: 0.2rem; min-width: 11.25rem; max-width: calc(100vw - 1rem); overflow: auto; + overscroll-behavior: contain; border: 0; border-radius: var(--nodedc-radius-control); background: var(--nodedc-floating-surface); diff --git a/registry/components.json b/registry/components.json index 51faa21..6d9c098 100644 --- a/registry/components.json +++ b/registry/components.json @@ -98,10 +98,11 @@ "domExports": ["createFloatingLayer"], "domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"], "summary": "Shared portal/fixed floating layer for action and selection menus.", - "behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close with trigger focus restore", "scroll and resize reposition", "offscreen trigger close", "one open dropdown per window"], + "behavior": ["portal to document body", "viewport clamp", "vertical flip", "outside pointer close", "Escape close with trigger focus restore", "scroll and resize reposition", "contained menu scroll without geometry growth", "offscreen trigger close", "one open dropdown per window"], "rules": [ "Never render a runtime dropdown as an absolute child of a card, sticky header or scroll container.", - "Action menus and selection menus share floating behavior even when their row content differs." + "Action menus and selection menus share floating behavior even when their row content differs.", + "The portal surface uses border-box sizing and contained overscroll so repeated menu scrolling cannot grow its measured width or move the underlying application surface." ] }, { diff --git a/scripts/floating-position-contract.test.mjs b/scripts/floating-position-contract.test.mjs index 530d1b0..ac4a26e 100644 --- a/scripts/floating-position-contract.test.mjs +++ b/scripts/floating-position-contract.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import { computeFloatingPosition } from "../packages/ui-core/dist/index.js"; @@ -46,3 +47,15 @@ test("floating surface remains bounded when its anchor is below the viewport", ( assert.ok(position.top + Math.min(500, position.maxHeight) <= 712); assert.ok(position.maxHeight <= 704); }); + +test("dropdown scroll keeps portal geometry stable and contained", async () => { + const styles = await readFile( + new URL("../packages/ui-core/styles.css", import.meta.url), + "utf8", + ); + const dropdownRule = styles.match(/\.nodedc-dropdown-surface \{(?[\s\S]*?)\n\}/)?.groups?.body ?? ""; + + assert.match(dropdownRule, /box-sizing:\s*border-box;/); + assert.match(dropdownRule, /overflow:\s*auto;/); + assert.match(dropdownRule, /overscroll-behavior:\s*contain;/); +});