diff --git a/.changeset/select-item-aligned.md b/.changeset/select-item-aligned.md new file mode 100644 index 000000000..6764c20f4 --- /dev/null +++ b/.changeset/select-item-aligned.md @@ -0,0 +1,5 @@ +--- +"bits-ui": minor +--- + +feat(Select): add item-aligned positioning diff --git a/docs/content/components/select.md b/docs/content/components/select.md index c3363c17a..9e572bb45 100644 --- a/docs/content/components/select.md +++ b/docs/content/components/select.md @@ -4,7 +4,7 @@ description: Enables users to pick from a list of options displayed in a dropdow --- @@ -290,6 +290,32 @@ You can opt-out of this behavior by instead using the `Select.ContentStatic` com When using this component, you'll need to handle the positioning of the content yourself. Keep in mind that using `Select.Portal` alongside `Select.ContentStatic` may result in some unexpected positioning behavior, feel free to not use the portal or work around it. +## Item-Aligned Positioning + +By default, `Select.Content` uses [Floating UI](https://floating-ui.com/) to anchor the list to the trigger, much like a popover. When you set `position="item-aligned"`, the content is instead placed so that the currently selected item sits directly over the trigger, mirroring the behavior of a native `` on macOS — the `side`, `sideOffset`, `align`, `alignOffset`, and collision props are ignored, and `data-side` is set to `none`.", + definition: SelectPositionProp, + }), forceMount: forceMountProp, preventScroll: definePropSchema({ ...preventScrollProp, @@ -190,6 +198,13 @@ export const contentStatic = defineComponentApiSchema` on macOS. When this is active, `data-side` is set to `none`.", + definition: SelectPositionProp, + }), forceMount: forceMountProp, ...withChildProps({ elType: "HTMLDivElement", diff --git a/docs/src/routes/select-test/+page.svelte b/docs/src/routes/select-test/+page.svelte new file mode 100644 index 000000000..44f6ec37b --- /dev/null +++ b/docs/src/routes/select-test/+page.svelte @@ -0,0 +1,23 @@ + + +
+ +
+

Trigger near top of viewport

+ +
+ + +
+

Trigger near middle of viewport

+ +
+ + +
+

Trigger near bottom of viewport

+ +
+
diff --git a/packages/bits-ui/src/lib/bits/combobox/types.ts b/packages/bits-ui/src/lib/bits/combobox/types.ts index 9972e811a..67722e723 100644 --- a/packages/bits-ui/src/lib/bits/combobox/types.ts +++ b/packages/bits-ui/src/lib/bits/combobox/types.ts @@ -1,6 +1,10 @@ import type { BitsPrimitiveInputAttributes } from "$lib/shared/attributes.js"; import type { SelectBaseRootPropsWithoutHTML, + SelectContentProps, + SelectContentPropsWithoutHTML, + SelectContentStaticProps, + SelectContentStaticPropsWithoutHTML, SelectMultipleRootPropsWithoutHTML, SelectSingleRootPropsWithoutHTML, } from "$lib/bits/select/types.js"; @@ -34,11 +38,17 @@ export type ComboboxRootPropsWithoutHTML = ComboboxBaseRootPropsWithoutHTML & export type ComboboxRootProps = ComboboxRootPropsWithoutHTML; +// Combobox doesn't support `position="item-aligned"` — item-aligned positioning +// doesn't apply when the list is filtered by typing. +export type ComboboxContentProps = Omit; +export type ComboboxContentPropsWithoutHTML = Omit; +export type ComboboxContentStaticProps = Omit; +export type ComboboxContentStaticPropsWithoutHTML = Omit< + SelectContentStaticPropsWithoutHTML, + "position" +>; + export type { - SelectContentProps as ComboboxContentProps, - SelectContentPropsWithoutHTML as ComboboxContentPropsWithoutHTML, - SelectContentStaticProps as ComboboxContentStaticProps, - SelectContentStaticPropsWithoutHTML as ComboboxContentStaticPropsWithoutHTML, SelectItemProps as ComboboxItemProps, SelectItemPropsWithoutHTML as ComboboxItemPropsWithoutHTML, SelectItemSnippetProps as ComboboxItemSnippetProps, diff --git a/packages/bits-ui/src/lib/bits/select/components/select-content-static.svelte b/packages/bits-ui/src/lib/bits/select/components/select-content-static.svelte index 946129293..0a2ef468a 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select-content-static.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select-content-static.svelte @@ -6,6 +6,7 @@ import { createId } from "$lib/internal/create-id.js"; import { noop } from "$lib/internal/noop.js"; import PopperLayerForceMount from "$lib/bits/utilities/popper-layer/popper-layer-force-mount.svelte"; + import SelectItemAlignedContent from "./select-item-aligned-content.svelte"; const uid = $props.id(); @@ -19,6 +20,7 @@ child, preventScroll = false, style, + position = "popper", ...restProps }: SelectContentStaticProps = $props(); @@ -30,12 +32,45 @@ ), onInteractOutside: boxWith(() => onInteractOutside), onEscapeKeydown: boxWith(() => onEscapeKeydown), + position: boxWith(() => position), }); const mergedProps = $derived(mergeProps(restProps, contentState.props)); + + function mountWrapper(node: HTMLElement) { + contentState.setContentWrapper(node); + return { destroy: () => contentState.setContentWrapper(null) }; + } -{#if forceMount} +{#if contentState.useItemAligned} + + {#snippet content({ props: layerProps })} + {@const contentProps = mergeProps(restProps, layerProps, contentState.props, { style })} +
+ {#if child} + {@render child({ props: contentProps, ...contentState.snippetProps })} + {:else} +
+ {@render children?.()} +
+ {/if} +
+ {/snippet} +
+{:else if forceMount} onInteractOutside), onEscapeKeydown: boxWith(() => onEscapeKeydown), + position: boxWith(() => position), }); const mergedProps = $derived(mergeProps(restProps, contentState.props)); + + // Svelte action to register the fixed wrapper node. Using use: instead of ref={fn} + // because the callback ref syntax doesn't trigger in this Svelte version. + function mountWrapper(node: HTMLElement) { + contentState.setContentWrapper(node); + return { destroy: () => contentState.setContentWrapper(null) }; + } -{#if forceMount} +{#if contentState.useItemAligned} + + {#snippet content({ props: layerProps })} + {@const contentProps = mergeProps(restProps, layerProps, contentState.props, { style })} + +
+ {#if child} + {@render child({ + props: contentProps, + wrapperProps: {}, + ...contentState.snippetProps, + })} + {:else} +
+ {@render children?.()} +
+ {/if} +
+ {/snippet} +
+{:else if forceMount} + import type { Snippet } from "svelte"; + import type { WritableBox } from "svelte-toolbelt"; + import { mergeProps } from "svelte-toolbelt"; + import EscapeLayer from "$lib/bits/utilities/escape-layer/escape-layer.svelte"; + import DismissibleLayer from "$lib/bits/utilities/dismissible-layer/dismissible-layer.svelte"; + import FocusScope from "$lib/bits/utilities/focus-scope/focus-scope.svelte"; + import TextSelectionLayer from "$lib/bits/utilities/text-selection-layer/text-selection-layer.svelte"; + import ScrollLock from "$lib/bits/utilities/scroll-lock/scroll-lock.svelte"; + import type { EscapeBehaviorType } from "$lib/bits/utilities/escape-layer/types.js"; + import type { InteractOutsideBehaviorType } from "$lib/bits/utilities/dismissible-layer/types.js"; + import { noop } from "$lib/internal/noop.js"; + + let { + id, + ref, + enabled, + shouldRender, + preventScroll = false, + onEscapeKeydown = noop, + escapeKeydownBehavior = "close", + onInteractOutside = noop, + onFocusOutside = noop, + interactOutsideBehavior = "close", + isValidEvent = () => false, + preventOverflowTextSelection = true, + onOpenAutoFocus = noop, + onCloseAutoFocus = noop, + trapFocus = false, + loop = false, + content, + }: { + id: string; + ref: WritableBox; + enabled: boolean; + shouldRender: boolean; + preventScroll?: boolean; + onEscapeKeydown?: (e: KeyboardEvent) => void; + escapeKeydownBehavior?: EscapeBehaviorType; + onInteractOutside?: (e: PointerEvent) => void; + onFocusOutside?: (e: FocusEvent) => void; + interactOutsideBehavior?: InteractOutsideBehaviorType; + isValidEvent?: (e: PointerEvent, target: HTMLElement) => boolean; + preventOverflowTextSelection?: boolean; + onOpenAutoFocus?: (e: Event) => void; + onCloseAutoFocus?: (e: Event) => void; + trapFocus?: boolean; + loop?: boolean; + content: Snippet<[{ props: Record }]>; + } = $props(); + + +{#if shouldRender} + + + {#snippet focusScope({ props: focusScopeProps })} + + + {#snippet children({ props: dismissibleProps })} + + {@render content({ + props: mergeProps(focusScopeProps, dismissibleProps), + })} + + {/snippet} + + + {/snippet} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/select/select.svelte.ts b/packages/bits-ui/src/lib/bits/select/select.svelte.ts index 508f904b4..15aeb2d89 100644 --- a/packages/bits-ui/src/lib/bits/select/select.svelte.ts +++ b/packages/bits-ui/src/lib/bits/select/select.svelte.ts @@ -5,6 +5,7 @@ import { onDestroyEffect, attachRef, DOMContext, + executeCallbacks, type ReadableBoxedValues, type WritableBoxedValues, type Box, @@ -33,6 +34,7 @@ import type { } from "$lib/internal/types.js"; import { noop } from "$lib/internal/noop.js"; import { isIOS } from "$lib/internal/is.js"; +import { isOrContainsTarget } from "$lib/internal/elements.js"; import { createBitsAttrs } from "$lib/internal/attrs.js"; import { getFloatingContentCSSVars } from "$lib/internal/floating-svelte/floating-utils.svelte.js"; import { DataTypeahead } from "$lib/internal/data-typeahead.svelte.js"; @@ -101,6 +103,7 @@ abstract class SelectBaseRootState { contentPresence: PresenceManager; viewportNode = $state(null); triggerNode = $state(null); + triggerPointerDownPos = $state<{ x: number; y: number } | null>(null); valueNode = $state(null); valueId = $state(""); highlightedNode = $state(null); @@ -117,9 +120,12 @@ abstract class SelectBaseRootState { return this.highlightedNode.getAttribute("data-label"); }); contentIsPositioned = $state(false); + isItemAligned = $state(false); isUsingKeyboard = false; isCombobox = false; domContext = new DOMContext(() => null); + selectedItemNode = $state(null); + contentWrapperNode = $state(null); constructor(opts: SelectBaseRootStateOpts) { this.opts = opts; @@ -142,7 +148,10 @@ abstract class SelectBaseRootState { setHighlightedNode(node: HTMLElement | null, initial = false) { this.highlightedNode = node; - if (node && (this.isUsingKeyboard || initial)) { + // In item-aligned mode, the content positioner scrolls the viewport so the + // selected item lines up with the trigger. Calling scrollIntoView here would + // fight that positioning and break the alignment. + if (node && (this.isUsingKeyboard || initial) && !this.isItemAligned) { this.scrollHighlightedNodeIntoView(node); } } @@ -160,6 +169,12 @@ abstract class SelectBaseRootState { ); } + getAllItemNodes(): HTMLElement[] { + const node = this.contentNode; + if (!node) return []; + return Array.from(node.querySelectorAll(`[${this.getBitsAttr("item")}]`)); + } + setHighlightedToFirstCandidate(initial = false) { this.setHighlightedNode(null); @@ -774,7 +789,11 @@ export class SelectTriggerState { this.#domTypeahead.resetTypeahead(); } - #handlePointerOpen(_: PointerEvent) { + #handlePointerOpen(e: PointerEvent) { + this.root.triggerPointerDownPos = { + x: Math.round(e.pageX), + y: Math.round(e.pageY), + }; this.#handleOpen(); } @@ -985,6 +1004,7 @@ interface SelectContentStateOpts ReadableBoxedValues<{ onInteractOutside: (e: PointerEvent) => void; onEscapeKeydown: (e: KeyboardEvent) => void; + position: "popper" | "item-aligned"; }> {} export class SelectContentState { @@ -996,6 +1016,15 @@ export class SelectContentState { readonly attachment: RefAttachment; isPositioned = $state(false); domContext: DOMContext; + readonly useItemAligned = $derived.by(() => this.opts.position.current === "item-aligned"); + // Matches Radix shouldRepositionRef — resets to true on each open, flipped false after + // the first scroll-button-triggered reposition so we only reposition once. + shouldReposition = true; + // True when #position() used the if-branch (content grows upward from below trigger). + expandsUpward = false; + pageScrollTopOffset: number | null = null; + pageScrollRaf = 0; + itemAlignedAnchorNode: HTMLElement | null = null; constructor(opts: SelectContentStateOpts, root: SelectRoot) { this.opts = opts; @@ -1007,29 +1036,329 @@ export class SelectContentState { this.root.domContext = this.domContext; } + $effect(() => { + this.root.isItemAligned = this.useItemAligned; + }); + onDestroyEffect(() => { + this.#cancelPageScrollReposition(); this.root.contentNode = null; + this.root.contentWrapperNode = null; this.root.contentIsPositioned = false; this.isPositioned = false; + this.pageScrollTopOffset = null; + this.itemAlignedAnchorNode = null; }); watch( () => this.root.opts.open.current, () => { - if (this.root.opts.open.current) return; + if (this.root.opts.open.current) { + this.shouldReposition = true; + this.pageScrollTopOffset = null; + this.itemAlignedAnchorNode = null; + return; + } + this.#cancelPageScrollReposition(); this.root.contentIsPositioned = false; this.isPositioned = false; + this.pageScrollTopOffset = null; + this.itemAlignedAnchorNode = null; } ); watch([() => this.isPositioned, () => this.root.highlightedNode], () => { if (!this.isPositioned || !this.root.highlightedNode) return; + if (this.useItemAligned) return; this.root.scrollHighlightedNodeIntoView(this.root.highlightedNode); }); + // Re-run item-aligned positioning whenever layout-affecting state changes. + watch( + [ + () => this.useItemAligned, + () => this.root.opts.open.current, + () => this.root.contentWrapperNode, + () => this.root.contentNode, + () => this.root.viewportNode, + () => this.root.triggerNode, + () => this.root.selectedItemNode, + ], + () => { + if (!this.useItemAligned || !this.root.opts.open.current) return; + this.#position(); + } + ); + + $effect(() => { + if (!this.useItemAligned || !this.root.opts.open.current) return; + this.root.contentWrapperNode; + this.root.contentNode; + this.root.viewportNode; + this.root.triggerNode; + this.root.selectedItemNode; + afterTick(() => this.#position()); + }); + + // Radix closes the select on resize in item-aligned mode. + // Radix also locks page scroll while open. Bits leaves page scroll available by + // default, so page scroll only moves the already-positioned fixed wrapper with + // the trigger. The size from the Radix alignment pass is preserved to avoid a + // visibly unstable resize while the page is moving. + $effect(() => { + if (!this.useItemAligned) return; + const content = this.root.contentNode; + if (!content) return; + const win = content.ownerDocument.defaultView; + if (!win) return; + const reposition = (e: Event) => { + if (e.target === this.root.viewportNode) return; + if (!this.isPositioned) return; + this.#queuePageScrollReposition(); + }; + return executeCallbacks( + on(win, "resize", () => this.root.handleClose()), + on(win, "scroll", reposition, { capture: true, passive: true }) + ); + }); + + // Mirrors Radix's pointer-movement threshold guard. When the menu opens via a + // mouse pointerdown on the trigger, attach document-level listeners that track + // how far the pointer has moved. On the first pointerup, if the delta is ≤10px + // in both axes, preventDefault() blocks item selection so that a quick click-to- + // open doesn't immediately re-select the highlighted item. + $effect(() => { + const content = this.root.contentNode; + const triggerPointerDownPos = this.root.triggerPointerDownPos; + if (!content || !triggerPointerDownPos) return; + + const doc = this.domContext.getDocument(); + let pointerMoveDelta = { x: 0, y: 0 }; + + const handlePointerMove = (e: PointerEvent) => { + pointerMoveDelta = { + x: Math.abs(Math.round(e.pageX) - triggerPointerDownPos.x), + y: Math.abs(Math.round(e.pageY) - triggerPointerDownPos.y), + }; + }; + + const handlePointerUp = (e: PointerEvent) => { + if (pointerMoveDelta.x <= 10 && pointerMoveDelta.y <= 10) { + e.preventDefault(); + } else if (!e.composedPath().includes(content)) { + this.root.handleClose(); + } + this.root.triggerPointerDownPos = null; + }; + + doc.addEventListener("pointermove", handlePointerMove); + doc.addEventListener("pointerup", handlePointerUp, { capture: true, once: true }); + + return () => { + doc.removeEventListener("pointermove", handlePointerMove); + doc.removeEventListener("pointerup", handlePointerUp, { capture: true }); + }; + }); + this.onpointermove = this.onpointermove.bind(this); } + setContentWrapper(node: HTMLElement | null) { + this.root.contentWrapperNode = node; + if (node) { + afterTick(() => this.#position()); + } + } + + /** + * Called when a scroll button mounts. Mirrors Radix's `handleScrollButtonChange`: + * repositions once if `shouldReposition` is still true (scroll up button appearance + * shifts the viewport down, invalidating the initial alignment). + */ + handleScrollButtonChange() { + if (this.shouldReposition) { + this.#position(); + this.shouldReposition = false; + } + } + + /** + * Positions the content wrapper so the selected item's center aligns with the + * trigger's center, exactly matching the Radix UI SelectItemAlignedPosition algorithm. + */ + #position() { + const contentWrapper = this.root.contentWrapperNode; + const content = this.root.contentNode; + const viewport = this.root.viewportNode; + const trigger = this.root.triggerNode; + const selectedItem = this.#getItemAlignedAnchorNode(); + + if (!contentWrapper || !content || !viewport || !trigger || !selectedItem) { + return; + } + + const win = content.ownerDocument.defaultView; + if (!win) return; + + const triggerRect = trigger.getBoundingClientRect(); + + // ----------------------------------------------------------------------------------------- + // Horizontal positioning — match trigger width and left edge exactly. + // ----------------------------------------------------------------------------------------- + contentWrapper.style.width = triggerRect.width + "px"; + contentWrapper.style.left = triggerRect.left + "px"; + + // ----------------------------------------------------------------------------------------- + // Vertical positioning + // ----------------------------------------------------------------------------------------- + const allItems = this.root.getAllItemNodes(); + const availableHeight = win.innerHeight - CONTENT_MARGIN * 2; + const itemsHeight = viewport.scrollHeight; + + const contentStyles = win.getComputedStyle(content); + const contentBorderTopWidth = parseInt(contentStyles.borderTopWidth, 10); + const contentPaddingTop = parseInt(contentStyles.paddingTop, 10); + const contentBorderBottomWidth = parseInt(contentStyles.borderBottomWidth, 10); + const contentPaddingBottom = parseInt(contentStyles.paddingBottom, 10); + // prettier-ignore + const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth; + const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight); + + const viewportStyles = win.getComputedStyle(viewport); + const viewportPaddingTop = parseInt(viewportStyles.paddingTop, 10); + const viewportPaddingBottom = parseInt(viewportStyles.paddingBottom, 10); + + const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN; + const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle; + + const selectedItemHalfHeight = selectedItem.offsetHeight / 2; + const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight; + const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle; + const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle; + // Use getBoundingClientRect for a pixel-accurate measurement that includes the content + // element's border regardless of its CSS position value (relative vs. static changes + // which element is the offsetParent and whether viewport.offsetTop includes the border). + const contentWrapperRect = contentWrapper.getBoundingClientRect(); + const selectedItemRect = selectedItem.getBoundingClientRect(); + const viewportTopToItemMiddle = + selectedItemRect.top - + contentWrapperRect.top + + viewport.scrollTop + + selectedItemHalfHeight; + + const willAlignWithoutTopOverflow = viewportTopToItemMiddle <= topEdgeToTriggerMiddle; + + if (willAlignWithoutTopOverflow) { + this.expandsUpward = true; + const isLastItem = + allItems.length > 0 && selectedItem === allItems[allItems.length - 1]; + const viewportOffsetBottom = + content.clientHeight - viewport.offsetTop - viewport.offsetHeight; + const clampedTriggerMiddleToBottomEdge = Math.max( + triggerMiddleToBottomEdge, + selectedItemHalfHeight + + (isLastItem ? viewportPaddingBottom : 0) + + viewportOffsetBottom + + contentBorderBottomWidth + ); + const height = viewportTopToItemMiddle + clampedTriggerMiddleToBottomEdge; + contentWrapper.style.height = height + "px"; + // Compute top explicitly so content can scroll off-screen with the trigger. + // Equivalent to bottom:0px + CONTENT_MARGIN gap but expressed as top. + contentWrapper.style.top = win.innerHeight - CONTENT_MARGIN - height + "px"; + contentWrapper.style.bottom = ""; + } else { + this.expandsUpward = false; + const isFirstItem = allItems.length > 0 && selectedItem === allItems[0]; + const clampedTopEdgeToTriggerMiddle = Math.max( + topEdgeToTriggerMiddle, + contentBorderTopWidth + + viewport.offsetTop + + (isFirstItem ? viewportPaddingTop : 0) + + selectedItemHalfHeight + ); + const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom; + contentWrapper.style.height = height + "px"; + contentWrapper.style.bottom = ""; + // Scroll the viewport so the selected item aligns with the trigger center. + // For items near the end of the list, the viewport may not be able to scroll as far as + // needed (scrollTop gets clamped to scrollHeight - clientHeight). In that case, shift + // the wrapper upward by the uncovered difference to keep the item on the trigger center. + const desiredScrollTop = viewportTopToItemMiddle - topEdgeToTriggerMiddle; + viewport.scrollTop = desiredScrollTop; + const clampedBy = desiredScrollTop - viewport.scrollTop; + contentWrapper.style.top = CONTENT_MARGIN - clampedBy + "px"; + } + + contentWrapper.style.margin = ""; + contentWrapper.style.minHeight = minContentHeight + "px"; + contentWrapper.style.maxHeight = availableHeight + "px"; + // Copy z-index from content so stacking context is preserved. + contentWrapper.style.zIndex = win.getComputedStyle(content).zIndex; + this.#capturePageScrollOffset(); + + if (!this.isPositioned) { + this.isPositioned = true; + this.root.contentIsPositioned = true; + } + } + + #getItemAlignedAnchorNode() { + const selectedItem = this.root.selectedItemNode; + if (selectedItem) { + this.itemAlignedAnchorNode = selectedItem; + return selectedItem; + } + + if (this.itemAlignedAnchorNode?.isConnected) { + return this.itemAlignedAnchorNode; + } + + const anchor = this.root.highlightedNode ?? this.root.getCandidateNodes()[0] ?? null; + this.itemAlignedAnchorNode = anchor; + return anchor; + } + + /** + * Capture the wrapper's stable offset from the trigger after the full Radix alignment + * pass. While the page scrolls, we reuse this offset instead of re-running layout math, + * preserving the portal size and avoiding scroll-time jitter. + */ + #capturePageScrollOffset() { + const contentWrapper = this.root.contentWrapperNode; + const trigger = this.root.triggerNode; + if (!contentWrapper || !trigger) return; + + const triggerCenterY = trigger.getBoundingClientRect().top + trigger.offsetHeight / 2; + this.pageScrollTopOffset = (parseFloat(contentWrapper.style.top) || 0) - triggerCenterY; + } + + #queuePageScrollReposition() { + if (this.pageScrollRaf) return; + this.pageScrollRaf = requestAnimationFrame(() => { + this.pageScrollRaf = 0; + this.#repositionOnPageScroll(); + }); + } + + #repositionOnPageScroll() { + const contentWrapper = this.root.contentWrapperNode; + const trigger = this.root.triggerNode; + if (!contentWrapper || !trigger || this.pageScrollTopOffset === null) return; + + const triggerRect = trigger.getBoundingClientRect(); + const triggerCenterY = triggerRect.top + triggerRect.height / 2; + contentWrapper.style.top = triggerCenterY + this.pageScrollTopOffset + "px"; + contentWrapper.style.left = triggerRect.left + "px"; + contentWrapper.style.bottom = ""; + } + + #cancelPageScrollReposition() { + if (!this.pageScrollRaf) return; + cancelAnimationFrame(this.pageScrollRaf); + this.pageScrollRaf = 0; + } + onpointermove(_: BitsPointerEvent) { this.root.isUsingKeyboard = false; } @@ -1039,7 +1368,12 @@ export class SelectContentState { }); onInteractOutside = (e: PointerEvent) => { - if (e.target === this.root.triggerNode || e.target === this.root.inputNode) { + const target = e.target as Element | null; + if ( + (this.root.triggerNode && + isOrContainsTarget(this.root.triggerNode, target as HTMLElement)) || + (this.root.inputNode && isOrContainsTarget(this.root.inputNode, target as HTMLElement)) + ) { e.preventDefault(); return; } @@ -1075,16 +1409,28 @@ export class SelectContentState { role: "listbox", "aria-multiselectable": this.root.isMulti ? "true" : undefined, "data-state": getDataOpenClosed(this.root.opts.open.current), + "data-side": this.useItemAligned ? "none" : undefined, ...getDataTransitionAttrs(this.root.contentPresence.transitionStatus), [this.root.getBitsAttr("content")]: "", - style: { - display: "flex", - flexDirection: "column", - outline: "none", - boxSizing: "border-box", - pointerEvents: "auto", - ...this.#styles, - }, + style: this.useItemAligned + ? { + // In item-aligned mode the wrapper div carries all positioning; + // the content div just needs to fill it with correct box model. + boxSizing: "border-box", + maxHeight: "100%", + display: "flex", + flexDirection: "column", + outline: "none", + pointerEvents: "auto", + } + : { + display: "flex", + flexDirection: "column", + outline: "none", + boxSizing: "border-box", + pointerEvents: "auto", + ...this.#styles, + }, onpointermove: this.onpointermove, ...this.attachment, }) as const @@ -1153,6 +1499,22 @@ export class SelectItemState { } ); + // Publish this item's node to the root while it is the selected item so the + // item-aligned positioner can measure it. + watch([() => this.isSelected, () => this.mounted], () => { + if (this.isSelected && this.mounted) { + this.root.selectedItemNode = this.opts.ref.current; + } else if (this.root.selectedItemNode === this.opts.ref.current) { + this.root.selectedItemNode = null; + } + }); + + onDestroyEffect(() => { + if (this.root.selectedItemNode === this.opts.ref.current) { + this.root.selectedItemNode = null; + } + }); + this.onpointerdown = this.onpointerdown.bind(this); this.onpointerup = this.onpointerup.bind(this); this.onpointermove = this.onpointermove.bind(this); @@ -1368,7 +1730,6 @@ export class SelectViewportState { readonly content: SelectContentState; readonly root: SelectBaseRootState; readonly attachment: RefAttachment; - prevScrollTop = $state(0); constructor(opts: SelectViewportStateOpts, content: SelectContentState) { this.opts = opts; @@ -1541,10 +1902,16 @@ export class SelectScrollDownButtonState { clearTimeout(this.scrollIntoViewTimer); } this.scrollIntoViewTimer = afterSleep(5, () => { + if (this.content.useItemAligned) return; const activeItem = this.root.highlightedNode; if (!activeItem) return; this.root.scrollHighlightedNodeIntoView(activeItem); }); + // Reposition once when scroll button appears in item-aligned mode — the button + // shifts the viewport down, invalidating the initial alignment. + if (this.content.useItemAligned) { + this.content.handleScrollButtonChange(); + } } ); } @@ -1556,11 +1923,24 @@ export class SelectScrollDownButtonState { if (!manual) { this.scrollButtonState.handleUserScroll(); } - if (!this.root.viewportNode) return; - const maxScroll = this.root.viewportNode.scrollHeight - this.root.viewportNode.clientHeight; - const paddingTop = Number.parseInt(getComputedStyle(this.root.viewportNode).paddingTop, 10); - - this.canScrollDown = Math.ceil(this.root.viewportNode.scrollTop) < maxScroll - paddingTop; + const viewport = this.root.viewportNode; + if (!viewport) return; + const maxScroll = viewport.scrollHeight - viewport.clientHeight; + const paddingTop = Number.parseInt(getComputedStyle(viewport).paddingTop, 10); + + // In item-aligned mode the algorithm may scroll just enough to align the + // selected item's center with the trigger center, leaving a tiny strip of + // scrollable space below the last item. That strip would mount the scroll- + // down arrow even though there is no NEXT item to scroll to. Treat the + // arrow as unnecessary when the last item's top is already within the + // visible viewport. + const items = this.root.getAllItemNodes(); + const lastItem = items.length ? items[items.length - 1] : null; + const lastItemTopVisible = + !!lastItem && lastItem.offsetTop < viewport.scrollTop + viewport.clientHeight; + + this.canScrollDown = + !lastItemTopVisible && Math.ceil(viewport.scrollTop) < maxScroll - paddingTop; }; handleAutoScroll = () => { @@ -1602,6 +1982,16 @@ export class SelectScrollUpButtonState { this.handleScroll(true); return on(this.root.viewportNode, "scroll", () => this.handleScroll()); }); + + watch( + () => this.scrollButtonState.mounted, + () => { + // Reposition once when scroll up button appears in item-aligned mode. + if (this.scrollButtonState.mounted && this.content.useItemAligned) { + this.content.handleScrollButtonChange(); + } + } + ); } /** diff --git a/packages/bits-ui/src/lib/bits/select/types.ts b/packages/bits-ui/src/lib/bits/select/types.ts index 570560873..f272ae3ee 100644 --- a/packages/bits-ui/src/lib/bits/select/types.ts +++ b/packages/bits-ui/src/lib/bits/select/types.ts @@ -191,6 +191,20 @@ export type _SharedSelectContentProps = { * @defaultValue `false` */ loop?: boolean; + + /** + * The positioning strategy for the content. + * + * - `"popper"` (default): positions the content relative to the trigger using [Floating UI](https://floating-ui.com/), + * supporting `side`, `align`, `sideOffset`, and collision-related props. + * - `"item-aligned"`: aligns the currently selected item's center with the trigger's center, + * like a native `