fix(editor): open mention/slash popup upward and clamp height to viewport (#5376)

The `@`-mention and `/`-command suggestion popups defaulted to
`placement: "bottom-start"`, so they stayed below the caret whenever any
space existed below it — even when far more room was above. In
bottom-anchored composers (chat input, issue comment/reply) that meant the
list opened down over the send controls and, near the viewport bottom, was
squashed or clipped off-screen.

Two compounding causes:

- The preferred side was the cramped one. Composers' roomy side is above
  the caret, so default to `top-start`; `flip` still sends it down when the
  caret is near the viewport top.
- The floating-ui `size` middleware wrote `maxHeight` on the outer wrapper,
  which does not clip — the inner list is the scroll container and carried
  its own fixed `max-h-[300px]/[420px]`. That viewport-unaware cap was the
  real height authority and could overflow. Publish the size middleware's
  `availableHeight` as a CSS var and have the list clamp to
  `min(designMax, availableHeight)`, so there is a single, viewport-aware
  height authority. Drop the old `Math.max(120, ...)` floor that forced
  overflow in tight bands.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-14 11:04:52 +08:00
committed by GitHub
parent 47f9c5813f
commit b9faa27a67
3 changed files with 38 additions and 5 deletions

View File

@@ -352,9 +352,15 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
<div
className={cn(
"flex flex-col overflow-y-auto overscroll-contain border bg-popover py-1",
// Height budget: clamp to whichever is smaller — the design max or the
// viewport-aware `--suggestion-available-height` published by the
// floating-ui `size` middleware (suggestion-popup.tsx). The var falls
// back to the design max when the popup renders outside that
// controller. This is the single height authority; do not add a second
// fixed max-height above it or the list can overflow the viewport.
contextLayout
? "max-h-[420px] w-96 rounded-lg shadow-xl"
: "max-h-[300px] w-72 rounded-md shadow-md",
? "max-h-[min(420px,var(--suggestion-available-height,420px))] w-96 rounded-lg shadow-xl"
: "max-h-[min(300px,var(--suggestion-available-height,300px))] w-72 rounded-md shadow-md",
)}
>
{groups.map((group) => (

View File

@@ -127,7 +127,11 @@ export const SlashCommandList = forwardRef<
: item.description;
return (
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
// Height budget clamps to min(design max, viewport-aware
// `--suggestion-available-height` from suggestion-popup.tsx's size
// middleware), falling back to the design max when rendered standalone.
// Single height authority — mirrors MentionList.
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[min(300px,var(--suggestion-available-height,300px))] overflow-y-auto">
{items.map((item, index) => {
const description = describe(item);
return (

View File

@@ -126,7 +126,17 @@ export function createSuggestionPopupRender<
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
computePosition(virtualEl, el, {
placement: "bottom-start",
// Open upward by default. The dominant hosts are bottom-anchored
// composers (chat input, issue comment/reply) whose roomy side is above
// the caret; preferring that side means `size` clamps to a large budget
// with no visible compression, and `flip` only sends it down in the rare
// case the caret sits near the viewport top. A `bottom-start` default
// instead stayed down whenever *any* space existed below — even when far
// more room was above — which read as "mostly opens down and gets
// squashed". Document-body editors (issue/project description, agent
// instructions) also host this popup; there the caret usually has room
// both ways, so `flip` keeps them on-screen regardless of the default.
placement: "top-start",
strategy: "fixed",
middleware: [
offset(6),
@@ -135,7 +145,20 @@ export function createSuggestionPopupRender<
size({
padding: 8,
apply({ availableHeight }) {
el.style.maxHeight = `${Math.max(120, availableHeight)}px`;
// Publish the viewport-aware height budget as a CSS variable so
// the list component (the real scroll container) can clamp its own
// max-height to `min(designMax, availableHeight)`. Writing
// maxHeight on this wrapper is inert — the wrapper does not clip,
// the inner list scrolls — so it would let a fixed-height list
// overflow past the viewport edge. No floor here: when space is
// tight a short scrollable list beats one clipped off-screen.
el.style.setProperty(
"--suggestion-available-height",
// Guard against a negative budget (caret scrolled just out of
// the boundary): a negative max-height is invalid CSS and would
// drop the clamp entirely, letting the list overflow.
`${Math.max(0, Math.round(availableHeight))}px`,
);
},
}),
],