import { DATA_ATTR } from '../../constants/data-attributes'; import { Flipper } from '../../flipper'; import { keyCodes } from '../../utils'; import { generateId } from '../id-generator'; import { isKeyboardModality } from '../input-modality'; import type { PopoverItem, PopoverItemRenderParamsMap } from './components/popover-item'; import { PopoverItemSeparator, css as popoverItemCls, PopoverItemDefault, PopoverItemType } from './components/popover-item'; import type { PopoverItemParams } from '@/types/utils/popover/popover-item'; import { PopoverItemHtml } from './components/popover-item/popover-item-html/popover-item-html'; import type { SearchableItem } from './components/search-input'; import { SearchInput, SearchInputEvent, scoreSearchMatch } from './components/search-input'; import { PopoverAbstract } from './popover-abstract'; import { CSSVariables, css as popoverCss } from './popover.const'; import { clampNestedPopoverTop, NESTED_POPOVER_VIEWPORT_MARGIN, resolveNestedPopoverBelowPlacement } from './popover-nested-position'; import { resolvePosition } from './popover-position'; import { createPositionTracker, resolveBoundaryRect, type PositionTracker } from './anchored-position'; import { stripPopoverAttribute } from '../top-layer'; import { twMerge } from '../tw'; import type { PopoverParams, PopoverPositionUpdate, Flipper as FlipperHandle, } from '@/types/utils/popover/popover'; import { PopoverEvent } from '@/types/utils/popover/popover-event'; /** * Last observed pointer position, tracked globally via a document-level * mousemove listener. Used by `PopoverDesktop` to distinguish the * synthesized post-paint mouseover (which fires at the same coordinates * the pointer was already sitting at) from a genuine mouseover triggered * by real pointer motion. * * Fields are null until the first mousemove is observed in this document. */ const pointerTracker: { x: number | null; y: number | null } = { x: null, y: null }; const POINTER_TRACKER_KEY = '__blokPopoverPointerTracker__'; // Install the tracker once per document; installing from multiple // PopoverDesktop modules is a no-op. if (typeof document !== 'undefined' && !(document as unknown as Record)[POINTER_TRACKER_KEY]) { document.addEventListener('mousemove', (event: MouseEvent) => { pointerTracker.x = event.clientX; pointerTracker.y = event.clientY; }, { capture: true }); (document as unknown as Record)[POINTER_TRACKER_KEY] = true; } interface AnchorSnapshot { rect: DOMRect; scrollOffset: { x: number; y: number }; contextElement?: Element; contextRect?: DOMRect; } const isMeasurableRect = (rect: DOMRect | undefined): rect is DOMRect => rect !== undefined && (rect.width > 0 || rect.height > 0); const shiftRect = (rect: DOMRect, deltaX: number, deltaY: number): DOMRect => ({ x: rect.left + deltaX, y: rect.top + deltaY, top: rect.top + deltaY, bottom: rect.bottom + deltaY, left: rect.left + deltaX, right: rect.right + deltaX, width: rect.width, height: rect.height, toJSON: () => ({}), }); const captureAnchorSnapshot = (rect: DOMRect, contextElement?: Element): AnchorSnapshot => { const contextRect = contextElement?.getBoundingClientRect(); return { rect: shiftRect(rect, 0, 0), scrollOffset: { x: window.scrollX, y: window.scrollY }, ...(contextElement !== undefined && isMeasurableRect(contextRect) ? { contextElement, contextRect: shiftRect(contextRect, 0, 0) } : {}), }; }; const resolveAnchorSnapshot = (snapshot: AnchorSnapshot): DOMRect => { const liveContextRect = snapshot.contextElement?.getBoundingClientRect(); if (isMeasurableRect(liveContextRect) && snapshot.contextRect !== undefined) { return shiftRect( snapshot.rect, liveContextRect.left - snapshot.contextRect.left, liveContextRect.top - snapshot.contextRect.top ); } return shiftRect( snapshot.rect, snapshot.scrollOffset.x - window.scrollX, snapshot.scrollOffset.y - window.scrollY ); }; /** * Desktop popover. * On desktop devices popover behaves like a floating element. Nested popover appears at right or left side. * @internal * @todo support rtl for nested popovers and search */ export class PopoverDesktop extends PopoverAbstract { /** * Flipper - module for keyboard iteration between elements. * Typed as the published structural handle so a reused flipper passed via * params (also the handle type) assigns cleanly; `new Flipper()` is * structurally assignable to it. */ public flipper: FlipperHandle | undefined; /** * Popover nesting level. 0 value means that it is a root popover */ public nestingLevel = 0; /** * Reference to nested popover if exists. * Undefined by default, PopoverDesktop when exists and null after destroyed. */ protected nestedPopover: PopoverDesktop | undefined | null; /** * Item nested popover is displayed for */ protected nestedPopoverTriggerItem: PopoverItem | null = null; /** * Re-clamps a below-placement nested popover (the inline toolbar's link * field) while it is open: its content-driven width changes as the user * types, and unobserved growth would push the card past the viewport edge. */ private nestedBelowResizeObserver: ResizeObserver | null = null; /** * Last hovered item inside popover. * Is used to determine if cursor is moving inside one item or already moved away to another one. * Helps prevent reopening nested popover while cursor is moving inside one item area. */ private previouslyHoveredItem: PopoverItem | null = null; /** * Suppresses the synthesized mouseover that Chromium fires immediately * after the popover enters the CSS Top Layer (via `showPopover()`). * Without this guard, the layout-triggered hover would open whatever * nested submenu happens to sit under the pointer the moment the parent * popover appears (e.g. "Convert to" auto-expands the moment block * settings opens). * * The synthesized hit test fires at the same screen coordinates where * the pointer was sitting when the popover opened — it's a pure layout * event, with no pointer motion behind it. We capture those coordinates * at `show()` time; any mouseover whose clientX/Y matches is discarded * as synthesized, while a mouseover at different coordinates is a * genuine hover triggered by real pointer motion and passes through. * * Cleared on the first REAL pointer motion after `show()` — not on a * timer. The hit test is not bound to any frame budget: on a slow * machine it can arrive long after `show()` and after other hover * events, so a time-based window would let it slip through and steal * hover state (this made a nested-popover E2E fail CI-only). Only when * no pointer position has ever been observed (nothing to compare * against) does a short rAF-bounded window apply instead. */ private suppressSyncHover = false; /** * Pointer coordinates at the moment `show()` was invoked. Any mouseover * received while armed whose clientX/Y matches these coordinates is * treated as a synthesized post-paint hit test and swallowed. Null when * the suppressor is disarmed or no pointer motion has ever been seen. */ private suppressSyncHoverPointer: { x: number; y: number } | null = null; /** * Handle returned by requestAnimationFrame used to arm the frame that * clears `suppressSyncHover` as a safety net. Null when no frame is * scheduled. */ private suppressSyncHoverRaf: number | null = null; /** * Delay (ms) a *pointer hover* waits before opening a nested submenu (Radix * SubTrigger "open intent"). Keeps a diagonal pointer path that merely clips * a trigger-with-children on the way somewhere else from flashing a submenu * open. Keyboard navigation stays synchronous — it never goes through this. */ private static readonly NESTED_OPEN_INTENT_DELAY_MS = 100; /** * Pending pointer open-intent timer (see {@link NESTED_OPEN_INTENT_DELAY_MS}). * Null when no open is pending. */ private nestedOpenIntentTimer: number | null = null; /** * Grace period (ms) an open submenu keeps standing after the pointer leaves * its trigger item. Without it the submenu is unreachable by an ordinary * pointer: the trigger's item box stops short of the popover's own padding, * so a straight run at the submenu samples that chrome band first and the * menu vanishes from under the pointer. The grace also absorbs a diagonal * path that clips sibling rows on the way in. Keyboard closes stay * synchronous — they never go through this. */ private static readonly NESTED_CLOSE_INTENT_DELAY_MS = 300; /** * Pending pointer close-intent timer (see {@link NESTED_CLOSE_INTENT_DELAY_MS}). * Null when no close is pending. */ private nestedCloseIntentTimer: number | null = null; /** * Element of the page that creates 'scope' of the popover. * If possible, popover will not cross specified element's borders when opening. */ private scopeElement: HTMLElement = document.body; /** * Element relative to which the popover should be positioned */ private trigger: HTMLElement | undefined; /** * Trigger rect captured at construction time. Used as a fallback if the * live trigger rect has collapsed to zero size by the time `show()` runs * (e.g. because a subscriber to an "opened" event set the trigger to * `display:none`). Prevents the popover from rendering at the viewport's * top-left corner when the trigger is no longer measurable. */ private capturedTriggerAnchor: AnchorSnapshot | undefined; /** * Snapshot backing an explicit virtual position. Its dedicated * `positionContext` supplies movement deltas so a caret/context rect keeps * following its content through nested scrolling and layout movement. */ private explicitPositionAnchor: AnchorSnapshot | undefined; /** * Live element that owns an explicit virtual position. This is deliberately * independent of `trigger`: toolbars often open at a caret/pointer rect while * their button remains elsewhere, so trigger movement is not a safe proxy. */ private positionContext: HTMLElement | undefined; /** * Optional element whose left edge is used for horizontal positioning * instead of the trigger's left edge. */ private leftAlignElement: HTMLElement | undefined; /** * When true, places the popover to the left of the trigger, vertically * centered on the trigger and shifted only as needed to stay on screen. */ private placeLeftOfAnchor = false; /** * Which side of the trigger {@link placeLeftOfAnchor} docks to. Mirrors with * the gutter the trigger lives in — see `config.toolbarPosition`. */ private asideSide: 'left' | 'right' = 'left'; /** * Minimum gap (px) from viewport top/bottom when placeLeftOfAnchor is active. */ private viewportMargin = 0; /** * Updates the element whose left edge is used for horizontal positioning. * @param element - new element to align against, or undefined to fall back to trigger */ public setLeftAlignElement(element: HTMLElement | undefined): void { this.leftAlignElement = element; } /** * Keeps the popover anchored to its trigger while it is open by re-running * {@link reposition} on scroll / resize / content-resize. Created on show() * for trigger-based (root) popovers and torn down on hide()/destroy(). * Undefined while the popover is closed. */ private positionTracker: PositionTracker | undefined; /** * Popover size cache */ private _size: { height: number; width: number } | undefined; /** * Original order of item elements in the popover container. * Cached on first search so we can restore order when query is cleared. */ private originalItemOrder: Element[] | undefined; /** * Cache of promoted items built from nested children. * Built once on first non-empty search, destroyed on clear/hide/destroy. */ private promotedItemCache: { items: PopoverItemDefault[]; parentChains: Map; } | null = null; /** * Temporary group separator elements injected during search. */ private promotedSeparators: HTMLElement[] = []; /** * Group separator rendered above top-level matches in search results. */ private topLevelSeparator: HTMLElement | null = null; private isSearching = false; /** * Construct the instance * @param params - popover params * @param itemsRenderParams – popover item render params. * The parameters that are not set by user via popover api but rather depend on technical implementation */ constructor(params: PopoverParams, itemsRenderParams?: PopoverItemRenderParamsMap) { super(params, itemsRenderParams); if (params.trigger) { this.trigger = params.trigger; const initialRect = params.trigger.getBoundingClientRect(); if (isMeasurableRect(initialRect)) { /** * The snapshot's movement reference must be an element whose geometry * is independent of this popover's own open-state side effects. A * caller-supplied positionContext (e.g. the block holder) wins over * the auto-picked nearest measurable ancestor: the trigger's ancestors * (toolbar actions zone) shrink when sibling buttons hide on open, * and that layout shift would be misread as anchor movement. */ this.capturedTriggerAnchor = captureAnchorSnapshot( initialRect, params.positionContext ?? this.findMeasurableAncestor(params.trigger) ); } } this.positionContext = params.positionContext; if (params.position !== undefined) { this.explicitPositionAnchor = this.captureExplicitPosition(params.position); } if (params.leftAlignElement) { this.leftAlignElement = params.leftAlignElement; } if (params.placeLeftOfAnchor === true) { this.placeLeftOfAnchor = true; } if (params.asideSide !== undefined) { this.asideSide = params.asideSide; } if (typeof params.viewportMargin === 'number') { this.viewportMargin = params.viewportMargin; } if (params.nestingLevel !== undefined) { this.nestingLevel = params.nestingLevel; } if (this.nestingLevel > 0) { this.nodes.popover.setAttribute(DATA_ATTR.nested, 'true'); } if (params.scopeElement !== undefined) { this.scopeElement = params.scopeElement; } if (this.nodes.popoverContainer !== null) { this.listeners.on(this.nodes.popoverContainer, 'mouseover', (event: Event) => this.handleHover(event)); this.listeners.on(this.nodes.popoverContainer, 'mouseleave', (event: Event) => this.handleMouseLeave(event)); } if (params.searchable) { this.addSearch(); } if (params.flippable === false) { return; } const existingFlipper = params.flipper; if (existingFlipper !== undefined) { existingFlipper.deactivate(); existingFlipper.removeOnFlip(this.onFlip); this.flipper = existingFlipper; } else { this.flipper = new Flipper({ items: this.flippableElements, focusedItemClass: popoverItemCls.focused, allowedKeys: [ keyCodes.TAB, keyCodes.UP, keyCodes.DOWN, keyCodes.ENTER, keyCodes.RIGHT, keyCodes.LEFT, ], onArrowLeft: params.onNavigateBack, handleContentEditableTargets: params.handleContentEditableNavigation, }); } this.flipper?.onFlip(this.onFlip); // The flipper is created after addSearch(), so a search-field host recorded // there must be re-applied to the freshly-built flipper. if (this.activeDescendantHost !== null) { this.flipper?.setActiveDescendantHost(this.activeDescendantHost); } } /** * Sets the element that owns DOM focus while this popover is open (e.g. the * combobox contentEditable that drives the Toolbox). The flipper reflects the * currently-highlighted item on it via `aria-activedescendant`. * @param host - focus-owning element, or null to clear */ public setActiveDescendantHost(host: HTMLElement | null): void { this.activeDescendantHost = host; this.flipper?.setActiveDescendantHost(host); } /** * Element that owns DOM focus while this popover is open when focus is kept * outside the popover subtree (combobox contentEditable). Null when focus * lives inside the popover. */ private activeDescendantHost: HTMLElement | null = null; /** * Exposes the active-descendant focus host so the registry's focus-out * dismissal treats focus on it as "inside" the popover. */ public override getFocusHost(): HTMLElement | null { return this.activeDescendantHost; } /** * Returns true if some item inside popover is focused */ public hasFocus(): boolean { if (this.flipper === undefined) { return false; } return this.flipper.hasFocus(); } /** * Toggles hidden state of all items matching the given name. * Invalidates the cached size so the next access re-measures the popover. * @param name - name of the items to toggle * @param isHidden - true to hide, false to show */ public override toggleItemHiddenByName(name: string, isHidden: boolean): void { super.toggleItemHiddenByName(name, isHidden); this._size = undefined; } /** * Scroll position inside items container of the popover */ public get scrollTop(): number { if (this.nodes.items === null) { return 0; } return this.nodes.items.scrollTop; } /** * Returns visible element offset top */ public get offsetTop(): number { if (this.nodes.popoverContainer === null) { return 0; } return this.nodes.popoverContainer.offsetTop; } /** * Root popovers may be anchored by a live element, a virtual DOMRect, or * both. Inline/nested popovers have neither and stay in their local wrapper. */ private hasRootAnchor(): boolean { return this.trigger !== undefined || this.params.position !== undefined; } /** * Finds the nearest ancestor that remains measurable if a trigger is hidden. * Its movement supplies the delta for the captured trigger rect during * nested scrolling and layout shifts. * @param trigger - trigger whose ancestor chain is inspected */ private findMeasurableAncestor(trigger: HTMLElement): HTMLElement | undefined { const parent = trigger.parentElement; if (parent === null) { return undefined; } return isMeasurableRect(parent.getBoundingClientRect()) ? parent : this.findMeasurableAncestor(parent); } /** * Captures an explicit virtual rect together with the nearest live movement * reference available at that moment. * @param position - viewport-relative virtual anchor */ private captureExplicitPosition(position: DOMRect): AnchorSnapshot { if (this.positionContext !== undefined) { return captureAnchorSnapshot(position, this.positionContext); } return captureAnchorSnapshot(position); } /** * Whether the current virtual anchor has a live, measurable movement owner. * A declared context can later collapse or detach, so checking the parameter * alone is insufficient when a nested scroll occurs. */ private hasMeasurablePositionContext(): boolean { const context = this.explicitPositionAnchor?.contextElement; return context !== undefined && isMeasurableRect(context.getBoundingClientRect()); } /** * Open popover */ public show(): void { const mountTarget = this.getMountElement(); const hasAnchor = this.hasRootAnchor(); if (hasAnchor && mountTarget) { document.body.appendChild(mountTarget); } if (hasAnchor) { const { top, left, openTop, openLeft } = this.calculatePosition(); this.nodes.popover.style.position = 'absolute'; this.nodes.popover.style.top = `${top}px`; this.nodes.popover.style.left = `${left}px`; this.nodes.popover.style.setProperty(CSSVariables.PopoverTop, '0px'); this.nodes.popover.style.setProperty(CSSVariables.PopoverLeft, '0px'); this.applyResolvedSide(openTop, openLeft); } const measuredSize = this.size; this.nodes.popover.style.setProperty(CSSVariables.PopoverHeight, measuredSize.height + 'px'); if (this.params.width === undefined || this.params.width === 'auto') { const minWidth = this.params.minWidth !== undefined ? parseFloat(this.params.minWidth) : 0; const width = Math.max(measuredSize.width, minWidth); this.nodes.popover.style.setProperty('--width', width + 'px'); } if (!hasAnchor) { this.applyNonTriggerPosition(measuredSize); } // Keep the popover anchored to its trigger while it is open: a page scroll, // a viewport resize, or the popover's own size changing would otherwise // strand it away from the trigger. Root anchors track; virtual roots either // follow their declared position context or fail closed on nested scroll. // Nested submenus follow their parent, and inline popovers scroll with // their owning wrapper. if (hasAnchor) { this.positionTracker?.detach(); // Observe the container, not the outer positioning host: the host is a // zero-size box, so a ResizeObserver on it never fires when the menu's // rendered size settles after placement. this.positionTracker = createPositionTracker(this.nodes.popoverContainer ?? this.nodes.popover, (event?: Event) => { const nestedScrollerMoved = event?.type === 'scroll' && event.target instanceof Element; const hasUntrackableVirtualAnchor = this.params.position !== undefined && !this.hasMeasurablePositionContext(); /** * A DOMRect is a snapshot, so nested scrolling is impossible to infer * without a declared live owner. Keeping the menu open would silently * detach it from its content. Root window/document scrolling remains * resolvable from scroll offsets and continues to reposition normally. */ if (nestedScrollerMoved && hasUntrackableVirtualAnchor) { this.hide(); return; } /** * The show()-time size is measured on a detached clone and can settle * differently once the menu actually renders (fonts, promoted items, * min-width). On size/viewport changes (ResizeObserver and window * resize fire without a scroll event) re-measure so the recomputed * position uses the real size — a stale width can slide the menu over * its own trigger. Scrolls keep the cache: size cannot change there * and re-cloning per scroll frame would thrash layout. */ if (event?.type !== 'scroll') { this.invalidateSizeCache(); } this.reposition(); }); this.positionTracker.attach(); } // Set flag BEFORE super.show() so handleHover can suppress the // synthesized mouseover Chromium fires when the element enters the // CSS Top Layer. The flag is released as soon as the user moves the // mouse (real pointer motion distinguishes a genuine hover from a // paint-triggered hit test, which has no motion) or, as a safety net, // after two animation frames if nothing arrives. this.armSuppressSyncHover(); super.show(); this.flipper?.activate(this.flippableElements); // Focus the first item: search field if present, otherwise first menu item queueMicrotask(() => { this.focusInitialElement(); }); } /** * Focuses the initial element when popover is shown. * When a search field is present, it receives focus so the user can type immediately. * When autoFocusFirstItem is false, no item is pre-focused — focus only appears * after the user begins keyboard navigation. */ private focusInitialElement(): void { if (this.search) { this.search.focus(); return; } if (this.params.autoFocusFirstItem === false) { return; } /** * The cursor is a keyboard affordance, so a menu opened with the mouse * starts with no row highlighted. Typing into the search re-places it, * because typing is itself a keyboard gesture. */ if (!isKeyboardModality()) { return; } this.flipper?.focusItem(0, { skipNextTab: true }); } /** * Updates the popover position dynamically. * Used when the trigger position changes or when positioning at caret location. * @param position - new DOMRect position for the popover * @param update - explicit live context or fail-closed dismissal policy */ public updatePosition(position: DOMRect, update: PopoverPositionUpdate): void { this.params.position = position; // Optional chaining preserves the runtime fail-closed contract for // untyped JavaScript that still calls the old one-argument form. this.positionContext = update?.positionContext; this.explicitPositionAnchor = this.captureExplicitPosition(position); // Recalculate and apply position if already shown if (this.nodes.popover.hasAttribute('data-blok-popover-opened')) { const { top, left, openTop, openLeft } = this.calculatePosition(); this.nodes.popover.style.top = `${top}px`; this.nodes.popover.style.left = `${left}px`; this.applyResolvedSide(openTop, openLeft); } } /** * Applies the resolved vertical/horizontal placement flags: keeps the legacy * `data-blok-popover-open-top`/`-open-left` attributes (which * `popover-animation.css` keys off for transform-origin) in sync AND stamps * the Radix-style `data-side`/`data-align` attributes so newer CSS/animation * can key off the resolved side. * @param openTop - true when the popover opened above the anchor * @param openLeft - true when the popover opened to the left of the anchor */ private applyResolvedSide(openTop: boolean, openLeft: boolean): void { this.setOpenTop(openTop); this.setOpenLeft(openLeft); this.nodes.popover.setAttribute('data-side', openTop ? 'top' : 'bottom'); this.nodes.popover.setAttribute('data-align', openLeft ? 'end' : 'start'); } /** * Positions a non-trigger (inline-mounted) popover via CSS variables so the * visual gap stays handled by CSS `calc` rather than pixel math. * @param measuredSize - measured popover size */ private applyNonTriggerPosition(measuredSize: { height: number; width: number }): void { const containerRect = this.nodes.popoverContainer.getBoundingClientRect(); const viewportSize = { width: window.innerWidth, height: window.innerHeight }; const scopeBounds = resolveBoundaryRect(this.scopeElement, viewportSize); // offset: 0 because the visual gap is handled by CSS calc (0.5rem), not pixel positioning const { openTop, openLeft } = resolvePosition({ anchor: containerRect, popoverSize: measuredSize, scopeBounds, viewportSize, scrollOffset: { x: window.scrollX, y: window.scrollY }, offset: 0, }); this.nodes.popover.style.setProperty(CSSVariables.PopoverTop, openTop ? 'calc(-1 * (0.5rem + var(--popover-height)))' : '0px'); this.nodes.popover.style.setProperty(CSSVariables.PopoverLeft, openLeft ? 'calc(-1 * var(--width) + 100%)' : '0px'); this.setOpenTop(openTop); this.setOpenLeft(openLeft); } /** * Re-computes and re-applies the trigger-based popover position. Invoked by * the {@link positionTracker} on scroll / resize / content-resize while the * popover is open, so it stays anchored to a trigger that moved. */ private reposition(): void { if (!this.hasRootAnchor() || !this.nodes.popover.hasAttribute(DATA_ATTR.popoverOpened)) { return; } const { top, left, openTop, openLeft } = this.calculatePosition(); this.nodes.popover.style.top = `${top}px`; this.nodes.popover.style.left = `${left}px`; this.applyResolvedSide(openTop, openLeft); } /** * Calculates position for the popover */ private calculatePosition(): { top: number; left: number; openTop: boolean; openLeft: boolean } { const explicitPosition = this.params.position === undefined ? undefined : resolveAnchorSnapshot( this.explicitPositionAnchor ?? this.captureExplicitPosition(this.params.position) ); const liveRect = this.trigger?.getBoundingClientRect(); const isLiveRectCollapsed = liveRect !== undefined && !isMeasurableRect(liveRect); const fallbackRect = isLiveRectCollapsed && this.capturedTriggerAnchor !== undefined ? resolveAnchorSnapshot(this.capturedTriggerAnchor) : liveRect; const rect = explicitPosition ?? fallbackRect; if (!rect) { return { top: 0, left: 0, openTop: false, openLeft: false }; } // leftAlignElement forces horizontal alignment to a block-level content // element (e.g. the whole table). When the caller supplies an explicit // anchor rect via updatePosition(), that rect already reflects the // intended position (a caret rect or a specific cell rect) and must NOT // be overridden — otherwise the popover snaps to the block's left edge // and drifts hundreds of pixels away from the caret in wide containers // like multi-column tables. const leftAlignRect = explicitPosition === undefined ? this.leftAlignElement?.getBoundingClientRect() : undefined; const viewportSize = { width: window.innerWidth, height: window.innerHeight }; const scopeBounds = resolveBoundaryRect(this.scopeElement, viewportSize); return resolvePosition({ anchor: rect, popoverSize: this.size, scopeBounds, viewportSize, scrollOffset: { x: window.scrollX, y: window.scrollY }, offset: 8, leftAlignRect, placeLeftOfAnchor: this.placeLeftOfAnchor, asideSide: this.asideSide, viewportMargin: this.viewportMargin, }); } /** * Desktop close cleanup, run by the base {@link PopoverAbstract.hide} through * the template-method `onHide` hook (converted from an arrow-function `hide` * override so `PopoverInline` can extend the base close path instead of * re-implementing it and forgetting base steps). */ protected override onHide(): void { this.positionTracker?.detach(); this.positionTracker = undefined; this.cancelNestedOpenIntent(); this.cleanupPromotedItems(); this.destroyNestedPopoverIfExists(); this.flipper?.deactivate(); this.previouslyHoveredItem = null; // Tear down synthesized-hover suppression so a future show() starts // from a known state. this.disarmSuppressSyncHover(); // Clear any externally-supplied anchor rect so the next show() falls back // to the trigger element unless the caller explicitly sets a new one. // Also clear inline position so a stale top/left from a previous open // cannot render the popover at an unexpected location if it ever becomes // visible before calculatePosition() runs again. this.params.position = undefined; this.explicitPositionAnchor = undefined; this.positionContext = undefined; this.nodes.popover.style.top = ''; this.nodes.popover.style.left = ''; } /** * Bound wrapper used as the `ClosedOnActivate` event-handler reference for a * nested popover — `hide` is a regular method now, so on/off need a stable * bound reference to add and remove the same listener. */ private readonly closeOnNestedActivate = (): void => { this.hide(); }; /** * Clears memory */ public destroy(): void { this.hide(); super.destroy(); } /** * Arms the synthesized-hover suppression: records the pointer position * at the moment `show()` was called and schedules a safety rAF that * clears the flag even if no mouseover ever arrives. Idempotent — * re-arming tears down any prior armament first. */ private armSuppressSyncHover(): void { this.disarmSuppressSyncHover(); this.suppressSyncHover = true; this.suppressSyncHoverPointer = pointerTracker.x !== null && pointerTracker.y !== null ? { x: pointerTracker.x, y: pointerTracker.y } : null; if (this.suppressSyncHoverPointer === null) { // No pointer motion has ever been observed, so there is no resting point // to compare against — fall back to a short rAF-bounded window, matching // the pre-fix behavior for the very first popover in a fresh page. this.suppressSyncHoverRaf = window.requestAnimationFrame(() => { this.suppressSyncHoverRaf = window.requestAnimationFrame(() => { this.disarmSuppressSyncHover(); }); }); return; } // With a resting point captured, stay armed until the pointer ACTUALLY // moves. The post-paint hit test is not bound to any frame budget — on a // slow machine it can arrive long after show() (and after other hover // events), so a time-based window would let it slip through and steal // hover state from whatever item sits under the parked pointer. document.addEventListener('mousemove', this.onRealPointerMove, { capture: true, once: true }); } /** * Disarms the synthesized-hover suppression on the first real pointer * motion after show(). Bound so it can be removed on disarm/hide. */ private readonly onRealPointerMove = (): void => { this.disarmSuppressSyncHover(); }; /** * True when the given mouseover event looks like Chromium's post-paint * synthesized hit test — i.e. its clientX/Y match the pointer position * snapshot captured at `show()` time. If we have no snapshot (no * mousemove has ever fired in the document) we fall back to treating * every mouseover received while armed as synthesized, matching the * pre-fix behavior for the very first popover opened in a fresh page. * @param event - the mouseover event under consideration */ private isLikelySynthesizedHover(event: MouseEvent): boolean { if (this.suppressSyncHoverPointer === null) { return true; } return event.clientX === this.suppressSyncHoverPointer.x && event.clientY === this.suppressSyncHoverPointer.y; } /** * Tears down the synthesized-hover suppression: clears the flag, * forgets the pointer position snapshot, and cancels the safety rAF. * Safe to call when nothing is armed. */ private disarmSuppressSyncHover(): void { this.suppressSyncHover = false; this.suppressSyncHoverPointer = null; if (this.suppressSyncHoverRaf !== null) { window.cancelAnimationFrame(this.suppressSyncHoverRaf); this.suppressSyncHoverRaf = null; } document.removeEventListener('mousemove', this.onRealPointerMove, true); } /** * Checks if popover contains the node. * Overridden to check nested popover as well. * @param node - node to check */ public override hasNode(node: Node): boolean { if (super.hasNode(node)) { return true; } if (this.nestedPopover !== undefined && this.nestedPopover !== null) { return this.nestedPopover.hasNode(node); } return false; } /** * Handles displaying nested items for the item. * @param item – item to show nested popover for */ protected override showNestedItems(item: PopoverItem): void { // Asking for the submenu supersedes the grace close a wandering pointer may // have started — cancelled before the early return, or a submenu that is // already open would still be torn down under the keyboard a moment later. this.cancelNestedCloseIntent(); if (this.nestedPopover !== null && this.nestedPopover !== undefined) { return; } // The keyboard path opens synchronously — abandon any pending pointer-driven // open intent so a stale hover timer cannot fight the keyboard. this.cancelNestedOpenIntent(); this.nestedPopoverTriggerItem = item; this.showNestedPopoverForItem(item); } /** * Handles hover events inside popover items container * @param event - hover event data */ protected handleHover(event: Event): void { // Swallow the synthesized hit test Chromium fires when the element // enters the CSS Top Layer (or on any later relayout under a parked // pointer). That hit test reports the same clientX/Y the pointer was // sitting at when `show()` ran; a genuine hover triggered by real // pointer motion reports different coordinates. Matching events are // discarded for as long as the pointer has not actually moved; // non-matching events pass through. See `suppressSyncHover`. if (this.suppressSyncHover) { if (event instanceof MouseEvent && this.isLikelySynthesizedHover(event)) { // Swallowed WITHOUT disarming: the hit test can fire more than once // (any relayout under the parked pointer re-triggers it), and each // occurrence must be ignored until the pointer genuinely moves. return; } // A hover at other coordinates is genuine-looking — process it, but stay // armed: until a real mousemove arrives, a late post-paint hit test at // the resting point can still follow and must not steal hover state // (e.g. cancel this event's nested-open intent). Disarming happens on // the first real pointer motion (see armSuppressSyncHover). } /** * If the pointer moved into the nested popover (e.g. user is about to click * an item in the sub-menu), keep it open. */ if ( this.nestedPopover !== undefined && this.nestedPopover !== null && event.target instanceof Node && this.nestedPopover.hasNode(event.target) ) { this.cancelNestedCloseIntent(); return; } const item = this.getTargetItem(event); /** * Pointer is over the popover's own chrome (padding, separators, search * field…) — over neither the trigger item nor the submenu, so no submenu * may be visible or about to appear. */ if (item === undefined) { this.cancelNestedOpenIntent(); this.scheduleNestedClose(); this.previouslyHoveredItem = null; return; } if (this.previouslyHoveredItem === item) { return; } this.previouslyHoveredItem = item; // Moving onto a different item abandons a pending open-intent that targeted // the item we just left — this is what keeps a diagonal pointer path that // merely clips a sibling trigger from opening that sibling's submenu. this.cancelNestedOpenIntent(); /** * A nested popover belongs to its trigger item, so moving onto any other * row starts its close — but only after the grace period, which is what * lets a pointer aimed at the submenu cross the rows and chrome in * between. Another trigger item additionally opens its own submenu after * the open-intent delay, which supersedes the pending close. */ if (this.nestedPopover !== undefined && this.nestedPopover !== null) { if (item === this.nestedPopoverTriggerItem) { this.cancelNestedCloseIntent(); return; } this.scheduleNestedClose(); if (item.hasChildren) { this.scheduleNestedOpenIntent(item); } return; } if (!item.hasChildren) { return; } // Fresh open: defer by the intent delay so a quick pass-over does not flash // a submenu open. this.scheduleNestedOpenIntent(item); } /** * Handles mouse leaving the popover container. * Closes the open nested popover unless the mouse moved into it — the * submenu overlaps the parent, so entering it fires before this leave * resolves to anywhere else. * @param event - mouseleave event */ protected handleMouseLeave(event: Event): void { const mouseEvent = event as MouseEvent; const relatedTarget = mouseEvent.relatedTarget; if ( relatedTarget instanceof Node && this.nestedPopover !== undefined && this.nestedPopover !== null && this.nestedPopover.hasNode(relatedTarget) ) { return; } // Leaving the popover abandons any submenu that was about to open. this.cancelNestedOpenIntent(); this.scheduleNestedClose(); this.previouslyHoveredItem = null; } /** * Schedules a deferred pointer-driven open of the nested submenu for the given * item. Any previously-scheduled open is canceled first. * @param item - trigger-with-children item to open a submenu for */ private scheduleNestedOpenIntent(item: PopoverItem): void { this.cancelNestedOpenIntent(); this.nestedOpenIntentTimer = window.setTimeout(() => { this.nestedOpenIntentTimer = null; this.openNestedForHoveredItem(item); }, PopoverDesktop.NESTED_OPEN_INTENT_DELAY_MS); } /** * Cancels a pending pointer open-intent, if any. */ private cancelNestedOpenIntent(): void { if (this.nestedOpenIntentTimer !== null) { window.clearTimeout(this.nestedOpenIntentTimer); this.nestedOpenIntentTimer = null; } } /** * Schedules the deferred pointer-driven close of the open submenu. Does * nothing when no submenu is open, and keeps the deadline already running: * the grace window is measured from the moment the pointer left the * submenu's own area, not from the last row it wandered onto. */ private scheduleNestedClose(): void { if (this.nestedPopover === undefined || this.nestedPopover === null) { return; } if (this.nestedCloseIntentTimer !== null) { return; } this.nestedCloseIntentTimer = window.setTimeout(() => { this.nestedCloseIntentTimer = null; // Pointer intent must never evict a submenu the keyboard is inside: // typing into its search field or stepping through its items is an // active interaction that the pointer position says nothing about. // Without this the grace fires ~300ms after the mouse wanders off and // the submenu vanishes mid-typing, dropping focus to . if (this.isNestedPopoverFocused()) { return; } this.destroyNestedPopoverIfExists(false); }, PopoverDesktop.NESTED_CLOSE_INTENT_DELAY_MS); } /** * Whether the keyboard focus currently sits inside the open submenu. */ private isNestedPopoverFocused(): boolean { const active = document.activeElement; if (active === null || this.nestedPopover === undefined || this.nestedPopover === null) { return false; } return this.nestedPopover.hasNode(active); } /** * Cancels a pending pointer close-intent, if any. */ private cancelNestedCloseIntent(): void { if (this.nestedCloseIntentTimer !== null) { window.clearTimeout(this.nestedCloseIntentTimer); this.nestedCloseIntentTimer = null; } } /** * Opens the nested submenu for a pointer-hovered item once its open-intent * delay has elapsed, tearing down any submenu currently open for a different * item first. * @param item - trigger-with-children item to open a submenu for */ private openNestedForHoveredItem(item: PopoverItem): void { // No-op when no submenu is open; tears down a submenu open for another item. this.destroyNestedPopoverIfExists(false); this.nestedPopoverTriggerItem = item; this.showNestedPopoverForItem(item); } /** * Retrieves popover item that is the target of the specified event. * Overridden to include promoted items from recursive search. * @param event - event to retrieve popover item from */ protected override getTargetItem(event: Event): PopoverItemDefault | PopoverItemHtml | undefined { const allItems = this.promotedItemCache !== null ? [...this.items, ...this.promotedItemCache.items] : this.items; return allItems .filter((item): item is PopoverItemDefault | PopoverItemHtml => item instanceof PopoverItemDefault || item instanceof PopoverItemHtml ) .find(item => { const itemEl = item.getElement(); if (itemEl === null) { return false; } return event.composedPath().includes(itemEl); }); } /** * Sets CSS variable with position of item near which nested popover should be displayed. * Is used for correct positioning of the nested popover * @param nestedPopoverEl - nested popover element * @param item – item near which nested popover should be displayed */ protected setTriggerItemPosition(nestedPopoverEl: HTMLElement, item: PopoverItem): void { const itemEl = item.getElement(); const itemOffsetTop = (itemEl ? itemEl.offsetTop : 0) - this.scrollTop; const topOffset = this.offsetTop + itemOffsetTop; const queriedPopoverEl = nestedPopoverEl.querySelector(`[${DATA_ATTR.popover}]`); const actualPopoverEl: HTMLElement = queriedPopoverEl instanceof HTMLElement ? queriedPopoverEl : nestedPopoverEl; actualPopoverEl.style.setProperty(CSSVariables.TriggerItemTop, topOffset + 'px'); } /** * Destroys existing nested popover * @param restoreFocus - whether to restore keyboard focus to the trigger item after closing. * Should be true for keyboard-driven closes (e.g. ArrowLeft/Escape), false for mouse-driven closes * to avoid leaving a stale focus highlight on the trigger item. */ protected destroyNestedPopoverIfExists(restoreFocus = true): void { // A grace period counting down for the submenu being torn down here must // not outlive it and close whatever opens next. this.cancelNestedCloseIntent(); if (this.nestedPopover === undefined || this.nestedPopover === null) { return; } const triggerItemElement = this.nestedPopoverTriggerItem?.getElement(); const elementToRemove = this.nestedPopover.getElement(); this.nestedBelowResizeObserver?.disconnect(); this.nestedBelowResizeObserver = null; this.nestedPopover.off(PopoverEvent.ClosedOnActivate, this.closeOnNestedActivate); this.nestedPopover.hide(); this.nestedPopover.destroy(); elementToRemove.remove(); this.nestedPopover = null; this.flipper?.activate(this.flippableElements); if (restoreFocus) { // Focus the trigger item synchronously to ensure keyboard events work immediately this.focusAfterNestedPopoverClose(triggerItemElement); } this.nestedPopoverTriggerItem?.onChildrenClose(); // Reset trigger item so clicking the same item again will open the nested popover this.nestedPopoverTriggerItem = null; } /** * Focuses the appropriate item after nested popover closes. * Focuses the item that opened the nested popover, or falls back to first item. * @param triggerItemElement - element that triggered the nested popover */ private focusAfterNestedPopoverClose(triggerItemElement: HTMLElement | null | undefined): void { if (!triggerItemElement || !this.flipper) { this.flipper?.focusFirst(); return; } const triggerIndex = this.flippableElements.indexOf(triggerItemElement); if (triggerIndex !== -1) { // Don't skip next Tab - user expects Tab to move to next item after closing nested popover this.flipper.focusItem(triggerIndex, { skipNextTab: false }); return; } this.flipper.focusFirst(); } /** * Creates and displays nested popover for specified item. * Is used only on desktop * @param item - item to display nested popover by */ protected showNestedPopoverForItem(item: PopoverItem): PopoverDesktop { const handleContentEditable = this.flipper?.getHandleContentEditableTargets(); this.nestedPopover = new PopoverDesktop({ searchable: item.isChildrenSearchable, items: item.children, nestingLevel: this.nestingLevel + 1, flippable: item.isChildrenFlippable, messages: this.messages, onNavigateBack: this.destroyNestedPopoverIfExists.bind(this), width: item.childrenWidth, minWidth: item.childrenMinWidth, handleContentEditableNavigation: handleContentEditable, autoFocusFirstItem: this.params.autoFocusFirstItem, }); item.onChildrenOpen(); /** * Close nested popover when item with 'closeOnActivate' property set was clicked * parent popover should also be closed */ this.nestedPopover.on(PopoverEvent.ClosedOnActivate, this.closeOnNestedActivate); const nestedPopoverEl = this.nestedPopover.getMountElement(); this.nodes.popover.appendChild(nestedPopoverEl); this.setTriggerItemPosition(nestedPopoverEl, item); // Apply nested popover positioning (horizontal offset resolved to explicit // pixels; the former nesting-level CSS-var calc has been removed). this.applyNestedPopoverPositioning(nestedPopoverEl, item); /** * Refresh trigger item's active state after any click inside the nested popover. * This handles the case where a child action (e.g. color swatch click) changes * the trigger tool's active state but the parent popover is not aware of it. */ this.listeners.on(nestedPopoverEl, 'click', () => { if (this.nestedPopoverTriggerItem !== null) { this.refreshItemActiveState(this.nestedPopoverTriggerItem); } }); // Reaching the submenu abandons any pending open-intent for a sibling // clipped on the diagonal path in, and calls off the close the pointer // started when it left the trigger row. this.listeners.on(nestedPopoverEl, 'pointerenter', () => { this.cancelNestedOpenIntent(); this.cancelNestedCloseIntent(); }); this.nestedPopover.show(); // A below-placement popover keeps its content-driven width while open, so // observe it and re-clamp its position as the content grows or shrinks. // The observer's initial fire also settles any drift between the pre-show // clone measurement and the real rendered size. if (item.childrenPlacement === 'below' && typeof ResizeObserver !== 'undefined') { const nestedContainerEl = nestedPopoverEl.querySelector(`[${DATA_ATTR.popoverContainer}]`); if (nestedContainerEl instanceof HTMLElement) { this.nestedBelowResizeObserver = new ResizeObserver(() => { // Reposition on the next frame, not inside the observer callback: // the callback's own style writes shift layout in the same frame, // which makes Firefox drop the follow-up notification as an // undelivered resize-observer loop — leaving the card clamped // against a stale mid-growth width. requestAnimationFrame(() => { if (this.nestedPopover === null || this.nestedPopover === undefined) { return; } this.applyNestedPopoverPositioning(nestedPopoverEl, item); }); }); this.nestedBelowResizeObserver.observe(nestedContainerEl); } } this.flipper?.deactivate(); return this.nestedPopover; } /** * Applies positioning styles to nested popover container. * This replaces CSS selectors like [data-blok-nested] [data-blok-popover-container] * @param nestedPopoverEl - the nested popover element (mount element) * @param triggerItem - popover item whose children are rendered as the nested popover */ private applyNestedPopoverPositioning(nestedPopoverEl: HTMLElement, triggerItem: PopoverItem): void { const nestedContainerEl = nestedPopoverEl.querySelector(`[${DATA_ATTR.popoverContainer}]`); if (!(nestedContainerEl instanceof HTMLElement)) { return; } const nestedContainer = nestedContainerEl; const queriedPopoverEl = nestedPopoverEl.querySelector(`[${DATA_ATTR.popover}]`); const actualPopoverEl: HTMLElement = queriedPopoverEl instanceof HTMLElement ? queriedPopoverEl : nestedPopoverEl; // Apply position: absolute for nested container nestedContainer.style.position = 'absolute'; // The nested container stays positioned relative to its parent popover // root (its offset parent), so viewport coordinates are converted into // that local coordinate space. const parentRect = this.nodes.popoverContainer.getBoundingClientRect(); const parentRootRect = this.nodes.popover.getBoundingClientRect(); // Items may opt out of the beside-placement and open under the parent // popover instead (the inline toolbar's link field). Left edges aligned, // flipping above only when the viewport leaves no room below. if (triggerItem.childrenPlacement === 'below') { // The link field sizes its input to the typed content, so the card must // not stay frozen at the show()-time --width: the container follows its // content live, and the resize observer attached in showNestedPopover // re-runs this placement whenever the content grows or shrinks. nestedContainer.style.width = triggerItem.childrenWidth ?? 'max-content'; nestedContainer.style.minWidth = triggerItem.childrenMinWidth ?? '0'; // Prefer live layout sizes once the popover is rendered; the initial // pre-show call falls back to the detached-clone measurement // (offsetWidth is 0 until the top-layer popover is shown). const nestedWidth = nestedContainer.offsetWidth > 0 ? nestedContainer.offsetWidth : this.nestedPopover?.size.width ?? 0; const nestedHeight = nestedContainer.offsetHeight > 0 ? nestedContainer.offsetHeight : this.nestedPopover?.size.height ?? 0; const { left, top, side } = resolveNestedPopoverBelowPlacement({ parentRect, nestedWidth, nestedHeight, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, }); // A left below parentRect.left means the right-viewport clamp engaged. // Express that clamp as a CSS right-pin instead of a measured left: the // browser then keeps the card's right edge exactly on the margin while // the content-driven width keeps changing, so a transiently stale width // measurement (Firefox settles intrinsic sizes across frames) can never // strand the card past the viewport edge. if (left < parentRect.left) { nestedContainer.style.left = 'auto'; nestedContainer.style.right = `${parentRootRect.right - (window.innerWidth - NESTED_POPOVER_VIEWPORT_MARGIN)}px`; } else { nestedContainer.style.right = 'auto'; nestedContainer.style.left = `${left - parentRootRect.left}px`; } nestedContainer.style.top = `${top - parentRootRect.top}px`; actualPopoverEl.setAttribute('data-side', side); actualPopoverEl.setAttribute('data-align', 'start'); return; } // Overlap (px) the nested submenu is allowed to share with the parent's // trailing edge — matches the `--nested-popover-overlap` CSS variable // (0.25rem = 4px). const overlap = 4; // Submenus otherwise ALWAYS open on the right of their parent, regardless // of the parent's own side or the space available — a side that flips with // geometry made the same menu open left or right on different blocks. // Horizontal: place the submenu beside the parent, overlapping its trailing // edge by `overlap` px, then convert to parent-root-relative pixels. const viewportLeft = parentRect.right - overlap; // The side never flips, but a wide submenu (the 320px convert menu) opened // near the right edge would run off-screen, so slide it back in. A submenu // wider than the viewport keeps its left margin instead of hanging left. const nestedWidth = nestedContainer.offsetWidth > 0 ? nestedContainer.offsetWidth : this.nestedPopover?.size.width ?? 0; const rightLimit = window.innerWidth - NESTED_POPOVER_VIEWPORT_MARGIN - nestedWidth; const clampedLeft = nestedWidth > 0 ? Math.max(NESTED_POPOVER_VIEWPORT_MARGIN, Math.min(viewportLeft, rightLimit)) : viewportLeft; nestedContainer.style.left = `${clampedLeft - parentRootRect.left}px`; // Stamp the resolved side/align so CSS/animation can key off it, mirroring // the root popover's data-side/data-align contract. actualPopoverEl.setAttribute('data-side', 'right'); actualPopoverEl.setAttribute('data-align', 'center'); // Center nested popover vertically on the trigger item, then clamp // so the submenu never overflows the viewport top or bottom. const triggerItemEl = triggerItem.getElement(); const triggerItemRect = triggerItemEl?.getBoundingClientRect(); const nestedHeight = this.nestedPopover?.size.height ?? 0; if (triggerItemRect && nestedHeight > 0) { const triggerCenterY = triggerItemRect.top + triggerItemRect.height / 2; const desiredTop = triggerCenterY - nestedHeight / 2; const { top: clampedTop } = clampNestedPopoverTop({ desiredTop, nestedHeight, viewportHeight: window.innerHeight, }); nestedContainer.style.top = `${clampedTop - parentRootRect.top}px`; } else { nestedContainer.style.top = 'calc(var(--trigger-item-top) - var(--popover-height) / 2 + var(--item-height) / 2)'; } } /** * Helps to calculate size of popover that is only resolved when popover is displayed on screen. * Renders invisible clone of popover to get actual values. */ public get size(): { height: number; width: number } { if (this._size) { return this._size; } const size = { height: 0, width: 0, }; if (this.nodes.popover === null) { return size; } const popoverClone = this.nodes.popover.cloneNode(true) as HTMLElement; popoverClone.style.visibility = 'hidden'; popoverClone.style.position = 'absolute'; popoverClone.style.top = '-1000px'; // The native `popover` attribute makes the element `display:none` until // `showPopover()` is called. Strip it from the measurement clone (via the // centralized helper) so the container has a rendered box and // offsetHeight/offsetWidth are real. stripPopoverAttribute(popoverClone); popoverClone.setAttribute(DATA_ATTR.popoverOpened, 'true'); popoverClone.querySelector(`[${DATA_ATTR.nested}]`)?.remove(); const container = popoverClone.querySelector(`[${DATA_ATTR.popoverContainer}]`) as HTMLElement; container.className = twMerge(container.className, popoverCss.popoverContainerOpened); document.body.appendChild(popoverClone); size.height = container.offsetHeight; size.width = container.offsetWidth; popoverClone.remove(); this._size = size; return size; } /** * Invalidates the cached popover size so the next access to `size` re-measures. */ public invalidateSizeCache(): void { this._size = undefined; } protected override onTabsChange(): void { if (this.isSearching) { return; } super.onTabsChange(); this.invalidateSizeCache(); this.reposition(); if (this.flipper?.isActivated) { this.flipper.deactivate(); this.flipper.activate(this.flippableElements); } } /** * Returns list of elements available for keyboard navigation. */ protected get flippableElements(): HTMLElement[] { const result = this.items.flatMap(item => { return this.getFlippableElementsForItem(item); }).filter((item): item is HTMLElement => item !== undefined && item !== null); return result; } /** * Gets flippable elements for a single item. * @param item - popover item to get elements from * @returns array of HTML elements for keyboard navigation */ private getFlippableElementsForItem(item: PopoverItem): HTMLElement[] { if (item.getElement()?.hasAttribute(DATA_ATTR.hidden)) { return []; } if (item instanceof PopoverItemHtml) { // The wrapper is role="presentation" and non-focusable: only the inner // interactive controls are focus stops. A decorative item (section header, // metadata footer) contributes none — falling back to the wrapper would // put a role="presentation" element under aria-activedescendant and steal // index 0 from the first real option. return item.getControls().filter(control => { return !control.hasAttribute(DATA_ATTR.hidden) && (!control.matches('[role="tab"][data-blok-popover-tab]') || control.getAttribute('aria-selected') === 'true'); }); } if (!(item instanceof PopoverItemDefault)) { return []; } if (item.isDisabled) { return []; } const element = item.getElement(); return element ? [ element ] : []; } /** * Called on flipper navigation */ private onFlip = (): void => { const focusedItem = this.itemsDefault.find(item => item.isFocused); focusedItem?.onFocus(); }; /** * Builds cache of PopoverItemDefault instances from nested children. * Recursively walks the item tree to arbitrary depth. * Each cached item is mapped to its parent chain for group labeling. */ private buildPromotedItemCache(): { items: PopoverItemDefault[]; parentChains: Map } { const cache = { items: [] as PopoverItemDefault[], parentChains: new Map(), }; this.collectPromotedChildren(this.items, [], cache); return cache; } /** * Recursively collects default child items from items that have children. * @param items - items to inspect for children * @param parentChain - ancestor label chain accumulated so far * @param cache - mutable cache to populate */ private collectPromotedChildren( items: PopoverItem[], parentChain: string[], cache: { items: PopoverItemDefault[]; parentChains: Map } ): void { for (const item of items) { if (!(item instanceof PopoverItemDefault) || !item.hasChildren) { continue; } const label = item.title ?? item.name ?? ''; const newChain = [...parentChain, label]; this.collectDefaultChildren(item.children, newChain, cache); } } /** * Constructs PopoverItemDefault instances from raw params and adds them to the cache. * @param childParams - raw child item params from a parent item * @param parentChain - ancestor label chain for this group * @param cache - mutable cache to populate */ private collectDefaultChildren( childParams: PopoverItemParams[], parentChain: string[], cache: { items: PopoverItemDefault[]; parentChains: Map } ): void { for (const childParam of childParams) { if (childParam.type !== undefined && childParam.type !== PopoverItemType.Default) { continue; } const childInstance = new PopoverItemDefault(childParam, { menuItemRole: this.params.listbox === true ? 'option' : 'menuitem', }); if (childInstance.name !== undefined && this.isNamePermanentlyHidden(childInstance.name)) { childInstance.destroy(); continue; } cache.items.push(childInstance); cache.parentChains.set(childInstance, parentChain); if (childInstance.hasChildren) { this.collectPromotedChildren([childInstance], parentChain, cache); } } } /** * Removes promoted items and group separators from DOM and destroys cached instances. * Idempotent — safe to call when cache is already null. */ private cleanupPromotedItems(): void { for (const separator of this.promotedSeparators) { separator.remove(); } this.promotedSeparators = []; if (this.topLevelSeparator !== null) { this.topLevelSeparator.remove(); this.topLevelSeparator = null; } if (this.promotedItemCache !== null) { for (const item of this.promotedItemCache.items) { item.getElement()?.remove(); item.destroy(); } this.promotedItemCache = null; } } /** * Creates a group separator element for search results. * @param label - group label text * @param kind - which data attribute to tag the separator with (top-level vs promoted group) */ private createGroupSeparator(label: string, kind: 'promoted' | 'topLevel' = 'promoted'): HTMLElement { const el = document.createElement('div'); const attr = kind === 'topLevel' ? DATA_ATTR.topLevelGroupLabel : DATA_ATTR.promotedGroupLabel; el.setAttribute(attr, ''); el.setAttribute('role', 'separator'); // Same no-/50 contrast rule as Toolbox#buildSectionHeaderItem, whose visual // language this shares. el.className = 'pl-2 pr-3 pt-2.5 pb-1 text-xs font-medium text-gray-text cursor-default'; el.textContent = label; return el; } /** * Appends DOM elements for a group of promoted items to the items container. * @param groupItems - promoted items with their scores */ private appendPromotedGroupElements(groupItems: Array<{ item: PopoverItemDefault; score: number }>): void { for (const { item } of groupItems) { const el = item.getElement(); if (el !== null) { this.nodes.items?.appendChild(el); } } } /** * Filters out top-level items whose title duplicates a promoted item's title. * Prevents the same entry from appearing both at the top level and under a group * like "Convert to" during search. * @param topLevel - top-level items matching the search query * @param promoted - promoted items from nested children */ private deduplicateAgainstPromoted( topLevel: PopoverItemDefault[], promoted: Array<{ item: PopoverItemDefault }> ): PopoverItemDefault[] { const promotedTitles = new Set(); for (const { item } of promoted) { if (item.title !== undefined) { promotedTitles.add(item.title.toLowerCase()); } } return topLevel.filter(item => { // A state control and a same-title nested action can update different data. if (!(item instanceof PopoverItemDefault) || item.title === undefined || item.toggle !== undefined) { return true; } return !promotedTitles.has(item.title.toLowerCase()); }); } /** * Adds search to the popover */ private addSearch(): void { // The combobox input needs a results container to point aria-controls at. // The listbox surfaces (e.g. Toolbox) supply a stable id via listboxId; for // menu surfaces (e.g. BlockSettings) generate one so the link still holds. if (this.nodes.items !== null && this.nodes.items.id === '') { this.nodes.items.id = generateId('blok-popover-items-'); } this.search = new SearchInput({ items: this.itemsDefault, placeholder: this.messages.search, label: this.messages.search, controlsId: this.nodes.items?.id, }); // Wire the search input as the aria-activedescendant host so the flipper // mirrors the virtually-focused result onto it while the caret stays in the // field. The Toolbox drives its own contentEditable host instead (it has no // search field), so this only applies to search-field popovers. this.setActiveDescendantHost(this.search.getInput()); this.search.on(SearchInputEvent.Search, (searchData: { query: string; items: SearchableItem[] }) => { const isEmptyQuery = searchData.query === ''; if (isEmptyQuery) { this.cleanupPromotedItems(); this.onSearch({ query: searchData.query, topLevelItems: searchData.items, promotedItems: [], }); return; } // Build cache on first non-empty search if (this.promotedItemCache === null) { this.promotedItemCache = this.buildPromotedItemCache(); } // Score promoted items against the query const { parentChains } = this.promotedItemCache; const promotedScored = this.promotedItemCache.items .map(item => ({ item, score: scoreSearchMatch(item, searchData.query), chain: parentChains.get(item) ?? [], })) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score); this.onSearch({ query: searchData.query, topLevelItems: searchData.items, promotedItems: promotedScored, }); }); const searchElement = this.search.getElement(); // The popover container no longer pads its top, so the search input owns its own // top gap from the container edge (mt). Its gap from the results below is supplied by // the items list's before-first-element padding (css.items pt-1.5), so no mb here — // otherwise the search-to-results gap would double to 12px. searchElement.classList.add('mt-1.5'); this.nodes.popoverContainer.insertBefore(searchElement, this.nodes.popoverContainer.firstChild); } /** * Filters popover items by query string. * Used for inline slash search where typing happens in the block, not in a search input. * @param query - search query text */ public override filterItems(query: string): void { if (query === '') { this.cleanupPromotedItems(); this.onSearch({ query, topLevelItems: this.itemsDefault, promotedItems: [], }); return; } // Build cache on first non-empty search if (this.promotedItemCache === null) { this.promotedItemCache = this.buildPromotedItemCache(); } // Score top-level items const topLevelScored = this.itemsDefault .map(item => ({ item, score: scoreSearchMatch(item, query) })) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score); // Score promoted items from cache const { parentChains: chains } = this.promotedItemCache; const promotedScored = this.promotedItemCache.items .map(item => ({ item, score: scoreSearchMatch(item, query), chain: chains.get(item) ?? [], })) .filter(({ score }) => score > 0) .sort((a, b) => b.score - a.score); this.onSearch({ query, topLevelItems: topLevelScored.map(({ item }) => item), promotedItems: promotedScored, }); } /** * Handles search results from both filterItems and SearchInput. * Renders top-level matches and promoted children with group separators. */ private onSearch = (data: { query: string; topLevelItems: PopoverItemDefault[] | SearchableItem[]; promotedItems: Array<{ item: PopoverItemDefault; score: number; chain: string[] }>; }): void => { const isEmptyQuery = data.query === ''; this.isSearching = !isEmptyQuery; const allTopLevel = data.topLevelItems as unknown as PopoverItemDefault[]; if (this.nodes.contextLabel !== undefined) { if (isEmptyQuery) { this.nodes.contextLabel.removeAttribute(DATA_ATTR.hidden); this.nodes.contextLabel.classList.remove('hidden'); } else { this.nodes.contextLabel.setAttribute(DATA_ATTR.hidden, 'true'); this.nodes.contextLabel.classList.add('hidden'); } } // Deduplicate: hide top-level items whose title matches a promoted item const matchingTopLevel = !isEmptyQuery && data.promotedItems.length > 0 ? this.deduplicateAgainstPromoted(allTopLevel, data.promotedItems) : allTopLevel; const isNothingFound = matchingTopLevel.length === 0 && data.promotedItems.length === 0; this.announceResults(data.query, isNothingFound, matchingTopLevel.length + data.promotedItems.length); this.items .forEach((item) => { const isDefaultItem = item instanceof PopoverItemDefault; const isSeparatorOrHtml = item instanceof PopoverItemSeparator || item instanceof PopoverItemHtml; const isPermanentlyHidden = item.name !== undefined && this.isNamePermanentlyHidden(item.name); const isHidden = isDefaultItem ? !matchingTopLevel.includes(item) || isPermanentlyHidden || (isEmptyQuery && this.isItemHiddenByTab(item)) : (isSeparatorOrHtml && (isNothingFound || !isEmptyQuery)) || isPermanentlyHidden; item.toggleHidden(isHidden); }); // Invalidate size cache since item visibility changed this._size = undefined; // Reorder top-level DOM elements to reflect ranking if (!isEmptyQuery && matchingTopLevel.length > 0) { this.reorderItemsByRank(matchingTopLevel); } else if (isEmptyQuery && this.originalItemOrder !== undefined) { this.restoreOriginalItemOrder(); } // Detach previous promoted elements from DOM (don't destroy cache) for (const separator of this.promotedSeparators) { separator.remove(); } this.promotedSeparators = []; if (this.topLevelSeparator !== null) { this.topLevelSeparator.remove(); this.topLevelSeparator = null; } if (this.promotedItemCache !== null) { for (const item of this.promotedItemCache.items) { item.getElement()?.remove(); } } // Render top-level group header when promoted groups will be rendered below if (!isEmptyQuery && matchingTopLevel.length > 0 && data.promotedItems.length > 0) { const label = this.messages.actions ?? 'Actions'; const separator = this.createGroupSeparator(label, 'topLevel'); const firstRankedElement = matchingTopLevel[0].getElement(); if (firstRankedElement !== null && this.nodes.items !== null) { this.nodes.items.insertBefore(separator, firstRankedElement); this.topLevelSeparator = separator; } } // Render promoted items grouped by parent chain if (data.promotedItems.length > 0) { const groups = new Map>(); for (const entry of data.promotedItems) { const label = entry.chain.join(' \u203A '); const group = groups.get(label) ?? []; group.push({ item: entry.item, score: entry.score }); groups.set(label, group); } // Sort groups by best score in each group const sortedGroups = [...groups.entries()].sort((a, b) => { const bestA = Math.max(...a[1].map(e => e.score)); const bestB = Math.max(...b[1].map(e => e.score)); return bestB - bestA; }); for (const [label, groupItems] of sortedGroups) { const separator = this.createGroupSeparator(label); this.promotedSeparators.push(separator); this.nodes.items?.appendChild(separator); this.appendPromotedGroupElements(groupItems); } } this.toggleNothingFoundMessage(isNothingFound); this.updateScrollReel(); // Filtering changes the list height, hence whether/where the thumb sits. this.updateScrollbar(); // Recalculate position since popover height may have changed (trigger-based popovers only; // non-trigger popovers use CSS variable positioning that doesn't need pixel recalculation) if (this.trigger && this.nodes.popover.hasAttribute(DATA_ATTR.popoverOpened)) { const { top, left, openTop, openLeft } = this.calculatePosition(); this.nodes.popover.style.top = `${top}px`; this.nodes.popover.style.left = `${left}px`; this.applyResolvedSide(openTop, openLeft); } // Build flippable elements list: top-level matches + promoted items const topLevelFlippable = isEmptyQuery ? this.flippableElements : matchingTopLevel.map(item => item.getElement()); const promotedFlippable = data.promotedItems.map(({ item }) => item.getElement()); const flippableElements = [ ...topLevelFlippable, ...promotedFlippable, ].filter((el): el is HTMLElement => el !== null && !el.hasAttribute(DATA_ATTR.hidden)); if (!this.flipper?.isActivated) { return; } this.flipper.deactivate(); this.flipper.activate(flippableElements); if (flippableElements.length > 0 && !this.nodes.items.querySelector('[data-blok-convert-item]')) { this.flipper.focusItem(0, { skipNextTab: true }); } }; /** * Writes the current filter result count (or the empty-state message) into * the visually-hidden live region so screen readers announce it. The * announcer is cleared for an empty query so the full unfiltered list is not * announced as a "result count". * @param query - the current search query * @param isNothingFound - true when no items matched * @param count - number of matching items (top-level + promoted) */ private announceResults(query: string, isNothingFound: boolean, count: number): void { const announcer = this.nodes.resultsAnnouncer; if (announcer === undefined) { return; } if (query === '') { announcer.textContent = ''; return; } if (isNothingFound) { announcer.textContent = this.messages.nothingFound ?? 'Nothing found'; return; } const template = this.messages.searchResults; // Only announce a running count when a template is supplied (the Toolbox). // Other popovers keep the region silent for non-empty results. announcer.textContent = template !== undefined ? template.replace('{count}', String(count)) : ''; } /** * Reorders DOM children of the items container to match the ranked order. * Caches the original order on first call so it can be restored later. * @param rankedItems - items sorted by search relevance (best first) */ private reorderItemsByRank(rankedItems: PopoverItemDefault[]): void { if (this.originalItemOrder === undefined && this.nodes.items !== null) { this.originalItemOrder = Array.from(this.nodes.items.children); } const itemsContainer = this.nodes.items; if (itemsContainer === null) { return; } for (const item of rankedItems) { const el = item.getElement(); if (el !== null) { itemsContainer.appendChild(el); } } } /** * Restores the original DOM order of items container children. * Called when the search query is cleared. */ private restoreOriginalItemOrder(): void { const itemsContainer = this.nodes.items; if (itemsContainer === null || this.originalItemOrder === undefined) { return; } for (const el of this.originalItemOrder) { itemsContainer.appendChild(el); } this.originalItemOrder = undefined; } }