fix(ui): bound dropdowns during scroll
This commit is contained in:
@@ -56,6 +56,7 @@ Dropdown владеет floating-layer поведением:
|
||||
- переворачивается вверх при недостатке места;
|
||||
- ограничивает размеры viewport;
|
||||
- обновляется при resize и scroll;
|
||||
- закрывается, когда trigger полностью уходит за границы viewport;
|
||||
- закрывается по outside pointer и Escape;
|
||||
- после Escape возвращает фокус на реальную trigger-кнопку, не закрывая родительское окно;
|
||||
- закрывает другой открытый dropdown.
|
||||
|
||||
@@ -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 является дефектом.
|
||||
|
||||
|
||||
+2
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user