import { createSignal, createPortal, onLayout, getBoundingBox, Show, For, env } from "@solidrt/core" import type { LayoutProps } from "@solidrt/core" import { createPress } from "./press" import { theme } from "./theme" import { policy } from "./policy" import { space } from "./spacing" import { typeStyle } from "./typography" import type { Option, StyleProps } from "./types" import { Icon } from "./icon" export interface SelectProps { options: Option[] // Controlled selected value. If omitted, the select is uncontrolled. value?: unknown defaultValue?: unknown onChange?: (value: unknown) => void // Shown in the trigger while nothing is selected. placeholder?: string disabled?: boolean layout?: LayoutProps style?: StyleProps } const GAP = 4 // Minimum distance kept between the dropdown and the window edges. const MARGIN = 4 /** * A single-choice picker whose presentation forks on the interaction policy: * desktop/hybrid opens an anchored dropdown under the trigger (flipping above * when there is no room), touch opens a bottom sheet over a scrim. Same * value/onChange contract either way; pressing outside closes without a change. * The option list is not scrollable yet, so keep it short. */ export function Select(props: SelectProps) { let trigger: { id: number } | undefined let [open, setOpen] = createSignal(false) let [internal, setInternal] = createSignal(props.defaultValue) let value = () => (props.value !== undefined ? props.value : internal()) let selected = () => props.options.find((o) => o.value === value()) let choose = (v: unknown) => { setOpen(false) if (props.value === undefined) setInternal(() => v) props.onChange?.(v) } let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 }) // One option row, shared by both presentations; only the vertical padding // differs (the sheet gets taller touch targets). let OptionRow = (p: { option: Option; padY: number }) => { let press = createPress({ onPress: () => choose(p.option.value) }) return ( {p.option.label} ) } // Anchored under the trigger, sized at least as wide as it. Positioned like // Tooltip: portal at the window root, pinned at 0,0 and moved with the x/y // paint transforms after measuring, so tracking the anchor never reflows. let Dropdown = () => { let menu: { id: number } | undefined let [pos, setPos] = createSignal<{ x: number; y: number } | null>(null) let [minWidth, setMinWidth] = createSignal(0) onLayout(() => { let a = trigger && getBoundingBox(trigger) let b = menu && getBoundingBox(menu) if (!a || !b) return if (a.width !== minWidth()) setMinWidth(a.width) let x = Math.round(Math.min(Math.max(a.x, MARGIN), env.windowSize.width - b.width - MARGIN)) let below = a.y + a.height + GAP let y = Math.round(below + b.height > env.windowSize.height - MARGIN ? a.y - b.height - GAP : below) let cur = pos() if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y }) }) return createPortal( setOpen(false)} /> (menu = n)} position="absolute" top={0} left={0} x={pos()?.x ?? -10000} y={pos()?.y ?? 0} minWidth={minWidth() || undefined} flexDirection="column" paddingTop={theme.spacing.sm} paddingBottom={theme.spacing.sm} > {(o: Option) => } , ) } // Bottom sheet over a scrim; the content is a sibling of the scrim (Modal's // trick) so pressing an option never has the scrim on its hit path. let Sheet = () => createPortal( setOpen(false)}> {(o: Option) => } , ) let press = createPress({ onPress: () => { setOpen(!open()) }, }) let style = () => ({ borderColor: theme.color.border, borderWidth: theme.borderWidth.sm, borderRadius: theme.radius.sm, backgroundColor: theme.color.surface, ...theme.components.select, ...props.style, }) // Hover feedback: the theme overlay tint drawn over the trigger fill. let overlay = () => press.hovered() && !props.disabled && policy.interaction !== "touch" ? theme.color.overlayHover : "transparent" return ( { press.ref(n) trigger = n }} repaintBoundary flexDirection="row" alignItems="center" justifyContent="space-between" gap={theme.spacing.md} paddingTop={space("sm")} paddingBottom={space("sm")} paddingLeft={space("md")} paddingRight={space("md")} {...props.layout} x={style().x} y={style().y} scale={style().scale} rotate={style().rotate} opacity={style().opacity} {...press.handlers} pointerEvents={props.disabled ? "none" : undefined} > {props.placeholder ?? ""}} > {selected()!.label} } > }> 0}> ) }