/** * Shared helpers for the composite primitives. These mirror the small * list-navigation utilities in `packages/react/src/floating-ui-react/utils/ * composite.ts` + the input / scroll helpers in * `packages/react/src/internals/composite/composite.ts`. * * The 1D list primitives are all the non-floating consumers (toolbar, tabs, * radio-group, toggle-group, slider) need. Grid helpers are intentionally not * here — the grid implementation lives in `floating-ui/utils/composite.ts` * (`getGridNavigatedIndex`), used by the floating path for menu / select / * combobox. This stays 1D unless a non-floating grid consumer surfaces. */ import type { TextDirection } from "./composite-constants"; /** * `disabledIndices` input. Consumers may pass a frozen list or a predicate. */ export type DisabledIndices = | ReadonlyArray | ((index: number) => boolean); /** * Read-only element list. We accept `ReadonlyArray` so call sites that hand us * a mutable backing array (the `elementsRef` plain array) still type-check. */ export type ElementList = ReadonlyArray; export function isIndexOutOfListBounds( list: ElementList, index: number, ): boolean { return index < 0 || index >= list.length; } /** * Best-effort visibility check. Uses `checkVisibility()` where available * (Chromium 105+, Firefox 125+) and falls back to a computed-style probe. */ export function isElementVisible(element: Element): boolean { if (typeof (element as HTMLElement).checkVisibility === "function") { return (element as HTMLElement).checkVisibility(); } const owner = element.ownerDocument?.defaultView; if (!owner) return true; return owner.getComputedStyle(element).display !== "none"; } export function isListIndexDisabled( list: ElementList, index: number, disabledIndices?: DisabledIndices, ): boolean { const isExplicitlyDisabled = typeof disabledIndices === "function" ? disabledIndices(index) : (disabledIndices?.includes(index) ?? false); if (isExplicitlyDisabled) return true; const element = list[index]; if (!element) return false; if (!isElementVisible(element)) return true; return ( !disabledIndices && (element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true") ); } /** * Walk the list looking for the next non-disabled index. `startingIndex` is * exclusive (we advance before the first check) — matching React Base UI's * implementation so consumers can port semantics unchanged. */ export function findNonDisabledListIndex( list: ElementList, options: { startingIndex?: number | undefined; decrement?: boolean | undefined; disabledIndices?: DisabledIndices | undefined; amount?: number | undefined; } = {}, ): number { const { startingIndex = -1, decrement = false, disabledIndices, amount = 1, } = options; let index = startingIndex; do { index += decrement ? -amount : amount; } while ( index >= 0 && index <= list.length - 1 && isListIndexDisabled(list, index, disabledIndices) ); return index; } export function getMinListIndex( list: ElementList, disabledIndices?: DisabledIndices, ): number { return findNonDisabledListIndex(list, { disabledIndices }); } export function getMaxListIndex( list: ElementList, disabledIndices?: DisabledIndices, ): number { return findNonDisabledListIndex(list, { decrement: true, startingIndex: list.length, disabledIndices, }); } /** * Sort comparator for `compareDocumentPosition`. Uses the "following"/ * "preceding" bits so nodes render in DOM order regardless of registration * sequence. */ export function sortByDocumentPosition(a: Element, b: Element): number { const position = a.compareDocumentPosition(b); const follows = (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 || (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0; if (follows) return -1; const precedes = (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0 || (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0; if (precedes) return 1; return 0; } /** * Is the target a native text input? Used to defer arrow-key handling to the * browser when the cursor is inside a textbox. */ export function isNativeInput( element: EventTarget | null, ): element is HTMLInputElement | HTMLTextAreaElement { if (element == null) return false; const el = element as HTMLElement; if (el.tagName === "INPUT") { const input = el as HTMLInputElement; return input.selectionStart != null; } if (el.tagName === "TEXTAREA") return true; return false; } /** * Read-only view of `HTMLElement.disabled` / `aria-disabled` for targets that * may not have the `disabled` DOM property (e.g. links). */ export function isElementDisabled(element: EventTarget | null): boolean { if (element == null || !(element instanceof Element)) return false; const el = element as HTMLElement; if ((el as { disabled?: boolean }).disabled === true) return true; return el.getAttribute("aria-disabled") === "true"; } /** * Scroll `element` into view inside `scrollContainer` when it's outside the * visible rectangle, respecting `direction` and `orientation`. The logic is a * direct port of Base UI React's `scrollIntoViewIfNeeded`. */ export function scrollIntoViewIfNeeded( scrollContainer: HTMLElement | null, element: HTMLElement | null, direction: TextDirection, orientation: "horizontal" | "vertical" | "both", ): void { if (!scrollContainer || !element?.scrollTo) { return; } let targetX = scrollContainer.scrollLeft; let targetY = scrollContainer.scrollTop; const isOverflowingX = scrollContainer.clientWidth < scrollContainer.scrollWidth; const isOverflowingY = scrollContainer.clientHeight < scrollContainer.scrollHeight; if (isOverflowingX && orientation !== "vertical") { const elementOffsetLeft = getOffset(scrollContainer, element, "left"); const containerStyles = getStyles(scrollContainer); const elementStyles = getStyles(element); if (direction === "ltr") { if ( elementOffsetLeft + element.offsetWidth + elementStyles.scrollMarginRight > scrollContainer.scrollLeft + scrollContainer.clientWidth - containerStyles.scrollPaddingRight ) { targetX = elementOffsetLeft + element.offsetWidth + elementStyles.scrollMarginRight - scrollContainer.clientWidth + containerStyles.scrollPaddingRight; } else if ( elementOffsetLeft - elementStyles.scrollMarginLeft < scrollContainer.scrollLeft + containerStyles.scrollPaddingLeft ) { targetX = elementOffsetLeft - elementStyles.scrollMarginLeft - containerStyles.scrollPaddingLeft; } } if (direction === "rtl") { if ( elementOffsetLeft - elementStyles.scrollMarginRight < scrollContainer.scrollLeft + containerStyles.scrollPaddingLeft ) { targetX = elementOffsetLeft - elementStyles.scrollMarginLeft - containerStyles.scrollPaddingLeft; } else if ( elementOffsetLeft + element.offsetWidth + elementStyles.scrollMarginRight > scrollContainer.scrollLeft + scrollContainer.clientWidth - containerStyles.scrollPaddingRight ) { targetX = elementOffsetLeft + element.offsetWidth + elementStyles.scrollMarginRight - scrollContainer.clientWidth + containerStyles.scrollPaddingRight; } } } if (isOverflowingY && orientation !== "horizontal") { const elementOffsetTop = getOffset(scrollContainer, element, "top"); const containerStyles = getStyles(scrollContainer); const elementStyles = getStyles(element); if ( elementOffsetTop - elementStyles.scrollMarginTop < scrollContainer.scrollTop + containerStyles.scrollPaddingTop ) { targetY = elementOffsetTop - elementStyles.scrollMarginTop - containerStyles.scrollPaddingTop; } else if ( elementOffsetTop + element.offsetHeight + elementStyles.scrollMarginBottom > scrollContainer.scrollTop + scrollContainer.clientHeight - containerStyles.scrollPaddingBottom ) { targetY = elementOffsetTop + element.offsetHeight + elementStyles.scrollMarginBottom - scrollContainer.clientHeight + containerStyles.scrollPaddingBottom; } } scrollContainer.scrollTo({ left: targetX, top: targetY, behavior: "auto" }); } function getOffset( ancestor: HTMLElement, element: HTMLElement, side: "left" | "top", ): number { const propName = side === "left" ? "offsetLeft" : "offsetTop"; let current: HTMLElement = element; let result = 0; while (current.offsetParent) { result += current[propName]; if (current.offsetParent === ancestor) break; current = current.offsetParent as HTMLElement; } return result; } function getStyles(element: HTMLElement): { scrollMarginTop: number; scrollMarginRight: number; scrollMarginBottom: number; scrollMarginLeft: number; scrollPaddingTop: number; scrollPaddingRight: number; scrollPaddingBottom: number; scrollPaddingLeft: number; } { const owner = element.ownerDocument?.defaultView; if (!owner) { return { scrollMarginTop: 0, scrollMarginRight: 0, scrollMarginBottom: 0, scrollMarginLeft: 0, scrollPaddingTop: 0, scrollPaddingRight: 0, scrollPaddingBottom: 0, scrollPaddingLeft: 0, }; } const styles = owner.getComputedStyle(element); return { scrollMarginTop: Number.parseFloat(styles.scrollMarginTop) || 0, scrollMarginRight: Number.parseFloat(styles.scrollMarginRight) || 0, scrollMarginBottom: Number.parseFloat(styles.scrollMarginBottom) || 0, scrollMarginLeft: Number.parseFloat(styles.scrollMarginLeft) || 0, scrollPaddingTop: Number.parseFloat(styles.scrollPaddingTop) || 0, scrollPaddingRight: Number.parseFloat(styles.scrollPaddingRight) || 0, scrollPaddingBottom: Number.parseFloat(styles.scrollPaddingBottom) || 0, scrollPaddingLeft: Number.parseFloat(styles.scrollPaddingLeft) || 0, }; }