Files
biggus-dickus/web/src/components/ui/select.tsx
T
logaritmisk 9a896bb5f6 fix(web): honor prefers-reduced-motion; contain overscroll in modal surfaces (#71)
Nothing in the app respected prefers-reduced-motion — the kit's
data-open/closed animations, the skeleton pulse, and the sidebar width
transition all ran unconditionally. Add a global base-layer rule that
collapses animation/transition durations to a single frame when the OS
asks for reduced motion; one rule covers current and future additions.

Add overscroll-y-contain to the scrollable modal/popup surfaces
(DrawerContent, SelectContent, ComboboxPopup) so flicking past the end
of their content no longer chain-scrolls the page beneath, and to
AlertDialogContent for when it gains scrollable content.

Verified in the built CSS: the media query and
overscroll-behavior-y:contain both compile into dist/assets.

Closes #71

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:38:37 +02:00

133 lines
4.2 KiB
TypeScript

import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
// Base UI's <Select.Value> resolves the selected item's *label* from the
// Root `items` prop only; without it the trigger shows the raw value. We
// derive that map from the rendered <SelectItem> children so consumers keep
// the simple `<SelectItem value="…">Label</SelectItem>` API and still get
// labels (not raw values) in the trigger.
function collectItems(
node: React.ReactNode,
out: Array<{ value: unknown; label: React.ReactNode }>
) {
React.Children.forEach(node, (child) => {
if (!React.isValidElement(child)) {
return
}
if (child.type === SelectItem) {
const props = child.props as SelectPrimitive.Item.Props
out.push({ value: props.value, label: props.children })
return
}
const props = child.props as { children?: React.ReactNode }
if (props.children != null) {
collectItems(props.children, out)
}
})
}
function Select<Value>({ children, ...props }: SelectPrimitive.Root.Props<Value>) {
const items = React.useMemo(() => {
const collected: Array<{ value: unknown; label: React.ReactNode }> = []
collectItems(children, collected)
return collected
}, [children])
return (
<SelectPrimitive.Root data-slot="select" items={items} {...props}>
{children}
</SelectPrimitive.Root>
)
}
function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20",
"dark:bg-input/30 data-[popup-open]:border-ring",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon className="text-muted-foreground">
<ChevronDown className="h-4 w-4" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("truncate", className)}
{...props}
/>
)
}
function SelectContent({
className,
sideOffset = 6,
...props
}: SelectPrimitive.Popup.Props & {
sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
sideOffset={sideOffset}
className="z-50"
alignItemWithTrigger={false}
>
<SelectPrimitive.Popup
data-slot="select-content"
className={cn(
"max-h-[min(24rem,var(--available-height))] min-w-[var(--anchor-width)] overflow-y-auto overscroll-y-contain rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
className
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" aria-hidden />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }