diff --git a/web/src/components/ui/select.stories.tsx b/web/src/components/ui/select.stories.tsx new file mode 100644 index 0000000..c58dfe6 --- /dev/null +++ b/web/src/components/ui/select.stories.tsx @@ -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 ( + + ) +} + +const meta = { + component: Select, + tags: ['ai-generated'], + render: () => , +} satisfies Meta + +export default meta +type Story = StoryObj + +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') + }, +} diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 0000000..6d5ccfb --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -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 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 children so consumers keep +// the simple `Label` 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({ children, ...props }: SelectPrimitive.Root.Props) { + const items = React.useMemo(() => { + const collected: Array<{ value: unknown; label: React.ReactNode }> = [] + + collectItems(children, collected) + + return collected + }, [children]) + + return ( + + {children} + + ) +} + +function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) { + return ( + + {children} + + + + + ) +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectContent({ + className, + sideOffset = 6, + ...props +}: SelectPrimitive.Popup.Props & { + sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"] +}) { + return ( + + + + + + ) +} + +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { + return ( + + {children} + + + + + ) +} + +export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }