feat(web): ui/select Base UI Select wrapper matching Input + story (#51)
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { expect, within } from 'storybook/test'
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select'
|
||||||
|
|
||||||
|
function Controlled() {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={(next) => setValue(next ?? '')}>
|
||||||
|
<SelectTrigger aria-label="Fruit">
|
||||||
|
<SelectValue placeholder="Pick one" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="apple">Apple</SelectItem>
|
||||||
|
<SelectItem value="pear">Pear</SelectItem>
|
||||||
|
<SelectItem value="plum">Plum</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: Select,
|
||||||
|
tags: ['ai-generated'],
|
||||||
|
render: () => <Controlled />,
|
||||||
|
} satisfies Meta<typeof Select>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvas, userEvent }) => {
|
||||||
|
await userEvent.click(canvas.getByRole('combobox', { name: 'Fruit' }))
|
||||||
|
await userEvent.click(await within(document.body).findByRole('option', { name: 'Pear' }))
|
||||||
|
await expect(canvas.getByRole('combobox', { name: 'Fruit' })).toHaveTextContent('Pear')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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 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 }
|
||||||
Reference in New Issue
Block a user