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}>
)
}