| // --------------------------------------------------------------------------- | |
| // customer-grid / overlayPlacement.ts | |
| // Wave-9 I14 β the overlay positioning ARITHMETIC, extracted from | |
| // OverlaySurface.tsx so it can be proved instead of eyeballed. | |
| // | |
| // Why this file exists. I14 needs a flyout that opens to the RIGHT of the views | |
| // rail, which meant a third `Placement` and a new branch of collision maths. | |
| // Every other overlay on this surface already lands correctly, so a regression | |
| // here is invisible until a user finds it β and the failure mode is the exact | |
| // one this codebase has already paid for: a panel that is PAINTED but sits | |
| // outside the viewport, i.e. unreachable, with every assertion still green | |
| // ([[ui-invisible-to-assertions]]). | |
| // | |
| // Pure module, zero React imports β the same shape mapProjection.ts/ | |
| // mapGeometry.ts take for the map's maths, and for the same reason: a gate can | |
| // compile it with tsc and run it under node. | |
| // | |
| // The numbers here are byte-for-byte the ones OverlaySurface computed inline | |
| // before the extraction; only `right-start` is new. | |
| // --------------------------------------------------------------------------- | |
| export type Placement = "bottom-start" | "bottom-end" | "right-start"; | |
| export interface PlacementTarget { | |
| left: number; | |
| top: number; | |
| right: number; | |
| bottom: number; | |
| } | |
| export interface PlacementViewport { | |
| left: number; | |
| top: number; | |
| width: number; | |
| height: number; | |
| } | |
| export interface PlacementResult { | |
| left: number; | |
| top: number; | |
| maxWidth: number; | |
| maxHeight: number; | |
| /** Which way the panel actually resolved. Diagnostic only β nothing styles off it β | |
| * but it is what lets a gate assert "it flipped BECAUSE there was no room" rather | |
| * than merely "it ended up somewhere on screen". */ | |
| side: "below" | "above" | "right" | "left"; | |
| } | |
| /** Keep-away from the viewport edge, and from the anchor. The originals. */ | |
| export const OVERLAY_MARGIN = 8; | |
| export const OVERLAY_GAP = 6; | |
| // ------------------------------------------------------- wave-9 I2: the cell hover tip | |
| /** Glide's horizontal padding inside a text cell (8px each side at the default theme). */ | |
| export const CELL_TEXT_PAD = 16; | |
| /** Mirrors the `max-width` on `.cg-header-tip` / `.cg-cell-tip`. */ | |
| export const TIP_MAX_WIDTH = 280; | |
| /** | |
| * I2 β what a hovered cell should reveal, or `null` when there is nothing to reveal. | |
| * | |
| * The rule that makes this a feature rather than noise: **only text that is actually cut off | |
| * gets a tip.** A tooltip on every cell repeats what the user can already read, trains them to | |
| * ignore tips, and covers the row underneath while doing it. Grid cells never wrap here | |
| * (`allowOverlay: false`, no `allowWrapping`), so "cut off" is a pure width comparison and is | |
| * measured with the same canvas font glide draws with, not estimated from a character count. | |
| */ | |
| export function cellTipText( | |
| text: string, | |
| textPx: number, | |
| cellPx: number | |
| ): string | null { | |
| if (!text) return null; | |
| return textPx > Math.max(0, cellPx - CELL_TEXT_PAD) ? text : null; | |
| } | |
| /** | |
| * Keep a floating tip inside the viewport horizontally. | |
| * | |
| * β The EXISTING header tip does not do this β it uses the column's raw x, so a description on | |
| * a right-edge column runs off screen. The cell tip clamps; the header tip is left alone | |
| * because changing it is not in this item and it is somebody's shipped behaviour. | |
| */ | |
| export function tipLeft( | |
| x: number, | |
| viewportWidth: number, | |
| tipWidth: number, | |
| margin = OVERLAY_MARGIN | |
| ): number { | |
| const w = Math.min(tipWidth, TIP_MAX_WIDTH); | |
| return Math.round(Math.max(margin, Math.min(x, viewportWidth - margin - w))); | |
| } | |
| // -------------------------------------------------------- wave-9 I3: the header "(i)" | |
| export const INFO_MARK_SIZE = 16; | |
| /** Gap between the (i) and the header-menu slot to its right. */ | |
| export const INFO_MARK_GAP = 4; | |
| /** Glide's type-icon footprint before every header label. */ | |
| export const HEADER_LABEL_X = 8 + Math.ceil(18 * 1.3); // = 32 | |
| /** Clearance the label keeps from the (i). Touching is not overlapping, but it reads as one. */ | |
| export const HEADER_LABEL_GAP = 6; | |
| /** Enough title room for at least one normal glyph plus the ellipsis in Glide's header font. */ | |
| export const HEADER_MIN_TITLE_SPACE = 30; | |
| /** | |
| * Space kept clear at the LEFT of the (i), including Glide's type-icon footprint and the gap | |
| * between title and mark. Below this the mark is dropped instead of covering the title. | |
| */ | |
| export const INFO_MARK_MIN_LABEL = | |
| HEADER_LABEL_X + HEADER_LABEL_GAP + HEADER_MIN_TITLE_SPACE; | |
| /** | |
| * I3 β where the description "(i)" goes: **right-aligned in the header and vertically centred | |
| * with the field name.** | |
| * | |
| * β SCOUTED FIRST, and glide's own API cannot do it. `overlayIcon` is drawn by | |
| * `drawHeaderInner` at a HARD-CODED `drawX + 9`, `(height - 18) / 2 + 6` β a badge pinned to | |
| * the bottom-right corner of the TYPE icon, on the far LEFT of the header. No prop moves it. | |
| * So the mark is drawn by the `drawHeader` callback instead (glide's supported escape hatch: | |
| * it hands you the ctx, the rect, the menu bounds and the sprite manager, plus a | |
| * `drawContent()` for its own rendering) and `overlayIcon` is no longer set. | |
| * | |
| * `menuSlotWidth` is `menuBounds.width` β the "Β·Β·Β·" button's reserved strip at the right end. | |
| * The (i) sits to the LEFT of that strip **whether or not the menu is currently showing**, | |
| * because the menu only appears on hover and a mark that jumps sideways when you approach it | |
| * is worse than one sitting slightly inboard. | |
| * | |
| * Returns `null` when the column is too narrow to carry the mark without covering its own | |
| * label β a mark on top of the name is not "right-aligned", it is broken. | |
| */ | |
| export function infoMarkRect( | |
| header: { x: number; y: number; width: number; height: number }, | |
| menuSlotWidth: number, | |
| size = INFO_MARK_SIZE, | |
| gap = INFO_MARK_GAP | |
| ): { x: number; y: number; size: number } | null { | |
| return headerMarkLayout(header, menuSlotWidth, [size], gap)?.[0] ?? null; | |
| } | |
| // -------------------------------------- wave-14 item 15: the GROUP BAR's label vs the freeze | |
| /** | |
| * THE GROUP BAR'S FONT β the ONE copy, read by the cell that paints it | |
| * (`useGetCellContent.groupHeaderCell`'s `themeOverride.baseFontStyle`) AND by the canvas context | |
| * that measures it (`CustomerGrid.measureGroupText`). | |
| * | |
| * β It is SEMIBOLD, and that is the whole reason this constant exists rather than two literals. | |
| * Glide paints a cell with the MERGED theme's `baseFontFull` (`data-grid-render.cells.js:269`), | |
| * so a group bar paints at 600 while the grid's ordinary cell measurer | |
| * (`measureCellText`) is set to plain "13px". Measuring the fit at the lighter weight | |
| * UNDER-truncates β semibold Inter is ~4-6% wider β and the label then overflows into exactly | |
| * the hard mid-glyph `ctx.clip()` that `fitGroupLabel` exists to prevent. Worse as the frozen | |
| * strip gets WIDER, which is the opposite of intuition. | |
| * | |
| * This is the third instance of one bug: a measurement pinned to a weight the renderer no longer | |
| * uses (`measureHeaderText`'s 600 vs the involved header's 700 was the second). Hence one | |
| * constant, no literals, and a gate leg asserting it is not the base cell font. | |
| */ | |
| export const GROUP_HEADER_FONT = "600 13px"; | |
| /** | |
| * Glide's own cell padding β the group bar's text does not get the full strip. | |
| * | |
| * Glide insets text by `cellHorizontalPadding` (8) on the LEFT and clips at the rect's right | |
| * edge, so the strictly-usable width is `width - 8`. Reserving 16 (both sides) is DELIBERATELY | |
| * conservative: it makes the label a few pixels shorter than it strictly must be, which is the | |
| * safe direction to be wrong. β Do not let this slack be the thing that hides a font-weight | |
| * mismatch β see `GROUP_HEADER_FONT`. | |
| */ | |
| export const GROUP_LABEL_PAD = 16; | |
| /** | |
| * Item 15 β the collapsible group bar's label, fitted to the FROZEN STRIP. | |
| * | |
| * β Why this has to exist, because it is the non-obvious half of pinning the first column. | |
| * Freezing does NOT make a group bar's `span` vanish: glide SPLITS it, `getSpanBounds` returning | |
| * `[frozenRect, contentRect]`, and paints the row's CONTENTS from the frozen half only (the | |
| * scrollable half sets `skipContents`). But `frozenRect` is exactly the frozen columns' own | |
| * width β substitute `sourceIndex = 0`, `startCol = 0`, `firstNonSticky = 1` and both of its | |
| * loops run zero times β and glide clips to it with `ctx.rect(β¦); ctx.clip()` while `fillText` | |
| * carries no `maxWidth`. So the label is cut MID-GLYPH, with no ellipsis, at the freeze line. | |
| * Before the pin the bar drew across the whole row and this could not happen; the pin is what | |
| * the owner asked for (R8), so the truncation has to become honest rather than the pin reverted. | |
| * | |
| * The COUNT survives and the LABEL is what gives way. The count is the number the reader came | |
| * for and cannot be reconstructed from a prefix; a name usually can. So the label ellipsises and | |
| * `(count)` is always re-appended. | |
| */ | |
| export function fitGroupLabel( | |
| marker: string, | |
| label: string, | |
| count: string, | |
| space: number | null, | |
| measure: (text: string) => number | |
| ): string { | |
| const compose = (text: string) => `${marker} ${text} (${count})`; | |
| const full = compose(label); | |
| if (space == null || space <= 0) return full; | |
| if (measure(full) <= space) return full; | |
| // What the label may occupy = the space minus everything in the line that is NOT the label. | |
| const room = space - measure(compose("")); | |
| // No room for a name at all: say the whole thing and let glide clip it. A bar reading | |
| // "βΌ (1,247)" names no group, which is worse than one whose name is cut short. | |
| if (room <= 0) return full; | |
| return compose(fitHeaderTitle(label, room, measure)); | |
| } | |
| // ------------------------------------------ wave-14 R11: the header's right-hand MARK STRIP | |
| /** The user-created-field dot (item 1 / R11). Small on purpose: it is an affordance saying | |
| * "this column is yours, not Odoo's", not a category badge. */ | |
| export const CUSTOM_MARK_SIZE = 6; | |
| /** Clearance BETWEEN two marks in the strip. */ | |
| export const HEADER_MARK_GAP = 6; | |
| /** | |
| * R11 β where the header's right-hand marks go, laid out from the OUTSIDE IN. | |
| * | |
| * There are two of them now: the description "(i)" (wave-9 I3) and the user-created-field dot | |
| * (wave-14 item 1, replacing the yellow header wash the owner killed). They share one strip so | |
| * they can never be drawn on the same pixels, and so the title's truncation reserve accounts for | |
| * however many are actually present. | |
| * | |
| * `sizes` is the slot widths from the right edge inwards β build it with `headerMarkSizes()` so | |
| * the ORDER lives in exactly one place. Slot 0 is adjacent to the menu strip; the (i) keeps it, | |
| * because it is the one a pointer goes looking for and a hover target that moves between columns | |
| * is worse than a passive dot that does. | |
| * | |
| * **All-or-nothing**: if the innermost slot would eat the label, this returns `null` and NOTHING | |
| * is drawn. That is what lets `headerLabelSpace` and `CustomerGrid.drawGridHeader` read the same | |
| * answer from one function β a per-mark decision would let the reserve say "no truncation" while | |
| * the drawer still painted a mark over the name, which is the exact failure the wave-9 comment | |
| * above warns about ([[ui-invisible-to-assertions]]). | |
| */ | |
| export function headerMarkLayout( | |
| header: { x: number; y: number; width: number; height: number }, | |
| menuSlotWidth: number, | |
| sizes: readonly number[], | |
| gap = INFO_MARK_GAP | |
| ): { x: number; y: number; size: number }[] | null { | |
| if (sizes.length === 0) return null; | |
| const out: { x: number; y: number; size: number }[] = []; | |
| let right = header.x + header.width - menuSlotWidth - gap; | |
| for (const size of sizes) { | |
| const x = right - size; | |
| if (x - header.x < INFO_MARK_MIN_LABEL) return null; | |
| out.push({ | |
| x: Math.round(x), | |
| // Vertically centred in the header band β the same band the label's baseline is centred | |
| // in, which is what "centred with the field name" means on a canvas. | |
| y: Math.round(header.y + (header.height - size) / 2), | |
| size, | |
| }); | |
| right = x - HEADER_MARK_GAP; | |
| } | |
| return out; | |
| } | |
| /** The strip's ORDER, in one place: (i) outermost, the user-created dot inboard of it. Both the | |
| * truncation reserve and the drawing path read this, so they cannot disagree about how many | |
| * marks a column carries or which is which. */ | |
| export function headerMarkSizes(hasInfoMark: boolean, isCustomField: boolean): number[] { | |
| const out: number[] = []; | |
| if (hasInfoMark) out.push(INFO_MARK_SIZE); | |
| if (isCustomField) out.push(CUSTOM_MARK_SIZE); | |
| return out; | |
| } | |
| // ------------------------------------------------- owner item 16: the header title vs the (i) | |
| /** | |
| * Glide's own header metrics, READ OFF THE LIBRARY rather than guessed, and re-stated here | |
| * because the fit below has to agree with the renderer to the pixel. | |
| * | |
| * `data-grid-render.header.js:197-212` drawX = x + cellHorizontalPadding, then | |
| * drawX += ceil(headerIconSize * 1.3) once an `icon` | |
| * is set β and `useGridColumns` sets one on EVERY | |
| * column (the field-type mark, wave-8 I20). | |
| * `:157` menuButtonSize = 30, a CONSTANT: the "Β·Β·Β·" slot is | |
| * the same width on a 90px column and a 600px one. | |
| * `common/styles.js:71-74` cellHorizontalPadding 8 Β· headerIconSize 18 Β· | |
| * headerFontStyle "600 13px". | |
| * | |
| * β `theme.ts` overrides none of the three metrics, so these hold. If it ever does, they move | |
| * together or the label creeps back under the mark. | |
| */ | |
| export const HEADER_MENU_SLOT = 30; | |
| /** | |
| * Item 16 β how much width the header LABEL may occupy before it runs under the (i), or `null` | |
| * when nothing is in its way. | |
| * | |
| * β Derived from `infoMarkRect` rather than from a second copy of the same arithmetic. The mark | |
| * is dropped entirely below `INFO_MARK_MIN_LABEL`, so a re-derived reserve would keep shrinking | |
| * a label that has nothing to collide with β and the two numbers would drift the first time | |
| * either constant moved. ONE geometry source, queried twice. | |
| * | |
| * `null` means "do not truncate": either the field carries no description (no mark is drawn) or | |
| * the column is too narrow for the mark, which `infoMarkRect` already answers by returning null. | |
| * Glide fades the title under the hover menu on its own; that is its behaviour and not this | |
| * item's subject. | |
| */ | |
| export function headerLabelSpace( | |
| width: number, | |
| /** | |
| * Wave-14 R11 β the marks this header carries. `true`/`false` is the pre-R11 shape (the (i) | |
| * alone) and still means exactly what it meant; a `number[]` from `headerMarkSizes()` is the | |
| * general form, and it is what a column with BOTH an (i) and a user-created dot must pass or | |
| * the label gets reserved room for only one of them. | |
| */ | |
| marks: boolean | readonly number[], | |
| menuSlotWidth = HEADER_MENU_SLOT | |
| ): number | null { | |
| const sizes = marks === true ? [INFO_MARK_SIZE] : marks === false ? [] : marks; | |
| // The INNERMOST mark is the one the label must clear, and `headerMarkLayout` is all-or-nothing, | |
| // so this is still ONE geometry source queried twice rather than two copies of the arithmetic. | |
| const layout = headerMarkLayout({ x: 0, y: 0, width, height: 32 }, menuSlotWidth, sizes); | |
| if (!layout) return null; | |
| return Math.max(0, layout[layout.length - 1].x - HEADER_LABEL_X - HEADER_LABEL_GAP); | |
| } | |
| /** The ellipsis glyph, as ONE character β "..." is three and measures wider. */ | |
| export const ELLIPSIS = "β¦"; | |
| /** | |
| * Item 16 β `title` shortened to fit `space`, with an ellipsis, or returned untouched. | |
| * | |
| * `measure` is injected rather than reached for: the only honest measurement is canvas | |
| * `measureText` in glide's own header font ("600 13px"), and a pure function is the half of | |
| * this that a node gate can hold. Measuring at the wrong WEIGHT is the quiet failure β | |
| * semibold 13px is wider than regular 13px, so measuring with the cell font under-truncates | |
| * and the label still slides under the mark. | |
| * | |
| * Binary search over the cut point: `measure` is a canvas call per probe, and a linear walk | |
| * down a 40-character label is 40 of them per column per render. | |
| * | |
| * Never returns a bare ellipsis: if not even one character fits, the caller is better served | |
| * by the untouched title (glide will fade it) than by a header that says nothing at all. | |
| */ | |
| export function fitHeaderTitle( | |
| title: string, | |
| space: number | null, | |
| measure: (text: string) => number | |
| ): string { | |
| if (space == null || !title) return title; | |
| if (measure(title) <= space) return title; | |
| const ell = measure(ELLIPSIS); | |
| if (ell > space) return title; // no room to say "there is more" β say it all | |
| let lo = 0; // known to fit | |
| let hi = title.length; // known not to | |
| while (lo < hi) { | |
| const mid = Math.ceil((lo + hi) / 2); | |
| if (measure(title.slice(0, mid)) + ell <= space) lo = mid; | |
| else hi = mid - 1; | |
| } | |
| if (lo <= 0) return title; | |
| // A cut that lands on a trailing space would render as "Last order β¦". | |
| return title.slice(0, lo).trimEnd() + ELLIPSIS; | |
| } | |
| // ------------------------------------------- owner item 19: the hover-only Expand affordance | |
| export const EXPAND_BTN_SIZE = 22; | |
| /** Breathing room between the button and the right edge of the primary cell. */ | |
| export const EXPAND_BTN_INSET = 6; | |
| /** A row shorter than this cannot hold the button without touching the rules above and below. */ | |
| export const EXPAND_BTN_MIN_ROW = EXPAND_BTN_SIZE + 4; | |
| /** Minimum primary-cell label room; independent of the header's type-icon geometry. */ | |
| export const EXPAND_BTN_MIN_LABEL = 46; | |
| /** | |
| * Item 19 β where the row's Expand button sits: **at the right end of the PRIMARY cell**, | |
| * vertically centred on the row (Airtable's placement, and the reason the primary column is | |
| * the frozen one). | |
| * | |
| * `primary` is the first DATA column's rect for that row, straight from glide's | |
| * `DataEditorRef.getBounds(0, row)` β VIEWPORT coordinates, because that is what glide returns | |
| * (`data-grid.js:82-83` adds the canvas's own `getBoundingClientRect()` before returning) and | |
| * what `.cg-header-tip` already positions against. Taking it from glide rather than computing | |
| * it means freeze, horizontal scroll and row-height mode are all handled by the thing that owns | |
| * them. | |
| * | |
| * β The button is a real `<button>` laid over the canvas, so the rect below IS the hit test β | |
| * there is no second copy of this geometry to drift out of step with the drawing, which is the | |
| * failure mode a canvas-drawn affordance has ([[ui-invisible-to-assertions]]). | |
| * | |
| * `null` when the row or the column is too small to carry it: better absent than painted over | |
| * the customer's name in a 28px row. | |
| */ | |
| export function expandButtonRect( | |
| primary: { x: number; y: number; width: number; height: number }, | |
| /** | |
| * The grid's own box, in the same viewport space. β NOT optional in practice: the button is | |
| * `position: fixed`, and glide's bounds are VIEWPORT coordinates that follow the primary cell | |
| * WHEREVER it goes β including outside the grid. An unclamped fixed button then paints over | |
| * the chrome, on a row nobody can see: painted, plausible and in the wrong place | |
| * ([[ui-invisible-to-assertions]]). | |
| * | |
| * β RETARGETED wave-14 item 15 (R8). This used to justify itself with "`freezeColumns` is 0 | |
| * while a grouping is active, so the primary column scrolls away like any other" β TRUE then, | |
| * FALSE now: the first column is frozen in every mode, because glide splits a group bar's | |
| * span across the freeze boundary rather than losing it (`getSpanBounds`). The VERTICAL case | |
| * is what keeps this parameter load-bearing β a row scrolled under the header or past the | |
| * bottom still reports a rect outside the box. The horizontal legs remain as defence in depth | |
| * (a column drag, a box narrower than the frozen strip), and they are still asserted. | |
| */ | |
| box?: { x: number; y: number; width: number; height: number }, | |
| size = EXPAND_BTN_SIZE, | |
| inset = EXPAND_BTN_INSET | |
| ): { x: number; y: number; size: number } | null { | |
| if (primary.height < EXPAND_BTN_MIN_ROW) return null; | |
| // It must not eat the whole cell: below this the name is entirely covered by its own control. | |
| if (primary.width < size + inset * 2 + EXPAND_BTN_MIN_LABEL) return null; | |
| const x = Math.round(primary.x + primary.width - inset - size); | |
| const y = Math.round(primary.y + (primary.height - size) / 2); | |
| // Absent rather than clamped: a clamped button would sit at the grid's edge pointing at a row | |
| // whose primary cell is somewhere else entirely, which is a worse answer than no button. | |
| if (box) { | |
| if (x < box.x || x + size > box.x + box.width) return null; | |
| if (y < box.y || y + size > box.y + box.height) return null; | |
| } | |
| return { x, y, size }; | |
| } | |
| /** | |
| * Where a panel of `panel` size should sit relative to `target`, inside `viewport`. | |
| * | |
| * The invariant every caller depends on, and the one the gate asserts for all three | |
| * placements: **the returned box is inside the viewport whenever the viewport can hold it | |
| * at all.** A panel larger than the viewport is pinned to the top-left margin rather than | |
| * centred, so its first row stays reachable β a menu whose head is off-screen cannot be | |
| * used even though every item "rendered". | |
| */ | |
| export function computeOverlayPosition(args: { | |
| placement: Placement; | |
| target: PlacementTarget; | |
| /** The panel's DESIRED size, before clamping (scrollWidth/scrollHeight at the call site). */ | |
| panel: { width: number; height: number }; | |
| viewport: PlacementViewport; | |
| }): PlacementResult { | |
| const { placement, target, panel, viewport } = args; | |
| const margin = OVERLAY_MARGIN; | |
| const gap = OVERLAY_GAP; | |
| const minLeft = viewport.left + margin; | |
| const maxRight = viewport.left + viewport.width - margin; | |
| const minTop = viewport.top + margin; | |
| const maxBottom = viewport.top + viewport.height - margin; | |
| const maxWidth = Math.max(1, maxRight - minLeft); | |
| const width = Math.min(panel.width, maxWidth); | |
| const desiredHeight = panel.height; | |
| let left: number; | |
| let top: number; | |
| let maxHeight: number; | |
| let side: PlacementResult["side"]; | |
| if (placement === "right-start") { | |
| // Beside the anchor. Prefer the right; flip left only when the right genuinely cannot | |
| // hold the panel AND the left has more room β the same "pick the roomier side" rule the | |
| // vertical branch uses, turned 90 degrees. | |
| const roomRight = Math.max(0, maxRight - (target.right + gap)); | |
| const roomLeft = Math.max(0, target.left - gap - minLeft); | |
| const toRight = width <= roomRight || roomRight >= roomLeft; | |
| side = toRight ? "right" : "left"; | |
| const unclampedLeft = toRight ? target.right + gap : target.left - gap - width; | |
| left = Math.min(Math.max(unclampedLeft, minLeft), Math.max(minLeft, maxRight - width)); | |
| // Vertically the flyout is bounded by the VIEWPORT, not by the anchor: it opens level | |
| // with the button and slides up only as far as it must to stay on screen. (A rail | |
| // button near the bottom of a short iframe is the case that matters.) | |
| maxHeight = Math.max(1, maxBottom - minTop); | |
| top = Math.min( | |
| Math.max(target.top, minTop), | |
| Math.max(minTop, maxBottom - Math.min(desiredHeight, maxHeight)) | |
| ); | |
| } else { | |
| const below = Math.max(0, maxBottom - (target.bottom + gap)); | |
| const above = Math.max(0, target.top - gap - minTop); | |
| const placeBelow = desiredHeight <= below || below >= above; | |
| side = placeBelow ? "below" : "above"; | |
| maxHeight = Math.max(1, placeBelow ? below : above); | |
| const unclampedLeft = placement === "bottom-end" ? target.right - width : target.left; | |
| // The horizontal clamp needs no `Math.max(minLeft, β¦)` on its outer bound, unlike the | |
| // `right-start` branch above: `width` is ALREADY clamped to `maxWidth = maxRight - minLeft`, | |
| // so `maxRight - width >= minLeft` always holds and the min cannot undo the max. Proven the | |
| // only way worth proving it β a mutation that removed the "fix" left every leg green, which | |
| // is what a guard against an impossible state looks like ([[gate-negative-control]]). | |
| left = Math.min(Math.max(unclampedLeft, minLeft), maxRight - width); | |
| // β D-20 (wave 20) β `Math.max(minTop, β¦)` IS load-bearing, and it was missing. | |
| // | |
| // The below-branch had no lower bound, so an anchor ABOVE the viewport put the panel above | |
| // it too β painted, and unreachable. Not hypothetical: it is the null-anchor case D-20 is | |
| // about (a null anchor degrades to a ZERO rect at the DOCUMENT origin, which is above the | |
| // viewport whenever `visualViewport` carries an offset β a pinch-zoomed phone), and it is | |
| // equally a trigger that scrolls out of view with its menu still open. `verify_overlay`'s | |
| // `below-branch-loses-its-top-clamp` control restages exactly this. | |
| top = placeBelow | |
| ? Math.max(minTop, Math.min(target.bottom + gap, maxBottom - maxHeight)) | |
| : Math.max(minTop, target.top - gap - Math.min(desiredHeight, maxHeight)); | |
| } | |
| return { | |
| left: Math.round(left), | |
| top: Math.round(top), | |
| maxWidth: Math.floor(maxWidth), | |
| maxHeight: Math.floor(maxHeight), | |
| side, | |
| }; | |
| } | |