diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index a765850d494e..530a9e9d98ee 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -90,6 +90,13 @@ } } +/* When we portal select content into a scroll container, ensure it's a positioning + * context for Floating UI's `strategy: "absolute"`. */ +[data-select-mount="true"] { + position: relative; + z-index: 0; +} + [data-component="select-content"] { min-width: 104px; max-width: 23rem; @@ -98,7 +105,7 @@ background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 60; + z-index: 80; &[data-expanded] { animation: select-open 0.15s ease-out; @@ -174,6 +181,7 @@ min-width: 160px; border-radius: 8px; padding: 0; + z-index: 9; [data-slot="select-select-content-list"] { padding: 4px; diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index b370dbb64568..284d4183b118 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,5 +1,5 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" @@ -35,11 +35,35 @@ export function Select(props: SelectProps & Omit) "onSelect", "onHighlight", "onOpenChange", + "flip", + "slide", + "overlap", + "fitViewport", + "overflowPadding", "children", "triggerStyle", "triggerVariant", ]) + const [mount, setMount] = createSignal() + + const settings = () => local.triggerVariant === "settings" + + const scroller = (el: HTMLElement | undefined) => { + if (!el) return undefined + if (typeof window === "undefined") return undefined + + let fallback: HTMLElement | undefined + for (let node: HTMLElement | null = el; node; node = node.parentElement) { + if (node.getAttribute("data-slot") === "dialog-body") fallback = node + const value = getComputedStyle(node).overflowY + const scroll = value === "auto" || value === "scroll" || value === "overlay" + if (scroll) return node + } + + return fallback + } + const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -86,8 +110,13 @@ export function Select(props: SelectProps & Omit) {...others} data-component="select" data-trigger-style={local.triggerVariant} - placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} + placement={settings() ? "bottom-end" : "bottom-start"} gutter={4} + flip={local.flip ?? settings()} + slide={local.slide ?? settings()} + overlap={local.overlap} + fitViewport={local.fitViewport ?? settings()} + overflowPadding={local.overflowPadding ?? (settings() ? 16 : undefined)} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -126,6 +155,14 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { + if (open && settings() && !mount() && typeof document !== "undefined") { + const el = document.activeElement + const node = scroller(el instanceof HTMLElement ? el : undefined) + if (node) { + node.dataset.selectMount = "true" + setMount(node) + } + } local.onOpenChange?.(open) if (!open) stop() }} @@ -134,6 +171,13 @@ export function Select(props: SelectProps & Omit) disabled={props.disabled} data-slot="select-select-trigger" as={Button} + onPointerDown={(e) => { + if (!settings()) return + const node = scroller(e.currentTarget as HTMLElement) + if (!node) return + node.dataset.selectMount = "true" + setMount(node) + }} size={props.size} variant={props.variant} style={local.triggerStyle} @@ -151,10 +195,10 @@ export function Select(props: SelectProps & Omit) }} - + - +