From c5a6a58948bca8bc5cbb3e924ad70217640cddf0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 14:36:35 +0300 Subject: [PATCH] fix(ui): bound dropdowns during scroll --- docs/COMPONENTS.md | 1 + docs/WINDOWS_AND_LAYERS.md | 7 +-- package.json | 3 +- packages/ui-core/src/floating.ts | 30 ++++++++++--- packages/ui-dom/src/floating.ts | 11 ++++- packages/ui-react/src/Dropdown.tsx | 12 +++++- registry/components.json | 2 +- scripts/floating-position-contract.test.mjs | 48 +++++++++++++++++++++ 8 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 scripts/floating-position-contract.test.mjs diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 9a52872..7558342 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -56,6 +56,7 @@ Dropdown владеет floating-layer поведением: - переворачивается вверх при недостатке места; - ограничивает размеры viewport; - обновляется при resize и scroll; +- закрывается, когда trigger полностью уходит за границы viewport; - закрывается по outside pointer и Escape; - после Escape возвращает фокус на реальную trigger-кнопку, не закрывая родительское окно; - закрывает другой открытый dropdown. diff --git a/docs/WINDOWS_AND_LAYERS.md b/docs/WINDOWS_AND_LAYERS.md index 12fc690..5277e36 100644 --- a/docs/WINDOWS_AND_LAYERS.md +++ b/docs/WINDOWS_AND_LAYERS.md @@ -25,9 +25,10 @@ 4. горизонтальная позиция ограничивается viewport; 5. при недостатке места surface меняет bottom/top placement; 6. scroll/resize пересчитывают позицию; -7. outside pointer и Escape закрывают surface; -8. после Escape фокус возвращается на trigger; -9. открытие другого dropdown закрывает предыдущий. +7. trigger, полностью ушедший за viewport, закрывает surface; +8. outside pointer и Escape закрывают surface; +9. после Escape фокус возвращается на trigger; +10. открытие другого dropdown закрывает предыдущий. Inline absolute dropdown внутри card/sidebar/sticky container является дефектом. diff --git a/package.json b/package.json index 215f45b..02fc7a4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog", "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns", - "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", + "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.mjs", @@ -30,6 +30,7 @@ "test:map-subject-card": "node --test scripts/map-subject-card.test.mjs", "test:map-subject-detail-profile": "node --test server/map-subject-detail-profile.test.mjs server/map-live-data-slot.test.mjs", "test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs", + "test:floating-position": "node --test scripts/floating-position-contract.test.mjs", "test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs", "test:range-control": "node --test scripts/range-control-contract.test.mjs" }, diff --git a/packages/ui-core/src/floating.ts b/packages/ui-core/src/floating.ts index 3da3be5..5c0ff55 100644 --- a/packages/ui-core/src/floating.ts +++ b/packages/ui-core/src/floating.ts @@ -37,8 +37,18 @@ export function computeFloatingPosition({ viewportHeight = typeof window === "undefined" ? 1080 : window.innerHeight, viewportPadding = 8, }: FloatingPositionOptions): FloatingPosition { - const roomBelow = viewportHeight - anchor.bottom - offset - viewportPadding; - const roomAbove = anchor.top - offset - viewportPadding; + const viewportAvailableHeight = Math.max( + 0, + viewportHeight - viewportPadding * 2, + ); + const roomBelow = Math.min( + viewportAvailableHeight, + Math.max(0, viewportHeight - anchor.bottom - offset - viewportPadding), + ); + const roomAbove = Math.min( + viewportAvailableHeight, + Math.max(0, anchor.top - offset - viewportPadding), + ); const requestedTop = placement.startsWith("top"); const shouldFlip = requestedTop ? surfaceHeight > roomAbove && roomBelow > roomAbove @@ -56,12 +66,18 @@ export function computeFloatingPosition({ ); const availableHeight = resolvedPlacement.startsWith("top") ? roomAbove : roomBelow; - const maxHeight = Math.max(96, availableHeight); + const maxHeight = Math.min( + viewportAvailableHeight, + Math.max(Math.min(96, viewportAvailableHeight), availableHeight), + ); const visibleHeight = Math.min(surfaceHeight, maxHeight); - const top = resolvedPlacement.startsWith("top") - ? Math.max(viewportPadding, anchor.top - offset - visibleHeight) - : Math.min(anchor.bottom + offset, viewportHeight - visibleHeight - viewportPadding); + const desiredTop = resolvedPlacement.startsWith("top") + ? anchor.top - offset - visibleHeight + : anchor.bottom + offset; + const top = Math.min( + Math.max(viewportPadding, desiredTop), + Math.max(viewportPadding, viewportHeight - visibleHeight - viewportPadding), + ); return { top, left, maxHeight, resolvedPlacement }; } - diff --git a/packages/ui-dom/src/floating.ts b/packages/ui-dom/src/floating.ts index 79b0fd7..20d44a8 100644 --- a/packages/ui-dom/src/floating.ts +++ b/packages/ui-dom/src/floating.ts @@ -41,6 +41,16 @@ export function createFloatingLayer({ const reposition = () => { if (!openState) return; const anchor = trigger.getBoundingClientRect(); + const anchorVisible = ( + anchor.bottom > 0 + && anchor.top < window.innerHeight + && anchor.right > 0 + && anchor.left < window.innerWidth + ); + if (!anchorVisible) { + close(); + return; + } const measured = surface.getBoundingClientRect(); const surfaceWidth = Math.max(minWidth, matchTriggerWidth ? anchor.width : 0, measured.width); const surfaceHeight = Math.max(1, surface.scrollHeight); @@ -128,4 +138,3 @@ export function createFloatingLayer({ }, }; } - diff --git a/packages/ui-react/src/Dropdown.tsx b/packages/ui-react/src/Dropdown.tsx index 51d2217..4d6b0a8 100644 --- a/packages/ui-react/src/Dropdown.tsx +++ b/packages/ui-react/src/Dropdown.tsx @@ -67,6 +67,16 @@ export function Dropdown({ const anchorNode = anchorElement ?? triggerElement; if (!anchorNode || !surfaceRef.current) return; const anchor = anchorNode.getBoundingClientRect(); + const anchorVisible = ( + anchor.bottom > 0 + && anchor.top < window.innerHeight + && anchor.right > 0 + && anchor.left < window.innerWidth + ); + if (!anchorVisible) { + close(); + return; + } const measured = surfaceRef.current.getBoundingClientRect(); const surfaceWidth = typeof width === "number" ? width @@ -86,7 +96,7 @@ export function Dropdown({ maxHeight: position.maxHeight, visibility: "visible", }); - }, [anchorElement, minWidth, offset, placement, triggerElement, width]); + }, [anchorElement, close, minWidth, offset, placement, triggerElement, width]); useLayoutEffect(() => { if (!isOpen) return; diff --git a/registry/components.json b/registry/components.json index 074d91f..51faa21 100644 --- a/registry/components.json +++ b/registry/components.json @@ -98,7 +98,7 @@ "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", "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", "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." diff --git a/scripts/floating-position-contract.test.mjs b/scripts/floating-position-contract.test.mjs new file mode 100644 index 0000000..530d1b0 --- /dev/null +++ b/scripts/floating-position-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { computeFloatingPosition } from "../packages/ui-core/dist/index.js"; + +test("floating surface remains bounded when its anchor is above the viewport", () => { + const position = computeFloatingPosition({ + anchor: { + top: -1888, + right: 1228, + bottom: -1842, + left: 940, + width: 288, + height: 46, + }, + surfaceWidth: 324, + surfaceHeight: 144, + viewportWidth: 1280, + viewportHeight: 720, + viewportPadding: 8, + }); + + assert.equal(position.top, 8); + assert.ok(position.maxHeight <= 704); + assert.ok(position.maxHeight >= 0); +}); + +test("floating surface remains bounded when its anchor is below the viewport", () => { + const position = computeFloatingPosition({ + anchor: { + top: 1200, + right: 640, + bottom: 1246, + left: 320, + width: 320, + height: 46, + }, + surfaceWidth: 320, + surfaceHeight: 500, + viewportWidth: 1280, + viewportHeight: 720, + viewportPadding: 8, + }); + + assert.ok(position.top >= 8); + assert.ok(position.top + Math.min(500, position.maxHeight) <= 712); + assert.ok(position.maxHeight <= 704); +});