fix(ui): bound dropdowns during scroll

This commit is contained in:
Codex
2026-07-27 14:36:35 +03:00
parent a8c8bfd89a
commit c5a6a58948
8 changed files with 100 additions and 14 deletions
+1
View File
@@ -56,6 +56,7 @@ Dropdown владеет floating-layer поведением:
- переворачивается вверх при недостатке места; - переворачивается вверх при недостатке места;
- ограничивает размеры viewport; - ограничивает размеры viewport;
- обновляется при resize и scroll; - обновляется при resize и scroll;
- закрывается, когда trigger полностью уходит за границы viewport;
- закрывается по outside pointer и Escape; - закрывается по outside pointer и Escape;
- после Escape возвращает фокус на реальную trigger-кнопку, не закрывая родительское окно; - после Escape возвращает фокус на реальную trigger-кнопку, не закрывая родительское окно;
- закрывает другой открытый dropdown. - закрывает другой открытый dropdown.
+4 -3
View File
@@ -25,9 +25,10 @@
4. горизонтальная позиция ограничивается viewport; 4. горизонтальная позиция ограничивается viewport;
5. при недостатке места surface меняет bottom/top placement; 5. при недостатке места surface меняет bottom/top placement;
6. scroll/resize пересчитывают позицию; 6. scroll/resize пересчитывают позицию;
7. outside pointer и Escape закрывают surface; 7. trigger, полностью ушедший за viewport, закрывает surface;
8. после Escape фокус возвращается на trigger; 8. outside pointer и Escape закрывают surface;
9. открытие другого dropdown закрывает предыдущий. 9. после Escape фокус возвращается на trigger;
10. открытие другого dropdown закрывает предыдущий.
Inline absolute dropdown внутри card/sidebar/sticky container является дефектом. Inline absolute dropdown внутри card/sidebar/sticky container является дефектом.
+2 -1
View File
@@ -11,7 +11,7 @@
"scripts": { "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": "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", "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", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs", "serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.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-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-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: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:inspector-select": "node --test scripts/inspector-select-contract.test.mjs",
"test:range-control": "node --test scripts/range-control-contract.test.mjs" "test:range-control": "node --test scripts/range-control-contract.test.mjs"
}, },
+23 -7
View File
@@ -37,8 +37,18 @@ export function computeFloatingPosition({
viewportHeight = typeof window === "undefined" ? 1080 : window.innerHeight, viewportHeight = typeof window === "undefined" ? 1080 : window.innerHeight,
viewportPadding = 8, viewportPadding = 8,
}: FloatingPositionOptions): FloatingPosition { }: FloatingPositionOptions): FloatingPosition {
const roomBelow = viewportHeight - anchor.bottom - offset - viewportPadding; const viewportAvailableHeight = Math.max(
const roomAbove = anchor.top - offset - viewportPadding; 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 requestedTop = placement.startsWith("top");
const shouldFlip = requestedTop const shouldFlip = requestedTop
? surfaceHeight > roomAbove && roomBelow > roomAbove ? surfaceHeight > roomAbove && roomBelow > roomAbove
@@ -56,12 +66,18 @@ export function computeFloatingPosition({
); );
const availableHeight = resolvedPlacement.startsWith("top") ? roomAbove : roomBelow; 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 visibleHeight = Math.min(surfaceHeight, maxHeight);
const top = resolvedPlacement.startsWith("top") const desiredTop = resolvedPlacement.startsWith("top")
? Math.max(viewportPadding, anchor.top - offset - visibleHeight) ? anchor.top - offset - visibleHeight
: Math.min(anchor.bottom + offset, viewportHeight - visibleHeight - viewportPadding); : anchor.bottom + offset;
const top = Math.min(
Math.max(viewportPadding, desiredTop),
Math.max(viewportPadding, viewportHeight - visibleHeight - viewportPadding),
);
return { top, left, maxHeight, resolvedPlacement }; return { top, left, maxHeight, resolvedPlacement };
} }
+10 -1
View File
@@ -41,6 +41,16 @@ export function createFloatingLayer({
const reposition = () => { const reposition = () => {
if (!openState) return; if (!openState) return;
const anchor = trigger.getBoundingClientRect(); 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 measured = surface.getBoundingClientRect();
const surfaceWidth = Math.max(minWidth, matchTriggerWidth ? anchor.width : 0, measured.width); const surfaceWidth = Math.max(minWidth, matchTriggerWidth ? anchor.width : 0, measured.width);
const surfaceHeight = Math.max(1, surface.scrollHeight); const surfaceHeight = Math.max(1, surface.scrollHeight);
@@ -128,4 +138,3 @@ export function createFloatingLayer({
}, },
}; };
} }
+11 -1
View File
@@ -67,6 +67,16 @@ export function Dropdown({
const anchorNode = anchorElement ?? triggerElement; const anchorNode = anchorElement ?? triggerElement;
if (!anchorNode || !surfaceRef.current) return; if (!anchorNode || !surfaceRef.current) return;
const anchor = anchorNode.getBoundingClientRect(); 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 measured = surfaceRef.current.getBoundingClientRect();
const surfaceWidth = typeof width === "number" const surfaceWidth = typeof width === "number"
? width ? width
@@ -86,7 +96,7 @@ export function Dropdown({
maxHeight: position.maxHeight, maxHeight: position.maxHeight,
visibility: "visible", visibility: "visible",
}); });
}, [anchorElement, minWidth, offset, placement, triggerElement, width]); }, [anchorElement, close, minWidth, offset, placement, triggerElement, width]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isOpen) return; if (!isOpen) return;
+1 -1
View File
@@ -98,7 +98,7 @@
"domExports": ["createFloatingLayer"], "domExports": ["createFloatingLayer"],
"domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"], "domContract": ["nodedc-dropdown-surface", "nodedc-dropdown-option"],
"summary": "Shared portal/fixed floating layer for action and selection menus.", "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": [ "rules": [
"Never render a runtime dropdown as an absolute child of a card, sticky header or scroll container.", "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."
@@ -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);
});