import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — the popup container that renders the select listbox. * Mirrors `packages/react/src/select/popup/SelectPopup.tsx`. * * Wraps `` (non-modal per React — modal semantics * live on the Positioner via the InternalBackdrop + scroll-lock). When a * `` child is present, the popup becomes `role="presentation"` * and the list element owns the listbox role; otherwise the popup itself * is the listbox. * * Trigger-aligned placement (`alignItemWithTrigger`) is implemented here — * the geometry effect measures the trigger + value + selected-item-text * rects and writes `positioner.style.{top,bottom,left,height,marginTop, * marginBottom,maxHeight}` so the selected item's text aligns with the * trigger's value text. The popup also wires `store.scrollUpArrowVisible` / * `scrollDownArrowVisible` flags (surfaced to `` / * ``). None of the geometry is exercisable in * happy-dom (0 rects); the state-machine wiring is unit-tested and the * math remains a browser-test gap. */ import { rectToClientRect } from "@floating-ui/utils"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, omit, onCleanup, untrack } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { ownerDocument } from "../../internals/dom/owner-document"; import { ownerWindow } from "../../internals/dom/owner-window"; import { FloatingFocusManager } from "../../internals/floating-ui/components/floating-focus-manager"; import { isWebKit } from "../../internals/floating-ui/detect-browser"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { getMaxScrollOffset, SCROLL_EDGE_TOLERANCE_PX, } from "../../internals/scroll-area-utils/scroll-edges"; import { styleDisableScrollbar } from "../../internals/scroll-area-utils/style-disable-scrollbar"; import { clamp } from "../../internals/slider-utils/clamp"; import type { BaseUIComponentProps } from "../../internals/types"; import { useAnimationFrame } from "../../internals/use-animation-frame"; import type { TransitionStatus } from "../../internals/use-transition-status/use-transition-status"; import { type SelectAlign, type SelectSide, useSelectPositionerContext, } from "../positioner/select-positioner-context"; import { useSelectRootContext } from "../select-root-context"; export interface SelectPopupState { open: boolean; side: SelectSide; align: SelectAlign; transitionStatus: TransitionStatus; } export interface SelectPopupProps extends BaseUIComponentProps<"div", SelectPopupState> { as?: ValidComponent; /** * Element to focus when the popup closes. * - `false`: do not move focus * - `true`: default (trigger) * - `HTMLElement` or ref: explicit target */ finalFocus?: | boolean | HTMLElement | { current: HTMLElement | null } | undefined; } type ClientRectObject = ReturnType; function getMaxPopupHeight(popupStyles: CSSStyleDeclaration): number { const maxHeightStyle = popupStyles.maxHeight || ""; if (!maxHeightStyle.endsWith("px")) return Number.POSITIVE_INFINITY; const parsed = Number.parseFloat(maxHeightStyle); return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY; } function getMaxScrollTop(scroller: HTMLElement): number { return getMaxScrollOffset(scroller.scrollHeight, scroller.clientHeight); } function getScale(element: HTMLElement): { x: number; y: number } { // Approximate `@floating-ui/dom`'s `platform.getScale` — we don't bring // in the whole platform module here. const rect = element.getBoundingClientRect(); const width = element.offsetWidth; const height = element.offsetHeight; const x = width > 0 ? rect.width / width : 1; const y = height > 0 ? rect.height / height : 1; return { x: Number.isFinite(x) && x !== 0 ? x : 1, y: Number.isFinite(y) && y !== 0 ? y : 1, }; } function normalizeSize( size: number, axis: "x" | "y", scale: { x: number; y: number }, ): number { return size / scale[axis]; } function normalizeRect( rect: DOMRect | DOMRectReadOnly, scale: { x: number; y: number }, ): ClientRectObject { return rectToClientRect({ x: normalizeSize(rect.x, "x", scale), y: normalizeSize(rect.y, "y", scale), width: normalizeSize(rect.width, "x", scale), height: normalizeSize(rect.height, "y", scale), }); } const TRANSFORM_STYLE_RESETS = [ ["transform", "none"], ["scale", "1"], ["translate", "0 0"], ] as const; type TransformStyleProperty = (typeof TRANSFORM_STYLE_RESETS)[number][0]; function unsetTransformStyles(popupElement: HTMLElement): () => void { const { style } = popupElement; const originalStyles = {} as Record; for (const [property, value] of TRANSFORM_STYLE_RESETS) { originalStyles[property] = style.getPropertyValue(property); style.setProperty(property, value, "important"); } return () => { for (const [property] of TRANSFORM_STYLE_RESETS) { const originalValue = originalStyles[property]; if (originalValue) { style.setProperty(property, originalValue); } else { style.removeProperty(property); } } }; } interface PositionerStylesSnapshot { top: string; left: string; right: string; height: string; bottom: string; minHeight: string; maxHeight: string; marginTop: string; marginBottom: string; } function clearPositionerStyles( element: HTMLElement | null, original: PositionerStylesSnapshot, ): void { if (!element) return; element.style.top = original.top; element.style.left = original.left; element.style.right = original.right; element.style.height = original.height; element.style.bottom = original.bottom; element.style.minHeight = original.minHeight; element.style.maxHeight = original.maxHeight; element.style.marginTop = original.marginTop; element.style.marginBottom = original.marginBottom; } /** Mirrors React's `LIST_FUNCTIONAL_STYLES`. */ const LIST_FUNCTIONAL_STYLES: JSX.CSSProperties = { overflow: "auto", "overscroll-behavior": "contain", }; export function SelectPopup(props: SelectPopupProps): JSX.Element { const root = useSelectRootContext(); const positioner = useSelectPositionerContext(); // Toggle the disable-scrollbar class imperatively via classList so the // spread boundary stays stable (a reactive `get class()` that flips // between "" and a class name at runtime breaks Solid 2's DOM binding // for a particular class-update path). Driven by a // render effect that reacts to `alignItemWithTriggerActive()` plus the // list-present check. const setPopupRef = (el: HTMLElement | null): void => { root.popupRef.current = el; }; // Declared as a hoisted `function` (not a `const` arrow) on purpose: // the `internal` role/id/aria-* getters below are read by the `{...merged}` // spread's render-effect, whose compute runs synchronously at creation in // Solid 2. Under CDP's renderer scheduling a re-entrant re-flush can invoke // one of those getters while this body is still suspended above its own // declaration line — a `const` binding would throw `Cannot access 'hasList' // before initialization`. A function declaration hoists with its // body, so there is no temporal dead zone. Do NOT convert back to a const. function hasList(): boolean { return root.listElement() != null; } createEffect( () => ({ // Track `mounted` so the effect re-fires after the popup's ref // callback has run (the ref fires during `insert` — before the // first effect apply — in Solid 2). mountedNow: root.mounted(), aligned: positioner.alignItemWithTriggerActive(), listPresent: hasList(), }), ({ aligned, listPresent }) => { const popup = root.popupRef.current; if (!popup) return; const shouldAdd = !listPresent && aligned; popup.classList.toggle(styleDisableScrollbar.className, shouldAdd); if (aligned && !listPresent) { for (const [key, value] of Object.entries(LIST_FUNCTIONAL_STYLES)) { popup.style.setProperty(key, String(value)); } } else if (aligned && listPresent) { popup.style.height = "100%"; } else { for (const key of Object.keys(LIST_FUNCTIONAL_STYLES)) { popup.style.removeProperty(key); } if (popup.style.height === "100%") { popup.style.removeProperty("height"); } } }, ); const state = createMemo(() => ({ open: root.open(), side: positioner.side(), align: positioner.align(), transitionStatus: root.transitionStatus(), })); // Note: `as`, `class`, `style`, `render`, `finalFocus` stripped at the // bottom via `omit` before user spread. // Geometry bookkeeping — `reachedMaxHeightRef` mirrors React's // `useRef(false)`, `initialPlacedRef` tracks whether the align effect has // run for the current open, and `originalPositionerStylesRef` captures // the positioner's styles on mount so we can restore them. let reachedMaxHeight = false; let initialPlaced = false; let originalPositionerStyles: PositionerStylesSnapshot | null = null; const scrollArrowFrame = useAnimationFrame(); /** * Probes the popup / list scroller's `scrollTop` against max-scroll and * writes the `scrollUp/DownArrowVisible` flags. Mirrors React's * `handleScrollArrowVisibility` (via the same name in `popup-store-utils` * on the React side). Runs after every layout change + scroll event. */ const handleScrollArrowVisibility = (): void => { const popupEl = root.popupRef.current; if (!popupEl) return; const scroller = root.listElement() ?? popupEl; if (!scroller) return; const maxScrollTop = getMaxScrollTop(scroller); const scrollTop = scroller.scrollTop; const upVisible = scrollTop > SCROLL_EDGE_TOLERANCE_PX; const downVisible = scrollTop < maxScrollTop - SCROLL_EDGE_TOLERANCE_PX; if (root.scrollUpArrowVisible() !== upVisible) { root.setScrollUpArrowVisible(upVisible); } if (root.scrollDownArrowVisible() !== downVisible) { root.setScrollDownArrowVisible(downVisible); } }; root.handleScrollArrowVisibilityRef.current = handleScrollArrowVisibility; onCleanup(() => { if ( root.handleScrollArrowVisibilityRef.current === handleScrollArrowVisibility ) { root.handleScrollArrowVisibilityRef.current = () => {}; } }); /** * Incremental scroll-height management for trigger-aligned popups. When * the user scrolls to the edge, we grow the positioner to consume the * remaining space (matches the item-aligned UX React ships). Only active * when `initialPlaced && !reachedMaxHeight && alignItemWithTriggerActive`. * For the non-aligned path we simply re-probe the scroll arrows. */ const handleScroll = (scroller: HTMLElement): void => { const positionerElementActual = (() => { // The positioner element is the popup's offsetParent when `position: // fixed` is used for trigger-aligned. Walk up from the popup. const el = root.popupRef.current; if (!el) return null; const parent = el.parentElement; return parent; })(); const positionerNode = positionerElementActual; if (!positionerNode || !root.popupRef.current || !initialPlaced) return; if (reachedMaxHeight || !positioner.alignItemWithTriggerActive()) { handleScrollArrowVisibility(); return; } const isTopPositioned = positionerNode.style.top === "0px"; const isBottomPositioned = positionerNode.style.bottom === "0px"; if (!isTopPositioned && !isBottomPositioned) { handleScrollArrowVisibility(); return; } const scale = getScale(positionerNode); const currentHeight = normalizeSize( positionerNode.getBoundingClientRect().height, "y", scale, ); const doc = ownerDocument(positionerNode); const positionerStyles = getComputedStyle(positionerNode); const marginTop = Number.parseFloat(positionerStyles.marginTop) || 0; const marginBottom = Number.parseFloat(positionerStyles.marginBottom) || 0; const maxPopupHeight = getMaxPopupHeight( getComputedStyle(root.popupRef.current), ); const maxAvailableHeight = Math.min( doc.documentElement.clientHeight - marginTop - marginBottom, maxPopupHeight, ); const scrollTop = scroller.scrollTop; const maxScrollTop = getMaxScrollTop(scroller); let nextPositionerHeight = 0; let nextScrollTop: number | null = null; let setReachedMax = false; let scrollToMax = false; const setHeight = (height: number): void => { positionerNode.style.height = `${height}px`; }; const handleSmallDiff = (diff: number, targetScrollTop: number): void => { const heightDelta = clamp(diff, 0, maxAvailableHeight - currentHeight); if (heightDelta > 0) { setHeight(currentHeight + heightDelta); } scroller.scrollTop = targetScrollTop; if ( maxAvailableHeight - (currentHeight + heightDelta) <= SCROLL_EDGE_TOLERANCE_PX ) { reachedMaxHeight = true; } handleScrollArrowVisibility(); }; const diff = isTopPositioned ? maxScrollTop - scrollTop : scrollTop; const nextHeight = Math.min(currentHeight + diff, maxAvailableHeight); nextPositionerHeight = nextHeight; if (diff <= SCROLL_EDGE_TOLERANCE_PX) { handleSmallDiff(diff, isTopPositioned ? maxScrollTop : 0); return; } if (maxAvailableHeight - nextHeight > SCROLL_EDGE_TOLERANCE_PX) { if (isTopPositioned) { scrollToMax = true; } else { nextScrollTop = 0; } } else { setReachedMax = true; if (isBottomPositioned && scrollTop < maxScrollTop) { const overshoot = currentHeight + diff - maxAvailableHeight; nextScrollTop = scrollTop - (diff - overshoot); } } nextPositionerHeight = Math.ceil(nextPositionerHeight); if (nextPositionerHeight !== 0) { setHeight(nextPositionerHeight); } if (scrollToMax || nextScrollTop != null) { const nextMaxScrollTop = getMaxScrollTop(scroller); const target = scrollToMax ? nextMaxScrollTop : clamp(nextScrollTop as number, 0, nextMaxScrollTop); if (Math.abs(scroller.scrollTop - target) > SCROLL_EDGE_TOLERANCE_PX) { scroller.scrollTop = target; } } if ( setReachedMax || nextPositionerHeight >= maxAvailableHeight - SCROLL_EDGE_TOLERANCE_PX ) { reachedMaxHeight = true; } handleScrollArrowVisibility(); }; root.scrollHandlerRef.current = handleScroll; onCleanup(() => { if (root.scrollHandlerRef.current === handleScroll) { root.scrollHandlerRef.current = () => {}; } }); // Capture positioner styles once the popup + positioner element are // both present. Use a plain effect — we snapshot defensively; timing // isn't critical here (the trigger-aligned writes happen in the // geometry effect below). createEffect( () => ({ popup: root.popupRef.current, // Re-run on mount transitions so the snapshot is current for the // next open. mountedNow: root.mounted(), }), ({ popup, mountedNow }) => { if (!popup || !mountedNow) return; const positionerElement = popup.parentElement; if (!positionerElement || originalPositionerStyles) return; originalPositionerStyles = { top: positionerElement.style.top || "0", left: positionerElement.style.left || "0", right: positionerElement.style.right, height: positionerElement.style.height, bottom: positionerElement.style.bottom, minHeight: positionerElement.style.minHeight, maxHeight: positionerElement.style.maxHeight, marginTop: positionerElement.style.marginTop, marginBottom: positionerElement.style.marginBottom, }; }, ); // Reset trigger-aligned geometry when the popup is not open or the // feature is inactive. Matches React's `useIsoLayoutEffect` branch. createEffect( () => ({ isOpen: root.open(), alignActive: positioner.alignItemWithTriggerActive(), }), ({ isOpen, alignActive }) => { if (isOpen || alignActive) return; initialPlaced = false; reachedMaxHeight = false; const popup = root.popupRef.current; const positionerElement = popup?.parentElement; if (positionerElement && originalPositionerStyles) { clearPositionerStyles(positionerElement, originalPositionerStyles); } }, ); // Trigger-aligned placement effect. Runs as a regular effect — // happy-dom rects are all zero so the math is inert here, but the state- // machine transitions (init-placed flip, fallback-to-trigger-aligned, // transform-origin) are wired for browser coverage. createEffect( () => ({ isOpen: root.open(), trigger: root.triggerElement(), alignActive: positioner.alignItemWithTriggerActive(), isPositioned: positioner.isPositioned(), mountedNow: root.mounted(), transition: root.transitionStatus(), listEl: root.listElement(), }), // Apply scope is unowned/untracked — wrap the entire body so // reactive reads inside the geometry math (`root.listElement()`, // `root.highlightItemOnHover()`, `root.selectedIndex()`, // `root.activeIndex()`) plus the writebacks // (`root.setActiveIndex`, `positioner.setControlledAlignItemWithTrigger`) // don't trip `[STRICT_READ_UNTRACKED]`. The compute object above // already drives re-runs on every reactive input we care about. ({ isOpen, trigger, alignActive, isPositioned, transition }) => untrack(() => { const popupElement = root.popupRef.current; if (!popupElement) return; const positionerElement = popupElement.parentElement; if (!positionerElement) return; if (!isOpen || !trigger) return; if (alignActive && !isPositioned) return; if (transition === "ending") return; if (!alignActive) { initialPlaced = true; scrollArrowFrame.request(handleScrollArrowVisibility); popupElement.style.removeProperty("--transform-origin"); return; } const restoreTransformStyles = unsetTransformStyles(popupElement); popupElement.style.removeProperty("--transform-origin"); try { const textElement = root.selectedItemTextRef.current; const valueElement = root.valueRef.current; const positionerStyles = getComputedStyle(positionerElement); const popupStyles = getComputedStyle(popupElement); const doc = ownerDocument(trigger); const win = ownerWindow(positionerElement); const scale = getScale(trigger); const triggerRect = normalizeRect( trigger.getBoundingClientRect(), scale, ); const positionerRect = normalizeRect( positionerElement.getBoundingClientRect(), scale, ); const triggerHeight = triggerRect.height; const scroller = root.listElement() ?? popupElement; const scrollHeight = scroller.scrollHeight; const borderBottom = Number.parseFloat(popupStyles.borderBottomWidth) || 0; const marginTop = Number.parseFloat(positionerStyles.marginTop) || 10; const marginBottom = Number.parseFloat(positionerStyles.marginBottom) || 10; const minHeight = Number.parseFloat(positionerStyles.minHeight) || 100; const maxPopupHeight = getMaxPopupHeight(popupStyles); const paddingLeft = 5; const paddingRight = 5; const triggerCollisionThreshold = 20; const viewportHeight = doc.documentElement.clientHeight - marginTop - marginBottom; const viewportWidth = doc.documentElement.clientWidth; const availableSpaceBeneathTrigger = viewportHeight - triggerRect.bottom + triggerHeight; let textRect: ClientRectObject | undefined; let alignedLeft = triggerRect.left; let offsetY = 0; if (textElement && valueElement) { const valueRect = normalizeRect( valueElement.getBoundingClientRect(), scale, ); textRect = normalizeRect( textElement.getBoundingClientRect(), scale, ); alignedLeft = positionerRect.left + (valueRect.left - textRect.left); const valueCenterFromTriggerTop = valueRect.top - triggerRect.top + valueRect.height / 2; const textCenterFromPositionerTop = textRect.top - positionerRect.top + textRect.height / 2; offsetY = textCenterFromPositionerTop - valueCenterFromTriggerTop; } const idealHeight = availableSpaceBeneathTrigger + offsetY + marginBottom + borderBottom; let height = Math.min(viewportHeight, idealHeight); const maxHeight = viewportHeight - marginTop - marginBottom; const scrollTop = idealHeight - height; const maxRight = viewportWidth - paddingRight; positionerElement.style.left = `${clamp( alignedLeft, paddingLeft, maxRight - positionerRect.width, )}px`; positionerElement.style.height = `${height}px`; positionerElement.style.maxHeight = "auto"; positionerElement.style.marginTop = `${marginTop}px`; positionerElement.style.marginBottom = `${marginBottom}px`; popupElement.style.height = "100%"; const maxScrollTop = getMaxScrollTop(scroller); const isTopPositioned = scrollTop >= maxScrollTop - SCROLL_EDGE_TOLERANCE_PX; if (isTopPositioned) { height = Math.min(viewportHeight, positionerRect.height) - (scrollTop - maxScrollTop); } const fallbackToAlignPopupToTrigger = triggerRect.top < triggerCollisionThreshold || triggerRect.bottom > viewportHeight - triggerCollisionThreshold || Math.ceil(height) + SCROLL_EDGE_TOLERANCE_PX < Math.min(scrollHeight, minHeight); const isPinchZoomed = (win.visualViewport?.scale ?? 1) !== 1 && isWebKit; if (fallbackToAlignPopupToTrigger || isPinchZoomed) { initialPlaced = true; if (originalPositionerStyles) { clearPositionerStyles( positionerElement, originalPositionerStyles, ); } positioner.setControlledAlignItemWithTrigger(false); return; } const initialHeightCalc = Math.max(minHeight, height); if (isTopPositioned) { const topOffset = Math.max(0, viewportHeight - idealHeight); positionerElement.style.top = positionerRect.height >= maxHeight ? "0" : `${topOffset}px`; positionerElement.style.height = `${height}px`; scroller.scrollTop = getMaxScrollTop(scroller); } else { positionerElement.style.bottom = "0"; scroller.scrollTop = scrollTop; } if (textRect) { const popupTop = positionerRect.top; const popupHeight = positionerRect.height; const textCenterY = textRect.top + textRect.height / 2; const transformOriginY = popupHeight > 0 ? ((textCenterY - popupTop) / popupHeight) * 100 : 50; const clampedY = clamp(transformOriginY, 0, 100); popupElement.style.setProperty( "--transform-origin", `50% ${clampedY}%`, ); } if ( initialHeightCalc === viewportHeight || height >= maxPopupHeight ) { reachedMaxHeight = true; } handleScrollArrowVisibility(); if ( root.highlightItemOnHover() && root.selectedIndex() === null && root.activeIndex() === null && root.listRef.current[0] != null ) { root.setActiveIndex(0); } initialPlaced = true; } finally { restoreTransformStyles(); } }), ); // Window-resize dismisses the popup while trigger-aligned (React parity). createEffect( () => ({ alignActive: positioner.alignItemWithTriggerActive(), isOpen: root.open(), }), ({ alignActive, isOpen }) => { if (!alignActive || !isOpen) return undefined; const popup = root.popupRef.current; const win = ownerWindow(popup); const onResize = (event: UIEvent): void => { root.setOpen( false, createChangeEventDetails(REASONS.windowResize, event), ); }; win.addEventListener("resize", onResize); // Apply scope is unowned — return cleanup instead of `onCleanup`. return () => { win.removeEventListener("resize", onResize); }; }, ); const internal = { get role() { return hasList() ? "presentation" : "listbox"; }, get id() { if (hasList()) return ""; return `${root.id() ?? ""}-list`; }, get "aria-multiselectable"() { if (hasList()) return ""; return root.multiple() ? "true" : ""; }, get "aria-orientation"() { return hasList() ? "" : ""; }, onKeyDown(event: KeyboardEvent): void { root.keyboardActiveRef.current = true; void event; }, onMouseMove(event: MouseEvent): void { root.keyboardActiveRef.current = false; void event; }, onScroll(event: Event): void { if (root.listElement()) return; const target = event.currentTarget as HTMLElement | null; if (target) { root.scrollHandlerRef.current(target); } }, // popupWithTransitionStateMapping projection. get "data-open"() { return state().open ? "" : undefined; }, get "data-closed"() { return state().open ? undefined : ""; }, get "data-starting-style"() { return state().transitionStatus === "starting" ? "" : undefined; }, get "data-ending-style"() { return state().transitionStatus === "ending" ? "" : undefined; }, // `side`/`align` are state but not in popupWithTransitionStateMapping. // Default projection runs unmapped keys: data-side, data-align. get "data-side"() { return state().side; }, get "data-align"() { return state().align; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit(props, "as", "class", "style", "attrs", "finalFocus"); let merged: Record = mergeRootProps( root.interactions.getFloatingProps(), internal, ); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); const popupElement = ( ); return ( {popupElement} ); }