import type { JSX, ValidComponent } from "@solidjs/web"; /** * `` — internal shared implementation for the * `` / `` parts. Mirrors * `packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx`. * * Visibility is driven by `root.scrollUpArrowVisible` / * `root.scrollDownArrowVisible` flags — the popup's geometry effect writes * them during trigger-aligned placement, and this component gates mount * on them plus `openMethod !== 'touch'` (scroll arrows are hover-only). * * The full scroll-by-item behavior (`onMouseMove` progressing through * `listRef` via `getTargetScrollTop`) is ported but only becomes exercisable * when real DOM geometry is available (requires browser tests). */ import { dynamic } from "@solidjs/web"; import { createMemo, createRenderEffect, omit, onCleanup } from "solid-js"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { getMaxScrollOffset, normalizeScrollOffset, SCROLL_EDGE_TOLERANCE_PX, } from "../../internals/scroll-area-utils/scroll-edges"; import type { BaseUIComponentProps } from "../../internals/types"; import { Timeout } from "../../internals/use-timeout"; import { type TransitionStatus, useTransitionStatus, } from "../../internals/use-transition-status/use-transition-status"; import { type SelectSide, useSelectPositionerContext, } from "../positioner/select-positioner-context"; import { useSelectRootContext } from "../select-root-context"; export interface SelectScrollArrowState { direction: "up" | "down"; visible: boolean; side: SelectSide; transitionStatus: TransitionStatus; } export interface SelectScrollArrowProps extends BaseUIComponentProps<"div", SelectScrollArrowState> { as?: ValidComponent; /** Whether to keep the element mounted when the popup isn't scrollable. @default false */ keepMounted?: boolean | undefined; /** Internal — whether this arrow renders up- or down-scroll affordance. */ direction: "up" | "down"; } export function SelectScrollArrow(props: SelectScrollArrowProps): JSX.Element { const root = useSelectRootContext(); const positioner = useSelectPositionerContext(); const isUp = (): boolean => props.direction === "up"; const keepMounted = (): boolean => props.keepMounted ?? false; const scrollArrowRef = isUp() ? positioner.scrollUpArrowRef : positioner.scrollDownArrowRef; const setArrowRef = (el: HTMLElement | null): void => { scrollArrowRef.current = el; }; const stateVisible = createMemo(() => isUp() ? root.scrollUpArrowVisible() : root.scrollDownArrowVisible(), ); const visible = createMemo( () => stateVisible() && root.openMethod() !== "touch", ); const { transitionStatus, setMounted } = useTransitionStatus(visible); root.scrollArrowsMountedCountRef.current += 1; onCleanup(() => { root.scrollArrowsMountedCountRef.current = Math.max( 0, root.scrollArrowsMountedCountRef.current - 1, ); }); // When visibility drops, unmount after any exit animation — eager here // because we don't thread `useOpenChangeComplete` per-arrow. createRenderEffect( () => visible(), (isVisible) => { if (!isVisible) setMounted(false); }, ); const timeout = Timeout.create(); onCleanup(() => timeout.clear()); const state = createMemo(() => ({ direction: props.direction, visible: visible(), side: positioner.side(), transitionStatus: transitionStatus(), })); const internal = { "aria-hidden": "true", get style() { const base: JSX.CSSProperties = { position: "absolute" }; return base; }, children: (isUp() ? "\u25B2" : "\u25BC") as JSX.Element, // Default projection: direction \u2192 data-direction=value; // visible/transitionStatus mapped explicitly below for clarity. get "data-direction"() { return state().direction; }, get "data-visible"() { return state().visible ? "" : undefined; }, get "data-side"() { return state().side; }, get "data-starting-style"() { return state().transitionStatus === "starting" ? "" : undefined; }, get "data-ending-style"() { return state().transitionStatus === "ending" ? "" : undefined; }, onMouseMove(event: MouseEvent): void { if ( (event.movementX === 0 && event.movementY === 0) || timeout.isStarted() ) { return; } root.setActiveIndex(null); const scrollNextItem = (): void => { const popup = root.popupRef.current; const scroller = root.listElement() ?? popup; if (!scroller) return; root.setActiveIndex(null); root.handleScrollArrowVisibility(); const maxScrollTop = getMaxScrollOffset( scroller.scrollHeight, scroller.clientHeight, ); const scrollTop = normalizeScrollOffset( scroller.scrollTop, maxScrollTop, ); const isScrolledToEdge = scrollTop === (isUp() ? 0 : maxScrollTop); const items = root.listRef.current; if (scrollTop !== scroller.scrollTop) { scroller.scrollTop = scrollTop; } if (items.length === 0) { if (isUp()) { root.setScrollUpArrowVisible(!isScrolledToEdge); } else { root.setScrollDownArrowVisible(!isScrolledToEdge); } } if (isScrolledToEdge) { timeout.clear(); return; } if (items.length > 0) { const scrollArrowHeight = (scrollArrowRef.current as HTMLElement | null)?.offsetHeight ?? 0; scroller.scrollTop = getTargetScrollTop( items, isUp(), scrollTop, scroller.clientHeight, scrollArrowHeight, maxScrollTop, ); } timeout.start(40, scrollNextItem); }; timeout.start(40, scrollNextItem); }, onMouseLeave(): void { timeout.clear(); }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit( props, "as", "class", "style", "attrs", "direction", "keepMounted", ); let merged: Record = mergeRootProps( internal, userClassStyle, ); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "div"); return ( <> {(() => { const shouldRender = visible() || keepMounted(); if (!shouldRender) return null; return ( ); })()} ); } function getTargetScrollTop( items: Array, isUp: boolean, scrollTop: number, clientHeight: number, scrollArrowHeight: number, maxScrollTop: number, ): number { if (isUp) { let firstVisibleIndex = 0; const visibleTop = scrollTop + scrollArrowHeight - SCROLL_EDGE_TOLERANCE_PX; for (let i = 0; i < items.length; i += 1) { const item = items[i]; if (item && item.offsetTop >= visibleTop) { firstVisibleIndex = i; break; } } const targetIndex = Math.max(0, firstVisibleIndex - 1); const targetItem = items[targetIndex]; return targetIndex < firstVisibleIndex && targetItem ? normalizeScrollOffset( targetItem.offsetTop - scrollArrowHeight, maxScrollTop, ) : 0; } let lastVisibleIndex = items.length - 1; const visibleBottom = scrollTop + clientHeight - scrollArrowHeight + SCROLL_EDGE_TOLERANCE_PX; for (let i = 0; i < items.length; i += 1) { const item = items[i]; if (item && item.offsetTop + item.offsetHeight > visibleBottom) { lastVisibleIndex = Math.max(0, i - 1); break; } } const targetIndex = Math.min(items.length - 1, lastVisibleIndex + 1); const targetItem = items[targetIndex]; return targetIndex > lastVisibleIndex && targetItem ? normalizeScrollOffset( targetItem.offsetTop + targetItem.offsetHeight - clientHeight + scrollArrowHeight, maxScrollTop, ) : maxScrollTop; }