From 762ac483b64abe26b86d2582fbc85089e9bf0974 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Sun, 3 May 2026 19:10:47 -0500 Subject: [PATCH 01/21] docs: add position=item-aligned documentation and demo scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds documentation, API reference, and demo scaffolding for the upcoming `position="item-aligned"` feature. Implementation removed — will be re-ported from Radix UI. Co-Authored-By: Claude Sonnet 4.6 --- docs/content/components/select.md | 28 ++++++- docs/src/lib/components/demos/index.ts | 1 + .../demos/select-demo-item-aligned.svelte | 77 +++++++++++++++++++ .../extended-types/select/index.ts | 1 + .../extended-types/select/position-prop.md | 3 + .../lib/content/api-reference/select.api.ts | 15 ++++ docs/src/routes/select-test/+page.svelte | 23 ++++++ 7 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 docs/src/lib/components/demos/select-demo-item-aligned.svelte create mode 100644 docs/src/lib/content/api-reference/extended-types/select/position-prop.md create mode 100644 docs/src/routes/select-test/+page.svelte 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

+ +
+
From 3c060c616c559f93a6f1d807c949975c67b9b314 Mon Sep 17 00:00:00 2001 From: Max Farrell Date: Sun, 3 May 2026 19:26:01 -0500 Subject: [PATCH 02/21] feat(Select): port position=item-aligned from Radix UI Ports the exact SelectItemAlignedPosition algorithm from Radix UI's select.tsx. Uses a two-div structure (fixed wrapper + content) with direct DOM style writes, matching Radix's approach: - Aligns the selected item's center vertically with the trigger's center - Aligns the item's left edge with the trigger value node's left edge - Expands the content wrapper as the user scrolls (shouldExpandOnScroll) - Repositions once when scroll buttons appear (handleScrollButtonChange) - Closes the select on window resize (Radix behaviour) Also adds `getAllItemNodes()` to SelectBaseRootState to correctly detect first/last items (including disabled) for edge padding calculations. Combobox content types omit `position` since item-aligned doesn't apply when filtering by typing. Co-Authored-By: Claude Sonnet 4.6 --- .../bits-ui/src/lib/bits/combobox/types.ts | 18 +- .../components/select-content-static.svelte | 35 ++- .../select/components/select-content.svelte | 40 ++- .../select-item-aligned-content.svelte | 91 ++++++ .../src/lib/bits/select/select.svelte.ts | 290 +++++++++++++++++- packages/bits-ui/src/lib/bits/select/types.ts | 14 + 6 files changed, 473 insertions(+), 15 deletions(-) create mode 100644 packages/bits-ui/src/lib/bits/select/components/select-item-aligned-content.svelte 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..e62394080 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,43 @@ ), onInteractOutside: boxWith(() => onInteractOutside), onEscapeKeydown: boxWith(() => onEscapeKeydown), + position: boxWith(() => position), }); const mergedProps = $derived(mergeProps(restProps, contentState.props)); -{#if forceMount} +{#if contentState.useItemAligned} + + {#snippet content({ props: layerProps })} + {@const contentProps = mergeProps(restProps, layerProps, contentState.props, { style })} +
contentState.setContentWrapper(node)} + style={{ position: "fixed", display: "flex", flexDirection: "column" }} + > + {#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)); -{#if forceMount} +{#if contentState.useItemAligned} + + {#snippet content({ props: layerProps })} + {@const contentProps = mergeProps(restProps, layerProps, contentState.props, { style })} + +
contentState.setContentWrapper(node)} + style={{ position: "fixed", display: "flex", flexDirection: "column" }} + > + {#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..7cf6094c4 100644 --- a/packages/bits-ui/src/lib/bits/select/select.svelte.ts +++ b/packages/bits-ui/src/lib/bits/select/select.svelte.ts @@ -120,6 +120,8 @@ abstract class SelectBaseRootState { isUsingKeyboard = false; isCombobox = false; domContext = new DOMContext(() => null); + selectedItemNode = $state(null); + contentWrapperNode = $state(null); constructor(opts: SelectBaseRootStateOpts) { this.opts = opts; @@ -160,6 +162,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); @@ -985,6 +993,7 @@ interface SelectContentStateOpts ReadableBoxedValues<{ onInteractOutside: (e: PointerEvent) => void; onEscapeKeydown: (e: KeyboardEvent) => void; + position: "popper" | "item-aligned"; }> {} export class SelectContentState { @@ -996,6 +1005,13 @@ 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; + // Matches Radix shouldExpandOnScrollRef — activated after initial positioning so that + // user-initiated scrolling can expand the content wrapper height. + shouldExpandOnScroll = false; constructor(opts: SelectContentStateOpts, root: SelectRoot) { this.opts = opts; @@ -1009,6 +1025,7 @@ export class SelectContentState { onDestroyEffect(() => { this.root.contentNode = null; + this.root.contentWrapperNode = null; this.root.contentIsPositioned = false; this.isPositioned = false; }); @@ -1016,7 +1033,11 @@ export class SelectContentState { watch( () => this.root.opts.open.current, () => { - if (this.root.opts.open.current) return; + if (this.root.opts.open.current) { + this.shouldReposition = true; + this.shouldExpandOnScroll = false; + return; + } this.root.contentIsPositioned = false; this.isPositioned = false; } @@ -1027,9 +1048,207 @@ export class SelectContentState { 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(); + } + ); + + // Radix closes the select on resize in item-aligned mode. + $effect(() => { + if (!this.useItemAligned) return; + const win = this.domContext.getWindow(); + if (!win) return; + const close = () => this.root.handleClose(); + return on(win, "resize", close); + }); + this.onpointermove = this.onpointermove.bind(this); } + setContentWrapper(node: HTMLElement | null) { + this.root.contentWrapperNode = node; + } + + /** + * 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; + } + } + + /** + * Called by SelectViewportState's scroll handler. Expands the content wrapper height + * as the user scrolls, up to the available viewport height. Mirrors Radix's + * `shouldExpandOnScrollRef` logic in the viewport's onScroll handler. + */ + handleExpandOnScroll(viewport: HTMLElement, prevScrollTop: number): number { + const contentWrapper = this.root.contentWrapperNode; + if (!this.shouldExpandOnScroll || !contentWrapper) return viewport.scrollTop; + + const scrolledBy = Math.abs(prevScrollTop - viewport.scrollTop); + if (scrolledBy > 0) { + const win = this.domContext.getWindow(); + if (!win) return viewport.scrollTop; + const availableHeight = win.innerHeight - CONTENT_MARGIN * 2; + const cssMinHeight = parseFloat(contentWrapper.style.minHeight); + const cssHeight = parseFloat(contentWrapper.style.height); + const prevHeight = Math.max(cssMinHeight, cssHeight); + if (prevHeight < availableHeight) { + const nextHeight = prevHeight + scrolledBy; + const clampedNextHeight = Math.min(availableHeight, nextHeight); + const heightDiff = nextHeight - clampedNextHeight; + contentWrapper.style.height = clampedNextHeight + "px"; + if (contentWrapper.style.bottom === "0px") { + viewport.scrollTop = heightDiff > 0 ? heightDiff : 0; + contentWrapper.style.justifyContent = "flex-end"; + } + } + } + return viewport.scrollTop; + } + + /** + * 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 valueNode = this.root.valueNode; + const selectedItem = this.root.selectedItemNode; + + if (!contentWrapper || !content || !viewport || !trigger || !valueNode || !selectedItem) { + return; + } + + const win = this.domContext.getWindow(); + if (!win) return; + + // In Radix, `selectedItemText` is a separate sub-element. Since bits-ui + // has no separate ItemText component, we use the item element itself as the proxy. + const selectedItemText = selectedItem; + + const triggerRect = trigger.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + const valueNodeRect = valueNode.getBoundingClientRect(); + const itemTextRect = selectedItemText.getBoundingClientRect(); + + // ----------------------------------------------------------------------------------------- + // Horizontal positioning — align item-text left edge with value-node left edge. + // ----------------------------------------------------------------------------------------- + const itemTextOffset = itemTextRect.left - contentRect.left; + const left = valueNodeRect.left - itemTextOffset; + const leftDelta = triggerRect.left - left; + const minContentWidth = triggerRect.width + leftDelta; + const contentWidth = Math.max(minContentWidth, contentRect.width); + const rightEdge = win.innerWidth - CONTENT_MARGIN; + const clampedLeft = Math.max( + CONTENT_MARGIN, + Math.min(left, Math.max(CONTENT_MARGIN, rightEdge - contentWidth)) + ); + + contentWrapper.style.minWidth = minContentWidth + "px"; + contentWrapper.style.left = clampedLeft + "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; + + const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle; + + if (willAlignWithoutTopOverflow) { + const isLastItem = + allItems.length > 0 && selectedItem === allItems[allItems.length - 1]; + contentWrapper.style.bottom = "0px"; + contentWrapper.style.top = ""; + const viewportOffsetBottom = + content.clientHeight - viewport.offsetTop - viewport.offsetHeight; + const clampedTriggerMiddleToBottomEdge = Math.max( + triggerMiddleToBottomEdge, + selectedItemHalfHeight + + (isLastItem ? viewportPaddingBottom : 0) + + viewportOffsetBottom + + contentBorderBottomWidth + ); + const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge; + contentWrapper.style.height = height + "px"; + } else { + const isFirstItem = allItems.length > 0 && selectedItem === allItems[0]; + contentWrapper.style.top = "0px"; + contentWrapper.style.bottom = ""; + const clampedTopEdgeToTriggerMiddle = Math.max( + topEdgeToTriggerMiddle, + contentBorderTopWidth + + viewport.offsetTop + + (isFirstItem ? viewportPaddingTop : 0) + + selectedItemHalfHeight + ); + const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom; + contentWrapper.style.height = height + "px"; + viewport.scrollTop = + contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop; + } + + contentWrapper.style.margin = `${CONTENT_MARGIN}px 0`; + 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; + + if (!this.isPositioned) { + this.isPositioned = true; + this.root.contentIsPositioned = true; + } + + // Enable expand-on-scroll after the initial positioning settles. + requestAnimationFrame(() => { + this.shouldExpandOnScroll = true; + }); + } + onpointermove(_: BitsPointerEvent) { this.root.isUsingKeyboard = false; } @@ -1075,16 +1294,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 +1384,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); @@ -1377,6 +1624,16 @@ export class SelectViewportState { this.attachment = attachRef(opts.ref, (v) => { this.root.viewportNode = v; }); + + // Expand the content wrapper as the user scrolls in item-aligned mode. + watch([() => this.root.viewportNode, () => this.content.useItemAligned], () => { + const viewport = this.root.viewportNode; + if (!viewport || !this.content.useItemAligned) return; + let prevScrollTop = viewport.scrollTop; + return on(viewport, "scroll", () => { + prevScrollTop = this.content.handleExpandOnScroll(viewport, prevScrollTop); + }); + }); } readonly props = $derived.by( @@ -1545,6 +1802,11 @@ export class SelectScrollDownButtonState { 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(); + } } ); } @@ -1602,6 +1864,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 `