/** * Where an overlay goes, and how it attaches to the control that opened it. * * One decision and one file. The geometry — a rectangle, a viewport and a margin deciding a side and * an alignment — lived apart from the anchoring that reads it, which put `MDY_OVERLAY_VIEWPORT_MARGIN` * and `MDY_OVERLAY_GAP` in two different modules. Two constants that govern the same decision have to * be read together or they are not a rule. * * Anchoring is one decision, taken here: which side of the anchor the popup sits on, which edge it * aligns to, how tall it may grow, how wide it is, and the exact coordinates that follow from that. * Adapters measure the anchor and the viewport, apply the properties this returns, and decide * nothing themselves — three renderers each computing "below unless it doesn't fit" is three * chances for them to disagree about where a popup belongs. * * The output is deliberately a map of CSS custom properties rather than a style string: the * foundation already positions `.mdy-overlay` from `--mdy-overlay-*`, so an adapter's whole job is * to copy these onto the element. */ import { type MdyPopupWidgetKind, type MdyWidgetKind } from "./catalog.js"; /** * Which side of its anchor the popup sits on. `"overlay"` is the case where neither side had room * and the popup centred itself on the viewport instead. */ export declare const MDY_OVERLAY_VIEWPORT_MARGIN = 12; /** * What raised a panel, because it decides where the keyboard goes next. * * A keyboard open is followed by a keypress, so focus must be where that press will land. A pointer * open is followed by a click, and moving focus into the panel scrolls it under the pointer and * draws a ring on something nobody touched. One rule reading the modality, rather than two * behaviours for one control. ADR 0179. */ export type MdyOpenModality = "keyboard" | "pointer"; export interface MdyOverlayGeometry { readonly viewportWidth: number; readonly viewportHeight: number; readonly anchorTop: number; readonly anchorBottom: number; readonly anchorLeft: number; readonly anchorRight: number; readonly anchorWidth: number; readonly minSpace: number; /** Whether the content scrolls. `false` means a side that cannot hold it is not a placement. */ readonly scrolls?: boolean; readonly minWidth: number; readonly preferred: "above" | "below"; readonly pointerX?: number; /** * How tall the popup wants to be, gap included, when the host has measured it. * * This is what turns "a side with enough room" into "the side where the content is whole": a * calendar with 200px below it and 500px above belongs above, and a policy that only knows * `minSpace` puts it below and lets it scroll. Left out when nothing measured the popup, in which * case the choice falls back to the minimum-space rule. */ readonly desiredHeight?: number; /** How wide the popup wants to be, when measured. Used to pick the edge its content fits from. */ readonly desiredWidth?: number; /** * The edge the widget says its popup hangs from, from `capabilities.anchoring`. * * A widget knows where its trigger is — the arrow, the calendar button, the swatch all sit at the * end of the control — and that is the edge a popup should open from, every time. When a widget * states it, it wins: the pointer and the anchor's position on the page decide nothing, because a * popup that opens from a different corner depending on where you clicked inside the same field is * the behaviour this exists to stop. Only the viewport can overrule it, and only when the content * would not fit. */ readonly preferredAlignment?: "left" | "right"; /** * Take the modal placement whatever the room, because the host asked for it. * * The placement itself is not new — it is where a popup goes when neither side holds it, and * ADR 0023 names it the modal placement. What was missing is the host being able to *choose* it: a * picker that covers the viewport on a phone, or one a product wants modal on every screen, had to * hope the geometry refused every side. * * Presentation and nothing else. What a widget commits, and when, is the value contract's answer * (`MDY_VALUE_CONTRACTS`), and a placement never changes it. */ readonly forceModal?: boolean; } export interface MdyOverlayDecision { readonly placement: "above" | "below" | "overlay"; readonly alignment: "left" | "right"; readonly maxHeight: number; readonly width: number; /** * Whether the content fits the space decided for it, so the popup shows whole and does not * scroll. `true` when nothing measured the popup: no measurement is not evidence of a squeeze. */ readonly fits: boolean; } /** Pure framework-independent overlay collision policy. Hosts only measure and apply coordinates. */ export declare function decideOverlayPlacement(input: MdyOverlayGeometry): MdyOverlayDecision; /** * Which edge of the anchor the popup hangs from. * * In order: the edge the widget declares, then — for a widget that declares none — the half of the * control the pointer landed in, and its position on the page when it was opened from the keyboard. * A measured width then overrules all of that when the chosen edge has no room for it: hanging left * off a control near the right edge is how a content-sized calendar ends up half off-screen. * * Note which comparison is *not* here any more: the pointer against the middle of the viewport. It * made the edge a popup opened from depend on where its field happened to sit on the page, so the * same calendar opened from the left corner on one form and the right corner on another. */ export declare function decideOverlayAlignment(input: MdyOverlayGeometry): MdyOverlayDecision["alignment"]; /** * Keeps an open overlay's shape steady while its anchor moves. * * Re-deciding from scratch on every scroll frame is what makes a popup flip sides and change * height as the page moves under it. The coordinates must follow the anchor — that is what keeps * the popup attached — but the *shape* is a decision taken when it opened: placement, size and * alignment only change when the side it was opened on has genuinely stopped fitting. * * Hosts call this with the decision they are holding and the one they just measured. */ export declare function stabilizeOverlayPlacement(previous: MdyOverlayDecision | null, next: MdyOverlayDecision, input: MdyOverlayGeometry): MdyOverlayDecision; /** Canonical keyboard mapping. Framework adapters must not reinterpret these keys. */ export type MdyOverlayPlacement = MdyOverlayDecision["placement"]; /** Which edge of its anchor the popup hangs from. */ export type MdyOverlayAlignment = MdyOverlayDecision["alignment"]; /** * Where a popup ends up, in viewport coordinates. * * For a host that positions its panel itself, rather than by copying the custom properties onto the * element. Same decision, read a different way. */ export interface MdyOverlayCoords { readonly top?: number | undefined; readonly bottom?: number | undefined; readonly left?: number | undefined; readonly right?: number | undefined; readonly width?: number | undefined; /** The widest the popup may be where it now sits — the room measured on the side it hangs from. * Without it a content-sized popup near a viewport edge shows half off the screen. */ readonly maxWidth?: number | undefined; /** * The tallest the popup may be on the side it was given, so a long list scrolls rather than * overflowing the viewport. * * Carried here rather than left to the host because it is the other half of the placement * decision: a side is chosen for the room it has, and the number describing that room has to * arrive with the coordinates or the popup grows past the space it was placed in. */ readonly maxHeight?: number | undefined; /** * Which side the popup ended up on, because `"overlay"` is not a coordinate. * * A modal placement has given up on its anchor and centres itself on the viewport, which is a * percentage offset and a translation rather than the measured insets the other placements use. * Without the placement travelling with the numbers there is no way to serialise that from coords * alone, so every host on this path had to special-case it — and one of them picked a different * height for the modal than the policy did. */ readonly placement?: MdyOverlayPlacement | undefined; } /** A placed popup: the side, the edge, and the coordinates that follow from them. */ export interface MdyOverlayPlacementResult { readonly position: MdyOverlayPlacement; readonly alignment: MdyOverlayAlignment; readonly coords: MdyOverlayCoords; } /** * Coordinates as the custom properties the foundation positions from. * * Every unused coordinate is written out rather than left off: a popup that moves from hanging left * to hanging right must stop having a `left`, and a property left in place from the previous * placement is inherited and quietly wins. * * An unused inset is `auto`, which is the value {@link anchorOverlay} writes and therefore the one * every theme has been reading. `unset` would leave `var()` invalid at computed-value time and let * a stylesheet fallback answer instead — a difference that only shows on the placements a host * exercises least, which is exactly where the two projections had already drifted apart. */ export declare function overlayStyleProperties(coords: MdyOverlayCoords): Readonly>; /** A measured anchor, in viewport coordinates — a `DOMRect` satisfies it. */ export interface MdyAnchorRect { readonly top: number; readonly bottom: number; readonly left: number; readonly right: number; readonly width: number; } /** * The viewport the coordinates are laid out against. * * `document.documentElement.clientWidth`/`clientHeight` — the viewport *without* the scrollbars. * `window.innerWidth`/`innerHeight` include them, and a popup pinned by its right or bottom edge * then sits a scrollbar's width away from its control on any page long enough to scroll. */ export interface MdyViewportSize { readonly width: number; readonly height: number; } export interface MdyOverlayAnchorOptions { /** Smallest usable space before the popup flips to the other side or goes modal. */ readonly minSpace?: number; /** Narrowest the popup may be, whatever the anchor measures. */ readonly minWidth?: number; /** Which side to try first. */ readonly preferred?: "above" | "below"; /** Match the anchor's width (a select's list) or size to content (a calendar). */ readonly matchAnchorWidth?: boolean; /** * The writing direction the field is laid out in. Defaults to `"ltr"`. * * Only the **declared** alignment mirrors. `overlayAnchoringFor(kind)` says which edge of the * control a popup hangs from — the end where the trigger sits, the arrow, the calendar button — * and that is an inline idea: in a right-to-left field the same edge is the left one. * * Everything else here stays physical on purpose. How much room there is before the viewport's * right edge, and where the pointer landed, are facts about the screen and the user's hand; they * do not mirror, and a popup that ignored them would hang off the side of the window. */ readonly direction?: "ltr" | "rtl"; /** Distance between the anchor and the popup. */ readonly gap?: number; /** * Whether this popup's content scrolls. Defaults to `true`, which is the behaviour every caller * had before it existed. * * `false` says the content has a size it must be shown at, so a side that cannot hold it is not a * placement — it centres instead of clamping. `overlayAnchoringFor` supplies it from the kind's * `capabilities.overlayScrolls`, so a renderer gets it without asking. */ readonly scrolls?: boolean; /** * The popup's own height, measured by the host — `scrollHeight` is exactly this, since it reports * the content's full height whatever `max-height` is currently clamping it to. * * Supplying it is what lets the policy put the popup where it shows whole: a side is chosen * because the content fits there, not merely because the side is big enough to bother with. Left * out, placement falls back to the minimum-space rule and the popup may scroll. */ readonly contentHeight?: number; /** The popup's own width, measured the same way, so its edge is chosen where the content fits. */ readonly contentWidth?: number; /** * The edge the popup hangs from, as the widget's `capabilities.anchoring` declares it. Stated, it * decides; only a content width that will not fit that side can overrule it. */ readonly alignment?: "left" | "right"; /** Where the pointer opened it, so a popup follows the click rather than the element's centre. */ readonly pointerX?: number; /** * Take the modal placement whatever the room. See {@link MdyOverlayGeometry.forceModal}. * * Presentation only: it decides where the popup goes, never what the field commits. */ readonly forceModal?: boolean; /** * The decision this overlay is already holding, if it is open. Passing it keeps the popup's side * and height steady while its anchor moves; omitting it decides afresh, which is what opening is. */ readonly current?: MdyOverlayDecision | null; /** * Keep this side and edge, but measure the height again — so the popup shrinks and scrolls as * its room narrows, rather than moving. * * **This is the exception, not the policy.** `current` is what an open overlay passes: the shape * is held and the side is re-decided only once it has stopped fitting. `lock` trades a popup that * moves for one that quietly loses content off the bottom, which is why no adapter uses it — it * remains for a host that genuinely wants a popup pinned to its corner, and the difference * between the two is asserted in the anchoring suite rather than left to be discovered. */ readonly lock?: { readonly placement: MdyOverlayDecision["placement"]; readonly alignment: MdyOverlayDecision["alignment"]; } | null; /** * Which widget is being anchored. * * `anchorOverlay` does not read it — placement is geometry, and geometry does not care what the * popup contains. It travels with the options so that a renderer holding an anchoring already * holds everything needed to reflect the result: the catalog declares `above` and `overlay` as * states of every popup part, and a renderer with the kind can name them through `partClasses` * instead of inventing a spelling of its own. */ readonly kind?: MdyPopupWidgetKind; } export interface MdyOverlayAnchoring { readonly decision: MdyOverlayDecision; /** `--mdy-overlay-*` custom properties, ready to be written onto the popup. */ readonly properties: Readonly>; /** Mirrors `decision.placement`, for adapters that also reflect it as a data attribute. */ readonly placement: MdyOverlayDecision["placement"]; } /** The breathing space between a control and its popup, when the caller does not say otherwise. */ export declare const MDY_OVERLAY_GAP = 6; /** * The anchoring a widget declares, as options for {@link anchorOverlay}. * * The one call an adapter needs: how wide the popup is, how much room it wants and which edge it * hangs from all come from the catalog, so a renderer never holds a number of its own. Three * renderers each carrying their own `minSpace` is three widgets that behave differently while * appearing to share a contract. * * Returns an empty object for a widget with no popup, so a caller can pass it unconditionally. */ export declare function overlayAnchoringFor(kind: MdyWidgetKind): MdyOverlayAnchorOptions; /** * The class a popup wears to say which side it ended up on, or `null` for the ordinary case. * * The catalog declares `above` and `overlay` as states of every popup part; this is the one place * that turns a placement into the class for them. Three renderers each deriving it from * `partClasses` is three chances to derive it differently — which is precisely how * `mdy-overlay-panel--above`, a name no stylesheet has ever matched, came to exist in two adapters * at once while the catalog already had a name for it. * * `below` is the ordinary case and has no class, so a popup sitting under its anchor is spelled * exactly like a popup nobody has placed yet. */ export declare function popupPlacementClass(kind: MdyPopupWidgetKind, placement: MdyOverlayPlacement): string | null; /** * The class a popup wears to say which edge it hangs from, or `null` for the ordinary case. * * The other half of the same decision. `left` is the ordinary case and carries no class, exactly as * `below` does for placement. * * This existed in the catalog — every popup declares `right` alongside `above` and `overlay` — and * had no derivation, so the adapters each invented `mdy-overlay-panel--right`, a name no stylesheet * has ever matched, while the contract's own spelling went unemitted and dropped out of the style * audit as a stale entry. That is the same failure {@link popupPlacementClass} was written to end for * `--above`, in the one case it did not cover. */ export declare function popupAlignmentClass(kind: MdyPopupWidgetKind, alignment: MdyOverlayAlignment): string | null; /** * Anchors an overlay against a measured control. * * A modal placement (`"overlay"`) is what happens when neither side of the anchor has room: the * popup stops chasing the control and centres itself, because a list squeezed into 40px of space * is worse than one that ignores the anchor. */ export declare function anchorOverlay(anchor: MdyAnchorRect, viewport: MdyViewportSize, options?: MdyOverlayAnchorOptions): MdyOverlayAnchoring;