// --------------------------------------------------------------------------- // 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 `