Async Select
16 |19 | Async Select component built with React &{" "} 20 | 25 | shadcn/ui 26 | 27 |
28 |├── bun.lockb ├── public ├── og.png └── favicon.png ├── postcss.config.mjs ├── src ├── lib │ └── utils.ts ├── hooks │ ├── use-mounted.ts │ ├── use-debounce.ts │ └── use-mobile.tsx ├── app │ ├── (marketing) │ │ ├── layout.tsx │ │ └── page.tsx │ ├── not-found.tsx │ ├── error.tsx │ ├── actions.ts │ └── layout.tsx ├── components │ ├── theme │ │ ├── provider.tsx │ │ └── toggler.tsx │ ├── providers │ │ └── index.tsx │ ├── mdx │ │ ├── mdx-content-renderer.tsx │ │ ├── callout.tsx │ │ ├── codeblock.tsx │ │ ├── toc.tsx │ │ └── components.tsx │ ├── docs.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── popover.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── drawer.tsx │ │ ├── dialog.tsx │ │ └── command.tsx │ ├── copy-button.tsx │ ├── examples │ │ ├── async-select-example.tsx │ │ └── async-select-preload-example.tsx │ ├── wrapper.tsx │ └── async-select.tsx ├── types │ └── index.d.ts ├── config │ └── site.config.ts └── styles │ └── globals.css ├── next.config.ts ├── components.json ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── velite.config.ts ├── content └── snippets │ ├── async-select-example.mdx │ ├── async-select-preload-example.mdx │ ├── component-api.mdx │ ├── async-select.mdx │ └── async-select-preload.mdx ├── tailwind.config.ts └── README.md /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/asyncr/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/asyncr/HEAD/public/og.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/asyncr/HEAD/public/favicon.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/use-mounted.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | export function useMounted() { 6 | const [mounted, setMounted] = useState(false); 7 | 8 | useEffect(() => setMounted(true), []); 9 | 10 | return mounted; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function MarketingLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode; 5 | }) { 6 | return ( 7 |
18 | {children}
19 |
20 | 14 | The page you are looking for doesn't exist or has been moved. 15 |
16 |26 | An error occurred while loading this page. 27 |
28 |Table of contents
36 |19 | Async Select component built with React &{" "} 20 | 25 | shadcn/ui 26 | 27 |
28 |*]:text-muted-foreground", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | ), 97 | img: ({ 98 | className, 99 | alt, 100 | ...props 101 | }: React.ImgHTMLAttributes) => ( 102 | // eslint-disable-next-line @next/next/no-img-element 103 | 108 | ), 109 | hr: ({ ...props }) =>
, 110 | table: ({ 111 | className, 112 | ...props 113 | }: React.HTMLAttributes) => ( 114 | 115 |117 | ), 118 | tr: ({ 119 | className, 120 | ...props 121 | }: React.HTMLAttributes116 |
) => ( 122 | 126 | ), 127 | th: ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes ) => ( 131 | 138 | ), 139 | td: ({ 140 | className, 141 | ...props 142 | }: React.HTMLAttributes ) => ( 143 | 150 | ), 151 | pre: CodeBlock, 152 | code: ({ className, ...props }: React.HTMLAttributes ) => ( 153 | 160 | ), 161 | Image: (props: ImageProps) =>, 162 | Callout, 163 | }; -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { type DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef , 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | const CommandDialog = ({ children, ...props }: DialogProps) => { 27 | return ( 28 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef , 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 43 |53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef44 | 52 | , 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef , 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef , 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef , 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef , 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes ) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /content/snippets/component-api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Async Select 3 | description: Async Select component built with React & shadcn/ui 4 | code: async-select-example.mdx 5 | --- 6 | 7 | ## Installation 8 | 9 | The Async Select Component is built through the composition of ` ` and the ` ` components from [shadcn/ui](https://ui.shadcn.com/docs)**.** 10 | 11 | See installation instructions for the [Popover](https://ui.shadcn.com/docs/components/popover#installation) and the [Command](https://ui.shadcn.com/docs/components/command#installation) components. 12 | 13 | ## Basic Usage 14 | 15 | ```tsx 16 | import { AsyncSelect } from "@/components/async-select"; 17 | 18 | function MyComponent() { 19 | const [value, setValue] = useState(""); 20 | 21 | return ( 22 | 23 | fetcher={fetchData} 24 | renderOption={(item) => {item.name}} 25 | getOptionValue={(item) => item.id} 26 | getDisplayValue={(item) => item.name} 27 | label="Select" 28 | value={value} 29 | onChange={setValue} 30 | /> 31 | ); 32 | } 33 | ``` 34 | 35 | ## Props 36 | 37 | ### Required Props 38 | 39 | | Prop | Type | Description | 40 | |------|------|-------------| 41 | | `fetcher` | `(query?: string) => Promise` | Async function to fetch options | 42 | | `renderOption` | `(option: T) => React.ReactNode` | Function to render each option in the dropdown | 43 | | `getOptionValue` | `(option: T) => string` | Function to get unique value from option | 44 | | `getDisplayValue` | `(option: T) => React.ReactNode` | Function to render selected value | 45 | | `value` | `string` | Currently selected value | 46 | | `onChange` | `(value: string) => void` | Callback when selection changes | 47 | | `label` | `string` | Label for the select field | 48 | 49 | ### Optional Props 50 | 51 | | Prop | Type | Default | Description | 52 | |------|------|---------|-------------| 53 | | `preload` | `boolean` | `false` | Enable preloading all options | 54 | | `filterFn` | `(option: T, query: string) => boolean` | - | Custom filter function for preload mode | 55 | | `notFound` | `React.ReactNode` | - | Custom not found message/component | 56 | | `loadingSkeleton` | `React.ReactNode` | - | Custom loading state component | 57 | | `placeholder` | `string` | "Select..." | Placeholder text | 58 | | `disabled` | `boolean` | `false` | Disable the select | 59 | | `width` | `string \| number` | "200px" | Custom width | 60 | | `className` | `string` | - | Custom class for popover | 61 | | `triggerClassName` | `string` | - | Custom class for trigger button | 62 | | `noResultsMessage` | `string` | - | Custom no results message | 63 | | `clearable` | `boolean` | `true` | Allow clearing selection | 64 | 65 | ## Examples 66 | 67 | ### Async Mode 68 | 69 | ```tsx 70 | 71 | fetcher={searchUsers} 72 | renderOption={(user) => ( 73 | 74 |86 | )} 87 | getOptionValue={(user) => user.id} 88 | getDisplayValue={(user) => ( 89 |81 | 82 |85 |{user.name}83 |{user.role}84 |90 |102 | )} 103 | notFound={97 | 98 |101 |{user.name}99 |{user.role}100 |No users found} 104 | label="User" 105 | placeholder="Search users..." 106 | value={selectedUser} 107 | onChange={setSelectedUser} 108 | width="375px" 109 | /> 110 | ``` 111 | 112 | ### Preload Mode 113 | 114 | ```tsx 115 |116 | fetcher={searchAllUsers} 117 | preload 118 | filterFn={(user, query) => user.name.toLowerCase().includes(query.toLowerCase())} 119 | renderOption={(user) => ( 120 | 121 |133 | )} 134 | getOptionValue={(user) => user.id} 135 | getDisplayValue={(user) => user.name} 136 | label="User" 137 | value={selectedUser} 138 | onChange={setSelectedUser} 139 | /> 140 | ``` 141 | 142 | ## TypeScript Interface 143 | 144 | ```tsx 145 | interface AsyncSelectProps128 | 129 |132 |{user.name}130 |{user.role}131 |{ 146 | fetcher: (query?: string) => Promise ; 147 | preload?: boolean; 148 | filterFn?: (option: T, query: string) => boolean; 149 | renderOption: (option: T) => React.ReactNode; 150 | getOptionValue: (option: T) => string; 151 | getDisplayValue: (option: T) => React.ReactNode; 152 | notFound?: React.ReactNode; 153 | loadingSkeleton?: React.ReactNode; 154 | value: string; 155 | onChange: (value: string) => void; 156 | label: string; 157 | placeholder?: string; 158 | disabled?: boolean; 159 | width?: string | number; 160 | className?: string; 161 | triggerClassName?: string; 162 | noResultsMessage?: string; 163 | clearable?: boolean; 164 | } 165 | ``` 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Async Select Component 2 | 3 | A modern, accessible, and customizable async select component for React applications. Built with TypeScript and shadcn/ui components. 4 | 5 |  6 | 7 | ## Installation 8 | 9 | The Async Select Component is built through the composition of ` ` and the ` ` components from [shadcn/ui](https://ui.shadcn.com/docs). 10 | 11 | See installation instructions for the [Popover](https://ui.shadcn.com/docs/components/popover#installation) and the [Command](https://ui.shadcn.com/docs/components/command#installation) components. 12 | 13 | ## Basic Usage 14 | 15 | ```tsx 16 | import { AsyncSelect } from "@/components/async-select"; 17 | 18 | function MyComponent() { 19 | const [value, setValue] = useState(""); 20 | 21 | return ( 22 | 23 | fetcher={fetchData} 24 | renderOption={(item) => {item.name}} 25 | getOptionValue={(item) => item.id} 26 | getDisplayValue={(item) => item.name} 27 | label="Select" 28 | value={value} 29 | onChange={setValue} 30 | /> 31 | ); 32 | } 33 | ``` 34 | 35 | ## Props 36 | 37 | ### Required Props 38 | 39 | | Prop | Type | Description | 40 | |------|------|-------------| 41 | | `fetcher` | `(query?: string) => Promise` | Async function to fetch options | 42 | | `renderOption` | `(option: T) => React.ReactNode` | Function to render each option in the dropdown | 43 | | `getOptionValue` | `(option: T) => string` | Function to get unique value from option | 44 | | `getDisplayValue` | `(option: T) => React.ReactNode` | Function to render selected value | 45 | | `value` | `string` | Currently selected value | 46 | | `onChange` | `(value: string) => void` | Callback when selection changes | 47 | | `label` | `string` | Label for the select field | 48 | 49 | ### Optional Props 50 | 51 | | Prop | Type | Default | Description | 52 | |------|------|---------|-------------| 53 | | `preload` | `boolean` | `false` | Enable preloading all options | 54 | | `filterFn` | `(option: T, query: string) => boolean` | - | Custom filter function for preload mode | 55 | | `notFound` | `React.ReactNode` | - | Custom not found message/component | 56 | | `loadingSkeleton` | `React.ReactNode` | - | Custom loading state component | 57 | | `placeholder` | `string` | "Select..." | Placeholder text | 58 | | `disabled` | `boolean` | `false` | Disable the select | 59 | | `width` | `string \| number` | "200px" | Custom width | 60 | | `className` | `string` | - | Custom class for popover | 61 | | `triggerClassName` | `string` | - | Custom class for trigger button | 62 | | `noResultsMessage` | `string` | - | Custom no results message | 63 | | `clearable` | `boolean` | `true` | Allow clearing selection | 64 | 65 | ## Examples 66 | 67 | ### Async Mode 68 | 69 | ```tsx 70 | 71 | fetcher={searchUsers} 72 | renderOption={(user) => ( 73 | 74 |86 | )} 87 | getOptionValue={(user) => user.id} 88 | getDisplayValue={(user) => ( 89 |81 | 82 |85 |{user.name}83 |{user.role}84 |90 |102 | )} 103 | notFound={97 | 98 |101 |{user.name}99 |{user.role}100 |No users found} 104 | label="User" 105 | placeholder="Search users..." 106 | value={selectedUser} 107 | onChange={setSelectedUser} 108 | width="375px" 109 | /> 110 | ``` 111 | 112 | ### Preload Mode 113 | 114 | ```tsx 115 |116 | fetcher={searchAllUsers} 117 | preload 118 | filterFn={(user, query) => user.name.toLowerCase().includes(query.toLowerCase())} 119 | renderOption={(user) => ( 120 | 121 |133 | )} 134 | getOptionValue={(user) => user.id} 135 | getDisplayValue={(user) => user.name} 136 | label="User" 137 | value={selectedUser} 138 | onChange={setSelectedUser} 139 | /> 140 | ``` 141 | 142 | ## TypeScript Interface 143 | 144 | ```tsx 145 | interface AsyncSelectProps128 | 129 |132 |{user.name}130 |{user.role}131 |{ 146 | fetcher: (query?: string) => Promise ; 147 | preload?: boolean; 148 | filterFn?: (option: T, query: string) => boolean; 149 | renderOption: (option: T) => React.ReactNode; 150 | getOptionValue: (option: T) => string; 151 | getDisplayValue: (option: T) => React.ReactNode; 152 | notFound?: React.ReactNode; 153 | loadingSkeleton?: React.ReactNode; 154 | value: string; 155 | onChange: (value: string) => void; 156 | label: string; 157 | placeholder?: string; 158 | disabled?: boolean; 159 | width?: string | number; 160 | className?: string; 161 | triggerClassName?: string; 162 | noResultsMessage?: string; 163 | clearable?: boolean; 164 | } 165 | ``` 166 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | body { 5 | font-family: Arial, Helvetica, sans-serif; 6 | } 7 | 8 | @layer base { 9 | :root { 10 | --background: 0 0% 100%; 11 | --foreground: 240 10% 3.9%; 12 | --card: 0 0% 100%; 13 | --card-foreground: 240 10% 3.9%; 14 | --popover: 0 0% 100%; 15 | --popover-foreground: 240 10% 3.9%; 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | --secondary: 240 4.8% 95.9%; 19 | --secondary-foreground: 240 5.9% 10%; 20 | --muted: 240 4.8% 95.9%; 21 | --muted-foreground: 240 3.8% 46.1%; 22 | --accent: 240 4.8% 95.9%; 23 | --accent-foreground: 240 5.9% 10%; 24 | --destructive: 0 84.2% 60.2%; 25 | --destructive-foreground: 0 0% 98%; 26 | --border: 240 5.9% 90%; 27 | --input: 240 5.9% 90%; 28 | --ring: 240 10% 3.9%; 29 | --chart-1: 12 76% 61%; 30 | --chart-2: 173 58% 39%; 31 | --chart-3: 197 37% 24%; 32 | --chart-4: 43 74% 66%; 33 | --chart-5: 27 87% 67%; 34 | --radius: 0.5rem; 35 | --sidebar-background: 0 0% 98%; 36 | --sidebar-foreground: 240 5.3% 26.1%; 37 | --sidebar-primary: 240 5.9% 10%; 38 | --sidebar-primary-foreground: 0 0% 98%; 39 | --sidebar-accent: 240 4.8% 95.9%; 40 | --sidebar-accent-foreground: 240 5.9% 10%; 41 | --sidebar-border: 220 13% 91%; 42 | --sidebar-ring: 217.2 91.2% 59.8%; 43 | 44 | --expo-in: linear( 45 | 0 0%, 0.0085 31.26%, 0.0167 40.94%, 46 | 0.0289 48.86%, 0.0471 55.92%, 47 | 0.0717 61.99%, 0.1038 67.32%, 48 | 0.1443 72.07%, 0.1989 76.7%, 49 | 0.2659 80.89%, 0.3465 84.71%, 50 | 0.4419 88.22%, 0.554 91.48%, 51 | 0.6835 94.51%, 0.8316 97.34%, 1 100% 52 | ); 53 | --expo-out: linear( 54 | 0 0%, 0.1684 2.66%, 0.3165 5.49%, 55 | 0.446 8.52%, 0.5581 11.78%, 56 | 0.6535 15.29%, 0.7341 19.11%, 57 | 0.8011 23.3%, 0.8557 27.93%, 58 | 0.8962 32.68%, 0.9283 38.01%, 59 | 0.9529 44.08%, 0.9711 51.14%, 60 | 0.9833 59.06%, 0.9915 68.74%, 1 100% 61 | ); 62 | } 63 | .dark { 64 | --background: 240 10% 3.9%; 65 | --foreground: 0 0% 98%; 66 | --card: 240 10% 3.9%; 67 | --card-foreground: 0 0% 98%; 68 | --popover: 240 10% 3.9%; 69 | --popover-foreground: 0 0% 98%; 70 | --primary: 0 0% 98%; 71 | --primary-foreground: 240 5.9% 10%; 72 | --secondary: 240 3.7% 15.9%; 73 | --secondary-foreground: 0 0% 98%; 74 | --muted: 240 3.7% 15.9%; 75 | --muted-foreground: 240 5% 64.9%; 76 | --accent: 240 3.7% 15.9%; 77 | --accent-foreground: 0 0% 98%; 78 | --destructive: 0 62.8% 30.6%; 79 | --destructive-foreground: 0 0% 98%; 80 | --border: 240 3.7% 15.9%; 81 | --input: 240 3.7% 15.9%; 82 | --ring: 240 4.9% 83.9%; 83 | --chart-1: 220 70% 50%; 84 | --chart-2: 160 60% 45%; 85 | --chart-3: 30 80% 55%; 86 | --chart-4: 280 65% 60%; 87 | --chart-5: 340 75% 55%; 88 | --sidebar-background: 240 5.9% 10%; 89 | --sidebar-foreground: 240 4.8% 95.9%; 90 | --sidebar-primary: 224.3 76.3% 48%; 91 | --sidebar-primary-foreground: 0 0% 100%; 92 | --sidebar-accent: 240 3.7% 15.9%; 93 | --sidebar-accent-foreground: 240 4.8% 95.9%; 94 | --sidebar-border: 240 3.7% 15.9%; 95 | --sidebar-ring: 217.2 91.2% 59.8%; 96 | } 97 | } 98 | 99 | @layer base { 100 | * { 101 | @apply border-border; 102 | } 103 | body { 104 | @apply bg-background text-foreground; 105 | } 106 | } 107 | 108 | ::-moz-selection { 109 | color: hsl(var(--background)); 110 | background: hsl(var(--foreground)); 111 | } 112 | 113 | ::selection { 114 | color: hsl(var(--background)); 115 | background: hsl(var(--foreground)); 116 | } 117 | 118 | /* tailwind styles */ 119 | .head-text-lg { 120 | @apply text-3xl md:text-5xl lg:text-6xl font-bold font-heading tracking-tight leading-[1.5]; 121 | } 122 | 123 | .head-text-md { 124 | @apply text-2xl md:text-4xl lg:text-5xl font-bold font-heading tracking-tight leading-[1.5]; 125 | } 126 | 127 | .head-text-sm { 128 | @apply text-lg md:text-xl lg:text-2xl font-bold font-heading tracking-tight leading-[1.5]; 129 | } 130 | 131 | /* view transition */ 132 | ::view-transition-group(root) { 133 | animation-duration: 0.7s; 134 | animation-timing-function: var(--expo-out); 135 | } 136 | 137 | ::view-transition-new(root) { 138 | animation-name: reveal-light; 139 | } 140 | 141 | ::view-transition-old(root), 142 | .dark::view-transition-old(root) { 143 | animation: none; 144 | z-index: -1; 145 | } 146 | .dark::view-transition-new(root) { 147 | animation-name: reveal-dark; 148 | } 149 | 150 | @keyframes reveal-dark { 151 | from { 152 | clip-path: polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%); 153 | } 154 | to { 155 | clip-path: polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%); 156 | } 157 | } 158 | 159 | @keyframes reveal-light { 160 | from { 161 | clip-path: polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%); 162 | } 163 | to { 164 | clip-path: polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%); 165 | } 166 | } 167 | 168 | /* code */ 169 | [data-rehype-pretty-code-figure] code { 170 | @apply grid min-w-full break-words border-none bg-transparent pl-3 text-sm text-black; 171 | counter-reset: line; 172 | box-decoration-break: clone; 173 | } 174 | [data-rehype-pretty-code-figure] .line { 175 | @apply px-4 py-1; 176 | } 177 | [data-rehype-pretty-code-figure] [data-line-numbers] > .line::before { 178 | counter-increment: line; 179 | content: counter(line); 180 | display: inline-block; 181 | width: 1rem; 182 | margin-right: 1rem; 183 | text-align: right; 184 | color: gray; 185 | } 186 | [data-rehype-pretty-code-figure] .line--highlighted { 187 | @apply bg-slate-300 bg-opacity-10; 188 | } 189 | [data-rehype-pretty-code-figure] .line-highlighted span { 190 | @apply relative; 191 | } 192 | [data-rehype-pretty-code-figure] .word--highlighted { 193 | @apply rounded-md bg-slate-300 bg-opacity-10 p-1; 194 | } 195 | [data-rehype-pretty-code-title] { 196 | @apply mt-4 py-2 px-4 text-sm font-medium; 197 | } 198 | [data-rehype-pretty-code-title] + pre { 199 | @apply mt-0; 200 | } 201 | -------------------------------------------------------------------------------- /src/components/async-select.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from "react"; 2 | import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; 3 | import { useDebounce } from "@/hooks/use-debounce"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | Command, 9 | CommandEmpty, 10 | CommandGroup, 11 | CommandInput, 12 | CommandItem, 13 | CommandList, 14 | } from "@/components/ui/command"; 15 | import { 16 | Popover, 17 | PopoverContent, 18 | PopoverTrigger, 19 | } from "@/components/ui/popover"; 20 | 21 | export interface Option { 22 | value: string; 23 | label: string; 24 | disabled?: boolean; 25 | description?: string; 26 | icon?: React.ReactNode; 27 | } 28 | 29 | export interface AsyncSelectProps { 30 | /** Async function to fetch options */ 31 | fetcher: (query?: string) => Promise ; 32 | /** Preload all data ahead of time */ 33 | preload?: boolean; 34 | /** Function to filter options */ 35 | filterFn?: (option: T, query: string) => boolean; 36 | /** Function to render each option */ 37 | renderOption: (option: T) => React.ReactNode; 38 | /** Function to get the value from an option */ 39 | getOptionValue: (option: T) => string; 40 | /** Function to get the display value for the selected option */ 41 | getDisplayValue: (option: T) => React.ReactNode; 42 | /** Custom not found message */ 43 | notFound?: React.ReactNode; 44 | /** Custom loading skeleton */ 45 | loadingSkeleton?: React.ReactNode; 46 | /** Currently selected value */ 47 | value: string; 48 | /** Callback when selection changes */ 49 | onChange: (value: string) => void; 50 | /** Label for the select field */ 51 | label: string; 52 | /** Placeholder text when no selection */ 53 | placeholder?: string; 54 | /** Disable the entire select */ 55 | disabled?: boolean; 56 | /** Custom width for the popover */ 57 | width?: string | number; 58 | /** Custom class names */ 59 | className?: string; 60 | /** Custom trigger button class names */ 61 | triggerClassName?: string; 62 | /** Custom no results message */ 63 | noResultsMessage?: string; 64 | /** Allow clearing the selection */ 65 | clearable?: boolean; 66 | } 67 | 68 | export function AsyncSelect ({ 69 | fetcher, 70 | preload, 71 | filterFn, 72 | renderOption, 73 | getOptionValue, 74 | getDisplayValue, 75 | notFound, 76 | loadingSkeleton, 77 | label, 78 | placeholder = "Select...", 79 | value, 80 | onChange, 81 | disabled = false, 82 | width = "200px", 83 | className, 84 | triggerClassName, 85 | noResultsMessage, 86 | clearable = true, 87 | }: AsyncSelectProps ) { 88 | const [mounted, setMounted] = useState(false); 89 | const [open, setOpen] = useState(false); 90 | const [options, setOptions] = useState ([]); 91 | const [loading, setLoading] = useState(false); 92 | const [error, setError] = useState (null); 93 | const [selectedValue, setSelectedValue] = useState(value); 94 | const [selectedOption, setSelectedOption] = useState (null); 95 | const [searchTerm, setSearchTerm] = useState(""); 96 | const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 300); 97 | const [originalOptions, setOriginalOptions] = useState ([]); 98 | 99 | useEffect(() => { 100 | setMounted(true); 101 | setSelectedValue(value); 102 | }, [value]); 103 | 104 | // Initialize selectedOption when options are loaded and value exists 105 | useEffect(() => { 106 | if (value && options.length > 0) { 107 | const option = options.find(opt => getOptionValue(opt) === value); 108 | if (option) { 109 | setSelectedOption(option); 110 | } 111 | } 112 | }, [value, options, getOptionValue]); 113 | 114 | // Effect for initial fetch 115 | useEffect(() => { 116 | const initializeOptions = async () => { 117 | try { 118 | setLoading(true); 119 | setError(null); 120 | // If we have a value, use it for the initial search 121 | const data = await fetcher(value); 122 | setOriginalOptions(data); 123 | setOptions(data); 124 | } catch (err) { 125 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 126 | } finally { 127 | setLoading(false); 128 | } 129 | }; 130 | 131 | if (!mounted) { 132 | initializeOptions(); 133 | } 134 | }, [mounted, fetcher, value]); 135 | 136 | useEffect(() => { 137 | const fetchOptions = async () => { 138 | try { 139 | setLoading(true); 140 | setError(null); 141 | const data = await fetcher(debouncedSearchTerm); 142 | setOriginalOptions(data); 143 | setOptions(data); 144 | } catch (err) { 145 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 146 | } finally { 147 | setLoading(false); 148 | } 149 | }; 150 | 151 | if (!mounted) { 152 | fetchOptions(); 153 | } else if (!preload) { 154 | fetchOptions(); 155 | } else if (preload) { 156 | if (debouncedSearchTerm) { 157 | setOptions(originalOptions.filter((option) => filterFn ? filterFn(option, debouncedSearchTerm) : true)); 158 | } else { 159 | setOptions(originalOptions); 160 | } 161 | } 162 | }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn]); 163 | 164 | const handleSelect = useCallback((currentValue: string) => { 165 | const newValue = clearable && currentValue === selectedValue ? "" : currentValue; 166 | setSelectedValue(newValue); 167 | setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null); 168 | onChange(newValue); 169 | setOpen(false); 170 | }, [selectedValue, onChange, clearable, options, getOptionValue]); 171 | 172 | return ( 173 | 174 | 244 | ); 245 | } 246 | 247 | function DefaultLoadingSkeleton() { 248 | return ( 249 |175 | 194 | 195 |196 | 243 |197 | 242 |198 |211 |{ 202 | setSearchTerm(value); 203 | }} 204 | /> 205 | {loading && options.length > 0 && ( 206 | 207 |209 | )} 210 |208 | 212 | {error && ( 213 | 241 |214 | {error} 215 |216 | )} 217 | {loading && options.length === 0 && ( 218 | loadingSkeleton ||219 | )} 220 | {!loading && !error && options.length === 0 && ( 221 | notFound || {noResultsMessage ?? `No ${label.toLowerCase()} found.`} 222 | )} 223 |224 | {options.map((option) => ( 225 | 240 |230 | {renderOption(option)} 231 | 238 | ))} 239 |237 | 250 | {[1, 2, 3].map((i) => ( 251 | 262 | ); 263 | } 264 | -------------------------------------------------------------------------------- /content/snippets/async-select.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Async Select 3 | description: Async Select component built with React & shadcn/ui 4 | code: | 5 | import { useState, useEffect, useCallback } from "react"; 6 | import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; 7 | import { useDebounce } from "@/hooks/use-debounce"; 8 | 9 | import { cn } from "@/lib/utils"; 10 | import { Button } from "@/components/ui/button"; 11 | import { 12 | Command, 13 | CommandEmpty, 14 | CommandGroup, 15 | CommandInput, 16 | CommandItem, 17 | CommandList, 18 | } from "@/components/ui/command"; 19 | import { 20 | Popover, 21 | PopoverContent, 22 | PopoverTrigger, 23 | } from "@/components/ui/popover"; 24 | 25 | export interface Option { 26 | value: string; 27 | label: string; 28 | disabled?: boolean; 29 | description?: string; 30 | icon?: React.ReactNode; 31 | } 32 | 33 | export interface AsyncSelectProps252 | 260 | ))} 261 |253 | 254 |259 |255 | 256 | 257 |258 |{ 34 | /** Async function to fetch options */ 35 | fetcher: (query?: string) => Promise ; 36 | /** Preload all data ahead of time */ 37 | preload?: boolean; 38 | /** Function to filter options */ 39 | filterFn?: (option: T, query: string) => boolean; 40 | /** Function to render each option */ 41 | renderOption: (option: T) => React.ReactNode; 42 | /** Function to get the value from an option */ 43 | getOptionValue: (option: T) => string; 44 | /** Function to get the display value for the selected option */ 45 | getDisplayValue: (option: T) => React.ReactNode; 46 | /** Custom not found message */ 47 | notFound?: React.ReactNode; 48 | /** Custom loading skeleton */ 49 | loadingSkeleton?: React.ReactNode; 50 | /** Currently selected value */ 51 | value: string; 52 | /** Callback when selection changes */ 53 | onChange: (value: string) => void; 54 | /** Label for the select field */ 55 | label: string; 56 | /** Placeholder text when no selection */ 57 | placeholder?: string; 58 | /** Disable the entire select */ 59 | disabled?: boolean; 60 | /** Custom width for the popover */ 61 | width?: string | number; 62 | /** Custom class names */ 63 | className?: string; 64 | /** Custom trigger button class names */ 65 | triggerClassName?: string; 66 | /** Custom no results message */ 67 | noResultsMessage?: string; 68 | /** Allow clearing the selection */ 69 | clearable?: boolean; 70 | } 71 | 72 | export function AsyncSelect ({ 73 | fetcher, 74 | preload, 75 | filterFn, 76 | renderOption, 77 | getOptionValue, 78 | getDisplayValue, 79 | notFound, 80 | loadingSkeleton, 81 | label, 82 | placeholder = "Select...", 83 | value, 84 | onChange, 85 | disabled = false, 86 | width = "200px", 87 | className, 88 | triggerClassName, 89 | noResultsMessage, 90 | clearable = true, 91 | }: AsyncSelectProps ) { 92 | const [mounted, setMounted] = useState(false); 93 | const [open, setOpen] = useState(false); 94 | const [options, setOptions] = useState ([]); 95 | const [loading, setLoading] = useState(false); 96 | const [error, setError] = useState (null); 97 | const [selectedValue, setSelectedValue] = useState(value); 98 | const [selectedOption, setSelectedOption] = useState (null); 99 | const [searchTerm, setSearchTerm] = useState(""); 100 | const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 300); 101 | const [originalOptions, setOriginalOptions] = useState ([]); 102 | 103 | useEffect(() => { 104 | setMounted(true); 105 | setSelectedValue(value); 106 | }, [value]); 107 | 108 | // Initialize selectedOption when options are loaded and value exists 109 | useEffect(() => { 110 | if (value && options.length > 0) { 111 | const option = options.find(opt => getOptionValue(opt) === value); 112 | if (option) { 113 | setSelectedOption(option); 114 | } 115 | } 116 | }, [value, options, getOptionValue]); 117 | 118 | // Effect for initial fetch 119 | useEffect(() => { 120 | const initializeOptions = async () => { 121 | try { 122 | setLoading(true); 123 | setError(null); 124 | // If we have a value, use it for the initial search 125 | const data = await fetcher(value); 126 | setOriginalOptions(data); 127 | setOptions(data); 128 | } catch (err) { 129 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 130 | } finally { 131 | setLoading(false); 132 | } 133 | }; 134 | 135 | if (!mounted) { 136 | initializeOptions(); 137 | } 138 | }, [mounted, fetcher, value]); 139 | 140 | useEffect(() => { 141 | const fetchOptions = async () => { 142 | try { 143 | setLoading(true); 144 | setError(null); 145 | const data = await fetcher(debouncedSearchTerm); 146 | setOriginalOptions(data); 147 | setOptions(data); 148 | } catch (err) { 149 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 150 | } finally { 151 | setLoading(false); 152 | } 153 | }; 154 | 155 | if (!mounted) { 156 | fetchOptions(); 157 | } else if (!preload) { 158 | fetchOptions(); 159 | } else if (preload) { 160 | if (debouncedSearchTerm) { 161 | setOptions(originalOptions.filter((option) => filterFn ? filterFn(option, debouncedSearchTerm) : true)); 162 | } else { 163 | setOptions(originalOptions); 164 | } 165 | } 166 | }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn]); 167 | 168 | const handleSelect = useCallback((currentValue: string) => { 169 | const newValue = clearable && currentValue === selectedValue ? "" : currentValue; 170 | setSelectedValue(newValue); 171 | setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null); 172 | onChange(newValue); 173 | setOpen(false); 174 | }, [selectedValue, onChange, clearable, options, getOptionValue]); 175 | 176 | return ( 177 | 178 | 248 | ); 249 | } 250 | 251 | function DefaultLoadingSkeleton() { 252 | return ( 253 |179 | 198 | 199 |200 | 247 |201 | 246 |202 |215 |{ 206 | setSearchTerm(value); 207 | }} 208 | /> 209 | {loading && options.length > 0 && ( 210 | 211 |213 | )} 214 |212 | 216 | {error && ( 217 | 245 |218 | {error} 219 |220 | )} 221 | {loading && options.length === 0 && ( 222 | loadingSkeleton ||223 | )} 224 | {!loading && !error && options.length === 0 && ( 225 | notFound || {noResultsMessage ?? `No ${label.toLowerCase()} found.`} 226 | )} 227 |228 | {options.map((option) => ( 229 | 244 |234 | {renderOption(option)} 235 | 242 | ))} 243 |241 | 254 | {[1, 2, 3].map((i) => ( 255 | 266 | ); 267 | } 268 | --- 269 | 270 | ```tsx 271 | import { useState, useEffect, useCallback } from "react"; 272 | import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; 273 | import { useDebounce } from "@/hooks/use-debounce"; 274 | 275 | import { cn } from "@/lib/utils"; 276 | import { Button } from "@/components/ui/button"; 277 | import { 278 | Command, 279 | CommandEmpty, 280 | CommandGroup, 281 | CommandInput, 282 | CommandItem, 283 | CommandList, 284 | } from "@/components/ui/command"; 285 | import { 286 | Popover, 287 | PopoverContent, 288 | PopoverTrigger, 289 | } from "@/components/ui/popover"; 290 | 291 | export interface Option { 292 | value: string; 293 | label: string; 294 | disabled?: boolean; 295 | description?: string; 296 | icon?: React.ReactNode; 297 | } 298 | 299 | export interface AsyncSelectProps256 | 264 | ))} 265 |257 | 258 |263 |259 | 260 | 261 |262 |{ 300 | /** Async function to fetch options */ 301 | fetcher: (query?: string) => Promise ; 302 | /** Preload all data ahead of time */ 303 | preload?: boolean; 304 | /** Function to filter options */ 305 | filterFn?: (option: T, query: string) => boolean; 306 | /** Function to render each option */ 307 | renderOption: (option: T) => React.ReactNode; 308 | /** Function to get the value from an option */ 309 | getOptionValue: (option: T) => string; 310 | /** Function to get the display value for the selected option */ 311 | getDisplayValue: (option: T) => React.ReactNode; 312 | /** Custom not found message */ 313 | notFound?: React.ReactNode; 314 | /** Custom loading skeleton */ 315 | loadingSkeleton?: React.ReactNode; 316 | /** Currently selected value */ 317 | value: string; 318 | /** Callback when selection changes */ 319 | onChange: (value: string) => void; 320 | /** Label for the select field */ 321 | label: string; 322 | /** Placeholder text when no selection */ 323 | placeholder?: string; 324 | /** Disable the entire select */ 325 | disabled?: boolean; 326 | /** Custom width for the popover */ 327 | width?: string | number; 328 | /** Custom class names */ 329 | className?: string; 330 | /** Custom trigger button class names */ 331 | triggerClassName?: string; 332 | /** Custom no results message */ 333 | noResultsMessage?: string; 334 | /** Allow clearing the selection */ 335 | clearable?: boolean; 336 | } 337 | 338 | export function AsyncSelect ({ 339 | fetcher, 340 | preload, 341 | filterFn, 342 | renderOption, 343 | getOptionValue, 344 | getDisplayValue, 345 | notFound, 346 | loadingSkeleton, 347 | label, 348 | placeholder = "Select...", 349 | value, 350 | onChange, 351 | disabled = false, 352 | width = "200px", 353 | className, 354 | triggerClassName, 355 | noResultsMessage, 356 | clearable = true, 357 | }: AsyncSelectProps ) { 358 | const [mounted, setMounted] = useState(false); 359 | const [open, setOpen] = useState(false); 360 | const [options, setOptions] = useState ([]); 361 | const [loading, setLoading] = useState(false); 362 | const [error, setError] = useState (null); 363 | const [selectedValue, setSelectedValue] = useState(value); 364 | const [selectedOption, setSelectedOption] = useState (null); 365 | const [searchTerm, setSearchTerm] = useState(""); 366 | const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 300); 367 | const [originalOptions, setOriginalOptions] = useState ([]); 368 | 369 | useEffect(() => { 370 | setMounted(true); 371 | setSelectedValue(value); 372 | }, [value]); 373 | 374 | // Initialize selectedOption when options are loaded and value exists 375 | useEffect(() => { 376 | if (value && options.length > 0) { 377 | const option = options.find(opt => getOptionValue(opt) === value); 378 | if (option) { 379 | setSelectedOption(option); 380 | } 381 | } 382 | }, [value, options, getOptionValue]); 383 | 384 | // Effect for initial fetch 385 | useEffect(() => { 386 | const initializeOptions = async () => { 387 | try { 388 | setLoading(true); 389 | setError(null); 390 | // If we have a value, use it for the initial search 391 | const data = await fetcher(value); 392 | setOriginalOptions(data); 393 | setOptions(data); 394 | } catch (err) { 395 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 396 | } finally { 397 | setLoading(false); 398 | } 399 | }; 400 | 401 | if (!mounted) { 402 | initializeOptions(); 403 | } 404 | }, [mounted, fetcher, value]); 405 | 406 | useEffect(() => { 407 | const fetchOptions = async () => { 408 | try { 409 | setLoading(true); 410 | setError(null); 411 | const data = await fetcher(debouncedSearchTerm); 412 | setOriginalOptions(data); 413 | setOptions(data); 414 | } catch (err) { 415 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 416 | } finally { 417 | setLoading(false); 418 | } 419 | }; 420 | 421 | if (!mounted) { 422 | fetchOptions(); 423 | } else if (!preload) { 424 | fetchOptions(); 425 | } else if (preload) { 426 | if (debouncedSearchTerm) { 427 | setOptions(originalOptions.filter((option) => filterFn ? filterFn(option, debouncedSearchTerm) : true)); 428 | } else { 429 | setOptions(originalOptions); 430 | } 431 | } 432 | }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn]); 433 | 434 | const handleSelect = useCallback((currentValue: string) => { 435 | const newValue = clearable && currentValue === selectedValue ? "" : currentValue; 436 | setSelectedValue(newValue); 437 | setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null); 438 | onChange(newValue); 439 | setOpen(false); 440 | }, [selectedValue, onChange, clearable, options, getOptionValue]); 441 | 442 | return ( 443 | 444 | 514 | ); 515 | } 516 | 517 | function DefaultLoadingSkeleton() { 518 | return ( 519 |445 | 464 | 465 |466 | 513 |467 | 512 |468 |481 |{ 472 | setSearchTerm(value); 473 | }} 474 | /> 475 | {loading && options.length > 0 && ( 476 | 477 |479 | )} 480 |478 | 482 | {error && ( 483 | 511 |484 | {error} 485 |486 | )} 487 | {loading && options.length === 0 && ( 488 | loadingSkeleton ||489 | )} 490 | {!loading && !error && options.length === 0 && ( 491 | notFound || {noResultsMessage ?? `No ${label.toLowerCase()} found.`} 492 | )} 493 |494 | {options.map((option) => ( 495 | 510 |500 | {renderOption(option)} 501 | 508 | ))} 509 |507 | 520 | {[1, 2, 3].map((i) => ( 521 | 532 | ); 533 | } 534 | ``` -------------------------------------------------------------------------------- /content/snippets/async-select-preload.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Async Select Preload 3 | description: Async Select component built with React & shadcn/ui 4 | code: | 5 | import { useState, useEffect, useCallback } from "react"; 6 | import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; 7 | import { useDebounce } from "@/hooks/use-debounce"; 8 | 9 | import { cn } from "@/lib/utils"; 10 | import { Button } from "@/components/ui/button"; 11 | import { 12 | Command, 13 | CommandEmpty, 14 | CommandGroup, 15 | CommandInput, 16 | CommandItem, 17 | CommandList, 18 | } from "@/components/ui/command"; 19 | import { 20 | Popover, 21 | PopoverContent, 22 | PopoverTrigger, 23 | } from "@/components/ui/popover"; 24 | 25 | export interface Option { 26 | value: string; 27 | label: string; 28 | disabled?: boolean; 29 | description?: string; 30 | icon?: React.ReactNode; 31 | } 32 | 33 | export interface AsyncSelectProps522 | 530 | ))} 531 |523 | 524 |529 |525 | 526 | 527 |528 |{ 34 | /** Async function to fetch options */ 35 | fetcher: (query?: string) => Promise ; 36 | /** Preload all data ahead of time */ 37 | preload?: boolean; 38 | /** Function to filter options */ 39 | filterFn?: (option: T, query: string) => boolean; 40 | /** Function to render each option */ 41 | renderOption: (option: T) => React.ReactNode; 42 | /** Function to get the value from an option */ 43 | getOptionValue: (option: T) => string; 44 | /** Function to get the display value for the selected option */ 45 | getDisplayValue: (option: T) => React.ReactNode; 46 | /** Custom not found message */ 47 | notFound?: React.ReactNode; 48 | /** Custom loading skeleton */ 49 | loadingSkeleton?: React.ReactNode; 50 | /** Currently selected value */ 51 | value: string; 52 | /** Callback when selection changes */ 53 | onChange: (value: string) => void; 54 | /** Label for the select field */ 55 | label: string; 56 | /** Placeholder text when no selection */ 57 | placeholder?: string; 58 | /** Disable the entire select */ 59 | disabled?: boolean; 60 | /** Custom width for the popover */ 61 | width?: string | number; 62 | /** Custom class names */ 63 | className?: string; 64 | /** Custom trigger button class names */ 65 | triggerClassName?: string; 66 | /** Custom no results message */ 67 | noResultsMessage?: string; 68 | /** Allow clearing the selection */ 69 | clearable?: boolean; 70 | } 71 | 72 | export function AsyncSelect ({ 73 | fetcher, 74 | preload, 75 | filterFn, 76 | renderOption, 77 | getOptionValue, 78 | getDisplayValue, 79 | notFound, 80 | loadingSkeleton, 81 | label, 82 | placeholder = "Select...", 83 | value, 84 | onChange, 85 | disabled = false, 86 | width = "200px", 87 | className, 88 | triggerClassName, 89 | noResultsMessage, 90 | clearable = true, 91 | }: AsyncSelectProps ) { 92 | const [mounted, setMounted] = useState(false); 93 | const [open, setOpen] = useState(false); 94 | const [options, setOptions] = useState ([]); 95 | const [loading, setLoading] = useState(false); 96 | const [error, setError] = useState (null); 97 | const [selectedValue, setSelectedValue] = useState(value); 98 | const [selectedOption, setSelectedOption] = useState (null); 99 | const [searchTerm, setSearchTerm] = useState(""); 100 | const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 300); 101 | const [originalOptions, setOriginalOptions] = useState ([]); 102 | 103 | useEffect(() => { 104 | setMounted(true); 105 | setSelectedValue(value); 106 | }, [value]); 107 | 108 | // Initialize selectedOption when options are loaded and value exists 109 | useEffect(() => { 110 | if (value && options.length > 0) { 111 | const option = options.find(opt => getOptionValue(opt) === value); 112 | if (option) { 113 | setSelectedOption(option); 114 | } 115 | } 116 | }, [value, options, getOptionValue]); 117 | 118 | // Effect for initial fetch 119 | useEffect(() => { 120 | const initializeOptions = async () => { 121 | try { 122 | setLoading(true); 123 | setError(null); 124 | // If we have a value, use it for the initial search 125 | const data = await fetcher(value); 126 | setOriginalOptions(data); 127 | setOptions(data); 128 | } catch (err) { 129 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 130 | } finally { 131 | setLoading(false); 132 | } 133 | }; 134 | 135 | if (!mounted) { 136 | initializeOptions(); 137 | } 138 | }, [mounted, fetcher, value]); 139 | 140 | useEffect(() => { 141 | const fetchOptions = async () => { 142 | try { 143 | setLoading(true); 144 | setError(null); 145 | const data = await fetcher(debouncedSearchTerm); 146 | setOriginalOptions(data); 147 | setOptions(data); 148 | } catch (err) { 149 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 150 | } finally { 151 | setLoading(false); 152 | } 153 | }; 154 | 155 | if (!mounted) { 156 | fetchOptions(); 157 | } else if (!preload) { 158 | fetchOptions(); 159 | } else if (preload) { 160 | if (debouncedSearchTerm) { 161 | setOptions(originalOptions.filter((option) => filterFn ? filterFn(option, debouncedSearchTerm) : true)); 162 | } else { 163 | setOptions(originalOptions); 164 | } 165 | } 166 | }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn]); 167 | 168 | const handleSelect = useCallback((currentValue: string) => { 169 | const newValue = clearable && currentValue === selectedValue ? "" : currentValue; 170 | setSelectedValue(newValue); 171 | setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null); 172 | onChange(newValue); 173 | setOpen(false); 174 | }, [selectedValue, onChange, clearable, options, getOptionValue]); 175 | 176 | return ( 177 | 178 | 248 | ); 249 | } 250 | 251 | function DefaultLoadingSkeleton() { 252 | return ( 253 |179 | 198 | 199 |200 | 247 |201 | 246 |202 |215 |{ 206 | setSearchTerm(value); 207 | }} 208 | /> 209 | {loading && options.length > 0 && ( 210 | 211 |213 | )} 214 |212 | 216 | {error && ( 217 | 245 |218 | {error} 219 |220 | )} 221 | {loading && options.length === 0 && ( 222 | loadingSkeleton ||223 | )} 224 | {!loading && !error && options.length === 0 && ( 225 | notFound || {noResultsMessage ?? `No ${label.toLowerCase()} found.`} 226 | )} 227 |228 | {options.map((option) => ( 229 | 244 |234 | {renderOption(option)} 235 | 242 | ))} 243 |241 | 254 | {[1, 2, 3].map((i) => ( 255 | 266 | ); 267 | } 268 | --- 269 | 270 | ```tsx 271 | import { useState, useEffect, useCallback } from "react"; 272 | import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; 273 | import { useDebounce } from "@/hooks/use-debounce"; 274 | 275 | import { cn } from "@/lib/utils"; 276 | import { Button } from "@/components/ui/button"; 277 | import { 278 | Command, 279 | CommandEmpty, 280 | CommandGroup, 281 | CommandInput, 282 | CommandItem, 283 | CommandList, 284 | } from "@/components/ui/command"; 285 | import { 286 | Popover, 287 | PopoverContent, 288 | PopoverTrigger, 289 | } from "@/components/ui/popover"; 290 | 291 | export interface Option { 292 | value: string; 293 | label: string; 294 | disabled?: boolean; 295 | description?: string; 296 | icon?: React.ReactNode; 297 | } 298 | 299 | export interface AsyncSelectProps256 | 264 | ))} 265 |257 | 258 |263 |259 | 260 | 261 |262 |{ 300 | /** Async function to fetch options */ 301 | fetcher: (query?: string) => Promise ; 302 | /** Preload all data ahead of time */ 303 | preload?: boolean; 304 | /** Function to filter options */ 305 | filterFn?: (option: T, query: string) => boolean; 306 | /** Function to render each option */ 307 | renderOption: (option: T) => React.ReactNode; 308 | /** Function to get the value from an option */ 309 | getOptionValue: (option: T) => string; 310 | /** Function to get the display value for the selected option */ 311 | getDisplayValue: (option: T) => React.ReactNode; 312 | /** Custom not found message */ 313 | notFound?: React.ReactNode; 314 | /** Custom loading skeleton */ 315 | loadingSkeleton?: React.ReactNode; 316 | /** Currently selected value */ 317 | value: string; 318 | /** Callback when selection changes */ 319 | onChange: (value: string) => void; 320 | /** Label for the select field */ 321 | label: string; 322 | /** Placeholder text when no selection */ 323 | placeholder?: string; 324 | /** Disable the entire select */ 325 | disabled?: boolean; 326 | /** Custom width for the popover */ 327 | width?: string | number; 328 | /** Custom class names */ 329 | className?: string; 330 | /** Custom trigger button class names */ 331 | triggerClassName?: string; 332 | /** Custom no results message */ 333 | noResultsMessage?: string; 334 | /** Allow clearing the selection */ 335 | clearable?: boolean; 336 | } 337 | 338 | export function AsyncSelect ({ 339 | fetcher, 340 | preload, 341 | filterFn, 342 | renderOption, 343 | getOptionValue, 344 | getDisplayValue, 345 | notFound, 346 | loadingSkeleton, 347 | label, 348 | placeholder = "Select...", 349 | value, 350 | onChange, 351 | disabled = false, 352 | width = "200px", 353 | className, 354 | triggerClassName, 355 | noResultsMessage, 356 | clearable = true, 357 | }: AsyncSelectProps ) { 358 | const [mounted, setMounted] = useState(false); 359 | const [open, setOpen] = useState(false); 360 | const [options, setOptions] = useState ([]); 361 | const [loading, setLoading] = useState(false); 362 | const [error, setError] = useState (null); 363 | const [selectedValue, setSelectedValue] = useState(value); 364 | const [selectedOption, setSelectedOption] = useState (null); 365 | const [searchTerm, setSearchTerm] = useState(""); 366 | const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 300); 367 | const [originalOptions, setOriginalOptions] = useState ([]); 368 | 369 | useEffect(() => { 370 | setMounted(true); 371 | setSelectedValue(value); 372 | }, [value]); 373 | 374 | // Initialize selectedOption when options are loaded and value exists 375 | useEffect(() => { 376 | if (value && options.length > 0) { 377 | const option = options.find(opt => getOptionValue(opt) === value); 378 | if (option) { 379 | setSelectedOption(option); 380 | } 381 | } 382 | }, [value, options, getOptionValue]); 383 | 384 | // Effect for initial fetch 385 | useEffect(() => { 386 | const initializeOptions = async () => { 387 | try { 388 | setLoading(true); 389 | setError(null); 390 | // If we have a value, use it for the initial search 391 | const data = await fetcher(value); 392 | setOriginalOptions(data); 393 | setOptions(data); 394 | } catch (err) { 395 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 396 | } finally { 397 | setLoading(false); 398 | } 399 | }; 400 | 401 | if (!mounted) { 402 | initializeOptions(); 403 | } 404 | }, [mounted, fetcher, value]); 405 | 406 | useEffect(() => { 407 | const fetchOptions = async () => { 408 | try { 409 | setLoading(true); 410 | setError(null); 411 | const data = await fetcher(debouncedSearchTerm); 412 | setOriginalOptions(data); 413 | setOptions(data); 414 | } catch (err) { 415 | setError(err instanceof Error ? err.message : 'Failed to fetch options'); 416 | } finally { 417 | setLoading(false); 418 | } 419 | }; 420 | 421 | if (!mounted) { 422 | fetchOptions(); 423 | } else if (!preload) { 424 | fetchOptions(); 425 | } else if (preload) { 426 | if (debouncedSearchTerm) { 427 | setOptions(originalOptions.filter((option) => filterFn ? filterFn(option, debouncedSearchTerm) : true)); 428 | } else { 429 | setOptions(originalOptions); 430 | } 431 | } 432 | }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn]); 433 | 434 | const handleSelect = useCallback((currentValue: string) => { 435 | const newValue = clearable && currentValue === selectedValue ? "" : currentValue; 436 | setSelectedValue(newValue); 437 | setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null); 438 | onChange(newValue); 439 | setOpen(false); 440 | }, [selectedValue, onChange, clearable, options, getOptionValue]); 441 | 442 | return ( 443 | 444 | 514 | ); 515 | } 516 | 517 | function DefaultLoadingSkeleton() { 518 | return ( 519 |445 | 464 | 465 |466 | 513 |467 | 512 |468 |481 |{ 472 | setSearchTerm(value); 473 | }} 474 | /> 475 | {loading && options.length > 0 && ( 476 | 477 |479 | )} 480 |478 | 482 | {error && ( 483 | 511 |484 | {error} 485 |486 | )} 487 | {loading && options.length === 0 && ( 488 | loadingSkeleton ||489 | )} 490 | {!loading && !error && options.length === 0 && ( 491 | notFound || {noResultsMessage ?? `No ${label.toLowerCase()} found.`} 492 | )} 493 |494 | {options.map((option) => ( 495 | 510 |500 | {renderOption(option)} 501 | 508 | ))} 509 |507 | 520 | {[1, 2, 3].map((i) => ( 521 | 532 | ); 533 | } 534 | ``` --------------------------------------------------------------------------------522 | 530 | ))} 531 |523 | 524 |529 |525 | 526 | 527 |528 |